W-03: Unify split transactions — _create_db_session() now uses flush()
instead of commit(), callers own the final commit.
W-04: Time-bound dedup key fetch to 7-day purge window.
S-01: Type admin dashboard response with RecentLoginItem/RecentAuditItem.
S-02: Convert starred events index to partial index WHERE is_starred = true.
S-03: EventTemplate.created_at default changed to func.now() for consistency.
S-04: Add single-worker scaling note to weather cache.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
160 lines
5.6 KiB
Python
160 lines
5.6 KiB
Python
from fastapi import APIRouter, Depends, HTTPException, Query
|
|
from pydantic import BaseModel
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
from sqlalchemy import select
|
|
from datetime import datetime, timedelta
|
|
from collections import OrderedDict
|
|
import asyncio
|
|
import urllib.request
|
|
import urllib.parse
|
|
import urllib.error
|
|
import json
|
|
|
|
from app.database import get_db
|
|
from app.models.settings import Settings
|
|
from app.models.user import User
|
|
from app.config import settings as app_settings
|
|
from app.routers.auth import get_current_user, get_current_settings
|
|
|
|
router = APIRouter()
|
|
|
|
# SEC-15: Bounded LRU cache keyed by (user_id, location) — max 100 entries.
|
|
# OrderedDict preserves insertion order; move_to_end on hit, popitem(last=False)
|
|
# to evict the oldest when capacity is exceeded.
|
|
# NOTE: This cache is process-local. With multiple workers each process would
|
|
# maintain its own copy, wasting API quota. Currently safe — single Uvicorn worker.
|
|
_CACHE_MAX = 100
|
|
_cache: OrderedDict = OrderedDict()
|
|
|
|
|
|
def _cache_get(key: tuple) -> dict | None:
|
|
"""Return cached entry if it exists and hasn't expired."""
|
|
entry = _cache.get(key)
|
|
if entry and datetime.now() < entry["expires_at"]:
|
|
_cache.move_to_end(key) # LRU: promote to most-recently-used
|
|
return entry["data"]
|
|
if entry:
|
|
del _cache[key] # expired — evict immediately
|
|
return None
|
|
|
|
|
|
def _cache_set(key: tuple, data: dict) -> None:
|
|
"""Store an entry; evict the oldest if over capacity."""
|
|
if key in _cache:
|
|
_cache.move_to_end(key)
|
|
_cache[key] = {"data": data, "expires_at": datetime.now() + timedelta(hours=1)}
|
|
while len(_cache) > _CACHE_MAX:
|
|
_cache.popitem(last=False) # evict LRU (oldest)
|
|
|
|
|
|
class GeoSearchResult(BaseModel):
|
|
name: str
|
|
state: str
|
|
country: str
|
|
lat: float
|
|
lon: float
|
|
|
|
|
|
def _fetch_json(url: str) -> dict:
|
|
with urllib.request.urlopen(url, timeout=10) as resp:
|
|
return json.loads(resp.read().decode())
|
|
|
|
|
|
@router.get("/search", response_model=list[GeoSearchResult])
|
|
async def search_locations(
|
|
q: str = Query(..., min_length=1, max_length=100),
|
|
current_user: User = Depends(get_current_user)
|
|
):
|
|
api_key = app_settings.OPENWEATHERMAP_API_KEY
|
|
if not api_key:
|
|
raise HTTPException(status_code=500, detail="Weather API key not configured")
|
|
|
|
try:
|
|
loop = asyncio.get_running_loop()
|
|
url = f"https://api.openweathermap.org/geo/1.0/direct?q={urllib.parse.quote(q)}&limit=5&appid={api_key}"
|
|
results = await loop.run_in_executor(None, _fetch_json, url)
|
|
|
|
return [
|
|
{
|
|
"name": r.get("name", ""),
|
|
"state": r.get("state", ""),
|
|
"country": r.get("country", ""),
|
|
"lat": r.get("lat"),
|
|
"lon": r.get("lon"),
|
|
}
|
|
for r in results
|
|
]
|
|
except urllib.error.URLError:
|
|
raise HTTPException(status_code=502, detail="Geocoding service unavailable")
|
|
except (KeyError, json.JSONDecodeError):
|
|
raise HTTPException(status_code=502, detail="Invalid geocoding data")
|
|
|
|
|
|
@router.get("/")
|
|
async def get_weather(
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
current_settings: Settings = Depends(get_current_settings),
|
|
):
|
|
city = current_settings.weather_city
|
|
lat = current_settings.weather_lat
|
|
lon = current_settings.weather_lon
|
|
|
|
if not city and (lat is None or lon is None):
|
|
raise HTTPException(status_code=400, detail="No weather location configured")
|
|
|
|
# Cache key includes user_id so each user gets isolated cache entries
|
|
use_coords = lat is not None and lon is not None
|
|
location_key = f"{lat},{lon}" if use_coords else city
|
|
cache_key = (current_user.id, location_key)
|
|
|
|
cached = _cache_get(cache_key)
|
|
if cached is not None:
|
|
return cached
|
|
|
|
api_key = app_settings.OPENWEATHERMAP_API_KEY
|
|
if not api_key:
|
|
raise HTTPException(status_code=500, detail="Weather API key not configured")
|
|
|
|
try:
|
|
loop = asyncio.get_running_loop()
|
|
|
|
# Build query params based on coordinates or city name
|
|
if use_coords:
|
|
location_params = f"lat={lat}&lon={lon}"
|
|
else:
|
|
location_params = f"q={urllib.parse.quote(city)}"
|
|
|
|
current_url = f"https://api.openweathermap.org/data/2.5/weather?{location_params}&units=metric&appid={api_key}"
|
|
forecast_url = f"https://api.openweathermap.org/data/2.5/forecast?{location_params}&units=metric&cnt=8&appid={api_key}"
|
|
|
|
current_data, forecast_data = await asyncio.gather(
|
|
loop.run_in_executor(None, _fetch_json, current_url),
|
|
loop.run_in_executor(None, _fetch_json, forecast_url),
|
|
)
|
|
|
|
rain_chance = 0
|
|
for item in forecast_data.get("list", []):
|
|
pop = item.get("pop", 0)
|
|
if pop > rain_chance:
|
|
rain_chance = pop
|
|
|
|
weather_result = {
|
|
"temp": round(current_data["main"]["temp"]),
|
|
"temp_min": round(current_data["main"]["temp_min"]),
|
|
"temp_max": round(current_data["main"]["temp_max"]),
|
|
"description": current_data["weather"][0]["description"],
|
|
"rain_chance": round(rain_chance * 100),
|
|
"sunrise": current_data["sys"]["sunrise"],
|
|
"sunset": current_data["sys"]["sunset"],
|
|
"city": current_data["name"],
|
|
}
|
|
|
|
_cache_set(cache_key, weather_result)
|
|
return weather_result
|
|
|
|
except urllib.error.URLError:
|
|
raise HTTPException(status_code=502, detail="Weather service unavailable")
|
|
except (KeyError, json.JSONDecodeError):
|
|
raise HTTPException(status_code=502, detail="Invalid weather data")
|