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 %} +
+ +
+ + + Manage presets +
+
+ {% else %} +
+ Create first preset +
+ {% 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 %}

+ +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ + {% if editing %} + Cancel + {% endif %} +
+
+
+ +
+

Existing presets

+ + {% if presets %} +
+ + + + + + + + + + + + + + {% for preset in presets %} + + + + + + + + + + {% endfor %} + +
NameOwnerPaid byCovered peopleAttendeesSharedActions
{{ 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" }} +
+ Edit +
+ +
+
+
+
+ {% else %} +

No presets yet.

+ {% endif %} +
+
+
+ + + +