feat: implement photo management features in the application
All checks were successful
Deploy Development / deploy (push) Successful in 55s
Build Test / pytest-backend (push) Successful in 5s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 24s

- Added a new `resolve_photo_path` function to handle legacy and new photo paths effectively.
- Updated the photo upload process to store only filenames in the database, improving path resolution.
- Enhanced the photo retrieval and deletion processes to utilize the new path resolution logic.
- Introduced a dedicated PhotosCapturePage for managing photo uploads and viewing.
- Updated the dashboard and navigation to include links to the new photo management features.
- Improved the photo grid display with sorting and deletion capabilities for better user experience.
This commit is contained in:
Lars 2026-04-19 09:20:28 +02:00
parent 7676897fda
commit c91317df8e
9 changed files with 417 additions and 38 deletions

View File

@ -24,6 +24,7 @@ from feature_logger import log_feature_usage
from caliper_composition import enrich_caliper_row_for_response, load_weight_rows
from data_layer.activity_session_metrics import enrich_sessions_with_metrics
from data_layer.utils import serialize_dates
from routers.photos import resolve_photo_path
router = APIRouter(prefix="/api/export", tags=["export"])
logger = logging.getLogger(__name__)
@ -392,8 +393,8 @@ Datumsformat: YYYY-MM-DD
cur.execute("SELECT * FROM photos WHERE profile_id=%s ORDER BY date", (pid,))
photos = [r2d(r) for r in cur.fetchall()]
for i, photo in enumerate(photos):
photo_path = Path(PHOTOS_DIR) / photo['path']
if photo_path.exists():
photo_path = resolve_photo_path(photo.get('path'))
if photo_path and photo_path.exists():
filename = f"{photo.get('date') or export_date}_{i+1}{photo_path.suffix}"
zf.write(photo_path, f"photos/{filename}")

View File

