feat: enhance photo upload and management features #90

Merged
Lars merged 1 commits from develop into main 2026-04-19 10:20:05 +02:00
12 changed files with 225 additions and 36 deletions

View File

@ -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';

103
backend/photo_exif.py Normal file
View File

@ -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)

View File

@ -9,3 +9,4 @@ bcrypt==4.1.3
slowapi==0.1.9 slowapi==0.1.9
psycopg2-binary==2.9.9 psycopg2-binary==2.9.9
python-dateutil==2.9.0 python-dateutil==2.9.0
tzdata>=2024.1 # ZoneInfo (Europe/Berlin) auch unter Windows

View File

@ -6,6 +6,7 @@ Handles progress photo uploads and retrieval.
import os import os
import uuid import uuid
import logging import logging
from datetime import date as date_cls
from pathlib import Path from pathlib import Path
from typing import Optional from typing import Optional
@ -14,6 +15,7 @@ from fastapi.responses import FileResponse
import aiofiles import aiofiles
from db import get_db, get_cursor, r2d 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 auth import require_auth, require_auth_flexible, check_feature_access, increment_feature_usage
from routers.profiles import get_pid from routers.profiles import get_pid
from feature_logger import log_feature_usage from feature_logger import log_feature_usage
@ -46,9 +48,20 @@ def resolve_photo_path(stored: Optional[str]) -> Optional[Path]:
@router.post("") @router.post("")
async def upload_photo(file: UploadFile=File(...), date: str=Form(""), async def upload_photo(
x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)): file: UploadFile = File(...),
"""Upload progress photo.""" 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) pid = get_pid(x_profile_id)
# Phase 4: Check feature access and ENFORCE # 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." 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()) fid = str(uuid.uuid4())
ext = Path(file.filename).suffix or '.jpg' ext = Path(file.filename).suffix or ".jpg"
fname = f"{fid}{ext}" fname = f"{fid}{ext}"
path = PHOTOS_DIR / fname path = PHOTOS_DIR / fname
async with aiofiles.open(path,'wb') as f: await f.write(await file.read()) async with aiofiles.open(path, "wb") as f:
await f.write(raw)
# Convert empty string to NULL for date field
photo_date = date if date and date.strip() else None
with get_db() as conn: with get_db() as conn:
cur = get_cursor(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(
cur.execute("INSERT INTO photos (id,profile_id,date,path,created) VALUES (%s,%s,%s,%s,CURRENT_TIMESTAMP)", """
(fid,pid,photo_date,fname)) 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 # 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}") @router.get("/{fid}")
@ -114,7 +155,7 @@ def list_photos(x_profile_id: Optional[str]=Header(default=None), session: dict=
cur.execute( cur.execute(
""" """
SELECT * FROM photos WHERE profile_id=%s 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 LIMIT 500
""", """,
(pid,), (pid,),

View File

@ -180,6 +180,7 @@ CREATE TABLE IF NOT EXISTS photos (
meas_id UUID, -- Legacy: reference to measurement (circumference/caliper) meas_id UUID, -- Legacy: reference to measurement (circumference/caliper)
date DATE, date DATE,
path TEXT NOT NULL, path TEXT NOT NULL,
taken_at TIMESTAMPTZ, -- Aufnahmezeit aus EXIF (optional)
created TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP created TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
); );

View File

@ -1,6 +1,7 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { api } from '../../utils/api' import { api } from '../../utils/api'
import { formatPhotoCaption } from '../../utils/photoDisplay'
/** /**
* Fortschrittsfotos (Galerie wie Verlauf-Tab Fotos). * Fortschrittsfotos (Galerie wie Verlauf-Tab Fotos).
@ -76,7 +77,7 @@ export default function ProgressPhotosWidget({ refreshTick = 0 }) {
borderRadius: 3, borderRadius: 3,
}} }}
> >
{p.date?.slice(0, 10) || p.created?.slice(0, 10)} {formatPhotoCaption(p)}
</div> </div>
</div> </div>
))} ))}

View File

@ -49,7 +49,7 @@ export default function CircumScreen() {
FIELDS.forEach(k=>{ if(form[k]!=='') payload[k]=parseFloat(form[k]) }) FIELDS.forEach(k=>{ if(form[k]!=='') payload[k]=parseFloat(form[k]) })
if(form.notes) payload.notes = form.notes if(form.notes) payload.notes = form.notes
if(photoFile) { 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 payload.photo_id = pr.id
} }
await api.upsertCirc(payload) await api.upsertCirc(payload)

View File

@ -8,6 +8,7 @@ import {
} from 'recharts' } from 'recharts'
import { ChevronRight, Brain, ChevronDown, ChevronUp, Trash2 } from 'lucide-react' import { ChevronRight, Brain, ChevronDown, ChevronUp, Trash2 } from 'lucide-react'
import { api } from '../utils/api' import { api } from '../utils/api'
import { photoMonthKey, photoSortKey, formatPhotoCaption } from '../utils/photoDisplay'
import { getBfCategory } from '../utils/calc' import { getBfCategory } from '../utils/calc'
import { getInterpretation, getStatusColor, getStatusBg } from '../utils/interpret' import { getInterpretation, getStatusColor, getStatusBg } from '../utils/interpret'
import Markdown from '../utils/Markdown' import Markdown from '../utils/Markdown'
@ -897,15 +898,6 @@ function CorrelationSection({ corrData, insights, profile, onRequest, loadingSlu
} }
// Photo Grid // 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() { function PhotoGrid() {
const [photos, setPhotos] = useState([]) const [photos, setPhotos] = useState([])
const [big, setBig] = useState(null) const [big, setBig] = useState(null)
@ -931,10 +923,10 @@ function PhotoGrid() {
return <EmptySection text="Noch keine Fotos." to="/photos" toLabel="Fotos erfassen" /> return <EmptySection text="Noch keine Fotos." to="/photos" toLabel="Fotos erfassen" />
} }
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() const byMonth = new Map()
for (const p of sorted) { for (const p of sorted) {
const mk = monthKeyFromPhoto(p) const mk = photoMonthKey(p)
if (!byMonth.has(mk)) byMonth.set(mk, []) if (!byMonth.has(mk)) byMonth.set(mk, [])
byMonth.get(mk).push(p) byMonth.get(mk).push(p)
} }
@ -993,7 +985,7 @@ function PhotoGrid() {
borderRadius: 3, borderRadius: 3,
}} }}
> >
{p.date?.slice(0, 10) || p.created?.slice(0, 10)} {formatPhotoCaption(p)}
</div> </div>
<button <button
type="button" type="button"

View File

@ -102,7 +102,7 @@ export default function NewMeasurement() {
if (id) { await api.updateMeasurement(id, payload) } if (id) { await api.updateMeasurement(id, payload) }
else { const r = await api.createMeasurement(payload); mid = r.id } else { const r = await api.createMeasurement(payload); mid = r.id }
if (photoFile && mid) { if (photoFile && mid) {
const pr = await api.uploadPhoto(photoFile, form.date) const pr = await api.uploadPhoto(photoFile, form.date, { skipExif: true })
await api.updateMeasurement(mid, {photo_id: pr.id}) await api.updateMeasurement(mid, {photo_id: pr.id})
} }
setSaved(true); setTimeout(()=>nav('/'), 1200) setSaved(true); setTimeout(()=>nav('/'), 1200)

View File

@ -3,6 +3,7 @@ import { Camera, Trash2 } from 'lucide-react'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import { api } from '../utils/api' import { api } from '../utils/api'
import UsageBadge from '../components/UsageBadge' import UsageBadge from '../components/UsageBadge'
import { formatPhotoCaption } from '../utils/photoDisplay'
export default function PhotosCapturePage() { export default function PhotosCapturePage() {
const [date, setDate] = useState(() => dayjs().format('YYYY-MM-DD')) const [date, setDate] = useState(() => dayjs().format('YYYY-MM-DD'))
@ -11,6 +12,7 @@ export default function PhotosCapturePage() {
const [uploading, setUploading] = useState(false) const [uploading, setUploading] = useState(false)
const [error, setError] = useState(null) const [error, setError] = useState(null)
const [big, setBig] = useState(null) const [big, setBig] = useState(null)
const [skipExif, setSkipExif] = useState(false)
const fileRef = useRef(null) const fileRef = useRef(null)
const load = () => api.listPhotos().then(setPhotos).catch(() => setPhotos([])) const load = () => api.listPhotos().then(setPhotos).catch(() => setPhotos([]))
@ -38,7 +40,7 @@ export default function PhotosCapturePage() {
setUploading(true) setUploading(true)
try { try {
for (const file of files) { for (const file of files) {
await api.uploadPhoto(file, date) await api.uploadPhoto(file, date, { skipExif })
} }
await load() await load()
await loadUsage() await loadUsage()
@ -74,11 +76,12 @@ export default function PhotosCapturePage() {
{photoUsage && <UsageBadge {...photoUsage} />} {photoUsage && <UsageBadge {...photoUsage} />}
</div> </div>
<p style={{ fontSize: 13, color: 'var(--text2)', marginTop: 0, marginBottom: 14, lineHeight: 1.5 }}> <p style={{ fontSize: 13, color: 'var(--text2)', marginTop: 0, marginBottom: 14, lineHeight: 1.5 }}>
Fortschrittsfotos unabhängig von Umfängen. Pro Tag beliebig viele Aufnahmen möglich Datum ist das Reihenfolge: zuerst <strong>EXIF</strong> (Aufnahme aus dem Bild), sonst die{' '}
Zuordnungsdatum (z.B. Aufnahmezeitpunkt). <strong>Datei-Zeit</strong> (Letzte Änderung der Datei, wie vom Browser übermittelt), sonst das
Fallback-Datum unten (Standard: heute).
</p> </p>
<div className="form-row"> <div className="form-row">
<label className="form-label">Zuordnungsdatum</label> <label className="form-label">Datum (Fallback ohne EXIF)</label>
<input <input
type="date" type="date"
className="form-input" className="form-input"
@ -88,6 +91,20 @@ export default function PhotosCapturePage() {
/> />
<span className="form-unit" /> <span className="form-unit" />
</div> </div>
<label
style={{
display: 'flex',
alignItems: 'center',
gap: 8,
fontSize: 13,
color: 'var(--text2)',
marginBottom: 12,
cursor: 'pointer',
}}
>
<input type="checkbox" checked={skipExif} onChange={(e) => setSkipExif(e.target.checked)} />
EXIF und Datei-Zeit ignorieren nur das Fallback-Datum verwenden
</label>
<input <input
ref={fileRef} ref={fileRef}
type="file" type="file"
@ -171,7 +188,7 @@ export default function PhotosCapturePage() {
borderRadius: 3, borderRadius: 3,
}} }}
> >
{p.date?.slice(0, 10) || p.created?.slice(0, 10)} {formatPhotoCaption(p)}
</div> </div>
<button <button
type="button" type="button"

View File

@ -170,11 +170,15 @@ export const api = {
const d=await r.json();if(!r.ok)throw new Error(formatFastApiDetail(d.detail, JSON.stringify(d)));return d const d=await r.json();if(!r.ok)throw new Error(formatFastApiDetail(d.detail, JSON.stringify(d)));return d
}, },
// Photos // Photos: EXIF → File.lastModified → date-Feld → heute; skipExif nur Formular/heute
uploadPhoto: async (file, date = '') => { uploadPhoto: async (file, date = '', opts = {}) => {
const fd = new FormData() const fd = new FormData()
fd.append('file', file) fd.append('file', file)
fd.append('date', date) fd.append('date', date)
if (opts.skipExif) fd.append('skip_exif', '1')
if (file && typeof file.lastModified === 'number' && !Number.isNaN(file.lastModified) && file.lastModified > 0) {
fd.append('file_last_modified', String(file.lastModified))
}
const r = await fetch(`${BASE}/photos`, { method: 'POST', body: fd, headers: hdrs() }) const r = await fetch(`${BASE}/photos`, { method: 'POST', body: fd, headers: hdrs() })
const d = await r.json().catch(() => ({})) const d = await r.json().catch(() => ({}))
if (!r.ok) throw new Error(formatFastApiDetail(d.detail, JSON.stringify(d))) if (!r.ok) throw new Error(formatFastApiDetail(d.detail, JSON.stringify(d)))

View File

@ -0,0 +1,25 @@
import dayjs from 'dayjs'
/** Monatsschlüssel YYYY-MM für Gruppierung (EXIF-Zeit bevorzugt). */
export function photoMonthKey(p) {
if (p.taken_at) return p.taken_at.slice(0, 7)
if (p.date) {
return typeof p.date === 'string' ? p.date.slice(0, 7) : dayjs(p.date).format('YYYY-MM')
}
return (p.created || '').slice(0, 7) || '0000-00'
}
/** Sortierung: ISO-Zeit oder Datum. */
export function photoSortKey(p) {
if (p.taken_at) return p.taken_at
const d = p.date || p.created || ''
return typeof d === 'string' ? d.slice(0, 10) : dayjs(d).format('YYYY-MM-DD')
}
/** Badge / Untertitel: mit Uhrzeit wenn EXIF vorhanden. */
export function formatPhotoCaption(p) {
if (p.taken_at) return dayjs(p.taken_at).format('DD.MM.YYYY HH:mm')
const raw = p.date || p.created
if (!raw) return ''
return typeof raw === 'string' ? raw.slice(0, 10) : dayjs(raw).format('DD.MM.YYYY')
}