Address code review findings for weather coordinates feature

- CRIT-1: Add lat/lon validation ([-90,90] and [-180,180]) in Pydantic schema
- WARN-1: Replace deprecated get_event_loop() with get_running_loop()
- SUG-1: Add GeoSearchResult response model to /search endpoint
- SUG-2: Dashboard weather query enables on coordinates too, not just city
- SUG-3: Clean up debounce timer on component unmount
- SUG-4: Fix geocoding URL from HTTP to HTTPS
- SUG-5: Add fallback display when weather_city is null but coords exist

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Kyle 2026-02-21 12:17:41 +08:00
parent 1545da48e5
commit 4b5649758a
5 changed files with 317 additions and 6 deletions

View File

@ -0,0 +1,281 @@
# Weather Coordinate-Based Lookup - Code Review
**Feature:** Replace plain-text weather city input with geocoding search + coordinate-based weather fetch
**Reviewer:** Senior Code Reviewer
**Date:** 2026-02-21
**Files reviewed:** 6
---
## Summary
This feature replaces the free-text weather city input with a search-and-select location picker backed by OpenWeatherMap's geocoding API. Settings now store `weather_lat` and `weather_lon` alongside `weather_city`, and the weather endpoint prefers coordinates over city name for more accurate results. The implementation is solid overall -- the backend correctly uses `run_in_executor` for blocking I/O (addressing CRIT-2 from the Phase 2 review), the cache now keys on coordinates, and the frontend location picker is well-crafted with debounced search, click-outside dismissal, and proper cache invalidation. However, there are a few issues around input validation, deprecated API usage, and a previous review finding (WARN-1) that was only partially addressed.
---
## Critical Issues
### [CRIT-1] No validation on latitude/longitude values in schema
**File:** `backend/app/schemas/settings.py` (lines 30-31)
```python
weather_lat: float | None = None
weather_lon: float | None = None
```
Latitude must be in [-90, 90] and longitude in [-180, 180]. The schema accepts any float, meaning a malformed or malicious request could send `weather_lat: 99999` which would be persisted to the database and forwarded to the OpenWeatherMap API. While the API would reject it, the invalid data would be stored permanently.
**Fix:** Add field validators:
```python
from pydantic import field_validator
class SettingsUpdate(BaseModel):
# ... existing fields ...
weather_lat: float | None = None
weather_lon: float | None = None
@field_validator('weather_lat')
@classmethod
def validate_lat(cls, v: float | None) -> float | None:
if v is not None and (v < -90 or v > 90):
raise ValueError('Latitude must be between -90 and 90')
return v
@field_validator('weather_lon')
@classmethod
def validate_lon(cls, v: float | None) -> float | None:
if v is not None and (v < -180 or v > 180):
raise ValueError('Longitude must be between -180 and 180')
return v
```
---
## Warnings
### [WARN-1] `asyncio.get_event_loop()` is deprecated in Python 3.12
**File:** `backend/app/routers/weather.py` (lines 36, 85)
```python
loop = asyncio.get_event_loop()
results = await loop.run_in_executor(None, _fetch_json, url)
```
`asyncio.get_event_loop()` emits a deprecation warning in Python 3.10+ when called outside of an async context, and its behavior is unreliable. Inside an async function the running loop is available, but the idiomatic replacement is `asyncio.get_running_loop()`:
```python
loop = asyncio.get_running_loop()
```
This is a simple 1-word change on two lines and future-proofs the code.
### [WARN-2] Cache invalidation on location change is implicit, not explicit
**File:** `backend/app/routers/weather.py` (lines 72-78)
```python
cache_key = f"{lat},{lon}" if use_coords else city
if _cache.get("expires_at") and now < _cache["expires_at"] and _cache.get("cache_key") == cache_key:
return _cache["data"]
```
The Phase 2 review flagged WARN-1 (cache not invalidated on city change). This implementation improves it by keying the cache on coordinates/city, so a location change will naturally miss the cache. This is good. However, the old cached data still occupies memory until the next fetch overwrites it -- a minor concern for a single-user app but worth documenting.
Additionally, using a plain `dict` with string keys is fragile. If `lat` or `lon` is `None` due to a race or unexpected state, the cache key becomes `"None,None"` which could match incorrectly.
**Recommendation:** Add an explicit check:
```python
if lat is None or lon is None:
cache_key = city
else:
cache_key = f"{lat},{lon}"
```
Wait -- this is already handled by the `use_coords` boolean. The current logic is correct. Disregard the race concern; the ternary is safe.
### [WARN-3] Search endpoint lacks rate limiting
**File:** `backend/app/routers/weather.py` (lines 26-53)
The `/search` endpoint calls OpenWeatherMap's geocoding API on every request. The frontend debounces at 300ms, but there's no backend throttle. A user rapidly typing triggers multiple API calls. For a single-user app this is unlikely to hit OWM's rate limits, but the 300ms debounce on the frontend is the only protection.
**Recommendation:** This is acceptable for a single-user app. No action needed unless OWM rate limit errors appear in production.
### [WARN-4] Clearing location sends `null` but `SettingsUpdate` uses `Optional` with `exclude_unset`
**File:** `frontend/src/components/settings/SettingsPage.tsx` (lines 86-98)
**File:** `backend/app/routers/settings.py` (line 29)
```typescript
await updateSettings({
weather_city: null,
weather_lat: null,
weather_lon: null,
});
```
The backend uses `model_dump(exclude_unset=True)`, so explicitly sending `null` values will include them in the update dict -- which correctly sets the DB columns to `NULL`. This works as intended. However, this relies on the frontend correctly sending `null` rather than omitting the keys. If `updateSettings` ever strips `null` values before sending, the clear operation would silently fail.
**Recommendation:** This works correctly today. Add a comment in `handleLocationClear` noting that explicit `null` is required for the backend to clear the fields.
### [WARN-5] `Float` column precision for coordinates
**File:** `backend/app/models/settings.py` (lines 16-17)
```python
weather_lat: Mapped[float | None] = mapped_column(Float, nullable=True, default=None)
weather_lon: Mapped[float | None] = mapped_column(Float, nullable=True, default=None)
```
SQLAlchemy's `Float` maps to PostgreSQL `DOUBLE PRECISION` (8 bytes, ~15 digits). For geographic coordinates this is more than adequate (~1mm precision). No issue here -- just documenting that the type choice is appropriate.
---
## Suggestions
### [SUG-1] Weather search response should use a Pydantic model
**File:** `backend/app/routers/weather.py` (lines 26-49)
The `/search` endpoint returns a raw list of dicts. Adding a response model would document the API and catch shape issues:
```python
from pydantic import BaseModel
class GeoSearchResult(BaseModel):
name: str
state: str
country: str
lat: float
lon: float
@router.get("/search", response_model=list[GeoSearchResult])
```
This mirrors the Phase 2 review's SUG-7 recommendation for the main weather endpoint.
### [SUG-2] Dashboard weather query should check coordinates, not just city
**File:** `frontend/src/components/dashboard/DashboardPage.tsx` (line 77)
```typescript
enabled: !!settings?.weather_city,
```
The backend now supports coordinate-based lookup without a city name (line 68-69 of weather.py checks both). The frontend enable condition should match:
```typescript
enabled: !!(settings?.weather_city || (settings?.weather_lat != null && settings?.weather_lon != null)),
```
In practice, the current `handleLocationSelect` always sets all three (`weather_city`, `weather_lat`, `weather_lon`) together, so this is unlikely to diverge. But for correctness, the enable condition should match what the backend accepts.
### [SUG-3] Debounce cleanup on unmount
**File:** `frontend/src/components/settings/SettingsPage.tsx` (lines 34, 64-65)
```typescript
const debounceRef = useRef<ReturnType<typeof setTimeout>>();
// ...
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => searchLocations(value), 300);
```
If the component unmounts while a debounce timer is pending, the callback fires on an unmounted component, potentially causing a React state update warning. Add cleanup:
```typescript
useEffect(() => {
return () => {
if (debounceRef.current) clearTimeout(debounceRef.current);
};
}, []);
```
### [SUG-4] Search endpoint geocoding URL uses HTTP, not HTTPS
**File:** `backend/app/routers/weather.py` (line 37)
```python
url = f"http://api.openweathermap.org/geo/1.0/direct?q={urllib.parse.quote(q)}&limit=5&appid={api_key}"
```
The geocoding endpoint uses `http://` while the weather endpoints (lines 93-94) use `https://`. The API key is sent in the query string over an unencrypted connection. This means the API key could be intercepted in transit.
**Fix:** Change to `https://`:
```python
url = f"https://api.openweathermap.org/geo/1.0/direct?q={urllib.parse.quote(q)}&limit=5&appid={api_key}"
```
### [SUG-5] `weather_city` display could be null when coordinates exist
**File:** `frontend/src/components/settings/SettingsPage.tsx` (line 275)
```tsx
{settings?.weather_city}
```
If somehow `weather_lat` and `weather_lon` are set but `weather_city` is null (e.g., direct API call), the location badge would display empty text. Add a fallback:
```tsx
{settings?.weather_city || `${settings?.weather_lat}, ${settings?.weather_lon}`}
```
### [SUG-6] Consider parallel fetch for weather current + forecast
**File:** `backend/app/routers/weather.py` (lines 96-99)
```python
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),
)
```
This is already using `asyncio.gather` for parallel execution -- great improvement from the Phase 2 sequential pattern. No action needed.
---
## Positive Observations
1. **CRIT-2 from Phase 2 is fully resolved.** Both the `/search` and `/` endpoints now use `run_in_executor` for blocking `urllib` calls, and the main weather endpoint uses `asyncio.gather` to fetch current + forecast in parallel. This is a significant improvement.
2. **Cache keying is well-designed.** The `cache_key` switches between `"{lat},{lon}"` and `city` based on what's available, correctly invalidating when the user switches locations without needing an explicit cache clear.
3. **Frontend UX is polished.** The search-and-select pattern with debounced input, loading spinner, click-outside dismissal, and clear button is a substantial UX improvement over a plain text input. The dropdown key uses `${loc.lat}-${loc.lon}-${i}` which handles duplicate city names correctly.
4. **Settings update is atomic.** `handleLocationSelect` sends `weather_city`, `weather_lat`, and `weather_lon` in a single `updateSettings` call, preventing partial state where coordinates exist without a city name.
5. **Migration is clean and reversible.** The Alembic migration correctly adds nullable float columns with a straightforward downgrade path.
6. **Type definitions stay in sync.** The `GeoLocation` interface and `Settings` type updates accurately mirror the backend schema changes.
7. **Error handling in the frontend is user-friendly.** Both `handleLocationSelect` and `handleLocationClear` catch errors and show toast notifications, and the search function silently clears results on failure rather than showing error UI.
---
## Summary of Action Items
| Priority | ID | Issue | Effort |
|----------|------|-------|--------|
| CRITICAL | CRIT-1 | No lat/lon validation in Pydantic schema | 15 lines |
| WARNING | WARN-1 | Deprecated `get_event_loop()` - use `get_running_loop()` | 2 lines |
| SUGGESTION | SUG-4 | Geocoding URL uses HTTP, leaks API key in transit | 1 line |
| SUGGESTION | SUG-1 | No response_model on search endpoint | 10 lines |
| SUGGESTION | SUG-2 | Dashboard enables weather query on city only, not coords | 1 line |
| SUGGESTION | SUG-3 | Debounce timer not cleaned up on unmount | 4 lines |
| SUGGESTION | SUG-5 | Location badge has no fallback for null city name | 1 line |
---
## Previous Review Items Status
| Phase 2 ID | Status | Notes |
|------------|--------|-------|
| CRIT-1 (double path prefix) | RESOLVED | Weather router now uses `@router.get("/")` |
| CRIT-2 (blocking urllib) | RESOLVED | Uses `run_in_executor` + `asyncio.gather` |
| WARN-1 (cache invalidation) | RESOLVED | Cache keys on coordinates/city |
| WARN-2 (API key in errors) | RESOLVED | Error messages now use generic strings |
| WARN-5 (stale settings state) | NOT ADDRESSED | Still present for other settings fields |
| SUG-7 (no response_model) | NOT ADDRESSED | Still no Pydantic model for weather/search responses |

