diff --git a/backend/migrations/058_photos_taken_at.sql b/backend/migrations/058_photos_taken_at.sql new file mode 100644 index 0000000..e064e77 --- /dev/null +++ b/backend/migrations/058_photos_taken_at.sql @@ -0,0 +1,4 @@ +-- EXIF-Aufnahmezeit (optional); Sortierung / Anzeige +ALTER TABLE photos ADD COLUMN IF NOT EXISTS taken_at TIMESTAMPTZ; + +COMMENT ON COLUMN photos.taken_at IS 'Aufnahmezeit aus EXIF (DateTimeOriginal o.ä.), Zeitzone siehe PHOTO_EXIF_TIMEZONE'; diff --git a/backend/photo_exif.py b/backend/photo_exif.py new file mode 100644 index 0000000..66a5fd5 --- /dev/null +++ b/backend/photo_exif.py @@ -0,0 +1,103 @@ +""" +EXIF-Aufnahmedatum/-zeit aus Bildbytes (JPEG, PNG mit EXIF, …). + +EXIF enthält keine Zeitzone; wir interpretieren die Wandzeit in PHOTO_EXIF_TIMEZONE +(Standard Europe/Berlin) und speichern als TIMESTAMPTZ (UTC in PostgreSQL). +""" +from __future__ import annotations + +import os +from datetime import datetime, timezone +from io import BytesIO +from typing import Optional +from zoneinfo import ZoneInfo + +from PIL import Image + +EXIF_DATETIME_FMT = "%Y:%m:%d %H:%M:%S" +_EXIF_IFD = 0x8769 +_EXIF_DATETIME_TAGS = (36867, 36868) # DateTimeOriginal, DateTimeDigitized +_TAG_DATETIME_MAIN = 306 + + +def extract_taken_at_from_image_bytes(raw: bytes) -> Optional[datetime]: + """ + Liest DateTimeOriginal (o. ä.) aus EXIF und gibt ein timezone-aware datetime zurück, + oder None wenn nicht ermittelbar. + """ + try: + img = Image.open(BytesIO(raw)) + except Exception: + return None + try: + naive = _extract_exif_naive_datetime(img) + finally: + try: + img.close() + except Exception: + pass + if naive is None: + return None + tz_name = os.getenv("PHOTO_EXIF_TIMEZONE", "Europe/Berlin") + try: + tz = ZoneInfo(tz_name) + except Exception: + tz = ZoneInfo("Europe/Berlin") + return naive.replace(tzinfo=tz) + + +def _extract_exif_naive_datetime(img: Image.Image) -> Optional[datetime]: + exif = img.getexif() + if not exif: + return None + strings: list[str] = [] + try: + exif_ifd = exif.get_ifd(_EXIF_IFD) + except Exception: + exif_ifd = None + if exif_ifd: + for tag in _EXIF_DATETIME_TAGS: + v = exif_ifd.get(tag) + if isinstance(v, str) and v.strip(): + strings.append(v) + v = exif.get(_TAG_DATETIME_MAIN) + if isinstance(v, str) and v.strip(): + strings.append(v) + for s in strings: + dt = _parse_exif_datetime_str(s) + if dt: + return dt + return None + + +def _parse_exif_datetime_str(s: str) -> Optional[datetime]: + s = (s or "").strip() + if not s: + return None + try: + return datetime.strptime(s, EXIF_DATETIME_FMT) + except ValueError: + return None + + +def taken_at_from_file_last_modified_ms(ms_raw: Optional[str]) -> Optional[datetime]: + """ + Browser sendet File.lastModified (ms seit UTC-Epoch), echte Dateirevision auf der Platte. + Wird als echter Zeitpunkt interpretiert und nach PHOTO_EXIF_TIMEZONE für Anzeige gelegt + (konsistent zu EXIF-Wandzeit). + """ + if not ms_raw or not str(ms_raw).strip(): + return None + try: + ms = int(str(ms_raw).strip()) + except ValueError: + return None + if ms <= 0: + return None + instant_utc = datetime.fromtimestamp(ms / 1000.0, tz=timezone.utc) + tz_name = os.getenv("PHOTO_EXIF_TIMEZONE", "Europe/Berlin") + try: + tz = ZoneInfo(tz_name) + except Exception: + tz = ZoneInfo("Europe/Berlin") + return instant_utc.astimezone(tz) diff --git a/backend/requirements.txt b/backend/requirements.txt index ab909ab..445b62d 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -9,3 +9,4 @@ bcrypt==4.1.3 slowapi==0.1.9 psycopg2-binary==2.9.9 python-dateutil==2.9.0 +tzdata>=2024.1 # ZoneInfo (Europe/Berlin) auch unter Windows diff --git a/backend/routers/photos.py b/backend/routers/photos.py index 65a6ef8..f85e08f 100644 --- a/backend/routers/photos.py +++ b/backend/routers/photos.py @@ -6,6 +6,7 @@ Handles progress photo uploads and retrieval. import os import uuid import logging +from datetime import date as date_cls from pathlib import Path from typing import Optional @@ -14,6 +15,7 @@ from fastapi.responses import FileResponse import aiofiles from db import get_db, get_cursor, r2d +from photo_exif import extract_taken_at_from_image_bytes, taken_at_from_file_last_modified_ms from auth import require_auth, require_auth_flexible, check_feature_access, increment_feature_usage from routers.profiles import get_pid from feature_logger import log_feature_usage @@ -46,9 +48,20 @@ def resolve_photo_path(stored: Optional[str]) -> Optional[Path]: @router.post("") -async def upload_photo(file: UploadFile=File(...), date: str=Form(""), - x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)): - """Upload progress photo.""" +async def upload_photo( + file: UploadFile = File(...), + date: str = Form(""), + skip_exif: str = Form(""), + file_last_modified: str = Form(""), + x_profile_id: Optional[str] = Header(default=None), + session: dict = Depends(require_auth), +): + """ + Upload progress photo. + + Reihenfolge (wenn nicht ``skip_exif``): EXIF → Datei-Zeitstempel (Browser ``File.lastModified``) + → Formularfeld ``date`` → heute. Bei ``skip_exif``: nur Formular / heute (weder EXIF noch Datei-Zeit). + """ pid = get_pid(x_profile_id) # Phase 4: Check feature access and ENFORCE @@ -66,25 +79,53 @@ async def upload_photo(file: UploadFile=File(...), date: str=Form(""), f"Bitte kontaktiere den Admin oder warte bis zum nächsten Reset." ) + raw = await file.read() + ignore_exif = skip_exif.strip().lower() in ("1", "true", "yes", "on") + taken_at = None + exif_used = False + file_mtime_used = False + + if not ignore_exif: + taken_at = extract_taken_at_from_image_bytes(raw) + exif_used = taken_at is not None + if not taken_at: + taken_at = taken_at_from_file_last_modified_ms(file_last_modified) + file_mtime_used = taken_at is not None + + if taken_at: + photo_date = taken_at.date() + elif date and date.strip(): + photo_date = date_cls.fromisoformat(date.strip()[:10]) + else: + photo_date = date_cls.today() + fid = str(uuid.uuid4()) - ext = Path(file.filename).suffix or '.jpg' + ext = Path(file.filename).suffix or ".jpg" fname = f"{fid}{ext}" path = PHOTOS_DIR / fname - async with aiofiles.open(path,'wb') as f: await f.write(await file.read()) - - # Convert empty string to NULL for date field - photo_date = date if date and date.strip() else None + async with aiofiles.open(path, "wb") as f: + await f.write(raw) with get_db() as conn: cur = get_cursor(conn) - # Nur Dateiname in DB — Auflösung über PHOTOS_DIR (auch für ältere absolute Pfade: resolve_photo_path) - cur.execute("INSERT INTO photos (id,profile_id,date,path,created) VALUES (%s,%s,%s,%s,CURRENT_TIMESTAMP)", - (fid,pid,photo_date,fname)) + cur.execute( + """ + INSERT INTO photos (id, profile_id, date, path, taken_at, created) + VALUES (%s, %s, %s, %s, %s, CURRENT_TIMESTAMP) + """, + (fid, pid, photo_date, fname, taken_at), + ) # Phase 2: Increment usage counter - increment_feature_usage(pid, 'photos') + increment_feature_usage(pid, "photos") - return {"id":fid,"date":photo_date} + return { + "id": fid, + "date": photo_date.isoformat(), + "taken_at": taken_at.isoformat() if taken_at else None, + "exif_used": exif_used, + "file_mtime_used": file_mtime_used, + } @router.get("/{fid}") @@ -114,7 +155,7 @@ def list_photos(x_profile_id: Optional[str]=Header(default=None), session: dict= cur.execute( """ SELECT * FROM photos WHERE profile_id=%s - ORDER BY date DESC NULLS LAST, created DESC + ORDER BY COALESCE(taken_at, date::timestamptz, created) DESC NULLS LAST LIMIT 500 """, (pid,), diff --git a/backend/schema.sql b/backend/schema.sql index d0bc600..b422532 100644 --- a/backend/schema.sql +++ b/backend/schema.sql @@ -180,6 +180,7 @@ CREATE TABLE IF NOT EXISTS photos ( meas_id UUID, -- Legacy: reference to measurement (circumference/caliper) date DATE, path TEXT NOT NULL, + taken_at TIMESTAMPTZ, -- Aufnahmezeit aus EXIF (optional) created TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP ); diff --git a/frontend/src/components/dashboard-widgets/ProgressPhotosWidget.jsx b/frontend/src/components/dashboard-widgets/ProgressPhotosWidget.jsx index f97bb56..9f597b7 100644 --- a/frontend/src/components/dashboard-widgets/ProgressPhotosWidget.jsx +++ b/frontend/src/components/dashboard-widgets/ProgressPhotosWidget.jsx @@ -1,6 +1,7 @@ import { useState, useEffect } from 'react' import { useNavigate } from 'react-router-dom' import { api } from '../../utils/api' +import { formatPhotoCaption } from '../../utils/photoDisplay' /** * Fortschrittsfotos (Galerie wie Verlauf-Tab Fotos). @@ -76,7 +77,7 @@ export default function ProgressPhotosWidget({ refreshTick = 0 }) { borderRadius: 3, }} > - {p.date?.slice(0, 10) || p.created?.slice(0, 10)} + {formatPhotoCaption(p)} ))} diff --git a/frontend/src/pages/CircumScreen.jsx b/frontend/src/pages/CircumScreen.jsx index 0fde54d..63a5211 100644 --- a/frontend/src/pages/CircumScreen.jsx +++ b/frontend/src/pages/CircumScreen.jsx @@ -49,7 +49,7 @@ export default function CircumScreen() { FIELDS.forEach(k=>{ if(form[k]!=='') payload[k]=parseFloat(form[k]) }) if(form.notes) payload.notes = form.notes if(photoFile) { - const pr = await api.uploadPhoto(photoFile, form.date) + const pr = await api.uploadPhoto(photoFile, form.date, { skipExif: true }) payload.photo_id = pr.id } await api.upsertCirc(payload) diff --git a/frontend/src/pages/History.jsx b/frontend/src/pages/History.jsx index 496aff3..8a8e2b4 100644 --- a/frontend/src/pages/History.jsx +++ b/frontend/src/pages/History.jsx @@ -8,6 +8,7 @@ import { } from 'recharts' import { ChevronRight, Brain, ChevronDown, ChevronUp, Trash2 } from 'lucide-react' import { api } from '../utils/api' +import { photoMonthKey, photoSortKey, formatPhotoCaption } from '../utils/photoDisplay' import { getBfCategory } from '../utils/calc' import { getInterpretation, getStatusColor, getStatusBg } from '../utils/interpret' import Markdown from '../utils/Markdown' @@ -897,15 +898,6 @@ function CorrelationSection({ corrData, insights, profile, onRequest, loadingSlu } // ── Photo Grid ──────────────────────────────────────────────────────────────── -function monthKeyFromPhoto(p) { - if (p.date) return p.date.slice(0, 7) - return (p.created || '').slice(0, 7) || '0000-00' -} - -function sortPhotoDateKey(p) { - return (p.date || p.created || '').slice(0, 10) -} - function PhotoGrid() { const [photos, setPhotos] = useState([]) const [big, setBig] = useState(null) @@ -931,10 +923,10 @@ function PhotoGrid() { return } - const sorted = [...photos].sort((a, b) => sortPhotoDateKey(b).localeCompare(sortPhotoDateKey(a))) + const sorted = [...photos].sort((a, b) => photoSortKey(b).localeCompare(photoSortKey(a))) const byMonth = new Map() for (const p of sorted) { - const mk = monthKeyFromPhoto(p) + const mk = photoMonthKey(p) if (!byMonth.has(mk)) byMonth.set(mk, []) byMonth.get(mk).push(p) } @@ -993,7 +985,7 @@ function PhotoGrid() { borderRadius: 3, }} > - {p.date?.slice(0, 10) || p.created?.slice(0, 10)} + {formatPhotoCaption(p)}