Skip to content

Commit 2853286

Browse files
committed
feat(api,frontend): add db-backed templates, upload flow, and preview endpoint
Add /templates/upload, /templates, and /templates/preview endpoints. Update vanilla frontend to support drag/drop upload, DB template loading, separate Fill Form tab, and reliable PDF preview via API.
1 parent 51c8a83 commit 2853286

8 files changed

Lines changed: 1178 additions & 9 deletions

File tree

api/db/repositories.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,14 @@ def create_template(session: Session, template: Template) -> Template:
1111
def get_template(session: Session, template_id: int) -> Template | None:
1212
return session.get(Template, template_id)
1313

14+
15+
def list_templates(session: Session) -> list[Template]:
16+
statement = select(Template).order_by(Template.created_at.desc(), Template.id.desc())
17+
return list(session.exec(statement))
18+
1419
# Forms
1520
def create_form(session: Session, form: FormSubmission) -> FormSubmission:
1621
session.add(form)
1722
session.commit()
1823
session.refresh(form)
19-
return form
24+
return form

api/main.py

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,26 @@
1+
import os
2+
13
from fastapi import FastAPI
2-
from api.routes import templates, forms
4+
from fastapi.middleware.cors import CORSMiddleware
5+
6+
from api.routes import forms, templates
37

48
app = FastAPI()
59

10+
default_origins = "http://127.0.0.1:5173"
11+
allowed_origins = [
12+
origin.strip()
13+
for origin in os.getenv("FRONTEND_ORIGINS", default_origins).split(",")
14+
if origin.strip()
15+
]
16+
17+
app.add_middleware(
18+
CORSMiddleware,
19+
allow_origins=allowed_origins,
20+
allow_credentials=False,
21+
allow_methods=["*"],
22+
allow_headers=["*"],
23+
)
24+
625
app.include_router(templates.router)
7-
app.include_router(forms.router)
26+
app.include_router(forms.router)

api/routes/templates.py

Lines changed: 97 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,109 @@
1-
from fastapi import APIRouter, Depends
1+
from datetime import datetime
2+
from pathlib import Path
3+
4+
from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, UploadFile
5+
from fastapi.responses import FileResponse
26
from sqlmodel import Session
37
from api.deps import get_db
4-
from api.schemas.templates import TemplateCreate, TemplateResponse
5-
from api.db.repositories import create_template
8+
from api.schemas.templates import (
9+
TemplateCreate,
10+
TemplateResponse,
11+
TemplateUploadResponse,
12+
)
13+
from api.db.repositories import create_template, list_templates
614
from api.db.models import Template
715
from src.controller import Controller
816

917
router = APIRouter(prefix="/templates", tags=["templates"])
18+
PROJECT_ROOT = Path(__file__).resolve().parents[2]
19+
DEFAULT_TEMPLATE_DIR = "src/inputs"
20+
21+
22+
def _resolve_target_directory(directory: str) -> Path:
23+
dir_value = (directory or DEFAULT_TEMPLATE_DIR).strip()
24+
if not dir_value:
25+
raise HTTPException(status_code=400, detail="Directory is required.")
26+
27+
candidate = Path(dir_value)
28+
if not candidate.is_absolute():
29+
candidate = (PROJECT_ROOT / candidate).resolve()
30+
else:
31+
candidate = candidate.resolve()
32+
33+
if candidate != PROJECT_ROOT and PROJECT_ROOT not in candidate.parents:
34+
raise HTTPException(status_code=400, detail="Directory must be inside the project.")
35+
36+
return candidate
37+
38+
39+
def _resolve_project_file(file_path: str) -> Path:
40+
raw_path = (file_path or "").strip()
41+
if not raw_path:
42+
raise HTTPException(status_code=400, detail="Path is required.")
43+
44+
candidate = Path(raw_path)
45+
if not candidate.is_absolute():
46+
candidate = (PROJECT_ROOT / candidate).resolve()
47+
else:
48+
candidate = candidate.resolve()
49+
50+
if candidate != PROJECT_ROOT and PROJECT_ROOT not in candidate.parents:
51+
raise HTTPException(status_code=400, detail="Path must be inside the project.")
52+
53+
return candidate
54+
55+
56+
@router.post("/upload", response_model=TemplateUploadResponse)
57+
async def upload_template_pdf(
58+
file: UploadFile = File(...),
59+
directory: str = Form(DEFAULT_TEMPLATE_DIR),
60+
):
61+
filename = Path(file.filename or "").name
62+
if not filename:
63+
raise HTTPException(status_code=400, detail="A PDF filename is required.")
64+
65+
if not filename.lower().endswith(".pdf"):
66+
raise HTTPException(status_code=400, detail="Only PDF files are supported.")
67+
68+
target_dir = _resolve_target_directory(directory)
69+
target_dir.mkdir(parents=True, exist_ok=True)
70+
71+
target_path = target_dir / filename
72+
if target_path.exists():
73+
timestamp = datetime.utcnow().strftime("%Y%m%d_%H%M%S")
74+
target_path = target_dir / f"{target_path.stem}_{timestamp}{target_path.suffix}"
75+
76+
content = await file.read()
77+
with target_path.open("wb") as output_file:
78+
output_file.write(content)
79+
80+
return TemplateUploadResponse(
81+
filename=target_path.name,
82+
pdf_path=target_path.relative_to(PROJECT_ROOT).as_posix(),
83+
)
84+
85+
86+
@router.get("", response_model=list[TemplateResponse])
87+
def get_templates(db: Session = Depends(get_db)):
88+
return list_templates(db)
89+
90+
91+
@router.get("/preview")
92+
def preview_template_pdf(path: str = Query(..., description="Project-relative PDF path")):
93+
resolved_path = _resolve_project_file(path)
94+
95+
if not resolved_path.exists() or not resolved_path.is_file():
96+
raise HTTPException(status_code=404, detail="PDF file not found.")
97+
98+
if resolved_path.suffix.lower() != ".pdf":
99+
raise HTTPException(status_code=400, detail="Only PDF files can be previewed.")
100+
101+
return FileResponse(resolved_path, media_type="application/pdf", filename=resolved_path.name)
102+
10103

11104
@router.post("/create", response_model=TemplateResponse)
12105
def create(template: TemplateCreate, db: Session = Depends(get_db)):
13106
controller = Controller()
14107
template_path = controller.create_template(template.pdf_path)
15108
tpl = Template(**template.model_dump(exclude={"pdf_path"}), pdf_path=template_path)
16-
return create_template(db, tpl)
109+
return create_template(db, tpl)

api/schemas/templates.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,4 +12,9 @@ class TemplateResponse(BaseModel):
1212
fields: dict
1313

1414
class Config:
15-
from_attributes = True
15+
from_attributes = True
16+
17+
18+
class TemplateUploadResponse(BaseModel):
19+
filename: str
20+
pdf_path: str

0 commit comments

Comments
 (0)