fix(photos): accept bare-date filter bounds, send T00:00:00 from UI
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) <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -212,11 +212,12 @@ export const useFilterStore = create<FilterStore>((set) => ({
|
||||
export function filtersToParams(f: FilterState): Record<string, string | number> {
|
||||
const params: Record<string, string | number> = {}
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user