Spaces:
Runtime error
Runtime error
| """Photo policy and per-section report photo upload endpoints.""" | |
| from __future__ import annotations | |
| import logging | |
| import uuid | |
| from pathlib import Path | |
| from fastapi import APIRouter, Depends, File, HTTPException, Request, UploadFile | |
| from fastapi.responses import FileResponse | |
| from sqlalchemy import delete, func, select | |
| from sqlalchemy.ext.asyncio import AsyncSession | |
| from app.api.rate_limit import check_read | |
| from app.config import settings | |
| from app.db.database import get_db | |
| from app.db.models import Report, ReportSectionPhoto | |
| from app.models.schemas import SectionPhotoAiSelectionRequest | |
| from app.services.photo_policy import get_photo_policy_for_tenant_async, photo_policy_configuration_incomplete | |
| from app.storage.photo_paths import report_photo_relpath, resolve_report_photo_path | |
| from app.templates.registry import ALL_VALID_SECTION_CODES | |
| logger = logging.getLogger(__name__) | |
| router = APIRouter() | |
| def _validate_section_code(code: str) -> str: | |
| c = (code or "").strip() | |
| if not c: | |
| raise HTTPException(status_code=422, detail="section_code is required") | |
| if c not in ALL_VALID_SECTION_CODES: | |
| raise HTTPException(status_code=422, detail=f"Unknown section_code '{c}'") | |
| return c | |
| def _report_photo_dir(tenant_id: str, report_id: str, section_code: str) -> Path: | |
| return settings.upload_dir / tenant_id / "report_photos" / str(report_id) / str(section_code) | |
| def _photo_item_dict(report_id: str, section_code: str, row: ReportSectionPhoto) -> dict: | |
| return { | |
| "photo_id": row.id, | |
| "original_filename": row.original_filename, | |
| "content_type": row.content_type, | |
| "created_at": row.created_at.isoformat() if row.created_at else "", | |
| "url": f"/reports/{report_id}/sections/{section_code}/photos/{row.id}", | |
| "selected_for_ai": bool(getattr(row, "selected_for_ai", False)), | |
| } | |
| def _validate_content_type(ct: str | None) -> str: | |
| content_type = (ct or "").strip().lower() | |
| if content_type not in ("image/jpeg", "image/png", "image/webp"): | |
| raise HTTPException(status_code=415, detail="Only JPEG, PNG, or WEBP images are supported.") | |
| return content_type | |
| async def get_report_photo_policy( | |
| report_id: str, | |
| request: Request, | |
| db: AsyncSession = Depends(get_db), | |
| _: None = Depends(check_read), | |
| ) -> dict: | |
| tenant_id: str = request.state.tenant_id | |
| report = await db.get(Report, report_id) | |
| if report is None or report.tenant_id != tenant_id: | |
| raise HTTPException(status_code=404, detail="Report not found") | |
| items, stats = await get_photo_policy_for_tenant_async(db, tenant_id, report.survey_level) | |
| return { | |
| "report_id": report_id, | |
| "survey_level": report.survey_level or 3, | |
| "indexed_upload_count": stats.files_total, | |
| "policy_configuration_required": photo_policy_configuration_incomplete(stats, items), | |
| "photo_limits": { | |
| "max_photos_per_section": int(settings.max_section_photos_per_section), | |
| "max_photos_for_ai": int(settings.max_section_photos_for_ai), | |
| }, | |
| "sections": [ | |
| {"code": x.code, "policy": x.policy.value, "reason": x.reason, "source": x.source} | |
| for x in items | |
| ], | |
| } | |
| async def upload_section_photos( | |
| report_id: str, | |
| section_code: str, | |
| request: Request, | |
| files: list[UploadFile] = File(...), | |
| db: AsyncSession = Depends(get_db), | |
| ) -> dict: | |
| tenant_id: str = request.state.tenant_id | |
| section_code = _validate_section_code(section_code) | |
| report = await db.get(Report, report_id) | |
| if report is None or report.tenant_id != tenant_id: | |
| raise HTTPException(status_code=404, detail="Report not found") | |
| if not files: | |
| raise HTTPException(status_code=422, detail="No files uploaded") | |
| # Enforce per-section cap (existing + new) | |
| existing_cnt = await db.execute( | |
| select(func.count()) | |
| .select_from(ReportSectionPhoto) | |
| .where( | |
| ReportSectionPhoto.tenant_id == tenant_id, | |
| ReportSectionPhoto.report_id == report_id, | |
| ReportSectionPhoto.section_code == section_code, | |
| ) | |
| ) | |
| existing = int(existing_cnt.scalar_one() or 0) | |
| max_per = int(settings.max_section_photos_per_section) | |
| if max_per >= 0 and existing + len(files) > max_per: | |
| raise HTTPException( | |
| status_code=422, | |
| detail=f"Too many photos for section {section_code}. Max is {max_per}.", | |
| ) | |
| saved: list[dict] = [] | |
| for uf in files: | |
| content_type = _validate_content_type(uf.content_type) | |
| raw = await uf.read() | |
| if len(raw) > int(settings.max_section_photo_bytes): | |
| raise HTTPException(status_code=413, detail="Photo exceeds max_section_photo_bytes limit.") | |
| ext = "jpg" if content_type == "image/jpeg" else "png" if content_type == "image/png" else "webp" | |
| pid = str(uuid.uuid4()) | |
| rel_path = report_photo_relpath(tenant_id, str(report_id), section_code, pid, ext) | |
| dest_path = settings.upload_dir / rel_path | |
| dest_path.parent.mkdir(parents=True, exist_ok=True) | |
| dest_path.write_bytes(raw) | |
| row = ReportSectionPhoto( | |
| id=pid, | |
| tenant_id=tenant_id, | |
| report_id=str(report_id), | |
| section_code=section_code, | |
| file_path=rel_path, | |
| original_filename=uf.filename or "upload", | |
| content_type=content_type, | |
| ) | |
| db.add(row) | |
| await db.flush() | |
| saved.append(_photo_item_dict(str(report_id), section_code, row)) | |
| await db.commit() | |
| return {"report_id": report_id, "section_code": section_code, "saved": saved} | |
| async def list_section_photos( | |
| report_id: str, | |
| section_code: str, | |
| request: Request, | |
| db: AsyncSession = Depends(get_db), | |
| _: None = Depends(check_read), | |
| ) -> dict: | |
| tenant_id: str = request.state.tenant_id | |
| section_code = _validate_section_code(section_code) | |
| report = await db.get(Report, report_id) | |
| if report is None or report.tenant_id != tenant_id: | |
| raise HTTPException(status_code=404, detail="Report not found") | |
| res = await db.execute( | |
| select(ReportSectionPhoto) | |
| .where( | |
| ReportSectionPhoto.tenant_id == tenant_id, | |
| ReportSectionPhoto.report_id == report_id, | |
| ReportSectionPhoto.section_code == section_code, | |
| ) | |
| .order_by(ReportSectionPhoto.created_at.asc()) | |
| ) | |
| rows = res.scalars().all() | |
| max_ai = int(settings.max_section_photos_for_ai) | |
| selected_count = sum(1 for r in rows if getattr(r, "selected_for_ai", False)) | |
| return { | |
| "report_id": report_id, | |
| "section_code": section_code, | |
| "max_photos_per_section": int(settings.max_section_photos_per_section), | |
| "max_photos_for_ai": max_ai, | |
| "selected_for_ai_count": selected_count, | |
| "photos": [_photo_item_dict(str(report_id), section_code, r) for r in rows], | |
| } | |
| async def set_section_photo_ai_selection( | |
| report_id: str, | |
| section_code: str, | |
| body: SectionPhotoAiSelectionRequest, | |
| request: Request, | |
| db: AsyncSession = Depends(get_db), | |
| ) -> dict: | |
| """Mark up to ``max_section_photos_for_ai`` photos for AI analysis on generation.""" | |
| tenant_id: str = request.state.tenant_id | |
| section_code = _validate_section_code(section_code) | |
| report = await db.get(Report, report_id) | |
| if report is None or report.tenant_id != tenant_id: | |
| raise HTTPException(status_code=404, detail="Report not found") | |
| max_ai = int(settings.max_section_photos_for_ai) | |
| requested = [str(pid).strip() for pid in (body.photo_ids or []) if str(pid).strip()] | |
| if len(requested) > max_ai: | |
| raise HTTPException( | |
| status_code=422, | |
| detail=f"At most {max_ai} photo(s) may be selected for AI analysis per section.", | |
| ) | |
| if len(requested) != len(set(requested)): | |
| raise HTTPException(status_code=422, detail="Duplicate photo_ids in selection.") | |
| res = await db.execute( | |
| select(ReportSectionPhoto).where( | |
| ReportSectionPhoto.tenant_id == tenant_id, | |
| ReportSectionPhoto.report_id == report_id, | |
| ReportSectionPhoto.section_code == section_code, | |
| ) | |
| ) | |
| rows = list(res.scalars().all()) | |
| by_id = {r.id: r for r in rows} | |
| missing = [pid for pid in requested if pid not in by_id] | |
| if missing: | |
| raise HTTPException( | |
| status_code=422, | |
| detail=f"Unknown photo_id(s) for this section: {', '.join(missing[:5])}", | |
| ) | |
| selected_set = set(requested) | |
| for row in rows: | |
| row.selected_for_ai = row.id in selected_set | |
| await db.commit() | |
| return { | |
| "report_id": report_id, | |
| "section_code": section_code, | |
| "selected_photo_ids": requested, | |
| "selected_for_ai_count": len(requested), | |
| "max_photos_for_ai": max_ai, | |
| "photos": [_photo_item_dict(str(report_id), section_code, r) for r in rows], | |
| } | |
| async def get_section_photo( | |
| report_id: str, | |
| section_code: str, | |
| photo_id: str, | |
| request: Request, | |
| db: AsyncSession = Depends(get_db), | |
| _: None = Depends(check_read), | |
| ) -> FileResponse: | |
| tenant_id: str = request.state.tenant_id | |
| section_code = _validate_section_code(section_code) | |
| report = await db.get(Report, report_id) | |
| if report is None or report.tenant_id != tenant_id: | |
| raise HTTPException(status_code=404, detail="Report not found") | |
| row = await db.get(ReportSectionPhoto, photo_id) | |
| if row is None or row.tenant_id != tenant_id or row.report_id != report_id or row.section_code != section_code: | |
| raise HTTPException(status_code=404, detail="Photo not found") | |
| resolved = resolve_report_photo_path( | |
| row.file_path, | |
| tenant_id=tenant_id, | |
| report_id=str(report_id), | |
| section_code=section_code, | |
| photo_id=photo_id, | |
| ) | |
| if resolved is None: | |
| raise HTTPException(status_code=404, detail="Photo file missing on disk") | |
| return FileResponse(path=resolved, media_type=row.content_type) | |
| async def delete_section_photo( | |
| report_id: str, | |
| section_code: str, | |
| photo_id: str, | |
| request: Request, | |
| db: AsyncSession = Depends(get_db), | |
| ) -> dict: | |
| tenant_id: str = request.state.tenant_id | |
| section_code = _validate_section_code(section_code) | |
| report = await db.get(Report, report_id) | |
| if report is None or report.tenant_id != tenant_id: | |
| raise HTTPException(status_code=404, detail="Report not found") | |
| row = await db.get(ReportSectionPhoto, photo_id) | |
| if row is None or row.tenant_id != tenant_id or row.report_id != report_id or row.section_code != section_code: | |
| raise HTTPException(status_code=404, detail="Photo not found") | |
| resolved = resolve_report_photo_path( | |
| row.file_path, | |
| tenant_id=tenant_id, | |
| report_id=str(report_id), | |
| section_code=section_code, | |
| photo_id=photo_id, | |
| ) | |
| file_removed = False | |
| if resolved is not None: | |
| try: | |
| Path(resolved).unlink(missing_ok=True) | |
| file_removed = True | |
| except OSError as exc: | |
| logger.warning("Could not delete photo file %s: %s", resolved, exc) | |
| await db.execute(delete(ReportSectionPhoto).where(ReportSectionPhoto.id == photo_id)) | |
| await db.commit() | |
| return { | |
| "report_id": report_id, | |
| "section_code": section_code, | |
| "photo_id": photo_id, | |
| "removed": True, | |
| "file_removed": file_removed, | |
| } | |