Spaces:
Sleeping
Sleeping
File size: 5,714 Bytes
3c31a2a 732b14f 3c31a2a b76f199 3c31a2a eda3d74 732b14f 3c31a2a 732b14f 3c31a2a eda3d74 3c31a2a eda3d74 3c31a2a eda3d74 b76f199 eda3d74 b76f199 3c31a2a b76f199 732b14f b76f199 3c31a2a b76f199 3c31a2a | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 | """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"
@router.get("/reports/{report_id}/export")
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}"'},
)
|