mitai-jinkendo/backend/routers/photos.py
Lars c91317df8e
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
feat: implement photo management features in the application
- 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.
2026-04-19 09:20:28 +02:00

150 lines
5.1 KiB
Python

"""
Photo Management Endpoints for Mitai Jinkendo
Handles progress photo uploads and retrieval.
"""
import os
import uuid
import logging
from pathlib import Path
from typing import Optional
from fastapi import APIRouter, UploadFile, File, Form, Header, HTTPException, Depends
from fastapi.responses import FileResponse
import aiofiles
from db import get_db, get_cursor, r2d
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
router = APIRouter(prefix="/api/photos", tags=["photos"])
logger = logging.getLogger(__name__)
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)):
"""Upload progress photo."""
pid = get_pid(x_profile_id)
# Phase 4: Check feature access and ENFORCE
access = check_feature_access(pid, 'photos')
log_feature_usage(pid, 'photos', access, 'upload')
if not access['allowed']:
logger.warning(
f"[FEATURE-LIMIT] User {pid} blocked: "
f"photos {access['reason']} (used: {access['used']}, limit: {access['limit']})"
)
raise HTTPException(
status_code=403,
detail=f"Limit erreicht: Du hast das Kontingent für Fotos überschritten ({access['used']}/{access['limit']}). "
f"Bitte kontaktiere den Admin oder warte bis zum nächsten Reset."
)
fid = str(uuid.uuid4())
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
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))
# Phase 2: Increment usage counter
increment_feature_usage(pid, 'photos')
return {"id":fid,"date":photo_date}
@router.get("/{fid}")
def get_photo(fid: str, session: dict=Depends(require_auth_flexible)):
"""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, profile_id FROM photos WHERE id=%s", (fid,))
row = cur.fetchone()
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)
@router.get("")
def list_photos(x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
"""Get all photos for current profile."""
pid = get_pid(x_profile_id)
with get_db() as conn:
cur = get_cursor(conn)
cur.execute(
"""
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}