feat: enhance photo upload and management features #90
4
backend/migrations/058_photos_taken_at.sql
Normal file
4
backend/migrations/058_photos_taken_at.sql
Normal 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
103
backend/photo_exif.py
Normal 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)
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 <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()
|
||||
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)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
|
|
|
|||
|
|
@ -102,7 +102,7 @@ export default function NewMeasurement() {
|
|||
if (id) { await api.updateMeasurement(id, payload) }
|
||||
else { const r = await api.createMeasurement(payload); mid = r.id }
|
||||
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})
|
||||
}
|
||||
setSaved(true); setTimeout(()=>nav('/'), 1200)
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { Camera, Trash2 } from 'lucide-react'
|
|||
import dayjs from 'dayjs'
|
||||
import { api } from '../utils/api'
|
||||
import UsageBadge from '../components/UsageBadge'
|
||||
import { formatPhotoCaption } from '../utils/photoDisplay'
|
||||
|
||||
export default function PhotosCapturePage() {
|
||||
const [date, setDate] = useState(() => dayjs().format('YYYY-MM-DD'))
|
||||
|
|
@ -11,6 +12,7 @@ export default function PhotosCapturePage() {
|
|||
const [uploading, setUploading] = useState(false)
|
||||
const [error, setError] = useState(null)
|
||||
const [big, setBig] = useState(null)
|
||||
const [skipExif, setSkipExif] = useState(false)
|
||||
const fileRef = useRef(null)
|
||||
|
||||
const load = () => api.listPhotos().then(setPhotos).catch(() => setPhotos([]))
|
||||
|
|
@ -38,7 +40,7 @@ export default function PhotosCapturePage() {
|
|||
setUploading(true)
|
||||
try {
|
||||
for (const file of files) {
|
||||
await api.uploadPhoto(file, date)
|
||||
await api.uploadPhoto(file, date, { skipExif })
|
||||
}
|
||||
await load()
|
||||
await loadUsage()
|
||||
|
|
@ -74,11 +76,12 @@ export default function PhotosCapturePage() {
|
|||
{photoUsage && <UsageBadge {...photoUsage} />}
|
||||
</div>
|
||||
<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
|
||||
Zuordnungsdatum (z. B. Aufnahmezeitpunkt).
|
||||
Reihenfolge: zuerst <strong>EXIF</strong> (Aufnahme aus dem Bild), sonst die{' '}
|
||||
<strong>Datei-Zeit</strong> (Letzte Änderung der Datei, wie vom Browser übermittelt), sonst das
|
||||
Fallback-Datum unten (Standard: heute).
|
||||
</p>
|
||||
<div className="form-row">
|
||||
<label className="form-label">Zuordnungsdatum</label>
|
||||
<label className="form-label">Datum (Fallback ohne EXIF)</label>
|
||||
<input
|
||||
type="date"
|
||||
className="form-input"
|
||||
|
|
@ -88,6 +91,20 @@ export default function PhotosCapturePage() {
|
|||
/>
|
||||
<span className="form-unit" />
|
||||
</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
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
|
|
@ -171,7 +188,7 @@ export default function PhotosCapturePage() {
|
|||
borderRadius: 3,
|
||||
}}
|
||||
>
|
||||
{p.date?.slice(0, 10) || p.created?.slice(0, 10)}
|
||||
{formatPhotoCaption(p)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
},
|
||||
|
||||
// Photos
|
||||
uploadPhoto: async (file, date = '') => {
|
||||
// Photos: EXIF → File.lastModified → date-Feld → heute; skipExif nur Formular/heute
|
||||
uploadPhoto: async (file, date = '', opts = {}) => {
|
||||
const fd = new FormData()
|
||||
fd.append('file', file)
|
||||
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 d = await r.json().catch(() => ({}))
|
||||
if (!r.ok) throw new Error(formatFastApiDetail(d.detail, JSON.stringify(d)))
|
||||
|
|
|
|||
25
frontend/src/utils/photoDisplay.js
Normal file
25
frontend/src/utils/photoDisplay.js
Normal 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')
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user