Initial upload: aesthetic-immich-starter

This commit is contained in:
Abderrahim-Mouhcine
2026-08-16 19:59:33 +01:00
commit 8938106e99
4777 changed files with 2176899 additions and 0 deletions
+15
View File
@@ -0,0 +1,15 @@
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
immich_base_url: str = "http://localhost:2283"
immich_api_key: str = ""
database_url: str = "sqlite:///./aesthetic_planner.db"
immich_album_prefix: str = "Patient"
upload_device_id: str = "aesthetic-photo-planner"
request_timeout_seconds: int = 90
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8")
settings = Settings()
+20
View File
@@ -0,0 +1,20 @@
from sqlalchemy import create_engine
from sqlalchemy.orm import DeclarativeBase, sessionmaker
from app.config import settings
connect_args = {"check_same_thread": False} if settings.database_url.startswith("sqlite") else {}
engine = create_engine(settings.database_url, connect_args=connect_args)
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False)
class Base(DeclarativeBase):
pass
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()
+998
View File
@@ -0,0 +1,998 @@
from pathlib import Path
from datetime import datetime
import json
import mimetypes
from tempfile import NamedTemporaryFile
from fastapi import Body, Depends, FastAPI, File, Form, HTTPException, UploadFile
from fastapi import Response
from fastapi.middleware.cors import CORSMiddleware
from sqlalchemy import or_, text
from sqlalchemy.orm import Session
from app.config import settings
from app.database import Base, engine, get_db
from app.models import Consultation, Document, Patient, Photo
from app.schemas import ConsultationCreate, ConsultationOut, ConsultationUpdate, DocumentCreate, DocumentOut, DocumentUpdate, ImmichAssetOut, ImmichPersonOut, PatientCreate, PatientOut, PatientUpdate, PhotoOut, PhotoUpdate
from app.services.immich import ImmichClient
from app.services.face_transform import render_face_guides, render_face_transform
app = FastAPI(title="Aesthetic Photo Planner API", version="0.3.7")
app.add_middleware(
CORSMiddleware,
allow_origins=["*"], # À durcir plus tard si accès public.
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
def _ensure_sqlite_patient_columns() -> None:
"""Mini migrations SQLite locales.
Pour une vraie prod, on passera à Alembic/PostgreSQL. Ici, on évite de casser
ta base locale existante juste parce qu'on ajoute trois champs, cette manie humaine.
"""
if not settings.database_url.startswith("sqlite"):
return
optional_columns = {
"phone": "TEXT",
"email": "TEXT",
"procedure": "TEXT",
"status": "VARCHAR(40) DEFAULT 'actif'",
"consent_signed": "VARCHAR(20) DEFAULT 'non'",
}
with engine.begin() as conn:
existing = {row[1] for row in conn.execute(text("PRAGMA table_info(patients)")).fetchall()}
for column_name, column_type in optional_columns.items():
if column_name not in existing:
conn.execute(text(f"ALTER TABLE patients ADD COLUMN {column_name} {column_type}"))
conn.execute(text("""
CREATE TABLE IF NOT EXISTS photo_face_landmarks (
photo_id INTEGER PRIMARY KEY,
manual_landmarks TEXT NOT NULL,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
"""))
@app.on_event("startup")
def on_startup() -> None:
Base.metadata.create_all(bind=engine)
_ensure_sqlite_patient_columns()
@app.get("/")
def root() -> dict:
return {
"app": "Aesthetic Photo Planner",
"version": "0.3.7",
"status": "running",
"docs": "/docs",
"health": "/health",
"patients": "/patients",
"documents": "/patients/{patient_id}/documents",
}
@app.get("/health")
def health() -> dict:
return {
"status": "ok",
"database": settings.database_url.split(":", 1)[0],
"immich_configured": bool(settings.immich_api_key),
}
def _clean_text(value: str | None) -> str | None:
if value is None:
return None
value = value.strip()
return value or None
def _validate_photo_kind(kind: str | None, default: str = "original") -> str:
safe_kind = (kind or default).strip().lower() or default
allowed = {"original", "annotated", "before", "after", "planning", "planification", "face", "simulation"}
if safe_kind not in allowed:
raise HTTPException(status_code=400, detail="kind invalide")
return safe_kind
def _sanitize_manual_landmarks(raw: object) -> dict[str, list[float]]:
if not isinstance(raw, dict):
raise HTTPException(status_code=400, detail="manual_landmarks invalide")
allowed_keys = {
"left_eye", "right_eye", "nose", "mouth", "chin", "face_top", "face_left", "face_right"
}
cleaned: dict[str, list[float]] = {}
for key, value in raw.items():
if key not in allowed_keys:
continue
if not isinstance(value, list | tuple) or len(value) < 2:
raise HTTPException(status_code=400, detail=f"repère invalide: {key}")
try:
x = float(value[0])
y = float(value[1])
except (TypeError, ValueError):
raise HTTPException(status_code=400, detail=f"repère invalide: {key}")
cleaned[key] = [max(0.0, min(1.0, x)), max(0.0, min(1.0, y))]
required = {"left_eye", "right_eye", "nose", "mouth", "chin", "face_top", "face_left", "face_right"}
missing = sorted(required - set(cleaned))
if missing:
raise HTTPException(status_code=400, detail=f"repères manquants: {', '.join(missing)}")
return cleaned
def _get_saved_manual_landmarks(db: Session, photo_id: int) -> dict[str, list[float]] | None:
if not settings.database_url.startswith("sqlite"):
return None
row = db.execute(
text("SELECT manual_landmarks FROM photo_face_landmarks WHERE photo_id = :photo_id"),
{"photo_id": photo_id},
).fetchone()
if not row:
return None
try:
data = json.loads(row[0])
return _sanitize_manual_landmarks(data)
except Exception:
return None
def _payload_with_saved_landmarks(payload: dict, db: Session, photo_id: int) -> dict:
if not isinstance(payload, dict):
payload = {}
output = dict(payload)
if not output.get("manual_landmarks"):
saved = _get_saved_manual_landmarks(db, photo_id)
if saved:
output["manual_landmarks"] = saved
return output
def _validate_document_status(status: str | None, default: str = "brouillon") -> str:
safe_status = (status or default).strip().lower() or default
allowed = {"brouillon", "à signer", "signe", "signé", "archive", "archivé"}
if safe_status not in allowed:
raise HTTPException(status_code=400, detail="status document invalide")
if safe_status == "signe":
return "signé"
if safe_status == "archive":
return "archivé"
return safe_status
def _validate_document_type(document_type: str | None, default: str = "consentement_photo") -> str:
safe_type = (document_type or default).strip().lower() or default
allowed = {
"consentement_photo",
"consentement_intervention",
"consentement_simulation",
"compte_rendu_consultation",
"note_interne",
"autre",
}
if safe_type not in allowed:
raise HTTPException(status_code=400, detail="type document invalide")
return safe_type
def _pdf_text_bytes(text: str) -> bytes:
data = text.encode("cp1252", errors="replace")
data = data.replace(b"\\", b"\\\\").replace(b"(", b"\\(").replace(b")", b"\\)")
return b"(" + data + b")"
def _wrap_pdf_line(line: str, width: int = 88) -> list[str]:
line = line.replace("\t", " ").strip()
if not line:
return [""]
words = line.split()
rows: list[str] = []
current = ""
for word in words:
candidate = word if not current else f"{current} {word}"
if len(candidate) <= width:
current = candidate
else:
if current:
rows.append(current)
current = word
if current:
rows.append(current)
return rows or [""]
def _build_simple_pdf(title: str, lines: list[str]) -> bytes:
wrapped: list[str] = []
for line in lines:
wrapped.extend(_wrap_pdf_line(line))
max_lines = 45
pages = [wrapped[i:i + max_lines] for i in range(0, len(wrapped), max_lines)] or [[""]]
objects: list[bytes | None] = [None, None]
catalog_id = 1
pages_id = 2
def add(obj: bytes) -> int:
objects.append(obj)
return len(objects)
font_id = add(b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>")
page_ids: list[int] = []
for page_index, page_lines in enumerate(pages, start=1):
stream = bytearray()
stream.extend(b"BT\n/F1 16 Tf\n50 800 Td\n")
stream.extend(_pdf_text_bytes(title))
stream.extend(b" Tj\n/F1 10 Tf\n0 -22 Td\n")
stream.extend(_pdf_text_bytes(f"Page {page_index}/{len(pages)}"))
stream.extend(b" Tj\n/F1 11 Tf\n0 -24 Td\n")
for row in page_lines:
stream.extend(_pdf_text_bytes(row))
stream.extend(b" Tj\n0 -16 Td\n")
stream.extend(b"ET\n")
content_id = add(b"<< /Length " + str(len(stream)).encode() + b" >>\nstream\n" + bytes(stream) + b"endstream")
page_obj = (
f"<< /Type /Page /Parent {pages_id} 0 R /MediaBox [0 0 595 842] "
f"/Resources << /Font << /F1 {font_id} 0 R >> >> /Contents {content_id} 0 R >>"
).encode()
page_ids.append(add(page_obj))
objects[catalog_id - 1] = f"<< /Type /Catalog /Pages {pages_id} 0 R >>".encode()
kids = " ".join(f"{pid} 0 R" for pid in page_ids)
objects[pages_id - 1] = f"<< /Type /Pages /Kids [{kids}] /Count {len(page_ids)} >>".encode()
output = bytearray(b"%PDF-1.4\n%\xe2\xe3\xcf\xd3\n")
offsets = [0]
for index, obj in enumerate(objects, start=1):
assert obj is not None
offsets.append(len(output))
output.extend(f"{index} 0 obj\n".encode())
output.extend(obj)
output.extend(b"\nendobj\n")
xref_offset = len(output)
output.extend(f"xref\n0 {len(objects) + 1}\n".encode())
output.extend(b"0000000000 65535 f \n")
for offset in offsets[1:]:
output.extend(f"{offset:010d} 00000 n \n".encode())
output.extend(
f"trailer\n<< /Size {len(objects) + 1} /Root {catalog_id} 0 R >>\nstartxref\n{xref_offset}\n%%EOF\n".encode()
)
return bytes(output)
def _document_pdf_lines(patient: Patient, document: Document) -> list[str]:
return [
f"Patient: {patient.last_name.upper()} {patient.first_name}",
f"Date de naissance: {patient.birth_date or 'Non renseignée'}",
f"Téléphone: {patient.phone or 'Non renseigné'}",
f"Email: {patient.email or 'Non renseigné'}",
f"Acte envisagé: {patient.procedure or 'Non renseigné'}",
"",
f"Document: {document.title}",
f"Type: {document.document_type}",
f"Statut: {document.status}",
f"Signataire: {document.signed_by or 'Non renseigné'}",
f"Date signature: {document.signed_at or 'Non renseignée'}",
f"Généré le: {datetime.now().strftime('%Y-%m-%d %H:%M')}",
"",
"Contenu:",
"",
*(document.content or "").splitlines(),
]
def _media_type_suffix(media_type: str) -> str:
if media_type == "image/png":
return ".png"
if media_type in {"image/webp", "image/x-webp"}:
return ".webp"
return mimetypes.guess_extension(media_type.split(";", 1)[0].strip()) or ".jpg"
def _person_name(person: dict) -> str:
name = (person.get("name") or "").strip()
return name or "Sans nom"
def _person_to_out(person: dict) -> dict:
person_id = person.get("id")
if not person_id:
raise HTTPException(status_code=502, detail="Réponse Immich personne sans id")
return {
"id": person_id,
"name": person.get("name") or None,
"thumbnail_url": f"/immich/people/{person_id}/thumbnail",
"is_hidden": person.get("isHidden"),
}
def _asset_filename(asset: dict) -> str:
filename = asset.get("originalFileName") or asset.get("fileName") or asset.get("filename")
if filename:
return str(filename)
original_path = asset.get("originalPath") or ""
if original_path:
return Path(str(original_path)).name
asset_id = str(asset.get("id") or "asset")
return f"immich_{asset_id[:8]}.jpg"
def _asset_to_out(asset: dict) -> dict:
asset_id = asset.get("id")
if not asset_id:
raise HTTPException(status_code=502, detail="Réponse Immich asset sans id")
return {
"id": asset_id,
"filename": _asset_filename(asset),
"thumbnail_url": f"/immich/assets/{asset_id}/thumbnail",
"file_url": f"/immich/assets/{asset_id}/file",
"taken_at": asset.get("localDateTime") or asset.get("fileCreatedAt") or asset.get("fileModifiedAt"),
"type": asset.get("type"),
}
@app.get("/immich/people", response_model=list[ImmichPersonOut])
async def list_immich_people(q: str | None = None, with_hidden: bool = False) -> list[dict]:
immich = ImmichClient()
people = await immich.list_people(q=q, with_hidden=with_hidden, limit=100)
return [_person_to_out(person) for person in people if person.get("id")]
@app.get("/immich/people/{person_id}/thumbnail")
async def get_immich_person_thumbnail(person_id: str) -> Response:
immich = ImmichClient()
content, media_type = await immich.get_person_thumbnail(person_id)
return Response(content=content, media_type=media_type)
@app.get("/immich/people/{person_id}/assets", response_model=list[ImmichAssetOut])
async def list_immich_person_assets(person_id: str, page: int = 1, size: int = 100) -> list[dict]:
immich = ImmichClient()
assets = await immich.list_person_assets(person_id, page=page, size=size)
return [_asset_to_out(asset) for asset in assets if asset.get("id")]
@app.get("/immich/assets/{asset_id}/thumbnail")
async def get_immich_asset_thumbnail(asset_id: str) -> Response:
immich = ImmichClient()
content, media_type = await immich.get_asset_thumbnail(asset_id, size="thumbnail")
return Response(content=content, media_type=media_type)
@app.get("/immich/assets/{asset_id}/file")
async def get_immich_asset_file(asset_id: str) -> Response:
immich = ImmichClient()
asset = await immich.get_asset_info(asset_id)
content, media_type = await immich.get_asset_original(asset_id)
filename = _asset_filename(asset)
headers = {"Content-Disposition": f'inline; filename="{filename}"'}
return Response(content=content, media_type=media_type, headers=headers)
@app.post("/patients/{patient_id}/photos/from-immich-asset/{asset_id}", response_model=PhotoOut)
async def import_photo_from_immich_asset(
patient_id: int,
asset_id: str,
db: Session = Depends(get_db),
) -> Photo:
patient = db.get(Patient, patient_id)
if not patient:
raise HTTPException(status_code=404, detail="Patient introuvable")
immich = ImmichClient()
asset = await immich.get_asset_info(asset_id)
album_name = f"{settings.immich_album_prefix} - {patient.last_name.upper()} {patient.first_name}"
album = await immich.get_or_create_album(
album_name,
description=f"Dossier esthétique patient #{patient.id}",
)
album_id = album["id"]
await immich.add_assets_to_album(album_id, [asset_id])
if not patient.immich_album_id:
patient.immich_album_id = album_id
filename = _asset_filename(asset)
photo = Photo(
patient_id=patient.id,
immich_asset_id=asset_id,
immich_album_id=album_id,
kind="original",
filename=filename,
notes="Photo complète importée depuis une personne reconnue Immich",
)
db.add(photo)
db.commit()
db.refresh(photo)
return photo
@app.post("/patients/{patient_id}/photos/from-immich-person/{person_id}", response_model=PhotoOut)
async def import_photo_from_immich_person(
patient_id: int,
person_id: str,
db: Session = Depends(get_db),
) -> Photo:
patient = db.get(Patient, patient_id)
if not patient:
raise HTTPException(status_code=404, detail="Patient introuvable")
immich = ImmichClient()
person = await immich.get_person(person_id)
content, media_type = await immich.get_person_thumbnail(person_id)
suffix = _media_type_suffix(media_type)
person_label = _person_name(person).replace("/", "-").replace("\\", "-")
with NamedTemporaryFile(delete=False, suffix=suffix) as temp_file:
temp_path = Path(temp_file.name)
temp_file.write(content)
try:
album_name = f"{settings.immich_album_prefix} - {patient.last_name.upper()} {patient.first_name}"
album = await immich.get_or_create_album(
album_name,
description=f"Dossier esthétique patient #{patient.id}",
)
album_id = album["id"]
filename = f"visage_immich_{person_label}_{person_id[:8]}{suffix}"
asset = await immich.upload_asset(temp_path, filename)
asset_id = asset["id"]
await immich.add_assets_to_album(album_id, [asset_id])
if not patient.immich_album_id:
patient.immich_album_id = album_id
photo = Photo(
patient_id=patient.id,
immich_asset_id=asset_id,
immich_album_id=album_id,
kind="face",
filename=filename,
notes=f"Visage reconnu Immich: {_person_name(person)}",
)
db.add(photo)
db.commit()
db.refresh(photo)
return photo
finally:
temp_path.unlink(missing_ok=True)
@app.post("/patients", response_model=PatientOut)
def create_patient(payload: PatientCreate, db: Session = Depends(get_db)) -> Patient:
patient = Patient(
first_name=payload.first_name.strip(),
last_name=payload.last_name.strip(),
birth_date=_clean_text(payload.birth_date),
phone=_clean_text(payload.phone),
email=_clean_text(payload.email),
procedure=_clean_text(payload.procedure),
status=_clean_text(payload.status) or "actif",
consent_signed=_clean_text(payload.consent_signed) or "non",
notes=_clean_text(payload.notes),
)
db.add(patient)
db.commit()
db.refresh(patient)
return patient
@app.get("/patients", response_model=list[PatientOut])
def list_patients(q: str | None = None, db: Session = Depends(get_db)) -> list[Patient]:
query = db.query(Patient)
if q and q.strip():
needle = f"%{q.strip()}%"
query = query.filter(
or_(
Patient.first_name.ilike(needle),
Patient.last_name.ilike(needle),
Patient.phone.ilike(needle),
Patient.email.ilike(needle),
Patient.procedure.ilike(needle),
Patient.notes.ilike(needle),
)
)
return query.order_by(Patient.created_at.desc()).all()
@app.get("/patients/{patient_id}", response_model=PatientOut)
def get_patient(patient_id: int, db: Session = Depends(get_db)) -> Patient:
patient = db.get(Patient, patient_id)
if not patient:
raise HTTPException(status_code=404, detail="Patient introuvable")
return patient
@app.patch("/patients/{patient_id}", response_model=PatientOut)
def update_patient(patient_id: int, payload: PatientUpdate, db: Session = Depends(get_db)) -> Patient:
patient = db.get(Patient, patient_id)
if not patient:
raise HTTPException(status_code=404, detail="Patient introuvable")
data = payload.model_dump(exclude_unset=True)
for key, value in data.items():
if isinstance(value, str):
value = value.strip() or None
setattr(patient, key, value)
if not patient.status:
patient.status = "actif"
if not patient.consent_signed:
patient.consent_signed = "non"
db.commit()
db.refresh(patient)
return patient
@app.get("/patients/{patient_id}/consultations", response_model=list[ConsultationOut])
def list_patient_consultations(patient_id: int, db: Session = Depends(get_db)) -> list[Consultation]:
patient = db.get(Patient, patient_id)
if not patient:
raise HTTPException(status_code=404, detail="Patient introuvable")
return (
db.query(Consultation)
.filter(Consultation.patient_id == patient_id)
.order_by(Consultation.created_at.desc())
.all()
)
@app.post("/patients/{patient_id}/consultations", response_model=ConsultationOut)
def create_patient_consultation(
patient_id: int,
payload: ConsultationCreate,
db: Session = Depends(get_db),
) -> Consultation:
patient = db.get(Patient, patient_id)
if not patient:
raise HTTPException(status_code=404, detail="Patient introuvable")
consultation = Consultation(
patient_id=patient.id,
consultation_date=_clean_text(payload.consultation_date),
procedure=_clean_text(payload.procedure) or patient.procedure,
reason=_clean_text(payload.reason),
plan=_clean_text(payload.plan),
follow_up=_clean_text(payload.follow_up),
status=_clean_text(payload.status) or "prévu",
notes=_clean_text(payload.notes),
)
db.add(consultation)
db.commit()
db.refresh(consultation)
return consultation
@app.patch("/consultations/{consultation_id}", response_model=ConsultationOut)
def update_consultation(
consultation_id: int,
payload: ConsultationUpdate,
db: Session = Depends(get_db),
) -> Consultation:
consultation = db.get(Consultation, consultation_id)
if not consultation:
raise HTTPException(status_code=404, detail="Consultation introuvable")
data = payload.model_dump(exclude_unset=True)
for key, value in data.items():
if isinstance(value, str):
value = value.strip() or None
setattr(consultation, key, value)
if not consultation.status:
consultation.status = "prévu"
db.commit()
db.refresh(consultation)
return consultation
@app.delete("/consultations/{consultation_id}")
def delete_consultation(consultation_id: int, db: Session = Depends(get_db)) -> dict:
consultation = db.get(Consultation, consultation_id)
if not consultation:
raise HTTPException(status_code=404, detail="Consultation introuvable")
db.delete(consultation)
db.commit()
return {"status": "deleted", "id": consultation_id}
@app.get("/patients/{patient_id}/documents", response_model=list[DocumentOut])
def list_patient_documents(patient_id: int, db: Session = Depends(get_db)) -> list[Document]:
patient = db.get(Patient, patient_id)
if not patient:
raise HTTPException(status_code=404, detail="Patient introuvable")
return (
db.query(Document)
.filter(Document.patient_id == patient_id)
.order_by(Document.created_at.desc())
.all()
)
@app.post("/patients/{patient_id}/documents", response_model=DocumentOut)
def create_patient_document(
patient_id: int,
payload: DocumentCreate,
db: Session = Depends(get_db),
) -> Document:
patient = db.get(Patient, patient_id)
if not patient:
raise HTTPException(status_code=404, detail="Patient introuvable")
if payload.consultation_id is not None:
consultation = db.get(Consultation, payload.consultation_id)
if not consultation or consultation.patient_id != patient_id:
raise HTTPException(status_code=400, detail="Consultation invalide pour ce patient")
title = _clean_text(payload.title)
if not title:
raise HTTPException(status_code=400, detail="Titre document obligatoire")
document = Document(
patient_id=patient.id,
consultation_id=payload.consultation_id,
document_type=_validate_document_type(payload.document_type),
title=title,
status=_validate_document_status(payload.status),
content=_clean_text(payload.content),
signed_by=_clean_text(payload.signed_by),
signed_at=_clean_text(payload.signed_at),
)
db.add(document)
db.commit()
db.refresh(document)
return document
@app.patch("/documents/{document_id}", response_model=DocumentOut)
def update_document(
document_id: int,
payload: DocumentUpdate,
db: Session = Depends(get_db),
) -> Document:
document = db.get(Document, document_id)
if not document:
raise HTTPException(status_code=404, detail="Document introuvable")
data = payload.model_dump(exclude_unset=True)
if "consultation_id" in data and data["consultation_id"] is not None:
consultation = db.get(Consultation, data["consultation_id"])
if not consultation or consultation.patient_id != document.patient_id:
raise HTTPException(status_code=400, detail="Consultation invalide pour ce patient")
for key, value in data.items():
if key == "document_type" and value is not None:
value = _validate_document_type(value, default=document.document_type)
elif key == "status" and value is not None:
value = _validate_document_status(value, default=document.status)
elif isinstance(value, str):
value = value.strip() or None
setattr(document, key, value)
if not document.title:
raise HTTPException(status_code=400, detail="Titre document obligatoire")
if not document.document_type:
document.document_type = "consentement_photo"
if not document.status:
document.status = "brouillon"
db.commit()
db.refresh(document)
return document
@app.delete("/documents/{document_id}")
def delete_document(document_id: int, db: Session = Depends(get_db)) -> dict:
document = db.get(Document, document_id)
if not document:
raise HTTPException(status_code=404, detail="Document introuvable")
db.delete(document)
db.commit()
return {"status": "deleted", "id": document_id}
@app.get("/documents/{document_id}/pdf")
def get_document_pdf(document_id: int, db: Session = Depends(get_db)) -> Response:
document = db.get(Document, document_id)
if not document:
raise HTTPException(status_code=404, detail="Document introuvable")
patient = db.get(Patient, document.patient_id)
if not patient:
raise HTTPException(status_code=404, detail="Patient introuvable")
pdf = _build_simple_pdf(document.title, _document_pdf_lines(patient, document))
safe_name = "_".join((document.title or "document").split())[:80] or "document"
headers = {"Content-Disposition": f'inline; filename="{safe_name}.pdf"'}
return Response(content=pdf, media_type="application/pdf", headers=headers)
@app.get("/patients/{patient_id}/photos", response_model=list[PhotoOut])
def list_patient_photos(patient_id: int, db: Session = Depends(get_db)) -> list[Photo]:
patient = db.get(Patient, patient_id)
if not patient:
raise HTTPException(status_code=404, detail="Patient introuvable")
return db.query(Photo).filter(Photo.patient_id == patient_id).order_by(Photo.created_at.desc()).all()
@app.get("/photos/{photo_id}/thumbnail")
async def get_photo_thumbnail(photo_id: int, db: Session = Depends(get_db)) -> Response:
photo = db.get(Photo, photo_id)
if not photo:
raise HTTPException(status_code=404, detail="Photo introuvable")
immich = ImmichClient()
content, media_type = await immich.get_asset_thumbnail(photo.immich_asset_id, size="thumbnail")
return Response(content=content, media_type=media_type)
@app.get("/photos/{photo_id}/file")
async def get_photo_file(photo_id: int, db: Session = Depends(get_db)) -> Response:
photo = db.get(Photo, photo_id)
if not photo:
raise HTTPException(status_code=404, detail="Photo introuvable")
immich = ImmichClient()
content, media_type = await immich.get_asset_original(photo.immich_asset_id)
headers = {"Content-Disposition": f'inline; filename="{photo.filename}"'}
return Response(content=content, media_type=media_type, headers=headers)
@app.get("/photos/{photo_id}/face-landmarks")
def get_photo_face_landmarks(photo_id: int, db: Session = Depends(get_db)) -> dict:
photo = db.get(Photo, photo_id)
if not photo:
raise HTTPException(status_code=404, detail="Photo introuvable")
row = db.execute(
text("SELECT manual_landmarks, updated_at FROM photo_face_landmarks WHERE photo_id = :photo_id"),
{"photo_id": photo_id},
).fetchone()
if not row:
return {"photo_id": photo_id, "manual_landmarks": None, "updated_at": None}
return {"photo_id": photo_id, "manual_landmarks": json.loads(row[0]), "updated_at": str(row[1]) if row[1] else None}
@app.put("/photos/{photo_id}/face-landmarks")
def save_photo_face_landmarks(
photo_id: int,
payload: dict = Body(default_factory=dict),
db: Session = Depends(get_db),
) -> dict:
photo = db.get(Photo, photo_id)
if not photo:
raise HTTPException(status_code=404, detail="Photo introuvable")
landmarks = _sanitize_manual_landmarks(payload.get("manual_landmarks") or payload)
encoded = json.dumps(landmarks, ensure_ascii=False, separators=(",", ":"))
db.execute(
text("""
INSERT INTO photo_face_landmarks (photo_id, manual_landmarks, updated_at)
VALUES (:photo_id, :manual_landmarks, CURRENT_TIMESTAMP)
ON CONFLICT(photo_id) DO UPDATE SET
manual_landmarks = excluded.manual_landmarks,
updated_at = CURRENT_TIMESTAMP
"""),
{"photo_id": photo_id, "manual_landmarks": encoded},
)
db.commit()
return {"photo_id": photo_id, "manual_landmarks": landmarks, "saved": True}
@app.delete("/photos/{photo_id}/face-landmarks")
def delete_photo_face_landmarks(photo_id: int, db: Session = Depends(get_db)) -> dict:
photo = db.get(Photo, photo_id)
if not photo:
raise HTTPException(status_code=404, detail="Photo introuvable")
db.execute(text("DELETE FROM photo_face_landmarks WHERE photo_id = :photo_id"), {"photo_id": photo_id})
db.commit()
return {"photo_id": photo_id, "deleted": True}
@app.post("/photos/{photo_id}/face-guides")
async def get_photo_face_guides(
photo_id: int,
payload: dict = Body(default_factory=dict),
db: Session = Depends(get_db),
) -> Response:
"""Retourne une image PNG avec repères de détection visage.
Sert à vérifier si le backend cible bien le visage avant de lancer une simulation.
"""
photo = db.get(Photo, photo_id)
if not photo:
raise HTTPException(status_code=404, detail="Photo introuvable")
payload = _payload_with_saved_landmarks(payload, db, photo_id)
immich = ImmichClient()
content, _media_type = await immich.get_asset_original(photo.immich_asset_id)
rendered, meta = render_face_guides(content, payload)
headers = {
"X-Face-Detected": "true" if meta.get("detected") else "false",
"X-Face-Method": str(meta.get("method") or "unknown"),
"X-Face-Landmark-Count": str(meta.get("landmark_count") or 0),
}
return Response(content=rendered, media_type="image/png", headers=headers)
@app.post("/photos/{photo_id}/face-transform/preview")
async def preview_photo_face_transform(
photo_id: int,
payload: dict = Body(default_factory=dict),
db: Session = Depends(get_db),
) -> Response:
"""Retourne un aperçu PNG transformé côté backend.
La transformation utilise OpenCV côté serveur, ce qui donne une image plus stable
que le rendu Flutter local, cette petite aventure de pixels approximatifs.
"""
photo = db.get(Photo, photo_id)
if not photo:
raise HTTPException(status_code=404, detail="Photo introuvable")
payload = _payload_with_saved_landmarks(payload, db, photo_id)
immich = ImmichClient()
content, _media_type = await immich.get_asset_original(photo.immich_asset_id)
rendered, meta = render_face_transform(content, payload, preview=False)
headers = {
"X-Face-Detected": "true" if meta.get("detected") else "false",
"X-Face-Method": str(meta.get("method") or "unknown"),
"X-Face-Landmark-Count": str(meta.get("landmark_count") or 0),
}
return Response(content=rendered, media_type="image/png", headers=headers)
@app.post("/photos/{photo_id}/face-transform", response_model=PhotoOut)
async def save_photo_face_transform(
photo_id: int,
payload: dict = Body(default_factory=dict),
db: Session = Depends(get_db),
) -> Photo:
"""Génère une simulation visage côté backend, l'envoie dans Immich et l'ajoute au dossier patient."""
source_photo = db.get(Photo, photo_id)
if not source_photo:
raise HTTPException(status_code=404, detail="Photo introuvable")
patient = db.get(Patient, source_photo.patient_id)
if not patient:
raise HTTPException(status_code=404, detail="Patient introuvable")
payload = _payload_with_saved_landmarks(payload, db, photo_id)
immich = ImmichClient()
content, _media_type = await immich.get_asset_original(source_photo.immich_asset_id)
rendered, meta = render_face_transform(content, payload, preview=False)
with NamedTemporaryFile(delete=False, suffix=".png") as temp_file:
temp_path = Path(temp_file.name)
temp_file.write(rendered)
try:
album_name = f"{settings.immich_album_prefix} - {patient.last_name.upper()} {patient.first_name}"
album = await immich.get_or_create_album(
album_name,
description=f"Dossier esthétique patient #{patient.id}",
)
album_id = album["id"]
filename = f"simulation_backend_{source_photo.id}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.png"
asset = await immich.upload_asset(temp_path, filename)
asset_id = asset["id"]
await immich.add_assets_to_album(album_id, [asset_id])
if not patient.immich_album_id:
patient.immich_album_id = album_id
status = "visage détecté" if meta.get("detected") else "repères approximatifs"
photo = Photo(
patient_id=patient.id,
immich_asset_id=asset_id,
immich_album_id=album_id,
kind="simulation",
filename=filename,
notes=f"Simulation visage backend OpenCV non contractuelle depuis {source_photo.filename} ({status}).",
)
db.add(photo)
db.commit()
db.refresh(photo)
return photo
finally:
temp_path.unlink(missing_ok=True)
@app.patch("/photos/{photo_id}", response_model=PhotoOut)
def update_photo(photo_id: int, payload: PhotoUpdate, db: Session = Depends(get_db)) -> Photo:
photo = db.get(Photo, photo_id)
if not photo:
raise HTTPException(status_code=404, detail="Photo introuvable")
data = payload.model_dump(exclude_unset=True)
if "kind" in data and data["kind"] is not None:
photo.kind = _validate_photo_kind(data["kind"], default=photo.kind)
if "notes" in data:
photo.notes = _clean_text(data["notes"])
db.commit()
db.refresh(photo)
return photo
@app.delete("/photos/{photo_id}")
def delete_photo(photo_id: int, db: Session = Depends(get_db)) -> dict:
photo = db.get(Photo, photo_id)
if not photo:
raise HTTPException(status_code=404, detail="Photo introuvable")
# On supprime seulement l'entrée du dossier patient local.
# L'asset Immich n'est pas détruit, pour éviter d'effacer une photo source utilisée ailleurs.
db.delete(photo)
db.commit()
return {"status": "deleted", "id": photo_id, "immich_asset_deleted": False}
@app.post("/patients/{patient_id}/photos", response_model=PhotoOut)
async def upload_patient_photo(
patient_id: int,
kind: str = Form("original"),
notes: str | None = Form(None),
file: UploadFile = File(...),
db: Session = Depends(get_db),
) -> Photo:
patient = db.get(Patient, patient_id)
if not patient:
raise HTTPException(status_code=404, detail="Patient introuvable")
safe_kind = _validate_photo_kind(kind)
if not file.filename:
raise HTTPException(status_code=400, detail="Nom de fichier manquant")
suffix = Path(file.filename).suffix or ".jpg"
with NamedTemporaryFile(delete=False, suffix=suffix) as temp_file:
temp_path = Path(temp_file.name)
temp_file.write(await file.read())
try:
album_name = f"{settings.immich_album_prefix} - {patient.last_name.upper()} {patient.first_name}"
immich = ImmichClient()
album = await immich.get_or_create_album(
album_name,
description=f"Dossier esthétique patient #{patient.id}",
)
album_id = album["id"]
asset = await immich.upload_asset(temp_path, file.filename)
asset_id = asset["id"]
await immich.add_assets_to_album(album_id, [asset_id])
if not patient.immich_album_id:
patient.immich_album_id = album_id
photo = Photo(
patient_id=patient.id,
immich_asset_id=asset_id,
immich_album_id=album_id,
kind=safe_kind,
filename=file.filename,
notes=_clean_text(notes),
)
db.add(photo)
db.commit()
db.refresh(photo)
return photo
finally:
temp_path.unlink(missing_ok=True)
+79
View File
@@ -0,0 +1,79 @@
from datetime import datetime, timezone
from sqlalchemy import DateTime, ForeignKey, Integer, String, Text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
def utc_now() -> datetime:
return datetime.now(timezone.utc)
class Patient(Base):
__tablename__ = "patients"
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
first_name: Mapped[str] = mapped_column(String(120), nullable=False)
last_name: Mapped[str] = mapped_column(String(120), nullable=False)
birth_date: Mapped[str | None] = mapped_column(String(20), nullable=True)
phone: Mapped[str | None] = mapped_column(String(60), nullable=True)
email: Mapped[str | None] = mapped_column(String(180), nullable=True)
procedure: Mapped[str | None] = mapped_column(String(160), nullable=True)
status: Mapped[str | None] = mapped_column(String(40), default="actif")
consent_signed: Mapped[str | None] = mapped_column(String(20), default="non")
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
immich_album_id: Mapped[str | None] = mapped_column(String(80), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
photos: Mapped[list["Photo"]] = relationship(back_populates="patient", cascade="all, delete-orphan")
consultations: Mapped[list["Consultation"]] = relationship(back_populates="patient", cascade="all, delete-orphan")
documents: Mapped[list["Document"]] = relationship(back_populates="patient", cascade="all, delete-orphan")
class Photo(Base):
__tablename__ = "photos"
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
patient_id: Mapped[int] = mapped_column(ForeignKey("patients.id"), nullable=False, index=True)
immich_asset_id: Mapped[str] = mapped_column(String(80), nullable=False, index=True)
immich_album_id: Mapped[str] = mapped_column(String(80), nullable=False)
kind: Mapped[str] = mapped_column(String(40), default="original")
filename: Mapped[str] = mapped_column(String(255), nullable=False)
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
patient: Mapped[Patient] = relationship(back_populates="photos")
class Consultation(Base):
__tablename__ = "consultations"
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
patient_id: Mapped[int] = mapped_column(ForeignKey("patients.id"), nullable=False, index=True)
consultation_date: Mapped[str | None] = mapped_column(String(40), nullable=True)
procedure: Mapped[str | None] = mapped_column(String(160), nullable=True)
reason: Mapped[str | None] = mapped_column(Text, nullable=True)
plan: Mapped[str | None] = mapped_column(Text, nullable=True)
follow_up: Mapped[str | None] = mapped_column(Text, nullable=True)
status: Mapped[str | None] = mapped_column(String(40), default="prévu")
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
patient: Mapped[Patient] = relationship(back_populates="consultations")
class Document(Base):
__tablename__ = "documents"
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
patient_id: Mapped[int] = mapped_column(ForeignKey("patients.id"), nullable=False, index=True)
consultation_id: Mapped[int | None] = mapped_column(ForeignKey("consultations.id"), nullable=True, index=True)
document_type: Mapped[str] = mapped_column(String(60), default="consentement_photo")
title: Mapped[str] = mapped_column(String(220), nullable=False)
status: Mapped[str] = mapped_column(String(40), default="brouillon")
content: Mapped[str | None] = mapped_column(Text, nullable=True)
signed_by: Mapped[str | None] = mapped_column(String(180), nullable=True)
signed_at: Mapped[str | None] = mapped_column(String(40), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
patient: Mapped[Patient] = relationship(back_populates="documents")
+148
View File
@@ -0,0 +1,148 @@
from datetime import datetime
from pydantic import BaseModel, ConfigDict
class PatientCreate(BaseModel):
first_name: str
last_name: str
birth_date: str | None = None
phone: str | None = None
email: str | None = None
procedure: str | None = None
status: str | None = "actif"
consent_signed: str | None = "non"
notes: str | None = None
class PatientUpdate(BaseModel):
first_name: str | None = None
last_name: str | None = None
birth_date: str | None = None
phone: str | None = None
email: str | None = None
procedure: str | None = None
status: str | None = None
consent_signed: str | None = None
notes: str | None = None
class PatientOut(BaseModel):
id: int
first_name: str
last_name: str
birth_date: str | None
phone: str | None = None
email: str | None = None
procedure: str | None = None
status: str | None = None
consent_signed: str | None = None
notes: str | None
immich_album_id: str | None
created_at: datetime
model_config = ConfigDict(from_attributes=True)
class PhotoOut(BaseModel):
id: int
patient_id: int
immich_asset_id: str
immich_album_id: str
kind: str
filename: str
notes: str | None
created_at: datetime
model_config = ConfigDict(from_attributes=True)
class PhotoUpdate(BaseModel):
kind: str | None = None
notes: str | None = None
class ImmichPersonOut(BaseModel):
id: str
name: str | None = None
thumbnail_url: str
is_hidden: bool | None = None
class ImmichAssetOut(BaseModel):
id: str
filename: str | None = None
thumbnail_url: str
file_url: str
taken_at: str | None = None
type: str | None = None
class ConsultationCreate(BaseModel):
consultation_date: str | None = None
procedure: str | None = None
reason: str | None = None
plan: str | None = None
follow_up: str | None = None
status: str | None = "prévu"
notes: str | None = None
class ConsultationUpdate(BaseModel):
consultation_date: str | None = None
procedure: str | None = None
reason: str | None = None
plan: str | None = None
follow_up: str | None = None
status: str | None = None
notes: str | None = None
class ConsultationOut(BaseModel):
id: int
patient_id: int
consultation_date: str | None = None
procedure: str | None = None
reason: str | None = None
plan: str | None = None
follow_up: str | None = None
status: str | None = None
notes: str | None = None
created_at: datetime
model_config = ConfigDict(from_attributes=True)
class DocumentCreate(BaseModel):
consultation_id: int | None = None
document_type: str | None = "consentement_photo"
title: str
status: str | None = "brouillon"
content: str | None = None
signed_by: str | None = None
signed_at: str | None = None
class DocumentUpdate(BaseModel):
consultation_id: int | None = None
document_type: str | None = None
title: str | None = None
status: str | None = None
content: str | None = None
signed_by: str | None = None
signed_at: str | None = None
class DocumentOut(BaseModel):
id: int
patient_id: int
consultation_id: int | None = None
document_type: str
title: str
status: str
content: str | None = None
signed_by: str | None = None
signed_at: str | None = None
created_at: datetime
model_config = ConfigDict(from_attributes=True)
File diff suppressed because it is too large Load Diff
+240
View File
@@ -0,0 +1,240 @@
from __future__ import annotations
from datetime import datetime, timezone
from pathlib import Path
from uuid import uuid4
import mimetypes
import httpx
from fastapi import HTTPException
from app.config import settings
def _api_base_url() -> str:
base = settings.immich_base_url.rstrip("/")
if not base.endswith("/api"):
base = f"{base}/api"
return base
class ImmichClient:
def __init__(self) -> None:
if not settings.immich_api_key:
raise HTTPException(status_code=500, detail="IMMICH_API_KEY manquante dans .env")
self.base_url = _api_base_url()
self.headers = {
"x-api-key": settings.immich_api_key,
"Accept": "application/json",
}
self.timeout = settings.request_timeout_seconds
async def list_albums(self) -> list[dict]:
async with httpx.AsyncClient(timeout=self.timeout) as client:
response = await client.get(f"{self.base_url}/albums", headers=self.headers)
self._raise_for_immich(response)
return response.json()
async def create_album(self, album_name: str, description: str | None = None) -> dict:
payload = {"albumName": album_name}
if description:
payload["description"] = description
async with httpx.AsyncClient(timeout=self.timeout) as client:
response = await client.post(
f"{self.base_url}/albums",
headers={**self.headers, "Content-Type": "application/json"},
json=payload,
)
self._raise_for_immich(response)
return response.json()
async def get_or_create_album(self, album_name: str, description: str | None = None) -> dict:
albums = await self.list_albums()
for album in albums:
if album.get("albumName") == album_name:
return album
return await self.create_album(album_name, description=description)
async def upload_asset(self, file_path: Path, original_filename: str) -> dict:
now = datetime.now(timezone.utc).isoformat()
mime_type = mimetypes.guess_type(original_filename)[0] or "application/octet-stream"
device_asset_id = f"{settings.upload_device_id}-{uuid4()}-{original_filename}"
data = {
"deviceAssetId": device_asset_id,
"deviceId": settings.upload_device_id,
"fileCreatedAt": now,
"fileModifiedAt": now,
"isFavorite": "false",
}
with file_path.open("rb") as file_handle:
files = {"assetData": (original_filename, file_handle, mime_type)}
async with httpx.AsyncClient(timeout=self.timeout) as client:
response = await client.post(
f"{self.base_url}/assets",
headers=self.headers,
data=data,
files=files,
)
self._raise_for_immich(response)
return response.json()
async def add_assets_to_album(self, album_id: str, asset_ids: list[str]) -> list[dict]:
payload = {"ids": asset_ids}
async with httpx.AsyncClient(timeout=self.timeout) as client:
response = await client.put(
f"{self.base_url}/albums/{album_id}/assets",
headers={**self.headers, "Content-Type": "application/json"},
json=payload,
)
self._raise_for_immich(response)
return response.json()
async def get_asset_thumbnail(self, asset_id: str, size: str = "preview") -> tuple[bytes, str]:
async with httpx.AsyncClient(timeout=self.timeout, follow_redirects=True) as client:
response = await client.get(
f"{self.base_url}/assets/{asset_id}/thumbnail",
headers={"x-api-key": settings.immich_api_key, "Accept": "image/*"},
params={"size": size},
)
self._raise_for_immich(response)
media_type = response.headers.get("content-type", "image/jpeg")
return response.content, media_type
async def get_asset_original(self, asset_id: str) -> tuple[bytes, str]:
async with httpx.AsyncClient(timeout=self.timeout, follow_redirects=True) as client:
response = await client.get(
f"{self.base_url}/assets/{asset_id}/original",
headers={"x-api-key": settings.immich_api_key, "Accept": "*/*"},
)
self._raise_for_immich(response)
media_type = response.headers.get("content-type", "application/octet-stream")
return response.content, media_type
async def list_people(self, q: str | None = None, with_hidden: bool = False, limit: int = 100) -> list[dict]:
"""Retourne les personnes reconnues par Immich.
Si q est fourni, on utilise /search/person. Sinon, on parcourt /people
avec pagination. Le parsing est volontairement tolérant car Immich a déjà
retourné soit une liste directe, soit un objet contenant une clé people.
"""
if q and q.strip():
return await self.search_people(q.strip(), with_hidden=with_hidden)
collected: list[dict] = []
page = 1
page_size = min(max(limit, 1), 100)
while len(collected) < limit:
async with httpx.AsyncClient(timeout=self.timeout) as client:
response = await client.get(
f"{self.base_url}/people",
headers=self.headers,
params={"page": page, "size": page_size, "withHidden": str(with_hidden).lower()},
)
self._raise_for_immich(response)
payload = response.json()
people = self._extract_people(payload)
if not people:
break
collected.extend(people)
has_next = bool(payload.get("hasNextPage")) if isinstance(payload, dict) else len(people) >= page_size
if not has_next or len(people) < page_size:
break
page += 1
return collected[:limit]
async def search_people(self, name: str, with_hidden: bool = False) -> list[dict]:
async with httpx.AsyncClient(timeout=self.timeout) as client:
response = await client.get(
f"{self.base_url}/search/person",
headers=self.headers,
params={"name": name, "withHidden": str(with_hidden).lower()},
)
self._raise_for_immich(response)
payload = response.json()
return self._extract_people(payload)
async def get_person(self, person_id: str) -> dict:
async with httpx.AsyncClient(timeout=self.timeout) as client:
response = await client.get(f"{self.base_url}/people/{person_id}", headers=self.headers)
self._raise_for_immich(response)
return response.json()
async def get_person_thumbnail(self, person_id: str) -> tuple[bytes, str]:
async with httpx.AsyncClient(timeout=self.timeout, follow_redirects=True) as client:
response = await client.get(
f"{self.base_url}/people/{person_id}/thumbnail",
headers={"x-api-key": settings.immich_api_key, "Accept": "image/*"},
)
self._raise_for_immich(response)
media_type = response.headers.get("content-type", "image/jpeg")
return response.content, media_type
async def list_person_assets(self, person_id: str, page: int = 1, size: int = 100) -> list[dict]:
"""Retourne les assets complets où Immich a reconnu cette personne.
Immich recommande maintenant /search/metadata pour filtrer les assets,
notamment par personIds, avec pagination.
"""
page = max(page, 1)
size = min(max(size, 1), 250)
payload = {
"personIds": [person_id],
"page": page,
"size": size,
}
async with httpx.AsyncClient(timeout=self.timeout) as client:
response = await client.post(
f"{self.base_url}/search/metadata",
headers={**self.headers, "Content-Type": "application/json"},
json=payload,
)
self._raise_for_immich(response)
return self._extract_assets(response.json())
async def get_asset_info(self, asset_id: str) -> dict:
async with httpx.AsyncClient(timeout=self.timeout) as client:
response = await client.get(f"{self.base_url}/assets/{asset_id}", headers=self.headers)
self._raise_for_immich(response)
return response.json()
@staticmethod
def _extract_assets(payload: object) -> list[dict]:
if isinstance(payload, list):
return [item for item in payload if isinstance(item, dict)]
if isinstance(payload, dict):
assets = payload.get("assets")
if isinstance(assets, list):
return [item for item in assets if isinstance(item, dict)]
if isinstance(assets, dict):
items = assets.get("items")
if isinstance(items, list):
return [item for item in items if isinstance(item, dict)]
for key in ("items", "results", "data"):
value = payload.get(key)
if isinstance(value, list):
return [item for item in value if isinstance(item, dict)]
return []
@staticmethod
def _extract_people(payload: object) -> list[dict]:
if isinstance(payload, list):
return [item for item in payload if isinstance(item, dict)]
if isinstance(payload, dict):
for key in ("people", "items", "results", "data"):
value = payload.get(key)
if isinstance(value, list):
return [item for item in value if isinstance(item, dict)]
return []
@staticmethod
def _raise_for_immich(response: httpx.Response) -> None:
if response.status_code >= 400:
try:
detail = response.json()
except Exception:
detail = response.text
raise HTTPException(status_code=response.status_code, detail={"immich_error": detail})