@ -25,6 +25,26 @@ PHOTOS_DIR = Path(os.getenv("PHOTOS_DIR", "./photos"))
PHOTOS_DIR.mkdir(parents=True, exist_ok=True)
def resolve_photo_path(stored: Optional[str]) -> Optional[Path]:
"""
Map DB `path` to an existing file. Supports legacy rows (absolute path or nested)
and new rows (filename only under PHOTOS_DIR).
"""
if not stored or not str(stored).strip():
return None
raw = str(stored).strip()
p = Path(raw)
if p.is_absolute() and p.exists():
return p
cand = PHOTOS_DIR / raw
if cand.exists():
return cand
cand2 = PHOTOS_DIR / Path(raw).name
if cand2.exists():
return cand2
return None
@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)):
@ -48,7 +68,8 @@ async def upload_photo(file: UploadFile=File(...), date: str=Form(""),
fid = str(uuid.uuid4())
ext = Path(file.filename).suffix or '.jpg'
path = PHOTOS_DIR / f"{fid}{ext}"
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
@ -56,8 +77,9 @@ async def upload_photo(file: UploadFile=File(...), date: str=Form(""),
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,str(path)))
(fid,pid,photo_date,fname))
# Phase 2: Increment usage counter
increment_feature_usage(pid, 'photos')
@ -67,14 +89,18 @@ async def upload_photo(file: UploadFile=File(...), date: str=Form(""),
@router.get("/{fid}")
def get_photo(fid: str, session: dict=Depends(require_auth_flexible)):
"""Get photo by ID. Auth via header or query param (for <img> tags)."""
"""Get photo by ID. Auth via header or query param ssetoken (for <img> tags)."""
profile_id = str(session["profile_id"])
with get_db() as conn:
cur = get_cursor(conn)
cur.execute("SELECT path FROM photos WHERE id=%s", (fid,))
cur.execute("SELECT path, profile_id FROM photos WHERE id=%s", (fid,))
row = cur.fetchone()
if not row: raise HTTPException(404, "Photo not found")
photo_path = Path(PHOTOS_DIR) / row['path']
if not photo_path.exists():
if not row:
raise HTTPException(404, "Photo not found")
if str(row["profile_id"]) != profile_id:
raise HTTPException(404, "Photo not found")
photo_path = resolve_photo_path(row["path"])
if not photo_path:
raise HTTPException(404, "Photo file not found")
return FileResponse(photo_path)
@ -86,5 +112,38 @@ def list_photos(x_profile_id: Optional[str]=Header(default=None), session: dict=
with get_db() as conn:
cur = get_cursor(conn)
cur.execute(
"SELECT * FROM photos WHERE profile_id=%s ORDER BY created DESC LIMIT 100", (pid,))
"""
SELECT * FROM photos WHERE profile_id=%s
ORDER BY date DESC NULLS LAST, created DESC
LIMIT 500
""",
(pid,),
)
return [r2d(r) for r in cur.fetchall()]
@router.delete("/{fid}")
def delete_photo(
fid: str,
x_profile_id: Optional[str] = Header(default=None),
session: dict = Depends(require_auth),
):
"""Delete a photo (DB row + Datei auf der Platte)."""
pid = get_pid(x_profile_id)
with get_db() as conn:
cur = get_cursor(conn)
cur.execute(
"SELECT path FROM photos WHERE id=%s AND profile_id=%s",
(fid, pid),
)
row = cur.fetchone()
if not row:
raise HTTPException(404, "Photo not found")
photo_path = resolve_photo_path(row["path"])
cur.execute("DELETE FROM photos WHERE id=%s AND profile_id=%s", (fid, pid))
if photo_path and photo_path.exists():
try:
photo_path.unlink()
except OSError as e:
logger.warning("Could not delete photo file %s: %s", photo_path, e)
return {"ok": True, "id": fid}

View File

@ -15,6 +15,7 @@ import CaptureShell from './layouts/CaptureShell'
import CaptureHub from './pages/CaptureHub'
import WeightScreen from './pages/WeightScreen'
import CircumScreen from './pages/CircumScreen'
import PhotosCapturePage from './pages/PhotosCapturePage'
import CaliperScreen from './pages/CaliperScreen'
import MeasureWizard from './pages/MeasureWizard'
import History from './pages/History'
@ -223,6 +224,7 @@ function AppShell() {
<Route path="/wizard" element={<MeasureWizard />} />
<Route path="/weight" element={<WeightScreen />} />
<Route path="/circum" element={<CircumScreen />} />
<Route path="/photos" element={<PhotosCapturePage />} />
<Route path="/caliper" element={<CaliperScreen />} />
<Route path="/sleep" element={<SleepPage />} />
<Route path="/rest-days" element={<RestDaysPage />} />

View File

@ -18,8 +18,8 @@ export default function ProgressPhotosWidget({ refreshTick = 0 }) {
return (
<div className="card section-gap" style={{ marginBottom: 16, textAlign: 'center', padding: 24 }}>
<div style={{ fontSize: 13, color: 'var(--text3)', marginBottom: 12 }}>Noch keine Fotos.</div>
<button type="button" className="btn btn-primary" onClick={() => nav('/capture')}>
Zur Erfassung
<button type="button" className="btn btn-primary" onClick={() => nav('/photos')}>
Fotos erfassen
</button>
</div>
)

View File

@ -26,6 +26,13 @@ export const CAPTURE_HUB_TILES = [
to: '/circum',
color: '#1D9E75',
},
{
icon: '📷',
label: 'Fotos',
sub: 'Fortschrittsfotos hochladen und verwalten',
to: '/photos',
color: '#5C6BC0',
},
{
icon: '📐',
label: 'Caliper',

View File

@ -6,7 +6,7 @@ import {
XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid,
ReferenceLine, PieChart, Pie, Cell
} from 'recharts'
import { ChevronRight, Brain, ChevronDown, ChevronUp } from 'lucide-react'
import { ChevronRight, Brain, ChevronDown, ChevronUp, Trash2 } from 'lucide-react'
import { api } from '../utils/api'
import { getBfCategory } from '../utils/calc'
import { getInterpretation, getStatusColor, getStatusBg } from '../utils/interpret'
@ -897,28 +897,128 @@ 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)
useEffect(()=>{ api.listPhotos().then(setPhotos) },[])
if(!photos.length) return <EmptySection text="Noch keine Fotos." to="/circum" toLabel="Umfänge erfassen"/>
const [photos, setPhotos] = useState([])
const [big, setBig] = useState(null)
const load = () => api.listPhotos().then(setPhotos)
useEffect(() => {
load()
}, [])
const handleDelete = async (id) => {
if (!confirm('Dieses Foto löschen?')) return
try {
await api.deletePhoto(id)
if (big === id) setBig(null)
await load()
} catch (e) {
alert(e.message || 'Löschen fehlgeschlagen')
}
}
if (!photos.length) {
return <EmptySection text="Noch keine Fotos." to="/photos" toLabel="Fotos erfassen" />
}
const sorted = [...photos].sort((a, b) => sortPhotoDateKey(b).localeCompare(sortPhotoDateKey(a)))
const byMonth = new Map()
for (const p of sorted) {
const mk = monthKeyFromPhoto(p)
if (!byMonth.has(mk)) byMonth.set(mk, [])
byMonth.get(mk).push(p)
}
const monthKeys = [...byMonth.keys()].sort((a, b) => b.localeCompare(a))
return (
<>
{big&&<div style={{position:'fixed',inset:0,background:'rgba(0,0,0,0.9)',zIndex:100,
display:'flex',alignItems:'center',justifyContent:'center'}} onClick={()=>setBig(null)}>
<img src={api.photoUrl(big)} style={{maxWidth:'100%',maxHeight:'100%',borderRadius:8}} alt=""/>
</div>}
<div className="photo-grid">
{photos.map(p=>(
<div key={p.id} style={{position:'relative'}}>
<img src={api.photoUrl(p.id)} className="photo-thumb" onClick={()=>setBig(p.id)} alt=""/>
<div style={{position:'absolute',bottom:4,left:4,fontSize:9,background:'rgba(0,0,0,0.6)',
color:'white',padding:'1px 4px',borderRadius:3}}>
{p.date?.slice(0,10)||p.created?.slice(0,10)}
{big && (
<div
style={{
position: 'fixed',
inset: 0,
background: 'rgba(0,0,0,0.9)',
zIndex: 100,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
onClick={() => setBig(null)}
role="presentation"
>
<img src={api.photoUrl(big)} style={{ maxWidth: '100%', maxHeight: '100%', borderRadius: 8 }} alt="" />
</div>
)}
{monthKeys.map((mk) => (
<div key={mk} style={{ marginBottom: 16 }}>
<div
style={{
fontSize: 14,
fontWeight: 700,
color: 'var(--text3)',
marginBottom: 10,
textTransform: 'capitalize',
}}
>
{dayjs(`${mk}-01`).format('MMMM YYYY')}
</div>
<div className="photo-grid">
{byMonth.get(mk).map((p) => (
<div key={p.id} style={{ position: 'relative' }}>
<img
src={api.photoUrl(p.id)}
className="photo-thumb"
onClick={() => setBig(p.id)}
alt=""
/>
<div
style={{
position: 'absolute',
bottom: 4,
left: 4,
fontSize: 9,
background: 'rgba(0,0,0,0.6)',
color: 'white',
padding: '1px 4px',
borderRadius: 3,
}}
>
{p.date?.slice(0, 10) || p.created?.slice(0, 10)}
</div>
<button
type="button"
className="btn btn-secondary"
style={{
position: 'absolute',
top: 4,
right: 4,
padding: 6,
minWidth: 0,
borderRadius: 8,
}}
title="Löschen"
onClick={(e) => {
e.stopPropagation()
handleDelete(p.id)
}}
>
<Trash2 size={14} />
</button>
</div>
))}
</div>
</div>
))}
</>
)
}

View File

@ -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, mid)
const pr = await api.uploadPhoto(photoFile, form.date)
await api.updateMeasurement(mid, {photo_id: pr.id})
}
setSaved(true); setTimeout(()=>nav('/'), 1200)

View File

@ -0,0 +1,203 @@
import { useState, useEffect, useRef } from 'react'
import { Camera, Trash2 } from 'lucide-react'
import dayjs from 'dayjs'
import { api } from '../utils/api'
import UsageBadge from '../components/UsageBadge'
export default function PhotosCapturePage() {
const [date, setDate] = useState(() => dayjs().format('YYYY-MM-DD'))
const [photos, setPhotos] = useState([])
const [photoUsage, setPhotoUsage] = useState(null)
const [uploading, setUploading] = useState(false)
const [error, setError] = useState(null)
const [big, setBig] = useState(null)
const fileRef = useRef(null)
const load = () => api.listPhotos().then(setPhotos).catch(() => setPhotos([]))
const loadUsage = () => {
api
.getFeatureUsage()
.then((features) => {
const f = features.find((x) => x.feature_id === 'photos')
setPhotoUsage(f)
})
.catch(() => setPhotoUsage(null))
}
useEffect(() => {
load()
loadUsage()
}, [])
const onPickFiles = async (e) => {
const files = Array.from(e.target.files || [])
e.target.value = ''
if (!files.length) return
setError(null)
setUploading(true)
try {
for (const file of files) {
await api.uploadPhoto(file, date)
}
await load()
await loadUsage()
} catch (err) {
setError(err.message || 'Upload fehlgeschlagen')
} finally {
setUploading(false)
}
}
const handleDelete = async (id) => {
if (!confirm('Dieses Foto löschen?')) return
try {
await api.deletePhoto(id)
if (big === id) setBig(null)
await load()
} catch (err) {
alert(err.message || 'Löschen fehlgeschlagen')
}
}
const blocked = photoUsage && !photoUsage.allowed
return (
<div className="capture-page">
<h1 className="page-title" style={{ marginBottom: 16 }}>
Fotos
</h1>
<div className="card section-gap">
<div className="card-title badge-container-right">
<span>Neues Foto</span>
{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).
</p>
<div className="form-row">
<label className="form-label">Zuordnungsdatum</label>
<input
type="date"
className="form-input"
style={{ width: '100%', maxWidth: 220 }}
value={date}
onChange={(e) => setDate(e.target.value)}
/>
<span className="form-unit" />
</div>
<input
ref={fileRef}
type="file"
accept="image/*"
multiple
style={{ display: 'none' }}
onChange={onPickFiles}
/>
{error && (
<div
style={{
padding: '10px',
background: 'var(--danger-bg)',
border: '1px solid var(--danger)',
borderRadius: 8,
fontSize: 13,
color: 'var(--danger)',
marginBottom: 10,
}}
>
{error}
</div>
)}
<button
type="button"
className="btn btn-primary btn-full"
disabled={uploading || blocked}
title={
blocked
? `Limit erreicht (${photoUsage.used}/${photoUsage.limit}).`
: undefined
}
onClick={() => fileRef.current?.click()}
>
<Camera size={16} style={{ marginRight: 8 }} />
{uploading ? 'Wird hochgeladen…' : 'Foto(s) auswählen'}
</button>
</div>
<div className="card section-gap">
<div className="card-title">Meine Fotos ({photos.length})</div>
{!photos.length ? (
<div style={{ fontSize: 13, color: 'var(--text3)', padding: '8px 0' }}>Noch keine Fotos.</div>
) : (
<>
{big && (
<div
style={{
position: 'fixed',
inset: 0,
background: 'rgba(0,0,0,0.9)',
zIndex: 100,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
onClick={() => setBig(null)}
role="presentation"
>
<img src={api.photoUrl(big)} style={{ maxWidth: '100%', maxHeight: '100%', borderRadius: 8 }} alt="" />
</div>
)}
<div className="photo-grid">
{photos.map((p) => (
<div key={p.id} style={{ position: 'relative' }}>
<img
src={api.photoUrl(p.id)}
className="photo-thumb"
alt=""
onClick={() => setBig(p.id)}
/>
<div
style={{
position: 'absolute',
bottom: 4,
left: 4,
fontSize: 9,
background: 'rgba(0,0,0,0.6)',
color: 'white',
padding: '1px 4px',
borderRadius: 3,
}}
>
{p.date?.slice(0, 10) || p.created?.slice(0, 10)}
</div>
<button
type="button"
className="btn btn-secondary"
style={{
position: 'absolute',
top: 4,
right: 4,
padding: 6,
minWidth: 0,
borderRadius: 8,
}}
title="Löschen"
onClick={(e) => {
e.stopPropagation()
handleDelete(p.id)
}}
>
<Trash2 size={14} />
</button>
</div>
))}
</div>
</>
)}
</div>
</div>
)
}

View File

@ -171,14 +171,21 @@ export const api = {
},
// Photos
uploadPhoto: (file,date='')=>{
const fd=new FormData();fd.append('file',file);fd.append('date',date)
return fetch(`${BASE}/photos`,{method:'POST',body:fd,headers:hdrs()}).then(r=>r.json())
uploadPhoto: async (file, date = '') => {
const fd = new FormData()
fd.append('file', file)
fd.append('date', date)
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)))
return d
},
listPhotos: () => req('/photos'),
deletePhoto: (id) => req(`/photos/${id}`, { method: 'DELETE' }),
/** Bild-URL; Query heißt `ssetoken` (require_auth_flexible), nicht `token`. */
photoUrl: (pid) => {
const token = getToken()
return `${BASE}/photos/${pid}${token ? `?token=${token}` : ''}`
return `${BASE}/photos/${pid}${token ? `?ssetoken=${encodeURIComponent(token)}` : ''}`
},
// Nutrition