diff --git a/app/api/routes/templates.py b/app/api/routes/templates.py index ee7189a..0997517 100644 --- a/app/api/routes/templates.py +++ b/app/api/routes/templates.py @@ -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 @@ -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) @@ -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.") @@ -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)]) @@ -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"} - diff --git a/app/db/repositories.py b/app/db/repositories.py index feed61f..f20a862 100644 --- a/app/db/repositories.py +++ b/app/db/repositories.py @@ -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) diff --git a/app/services/template.py b/app/services/template.py new file mode 100644 index 0000000..15525df --- /dev/null +++ b/app/services/template.py @@ -0,0 +1,195 @@ +import re +from datetime import datetime, timezone +from pathlib import Path + +from fastapi import HTTPException +from sqlmodel import Session + +from app.api.schemas.templates import ( + MakeFillableResponse, + TemplateCreate, + TemplateResponse, + TemplateUploadResponse, +) +from app.core.config import BASE_DIR, DEFAULT_TEMPLATE_DIR +from app.db.repositories import ( + create_template, + delete_template, + get_jobs_by_template, + get_submissions_by_template, + list_templates, +) +from app.models import Template +from app.services.controller import Controller + +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 + + +# 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) + + +class TemplateService: + def __init__(self): + self.controller = Controller() + + def list_templates(self, session: Session) -> list[Template]: + return list_templates(session) + + def resolve_pdf_path(self, path: str) -> Path: + return _resolve_project_file(path) + + def save_uploaded_pdf(self, directory: str, filename: str, content: bytes) -> TemplateUploadResponse: + 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}" + + 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 [], + ) + + def create_template(self, session: Session, template: TemplateCreate) -> TemplateResponse: + tpl = Template(**template.model_dump()) + created = create_template(session, 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), + ) + + def make_fillable(self, resolved_pdf_path: str) -> MakeFillableResponse: + new_absolute = self.controller.prepare_fillable(resolved_pdf_path) + 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), + ) + + def delete_template(self, session: Session, template: Template) -> None: + # Batched like the original route: only session.delete() per row here, + # single commit at the end (via the delete_template repo call) so the + # cascade stays atomic instead of partially committing on failure. + submissions = get_submissions_by_template(session, template.id) + 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 + session.delete(sub) + + jobs = get_jobs_by_template(session, template.id) + for job in jobs: + session.delete(job) + + 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 + + delete_template(session, template) diff --git a/tests/conftest.py b/tests/conftest.py index 12e8668..897a611 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -91,7 +91,7 @@ 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, \ + with patch("app.services.template.Controller") as tpl_cls, \ patch("app.services.form.Controller") as form_cls: tpl_instance = MagicMock() tpl_instance.create_template.return_value = "src/inputs/test_template.pdf" diff --git a/tests/test_api.py b/tests/test_api.py index 9d5155f..37a2ab0 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -138,7 +138,7 @@ def test_upload_pdf(self, client, pdf_upload, tmp_path, monkeypatch): # 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", + "app.services.template.PROJECT_ROOT", tmp_path, ) resp = client.post( @@ -387,7 +387,7 @@ class TestE2EPipeline: 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) + monkeypatch.setattr("app.services.template.PROJECT_ROOT", tmp_path) upload_resp = client.post( f"{API_PREFIX}/templates/upload", files=[pdf_upload], diff --git a/tests/test_deletion.py b/tests/test_deletion.py index 51fb920..4a22a26 100644 --- a/tests/test_deletion.py +++ b/tests/test_deletion.py @@ -69,7 +69,7 @@ def test_delete_template_cascades_submissions(self, client, db): 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) + monkeypatch.setattr("app.services.template.PROJECT_ROOT", tmp_path) pdf_file = tmp_path / "myform.pdf" pdf_file.write_bytes(b"%PDF-1.4 fake") @@ -81,7 +81,7 @@ def test_delete_template_deletes_pdf_file(self, client, tmp_path, monkeypatch): 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) + monkeypatch.setattr("app.services.template.PROJECT_ROOT", tmp_path) out_pdf = tmp_path / "filled.pdf" out_pdf.write_bytes(b"%PDF-1.4 filled")