Spaces:
Sleeping
Sleeping
| """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 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.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 _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), | |
| "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_id": pid, | |
| "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/{pid}", | |
| } | |
| ) | |
| await db.commit() | |
| from app.services.photo_vision import ( | |
| invalidate_section_photo_vision_cache, | |
| schedule_section_photo_vision_analysis, | |
| ) | |
| invalidate_section_photo_vision_cache(tenant_id, str(report_id), section_code) | |
| schedule_section_photo_vision_analysis( | |
| tenant_id=tenant_id, | |
| report_id=str(report_id), | |
| section_code=section_code, | |
| ) | |
| return { | |
| "report_id": report_id, | |
| "section_code": section_code, | |
| "saved": saved, | |
| "vision_analysis_scheduled": bool( | |
| settings.section_photo_analyze_on_upload | |
| and settings.section_photo_vision_enabled | |
| and (settings.openai_api_key or "").strip() | |
| ), | |
| } | |
| 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() | |
| return { | |
| "report_id": report_id, | |
| "section_code": section_code, | |
| "photos": [ | |
| { | |
| "photo_id": r.id, | |
| "original_filename": r.original_filename, | |
| "content_type": r.content_type, | |
| "created_at": r.created_at.isoformat(), | |
| "url": f"/reports/{report_id}/sections/{section_code}/photos/{r.id}", | |
| } | |
| 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) | |