From d580796dc85ac51c15c38460b67ec6fe3f8121da Mon Sep 17 00:00:00 2001 From: Claudio Date: Sun, 10 May 2026 23:16:09 +0200 Subject: [PATCH] fix(photos): accept bare-date filter bounds, send T00:00:00 from UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GET /api/v1/photos rejected ?date_from=2026-04-10 with 422 because pydantic v2's datetime parser doesn't accept date-only strings. The frontend has been padding date_to with T23:59:59 forever to make the upper bound inclusive, but date_from went out as a bare YYYY-MM-DD, so every date-range filter request 422'd and the grid showed nothing. Frontend: pad date_from with T00:00:00 the same way date_to gets T23:59:59 — symmetry, and pydantic v2 accepts the full form. Backend: change date_from/date_to to Optional[str] and parse with datetime.fromisoformat in the handler. fromisoformat accepts both bare dates ('2026-04-10' -> midnight) and full ISO strings, so any older client that still sends a date-only value continues to work. Tz-aware values get coerced to naive UTC before binding (matches the taken_at column's shape and the same fix applied to PATCH /photos/{id} earlier today). Bad input returns 400 with a clear message instead of pydantic's 422. Co-Authored-By: Claude Opus 4.7 (1M context) --- backend/app/routers/photos.py | 40 +++++++++++++++++++++++++------ frontend/src/store/filterStore.ts | 11 +++++---- 2 files changed, 39 insertions(+), 12 deletions(-) diff --git a/backend/app/routers/photos.py b/backend/app/routers/photos.py index aecc977..dbdbab4 100644 --- a/backend/app/routers/photos.py +++ b/backend/app/routers/photos.py @@ -52,8 +52,12 @@ router = APIRouter() @router.get("") async def list_photos( q: Optional[str] = None, - date_from: Optional[datetime] = None, - date_to: Optional[datetime] = None, + # Accept either a bare date ("2026-04-10") or a full ISO datetime + # ("2026-04-10T23:59:59"). pydantic v2's datetime parser rejects + # the bare form with 422; we coerce manually below so older + # clients that send a date-only string keep working. + date_from: Optional[str] = None, + date_to: Optional[str] = None, folder_id: Optional[str] = None, tag_ids: Optional[str] = None, media_type: Optional[str] = None, @@ -129,11 +133,33 @@ async def list_photos( ) ) - # Date range - if date_from: - filters.append(Photo.taken_at >= date_from) - if date_to: - filters.append(Photo.taken_at <= date_to) + # Date range. Both inputs are strings to keep pydantic from rejecting + # bare-date forms ("2026-04-10") with 422; coerce here. fromisoformat + # accepts both bare dates and full ISO datetimes — when given a date, + # it returns midnight, which is what we want for the lower bound. + def _parse_bound(s: Optional[str]) -> Optional[datetime]: + if not s: + return None + try: + dt = datetime.fromisoformat(s) + except ValueError: + raise HTTPException( + status_code=400, + detail=f"Invalid date: {s!r} (expected YYYY-MM-DD or ISO 8601)", + ) + # If the caller sent a tz-aware string, normalize to naive UTC — + # the photos.taken_at column is `timestamp without time zone`. + if dt.tzinfo is not None: + from datetime import timezone + dt = dt.astimezone(timezone.utc).replace(tzinfo=None) + return dt + + parsed_date_from = _parse_bound(date_from) + parsed_date_to = _parse_bound(date_to) + if parsed_date_from: + filters.append(Photo.taken_at >= parsed_date_from) + if parsed_date_to: + filters.append(Photo.taken_at <= parsed_date_to) # Folder filter. The sidebar can pass either a SourceRoot id or a # Folder id; both should include descendants so clicking a parent diff --git a/frontend/src/store/filterStore.ts b/frontend/src/store/filterStore.ts index 3fc2759..13f863f 100644 --- a/frontend/src/store/filterStore.ts +++ b/frontend/src/store/filterStore.ts @@ -212,11 +212,12 @@ export const useFilterStore = create((set) => ({ export function filtersToParams(f: FilterState): Record { const params: Record = {} if (f.q.trim()) params.q = f.q.trim() - if (f.dateFrom) params.date_from = f.dateFrom - // Make the upper bound inclusive end-of-day: the backend compares - // `taken_at <= date_to` as a full datetime, so a bare "2024-01-15" - // would exclude every photo taken after midnight that day — a - // single-day pick (from==to) would then match nothing. + // Both bounds need an explicit time component because pydantic v2 + // rejects bare date strings ("2026-04-10") for datetime params with + // 422. The from is start-of-day; the to is inclusive end-of-day so + // a single-day pick (from==to) still matches every photo taken + // that day. + if (f.dateFrom) params.date_from = `${f.dateFrom}T00:00:00` if (f.dateTo) params.date_to = `${f.dateTo}T23:59:59` if (f.mediaTypes.length > 0) params.media_type = f.mediaTypes.join(',') if (f.ratingMin > 0) params.rating_min = f.ratingMin