Skip to content

Commit 206f2ea

Browse files
committed
feat: robust report management and configuration architecture + API
1 parent 0fdc632 commit 206f2ea

17 files changed

Lines changed: 1478 additions & 23 deletions

.gitignore

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,6 @@
22
.idea
33
venv
44
.venv
5-
*.db
5+
*.db
6+
template_files/
7+
.env

api/db/models.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ class Template(SQLModel, table=True):
1414
class FormSubmission(SQLModel, table=True):
1515
id: int | None = Field(default=None, primary_key=True)
1616
template_id: int
17+
report_schema_id: int | None = Field(default=None)
18+
name: str | None = Field(default=None)
1719
input_text: str
1820
output_pdf_path: str
1921
created_at: datetime = Field(default_factory=datetime.utcnow)

api/main.py

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,25 @@
11
from fastapi import FastAPI
2-
from api.routes import templates, forms
2+
from fastapi.middleware.cors import CORSMiddleware
3+
4+
from api.routes import templates, forms, report_schemas
5+
from api.errors.handlers import register_exception_handlers
36

47
app = FastAPI()
8+
register_exception_handlers(app)
9+
10+
app.add_middleware(
11+
CORSMiddleware,
12+
allow_origins=[
13+
"http://127.0.0.1:5173",
14+
"http://localhost:5173",
15+
"http://127.0.0.1:4173",
16+
"http://localhost:4173",
17+
],
18+
allow_credentials=True,
19+
allow_methods=["*"],
20+
allow_headers=["*"],
21+
)
522

623
app.include_router(templates.router)
7-
app.include_router(forms.router)
24+
app.include_router(forms.router)
25+
app.include_router(report_schemas.router)

