From eef51e681cd83f403f674f5f6fddfc5fee66f4d3 Mon Sep 17 00:00:00 2001 From: chetanr25 Date: Sat, 27 Jun 2026 22:27:40 +0530 Subject: [PATCH 01/13] deleted old APIs under templates. --- app/api/routes/templates.py | 240 ------------------------------------ 1 file changed, 240 deletions(-) delete mode 100644 app/api/routes/templates.py diff --git a/app/api/routes/templates.py b/app/api/routes/templates.py deleted file mode 100644 index ee7189a..0000000 --- a/app/api/routes/templates.py +++ /dev/null @@ -1,240 +0,0 @@ -import re -from datetime import datetime, timezone -from pathlib import Path - -from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, UploadFile -from fastapi.responses import FileResponse -from sqlmodel import Session - -from app.api.deps import get_db, verify_api_key -from app.api.schemas.templates import ( - TemplateCreate, - TemplateResponse, - TemplateUploadResponse, - MakeFillableRequest, - MakeFillableResponse, -) -from app.core.config import BASE_DIR, DEFAULT_TEMPLATE_DIR -from app.db.repositories import create_template, list_templates, get_template, delete_template -from app.models import Template, FormSubmission, Job -from app.services.controller import Controller -from sqlmodel import select - -router = APIRouter(prefix="/templates", tags=["templates"]) -PROJECT_ROOT = BASE_DIR - - -def _resolve_target_directory(directory: str) -> Path: - dir_value = (directory or DEFAULT_TEMPLATE_DIR).strip() - if not dir_value: - raise HTTPException(status_code=400, detail="Directory is required.") - - candidate = Path(dir_value) - if not candidate.is_absolute(): - candidate = (PROJECT_ROOT / candidate).resolve() - else: - candidate = candidate.resolve() - - if candidate != PROJECT_ROOT and PROJECT_ROOT not in candidate.parents: - raise HTTPException(status_code=400, detail="Directory must be inside the project.") - - return candidate - - -def _resolve_project_file(file_path: str) -> Path: - raw_path = (file_path or "").strip() - if not raw_path: - raise HTTPException(status_code=400, detail="Path is required.") - - candidate = Path(raw_path) - if not candidate.is_absolute(): - candidate = (PROJECT_ROOT / candidate).resolve() - else: - candidate = candidate.resolve() - - if candidate != PROJECT_ROOT and PROJECT_ROOT not in candidate.parents: - raise HTTPException(status_code=400, detail="Path must be inside the project.") - - return candidate - - -@router.post("/upload", response_model=TemplateUploadResponse) -async def upload_template_pdf( - file: UploadFile = File(...), - directory: str = Form(DEFAULT_TEMPLATE_DIR), -): - filename = Path(file.filename or "").name - if not filename: - raise HTTPException(status_code=400, detail="A PDF filename is required.") - - if not filename.lower().endswith(".pdf"): - raise HTTPException(status_code=400, detail="Only PDF files are supported.") - - target_dir = _resolve_target_directory(directory) - target_dir.mkdir(parents=True, exist_ok=True) - - target_path = target_dir / filename - if target_path.exists(): - timestamp = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S") - target_path = target_dir / f"{target_path.stem}_{timestamp}{target_path.suffix}" - - content = await file.read() - with target_path.open("wb") as output_file: - output_file.write(content) - - relative_path = target_path.relative_to(PROJECT_ROOT).as_posix() - extracted = _extract_pdf_fields(relative_path) - return TemplateUploadResponse( - filename=target_path.name, - pdf_path=relative_path, - field_count=None if extracted is None else len(extracted), - fields=extracted or [], - ) - - -# PDF field-type codes -> the type values the frontend field builder uses. -_FIELD_TYPE_BY_FT = {"/Tx": "string", "/Btn": "checkbox", "/Ch": "list", "/Sig": "signature"} - - -def _pdf_text(value) -> str: - """Decode a pdfrw string (field name / tooltip) to plain text.""" - if value is None: - return "" - if hasattr(value, "to_unicode"): - return value.to_unicode().strip() - return str(value).strip() - - -def _humanize(name: str) -> str: - """Turn a raw field name into a readable description (JobTitle -> Job Title).""" - text = re.sub(r"_+", " ", name) - text = re.sub(r"(?<=[a-z])(?=[A-Z])", " ", text) - return re.sub(r"\s+", " ", text).strip() - - -def _extract_pdf_fields(pdf_path: str) -> list[dict] | None: - """Fillable widgets in the same order Filler.fill_form writes them - (top-to-bottom, left-to-right per page), so seeded rows line up with the - fill order. Returns None if the PDF can't be read.""" - try: - from pdfrw import PdfReader - candidate = Path(pdf_path) - if not candidate.is_absolute(): - candidate = (PROJECT_ROOT / candidate).resolve() - pdf = PdfReader(str(candidate)) - fields: list[dict] = [] - for page in pdf.pages: - widgets = [a for a in (page.Annots or []) if a.Subtype == "/Widget" and a.T] - widgets.sort(key=lambda a: (-float(a.Rect[1]), float(a.Rect[0]))) - for annot in widgets: - name = _pdf_text(annot.T) - fields.append({ - "name": name, - "description": _pdf_text(annot.TU) or _humanize(name), - "type": _FIELD_TYPE_BY_FT.get(str(annot.FT), "string"), - }) - return fields - except Exception: - return None - - -def _count_pdf_widgets(pdf_path: str) -> int | None: - """Number of fillable widgets in a PDF, or None if unreadable.""" - fields = _extract_pdf_fields(pdf_path) - return None if fields is None else len(fields) - - -@router.get("", response_model=list[TemplateResponse]) -def get_templates(db: Session = Depends(get_db)): - return list_templates(db) - - -@router.get("/preview") -def preview_template_pdf(path: str = Query(..., description="Project-relative PDF path")): - resolved_path = _resolve_project_file(path) - - if not resolved_path.exists() or not resolved_path.is_file(): - raise HTTPException(status_code=404, detail="PDF file not found.") - - if resolved_path.suffix.lower() != ".pdf": - raise HTTPException(status_code=400, detail="Only PDF files can be previewed.") - - return FileResponse( - resolved_path, - media_type="application/pdf", - filename=resolved_path.name, - content_disposition_type="inline", - ) - - -@router.post("/create", response_model=TemplateResponse) -def create(template: TemplateCreate, db: Session = Depends(get_db)): - tpl = Template(**template.model_dump()) - created = create_template(db, tpl) - return TemplateResponse( - id=created.id, - name=created.name, - pdf_path=created.pdf_path, - fields=created.fields, - field_count=_count_pdf_widgets(created.pdf_path), - ) - - -@router.post("/make-fillable", response_model=MakeFillableResponse) -def make_fillable(req: MakeFillableRequest): - # Validate the path stays inside the project root. - resolved = _resolve_project_file(req.pdf_path) - if not resolved.exists() or not resolved.is_file(): - raise HTTPException(status_code=404, detail="PDF file not found.") - - controller = Controller() - new_absolute = controller.prepare_fillable(str(resolved)) - new_path = Path(new_absolute) - if not new_path.is_absolute(): - new_path = (PROJECT_ROOT / new_path).resolve() - relative_path = new_path.relative_to(PROJECT_ROOT).as_posix() - - return MakeFillableResponse( - pdf_path=relative_path, - field_count=_count_pdf_widgets(relative_path), - ) - - -@router.delete("/{template_id}", dependencies=[Depends(verify_api_key)]) -def delete_template_endpoint(template_id: int, db: Session = Depends(get_db)): - template = get_template(db, template_id) - if not template: - raise HTTPException(status_code=404, detail="Template not found") - - # 1. Clean up associated submissions and their generated PDFs - sub_stmt = select(FormSubmission).where(FormSubmission.template_id == template_id) - submissions = list(db.exec(sub_stmt)) - for sub in submissions: - if sub.output_pdf_path: - try: - resolved_out = _resolve_project_file(sub.output_pdf_path) - if resolved_out.exists() and resolved_out.is_file(): - resolved_out.unlink() - except Exception: - pass - db.delete(sub) - - # 2. Clean up associated jobs - job_stmt = select(Job).where(Job.template_id == template_id) - jobs = list(db.exec(job_stmt)) - for job in jobs: - db.delete(job) - - # 3. Delete template PDF file - if template.pdf_path: - try: - resolved_pdf = _resolve_project_file(template.pdf_path) - if resolved_pdf.exists() and resolved_pdf.is_file(): - resolved_pdf.unlink() - except Exception: - pass - - # 4. Delete the template itself - delete_template(db, template) - return {"status": "success", "message": "Template and all associated data deleted"} - From 70f9c999721997596b7ce552528813c6d6e80224 Mon Sep 17 00:00:00 2001 From: chetanr25 Date: Sat, 27 Jun 2026 22:40:33 +0530 Subject: [PATCH 02/13] updated schemas and added required enums --- app/api/schemas/enums.py | 19 ++++++++ app/api/schemas/templates.py | 91 +++++++++++++++++++++++++----------- 2 files changed, 84 insertions(+), 26 deletions(-) diff --git a/app/api/schemas/enums.py b/app/api/schemas/enums.py index b633538..06c9798 100644 --- a/app/api/schemas/enums.py +++ b/app/api/schemas/enums.py @@ -72,6 +72,25 @@ class FormType(str, Enum): state_new_york = "state_new_york" +class TemplateStatus(str, Enum): + active = "active" + legacy = "legacy" + draft = "draft" + + +class TemplateFieldType(str, Enum): + string = "string" + integer = "integer" + number = "number" + boolean = "boolean" + date = "date" + datetime = "datetime" + time = "time" + enum = "enum" + text = "text" + array = "array" + + class IncidentCategory(str, Enum): fire = "fire" ems = "ems" diff --git a/app/api/schemas/templates.py b/app/api/schemas/templates.py index df39832..f70ecb2 100644 --- a/app/api/schemas/templates.py +++ b/app/api/schemas/templates.py @@ -1,38 +1,77 @@ +from datetime import date, datetime +from uuid import UUID + from pydantic import BaseModel -class TemplateCreate(BaseModel): - name: str - pdf_path: str - fields: dict +from app.api.schemas.enums import TemplateFieldType, TemplateStatus + + +# --------------------------------------------------------------------------- +# Contract Layer 6 schemas (contracts/schemas/template.yaml) +# --------------------------------------------------------------------------- +class TemplateField(BaseModel): + """One field definition within a template (schemas/template.yaml#/TemplateField).""" + + field_name: str + field_type: TemplateFieldType + required: bool + description: str | None = None + max_length: int | None = None + min_value: float | None = None + max_value: float | None = None + allowed_values: list[str] | None = None + incident_mapping: str | None = None + default_value: object | None = None + + +class CreateTemplateRequest(BaseModel): + """POST/PUT request body (schemas/template.yaml#/CreateTemplateRequest).""" + + form_type: str + display_name: str + jurisdiction: str + agency_type: str | None = None + fields: list[TemplateField] + field_mappings_from_incident: dict[str, str] + source_standard: str | None = None + pdf_template_ref: str | None = None -class MakeFillableRequest(BaseModel): - pdf_path: str +class TemplateSummary(BaseModel): + """List item (schemas/template.yaml#/TemplateSummary).""" + template_id: UUID + form_type: str + display_name: str + jurisdiction: str + agency_type: str | None = None + version: str + last_updated: date + field_count: int + status: TemplateStatus -class MakeFillableResponse(BaseModel): - pdf_path: str - field_count: int | None = None -class TemplateResponse(BaseModel): - id: int - name: str - pdf_path: str - fields: dict - field_count: int | None = None +class TemplateDetail(CreateTemplateRequest): + """Full template definition (schemas/template.yaml#/Template). - class Config: - from_attributes = True + Server-generated fields layered on top of CreateTemplateRequest. + """ + template_id: UUID + version: str + last_updated: date + field_count: int + status: TemplateStatus + created_at: datetime + updated_at: datetime -class ExtractedField(BaseModel): - name: str - description: str - type: str +class TemplateFieldsResponse(BaseModel): + """GET /templates/{id}/fields response (path/templates.yaml#L226-L244).""" -class TemplateUploadResponse(BaseModel): - filename: str - pdf_path: str - field_count: int | None = None - fields: list[ExtractedField] = [] + template_id: UUID + form_type: str + total_fields: int + required_fields: int + optional_fields: int + fields: list[TemplateField] From 0a5452578a1b85c97a86599b9f6cebd7105e06e1 Mon Sep 17 00:00:00 2001 From: chetanr25 Date: Sat, 27 Jun 2026 22:42:34 +0530 Subject: [PATCH 03/13] Updated API contracts to maintain consistancy (replaced confusing canonical to incident form) --- contracts/path/templates.yaml | 20 ++++++++++---------- contracts/schemas/template.yaml | 14 +++++++------- 2 files changed, 17 insertions(+), 17 deletions(-) diff --git a/contracts/path/templates.yaml b/contracts/path/templates.yaml index dab9dca..834e3b8 100644 --- a/contracts/path/templates.yaml +++ b/contracts/path/templates.yaml @@ -13,7 +13,7 @@ templates: Returns all registered form templates including built-in standard templates (NERIS, NEMSIS, NIBRS, NFIRS modules, OSHA, etc.) and any custom templates added for specific jurisdictions. Each template defines the fields, validation - rules, and mapping from the canonical FireForm schema. + rules, and mapping from the FireForm incident schema. tags: - templates responses: @@ -61,7 +61,7 @@ templates: Registers a new form template for a jurisdiction or agency not yet supported. This is how FireForm extends to new states, countries, or custom agency forms without code changes. The template defines all fields, their types, validation - rules, and how each maps from the canonical FireForm schema. + rules, and how each maps from the FireForm incident schema. tags: - templates requestBody: @@ -81,7 +81,7 @@ templates: required: true max_length: 20 description: "State-assigned incident number" - canonical_mapping: "report_metadata.incident_number" + incident_mapping: "report_metadata.incident_number" - field_name: "fire_cause" field_type: "enum" required: true @@ -90,8 +90,8 @@ templates: - "natural" - "intentional" - "undetermined" - canonical_mapping: "fire.cause_category" - field_mappings_from_canonical: + incident_mapping: "fire.cause_category" + field_mappings_from_incident: "report_metadata.incident_number": "incident_number" "fire.cause_category": "fire_cause" responses: @@ -120,8 +120,8 @@ template_by_id: summary: Get full template schema description: | Returns the complete template definition including all fields, their types, - required/optional status, validation rules, and the mapping from canonical - FireForm schema fields. Includes the source standard reference where applicable. + required/optional status, validation rules, and the mapping from the FireForm + incident schema fields. Includes the source standard reference where applicable. tags: - templates parameters: @@ -201,7 +201,7 @@ template_fields: summary: Get template field definitions description: | Returns just the fields list for a template with type, required/optional - status, validation rules, and canonical schema mapping. Useful for building + status, validation rules, and incident-schema mapping. Useful for building validation checklists and for the validate endpoint to determine requirements. tags: - templates @@ -253,12 +253,12 @@ template_fields: field_type: "enum" required: true description: "Primary incident type code" - canonical_mapping: "incident.types[0].neris_code" + incident_mapping: "incident.types[0].neris_code" - field_name: "incident_date" field_type: "date" required: true description: "Date of incident" - canonical_mapping: "incident.start_datetime" + incident_mapping: "incident.start_datetime" "404": description: Template not found content: diff --git a/contracts/schemas/template.yaml b/contracts/schemas/template.yaml index 45c71f7..9894fdc 100644 --- a/contracts/schemas/template.yaml +++ b/contracts/schemas/template.yaml @@ -36,7 +36,7 @@ CreateTemplateRequest: - display_name - jurisdiction - fields - - field_mappings_from_canonical + - field_mappings_from_incident properties: form_type: type: string @@ -51,13 +51,13 @@ CreateTemplateRequest: type: array items: $ref: "#/TemplateField" - field_mappings_from_canonical: + field_mappings_from_incident: type: object additionalProperties: type: string description: | - Mapping from canonical FireForm JSON paths to this template's field names. - Key = canonical path (e.g. "fire.cause_category"), value = template field name. + Mapping from FireForm incident-schema JSON paths to this template's field names. + Key = incident-schema path (e.g. "fire.cause_category"), value = template field name. source_standard: type: string nullable: true @@ -136,9 +136,9 @@ TemplateField: type: string nullable: true description: Valid values for enum fields - canonical_mapping: + incident_mapping: type: string - description: JSON path in canonical FireForm schema this field maps from + description: JSON path in the FireForm incident schema this field maps from default_value: nullable: true - description: Default value if canonical field is null + description: Default value if the incident field is null From 417a6d8890267b4b2f8f58f8821e2eb292c1f7db Mon Sep 17 00:00:00 2001 From: chetanr25 Date: Sat, 27 Jun 2026 22:43:18 +0530 Subject: [PATCH 04/13] added required forms model in template's scope --- app/models/__init__.py | 2 ++ app/models/models.py | 32 +++++++++++++++++++++++++++++++- 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/app/models/__init__.py b/app/models/__init__.py index bba2eec..1a24a34 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -4,6 +4,7 @@ Extraction, Form, FormSubmission, + FormTemplate, Incident, Input, Job, @@ -13,6 +14,7 @@ __all__ = [ "Template", + "FormTemplate", "FormSubmission", "Job", "Input", diff --git a/app/models/models.py b/app/models/models.py index cb9a5ff..d10ba0a 100644 --- a/app/models/models.py +++ b/app/models/models.py @@ -16,6 +16,7 @@ OutputFormat, PeriodType, ReportStatus, + TemplateStatus, ) @@ -92,7 +93,7 @@ class Extraction(SQLModel, table=True): model_used: str | None = None processing_time_seconds: float | None = None # Full IncidentContract superset blob; stores partial result while processing, - # final canonical JSON when status=completed. + # final incident JSON when status=completed. incident_contract: dict | None = Field(default=None, sa_column=Column(JSON)) # Audit trail of manual corrections applied via PATCH /extract/{id}. corrections: list | None = Field(default=None, sa_column=Column(JSON)) @@ -143,6 +144,35 @@ class Form(SQLModel, table=True): updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) +class FormTemplate(SQLModel, table=True): + """Contract Layer 6 form template registry (path/templates.yaml). + + Distinct from the legacy prototype `Template` (int PK + uploaded PDF): this + is the standards registry keyed by `form_type`, holding incident-schema field + definitions and mappings. `field_count` and `last_updated` are derived in + the response schemas (len(fields) / updated_at.date()), not stored. + """ + + __tablename__ = "form_templates" + + template_id: UUID = Field(default_factory=uuid4, primary_key=True) + form_type: str = Field(sa_column=Column(AutoString, nullable=False, unique=True, index=True)) + display_name: str + jurisdiction: str + agency_type: str | None = None + # List of TemplateField objects (see app/api/schemas/templates.py). + fields: list = Field(sa_column=Column(JSON, nullable=False)) + field_mappings_from_incident: dict = Field(sa_column=Column(JSON, nullable=False)) + source_standard: str | None = None + pdf_template_ref: str | None = None + version: str = Field(default="1.0") + status: TemplateStatus = Field( + default=TemplateStatus.active, sa_column=Column(AutoString, nullable=False) + ) + created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) + updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) + + class Report(SQLModel, table=True): __tablename__ = "reports" From 87540fb4a8dc2ee2fcce757942074d9693fbc6d8 Mon Sep 17 00:00:00 2001 From: chetanr25 Date: Sat, 27 Jun 2026 23:19:44 +0530 Subject: [PATCH 05/13] Implement templates API with all five contract endpoints --- app/api/routes/form_templates.py | 56 +++++++++++ app/db/repositories.py | 40 +++++--- app/services/form_templates.py | 156 +++++++++++++++++++++++++++++++ 3 files changed, 240 insertions(+), 12 deletions(-) create mode 100644 app/api/routes/form_templates.py create mode 100644 app/services/form_templates.py diff --git a/app/api/routes/form_templates.py b/app/api/routes/form_templates.py new file mode 100644 index 0000000..1029b4f --- /dev/null +++ b/app/api/routes/form_templates.py @@ -0,0 +1,56 @@ +"""Contract Layer 6 template registry endpoints (contracts/path/templates.yaml). + +Serves the form-template registry at /api/v1/templates, backed by the +UUID-keyed `FormTemplate` model. Handlers are thin — business logic lives in +app/services/form_templates.py. The legacy prototype template routes (upload / +create / make-fillable / preview / delete) were removed in the contract +migration; the legacy int-PK `Template` model survives only as the lookup target +of the fill pipeline (forms.py / jobs.py / tasks/fill.py). +""" + +from uuid import UUID + +from fastapi import APIRouter, Depends, Query +from sqlmodel import Session + +from app.api.deps import get_db +from app.api.schemas.templates import ( + CreateTemplateRequest, + TemplateDetail, + TemplateFieldsResponse, + TemplateSummary, +) +from app.services import form_templates as service + +router = APIRouter(prefix="/templates", tags=["templates"]) + + +@router.get("", response_model=list[TemplateSummary]) +def list_templates(db: Session = Depends(get_db)): + return service.list_templates(db) + + +@router.post("", response_model=TemplateDetail, status_code=201) +def create_template(body: CreateTemplateRequest, db: Session = Depends(get_db)): + return service.create_template(db, body) + + +@router.get("/{template_id}", response_model=TemplateDetail) +def get_template(template_id: UUID, db: Session = Depends(get_db)): + return service.get_template(db, template_id) + + +@router.put("/{template_id}", response_model=TemplateDetail) +def replace_template( + template_id: UUID, body: CreateTemplateRequest, db: Session = Depends(get_db) +): + return service.replace_template(db, template_id, body) + + +@router.get("/{template_id}/fields", response_model=TemplateFieldsResponse) +def get_template_fields( + template_id: UUID, + required_only: bool = Query(False, description="Return only required fields"), + db: Session = Depends(get_db), +): + return service.get_template_fields(db, template_id, required_only) diff --git a/app/db/repositories.py b/app/db/repositories.py index 9186d61..d329d96 100644 --- a/app/db/repositories.py +++ b/app/db/repositories.py @@ -2,23 +2,44 @@ from sqlmodel import Session, select -from app.models import Template, FormSubmission, Job, Input +from app.models import Template, FormSubmission, FormTemplate, Job, Input -# Templates -def create_template(session: Session, template: Template) -> Template: +# Templates (legacy fill pipeline - read-only lookup, consumed by forms/jobs/tasks) +def get_template(session: Session, template_id: int) -> Template | None: + return session.get(Template, template_id) + + +# Form templates (contract Layer 6 registry) +def create_form_template(session: Session, template: FormTemplate) -> FormTemplate: session.add(template) session.commit() session.refresh(template) return template -def get_template(session: Session, template_id: int) -> Template | None: - return session.get(Template, template_id) + +def get_form_template(session: Session, template_id: UUID) -> FormTemplate | None: + return session.get(FormTemplate, template_id) + + +def get_form_template_by_form_type(session: Session, form_type: str) -> FormTemplate | None: + statement = select(FormTemplate).where(FormTemplate.form_type == form_type) + return session.exec(statement).first() -def list_templates(session: Session) -> list[Template]: - statement = select(Template).order_by(Template.created_at.desc(), Template.id.desc()) +def list_form_templates(session: Session) -> list[FormTemplate]: + statement = select(FormTemplate).order_by( + FormTemplate.created_at.desc(), FormTemplate.template_id + ) return list(session.exec(statement)) + +def update_form_template(session: Session, template: FormTemplate) -> FormTemplate: + session.add(template) + session.commit() + session.refresh(template) + return template + + # Forms def create_form(session: Session, form: FormSubmission) -> FormSubmission: session.add(form) @@ -56,11 +77,6 @@ def update_job(session: Session, job: Job) -> Job: return job -def delete_template(session: Session, template: Template) -> None: - session.delete(template) - session.commit() - - def get_form_submission(session: Session, submission_id: int) -> FormSubmission | None: return session.get(FormSubmission, submission_id) diff --git a/app/services/form_templates.py b/app/services/form_templates.py new file mode 100644 index 0000000..8a3b5b5 --- /dev/null +++ b/app/services/form_templates.py @@ -0,0 +1,156 @@ +"""Business logic for the contract Layer 6 template registry. + +Sits between the route handlers (app/api/routes/form_templates.py) and the +repositories. No FastAPI imports here — handlers do HTTP, this does the work: +validation/conflict checks, ORM construction, and ORM -> response mapping +(including the derived `field_count` / `last_updated`). +""" + +from datetime import datetime, timezone +from uuid import UUID + +from sqlmodel import Session + +from app.api.schemas.templates import ( + CreateTemplateRequest, + TemplateDetail, + TemplateField, + TemplateFieldsResponse, + TemplateSummary, +) +from app.core.errors.base import AppError +from app.db.repositories import ( + create_form_template, + get_form_template, + get_form_template_by_form_type, + list_form_templates, + update_form_template, +) +from app.models import FormTemplate + + +# --------------------------------------------------------------------------- +# Mapping helpers (ORM -> response schema). field_count / last_updated are +# derived here rather than stored on the model. +# --------------------------------------------------------------------------- +def _field_count(template: FormTemplate) -> int: + return len(template.fields or []) + + +def _to_summary(template: FormTemplate) -> TemplateSummary: + return TemplateSummary( + template_id=template.template_id, + form_type=template.form_type, + display_name=template.display_name, + jurisdiction=template.jurisdiction, + agency_type=template.agency_type, + version=template.version, + last_updated=template.updated_at.date(), + field_count=_field_count(template), + status=template.status, + ) + + +def _to_detail(template: FormTemplate) -> TemplateDetail: + return TemplateDetail( + template_id=template.template_id, + form_type=template.form_type, + display_name=template.display_name, + jurisdiction=template.jurisdiction, + agency_type=template.agency_type, + fields=template.fields, + field_mappings_from_incident=template.field_mappings_from_incident, + source_standard=template.source_standard, + pdf_template_ref=template.pdf_template_ref, + version=template.version, + last_updated=template.updated_at.date(), + field_count=_field_count(template), + status=template.status, + created_at=template.created_at, + updated_at=template.updated_at, + ) + + +def _require_template(db: Session, template_id: UUID) -> FormTemplate: + template = get_form_template(db, template_id) + if not template: + raise AppError( + f"Template {template_id} not found", + status_code=404, + error_code="TEMPLATE_NOT_FOUND", + ) + return template + + +# --------------------------------------------------------------------------- +# Operations +# --------------------------------------------------------------------------- +def list_templates(db: Session) -> list[TemplateSummary]: + return [_to_summary(t) for t in list_form_templates(db)] + + +def create_template(db: Session, body: CreateTemplateRequest) -> TemplateDetail: + if get_form_template_by_form_type(db, body.form_type): + raise AppError( + f"Template with form_type '{body.form_type}' already exists", + status_code=409, + error_code="TEMPLATE_EXISTS", + ) + + template = FormTemplate( + form_type=body.form_type, + display_name=body.display_name, + jurisdiction=body.jurisdiction, + agency_type=body.agency_type, + fields=[f.model_dump() for f in body.fields], + field_mappings_from_incident=body.field_mappings_from_incident, + source_standard=body.source_standard, + pdf_template_ref=body.pdf_template_ref, + ) + return _to_detail(create_form_template(db, template)) + + +def get_template(db: Session, template_id: UUID) -> TemplateDetail: + return _to_detail(_require_template(db, template_id)) + + +def replace_template( + db: Session, template_id: UUID, body: CreateTemplateRequest +) -> TemplateDetail: + template = _require_template(db, template_id) + + # Contract defines a 409 TEMPLATE_IN_USE when submitted incidents reference + # this template. The contract forms/incidents layers tie records to + # extract_id + form_type, never template_id, so there is no linkage to query + # yet. Once a form_type<->submission link exists, gate the update here. + # TODO(contract): enforce 409 TEMPLATE_IN_USE. + + template.form_type = body.form_type + template.display_name = body.display_name + template.jurisdiction = body.jurisdiction + template.agency_type = body.agency_type + template.fields = [f.model_dump() for f in body.fields] + template.field_mappings_from_incident = body.field_mappings_from_incident + template.source_standard = body.source_standard + template.pdf_template_ref = body.pdf_template_ref + template.updated_at = datetime.now(timezone.utc) + return _to_detail(update_form_template(db, template)) + + +def get_template_fields( + db: Session, template_id: UUID, required_only: bool +) -> TemplateFieldsResponse: + template = _require_template(db, template_id) + + fields = [TemplateField(**f) for f in template.fields] + required = [f for f in fields if f.required] + selected = required if required_only else fields + + return TemplateFieldsResponse( + template_id=template.template_id, + form_type=template.form_type, + total_fields=len(fields), + required_fields=len(required), + optional_fields=len(fields) - len(required), + fields=selected, + ) From bedec9a68f309e963f92e30223a0db30457a0f04 Mon Sep 17 00:00:00 2001 From: chetanr25 Date: Sat, 27 Jun 2026 23:21:22 +0530 Subject: [PATCH 06/13] alembic migration file - form templates model --- alembic/versions/003_form_templates.py | 55 ++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 alembic/versions/003_form_templates.py diff --git a/alembic/versions/003_form_templates.py b/alembic/versions/003_form_templates.py new file mode 100644 index 0000000..5762b65 --- /dev/null +++ b/alembic/versions/003_form_templates.py @@ -0,0 +1,55 @@ +"""form_templates — contract Layer 6 template registry table. + +Revision ID: 003 +Revises: 002 +Create Date: 2026-06-26 + +Distinct from the legacy `template` table (int PK + uploaded PDF). This is the +standards registry keyed by `form_type`, holding incident-schema field definitions and +mappings. `fields` and `field_mappings_from_incident` use sa.JSON for +consistency with migrations 001/002 and SQLite test-harness compatibility. + +`form_type` is a plain VARCHAR (not a Postgres ENUM): custom jurisdictions are +registered here and are not part of the built-in FormType enum. +""" + +from collections.abc import Sequence + +from alembic import op +import sqlalchemy as sa +import sqlmodel + +revision: str = "003" +down_revision: str | None = "002" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.create_table( + "form_templates", + sa.Column("template_id", sa.Uuid(), primary_key=True), + sa.Column("form_type", sqlmodel.sql.sqltypes.AutoString, nullable=False), + sa.Column("display_name", sqlmodel.sql.sqltypes.AutoString, nullable=False), + sa.Column("jurisdiction", sqlmodel.sql.sqltypes.AutoString, nullable=False), + sa.Column("agency_type", sqlmodel.sql.sqltypes.AutoString, nullable=True), + sa.Column("fields", sa.JSON, nullable=False), + sa.Column("field_mappings_from_incident", sa.JSON, nullable=False), + sa.Column("source_standard", sqlmodel.sql.sqltypes.AutoString, nullable=True), + sa.Column("pdf_template_ref", sqlmodel.sql.sqltypes.AutoString, nullable=True), + sa.Column("version", sqlmodel.sql.sqltypes.AutoString, nullable=False), + sa.Column("status", sqlmodel.sql.sqltypes.AutoString, nullable=False), + sa.Column("created_at", sa.DateTime, nullable=False), + sa.Column("updated_at", sa.DateTime, nullable=False), + ) + op.create_index( + "ix_form_templates_form_type", + "form_templates", + ["form_type"], + unique=True, + ) + + +def downgrade() -> None: + op.drop_index("ix_form_templates_form_type", table_name="form_templates") + op.drop_table("form_templates") From 58f0d72be7f181d97b12f064b34e45e2c446f150 Mon Sep 17 00:00:00 2001 From: chetanr25 Date: Sat, 27 Jun 2026 23:28:13 +0530 Subject: [PATCH 07/13] feat(contracts): add visual layout to template fields --- contracts/path/templates.yaml | 45 +++++++++++++++-- contracts/schemas/template.yaml | 85 ++++++++++++++++++++++++++++----- 2 files changed, 114 insertions(+), 16 deletions(-) diff --git a/contracts/path/templates.yaml b/contracts/path/templates.yaml index 834e3b8..563ae46 100644 --- a/contracts/path/templates.yaml +++ b/contracts/path/templates.yaml @@ -75,6 +75,7 @@ templates: display_name: "Texas State Fire Marshal Incident Report" jurisdiction: "US-TX" agency_type: "fire_department" + pdf_template_ref: "templates/state_texas.pdf" fields: - field_name: "incident_number" field_type: "string" @@ -82,6 +83,16 @@ templates: max_length: 20 description: "State-assigned incident number" incident_mapping: "report_metadata.incident_number" + layout: + page: 0 + x: 188.33 + y: 621.33 + width: 127.33 + height: 28.67 + font: "Helvetica" + font_size: 10 + color: "#000000" + align: "left" - field_name: "fire_cause" field_type: "enum" required: true @@ -91,9 +102,25 @@ templates: - "intentional" - "undetermined" incident_mapping: "fire.cause_category" - field_mappings_from_incident: - "report_metadata.incident_number": "incident_number" - "fire.cause_category": "fire_cause" + layout: + page: 0 + x: 188.33 + y: 560.0 + width: 200.0 + height: 18.0 + - field_name: "report_footer" + field_type: "string" + required: false + static_text: "Generated by FireForm" + incident_mapping: null + layout: + page: 0 + x: 72.0 + y: 40.0 + width: 300.0 + height: 12.0 + font_size: 8 + align: "center" responses: "201": description: Template created @@ -254,11 +281,23 @@ template_fields: required: true description: "Primary incident type code" incident_mapping: "incident.types[0].neris_code" + layout: + page: 0 + x: 120.0 + y: 680.0 + width: 160.0 + height: 18.0 - field_name: "incident_date" field_type: "date" required: true description: "Date of incident" incident_mapping: "incident.start_datetime" + layout: + page: 0 + x: 120.0 + y: 655.0 + width: 120.0 + height: 18.0 "404": description: Template not found content: diff --git a/contracts/schemas/template.yaml b/contracts/schemas/template.yaml index 9894fdc..f9ce921 100644 --- a/contracts/schemas/template.yaml +++ b/contracts/schemas/template.yaml @@ -7,14 +7,17 @@ TemplateSummary: type: string format: uuid form_type: - $ref: "../schemas/enums.yaml#/FormType" + type: string + description: Unique form type identifier (built-in or custom jurisdiction) display_name: type: string jurisdiction: type: string + nullable: true description: Jurisdiction code (e.g. "US-Federal", "US-CA", "US-GA") agency_type: type: string + nullable: true version: type: string last_updated: @@ -34,9 +37,7 @@ CreateTemplateRequest: required: - form_type - display_name - - jurisdiction - fields - - field_mappings_from_incident properties: form_type: type: string @@ -45,19 +46,16 @@ CreateTemplateRequest: type: string jurisdiction: type: string + nullable: true + description: Jurisdiction code. Optional — the visual editor may register a + template before jurisdiction is assigned. agency_type: type: string + nullable: true fields: type: array items: $ref: "#/TemplateField" - field_mappings_from_incident: - type: object - additionalProperties: - type: string - description: | - Mapping from FireForm incident-schema JSON paths to this template's field names. - Key = incident-schema path (e.g. "fire.cause_category"), value = template field name. source_standard: type: string nullable: true @@ -65,7 +63,7 @@ CreateTemplateRequest: pdf_template_ref: type: string nullable: true - description: Reference to the PDF template file + description: Reference to the PDF template file the layout coordinates apply to Template: allOf: @@ -138,7 +136,68 @@ TemplateField: description: Valid values for enum fields incident_mapping: type: string - description: JSON path in the FireForm incident schema this field maps from + nullable: true + description: | + JSON path in the FireForm incident schema this field pulls its value from + (e.g. "fire.cause_category"). Null for a static field. Exactly one of + incident_mapping or static_text is expected. + static_text: + type: string + nullable: true + description: | + Fixed text drawn into the field instead of a mapped value. Null for a + data-mapped field. Mutually exclusive with incident_mapping. default_value: nullable: true - description: Default value if the incident field is null + description: Default value if the mapped incident field is null + layout: + nullable: true + description: Visual placement of the field on the PDF. Null for fields with + no fixed position. + allOf: + - $ref: "#/TemplateFieldLayout" + +TemplateFieldLayout: + type: object + description: | + Visual placement of a field on the PDF page. Coordinates are in PDF points + with the origin at the bottom-left of the page, so the field box runs from + start = (x, y) to end = (x + width, y + height). The editor is responsible + for converting rendered-canvas pixels to PDF points before saving. + required: + - page + - x + - y + - width + - height + properties: + page: + type: integer + description: Zero-based page index + x: + type: number + description: Lower-left X of the box, in PDF points + y: + type: number + description: Lower-left Y of the box, in PDF points (origin bottom-left) + width: + type: number + height: + type: number + font: + type: string + default: Helvetica + font_size: + type: number + default: 10 + color: + type: string + default: "#000000" + description: Hex color, e.g. "#000000" + align: + type: string + enum: + - left + - center + - right + default: left From 6de2b97be64dc53fb3a74773627f4fd38cdef563 Mon Sep 17 00:00:00 2001 From: chetanr25 Date: Sat, 27 Jun 2026 23:46:59 +0530 Subject: [PATCH 08/13] Updated schemas and models for templates after visualisation update in openapi contract --- app/api/schemas/templates.py | 122 +++++++++++++++++++++++++++++++---- app/models/models.py | 7 +- 2 files changed, 114 insertions(+), 15 deletions(-) diff --git a/app/api/schemas/templates.py b/app/api/schemas/templates.py index f70ecb2..5d52c8c 100644 --- a/app/api/schemas/templates.py +++ b/app/api/schemas/templates.py @@ -1,27 +1,103 @@ +import re from datetime import date, datetime from uuid import UUID -from pydantic import BaseModel +from pydantic import BaseModel, Field, field_validator, model_validator -from app.api.schemas.enums import TemplateFieldType, TemplateStatus +from app.api.schemas.enums import TemplateFieldType, TemplateStatus, TextAlign + +_HEX_COLOR = re.compile(r"^#[0-9A-Fa-f]{6}$") +_FORM_TYPE = re.compile(r"^[a-z0-9_-]+$") # --------------------------------------------------------------------------- # Contract Layer 6 schemas (contracts/schemas/template.yaml) # --------------------------------------------------------------------------- +class TemplateFieldLayout(BaseModel): + """Visual placement of a field on the PDF (schemas/template.yaml#/TemplateFieldLayout). + + Coordinates are PDF points with the origin at the bottom-left of the page: + box = start (x, y) .. end (x + width, y + height). + """ + + page: int = Field(ge=0, description="Zero-based page index") + x: float = Field(ge=0, description="Lower-left X in PDF points") + y: float = Field(ge=0, description="Lower-left Y in PDF points (origin bottom-left)") + width: float = Field(gt=0) + height: float = Field(gt=0) + font: str = "Helvetica" + font_size: float = Field(default=10, gt=0) + color: str = "#000000" + align: TextAlign = TextAlign.left + + @field_validator("font") + @classmethod + def _font_not_blank(cls, v: str) -> str: + if not v.strip(): + raise ValueError("font must not be blank") + return v + + @field_validator("color") + @classmethod + def _color_is_hex(cls, v: str) -> str: + if not _HEX_COLOR.match(v): + raise ValueError('color must be a hex string like "#000000"') + return v + + class TemplateField(BaseModel): - """One field definition within a template (schemas/template.yaml#/TemplateField).""" + """One field definition within a template (schemas/template.yaml#/TemplateField). + + A field draws its value from either `incident_mapping` (data binding) or + `static_text` (fixed text) — exactly one. `layout` places it on the PDF. + """ field_name: str field_type: TemplateFieldType required: bool description: str | None = None - max_length: int | None = None - min_value: float | None = None - max_value: float | None = None + max_length: int | None = Field(default=None, gt=0) + min_value: float | None = Field(default=None, gt=0) + max_value: float | None = Field(default=None, gt=0) allowed_values: list[str] | None = None incident_mapping: str | None = None + static_text: str | None = None default_value: object | None = None + layout: TemplateFieldLayout | None = None + + @field_validator("field_name") + @classmethod + def _name_not_blank(cls, v: str) -> str: + v = v.strip() + if not v: + raise ValueError("field_name must not be empty") + return v + + @model_validator(mode="after") + def _check_field(self) -> "TemplateField": + has_mapping = bool(self.incident_mapping and self.incident_mapping.strip()) + has_static = self.static_text is not None + if has_mapping and has_static: + raise ValueError( + f"field '{self.field_name}': set only one of incident_mapping or static_text" + ) + if not has_mapping and not has_static: + raise ValueError( + f"field '{self.field_name}': one of incident_mapping or static_text is required" + ) + if self.field_type == TemplateFieldType.enum and not self.allowed_values: + raise ValueError( + f"field '{self.field_name}': allowed_values is required when field_type is 'enum'" + ) + if ( + self.min_value is not None + and self.max_value is not None + and self.min_value > self.max_value + ): + raise ValueError( + f"field '{self.field_name}': min_value must be <= max_value" + ) + return self class CreateTemplateRequest(BaseModel): @@ -29,13 +105,37 @@ class CreateTemplateRequest(BaseModel): form_type: str display_name: str - jurisdiction: str + jurisdiction: str | None = None agency_type: str | None = None - fields: list[TemplateField] - field_mappings_from_incident: dict[str, str] + fields: list[TemplateField] = Field(min_length=1) source_standard: str | None = None pdf_template_ref: str | None = None + @field_validator("form_type") + @classmethod + def _form_type_slug(cls, v: str) -> str: + v = v.strip() + if not _FORM_TYPE.match(v): + raise ValueError( + "form_type must contain only lowercase letters, digits, underscores or hyphens" + ) + return v + + @field_validator("display_name") + @classmethod + def _display_not_blank(cls, v: str) -> str: + if not v.strip(): + raise ValueError("display_name must not be empty") + return v + + @model_validator(mode="after") + def _unique_field_names(self) -> "CreateTemplateRequest": + names = [f.field_name for f in self.fields] + dupes = sorted({n for n in names if names.count(n) > 1}) + if dupes: + raise ValueError(f"duplicate field_name(s): {', '.join(dupes)}") + return self + class TemplateSummary(BaseModel): """List item (schemas/template.yaml#/TemplateSummary).""" @@ -43,7 +143,7 @@ class TemplateSummary(BaseModel): template_id: UUID form_type: str display_name: str - jurisdiction: str + jurisdiction: str | None = None agency_type: str | None = None version: str last_updated: date @@ -67,7 +167,7 @@ class TemplateDetail(CreateTemplateRequest): class TemplateFieldsResponse(BaseModel): - """GET /templates/{id}/fields response (path/templates.yaml#L226-L244).""" + """GET /templates/{id}/fields response (path/templates.yaml#/template_fields).""" template_id: UUID form_type: str diff --git a/app/models/models.py b/app/models/models.py index d10ba0a..eb0c480 100644 --- a/app/models/models.py +++ b/app/models/models.py @@ -149,8 +149,8 @@ class FormTemplate(SQLModel, table=True): Distinct from the legacy prototype `Template` (int PK + uploaded PDF): this is the standards registry keyed by `form_type`, holding incident-schema field - definitions and mappings. `field_count` and `last_updated` are derived in - the response schemas (len(fields) / updated_at.date()), not stored. + definitions plus their visual `layout`. `field_count` and `last_updated` are + derived in the response schemas (len(fields) / updated_at.date()), not stored. """ __tablename__ = "form_templates" @@ -158,11 +158,10 @@ class FormTemplate(SQLModel, table=True): template_id: UUID = Field(default_factory=uuid4, primary_key=True) form_type: str = Field(sa_column=Column(AutoString, nullable=False, unique=True, index=True)) display_name: str - jurisdiction: str + jurisdiction: str | None = None agency_type: str | None = None # List of TemplateField objects (see app/api/schemas/templates.py). fields: list = Field(sa_column=Column(JSON, nullable=False)) - field_mappings_from_incident: dict = Field(sa_column=Column(JSON, nullable=False)) source_standard: str | None = None pdf_template_ref: str | None = None version: str = Field(default="1.0") From de44766315bee974b27d2c134100721cb7d0c7f0 Mon Sep 17 00:00:00 2001 From: chetanr25 Date: Sat, 27 Jun 2026 23:48:35 +0530 Subject: [PATCH 09/13] fix: updating forgoten enums and class models in services --- app/api/schemas/enums.py | 6 ++++++ app/services/form_templates.py | 7 ++----- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/app/api/schemas/enums.py b/app/api/schemas/enums.py index 06c9798..b94c399 100644 --- a/app/api/schemas/enums.py +++ b/app/api/schemas/enums.py @@ -78,6 +78,12 @@ class TemplateStatus(str, Enum): draft = "draft" +class TextAlign(str, Enum): + left = "left" + center = "center" + right = "right" + + class TemplateFieldType(str, Enum): string = "string" integer = "integer" diff --git a/app/services/form_templates.py b/app/services/form_templates.py index 8a3b5b5..97ce656 100644 --- a/app/services/form_templates.py +++ b/app/services/form_templates.py @@ -59,7 +59,6 @@ def _to_detail(template: FormTemplate) -> TemplateDetail: jurisdiction=template.jurisdiction, agency_type=template.agency_type, fields=template.fields, - field_mappings_from_incident=template.field_mappings_from_incident, source_standard=template.source_standard, pdf_template_ref=template.pdf_template_ref, version=template.version, @@ -102,8 +101,7 @@ def create_template(db: Session, body: CreateTemplateRequest) -> TemplateDetail: display_name=body.display_name, jurisdiction=body.jurisdiction, agency_type=body.agency_type, - fields=[f.model_dump() for f in body.fields], - field_mappings_from_incident=body.field_mappings_from_incident, + fields=[f.model_dump(mode="json") for f in body.fields], source_standard=body.source_standard, pdf_template_ref=body.pdf_template_ref, ) @@ -129,8 +127,7 @@ def replace_template( template.display_name = body.display_name template.jurisdiction = body.jurisdiction template.agency_type = body.agency_type - template.fields = [f.model_dump() for f in body.fields] - template.field_mappings_from_incident = body.field_mappings_from_incident + template.fields = [f.model_dump(mode="json") for f in body.fields] template.source_standard = body.source_standard template.pdf_template_ref = body.pdf_template_ref template.updated_at = datetime.now(timezone.utc) From 053f6b76b31a4c638a7683f71e41cc08fff9f2f7 Mon Sep 17 00:00:00 2001 From: chetanr25 Date: Sat, 27 Jun 2026 23:49:23 +0530 Subject: [PATCH 10/13] added templates routes --- app/api/router.py | 4 ++-- app/api/routes/__init__.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/app/api/router.py b/app/api/router.py index cc59847..f05d2db 100644 --- a/app/api/router.py +++ b/app/api/router.py @@ -1,10 +1,10 @@ from fastapi import APIRouter -from app.api.routes import forms, input, jobs, system, templates, weather, zipcode +from app.api.routes import forms, form_templates, input, jobs, system, weather, zipcode from app.core.config import API_PREFIX api_router = APIRouter() -api_router.include_router(templates.router, prefix=API_PREFIX) +api_router.include_router(form_templates.router, prefix=API_PREFIX) api_router.include_router(forms.router, prefix=API_PREFIX) api_router.include_router(system.router, prefix=API_PREFIX) api_router.include_router(jobs.router, prefix=API_PREFIX) diff --git a/app/api/routes/__init__.py b/app/api/routes/__init__.py index 2264aee..17c68a8 100644 --- a/app/api/routes/__init__.py +++ b/app/api/routes/__init__.py @@ -1,3 +1,3 @@ -from . import templates, forms +from . import form_templates, forms -__all__ = ["templates", "forms"] +__all__ = ["form_templates", "forms"] From 4d0e40e03258610ae81edba44ab92b39961d5627 Mon Sep 17 00:00:00 2001 From: chetanr25 Date: Sat, 27 Jun 2026 23:49:35 +0530 Subject: [PATCH 11/13] updating alembic migrations --- alembic/versions/003_form_templates.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/alembic/versions/003_form_templates.py b/alembic/versions/003_form_templates.py index 5762b65..6c7b6f5 100644 --- a/alembic/versions/003_form_templates.py +++ b/alembic/versions/003_form_templates.py @@ -5,9 +5,10 @@ Create Date: 2026-06-26 Distinct from the legacy `template` table (int PK + uploaded PDF). This is the -standards registry keyed by `form_type`, holding incident-schema field definitions and -mappings. `fields` and `field_mappings_from_incident` use sa.JSON for -consistency with migrations 001/002 and SQLite test-harness compatibility. +standards registry keyed by `form_type`, holding incident-schema field definitions +plus their visual layout. The `fields` JSON column (list of TemplateField objects, +each with a nested `layout`) uses sa.JSON for consistency with migrations 001/002 +and SQLite test-harness compatibility. `form_type` is a plain VARCHAR (not a Postgres ENUM): custom jurisdictions are registered here and are not part of the built-in FormType enum. @@ -31,10 +32,9 @@ def upgrade() -> None: sa.Column("template_id", sa.Uuid(), primary_key=True), sa.Column("form_type", sqlmodel.sql.sqltypes.AutoString, nullable=False), sa.Column("display_name", sqlmodel.sql.sqltypes.AutoString, nullable=False), - sa.Column("jurisdiction", sqlmodel.sql.sqltypes.AutoString, nullable=False), + sa.Column("jurisdiction", sqlmodel.sql.sqltypes.AutoString, nullable=True), sa.Column("agency_type", sqlmodel.sql.sqltypes.AutoString, nullable=True), sa.Column("fields", sa.JSON, nullable=False), - sa.Column("field_mappings_from_incident", sa.JSON, nullable=False), sa.Column("source_standard", sqlmodel.sql.sqltypes.AutoString, nullable=True), sa.Column("pdf_template_ref", sqlmodel.sql.sqltypes.AutoString, nullable=True), sa.Column("version", sqlmodel.sql.sqltypes.AutoString, nullable=False), From f188299db0d5b32011cfafef5e90fa2024d5018c Mon Sep 17 00:00:00 2001 From: chetanr25 Date: Sat, 27 Jun 2026 23:50:50 +0530 Subject: [PATCH 12/13] feat: added tests fo template APIs --- app/db/repositories.py | 1 - tests/conftest.py | 31 +++-- tests/test_api.py | 141 +++---------------- tests/test_deletion.py | 125 +++-------------- tests/test_jobs.py | 26 ++-- tests/test_migrations.py | 60 +++++++- tests/test_templates_v1.py | 273 +++++++++++++++++++++++++++++++++++++ 7 files changed, 398 insertions(+), 259 deletions(-) create mode 100644 tests/test_templates_v1.py diff --git a/app/db/repositories.py b/app/db/repositories.py index d329d96..ad3fdc9 100644 --- a/app/db/repositories.py +++ b/app/db/repositories.py @@ -8,7 +8,6 @@ def get_template(session: Session, template_id: int) -> Template | None: return session.get(Template, template_id) - # Form templates (contract Layer 6 registry) def create_form_template(session: Session, template: FormTemplate) -> FormTemplate: session.add(template) diff --git a/tests/conftest.py b/tests/conftest.py index 1f41d67..f0822a1 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -15,7 +15,7 @@ from app.main import app from app.api.deps import get_db from app.core.config import API_PREFIX # single source of truth for all tests -from app.models import Template, FormSubmission, Job, Input, Extraction, Incident, Form, Report # noqa: F401 — registers tables +from app.models import Template, FormSubmission, FormTemplate, Job, Input, Extraction, Incident, Form, Report # noqa: F401 — registers tables # --------------------------------------------------------------------------- # In-memory database @@ -85,18 +85,25 @@ def pdf_upload(pdf_bytes): # --------------------------------------------------------------------------- @pytest.fixture def mock_controller(): - """Patch Controller so create_template / fill_form don't touch the FS or LLM.""" - with patch("app.api.routes.templates.Controller") as tpl_cls, \ - patch("app.api.routes.forms.Controller") as form_cls: - tpl_instance = MagicMock() - tpl_instance.create_template.return_value = "src/inputs/test_template.pdf" - tpl_cls.return_value = tpl_instance - + """Patch the forms Controller so fill_form doesn't touch the FS or LLM.""" + with patch("app.api.routes.forms.Controller") as form_cls: form_instance = MagicMock() form_instance.fill_form.return_value = "src/outputs/filled_output.pdf" form_cls.return_value = form_instance - yield { - "template_ctrl": tpl_instance, - "form_ctrl": form_instance, - } + yield {"form_ctrl": form_instance} + + +@pytest.fixture +def seed_template(): + """Insert a legacy Template row directly (the /templates/create endpoint was + removed in the contract migration). Returns a factory -> template id.""" + def _make(name: str = "T", pdf_path: str = "src/inputs/t.pdf", fields: dict | None = None) -> int: + with Session(_engine) as session: + tpl = Template(name=name, pdf_path=pdf_path, fields=fields if fields is not None else {"name": "string"}) + session.add(tpl) + session.commit() + session.refresh(tpl) + return tpl.id + + return _make diff --git a/tests/test_api.py b/tests/test_api.py index 32104ae..3c8cbc4 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -84,104 +84,21 @@ def test_list_templates_ordering(self, db): class TestTemplateEndpoints: def test_list_templates_empty(self, client): + """Contract registry list is empty until a template is registered.""" resp = client.get(f"{API_PREFIX}/templates") assert resp.status_code == 200 assert resp.json() == [] - def test_create_template(self, client, mock_controller): - payload = { - "name": "Fire Report", - "pdf_path": "src/inputs/fire_report.pdf", - "fields": { - "Name": "string", - "Date": "string", - "Location": "string", - }, - } - resp = client.post(f"{API_PREFIX}/templates/create", json=payload) - assert resp.status_code == 200 - - data = resp.json() - assert data["id"] is not None - assert data["name"] == "Fire Report" - assert data["fields"]["Location"] == "string" - # Plain create just persists the row; commonforms only runs via - # the separate /make-fillable endpoint. - mock_controller["template_ctrl"].create_template.assert_not_called() - - def test_create_then_list(self, client, mock_controller): - """Creating a template should make it appear in the list.""" - client.post(f"{API_PREFIX}/templates/create", json={ - "name": "T1", - "pdf_path": "a.pdf", - "fields": {"f": "string"}, - }) - resp = client.get(f"{API_PREFIX}/templates") - assert resp.status_code == 200 - assert len(resp.json()) == 1 - assert resp.json()[0]["name"] == "T1" - - def test_upload_pdf(self, client, pdf_upload, tmp_path, monkeypatch): - """Upload a valid PDF file.""" - # Point the upload directory inside tmp_path (which is inside the project - # for the path-safety check — we monkeypatch the check). - monkeypatch.setattr( - "app.api.routes.templates.PROJECT_ROOT", - tmp_path, - ) - resp = client.post( - f"{API_PREFIX}/templates/upload", - files=[pdf_upload], - data={"directory": str(tmp_path)}, - ) - assert resp.status_code == 200 - data = resp.json() - assert data["filename"] == "test_form.pdf" - assert data["pdf_path"].endswith(".pdf") - - def test_upload_non_pdf_rejected(self, client): - import io - bad_file = ("file", ("notes.txt", io.BytesIO(b"hello"), "text/plain")) - resp = client.post(f"{API_PREFIX}/templates/upload", files=[bad_file]) - assert resp.status_code == 400 - assert "PDF" in resp.json()["detail"] - - def test_preview_missing_file(self, client): - resp = client.get(f"{API_PREFIX}/templates/preview", params={"path": "src/inputs/nonexistent.pdf"}) - assert resp.status_code == 404 - - def test_directory_traversal_blocked(self, client): - import io - pdf = ("file", ("evil.pdf", io.BytesIO(b"%PDF-1.4"), "application/pdf")) - resp = client.post( - f"{API_PREFIX}/templates/upload", - files=[pdf], - data={"directory": "/etc"}, - ) - assert resp.status_code == 400 - assert "inside the project" in resp.json()["detail"] - # ═══════════════════════════════════════════════════════════════════════════ -# Form fill endpoints +# Form fill endpoints (legacy pipeline — templates seeded directly in the DB +# since the /templates/create endpoint was removed in the contract migration) # ═══════════════════════════════════════════════════════════════════════════ class TestFormEndpoints: - def _seed_template(self, client, mock_controller): - """Helper: create a template and return its ID.""" - resp = client.post(f"{API_PREFIX}/templates/create", json={ - "name": "Employee Form", - "pdf_path": "src/inputs/employee.pdf", - "fields": { - "Employee's name": "string", - "Employee's email": "string", - }, - }) - return resp.json()["id"] - - def test_fill_form_success(self, client, mock_controller): - tpl_id = self._seed_template(client, mock_controller) + def test_fill_form_success(self, client, mock_controller, seed_template): + tpl_id = seed_template() resp = client.post(f"{API_PREFIX}/forms/fill", json={ "template_id": tpl_id, @@ -202,8 +119,8 @@ def test_fill_form_missing_template(self, client, mock_controller): }) assert resp.status_code == 404 - def test_fill_form_template_file_not_found(self, client, mock_controller): - tpl_id = self._seed_template(client, mock_controller) + def test_fill_form_template_file_not_found(self, client, mock_controller, seed_template): + tpl_id = seed_template() mock_controller["form_ctrl"].fill_form.side_effect = FileNotFoundError("PDF template not found") resp = client.post(f"{API_PREFIX}/forms/fill", json={ @@ -283,9 +200,9 @@ def boom(*a, **k): assert resp.status_code == 200 assert resp.json()["models"] == ["qwen2.5:1.5b"] - def test_fill_form_passes_model_override(self, client, mock_controller): + def test_fill_form_passes_model_override(self, client, mock_controller, seed_template): """A `model` in the request reaches Controller.fill_form but isn't persisted.""" - tpl_id = self._seed_template(client, mock_controller) + tpl_id = seed_template() resp = client.post(f"{API_PREFIX}/forms/fill", json={ "template_id": tpl_id, "input_text": "John Doe", @@ -316,45 +233,27 @@ def fake_post(*args, **kwargs): class TestE2EPipeline: """ - Full pipeline: upload PDF → create template → fill form → verify DB state. - This is the critical path that the product depends on. + Legacy fill pipeline: seed template → fill form → verify DB state. + Template registration via API was removed in the contract migration, so the + template is seeded directly in the DB. """ - def test_full_flow(self, client, mock_controller, pdf_upload, tmp_path, monkeypatch, db): - # -- Step 1: Upload a PDF -- - monkeypatch.setattr("app.api.routes.templates.PROJECT_ROOT", tmp_path) - upload_resp = client.post( - f"{API_PREFIX}/templates/upload", - files=[pdf_upload], - data={"directory": str(tmp_path)}, - ) - assert upload_resp.status_code == 200 - uploaded_path = upload_resp.json()["pdf_path"] - assert uploaded_path.endswith(".pdf") - - # -- Step 2: Create a template from the uploaded PDF -- - create_resp = client.post(f"{API_PREFIX}/templates/create", json={ - "name": "Incident Report", - "pdf_path": uploaded_path, - "fields": { + def test_full_flow(self, client, mock_controller, seed_template, db): + # -- Step 1: Seed a template -- + template_id = seed_template( + name="Incident Report", + pdf_path="src/inputs/incident.pdf", + fields={ "Officer name": "string", "Badge number": "string", "Incident date": "string", "Location": "string", "Description": "string", }, - }) - assert create_resp.status_code == 200 - template_id = create_resp.json()["id"] + ) assert template_id is not None - # -- Step 3: Verify template appears in list -- - list_resp = client.get(f"{API_PREFIX}/templates") - assert list_resp.status_code == 200 - templates = list_resp.json() - assert any(t["id"] == template_id for t in templates) - - # -- Step 4: Fill the form -- + # -- Step 2: Fill the form -- fill_resp = client.post(f"{API_PREFIX}/forms/fill", json={ "template_id": template_id, "input_text": ( diff --git a/tests/test_deletion.py b/tests/test_deletion.py index 5c39e43..15fe19b 100644 --- a/tests/test_deletion.py +++ b/tests/test_deletion.py @@ -1,5 +1,5 @@ -"""Tests for DELETE /api/v1/templates/{id}, DELETE /api/v1/forms/{id}, -POST /api/v1/forms/purge, and API-key access control. +"""Tests for DELETE /api/v1/forms/{id}, POST /api/v1/forms/purge, and API-key +access control. """ import io @@ -16,14 +16,14 @@ # Helpers # --------------------------------------------------------------------------- -def _seed_template(client, name="T1", pdf_path="src/inputs/t.pdf"): - resp = client.post(f"{API_PREFIX}/templates/create", json={ - "name": name, - "pdf_path": pdf_path, - "fields": {"name": "string"}, - }) - assert resp.status_code == 200, resp.json() - return resp.json()["id"] +def _seed_template(db, name="T1", pdf_path="src/inputs/t.pdf"): + """Insert a legacy Template row directly — the /templates/create endpoint was + removed in the contract migration.""" + tpl = Template(name=name, pdf_path=pdf_path, fields={"name": "string"}) + db.add(tpl) + db.commit() + db.refresh(tpl) + return tpl.id def _seed_submission(db, template_id, output_pdf_path="src/outputs/out.pdf"): @@ -38,64 +38,6 @@ def _seed_submission(db, template_id, output_pdf_path="src/outputs/out.pdf"): return sub.id -# =========================================================================== -# DELETE /api/v1/templates/{template_id} -# =========================================================================== - -class TestDeleteTemplate: - - def test_delete_template_no_key_required_when_unconfigured(self, client): - """No API key needed when FIREFORM_API_KEY is empty (default).""" - tpl_id = _seed_template(client) - resp = client.delete(f"{API_PREFIX}/templates/{tpl_id}") - assert resp.status_code == 200 - body = resp.json() - assert body["status"] == "success" - - def test_delete_template_removes_from_db(self, client, db): - tpl_id = _seed_template(client) - client.delete(f"{API_PREFIX}/templates/{tpl_id}") - assert db.get(Template, tpl_id) is None - - def test_delete_template_not_found(self, client): - resp = client.delete(f"{API_PREFIX}/templates/99999") - assert resp.status_code == 404 - - def test_delete_template_cascades_submissions(self, client, db): - tpl_id = _seed_template(client) - sub_id = _seed_submission(db, tpl_id) - - client.delete(f"{API_PREFIX}/templates/{tpl_id}") - - assert db.get(FormSubmission, sub_id) is None - assert db.get(Template, tpl_id) is None - - def test_delete_template_deletes_pdf_file(self, client, tmp_path, monkeypatch): - """Verify the template PDF file is removed from disk on delete.""" - monkeypatch.setattr("app.api.routes.templates.PROJECT_ROOT", tmp_path) - pdf_file = tmp_path / "myform.pdf" - pdf_file.write_bytes(b"%PDF-1.4 fake") - - relative_path = "myform.pdf" - tpl_id = _seed_template(client, pdf_path=relative_path) - - client.delete(f"{API_PREFIX}/templates/{tpl_id}") - assert not pdf_file.exists() - - def test_delete_template_deletes_submission_output_pdfs(self, client, db, tmp_path, monkeypatch): - """Output PDFs of related submissions should be wiped on template deletion.""" - monkeypatch.setattr("app.api.routes.templates.PROJECT_ROOT", tmp_path) - - out_pdf = tmp_path / "filled.pdf" - out_pdf.write_bytes(b"%PDF-1.4 filled") - - tpl_id = _seed_template(client, pdf_path="tpl.pdf") - sub_id = _seed_submission(db, tpl_id, output_pdf_path="filled.pdf") - - client.delete(f"{API_PREFIX}/templates/{tpl_id}") - assert not out_pdf.exists() - - # =========================================================================== # DELETE /api/v1/forms/{submission_id} # =========================================================================== @@ -103,14 +45,14 @@ def test_delete_template_deletes_submission_output_pdfs(self, client, db, tmp_pa class TestDeleteSubmission: def test_delete_submission_no_key_when_unconfigured(self, client, db): - tpl_id = _seed_template(client) + tpl_id = _seed_template(db) sub_id = _seed_submission(db, tpl_id) resp = client.delete(f"{API_PREFIX}/forms/{sub_id}") assert resp.status_code == 200 assert resp.json()["status"] == "success" def test_delete_submission_removes_from_db(self, client, db): - tpl_id = _seed_template(client) + tpl_id = _seed_template(db) sub_id = _seed_submission(db, tpl_id) client.delete(f"{API_PREFIX}/forms/{sub_id}") assert db.get(FormSubmission, sub_id) is None @@ -124,7 +66,7 @@ def test_delete_submission_removes_output_pdf(self, client, db, tmp_path, monkey out_pdf = tmp_path / "filled_out.pdf" out_pdf.write_bytes(b"%PDF-1.4") - tpl_id = _seed_template(client) + tpl_id = _seed_template(db) sub_id = _seed_submission(db, tpl_id, output_pdf_path="filled_out.pdf") client.delete(f"{API_PREFIX}/forms/{sub_id}") @@ -151,7 +93,7 @@ def _seed_old_submission(self, db, tpl_id, days_old=40): return sub.id def test_purge_removes_old_submissions(self, client, db): - tpl_id = _seed_template(client) + tpl_id = _seed_template(db) old_id = self._seed_old_submission(db, tpl_id, days_old=40) new_id = _seed_submission(db, tpl_id) # recent @@ -163,7 +105,7 @@ def test_purge_removes_old_submissions(self, client, db): assert db.get(FormSubmission, new_id) is not None def test_purge_nothing_to_remove(self, client, db): - tpl_id = _seed_template(client) + tpl_id = _seed_template(db) _seed_submission(db, tpl_id) # recent resp = client.post(f"{API_PREFIX}/forms/purge?days=30") assert resp.status_code == 200 @@ -174,7 +116,7 @@ def test_purge_removes_output_pdf_file(self, client, db, tmp_path, monkeypatch): out_pdf = tmp_path / "old_filled.pdf" out_pdf.write_bytes(b"%PDF-1.4") - tpl_id = _seed_template(client) + tpl_id = _seed_template(db) old_ts = datetime.now(timezone.utc) - timedelta(days=50) sub = FormSubmission( template_id=tpl_id, @@ -200,43 +142,14 @@ class TestApiKeyAccessControl: def _set_api_key(self, monkeypatch): monkeypatch.setattr("app.api.deps.FIREFORM_API_KEY", "secret-test-key") - def test_delete_template_requires_key_when_set(self, client): - tpl_id = _seed_template(client) - resp = client.delete(f"{API_PREFIX}/templates/{tpl_id}") - assert resp.status_code == 401 - - def test_delete_template_with_valid_x_api_key(self, client): - tpl_id = _seed_template(client) - resp = client.delete( - f"{API_PREFIX}/templates/{tpl_id}", - headers={"X-API-Key": "secret-test-key"}, - ) - assert resp.status_code == 200 - - def test_delete_template_with_bearer_token(self, client): - tpl_id = _seed_template(client) - resp = client.delete( - f"{API_PREFIX}/templates/{tpl_id}", - headers={"Authorization": "Bearer secret-test-key"}, - ) - assert resp.status_code == 200 - - def test_delete_template_wrong_key_rejected(self, client): - tpl_id = _seed_template(client) - resp = client.delete( - f"{API_PREFIX}/templates/{tpl_id}", - headers={"X-API-Key": "wrong-key"}, - ) - assert resp.status_code == 401 - def test_delete_submission_requires_key_when_set(self, client, db): - tpl_id = _seed_template(client) + tpl_id = _seed_template(db) sub_id = _seed_submission(db, tpl_id) resp = client.delete(f"{API_PREFIX}/forms/{sub_id}") assert resp.status_code == 401 def test_delete_submission_with_valid_key(self, client, db): - tpl_id = _seed_template(client) + tpl_id = _seed_template(db) sub_id = _seed_submission(db, tpl_id) resp = client.delete( f"{API_PREFIX}/forms/{sub_id}", @@ -249,7 +162,7 @@ def test_purge_requires_key_when_set(self, client): assert resp.status_code == 401 def test_purge_with_valid_key(self, client, db): - tpl_id = _seed_template(client) + tpl_id = _seed_template(db) resp = client.post( f"{API_PREFIX}/forms/purge?days=30", headers={"X-API-Key": "secret-test-key"}, diff --git a/tests/test_jobs.py b/tests/test_jobs.py index c79e4a4..d1c5504 100644 --- a/tests/test_jobs.py +++ b/tests/test_jobs.py @@ -6,21 +6,13 @@ class TestJobEndpoints: - def _seed_template(self, client): - resp = client.post(f"{API_PREFIX}/templates/create", json={ - "name": "Test Template", - "pdf_path": "test.pdf", - "fields": {"name": "string"}, - }) - return resp.json()["id"] - @patch("app.api.routes.jobs.fill_form_task") - def test_submit_async_single(self, mock_task, client): + def test_submit_async_single(self, mock_task, client, seed_template): mock_result = MagicMock() mock_result.id = "celery-task-id-1" mock_task.delay.return_value = mock_result - tpl_id = self._seed_template(client) + tpl_id = seed_template() resp = client.post(f"{API_PREFIX}/forms/jobs", json={ "template_ids": [tpl_id], "input_text": "John Doe firefighter", @@ -34,14 +26,14 @@ def test_submit_async_single(self, mock_task, client): mock_task.delay.assert_called_once_with(tpl_id, "John Doe firefighter", None) @patch("app.api.routes.jobs.fill_form_task") - def test_submit_async_batch(self, mock_task, client): + def test_submit_async_batch(self, mock_task, client, seed_template): mock_task.delay.side_effect = [ MagicMock(id="task-1"), MagicMock(id="task-2"), ] - t1 = self._seed_template(client) - t2 = self._seed_template(client) + t1 = seed_template() + t2 = seed_template() resp = client.post(f"{API_PREFIX}/forms/jobs", json={ "template_ids": [t1, t2], "input_text": "batch input", @@ -62,10 +54,10 @@ def test_submit_async_missing_template(self, mock_task, client): mock_task.delay.assert_not_called() @patch("app.api.routes.jobs.fill_form_task") - def test_get_job_status(self, mock_task, client): + def test_get_job_status(self, mock_task, client, seed_template): mock_task.delay.return_value = MagicMock(id="celery-abc") - tpl_id = self._seed_template(client) + tpl_id = seed_template() submit_resp = client.post(f"{API_PREFIX}/forms/jobs", json={ "template_ids": [tpl_id], "input_text": "test input", @@ -85,10 +77,10 @@ def test_get_job_not_found(self, client): assert resp.status_code == 404 @patch("app.api.routes.jobs.fill_form_task") - def test_submit_with_model_override(self, mock_task, client): + def test_submit_with_model_override(self, mock_task, client, seed_template): mock_task.delay.return_value = MagicMock(id="celery-xyz") - tpl_id = self._seed_template(client) + tpl_id = seed_template() resp = client.post(f"{API_PREFIX}/forms/jobs", json={ "template_ids": [tpl_id], "input_text": "test", diff --git a/tests/test_migrations.py b/tests/test_migrations.py index 6c404b8..d11ed18 100644 --- a/tests/test_migrations.py +++ b/tests/test_migrations.py @@ -41,6 +41,7 @@ def test_upgrade_head(alembic_cfg, alembic_engine): assert "incidents" in tables assert "forms" in tables assert "reports" in tables + assert "form_templates" in tables assert "alembic_version" in tables @@ -293,9 +294,9 @@ def test_reports_no_fk(alembic_cfg, alembic_engine): def test_downgrade_002(alembic_cfg, alembic_engine): - """Downgrade by one step removes only the 002 tables, leaving 001 tables intact.""" + """Downgrade to 001 removes the 002 and 003 tables, leaving 001 tables intact.""" command.upgrade(alembic_cfg, "head") - command.downgrade(alembic_cfg, "-1") + command.downgrade(alembic_cfg, "001") inspector = inspect(alembic_engine) tables = inspector.get_table_names() @@ -304,6 +305,61 @@ def test_downgrade_002(alembic_cfg, alembic_engine): assert "incidents" not in tables assert "forms" not in tables assert "reports" not in tables + assert "form_templates" not in tables assert "template" in tables assert "formsubmission" in tables assert "job" in tables + + +# --------------------------------------------------------------------------- +# 003 — form_templates registry table +# --------------------------------------------------------------------------- + +def test_form_templates_columns(alembic_cfg, alembic_engine): + command.upgrade(alembic_cfg, "head") + + inspector = inspect(alembic_engine) + columns = {c["name"] for c in inspector.get_columns("form_templates")} + assert columns == { + "template_id", + "form_type", + "display_name", + "jurisdiction", + "agency_type", + "fields", + "source_standard", + "pdf_template_ref", + "version", + "status", + "created_at", + "updated_at", + } + + +def test_form_templates_unique_form_type(alembic_cfg, alembic_engine): + command.upgrade(alembic_cfg, "head") + + inspector = inspect(alembic_engine) + indexes = {ix["name"]: ix for ix in inspector.get_indexes("form_templates")} + assert indexes["ix_form_templates_form_type"]["unique"] + + +def test_form_templates_no_fk(alembic_cfg, alembic_engine): + """form_templates is a standalone registry — no FK to legacy template/incidents.""" + command.upgrade(alembic_cfg, "head") + + inspector = inspect(alembic_engine) + assert inspector.get_foreign_keys("form_templates") == [] + + +def test_downgrade_003(alembic_cfg, alembic_engine): + """Downgrade by one step removes only form_templates, leaving 002 tables intact.""" + command.upgrade(alembic_cfg, "head") + command.downgrade(alembic_cfg, "-1") + + inspector = inspect(alembic_engine) + tables = inspector.get_table_names() + assert "form_templates" not in tables + assert "inputs" in tables + assert "forms" in tables + assert "reports" in tables diff --git a/tests/test_templates_v1.py b/tests/test_templates_v1.py new file mode 100644 index 0000000..47477dd --- /dev/null +++ b/tests/test_templates_v1.py @@ -0,0 +1,273 @@ +"""Tests for the contract Layer 6 template registry (app/api/routes/form_templates.py). + +Covers list / create / get / replace / fields against the in-memory DB. +""" + +from app.core.config import API_PREFIX + +TEMPLATES_URL = f"{API_PREFIX}/templates" + + +def _layout(**over) -> dict: + base = {"page": 0, "x": 188.33, "y": 621.33, "width": 127.33, "height": 28.67} + base.update(over) + return base + + +def _payload(form_type: str = "state_texas") -> dict: + return { + "form_type": form_type, + "display_name": "Texas State Fire Marshal Incident Report", + "jurisdiction": "US-TX", + "agency_type": "fire_department", + "fields": [ + { + "field_name": "incident_number", + "field_type": "string", + "required": True, + "max_length": 20, + "description": "State-assigned incident number", + "incident_mapping": "report_metadata.incident_number", + "layout": _layout(font="Helvetica", font_size=10, color="#000000", align="left"), + }, + { + "field_name": "fire_cause", + "field_type": "enum", + "required": False, + "allowed_values": ["accidental", "natural", "intentional", "undetermined"], + "incident_mapping": "fire.cause_category", + "layout": _layout(y=560.0), + }, + ], + "source_standard": "Texas SFM 2026", + } + + +def _create(client, **overrides): + body = _payload(**overrides) + return client.post(TEMPLATES_URL, json=body) + + +def test_list_empty(client): + resp = client.get(TEMPLATES_URL) + assert resp.status_code == 200 + assert resp.json() == [] + + +def test_create_returns_201_with_server_fields(client): + resp = _create(client) + assert resp.status_code == 201 + body = resp.json() + + assert body["form_type"] == "state_texas" + assert body["display_name"] == "Texas State Fire Marshal Incident Report" + assert body["field_count"] == 2 + assert body["status"] == "active" + assert body["version"] == "1.0" + assert body["template_id"] + assert body["last_updated"] + assert body["created_at"] + assert len(body["fields"]) == 2 + + +def test_create_duplicate_form_type_returns_409(client): + assert _create(client).status_code == 201 + dup = _create(client) + assert dup.status_code == 409 + assert dup.json()["error_code"] == "TEMPLATE_EXISTS" + + +def test_create_then_list(client): + _create(client) + resp = client.get(TEMPLATES_URL) + assert resp.status_code == 200 + items = resp.json() + assert len(items) == 1 + assert items[0]["form_type"] == "state_texas" + assert items[0]["field_count"] == 2 + # Summary is a projection — no full field list. + assert "fields" not in items[0] + + +def test_get_by_id(client): + template_id = _create(client).json()["template_id"] + resp = client.get(f"{TEMPLATES_URL}/{template_id}") + assert resp.status_code == 200 + body = resp.json() + assert body["template_id"] == template_id + first = body["fields"][0] + assert first["incident_mapping"] == "report_metadata.incident_number" + assert first["layout"]["x"] == 188.33 + assert first["layout"]["align"] == "left" + + +def test_get_missing_returns_404(client): + resp = client.get(f"{TEMPLATES_URL}/550e8400-e29b-41d4-a716-446655440099") + assert resp.status_code == 404 + assert resp.json()["error_code"] == "TEMPLATE_NOT_FOUND" + + +def test_get_invalid_uuid_returns_422(client): + assert client.get(f"{TEMPLATES_URL}/not-a-uuid").status_code == 422 + + +def test_replace_updates_fields(client): + template_id = _create(client).json()["template_id"] + + updated = _payload() + updated["display_name"] = "Texas SFM Incident Report v2" + updated["fields"] = [updated["fields"][0]] # drop one field + + resp = client.put(f"{TEMPLATES_URL}/{template_id}", json=updated) + assert resp.status_code == 200 + body = resp.json() + assert body["display_name"] == "Texas SFM Incident Report v2" + assert body["field_count"] == 1 + assert body["template_id"] == template_id + + +def test_replace_missing_returns_404(client): + resp = client.put( + f"{TEMPLATES_URL}/550e8400-e29b-41d4-a716-446655440099", json=_payload() + ) + assert resp.status_code == 404 + + +def test_create_missing_required_field_returns_422(client): + body = _payload() + del body["fields"] + assert client.post(TEMPLATES_URL, json=body).status_code == 422 + + +def test_fields_endpoint(client): + template_id = _create(client).json()["template_id"] + resp = client.get(f"{TEMPLATES_URL}/{template_id}/fields") + assert resp.status_code == 200 + body = resp.json() + assert body["total_fields"] == 2 + assert body["required_fields"] == 1 + assert body["optional_fields"] == 1 + assert len(body["fields"]) == 2 + assert body["form_type"] == "state_texas" + + +def test_fields_required_only(client): + template_id = _create(client).json()["template_id"] + resp = client.get(f"{TEMPLATES_URL}/{template_id}/fields?required_only=true") + assert resp.status_code == 200 + body = resp.json() + assert body["total_fields"] == 2 + assert body["required_fields"] == 1 + assert len(body["fields"]) == 1 + assert body["fields"][0]["field_name"] == "incident_number" + + +def test_fields_missing_returns_404(client): + resp = client.get( + f"{TEMPLATES_URL}/550e8400-e29b-41d4-a716-446655440099/fields" + ) + assert resp.status_code == 404 + + +# --------------------------------------------------------------------------- +# Validation (all 422 with the contract error envelope) +# --------------------------------------------------------------------------- +def _assert_422(resp): + assert resp.status_code == 422, resp.json() + body = resp.json() + assert body["error_code"] == "VALIDATION_ERROR" + assert len(body["validation_errors"]) >= 1 + + +def test_jurisdiction_optional(client): + body = _payload() + del body["jurisdiction"] + resp = client.post(TEMPLATES_URL, json=body) + assert resp.status_code == 201 + assert resp.json()["jurisdiction"] is None + + +def test_static_text_field_ok(client): + body = _payload() + body["fields"].append({ + "field_name": "footer", + "field_type": "string", + "required": False, + "static_text": "Generated by FireForm", + "layout": _layout(y=40.0, align="center"), + }) + resp = client.post(TEMPLATES_URL, json=body) + assert resp.status_code == 201 + assert resp.json()["field_count"] == 3 + + +def test_field_both_mapping_and_static_rejected(client): + body = _payload() + body["fields"][0]["static_text"] = "x" # already has incident_mapping + _assert_422(client.post(TEMPLATES_URL, json=body)) + + +def test_field_no_data_source_rejected(client): + body = _payload() + del body["fields"][0]["incident_mapping"] # neither mapping nor static + _assert_422(client.post(TEMPLATES_URL, json=body)) + + +def test_enum_without_allowed_values_rejected(client): + body = _payload() + del body["fields"][1]["allowed_values"] # fire_cause is enum + _assert_422(client.post(TEMPLATES_URL, json=body)) + + +def test_duplicate_field_names_rejected(client): + body = _payload() + body["fields"][1]["field_name"] = body["fields"][0]["field_name"] + _assert_422(client.post(TEMPLATES_URL, json=body)) + + +def test_empty_fields_rejected(client): + body = _payload() + body["fields"] = [] + _assert_422(client.post(TEMPLATES_URL, json=body)) + + +def test_bad_form_type_rejected(client): + _assert_422(_create(client, form_type="State Texas!")) + + +def test_min_greater_than_max_rejected(client): + body = _payload() + body["fields"][0]["min_value"] = 10 + body["fields"][0]["max_value"] = 5 + _assert_422(client.post(TEMPLATES_URL, json=body)) + + +def test_layout_bad_color_rejected(client): + body = _payload() + body["fields"][0]["layout"]["color"] = "black" + _assert_422(client.post(TEMPLATES_URL, json=body)) + + +def test_layout_missing_required_coord_rejected(client): + body = _payload() + del body["fields"][0]["layout"]["width"] + _assert_422(client.post(TEMPLATES_URL, json=body)) + + +def test_layout_negative_coordinate_rejected(client): + body = _payload() + body["fields"][0]["layout"]["x"] = -5 + _assert_422(client.post(TEMPLATES_URL, json=body)) + + +def test_layout_zero_width_rejected(client): + body = _payload() + body["fields"][0]["layout"]["width"] = 0 + _assert_422(client.post(TEMPLATES_URL, json=body)) + + +def test_replace_validates_body(client): + template_id = _create(client).json()["template_id"] + bad = _payload() + bad["fields"][0]["layout"]["color"] = "nope" + _assert_422(client.put(f"{TEMPLATES_URL}/{template_id}", json=bad)) From 974d03700c590fe50c4bddf51607defb883f1f02 Mon Sep 17 00:00:00 2001 From: chetanr25 Date: Sun, 28 Jun 2026 01:08:53 +0530 Subject: [PATCH 13/13] fixing all lint errors --- tests/conftest.py | 1 - tests/test_deletion.py | 5 +---- tests/test_v1_input.py | 1 - 3 files changed, 1 insertion(+), 6 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index f0822a1..2151461 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -14,7 +14,6 @@ from app.main import app from app.api.deps import get_db -from app.core.config import API_PREFIX # single source of truth for all tests from app.models import Template, FormSubmission, FormTemplate, Job, Input, Extraction, Incident, Form, Report # noqa: F401 — registers tables # --------------------------------------------------------------------------- diff --git a/tests/test_deletion.py b/tests/test_deletion.py index 15fe19b..830587c 100644 --- a/tests/test_deletion.py +++ b/tests/test_deletion.py @@ -2,10 +2,7 @@ access control. """ -import io from datetime import datetime, timedelta, timezone -from pathlib import Path -from unittest.mock import patch import pytest @@ -162,7 +159,7 @@ def test_purge_requires_key_when_set(self, client): assert resp.status_code == 401 def test_purge_with_valid_key(self, client, db): - tpl_id = _seed_template(db) + _seed_template(db) resp = client.post( f"{API_PREFIX}/forms/purge?days=30", headers={"X-API-Key": "secret-test-key"}, diff --git a/tests/test_v1_input.py b/tests/test_v1_input.py index 1a00b1c..a73f048 100644 --- a/tests/test_v1_input.py +++ b/tests/test_v1_input.py @@ -4,7 +4,6 @@ Whisper involvement — text input is fully synchronous. """ -import pytest TEXT_URL = "/api/v1/input/text" INPUT_URL = "/api/v1/input"