Spaces:
Sleeping
Sleeping
| """Report export endpoint — download the assembled report as a DOCX file. | |
| GET /reports/{report_id}/export?format=docx | |
| → streams a professional RICS-formatted ``.docx`` file. | |
| The endpoint reuses the exact same section-loading logic as | |
| GET /reports/{report_id}/sections so the exported document always matches | |
| what the user sees on screen. | |
| """ | |
| from __future__ import annotations | |
| import json | |
| import logging | |
| from fastapi import APIRouter, Depends, HTTPException, Query, Request | |
| from fastapi.responses import Response | |
| from sqlalchemy import select | |
| from sqlalchemy.ext.asyncio import AsyncSession | |
| from app.api.rate_limit import check_read | |
| from app.db.database import get_db | |
| from app.db.models import Report, ReportSection, ReportSectionPhoto | |
| from app.generator.docx_builder import build_docx | |
| from app.models.schemas import Provenance, SectionPayload, WritingStyleProfile | |
| from app.storage.photo_paths import resolve_report_photo_path | |
| logger = logging.getLogger(__name__) | |
| router = APIRouter() | |
| _DOCX_MIME = "application/vnd.openxmlformats-officedocument.wordprocessingml.document" | |
| async def export_report( | |
| report_id: str, | |
| request: Request, | |
| fmt: str = Query(default="docx", alias="format", pattern="^docx$"), | |
| db: AsyncSession = Depends(get_db), | |
| _: None = Depends(check_read), | |
| ) -> Response: | |
| """Stream the assembled report as a downloadable DOCX file. | |
| All sections are fetched from the database and rendered into a | |
| professional RICS-styled document. Sections that have not been | |
| generated yet are replaced with a placeholder paragraph. | |
| Args: | |
| report_id: UUID of the report to export. | |
| request: Provides ``state.tenant_id``. | |
| fmt: Output format — currently only ``docx`` is supported. | |
| db: Injected async database session. | |
| Returns: | |
| A binary ``Response`` with DOCX content and appropriate | |
| ``Content-Disposition`` header for browser downloads. | |
| Raises: | |
| HTTPException 404: Report not found or belongs to a different tenant. | |
| HTTPException 400: Unsupported export format (only ``docx`` accepted). | |
| """ | |
| 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") | |
| # Load all sections from the database | |
| result = await db.execute( | |
| select(ReportSection).where(ReportSection.report_id == report_id) | |
| ) | |
| sections_orm = result.scalars().all() | |
| sections: dict[str, SectionPayload] = {} | |
| for s in sections_orm: | |
| raw = json.loads(s.provenance or "[]") | |
| if isinstance(raw, dict): | |
| sources = raw.get("sources", []) | |
| meta = raw.get("meta", {}) | |
| else: | |
| sources = raw | |
| meta = {} | |
| style_profile: WritingStyleProfile | None = None | |
| raw_sp = meta.get("style_profile") | |
| if isinstance(raw_sp, dict): | |
| try: | |
| style_profile = WritingStyleProfile(**raw_sp) | |
| except Exception: | |
| style_profile = None | |
| prov_list: list[Provenance] = [] | |
| for p in sources: | |
| if not isinstance(p, dict) or "doc_id" not in p or "chunk_id" not in p or "score" not in p: | |
| continue | |
| try: | |
| prov_list.append(Provenance.model_validate(p)) | |
| except Exception: # noqa: BLE001 | |
| continue | |
| sections[s.section_code] = SectionPayload( | |
| text=s.text, | |
| confidence=s.confidence, | |
| provenance=prov_list, | |
| cached=bool(s.cached), | |
| mode=meta.get("mode", "generate"), | |
| style_profile=style_profile, | |
| ai_level=meta.get("ai_level"), | |
| ai_percent=meta.get("ai_percent"), | |
| ai_transparency=meta.get("ai_transparency") | |
| if isinstance(meta.get("ai_transparency"), dict) | |
| else None, | |
| pipeline=meta.get("pipeline"), | |
| fallback_used=meta.get("fallback_used"), | |
| inspector=meta.get("inspector") if isinstance(meta.get("inspector"), dict) else None, | |
| ) | |
| if not sections: | |
| raise HTTPException( | |
| status_code=422, | |
| detail="No sections have been generated yet. Generate at least one section before exporting.", | |
| ) | |
| photos_res = await db.execute( | |
| select(ReportSectionPhoto).where( | |
| ReportSectionPhoto.tenant_id == tenant_id, | |
| ReportSectionPhoto.report_id == report_id, | |
| ) | |
| ) | |
| photo_rows = photos_res.scalars().all() | |
| photo_paths_by_section: dict[str, list[str]] = {} | |
| for p in photo_rows: | |
| resolved = resolve_report_photo_path( | |
| p.file_path, | |
| tenant_id=tenant_id, | |
| report_id=report_id, | |
| section_code=p.section_code, | |
| photo_id=p.id, | |
| ) | |
| if resolved: | |
| photo_paths_by_section.setdefault(p.section_code, []).append(resolved) | |
| logger.info( | |
| "Exporting report=%s as %s for tenant=%s (%d sections)", | |
| report_id, | |
| fmt, | |
| tenant_id, | |
| len(sections), | |
| ) | |
| docx_bytes = build_docx( | |
| sections=sections, | |
| report_id=report_id, | |
| tenant_id=tenant_id, | |
| survey_level=report.survey_level, | |
| section_photo_paths=photo_paths_by_section, | |
| ) | |
| safe_report_id = report_id.replace("-", "")[:12] | |
| filename = f"RICS_Survey_Report_{safe_report_id}.docx" | |
| return Response( | |
| content=docx_bytes, | |
| media_type=_DOCX_MIME, | |
| headers={"Content-Disposition": f'attachment; filename="{filename}"'}, | |
| ) | |