api/routes/report_schemas.py

Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
from pathlib import Path
2+
from sqlite3 import IntegrityError
3+
from fastapi import APIRouter, Depends, HTTPException
4+
from fastapi.responses import FileResponse
5+
from sqlmodel import Session, select
6+
from api.deps import get_db
7+
from api.schemas.report_class import (
8+
ReportSchemaCreate,
9+
ReportSchemaUpdate,
10+
ReportSchemaResponse,
11+
TemplateAssociation,
12+
SchemaFieldResponse,
13+
SchemaFieldUpdate,
14+
CanonicalSchema,
15+
ReportFill,
16+
ReportFillResponse,
17+
FormSubmissionResponse,
18+
)
19+
from api.db import repositories as repo
20+
from api.db.models import ReportSchema
21+
from src.report_schema import ReportSchemaProcessor
22+
from src.controller import Controller
23+
from api.db.models import FormSubmission, ReportSchemaTemplate
24+
from sqlalchemy.exc import IntegrityError
25+
26+
router = APIRouter(prefix="/schemas", tags=["schemas"])
27+
28+
29+
@router.post("/create", response_model=ReportSchemaResponse)
30+
def create_schema(data: ReportSchemaCreate, db: Session = Depends(get_db)):
31+
schema = ReportSchema(**data.model_dump())
32+
try:
33+
return repo.create_report_schema(db, schema)
34+
except IntegrityError:
35+
raise HTTPException(
36+
status_code=409,
37+
detail="A schema with this name already exists"
38+
)
39+
40+
@router.get("/", response_model=list[ReportSchemaResponse])
41+
def list_schemas(db: Session = Depends(get_db)):
42+
return repo.list_report_schemas(db)
43+
44+
@router.get("/{schema_id}", response_model=ReportSchemaResponse)
45+
def get_schema(schema_id: int, db: Session = Depends(get_db)):
46+
schema = repo.get_report_schema(db, schema_id)
47+
if not schema:
48+
raise HTTPException(status_code=404, detail="Schema not found")
49+
return schema
50+
51+
@router.put("/{schema_id}", response_model=ReportSchemaResponse)
52+
def update_schema(schema_id: int, data: ReportSchemaUpdate, db: Session = Depends(get_db)):
53+
updates = data.model_dump(exclude_none=True)
54+
schema = repo.update_report_schema(db, schema_id, updates)
55+
if not schema:
56+
raise HTTPException(status_code=404, detail="Schema not found")
57+
return schema
58+
59+
@router.delete("/{schema_id}")
60+
def delete_schema(schema_id: int, db: Session = Depends(get_db)):
61+
deleted = repo.delete_report_schema(db, schema_id)
62+
if not deleted:
63+
raise HTTPException(status_code=404, detail="Schema not found")
64+
return {"detail": "Schema deleted"}
65+
66+
67+
@router.post("/{schema_id}/templates", response_model=list[SchemaFieldResponse])
68+
def add_template(schema_id: int, data: TemplateAssociation, db: Session = Depends(get_db)):
69+
"""Associate a template with a schema.
70+
71+
Auto-creates SchemaField entries from template.fields and returns them.
72+
"""
73+
try:
74+
repo.add_template_to_schema(db, schema_id, data.template_id)
75+
except IntegrityError:
76+
raise HTTPException(status_code=409, detail="Template is already added to schema")
77+
except ValueError as e:
78+
raise HTTPException(status_code=404, detail=str(e))
79+
return repo.get_schema_fields(db, schema_id)
80+
81+
@router.delete("/{schema_id}/templates/{template_id}")
82+
def remove_template(schema_id: int, template_id: int, db: Session = Depends(get_db)):
83+
removed = repo.remove_template_from_schema(db, schema_id, template_id)
84+
if not removed:
85+
raise HTTPException(status_code=404, detail="Template association not found")
86+
return {"detail": "Template disassociated"}
87+
88+
89+
90+
@router.get("/{schema_id}/fields", response_model=list[SchemaFieldResponse])
91+
def list_fields(schema_id: int, db: Session = Depends(get_db)):
92+
return repo.get_schema_fields(db, schema_id)
93+
94+
@router.put("/{schema_id}/fields/{field_id}", response_model=SchemaFieldResponse)
95+
def update_field(schema_id: int, field_id: int, data: SchemaFieldUpdate, db: Session = Depends(get_db)):
96+
updates = data.model_dump(exclude_none=True)
97+
field = repo.update_schema_field(db, schema_id, field_id, updates)
98+
if not field:
99+
raise HTTPException(status_code=404, detail="Field not found or does not belong to this schema")
100+
return field
101+
102+
103+
@router.post("/{schema_id}/canonize", response_model=CanonicalSchema)
104+
def canonize_schema(schema_id: int, db: Session = Depends(get_db)):
105+
"""Trigger canonization: group fields, assign canonical names, generate field mappings."""
106+
schema = repo.get_report_schema(db, schema_id)
107+
if not schema:
108+
raise HTTPException(status_code=404, detail="Schema not found")
109+
110+
return ReportSchemaProcessor.canonize(db, schema_id)
111+
112+
@router.get("/mapping/{schema_id}/{template_id}")
113+
def get_schema_template_mapping(schema_id: int, template_id: int, db: Session = Depends(get_db)):
114+
return repo.get_field_mapping(db, schema_id, template_id)
115+
116+
@router.post("/{schema_id}/fill", response_model=ReportFillResponse)
117+
def fill_schema(schema_id: int, data: ReportFill, db: Session = Depends(get_db)):
118+
"""
119+
End-to-end report generation.
120+
Takes a single transcript, extracts canonical fields, distributes to
121+
all schema templates, fills them, and logs the submissions.
122+
"""
123+
schema = repo.get_report_schema(db, schema_id)
124+
if not schema:
125+
raise HTTPException(status_code=404, detail="Schema not found")
126+
127+
controller = Controller()
128+
129+
output_paths = controller.fill_report(db, data.input_text, schema_id)
130+
131+
# Log submissions
132+
submission_ids: list[int] = []
133+
for template_id, path in output_paths.items():
134+
submission = FormSubmission(
135+
template_id=template_id,
136+
report_schema_id=schema_id,
137+
name=data.name,
138+
input_text=data.input_text,
139+
output_pdf_path=path
140+
)
141+
db.add(submission)
142+
db.flush()
143+
submission_ids.append(submission.id) # type: ignore
144+
145+
db.commit()
146+
147+
return ReportFillResponse(
148+
schema_id=schema_id,
149+
input_text=data.input_text,
150+
output_pdf_paths=list(output_paths.values()),
151+
submission_ids=submission_ids,
152+
)
153+
154+
155+
@router.get("/{schema_id}/submissions", response_model=list[FormSubmissionResponse])
156+
def list_submissions(schema_id: int, db: Session = Depends(get_db)):
157+
"""List all form submissions for a given schema."""
158+
return db.exec(
159+
select(FormSubmission)
160+
.where(FormSubmission.report_schema_id == schema_id)
161+
.order_by(FormSubmission.created_at.desc()) # type: ignore
162+
).all()
163+
164+
165+
@router.get("/submissions/{submission_id}/pdf")
166+
def get_submission_pdf(submission_id: int, db: Session = Depends(get_db)):
167+
"""Serve a filled PDF for a given form submission."""
168+
submission = db.get(FormSubmission, submission_id)
169+
if not submission:
170+
raise HTTPException(status_code=404, detail="Submission not found")
171+
path = Path(submission.output_pdf_path).resolve()
172+
if not path.is_file():
173+
raise HTTPException(status_code=404, detail="PDF file missing on disk")
174+
return FileResponse(
175+
path,
176+
media_type="application/pdf",
177+
filename=f"submission_{submission_id}.pdf",
178+
)

api/routes/templates.py

