Initial upload: aesthetic-immich-starter
This commit is contained in:
@@ -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)
|
||||
Reference in New Issue
Block a user