diff --git a/app/main.py b/app/main.py
index ae4ad5f..995455e 100644
--- a/app/main.py
+++ b/app/main.py
@@ -15,6 +15,7 @@ from app.routes.health import router as health_router
from app.routes.ingest import router as ingest_router
from app.routes.line_items import router as line_items_router
from app.routes.queue import router as queue_router
+from app.routes.presets import router as presets_router
from app.routes.trash import router as trash_router
app = FastAPI(title="document-processor")
@@ -26,6 +27,7 @@ app.include_router(documents_router)
app.include_router(ingest_router)
app.include_router(line_items_router)
app.include_router(queue_router)
+app.include_router(presets_router)
app.include_router(trash_router)
templates = Jinja2Templates(directory="app/templates")
diff --git a/app/models/__init__.py b/app/models/__init__.py
index ff48144..f91a612 100644
--- a/app/models/__init__.py
+++ b/app/models/__init__.py
@@ -4,6 +4,8 @@ from app.models.text_version import TextVersion
from app.models.extracted_field import ExtractedField
from app.models.layer1_candidate import Layer1Candidate
from app.models.receipt_line_item import ReceiptLineItem
+from app.models.document_additional_field import DocumentAdditionalField
+from app.models.document_preset import DocumentPreset
__all__ = [
"Document",
@@ -12,4 +14,6 @@ __all__ = [
"ExtractedField",
"Layer1Candidate",
"ReceiptLineItem",
+ "DocumentAdditionalField",
+ "DocumentPreset",
]
diff --git a/app/models/document_preset.py b/app/models/document_preset.py
new file mode 100644
index 0000000..1431da3
--- /dev/null
+++ b/app/models/document_preset.py
@@ -0,0 +1,35 @@
+from datetime import datetime
+
+from sqlalchemy import Boolean, DateTime, Text
+from sqlalchemy.dialects.postgresql import JSONB
+from sqlalchemy.orm import Mapped, mapped_column
+
+from app.db.base import Base
+
+
+class DocumentPreset(Base):
+ __tablename__ = "document_presets"
+
+ id: Mapped[int] = mapped_column(primary_key=True, index=True)
+ name: Mapped[str] = mapped_column(Text, nullable=False, unique=True)
+
+ owner_person: Mapped[str | None] = mapped_column(Text, nullable=True)
+ paid_by_person: Mapped[str | None] = mapped_column(Text, nullable=True)
+ occasion_note: Mapped[str | None] = mapped_column(Text, nullable=True)
+ is_shared_expense: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
+
+ covered_people: Mapped[list | None] = mapped_column(JSONB, nullable=True)
+ attendees: Mapped[list | None] = mapped_column(JSONB, nullable=True)
+ reimbursement_expected_from: Mapped[list | None] = mapped_column(JSONB, nullable=True)
+
+ reimbursement_paid_by: Mapped[str | None] = mapped_column(Text, nullable=True)
+ reimbursement_paid_to: Mapped[str | None] = mapped_column(Text, nullable=True)
+ reimbursement_note: Mapped[str | None] = mapped_column(Text, nullable=True)
+
+ created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False)
+ updated_at: Mapped[datetime] = mapped_column(
+ DateTime,
+ default=datetime.utcnow,
+ onupdate=datetime.utcnow,
+ nullable=False,
+ )
diff --git a/app/routes/documents.py b/app/routes/documents.py
index 1960312..fb3062e 100644
--- a/app/routes/documents.py
+++ b/app/routes/documents.py
@@ -1,6 +1,7 @@
from copy import deepcopy
+from datetime import datetime
+from decimal import Decimal, InvalidOperation
from pathlib import Path
-from urllib.parse import urlencode
from fastapi import APIRouter, Depends, Form, Request
from fastapi.responses import HTMLResponse, RedirectResponse
@@ -19,6 +20,8 @@ from app.logic.extraction import (
)
from app.logic.ingest import compute_quality_score, rerun_ocr_for_document
from app.models.document import Document
+from app.models.document_additional_field import DocumentAdditionalField
+from app.models.document_preset import DocumentPreset
from app.models.text_version import TextVersion
router = APIRouter(prefix="/documents", tags=["documents"])
@@ -26,7 +29,6 @@ router = APIRouter(prefix="/documents", tags=["documents"])
BASE_DIR = Path(__file__).resolve().parent.parent
templates = Jinja2Templates(directory=str(BASE_DIR / "templates"))
-
QUALITY_FLAG_OPTIONS = [
"bad_embedded_text",
"ocr_garbled",
@@ -49,11 +51,140 @@ QUALITY_FLAG_OPTIONS = [
]
-def _document_url(document_id: str, **params) -> str:
- clean_params = {k: v for k, v in params.items() if v not in (None, "", False)}
- if not clean_params:
- return f"/documents/{document_id}"
- return f"/documents/{document_id}?{urlencode(clean_params)}"
+def _parse_people_list(value: str) -> list[str]:
+ return [part.strip() for part in value.split(",") if part.strip()]
+
+
+def _format_people_list(value: list | None) -> str:
+ if not value:
+ return ""
+ return ", ".join(str(x).strip() for x in value if str(x).strip())
+
+
+def _to_decimal(value: str) -> Decimal | None:
+ cleaned = (value or "").strip()
+ if not cleaned:
+ return None
+ try:
+ return Decimal(cleaned)
+ except (InvalidOperation, TypeError):
+ return None
+
+
+
+def _get_all_presets(db: Session) -> list[DocumentPreset]:
+ return db.query(DocumentPreset).order_by(DocumentPreset.name.asc()).all()
+
+
+def _get_preset_by_id(db: Session, preset_id: int | None) -> DocumentPreset | None:
+ if not preset_id:
+ return None
+ return db.query(DocumentPreset).filter(DocumentPreset.id == preset_id).first()
+
+
+def _merge_additional_form_with_preset(values: dict, preset: DocumentPreset | None) -> dict:
+ if preset is None:
+ return values
+
+ return {
+ "owner_person": preset.owner_person if preset.owner_person is not None else values.get("owner_person", ""),
+ "paid_by_person": preset.paid_by_person if preset.paid_by_person is not None else values.get("paid_by_person", ""),
+ "covered_people": _format_people_list(preset.covered_people) if preset.covered_people is not None else values.get("covered_people", ""),
+ "attendees": _format_people_list(preset.attendees) if preset.attendees is not None else values.get("attendees", ""),
+ "occasion_note": preset.occasion_note if preset.occasion_note is not None else values.get("occasion_note", ""),
+ "is_shared_expense": bool(preset.is_shared_expense),
+ "reimbursement_expected_from": _format_people_list(preset.reimbursement_expected_from) if preset.reimbursement_expected_from is not None else values.get("reimbursement_expected_from", ""),
+ "reimbursement_paid_by": preset.reimbursement_paid_by if preset.reimbursement_paid_by is not None else values.get("reimbursement_paid_by", ""),
+ "reimbursement_paid_to": preset.reimbursement_paid_to if preset.reimbursement_paid_to is not None else values.get("reimbursement_paid_to", ""),
+ "reimbursement_paid_amount": values.get("reimbursement_paid_amount", ""),
+ "reimbursement_paid_date": values.get("reimbursement_paid_date", ""),
+ "reimbursement_note": preset.reimbursement_note if preset.reimbursement_note is not None else values.get("reimbursement_note", ""),
+ }
+
+
+def _get_current_additional_fields(document: Document) -> DocumentAdditionalField | None:
+ rows = list(getattr(document, "additional_fields", []) or [])
+ if not rows:
+ return None
+ return sorted(rows, key=lambda x: x.updated_at or x.created_at, reverse=True)[0]
+
+
+
+def _extracted_field_form_values(document: Document, request: Request) -> dict:
+ current = get_current_extracted_fields(document)
+ auto = request.query_params.get("autofill_extracted")
+
+ if auto == "1":
+ values = auto_extract_from_document(None, document)
+ elif current is not None:
+ values = {
+ "merchant_raw": current.merchant_raw or "",
+ "merchant_normalized": current.merchant_normalized or "",
+ "transaction_date": current.transaction_date.isoformat() if current.transaction_date else "",
+ "transaction_time": current.transaction_time or "",
+ "subtotal": str(current.subtotal) if current.subtotal is not None else "",
+ "tax": str(current.tax) if current.tax is not None else "",
+ "total": str(current.total) if current.total is not None else "",
+ "currency": current.currency or "",
+ "payment_method": current.payment_method or "",
+ "receipt_number": current.receipt_number or "",
+ "location": current.location or "",
+ "counterparty": current.counterparty or "",
+ "extra_json": "{}" if current.extra_json is None else __import__("json").dumps(current.extra_json, indent=2, sort_keys=True),
+ }
+ else:
+ values = {
+ "merchant_raw": "",
+ "merchant_normalized": "",
+ "transaction_date": "",
+ "transaction_time": "",
+ "subtotal": "",
+ "tax": "",
+ "total": "",
+ "currency": "",
+ "payment_method": "",
+ "receipt_number": "",
+ "location": "",
+ "counterparty": "",
+ "extra_json": "{}",
+ }
+
+ return values
+
+def _additional_field_form_values(document: Document, preset: DocumentPreset | None = None) -> dict:
+ current = _get_current_additional_fields(document)
+ if current is None:
+ values = {
+ "owner_person": "",
+ "paid_by_person": "",
+ "covered_people": "",
+ "attendees": "",
+ "occasion_note": "",
+ "is_shared_expense": False,
+ "reimbursement_expected_from": "",
+ "reimbursement_paid_by": "",
+ "reimbursement_paid_to": "",
+ "reimbursement_paid_amount": "",
+ "reimbursement_paid_date": "",
+ "reimbursement_note": "",
+ }
+ return _merge_additional_form_with_preset(values, preset)
+
+ values = {
+ "owner_person": current.owner_person or "",
+ "paid_by_person": current.paid_by_person or "",
+ "covered_people": _format_people_list(current.covered_people),
+ "attendees": _format_people_list(current.attendees),
+ "occasion_note": current.occasion_note or "",
+ "is_shared_expense": bool(current.is_shared_expense),
+ "reimbursement_expected_from": _format_people_list(current.reimbursement_expected_from),
+ "reimbursement_paid_by": current.reimbursement_paid_by or "",
+ "reimbursement_paid_to": current.reimbursement_paid_to or "",
+ "reimbursement_paid_amount": str(current.reimbursement_paid_amount) if current.reimbursement_paid_amount is not None else "",
+ "reimbursement_paid_date": current.reimbursement_paid_date.isoformat() if current.reimbursement_paid_date else "",
+ "reimbursement_note": current.reimbursement_note or "",
+ }
+ return _merge_additional_form_with_preset(values, preset)
def _get_current_text_versions(document: Document) -> tuple[TextVersion | None, TextVersion | None]:
@@ -185,51 +316,14 @@ def _get_queue_navigation(db: Session, document: Document) -> dict:
}
-def _extracted_field_form_values(document: Document, request: Request) -> dict:
- current = get_current_extracted_fields(document)
- auto = request.query_params.get("autofill_extracted")
-
- if auto == "1":
- values = auto_extract_from_document(None, document)
- elif current is not None:
- values = {
- "merchant_raw": current.merchant_raw or "",
- "merchant_normalized": current.merchant_normalized or "",
- "transaction_date": current.transaction_date.isoformat() if current.transaction_date else "",
- "transaction_time": current.transaction_time or "",
- "subtotal": str(current.subtotal) if current.subtotal is not None else "",
- "tax": str(current.tax) if current.tax is not None else "",
- "total": str(current.total) if current.total is not None else "",
- "currency": current.currency or "",
- "payment_method": current.payment_method or "",
- "receipt_number": current.receipt_number or "",
- "location": current.location or "",
- "counterparty": current.counterparty or "",
- "extra_json": "{}" if current.extra_json is None else __import__("json").dumps(current.extra_json, indent=2, sort_keys=True),
- }
- else:
- values = {
- "merchant_raw": "",
- "merchant_normalized": "",
- "transaction_date": "",
- "transaction_time": "",
- "subtotal": "",
- "tax": "",
- "total": "",
- "currency": "",
- "payment_method": "",
- "receipt_number": "",
- "location": "",
- "counterparty": "",
- "extra_json": "{}",
- }
-
- return values
-
-
@router.get("/", response_class=HTMLResponse)
def list_documents(request: Request, db: Session = Depends(get_db)):
- documents = db.query(Document).filter(Document.is_trashed.is_(False)).order_by(Document.created_at.desc()).all()
+ documents = (
+ db.query(Document)
+ .filter(Document.is_trashed.is_(False))
+ .order_by(Document.created_at.desc())
+ .all()
+ )
return templates.TemplateResponse(
request=request,
name="documents/list.html",
@@ -247,37 +341,23 @@ def rerun_ocr(document_id: str, db: Session = Depends(get_db)):
try:
rerun_ocr_for_document(db, document)
except Exception:
- return RedirectResponse(
- url=_document_url(document.document_id, error="rerun_ocr_failed", tab="ocr-review"),
- status_code=303,
- )
+ return RedirectResponse(url=f"/documents/{document.document_id}?error=rerun_ocr_failed", status_code=303)
- return RedirectResponse(
- url=_document_url(document.document_id, editor_source="raw", tab="ocr-review"),
- status_code=303,
- )
+ return RedirectResponse(url=f"/documents/{document.document_id}?editor_source=raw&tab=ocr-review", status_code=303)
@router.post("/{document_id}/save-ocr-corrected-pdf", response_class=RedirectResponse)
def save_ocr_corrected_pdf(document_id: str, db: Session = Depends(get_db)):
- document = (
- db.query(Document)
- .options(selectinload(Document.text_versions))
- .filter(Document.document_id == document_id)
- .first()
- )
+ document = db.query(Document).options(selectinload(Document.text_versions)).filter(Document.document_id == document_id).first()
if document is None:
return RedirectResponse(url="/documents/", status_code=303)
try:
create_ocr_corrected_pdf_version(db, document)
except Exception:
- return RedirectResponse(
- url=_document_url(document.document_id, error="save_ocr_corrected_failed", tab="ocr-review"),
- status_code=303,
- )
+ return RedirectResponse(url=f"/documents/{document.document_id}?error=save_ocr_corrected_failed", status_code=303)
- return RedirectResponse(url=_document_url(document.document_id, tab="ocr-review"), status_code=303)
+ return RedirectResponse(url=f"/documents/{document.document_id}?tab=ocr-review", status_code=303)
@router.post("/{document_id}/move-to-trash", response_class=RedirectResponse)
@@ -286,7 +366,6 @@ def move_to_trash(document_id: str, db: Session = Depends(get_db)):
if document is None:
return RedirectResponse(url="/documents/", status_code=303)
- from datetime import datetime
document.is_trashed = True
document.trashed_at = datetime.utcnow()
db.commit()
@@ -303,12 +382,9 @@ def save_field_enriched_pdf(document_id: str, db: Session = Depends(get_db)):
try:
create_field_enriched_pdf_version(db, document)
except Exception:
- return RedirectResponse(
- url=_document_url(document.document_id, error="save_field_enriched_failed", tab="extracted-fields"),
- status_code=303,
- )
+ return RedirectResponse(url=f"/documents/{document.document_id}?error=save_field_enriched_failed", status_code=303)
- return RedirectResponse(url=_document_url(document.document_id, tab="extracted-fields"), status_code=303)
+ return RedirectResponse(url=f"/documents/{document.document_id}?tab=extracted-fields", status_code=303)
@router.post("/{document_id}/review-text", response_class=RedirectResponse)
@@ -335,19 +411,11 @@ def save_reviewed_text(
if expected_line_count and actual_line_count != expected_line_count:
return RedirectResponse(
- url=_document_url(
- document.document_id,
- error="line_count_mismatch",
- expected=expected_line_count,
- actual=actual_line_count,
- tab="ocr-review",
- ),
+ url=f"/documents/{document.document_id}?error=line_count_mismatch&expected={expected_line_count}&actual={actual_line_count}&tab=ocr-review",
status_code=303,
)
- existing_reviewed = [
- tv for tv in document.text_versions if tv.version_type == "reviewed" and tv.is_current
- ]
+ existing_reviewed = [tv for tv in document.text_versions if tv.version_type == "reviewed" and tv.is_current]
for tv in existing_reviewed:
tv.is_current = False
@@ -374,13 +442,9 @@ def save_reviewed_text(
raw_ocr.quality_note = quality_note or None
document.review_status = "reviewed"
-
db.commit()
- return RedirectResponse(
- url=_document_url(document.document_id, editor_source="reviewed", tab="ocr-review"),
- status_code=303,
- )
+ return RedirectResponse(url=f"/documents/{document.document_id}?editor_source=reviewed&tab=ocr-review", status_code=303)
@router.post("/{document_id}/save-extracted-fields", response_class=RedirectResponse)
@@ -428,10 +492,55 @@ def save_extracted_fields_route(
extra_json=extra_json,
)
- return RedirectResponse(
- url=_document_url(document.document_id, autofill_extracted=0, tab="extracted-fields"),
- status_code=303,
+ return RedirectResponse(url=f"/documents/{document.document_id}?autofill_extracted=0&tab=extracted-fields", status_code=303)
+
+
+@router.post("/{document_id}/save-additional-fields", response_class=RedirectResponse)
+def save_additional_fields_route(
+ document_id: str,
+ owner_person: str = Form(""),
+ paid_by_person: str = Form(""),
+ covered_people: str = Form(""),
+ attendees: str = Form(""),
+ occasion_note: str = Form(""),
+ is_shared_expense: str | None = Form(None),
+ reimbursement_expected_from: str = Form(""),
+ reimbursement_paid_by: str = Form(""),
+ reimbursement_paid_to: str = Form(""),
+ reimbursement_paid_amount: str = Form(""),
+ reimbursement_paid_date: str = Form(""),
+ reimbursement_note: str = Form(""),
+ db: Session = Depends(get_db),
+):
+ document = (
+ db.query(Document)
+ .options(selectinload(Document.additional_fields))
+ .filter(Document.document_id == document_id)
+ .first()
)
+ if document is None:
+ return RedirectResponse(url="/documents/", status_code=303)
+
+ current = _get_current_additional_fields(document)
+ if current is None:
+ current = DocumentAdditionalField(document_id=document.id)
+ db.add(current)
+
+ current.owner_person = owner_person.strip() or None
+ current.paid_by_person = paid_by_person.strip() or None
+ current.covered_people = _parse_people_list(covered_people)
+ current.attendees = _parse_people_list(attendees)
+ current.occasion_note = occasion_note.strip() or None
+ current.is_shared_expense = bool(is_shared_expense)
+ current.reimbursement_expected_from = _parse_people_list(reimbursement_expected_from)
+ current.reimbursement_paid_by = reimbursement_paid_by.strip() or None
+ current.reimbursement_paid_to = reimbursement_paid_to.strip() or None
+ current.reimbursement_paid_amount = _to_decimal(reimbursement_paid_amount)
+ current.reimbursement_paid_date = datetime.strptime(reimbursement_paid_date, "%Y-%m-%d").date() if reimbursement_paid_date else None
+ current.reimbursement_note = reimbursement_note.strip() or None
+
+ db.commit()
+ return RedirectResponse(url=f"/documents/{document.document_id}?tab=additional-fields", status_code=303)
@router.get("/{document_id}", response_class=HTMLResponse)
@@ -443,6 +552,7 @@ def document_detail(document_id: str, request: Request, queue: str | None = None
selectinload(Document.text_versions),
selectinload(Document.extracted_fields),
selectinload(Document.layer1_candidates),
+ selectinload(Document.additional_fields),
)
.filter(Document.document_id == document_id)
.first()
@@ -454,7 +564,6 @@ def document_detail(document_id: str, request: Request, queue: str | None = None
raw_ocr, reviewed_ocr = _get_current_text_versions(document)
editor_source = request.query_params.get("editor_source", "reviewed")
- active_tab = request.query_params.get("tab", "ocr-review")
review_text_value = _build_review_text_value(raw_ocr, reviewed_ocr, editor_source)
expected_line_count = _line_count_from_layout(raw_ocr.layout_json if raw_ocr else None)
@@ -476,10 +585,25 @@ def document_detail(document_id: str, request: Request, queue: str | None = None
error_expected = request.query_params.get("expected")
error_actual = request.query_params.get("actual")
+ preset_id_raw = request.query_params.get("preset_id")
+ try:
+ preset_id = int(preset_id_raw) if preset_id_raw else None
+ except ValueError:
+ preset_id = None
+
+ selected_preset = _get_preset_by_id(db, preset_id)
+ all_presets = _get_all_presets(db)
+
extracted_form = _extracted_field_form_values(document, request)
+ additional_form = _additional_field_form_values(document, selected_preset)
current_extracted = get_current_extracted_fields(document)
+ current_additional = _get_current_additional_fields(document)
queue_nav = _get_queue_navigation(db, document)
+ active_tab = request.query_params.get("tab", "ocr-review")
+ if active_tab not in {"ocr-review", "extracted-fields", "additional-fields", "versions", "raw-ocr"}:
+ active_tab = "ocr-review"
+
return templates.TemplateResponse(
request=request,
name="documents/detail.html",
@@ -495,7 +619,6 @@ def document_detail(document_id: str, request: Request, queue: str | None = None
"review_text_value": review_text_value,
"file_url": file_url,
"app_url": app_url,
- "active_tab": active_tab,
"quality_flag_options": QUALITY_FLAG_OPTIONS,
"current_quality_flags": raw_ocr.quality_flags if raw_ocr and raw_ocr.quality_flags else [],
"current_quality_note": raw_ocr.quality_note if raw_ocr and raw_ocr.quality_note else "",
@@ -507,6 +630,11 @@ def document_detail(document_id: str, request: Request, queue: str | None = None
"error_actual": error_actual,
"extracted_form": extracted_form,
"current_extracted": current_extracted,
+ "additional_form": additional_form,
+ "current_additional": current_additional,
+ "presets": all_presets,
+ "selected_preset_id": preset_id,
+ "active_tab": active_tab,
"active_page": "documents",
},
)
diff --git a/app/routes/presets.py b/app/routes/presets.py
new file mode 100644
index 0000000..b35840a
--- /dev/null
+++ b/app/routes/presets.py
@@ -0,0 +1,153 @@
+from pathlib import Path
+
+from fastapi import APIRouter, Depends, Form, Request
+from fastapi.responses import HTMLResponse, RedirectResponse
+from fastapi.templating import Jinja2Templates
+from sqlalchemy.orm import Session
+
+from app.db.deps import get_db
+from app.models.document_preset import DocumentPreset
+
+router = APIRouter(prefix="/presets", tags=["presets"])
+
+BASE_DIR = Path(__file__).resolve().parent.parent
+templates = Jinja2Templates(directory=str(BASE_DIR / "templates"))
+
+
+def _parse_people_list(value: str) -> list[str]:
+ return [part.strip() for part in value.split(",") if part.strip()]
+
+
+def _format_people_list(value: list | None) -> str:
+ if not value:
+ return ""
+ return ", ".join(str(x).strip() for x in value if str(x).strip())
+
+
+def _preset_form_values(preset: DocumentPreset | None = None) -> dict:
+ if preset is None:
+ return {
+ "name": "",
+ "owner_person": "",
+ "paid_by_person": "",
+ "covered_people": "",
+ "attendees": "",
+ "occasion_note": "",
+ "is_shared_expense": False,
+ "reimbursement_expected_from": "",
+ "reimbursement_paid_by": "",
+ "reimbursement_paid_to": "",
+ "reimbursement_note": "",
+ }
+
+ return {
+ "name": preset.name or "",
+ "owner_person": preset.owner_person or "",
+ "paid_by_person": preset.paid_by_person or "",
+ "covered_people": _format_people_list(preset.covered_people),
+ "attendees": _format_people_list(preset.attendees),
+ "occasion_note": preset.occasion_note or "",
+ "is_shared_expense": bool(preset.is_shared_expense),
+ "reimbursement_expected_from": _format_people_list(preset.reimbursement_expected_from),
+ "reimbursement_paid_by": preset.reimbursement_paid_by or "",
+ "reimbursement_paid_to": preset.reimbursement_paid_to or "",
+ "reimbursement_note": preset.reimbursement_note or "",
+ }
+
+
+@router.get("/", response_class=HTMLResponse)
+def list_presets(request: Request, edit_id: int | None = None, db: Session = Depends(get_db)):
+ presets = db.query(DocumentPreset).order_by(DocumentPreset.name.asc()).all()
+ editing = None
+ if edit_id is not None:
+ editing = db.query(DocumentPreset).filter(DocumentPreset.id == edit_id).first()
+
+ return templates.TemplateResponse(
+ request=request,
+ name="presets/index.html",
+ context={
+ "request": request,
+ "presets": presets,
+ "editing": editing,
+ "form_values": _preset_form_values(editing),
+ "active_page": "presets",
+ },
+ )
+
+
+@router.post("/create", response_class=RedirectResponse)
+def create_preset(
+ name: str = Form(...),
+ owner_person: str = Form(""),
+ paid_by_person: str = Form(""),
+ covered_people: str = Form(""),
+ attendees: str = Form(""),
+ occasion_note: str = Form(""),
+ is_shared_expense: str | None = Form(None),
+ reimbursement_expected_from: str = Form(""),
+ reimbursement_paid_by: str = Form(""),
+ reimbursement_paid_to: str = Form(""),
+ reimbursement_note: str = Form(""),
+ db: Session = Depends(get_db),
+):
+ preset = DocumentPreset(
+ name=name.strip(),
+ owner_person=owner_person.strip() or None,
+ paid_by_person=paid_by_person.strip() or None,
+ covered_people=_parse_people_list(covered_people),
+ attendees=_parse_people_list(attendees),
+ occasion_note=occasion_note.strip() or None,
+ is_shared_expense=bool(is_shared_expense),
+ reimbursement_expected_from=_parse_people_list(reimbursement_expected_from),
+ reimbursement_paid_by=reimbursement_paid_by.strip() or None,
+ reimbursement_paid_to=reimbursement_paid_to.strip() or None,
+ reimbursement_note=reimbursement_note.strip() or None,
+ )
+ db.add(preset)
+ db.commit()
+ return RedirectResponse(url="/presets/", status_code=303)
+
+
+@router.post("/{preset_id}/update", response_class=RedirectResponse)
+def update_preset(
+ preset_id: int,
+ name: str = Form(...),
+ owner_person: str = Form(""),
+ paid_by_person: str = Form(""),
+ covered_people: str = Form(""),
+ attendees: str = Form(""),
+ occasion_note: str = Form(""),
+ is_shared_expense: str | None = Form(None),
+ reimbursement_expected_from: str = Form(""),
+ reimbursement_paid_by: str = Form(""),
+ reimbursement_paid_to: str = Form(""),
+ reimbursement_note: str = Form(""),
+ db: Session = Depends(get_db),
+):
+ preset = db.query(DocumentPreset).filter(DocumentPreset.id == preset_id).first()
+ if preset is None:
+ return RedirectResponse(url="/presets/", status_code=303)
+
+ preset.name = name.strip()
+ preset.owner_person = owner_person.strip() or None
+ preset.paid_by_person = paid_by_person.strip() or None
+ preset.covered_people = _parse_people_list(covered_people)
+ preset.attendees = _parse_people_list(attendees)
+ preset.occasion_note = occasion_note.strip() or None
+ preset.is_shared_expense = bool(is_shared_expense)
+ preset.reimbursement_expected_from = _parse_people_list(reimbursement_expected_from)
+ preset.reimbursement_paid_by = reimbursement_paid_by.strip() or None
+ preset.reimbursement_paid_to = reimbursement_paid_to.strip() or None
+ preset.reimbursement_note = reimbursement_note.strip() or None
+
+ db.commit()
+ return RedirectResponse(url="/presets/", status_code=303)
+
+
+@router.post("/{preset_id}/delete", response_class=RedirectResponse)
+def delete_preset(preset_id: int, db: Session = Depends(get_db)):
+ preset = db.query(DocumentPreset).filter(DocumentPreset.id == preset_id).first()
+ if preset is not None:
+ db.delete(preset)
+ db.commit()
+ return RedirectResponse(url="/presets/", status_code=303)
diff --git a/app/templates/documents/detail.html b/app/templates/documents/detail.html
index df1018c..a389064 100644
--- a/app/templates/documents/detail.html
+++ b/app/templates/documents/detail.html
@@ -94,6 +94,7 @@
+
@@ -187,6 +188,92 @@
+
+
Additional fields
+
+ {% if current_additional %}
+
Current additional fields last updated at {{ current_additional.updated_at }}
+ {% else %}
+
No additional fields saved yet.
+ {% endif %}
+
+ {% if presets %}
+
+ {% else %}
+
+ {% endif %}
+
+
+
+
Document versions
{% if document.versions %}
diff --git a/app/templates/presets/index.html b/app/templates/presets/index.html
new file mode 100644
index 0000000..fb53a66
--- /dev/null
+++ b/app/templates/presets/index.html
@@ -0,0 +1,143 @@
+
+
+
+
+
Presets
+
+
+
+
+ {% include "partials/sidebar.html" %}
+
+
+
+
+
Presets
+
Create reusable defaults for additional fields.
+
+
+
+
+
{% if editing %}Edit preset{% else %}Create preset{% endif %}
+
+
+
+
+
+
Existing presets
+
+ {% if presets %}
+
+
+
+
+ | Name |
+ Owner |
+ Paid by |
+ Covered people |
+ Attendees |
+ Shared |
+ Actions |
+
+
+
+ {% for preset in presets %}
+
+ | {{ preset.name }} |
+ {{ preset.owner_person or "" }} |
+ {{ preset.paid_by_person or "" }} |
+ {{ preset.covered_people or [] }} |
+ {{ preset.attendees or [] }} |
+ {{ "Yes" if preset.is_shared_expense else "No" }} |
+
+
+ |
+
+ {% endfor %}
+
+
+
+ {% else %}
+
No presets yet.
+ {% endif %}
+
+
+
+
+
+
+