feat: add document presets for additional fields defaults
This commit is contained in:
parent
fcd70ec256
commit
9db8aadfdf
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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",
|
||||
},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -94,6 +94,7 @@
|
|||
<div class="right-pane-tabs">
|
||||
<button class="tab-button{% if active_tab == 'ocr-review' %} active{% endif %}" type="button" data-tab="ocr-review">OCR Review</button>
|
||||
<button class="tab-button{% if active_tab == 'extracted-fields' %} active{% endif %}" type="button" data-tab="extracted-fields">Extracted Fields</button>
|
||||
<button class="tab-button{% if active_tab == 'additional-fields' %} active{% endif %}" type="button" data-tab="additional-fields">Additional Fields</button>
|
||||
<button class="tab-button{% if active_tab == 'versions' %} active{% endif %}" type="button" data-tab="versions">Versions</button>
|
||||
<button class="tab-button{% if active_tab == 'raw-ocr' %} active{% endif %}" type="button" data-tab="raw-ocr">Raw OCR</button>
|
||||
</div>
|
||||
|
|
@ -187,6 +188,92 @@
|
|||
</form>
|
||||
</div>
|
||||
|
||||
<div class="tab-panel{% if active_tab == 'additional-fields' %} active{% endif %}" data-panel="additional-fields">
|
||||
<h2 class="card-title">Additional fields</h2>
|
||||
|
||||
{% if current_additional %}
|
||||
<p>Current additional fields last updated at {{ current_additional.updated_at }}</p>
|
||||
{% else %}
|
||||
<p class="empty-state">No additional fields saved yet.</p>
|
||||
{% endif %}
|
||||
|
||||
{% if presets %}
|
||||
<form method="get" action="/documents/{{ document.document_id }}" style="margin-bottom: 1rem;">
|
||||
<input type="hidden" name="tab" value="additional-fields">
|
||||
<div class="button-row">
|
||||
<select name="preset_id">
|
||||
<option value="">Select preset</option>
|
||||
{% for preset in presets %}
|
||||
<option value="{{ preset.id }}" {% if selected_preset_id == preset.id %}selected{% endif %}>{{ preset.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<button type="submit">Apply preset</button>
|
||||
<a class="button-link" href="/presets/">Manage presets</a>
|
||||
</div>
|
||||
</form>
|
||||
{% else %}
|
||||
<div class="button-row" style="margin-bottom: 1rem;">
|
||||
<a class="button-link" href="/presets/">Create first preset</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<form method="post" action="/documents/{{ document.document_id }}/save-additional-fields">
|
||||
<div class="form-grid">
|
||||
<div class="form-field">
|
||||
<label>Owner person</label>
|
||||
<input type="text" name="owner_person" value="{{ additional_form.owner_person }}">
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<label>Paid by person</label>
|
||||
<input type="text" name="paid_by_person" value="{{ additional_form.paid_by_person }}">
|
||||
</div>
|
||||
<div class="form-field full">
|
||||
<label>Covered people</label>
|
||||
<input type="text" name="covered_people" value="{{ additional_form.covered_people }}" placeholder="Full Name, Full Name">
|
||||
</div>
|
||||
<div class="form-field full">
|
||||
<label>Attendees</label>
|
||||
<input type="text" name="attendees" value="{{ additional_form.attendees }}" placeholder="Full Name, Full Name">
|
||||
</div>
|
||||
<div class="form-field full">
|
||||
<label>Occasion note</label>
|
||||
<input type="text" name="occasion_note" value="{{ additional_form.occasion_note }}" placeholder="Dinner with Camie and friends">
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<label><input type="checkbox" name="is_shared_expense" value="1" {% if additional_form.is_shared_expense %}checked{% endif %}> Shared expense</label>
|
||||
</div>
|
||||
<div class="form-field full">
|
||||
<label>Reimbursement expected from</label>
|
||||
<input type="text" name="reimbursement_expected_from" value="{{ additional_form.reimbursement_expected_from }}" placeholder="Full Name, Full Name">
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<label>Reimbursement paid by</label>
|
||||
<input type="text" name="reimbursement_paid_by" value="{{ additional_form.reimbursement_paid_by }}">
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<label>Reimbursement paid to</label>
|
||||
<input type="text" name="reimbursement_paid_to" value="{{ additional_form.reimbursement_paid_to }}">
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<label>Reimbursement paid amount</label>
|
||||
<input type="text" name="reimbursement_paid_amount" value="{{ additional_form.reimbursement_paid_amount }}">
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<label>Reimbursement paid date</label>
|
||||
<input type="date" name="reimbursement_paid_date" value="{{ additional_form.reimbursement_paid_date }}">
|
||||
</div>
|
||||
<div class="form-field full">
|
||||
<label>Reimbursement note</label>
|
||||
<textarea name="reimbursement_note" rows="4">{{ additional_form.reimbursement_note }}</textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="button-row" style="margin-top: 1rem;">
|
||||
<button class="primary" type="submit">Save additional fields</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="tab-panel{% if active_tab == 'versions' %} active{% endif %}" data-panel="versions">
|
||||
<h2 class="card-title">Document versions</h2>
|
||||
{% if document.versions %}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,143 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Presets</title>
|
||||
<link rel="stylesheet" href="/static/app.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="app-shell" id="app-shell">
|
||||
{% include "partials/sidebar.html" %}
|
||||
|
||||
<main class="main">
|
||||
<div class="topbar">
|
||||
<div>
|
||||
<h1 class="page-title">Presets</h1>
|
||||
<p class="page-subtitle">Create reusable defaults for additional fields.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2 class="card-title">{% if editing %}Edit preset{% else %}Create preset{% endif %}</h2>
|
||||
|
||||
<form method="post" action="{% if editing %}/presets/{{ editing.id }}/update{% else %}/presets/create{% endif %}">
|
||||
<div class="form-grid">
|
||||
<div class="form-field">
|
||||
<label>Preset name</label>
|
||||
<input type="text" name="name" value="{{ form_values.name }}" required>
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<label>Owner person</label>
|
||||
<input type="text" name="owner_person" value="{{ form_values.owner_person }}">
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<label>Paid by person</label>
|
||||
<input type="text" name="paid_by_person" value="{{ form_values.paid_by_person }}">
|
||||
</div>
|
||||
<div class="form-field full">
|
||||
<label>Covered people</label>
|
||||
<input type="text" name="covered_people" value="{{ form_values.covered_people }}" placeholder="Full Name, Full Name">
|
||||
</div>
|
||||
<div class="form-field full">
|
||||
<label>Attendees</label>
|
||||
<input type="text" name="attendees" value="{{ form_values.attendees }}" placeholder="Full Name, Full Name">
|
||||
</div>
|
||||
<div class="form-field full">
|
||||
<label>Occasion note</label>
|
||||
<input type="text" name="occasion_note" value="{{ form_values.occasion_note }}">
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<label><input type="checkbox" name="is_shared_expense" value="1" {% if form_values.is_shared_expense %}checked{% endif %}> Shared expense</label>
|
||||
</div>
|
||||
<div class="form-field full">
|
||||
<label>Reimbursement expected from</label>
|
||||
<input type="text" name="reimbursement_expected_from" value="{{ form_values.reimbursement_expected_from }}" placeholder="Full Name, Full Name">
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<label>Reimbursement paid by</label>
|
||||
<input type="text" name="reimbursement_paid_by" value="{{ form_values.reimbursement_paid_by }}">
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<label>Reimbursement paid to</label>
|
||||
<input type="text" name="reimbursement_paid_to" value="{{ form_values.reimbursement_paid_to }}">
|
||||
</div>
|
||||
<div class="form-field full">
|
||||
<label>Reimbursement note</label>
|
||||
<textarea name="reimbursement_note" rows="3">{{ form_values.reimbursement_note }}</textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="button-row" style="margin-top: 1rem;">
|
||||
<button class="primary" type="submit">{% if editing %}Save preset{% else %}Create preset{% endif %}</button>
|
||||
{% if editing %}
|
||||
<a class="button-link" href="/presets/">Cancel</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2 class="card-title">Existing presets</h2>
|
||||
|
||||
{% if presets %}
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Owner</th>
|
||||
<th>Paid by</th>
|
||||
<th>Covered people</th>
|
||||
<th>Attendees</th>
|
||||
<th>Shared</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for preset in presets %}
|
||||
<tr>
|
||||
<td>{{ preset.name }}</td>
|
||||
<td>{{ preset.owner_person or "" }}</td>
|
||||
<td>{{ preset.paid_by_person or "" }}</td>
|
||||
<td>{{ preset.covered_people or [] }}</td>
|
||||
<td>{{ preset.attendees or [] }}</td>
|
||||
<td>{{ "Yes" if preset.is_shared_expense else "No" }}</td>
|
||||
<td>
|
||||
<div class="button-row">
|
||||
<a class="button-link" href="/presets/?edit_id={{ preset.id }}">Edit</a>
|
||||
<form method="post" action="/presets/{{ preset.id }}/delete">
|
||||
<button class="danger" type="submit">Delete</button>
|
||||
</form>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="empty-state">No presets yet.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
const appShell = document.getElementById("app-shell");
|
||||
const menuToggle = document.getElementById("menu-toggle");
|
||||
if (appShell && menuToggle) {
|
||||
menuToggle.addEventListener("click", function () {
|
||||
appShell.classList.toggle("nav-open");
|
||||
});
|
||||
menuToggle.addEventListener("keydown", function (e) {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
appShell.classList.toggle("nav-open");
|
||||
}
|
||||
});
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Loading…
Reference in New Issue