Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
179 changes: 11 additions & 168 deletions app/api/routes/templates.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
import re
from datetime import datetime, timezone
from pathlib import Path

from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, UploadFile
Expand All @@ -14,48 +12,11 @@
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
from app.core.config import DEFAULT_TEMPLATE_DIR
from app.db.repositories import get_template
from app.services.template import TemplateService

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)
Expand All @@ -70,88 +31,18 @@ async def upload_template_pdf(
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)
return TemplateService().save_uploaded_pdf(directory, filename, content)


@router.get("", response_model=list[TemplateResponse])
def get_templates(db: Session = Depends(get_db)):
return list_templates(db)
return TemplateService().list_templates(db)


@router.get("/preview")
def preview_template_pdf(path: str = Query(..., description="Project-relative PDF path")):
resolved_path = _resolve_project_file(path)
resolved_path = TemplateService().resolve_pdf_path(path)

if not resolved_path.exists() or not resolved_path.is_file():
raise HTTPException(status_code=404, detail="PDF file not found.")
Expand All @@ -169,35 +60,17 @@ def preview_template_pdf(path: str = Query(..., description="Project-relative PD

@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),
)
return TemplateService().create_template(db, template)


@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)
svc = TemplateService()
resolved = svc.resolve_pdf_path(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),
)
return svc.make_fillable(str(resolved))


@router.delete("/{template_id}", dependencies=[Depends(verify_api_key)])
Expand All @@ -206,35 +79,5 @@ def delete_template_endpoint(template_id: int, db: Session = Depends(get_db)):
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)
TemplateService().delete_template(db, template)
return {"status": "success", "message": "Template and all associated data deleted"}

10 changes: 10 additions & 0 deletions app/db/repositories.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,16 @@ def delete_template(session: Session, template: Template) -> None:
session.commit()


def get_submissions_by_template(session: Session, template_id: int) -> list[FormSubmission]:
statement = select(FormSubmission).where(FormSubmission.template_id == template_id)
return list(session.exec(statement))


def get_jobs_by_template(session: Session, template_id: int) -> list[Job]:
statement = select(Job).where(Job.template_id == template_id)
return list(session.exec(statement))


def get_form_submission(session: Session, submission_id: int) -> FormSubmission | None:
return session.get(FormSubmission, submission_id)

Expand Down
Loading
Loading