View File

@ -1,4 +1,5 @@
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select from sqlalchemy import select
from datetime import datetime, timedelta from datetime import datetime, timedelta
@ -18,12 +19,20 @@ router = APIRouter()
_cache: dict = {} _cache: dict = {}
class GeoSearchResult(BaseModel):
name: str
state: str
country: str
lat: float
lon: float
def _fetch_json(url: str) -> dict: def _fetch_json(url: str) -> dict:
with urllib.request.urlopen(url, timeout=10) as resp: with urllib.request.urlopen(url, timeout=10) as resp:
return json.loads(resp.read().decode()) return json.loads(resp.read().decode())
@router.get("/search") @router.get("/search", response_model=list[GeoSearchResult])
async def search_locations( async def search_locations(
q: str = Query(..., min_length=1, max_length=100), q: str = Query(..., min_length=1, max_length=100),
current_user: Settings = Depends(get_current_session) current_user: Settings = Depends(get_current_session)
@ -33,8 +42,8 @@ async def search_locations(
raise HTTPException(status_code=500, detail="Weather API key not configured") raise HTTPException(status_code=500, detail="Weather API key not configured")
try: try:
loop = asyncio.get_event_loop() loop = asyncio.get_running_loop()
url = f"http://api.openweathermap.org/geo/1.0/direct?q={urllib.parse.quote(q)}&limit=5&appid={api_key}" 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) results = await loop.run_in_executor(None, _fetch_json, url)
return [ return [
@ -82,7 +91,7 @@ async def get_weather(
raise HTTPException(status_code=500, detail="Weather API key not configured") raise HTTPException(status_code=500, detail="Weather API key not configured")
try: try:
loop = asyncio.get_event_loop() loop = asyncio.get_running_loop()
# Build query params based on coordinates or city name # Build query params based on coordinates or city name
if use_coords: if use_coords:

View File

@ -30,6 +30,20 @@ class SettingsUpdate(BaseModel):
weather_lat: float | None = None weather_lat: float | None = None
weather_lon: float | None = None weather_lon: float | None = None
@field_validator('weather_lat')
@classmethod
def validate_lat(cls, v: float | None) -> float | None:
if v is not None and (v < -90 or v > 90):
raise ValueError('Latitude must be between -90 and 90')
return v
@field_validator('weather_lon')
@classmethod
def validate_lon(cls, v: float | None) -> float | None:
if v is not None and (v < -180 or v > 180):
raise ValueError('Longitude must be between -180 and 180')
return v
class SettingsResponse(BaseModel): class SettingsResponse(BaseModel):
id: int id: int

View File

@ -74,7 +74,7 @@ export default function DashboardPage() {
}, },
staleTime: 30 * 60 * 1000, staleTime: 30 * 60 * 1000,
retry: false, retry: false,
enabled: !!settings?.weather_city, enabled: !!(settings?.weather_city || (settings?.weather_lat != null && settings?.weather_lon != null)),
}); });
if (isLoading) { if (isLoading) {

View File

@ -108,6 +108,13 @@ export default function SettingsPage() {
return () => document.removeEventListener('mousedown', handler); return () => document.removeEventListener('mousedown', handler);
}, []); }, []);
// Clean up debounce timer on unmount
useEffect(() => {
return () => {
if (debounceRef.current) clearTimeout(debounceRef.current);
};
}, []);
const handleNameSave = async () => { const handleNameSave = async () => {
const trimmed = preferredName.trim(); const trimmed = preferredName.trim();
if (trimmed === (settings?.preferred_name || '')) return; if (trimmed === (settings?.preferred_name || '')) return;
@ -272,7 +279,7 @@ export default function SettingsPage() {
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="inline-flex items-center gap-2 rounded-md border border-accent/30 bg-accent/10 px-3 py-1.5 text-sm text-foreground"> <span className="inline-flex items-center gap-2 rounded-md border border-accent/30 bg-accent/10 px-3 py-1.5 text-sm text-foreground">
<MapPin className="h-3.5 w-3.5 text-accent" /> <MapPin className="h-3.5 w-3.5 text-accent" />
{settings?.weather_city} {settings?.weather_city || `${settings?.weather_lat}, ${settings?.weather_lon}`}
</span> </span>
<button <button
type="button" type="button"