diff --git a/app/api/router.py b/app/api/router.py index 54415c18..4521b125 100644 --- a/app/api/router.py +++ b/app/api/router.py @@ -1,11 +1,28 @@ from fastapi import APIRouter -from app.api.routes import extraction, forms, form_templates, input, jobs, system, weather, zipcode +from app.api.routes import ( + extraction, + form_generation, + forms, + form_templates, + input, + jobs, + system, + weather, + zipcode, +) from app.core.config import API_PREFIX api_router = APIRouter() api_router.include_router(form_templates.router, prefix=API_PREFIX) api_router.include_router(forms.router, prefix=API_PREFIX) +# v1 form generation — same "/forms" prefix as the legacy router above, kept +# in a separate file/router rather than added to forms.py. Included AFTER +# `forms` on purpose: the legacy router's literal GET paths (/forms/models, +# /forms/submissions, ...) must be matched before this router's catch-all +# GET /forms/{form_id}, same reasoning form_templates.py uses for /pdf vs +# /{template_id} — otherwise "models"/"submissions" would be read as a form_id. +api_router.include_router(form_generation.router, prefix=API_PREFIX) api_router.include_router(system.router, prefix=API_PREFIX) api_router.include_router(jobs.router, prefix=API_PREFIX) api_router.include_router(weather.router, prefix=API_PREFIX) diff --git a/app/api/routes/form_generation.py b/app/api/routes/form_generation.py new file mode 100644 index 00000000..2a4a956e --- /dev/null +++ b/app/api/routes/form_generation.py @@ -0,0 +1,176 @@ +"""Contract Layer 3 form generation endpoints (contracts/path/forms.yaml). + +Serves POST /forms/generate and the retrieval endpoints at /api/v1/forms, +backed by the v1 Form model. Handlers are thin; business logic lives in +app/services/form_generation.py (write path) and app/services/form_fill_worker.py +(the Celery-dispatched fill). Distinct from the legacy prototype routes in +app/api/routes/forms.py (int template_id, no incident/batch concept), which +stay mounted at the same "/forms" prefix unchanged. + +/batch/{batch_id} is declared before /{form_id} for the same reason +form_templates.py declares /pdf before /{template_id}: FastAPI matches paths +in declaration order, so the literal segment has to come first or "batch" +gets read as a form_id. +""" + +from uuid import UUID + +from fastapi import APIRouter, Depends +from fastapi.responses import FileResponse, JSONResponse +from sqlmodel import Session + +from app.api.deps import get_db +from app.api.schemas.enums import FormStatus +from app.api.schemas.form_generation import ( + BatchFormEntry, + BatchGenerateResponse, + BatchStatus, + FormMappedJson, + FormRecord, + GenerateFormsRequest, + QueuedForm, + SkippedForm, +) +from app.core.config import ( + DATA_DIR, + ESTIMATED_FORM_GENERATION_SECONDS, + FORM_GENERATION_POLL_INTERVAL_SECONDS, +) +from app.core.errors.base import AppError +from app.db.repositories import get_form, list_forms_by_batch +from app.services.form_generation import FormGenerationService + +router = APIRouter(prefix="/forms", tags=["forms"]) + + +@router.post("/generate", response_model=BatchGenerateResponse, status_code=202) +def generate_forms(body: GenerateFormsRequest, db: Session = Depends(get_db)): + result = FormGenerationService().start_generation(db, body) + return BatchGenerateResponse( + batch_id=result.batch_id, + incident_id=result.incident_id, + forms_queued=[ + QueuedForm(form_id=f.form_id, template_id=f.template_id, form_type=f.form_type) + for f in result.queued + ], + forms_skipped=[ + SkippedForm(template_id=s.template_id, form_type=s.form_type, reason=s.reason) + for s in result.skipped + ], + estimated_seconds=ESTIMATED_FORM_GENERATION_SECONDS, + poll_url=f"/api/v1/forms/batch/{result.batch_id}", + ) + + +@router.get("/batch/{batch_id}", response_model=BatchStatus) +def get_batch_status(batch_id: UUID, db: Session = Depends(get_db)): + forms = list_forms_by_batch(db, batch_id) + if not forms: + raise AppError(f"Batch {batch_id} not found", status_code=404, error_code="BATCH_NOT_FOUND") + + completed = sum(1 for f in forms if f.status == FormStatus.completed) + failed = sum(1 for f in forms if f.status == FormStatus.failed) + total = len(forms) + done = completed + failed + if done < total: + status = "processing" + elif failed == total: + status = "failed" + else: + # Per design, a per-form failure doesn't fail the batch: the Job (and + # this status) reads "completed" as long as every form reached a + # terminal state and at least one succeeded — the forms list below + # still shows exactly which ones failed. + status = "completed" + + return BatchStatus( + batch_id=batch_id, + status=status, + total=total, + completed=completed, + failed=failed, + forms=[ + BatchFormEntry( + form_id=f.form_id, + template_id=f.template_id, + form_type=f.form_type, + status=f.status, + ) + for f in forms + ], + download_url=None, + ) + + +@router.get("/{form_id}", response_model=FormRecord) +def get_form_record(form_id: UUID, db: Session = Depends(get_db)): + form = get_form(db, form_id) + if not form: + raise AppError(f"Form {form_id} not found", status_code=404, error_code="FORM_NOT_FOUND") + + return FormRecord( + form_id=form.form_id, + template_id=form.template_id, + form_type=form.form_type, + status=form.status, + incident_id=form.incident_id, + batch_id=form.batch_id, + created_at=form.created_at, + completed_at=form.completed_at, + pdf_ready=form.pdf_ready, + json_ready=form.json_ready, + field_mapping_summary=form.field_mapping_summary, + ) + + +@router.get("/{form_id}/pdf", response_class=FileResponse) +def download_form_pdf(form_id: UUID, db: Session = Depends(get_db)): + form = get_form(db, form_id) + if not form: + raise AppError(f"Form {form_id} not found", status_code=404, error_code="FORM_NOT_FOUND") + + if form.status == FormStatus.failed: + raise AppError( + f"Form {form_id} failed to generate", + status_code=500, + error_code="PDF_GENERATION_FAILED", + detail={"reason": "Form generation failed"}, + ) + + if not form.pdf_ready or not form.pdf_path: + return JSONResponse( + status_code=202, + content={ + "message": "Form generation is still in progress", + "status": form.status, + "retry_after_seconds": FORM_GENERATION_POLL_INTERVAL_SECONDS, + }, + ) + + path = (DATA_DIR / form.pdf_path).resolve() + if not path.is_relative_to(DATA_DIR) or not path.is_file(): + raise AppError(f"Form {form_id} not found", status_code=404, error_code="FORM_NOT_FOUND") + + return FileResponse(path, media_type="application/pdf", filename=path.name) + + +@router.get("/{form_id}/json", response_model=FormMappedJson) +def get_form_json(form_id: UUID, db: Session = Depends(get_db)): + form = get_form(db, form_id) + if not form: + raise AppError(f"Form {form_id} not found", status_code=404, error_code="FORM_NOT_FOUND") + + if not form.json_ready or form.json_data is None: + raise AppError( + f"Form {form_id} has no JSON output yet", + status_code=404, + error_code="FORM_JSON_NOT_READY", + ) + + return FormMappedJson( + form_type=form.form_type, + form_id=form.form_id, + template_id=form.template_id, + incident_id=form.incident_id, + agency_fields=form.json_data, + ) diff --git a/app/api/schemas/form_generation.py b/app/api/schemas/form_generation.py new file mode 100644 index 00000000..6aac81f5 --- /dev/null +++ b/app/api/schemas/form_generation.py @@ -0,0 +1,137 @@ +"""Contract Layer 3 form generation schemas (contracts/schemas/form-record.yaml). + +Separate from app/api/schemas/forms.py, which holds the legacy prototype +fill-pipeline shapes (int template_id, no incident/batch concept) still served +by the old routes in app/api/routes/forms.py. This file is the v1 contract +shape only — mirrors the extraction.py / templates.py split, one file per +contract domain. +""" + +from __future__ import annotations + +from datetime import datetime +from typing import Literal +from uuid import UUID + +from pydantic import BaseModel, Field + +from app.api.schemas.enums import FormStatus, OutputFormat + + +# --------------------------------------------------------------------------- +# Request +# --------------------------------------------------------------------------- + +class GenerateFormsOptions(BaseModel): + output_format: OutputFormat | None = None + force_partial: bool = False + + +class GenerateFormsRequest(BaseModel): + """POST /forms/generate body. + + template_ids is required in this build: omitting it (generate every + template the readiness matrix reports as ready) is #554, not built here. + """ + + incident_id: UUID + template_ids: list[UUID] = Field(min_length=1) + options: GenerateFormsOptions | None = None + + +# --------------------------------------------------------------------------- +# Responses +# --------------------------------------------------------------------------- + +class QueuedForm(BaseModel): + form_id: UUID + template_id: UUID + form_type: str + + +class SkippedForm(BaseModel): + template_id: UUID + form_type: str + reason: str + + +class BatchGenerateResponse(BaseModel): + """202 body for POST /forms/generate.""" + + batch_id: UUID + status: Literal["processing"] = "processing" + incident_id: UUID + forms_queued: list[QueuedForm] = Field(default_factory=list) + forms_skipped: list[SkippedForm] = Field(default_factory=list) + estimated_seconds: int | None = None + poll_url: str + + +class FieldMappingSummary(BaseModel): + total_form_fields: int + fields_filled: int + fields_blank: int + coverage_percent: float + + +class FormRecord(BaseModel): + """GET /forms/{form_id} response.""" + + form_id: UUID + template_id: UUID + # form_type is an open string on the wire: registries can add form types + # the closed FormType enum does not know about yet (see FormTemplate.form_type). + form_type: str + status: FormStatus + incident_id: UUID + batch_id: UUID | None = None + created_at: datetime + completed_at: datetime | None = None + pdf_ready: bool + json_ready: bool + field_mapping_summary: FieldMappingSummary | None = None + + +class FormMappedJson(BaseModel): + """GET /forms/{form_id}/json response.""" + + form_type: str + form_id: UUID + template_id: UUID + incident_id: UUID + agency_fields: dict = Field(default_factory=dict) + + +class BatchFormEntry(BaseModel): + form_id: UUID + template_id: UUID + form_type: str + status: FormStatus + + +class BatchStatus(BaseModel): + """GET /forms/batch/{batch_id} response, derived on the fly from the + batch's Form rows — there is no Batch table.""" + + batch_id: UUID + status: Literal["processing", "completed", "failed"] + total: int + completed: int + failed: int + forms: list[BatchFormEntry] = Field(default_factory=list) + # Zip bundling of a batch's PDFs is #554; always null here. + download_url: str | None = None + + +__all__ = [ + "GenerateFormsOptions", + "GenerateFormsRequest", + "QueuedForm", + "SkippedForm", + "BatchGenerateResponse", + "FieldMappingSummary", + "FormRecord", + "FormMappedJson", + "BatchFormEntry", + "BatchStatus", +] diff --git a/app/core/celery.py b/app/core/celery.py index 04b0329d..b498e8e7 100644 --- a/app/core/celery.py +++ b/app/core/celery.py @@ -43,6 +43,7 @@ def _check_llm_config(**_kwargs): "app.tasks.transcribe", "app.tasks.extract", "app.tasks.detect_fields", + "app.tasks.generate_forms", ] # Optional Celery Beat schedule — runs purge_old_submissions once a day. diff --git a/app/core/config.py b/app/core/config.py index e956cc6d..9bfb7c72 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -181,4 +181,17 @@ "ogg": "audio/ogg", "webm": "audio/webm", } -ALLOWED_AUDIO_EXTENSIONS: frozenset[str] = frozenset(AUDIO_CONTENT_TYPES) \ No newline at end of file +ALLOWED_AUDIO_EXTENSIONS: frozenset[str] = frozenset(AUDIO_CONTENT_TYPES) + +# --- Generated form storage ------------------------------------------------- +# Filled form PDFs land here: {FORMS_OUTPUT_DIR}/{form_id}.pdf. Form.pdf_path +# stores this DATA_DIR-relative, same convention as FormTemplate.pdf_template_ref. +FORMS_OUTPUT_DIR = DATA_DIR / "forms" / "generated" + +# Advisory estimate returned in the 202 body of POST /forms/generate. Filling +# is pure lookup-and-draw (no LLM), so this is far below the extraction estimate. +ESTIMATED_FORM_GENERATION_SECONDS = int(os.getenv("ESTIMATED_FORM_GENERATION_SECONDS", "10")) + +# Polling hint returned by GET /forms/{id}/pdf while generation is still in +# progress. Matches the contract example (contracts/path/forms.yaml). +FORM_GENERATION_POLL_INTERVAL_SECONDS = 5 \ No newline at end of file diff --git a/app/services/form_fill_worker.py b/app/services/form_fill_worker.py new file mode 100644 index 00000000..59bb248f --- /dev/null +++ b/app/services/form_fill_worker.py @@ -0,0 +1,232 @@ +"""Batch form-fill worker. + +Fills every queued Form in a batch: resolve each template field's value from +the incident contract (extraction_readiness.resolve), draw the placed ones +onto a ReportLab overlay at their TemplateFieldLayout coordinates, merge that +overlay onto the template PDF with pypdf, and save the result. Each form is +independently try/excepted — one bad form marks that form failed and moves +on, it never sinks the batch or the job. + +Mirrors app/services/extraction/worker.py's shape (a plain function taking a +session, called by the thin Celery task in app/tasks/generate_forms.py). + +Not to be confused with the legacy app/services/filler.py, which fills +AcroForm widgets by name in visual order — this draws free text at explicit +layout coordinates onto a flat PDF and merges the overlay on top. Layout +coordinates are bottom-left-origin PDF points, the same space ReportLab's +canvas uses natively, so no flip is applied. +""" + +from __future__ import annotations + +from collections import defaultdict +from datetime import datetime, timezone +from io import BytesIO +from pathlib import Path +from uuid import UUID + +from pypdf import PdfReader, PdfWriter +from reportlab.lib.colors import HexColor +from reportlab.pdfbase.pdfmetrics import stringWidth +from reportlab.pdfgen import canvas +from sqlmodel import Session + +from app.api.schemas.enums import FormStatus, TextAlign +from app.api.schemas.templates import TemplateField +from app.core.config import DATA_DIR, FORMS_OUTPUT_DIR +from app.core.logging import get_logger +from app.db.repositories import ( + get_form_template, + get_incident, + get_job_by_uuid, + list_forms_by_batch, + update_form, + update_job, +) +from app.models import Form, FormTemplate +from app.services.extraction_readiness import gaps_for, resolve +from app.services.form_templates import resolve_template_pdf + +logger = get_logger(__name__) + + +def _now() -> datetime: + return datetime.now(timezone.utc) + + +def _format_value(value) -> str | None: + """A drawable string for a resolved field value, or None to skip drawing.""" + if value is None: + return None + if isinstance(value, bool): + return "Yes" if value else "No" + if isinstance(value, (dict, list)): + # A composite contract value has no single sane rendering on a form box. + return None + text = str(value).strip() + return text or None + + +def _fit_text(text: str, font: str, size: float, max_width: float) -> str: + """Truncate with an ellipsis if the text is wider than its box.""" + if stringWidth(text, font, size) <= max_width: + return text + while text and stringWidth(f"{text}…", font, size) > max_width: + text = text[:-1] + return f"{text}…" if text else "" + + +def _draw_field(c: canvas.Canvas, field: TemplateField, value: object) -> None: + layout = field.layout + text = _format_value(value) + if not text: + return + + c.setFont(layout.font, layout.font_size) + c.setFillColor(HexColor(layout.color)) + text = _fit_text(text, layout.font, layout.font_size, layout.width) + + if layout.align == TextAlign.center: + c.drawCentredString(layout.x + layout.width / 2, layout.y, text) + elif layout.align == TextAlign.right: + c.drawRightString(layout.x + layout.width, layout.y, text) + else: + c.drawString(layout.x, layout.y, text) + + +def _build_overlay( + template_pdf_path: Path, fields: list[TemplateField], contract: dict, form_type: str +) -> PdfReader: + """One ReportLab page per template page, sized to match, with each placed + field's resolved value drawn at its layout coordinates.""" + template_reader = PdfReader(str(template_pdf_path)) + page_count = len(template_reader.pages) + + by_page: dict[int, list[TemplateField]] = defaultdict(list) + for field in fields: + if field.layout is not None and 0 <= field.layout.page < page_count: + by_page[field.layout.page].append(field) + + buf = BytesIO() + c = canvas.Canvas(buf) + for page_index in range(page_count): + box = template_reader.pages[page_index].mediabox + c.setPageSize((float(box.width), float(box.height))) + for field in by_page.get(page_index, []): + _draw_field(c, field, resolve(contract, field, form_type)) + # showPage() advances to a fresh page — only between pages, never + # after the last one, or save() would emit a trailing blank page. + if page_index < page_count - 1: + c.showPage() + c.save() + buf.seek(0) + return PdfReader(buf) + + +def _merge_overlay(template_pdf_path: Path, overlay: PdfReader) -> PdfWriter: + """Merge the overlay onto a writer already cloned from the template. + + Merging happens on pages already attached to the writer (via + clone_from), not on bare PdfReader pages added afterward — pypdf + deprecated merge-then-add in favor of this order. + """ + writer = PdfWriter(clone_from=str(template_pdf_path)) + for index, page in enumerate(writer.pages): + if index < len(overlay.pages): + page.merge_page(overlay.pages[index], over=True) + return writer + + +def _summary(contract: dict, template: FormTemplate) -> dict: + gaps = gaps_for(contract, template) + total = len(template.fields or []) + blank = len(gaps.missing_required) + len(gaps.missing_recommended) + return { + "total_form_fields": total, + "fields_filled": total - blank, + "fields_blank": blank, + "coverage_percent": gaps.coverage_percent, + } + + +def fill_one(session: Session, form: Form) -> None: + """Fill a single queued form. Raises on any failure — the batch loop + below decides how to record that against the Form row.""" + form.status = FormStatus.generating + form.updated_at = _now() + update_form(session, form) + + incident = get_incident(session, form.incident_id) + if incident is None: + raise ValueError(f"incident {form.incident_id} no longer exists") + + template = get_form_template(session, form.template_id) + if template is None: + raise ValueError(f"template {form.template_id} no longer exists") + + contract = incident.incident_contract or {} + fields = [TemplateField.model_validate(entry) for entry in template.fields or []] + agency_fields = {f.field_name: resolve(contract, f, template.form_type) for f in fields} + + template_pdf_path = resolve_template_pdf(session, form.template_id) + overlay = _build_overlay(template_pdf_path, fields, contract, template.form_type) + writer = _merge_overlay(template_pdf_path, overlay) + + FORMS_OUTPUT_DIR.mkdir(parents=True, exist_ok=True) + output_path = FORMS_OUTPUT_DIR / f"{form.form_id}.pdf" + with output_path.open("wb") as handle: + writer.write(handle) + + form.pdf_path = str(output_path.relative_to(DATA_DIR)) + form.pdf_ready = True + form.json_data = agency_fields + form.json_ready = True + form.field_mapping_summary = _summary(contract, template) + form.status = FormStatus.completed + form.completed_at = _now() + form.updated_at = _now() + update_form(session, form) + + +def run_batch_fill(session: Session, batch_id: UUID, job_id: str) -> dict: + """Fill every queued form in a batch. Each form is independently + try/excepted: one failure marks that form failed and moves on — it never + sinks the batch or the job, per design.""" + forms = list_forms_by_batch(session, batch_id) + job = get_job_by_uuid(session, job_id) + + if job: + job.status = "processing" + job.updated_at = _now() + update_job(session, job) + + completed = 0 + failed = 0 + for index, form in enumerate(forms, start=1): + try: + fill_one(session, form) + completed += 1 + except Exception: + logger.exception("form %s (batch %s) failed to generate", form.form_id, batch_id) + form.status = FormStatus.failed + form.updated_at = _now() + update_form(session, form) + failed += 1 + + if job: + job.progress_percent = round(100 * index / len(forms)) if forms else 100 + job.updated_at = _now() + update_job(session, job) + + if job: + job.status = "completed" + job.progress_percent = 100 + job.result_url = f"/api/v1/forms/batch/{batch_id}" + job.updated_at = _now() + update_job(session, job) + + logger.info( + "batch %s finished: %d/%d forms completed, %d failed", + batch_id, completed, len(forms), failed, + ) + return {"batch_id": str(batch_id), "completed": completed, "failed": failed} diff --git a/app/services/form_generation.py b/app/services/form_generation.py new file mode 100644 index 00000000..6c2931e3 --- /dev/null +++ b/app/services/form_generation.py @@ -0,0 +1,128 @@ +"""Form generation service. + +Owns the write path for POST /forms/generate: validates the incident and +every requested template, splits templates into ready/not-ready via the +readiness engine (extraction_readiness.gaps_for), creates one Form row per +queued template, creates the batch Job, and dispatches the fill worker. +Mirrors ExtractionService.start_extraction's shape. The route stays a thin +HTTP handler and calls straight into here. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from datetime import datetime, timezone +from uuid import UUID, uuid4 + +from sqlmodel import Session + +from app.api.schemas.enums import FormStatus +from app.api.schemas.form_generation import GenerateFormsOptions, GenerateFormsRequest +from app.core.errors.base import AppError +from app.db.repositories import ( + create_generated_form, + create_job, + get_incident, + update_form, + update_job, +) +from app.models import Form, Job +from app.services.extraction_readiness import gaps_for +from app.services.form_templates import require_template +from app.tasks.generate_forms import generate_forms_batch_task + + +@dataclass +class SkippedTemplate: + template_id: UUID + form_type: str + reason: str + + +@dataclass +class GenerationResult: + batch_id: UUID + incident_id: UUID + queued: list[Form] = field(default_factory=list) + skipped: list[SkippedTemplate] = field(default_factory=list) + job: Job | None = None + + +def _skip_reason(gaps) -> str: + """One representative reason, per the agreed format. A template can be + missing more than one required field; this names the first one, the same + way the contract's own example names a single field.""" + gap = gaps.missing_required[0] + return f"Not ready: {gap.field_name} ({gap.source.value}) has no value" + + +class FormGenerationService: + def start_generation(self, session: Session, request: GenerateFormsRequest) -> GenerationResult: + incident = get_incident(session, request.incident_id) + if incident is None: + raise AppError( + f"Incident {request.incident_id} not found", + status_code=404, + error_code="INCIDENT_NOT_FOUND", + ) + + # Resolve every requested template before writing anything: a bad + # template_id 404s cleanly instead of leaving a partial batch behind. + templates = [require_template(session, tid) for tid in request.template_ids] + + options = request.options or GenerateFormsOptions() + contract = incident.incident_contract or {} + batch_id = uuid4() + now = datetime.now(timezone.utc) + result = GenerationResult(batch_id=batch_id, incident_id=incident.incident_id) + + for template in templates: + gaps = gaps_for(contract, template) + + if not gaps.ready and not options.force_partial: + result.skipped.append( + SkippedTemplate( + template_id=template.template_id, + form_type=template.form_type, + reason=_skip_reason(gaps), + ) + ) + continue + + form = Form( + template_id=template.template_id, + incident_id=incident.incident_id, + batch_id=batch_id, + form_type=template.form_type, + status=FormStatus.queued, + created_at=now, + updated_at=now, + ) + result.queued.append(create_generated_form(session, form)) + + if not result.queued: + raise AppError( + "No templates were selected and none are ready", + status_code=422, + error_code="NO_FORMS_TO_GENERATE", + ) + + job = Job(celery_task_id="", job_type="batch_form_generation", status="queued") + try: + job = create_job(session, job) + task_result = generate_forms_batch_task.delay(str(batch_id), job.job_id) + job.celery_task_id = task_result.id + job = update_job(session, job) + except Exception: + # Dispatch failed after the Form rows were already committed — + # mirrors InputService.process_voice_upload's cleanup discipline: + # nothing is left claiming to be queued with no job behind it. + failed_at = datetime.now(timezone.utc) + for queued_form in result.queued: + queued_form.status = FormStatus.failed + queued_form.updated_at = failed_at + update_form(session, queued_form) + raise + + result.job = job + return result diff --git a/app/tasks/generate_forms.py b/app/tasks/generate_forms.py new file mode 100644 index 00000000..a74b0138 --- /dev/null +++ b/app/tasks/generate_forms.py @@ -0,0 +1,25 @@ +"""Celery glue for the batch form-fill worker. + +All the work lives in app/services/form_fill_worker.py. This is only the +broker entry point: open a session, run the batch, close the session. Mirrors +app/tasks/extract.py's split — not app/tasks/fill.py, the legacy prototype task. +""" + +import logging +from uuid import UUID + +from app.core.celery import celery_app +from app.db.database import get_session +from app.services.form_fill_worker import run_batch_fill + +logger = logging.getLogger(__name__) + + +@celery_app.task(name="generate_forms_batch") +def generate_forms_batch_task(batch_id_str: str, job_id_str: str) -> dict: + """Fill every queued form in one batch.""" + session = next(get_session()) + try: + return run_batch_fill(session, UUID(batch_id_str), job_id_str) + finally: + session.close() diff --git a/data/forms/generated/.gitkeep b/data/forms/generated/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/requirements.txt b/requirements.txt index 53b74ebf..c09489c5 100644 --- a/requirements.txt +++ b/requirements.txt @@ -11,6 +11,8 @@ httpx numpy<2 openai pypdf +reportlab +Pillow python-multipart celery[redis] redis diff --git a/tests/test_v1_form_fill_worker.py b/tests/test_v1_form_fill_worker.py new file mode 100644 index 00000000..d73af213 --- /dev/null +++ b/tests/test_v1_form_fill_worker.py @@ -0,0 +1,290 @@ +"""Tests for the batch form-fill worker (app/services/form_fill_worker.py). + +Runs the real ReportLab-draw / pypdf-merge pipeline against the minimal valid +PDF from conftest — no mocking of the drawing itself, only the filesystem +location (redirected to tmp_path). Covers: field resolution onto pdf/json +output, the field_mapping_summary shape, status transitions, and per-form +failure isolation (one bad form doesn't sink the batch or the job). +""" + +from datetime import datetime, timezone +from uuid import uuid4 + +import pytest +from pypdf import PdfReader + +from app.api.schemas.enums import ( + ExtractionStatus, + FormStatus, + InputStatus, + InputType, + JobStatus, + ReportStatus, +) +from app.db.repositories import ( + create_extraction, + create_form_template, + create_generated_form, + create_incident, + create_input, + create_job, + get_job_by_uuid, +) +from app.models import Extraction, Form, FormTemplate, Incident, Input, Job +from app.services.form_fill_worker import fill_one, run_batch_fill + +_CONTRACT = { + "schema_version": "1.1.0", + "schema_name": "fireform_incident_contract", + "incident": {"name": "Bear Creek Wildfire"}, + "location": {"city": "Reno", "state": "NV"}, + "custom_fields": {"neris.marshal_signature_name": "A. Ruiz"}, +} + + +# --------------------------------------------------------------------------- +# Seed helpers +# --------------------------------------------------------------------------- + +def _layout(page=0, x=50, y=700, width=200, height=20, **extra) -> dict: + return {"page": page, "x": x, "y": y, "width": width, "height": height, **extra} + + +def _field(name, source="schema", required=True, layout=None, **extra) -> dict: + field = { + "field_name": name, + "field_type": "string", + "source": source, + "required": required, + "layout": layout, + } + if source == "schema": + field.setdefault("incident_mapping", "incident.name") + if source == "static": + field.setdefault("static_text", "Reno Fire Department") + if source == "manual": + pass + field.update(extra) + return field + + +def _incident(db, contract=None) -> Incident: + now = datetime.now(timezone.utc) + inp = create_input( + db, + Input( + input_type=InputType.text, + status=InputStatus.ready, + transcript="Wildfire off Bear Creek, no injuries.", + created_at=now, + updated_at=now, + ), + ) + extraction = create_extraction( + db, + Extraction(input_id=inp.input_id, status=ExtractionStatus.completed, started_at=now, completed_at=now), + ) + return create_incident( + db, + Incident( + extract_id=extraction.extract_id, + status=ReportStatus.draft, + incident_contract=_CONTRACT if contract is None else contract, + ), + ) + + +def _template(db, form_type="neris", fields=None, pdf_template_ref=None) -> FormTemplate: + return create_form_template( + db, + FormTemplate( + form_type=form_type, + display_name=form_type.upper(), + fields=fields if fields is not None else [_field("incident_name", layout=_layout())], + pdf_template_ref=pdf_template_ref, + ), + ) + + +def _form(db, incident, template, batch_id=None, **kwargs) -> Form: + defaults = dict( + template_id=template.template_id, + incident_id=incident.incident_id, + batch_id=batch_id, + form_type=template.form_type, + status=FormStatus.queued, + ) + return create_generated_form(db, Form(**{**defaults, **kwargs})) + + +@pytest.fixture +def output_dir(tmp_path, monkeypatch): + """Redirect the template-source lookup and the fill output to tmp_path, + same pattern test_templates_pdf.py uses for the upload flow.""" + monkeypatch.setattr("app.services.form_templates.DATA_DIR", tmp_path) + monkeypatch.setattr("app.services.form_fill_worker.DATA_DIR", tmp_path) + generated = tmp_path / "forms" / "generated" + monkeypatch.setattr("app.services.form_fill_worker.FORMS_OUTPUT_DIR", generated) + return tmp_path + + +def _seed_template_pdf(tmp_path, pdf_bytes, name="template.pdf") -> str: + """Write the source PDF under tmp_path and return its DATA_DIR-relative ref.""" + path = tmp_path / "templates" / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(pdf_bytes) + return str(path.relative_to(tmp_path)) + + +# --------------------------------------------------------------------------- +# fill_one +# --------------------------------------------------------------------------- + +class TestFillOne: + + def test_fills_schema_static_and_manual_fields(self, db, output_dir, pdf_bytes): + ref = _seed_template_pdf(output_dir, pdf_bytes) + incident = _incident(db) + template = _template( + db, + fields=[ + _field("incident_name", source="schema", incident_mapping="incident.name", layout=_layout(y=700)), + _field("agency", source="static", layout=_layout(y=650)), + _field("marshal_signature_name", source="manual", required=False, layout=None), + ], + pdf_template_ref=ref, + ) + form = _form(db, incident, template) + + fill_one(db, form) + + assert form.status == FormStatus.completed + assert form.pdf_ready is True + assert form.json_ready is True + assert form.completed_at is not None + + # pdf_path is DATA_DIR-relative and the file is a real, readable PDF. + assert not form.pdf_path.startswith("/") + written = output_dir / form.pdf_path + assert written.is_file() + reader = PdfReader(str(written)) + assert len(reader.pages) == 1 + + # agency_fields covers every field, placed or not. + assert form.json_data["incident_name"] == "Bear Creek Wildfire" + assert form.json_data["agency"] == "Reno Fire Department" + assert form.json_data["marshal_signature_name"] == "A. Ruiz" + + summary = form.field_mapping_summary + assert summary["total_form_fields"] == 3 + assert summary["fields_filled"] == 3 + assert summary["fields_blank"] == 0 + assert summary["coverage_percent"] == 100.0 + + def test_unplaced_field_has_no_layout_but_is_still_in_json(self, db, output_dir, pdf_bytes): + ref = _seed_template_pdf(output_dir, pdf_bytes) + incident = _incident(db) + template = _template( + db, + fields=[_field("incident_name", layout=None)], + pdf_template_ref=ref, + ) + form = _form(db, incident, template) + + fill_one(db, form) + + assert form.status == FormStatus.completed + assert form.json_data["incident_name"] == "Bear Creek Wildfire" + + def test_missing_required_field_still_fills_a_blank_box(self, db, output_dir, pdf_bytes): + """Filling doesn't gate on readiness — that's the generate-time skip check.""" + ref = _seed_template_pdf(output_dir, pdf_bytes) + incident = _incident(db, contract={"schema_version": "1.1.0", "schema_name": "fireform_incident_contract"}) + template = _template( + db, + fields=[_field("incident_name", source="schema", incident_mapping="incident.name", layout=_layout())], + pdf_template_ref=ref, + ) + form = _form(db, incident, template) + + fill_one(db, form) + + assert form.status == FormStatus.completed + assert form.json_data["incident_name"] is None + assert form.field_mapping_summary["fields_blank"] == 1 + assert form.field_mapping_summary["coverage_percent"] == 0.0 + + def test_missing_template_pdf_raises(self, db, output_dir): + incident = _incident(db) + template = _template( + db, + fields=[_field("incident_name", layout=_layout())], + pdf_template_ref="templates/does-not-exist.pdf", + ) + form = _form(db, incident, template) + + with pytest.raises(Exception): + fill_one(db, form) + + +# --------------------------------------------------------------------------- +# run_batch_fill — batch orchestration and per-form failure isolation +# --------------------------------------------------------------------------- + +class TestRunBatchFill: + + def _job(self, db) -> Job: + return create_job(db, Job(celery_task_id="task-1", job_type="batch_form_generation", status="queued")) + + def test_all_forms_complete_job_completed(self, db, output_dir, pdf_bytes): + ref = _seed_template_pdf(output_dir, pdf_bytes) + incident = _incident(db) + template = _template(db, fields=[_field("incident_name", layout=_layout())], pdf_template_ref=ref) + batch_id = uuid4() + _form(db, incident, template, batch_id=batch_id) + _form(db, incident, template, batch_id=batch_id) + job = self._job(db) + + result = run_batch_fill(db, batch_id, job.job_id) + + assert result["completed"] == 2 + assert result["failed"] == 0 + refreshed = get_job_by_uuid(db, job.job_id) + assert refreshed.status == JobStatus.completed + assert refreshed.progress_percent == 100 + + def test_one_bad_form_does_not_sink_the_batch(self, db, output_dir, pdf_bytes): + """One form's template PDF is missing; the other form in the same + batch still completes, and the Job still finishes as completed.""" + good_ref = _seed_template_pdf(output_dir, pdf_bytes, name="good.pdf") + good_template = _template(db, fields=[_field("incident_name", layout=_layout())], pdf_template_ref=good_ref) + bad_template = _template( + db, + form_type="cal_fire_ics209", + fields=[_field("incident_name", layout=_layout())], + pdf_template_ref="templates/missing.pdf", + ) + incident = _incident(db) + batch_id = uuid4() + good_form = _form(db, incident, good_template, batch_id=batch_id) + bad_form = _form(db, incident, bad_template, batch_id=batch_id) + job = self._job(db) + + result = run_batch_fill(db, batch_id, job.job_id) + + assert result["completed"] == 1 + assert result["failed"] == 1 + + from app.db.repositories import get_form + assert get_form(db, good_form.form_id).status == FormStatus.completed + assert get_form(db, bad_form.form_id).status == FormStatus.failed + + refreshed_job = get_job_by_uuid(db, job.job_id) + assert refreshed_job.status == JobStatus.completed + assert refreshed_job.progress_percent == 100 + + def test_empty_batch_completes_cleanly(self, db, output_dir): + job = self._job(db) + result = run_batch_fill(db, uuid4(), job.job_id) + assert result == {"batch_id": result["batch_id"], "completed": 0, "failed": 0} + assert get_job_by_uuid(db, job.job_id).status == JobStatus.completed diff --git a/tests/test_v1_form_generation.py b/tests/test_v1_form_generation.py new file mode 100644 index 00000000..17142d57 --- /dev/null +++ b/tests/test_v1_form_generation.py @@ -0,0 +1,515 @@ +"""Tests for POST /forms/generate, GET /forms/batch/{batch_id}, GET /forms/{form_id}, +GET /forms/{form_id}/pdf and GET /forms/{form_id}/json. + +Dispatch is mocked (no broker) — the actual fill is covered by +tests/test_v1_form_fill_worker.py. These cover the write path (queued vs +skipped split, 404s, the NO_FORMS_TO_GENERATE case) and the read endpoints +against Form rows seeded directly. +""" + +from datetime import datetime, timezone +from unittest.mock import MagicMock, patch +from uuid import UUID, uuid4 + +from app.api.schemas.enums import ( + ExtractionStatus, + FormStatus, + InputStatus, + InputType, + ReportStatus, +) +from app.core.config import API_PREFIX +from app.db.repositories import ( + create_extraction, + create_form_template, + create_generated_form, + create_incident, + create_input, +) +from app.models import Extraction, Form, FormTemplate, Incident, Input + +FORMS_URL = f"{API_PREFIX}/forms" + +_CONTRACT = { + "schema_version": "1.1.0", + "schema_name": "fireform_incident_contract", + "incident": {"name": "Bear Creek Wildfire"}, + "location": {"city": "Reno", "state": "NV"}, + "custom_fields": {"neris.marshal_signature_name": "A. Ruiz"}, +} + + +# --------------------------------------------------------------------------- +# Seed helpers +# --------------------------------------------------------------------------- + +def _field(name, source="schema", required=True, layout=None, **extra) -> dict: + field = { + "field_name": name, + "field_type": "string", + "source": source, + "required": required, + "layout": layout, + } + if source == "schema": + field.setdefault("incident_mapping", "incident.name") + if source == "static": + field.setdefault("static_text", "Reno Fire Department") + if source == "open": + field.setdefault("description", "Anything the narrative says about it") + field.update(extra) + return field + + +def _incident(db, contract=None) -> Incident: + now = datetime.now(timezone.utc) + inp = create_input( + db, + Input( + input_type=InputType.text, + status=InputStatus.ready, + transcript="Wildfire off Bear Creek, no injuries.", + created_at=now, + updated_at=now, + ), + ) + extraction = create_extraction( + db, + Extraction( + input_id=inp.input_id, + status=ExtractionStatus.completed, + started_at=now, + completed_at=now, + ), + ) + return create_incident( + db, + Incident( + extract_id=extraction.extract_id, + status=ReportStatus.draft, + incident_contract=_CONTRACT if contract is None else contract, + ), + ) + + +def _template(db, form_type="neris", fields=None) -> FormTemplate: + return create_form_template( + db, + FormTemplate( + form_type=form_type, + display_name=form_type.upper(), + fields=fields if fields is not None else [_field("incident_name")], + ), + ) + + +def _form(db, incident, template, batch_id=None, **kwargs) -> Form: + defaults = dict( + template_id=template.template_id, + incident_id=incident.incident_id, + batch_id=batch_id, + form_type=template.form_type, + status=FormStatus.queued, + ) + return create_generated_form(db, Form(**{**defaults, **kwargs})) + + +class NoCelery: + """Stand-in for the fill task so nothing is dispatched to a broker.""" + + def __enter__(self): + self._patch = patch("app.services.form_generation.generate_forms_batch_task") + self.task = self._patch.__enter__() + self.task.delay.return_value = MagicMock(id="celery-batch-1") + return self.task + + def __exit__(self, *exc): + self._patch.__exit__(*exc) + + +# --------------------------------------------------------------------------- +# POST /forms/generate +# --------------------------------------------------------------------------- + +class TestGenerateForms: + + def test_202_all_ready_queues_every_template(self, client, db): + incident = _incident(db) + template = _template(db, fields=[_field("incident_name")]) + + with NoCelery() as task: + resp = client.post( + f"{FORMS_URL}/generate", + json={"incident_id": str(incident.incident_id), "template_ids": [str(template.template_id)]}, + ) + + assert resp.status_code == 202 + body = resp.json() + assert body["status"] == "processing" + assert body["incident_id"] == str(incident.incident_id) + assert len(body["forms_queued"]) == 1 + assert body["forms_queued"][0]["template_id"] == str(template.template_id) + assert body["forms_queued"][0]["form_type"] == "neris" + assert body["forms_skipped"] == [] + assert body["poll_url"] == f"/api/v1/forms/batch/{body['batch_id']}" + assert body["estimated_seconds"] == 10 + task.delay.assert_called_once() + dispatched_batch_id, dispatched_job_id = task.delay.call_args[0] + assert dispatched_batch_id == body["batch_id"] + assert isinstance(dispatched_job_id, str) and dispatched_job_id + + def test_not_ready_template_is_skipped_with_reason(self, client, db): + # A not-ready template alone would 422 NO_FORMS_TO_GENERATE (covered + # separately below) — pair it with a ready one so the skip path is + # exercised inside a batch that still succeeds. + incident = _incident(db) + ready = _template(db, form_type="neris", fields=[_field("incident_name")]) + not_ready = _template( + db, + form_type="state_texas", + fields=[_field("marshal_signature_name", source="manual", required=True)], + ) + + with NoCelery(): + resp = client.post( + f"{FORMS_URL}/generate", + json={ + "incident_id": str(incident.incident_id), + "template_ids": [str(ready.template_id), str(not_ready.template_id)], + }, + ) + + assert resp.status_code == 202 + body = resp.json() + assert len(body["forms_queued"]) == 1 + assert len(body["forms_skipped"]) == 1 + skipped = body["forms_skipped"][0] + assert skipped["template_id"] == str(not_ready.template_id) + assert skipped["reason"] == "Not ready: marshal_signature_name (manual) has no value" + + def test_force_partial_queues_a_not_ready_template(self, client, db): + incident = _incident(db, contract={"schema_version": "1.1.0", "schema_name": "fireform_incident_contract"}) + template = _template( + db, + form_type="state_texas", + fields=[_field("marshal_signature_name", source="manual", required=True)], + ) + + with NoCelery(): + resp = client.post( + f"{FORMS_URL}/generate", + json={ + "incident_id": str(incident.incident_id), + "template_ids": [str(template.template_id)], + "options": {"force_partial": True}, + }, + ) + + assert resp.status_code == 202 + body = resp.json() + assert body["forms_skipped"] == [] + assert len(body["forms_queued"]) == 1 + + def test_mixed_batch_splits_queued_and_skipped(self, client, db): + incident = _incident(db) + ready = _template(db, form_type="neris", fields=[_field("incident_name")]) + not_ready = _template( + db, + form_type="cal_fire_ics209", + fields=[_field("something_missing", source="schema", incident_mapping="does.not.exist")], + ) + + with NoCelery(): + resp = client.post( + f"{FORMS_URL}/generate", + json={ + "incident_id": str(incident.incident_id), + "template_ids": [str(ready.template_id), str(not_ready.template_id)], + }, + ) + + body = resp.json() + assert len(body["forms_queued"]) == 1 + assert len(body["forms_skipped"]) == 1 + assert body["forms_queued"][0]["template_id"] == str(ready.template_id) + assert body["forms_skipped"][0]["template_id"] == str(not_ready.template_id) + + def test_creates_queued_form_rows_in_db(self, client, db, test_engine): + from sqlmodel import Session, select + + incident = _incident(db) + template = _template(db) + + with NoCelery(): + resp = client.post( + f"{FORMS_URL}/generate", + json={"incident_id": str(incident.incident_id), "template_ids": [str(template.template_id)]}, + ) + batch_id = UUID(resp.json()["batch_id"]) + + with Session(test_engine) as session: + rows = list(session.exec(select(Form).where(Form.batch_id == batch_id))) + assert len(rows) == 1 + assert rows[0].status == FormStatus.queued + assert rows[0].incident_id == incident.incident_id + assert rows[0].template_id == template.template_id + + def test_404_incident_not_found(self, client, db): + template = _template(db) + with NoCelery(): + resp = client.post( + f"{FORMS_URL}/generate", + json={"incident_id": str(uuid4()), "template_ids": [str(template.template_id)]}, + ) + assert resp.status_code == 404 + assert resp.json()["error_code"] == "INCIDENT_NOT_FOUND" + + def test_404_template_not_found(self, client, db): + incident = _incident(db) + with NoCelery(): + resp = client.post( + f"{FORMS_URL}/generate", + json={"incident_id": str(incident.incident_id), "template_ids": [str(uuid4())]}, + ) + assert resp.status_code == 404 + assert resp.json()["error_code"] == "TEMPLATE_NOT_FOUND" + + def test_404_on_bad_template_leaves_no_partial_batch(self, client, db, test_engine): + """A bad template_id anywhere in the list 404s before any Form row is written.""" + from sqlmodel import Session, select + + incident = _incident(db) + good = _template(db) + with NoCelery(): + resp = client.post( + f"{FORMS_URL}/generate", + json={ + "incident_id": str(incident.incident_id), + "template_ids": [str(good.template_id), str(uuid4())], + }, + ) + assert resp.status_code == 404 + with Session(test_engine) as session: + rows = list(session.exec(select(Form))) + assert rows == [] + + def test_422_empty_template_ids_rejected(self, client, db): + incident = _incident(db) + resp = client.post( + f"{FORMS_URL}/generate", + json={"incident_id": str(incident.incident_id), "template_ids": []}, + ) + assert resp.status_code == 422 + + def test_422_no_forms_to_generate_when_all_skipped(self, client, db): + incident = _incident(db, contract={"schema_version": "1.1.0", "schema_name": "fireform_incident_contract"}) + template = _template( + db, + form_type="state_texas", + fields=[_field("marshal_signature_name", source="manual", required=True)], + ) + with NoCelery(): + resp = client.post( + f"{FORMS_URL}/generate", + json={"incident_id": str(incident.incident_id), "template_ids": [str(template.template_id)]}, + ) + assert resp.status_code == 422 + assert resp.json()["error_code"] == "NO_FORMS_TO_GENERATE" + + +# --------------------------------------------------------------------------- +# GET /forms/batch/{batch_id} +# --------------------------------------------------------------------------- + +class TestBatchStatus: + + def test_processing_when_some_forms_still_queued(self, client, db): + incident = _incident(db) + template = _template(db) + batch_id = uuid4() + _form(db, incident, template, batch_id=batch_id, status=FormStatus.queued) + _form(db, incident, template, batch_id=batch_id, status=FormStatus.completed) + + resp = client.get(f"{FORMS_URL}/batch/{batch_id}") + assert resp.status_code == 200 + body = resp.json() + assert body["status"] == "processing" + assert body["total"] == 2 + assert body["completed"] == 1 + assert body["failed"] == 0 + assert body["download_url"] is None + + def test_completed_when_all_terminal_with_no_failures(self, client, db): + incident = _incident(db) + template = _template(db) + batch_id = uuid4() + _form(db, incident, template, batch_id=batch_id, status=FormStatus.completed) + _form(db, incident, template, batch_id=batch_id, status=FormStatus.completed) + + resp = client.get(f"{FORMS_URL}/batch/{batch_id}") + assert resp.json()["status"] == "completed" + + def test_completed_when_terminal_with_a_partial_failure(self, client, db): + """One failed form doesn't fail the batch — matches the per-form isolation design.""" + incident = _incident(db) + template = _template(db) + batch_id = uuid4() + _form(db, incident, template, batch_id=batch_id, status=FormStatus.completed) + _form(db, incident, template, batch_id=batch_id, status=FormStatus.failed) + + resp = client.get(f"{FORMS_URL}/batch/{batch_id}") + body = resp.json() + assert body["status"] == "completed" + assert body["completed"] == 1 + assert body["failed"] == 1 + + def test_failed_when_every_form_failed(self, client, db): + incident = _incident(db) + template = _template(db) + batch_id = uuid4() + _form(db, incident, template, batch_id=batch_id, status=FormStatus.failed) + + resp = client.get(f"{FORMS_URL}/batch/{batch_id}") + assert resp.json()["status"] == "failed" + + def test_404_unknown_batch(self, client, db): + resp = client.get(f"{FORMS_URL}/batch/{uuid4()}") + assert resp.status_code == 404 + assert resp.json()["error_code"] == "BATCH_NOT_FOUND" + + +# --------------------------------------------------------------------------- +# GET /forms/{form_id} +# --------------------------------------------------------------------------- + +class TestGetForm: + + def test_200_returns_form_record(self, client, db): + incident = _incident(db) + template = _template(db) + summary = { + "total_form_fields": 10, + "fields_filled": 8, + "fields_blank": 2, + "coverage_percent": 80.0, + } + form = _form( + db, incident, template, + status=FormStatus.completed, + pdf_ready=True, + json_ready=True, + field_mapping_summary=summary, + ) + + resp = client.get(f"{FORMS_URL}/{form.form_id}") + assert resp.status_code == 200 + body = resp.json() + assert body["form_id"] == str(form.form_id) + assert body["template_id"] == str(template.template_id) + assert body["form_type"] == "neris" + assert body["status"] == "completed" + assert body["incident_id"] == str(incident.incident_id) + assert body["pdf_ready"] is True + assert body["json_ready"] is True + assert body["field_mapping_summary"]["coverage_percent"] == 80.0 + + def test_404_unknown_form(self, client, db): + resp = client.get(f"{FORMS_URL}/{uuid4()}") + assert resp.status_code == 404 + assert resp.json()["error_code"] == "FORM_NOT_FOUND" + + +# --------------------------------------------------------------------------- +# GET /forms/{form_id}/pdf +# --------------------------------------------------------------------------- + +class TestGetFormPdf: + + def test_202_while_still_generating(self, client, db): + incident = _incident(db) + template = _template(db) + form = _form(db, incident, template, status=FormStatus.generating) + + resp = client.get(f"{FORMS_URL}/{form.form_id}/pdf") + assert resp.status_code == 202 + assert resp.json()["status"] == "generating" + + def test_500_when_form_failed(self, client, db): + incident = _incident(db) + template = _template(db) + form = _form(db, incident, template, status=FormStatus.failed) + + resp = client.get(f"{FORMS_URL}/{form.form_id}/pdf") + assert resp.status_code == 500 + assert resp.json()["error_code"] == "PDF_GENERATION_FAILED" + + def test_200_serves_the_pdf_file(self, client, db, monkeypatch, tmp_path, pdf_bytes): + monkeypatch.setattr("app.api.routes.form_generation.DATA_DIR", tmp_path) + incident = _incident(db) + template = _template(db) + + pdf_file = tmp_path / "forms" / "generated" / "x.pdf" + pdf_file.parent.mkdir(parents=True) + pdf_file.write_bytes(pdf_bytes) + + form = _form( + db, incident, template, + status=FormStatus.completed, + pdf_ready=True, + pdf_path="forms/generated/x.pdf", + ) + + resp = client.get(f"{FORMS_URL}/{form.form_id}/pdf") + assert resp.status_code == 200 + assert resp.headers["content-type"] == "application/pdf" + assert resp.content == pdf_bytes + + def test_404_path_escaping_data_dir_is_rejected(self, client, db, monkeypatch, tmp_path): + monkeypatch.setattr("app.api.routes.form_generation.DATA_DIR", tmp_path) + incident = _incident(db) + template = _template(db) + form = _form( + db, incident, template, + status=FormStatus.completed, + pdf_ready=True, + pdf_path="../../etc/passwd", + ) + + resp = client.get(f"{FORMS_URL}/{form.form_id}/pdf") + assert resp.status_code == 404 + + +# --------------------------------------------------------------------------- +# GET /forms/{form_id}/json +# --------------------------------------------------------------------------- + +class TestGetFormJson: + + def test_200_returns_agency_fields(self, client, db): + incident = _incident(db) + template = _template(db) + form = _form( + db, incident, template, + status=FormStatus.completed, + json_ready=True, + json_data={"incident_name": "Bear Creek Wildfire"}, + ) + + resp = client.get(f"{FORMS_URL}/{form.form_id}/json") + assert resp.status_code == 200 + body = resp.json() + assert body["form_id"] == str(form.form_id) + assert body["agency_fields"]["incident_name"] == "Bear Creek Wildfire" + + def test_404_when_not_ready_yet(self, client, db): + incident = _incident(db) + template = _template(db) + form = _form(db, incident, template, status=FormStatus.queued) + + resp = client.get(f"{FORMS_URL}/{form.form_id}/json") + assert resp.status_code == 404 + assert resp.json()["error_code"] == "FORM_JSON_NOT_READY" + + def test_404_unknown_form(self, client, db): + resp = client.get(f"{FORMS_URL}/{uuid4()}/json") + assert resp.status_code == 404