Lines changed: 122 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,131 @@
1-
from fastapi import APIRouter, Depends
1+
import re
2+
from sqlalchemy.exc import IntegrityError
3+
import uuid
4+
from pathlib import Path
5+
6+
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile
7+
from fastapi.responses import FileResponse
28
from sqlmodel import Session
9+
310
from api.deps import get_db
4-
from api.schemas.templates import TemplateCreate, TemplateResponse
5-
from api.db.repositories import create_template
611
from api.db.models import Template
12+
from api.db.repositories import (
13+
create_template,
14+
delete_template,
15+
get_template,
16+
update_template,
17+
list_templates
18+
)
19+
from api.schemas.templates import TemplateResponse, TemplateUpdate
720
from src.controller import Controller
821

922
router = APIRouter(prefix="/templates", tags=["templates"])
1023

24+
INPUT_FILES_DIR = Path(__file__).resolve().parents[2] / "template_files"
25+
26+
27+
def _safe_name_fragment(name: str) -> str:
28+
base = Path(name).name
29+
s = re.sub(r"[^\w\-.]+", "_", base.strip(), flags=re.UNICODE)
30+
s = s.strip("._-") or "template"
31+
return s[:120]
32+
33+
1134
@router.post("/create", response_model=TemplateResponse)
12-
def create(template: TemplateCreate, db: Session = Depends(get_db)):
35+
def create(
36+
name: str = Form(...),
37+
file: UploadFile = File(...),
38+
db: Session = Depends(get_db),
39+
):
40+
filename = (file.filename or "").lower()
41+
if not filename.endswith(".pdf"):
42+
raise HTTPException(status_code=400, detail="File must be a .pdf")
43+
44+
frag = _safe_name_fragment(name)
45+
uid = uuid.uuid4().hex
46+
INPUT_FILES_DIR.mkdir(parents=True, exist_ok=True)
47+
dest = INPUT_FILES_DIR / f"{frag}_{uid}.pdf"
48+
49+
raw = file.file.read()
50+
if not raw:
51+
raise HTTPException(status_code=400, detail="Empty file")
52+
dest.write_bytes(raw)
53+
1354
controller = Controller()
14-
template_path = controller.create_template(template.pdf_path)
15-
tpl = Template(**template.model_dump(exclude={"pdf_path"}), pdf_path=template_path)
16-
return create_template(db, tpl)
55+
try:
56+
template_path = controller.create_template(str(dest))
57+
except Exception as e:
58+
dest.unlink(missing_ok=True)
59+
print(e)
60+
raise HTTPException(
61+
status_code=500, detail=f"Failed to prepare PDF template: {e}"
62+
) from e
63+
64+
fields = controller.extract_template_fields(template_path)
65+
tpl = Template(name=name.strip(), fields=fields, pdf_path=template_path)
66+
67+
try:
68+
return create_template(db, tpl)
69+
except IntegrityError:
70+
raise HTTPException(
71+
status_code=409,
72+
detail="A template with the same name already exists"
73+
)
74+
75+
@router.get("/", response_model=list[Template])
76+
def list(db: Session = Depends(get_db)):
77+
return list_templates(db)
78+
79+
80+
@router.get("/{template_id}/pdf")
81+
def get_template_pdf(template_id: int, db: Session = Depends(get_db)):
82+
"""Serve the stored PDF for preview in the schema wizard."""
83+
tpl = get_template(db, template_id)
84+
if not tpl:
85+
raise HTTPException(status_code=404, detail="Template not found")
86+
root = INPUT_FILES_DIR.resolve()
87+
path = Path(tpl.pdf_path).resolve()
88+
try:
89+
path.relative_to(root)
90+
except ValueError:
91+
raise HTTPException(status_code=403, detail="Invalid template file location")
92+
if not path.is_file():
93+
raise HTTPException(status_code=404, detail="PDF file missing on disk")
94+
return FileResponse(
95+
path,
96+
media_type="application/pdf",
97+
filename=f"{tpl.name}.pdf",
98+
)
99+
100+
101+
@router.get("/{template_id}", response_model=TemplateResponse)
102+
def get_one(template_id: int, db: Session = Depends(get_db)):
103+
tpl = get_template(db, template_id)
104+
if not tpl:
105+
raise HTTPException(status_code=404, detail="Template not found")
106+
return tpl
107+
108+
109+
@router.put("/{template_id}", response_model=TemplateResponse)
110+
def update_one(
111+
template_id: int,
112+
data: TemplateUpdate,
113+
db: Session = Depends(get_db),
114+
):
115+
updates = data.model_dump(exclude_none=True)
116+
if not updates:
117+
tpl = get_template(db, template_id)
118+
if not tpl:
119+
raise HTTPException(status_code=404, detail="Template not found")
120+
return tpl
121+
tpl = update_template(db, template_id, updates)
122+
if not tpl:
123+
raise HTTPException(status_code=404, detail="Template not found")
124+
return tpl
125+
126+
127+
@router.delete("/{template_id}")
128+
def delete_one(template_id: int, db: Session = Depends(get_db)):
129+
if not delete_template(db, template_id):
130+
raise HTTPException(status_code=404, detail="Template not found")
131+
return {"detail": "Template deleted"}

0 commit comments

Comments
 (0)