document-processor/app/main.py

236 lines
8.0 KiB
Python

from pathlib import Path
from decimal import Decimal
from fastapi import FastAPI, Request
from fastapi.responses import RedirectResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from sqlalchemy import create_engine, func
from sqlalchemy.orm import sessionmaker
from app.core.config import DATABASE_URL
from app.models.document import Document
from app.models.extracted_field import ExtractedField
from app.models.receipt_line_item import ReceiptLineItem
from app.routes.documents import router as documents_router
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")
app.mount("/static", StaticFiles(directory="app/static"), name="static")
storage_dir = Path("/mnt/svr-01/storage")
if storage_dir.exists():
app.mount("/files", StaticFiles(directory=str(storage_dir)), name="files")
else:
print("WARNING: storage mount not available, /files disabled")
app.include_router(health_router)
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")
engine = create_engine(DATABASE_URL)
SessionLocal = sessionmaker(bind=engine)
def _to_str(value) -> str:
if value is None:
return ""
return str(value)
@app.get("/")
def root_dashboard(request: Request):
db = SessionLocal()
try:
total_documents = db.query(func.count(Document.id)).scalar() or 0
active_documents = (
db.query(func.count(Document.id))
.filter(Document.is_trashed.is_(False))
.scalar()
or 0
)
reviewed_documents = (
db.query(func.count(Document.id))
.filter(Document.is_trashed.is_(False))
.filter(Document.review_status == "reviewed")
.scalar()
or 0
)
pending_review_documents = (
db.query(func.count(Document.id))
.filter(Document.is_trashed.is_(False))
.filter(Document.review_status != "reviewed")
.scalar()
or 0
)
trashed_documents = (
db.query(func.count(Document.id))
.filter(Document.is_trashed.is_(True))
.scalar()
or 0
)
extracted_documents = db.query(func.count(ExtractedField.id)).scalar() or 0
total_line_items = db.query(func.count(ReceiptLineItem.id)).scalar() or 0
cocktail_count = (
db.query(func.count(ReceiptLineItem.id))
.filter(func.lower(ReceiptLineItem.item_category) == "cocktail")
.scalar()
or 0
)
quality_candidates = (
db.query(ReceiptLineItem.extra_json)
.filter(func.lower(ReceiptLineItem.item_category) == "cocktail")
.all()
)
rated_cocktails = 0
pending_cocktail_reviews = 0
na_cocktails = 0
rating_sum = Decimal("0")
for (extra_json,) in quality_candidates:
extra = extra_json or {}
status = str(extra.get("quality_status") or "").strip().lower()
rating_raw = str(extra.get("quality_rating") or "").strip()
rating_dec = None
if rating_raw:
try:
rating_dec = Decimal(rating_raw)
except Exception:
rating_dec = None
if status == "na":
na_cocktails += 1
elif rating_dec is not None:
rated_cocktails += 1
rating_sum += rating_dec
else:
pending_cocktail_reviews += 1
avg_cocktail_rating = ""
if rated_cocktails > 0:
avg_cocktail_rating = str((rating_sum / Decimal(rated_cocktails)).quantize(Decimal("0.01")))
recent_documents = (
db.query(Document)
.filter(Document.is_trashed.is_(False))
.order_by(Document.updated_at.desc())
.limit(10)
.all()
)
recent_line_items = (
db.query(ReceiptLineItem)
.join(Document, ReceiptLineItem.document_id == Document.id)
.filter(Document.is_trashed.is_(False))
.order_by(ReceiptLineItem.updated_at.desc())
.limit(10)
.all()
)
recent_line_item_rows = []
for item in recent_line_items:
doc = item.document
merchant = ""
transaction_date = ""
if doc and doc.extracted_fields:
extracted = sorted(
doc.extracted_fields,
key=lambda x: x.updated_at or x.created_at,
reverse=True,
)[0]
merchant = extracted.merchant_normalized or extracted.merchant_raw or ""
if extracted.transaction_date:
transaction_date = extracted.transaction_date.isoformat()
extra = item.extra_json or {}
recent_line_item_rows.append(
{
"document_id": doc.document_id if doc else "",
"merchant": merchant,
"transaction_date": transaction_date,
"description": item.normalized_description or item.raw_description or "",
"category": item.item_category or "",
"line_total": _to_str(item.line_total),
"quality_rating": _to_str(extra.get("quality_rating")),
"quality_status": _to_str(extra.get("quality_status")),
}
)
return templates.TemplateResponse(
request=request,
name="dashboard.html",
context={
"request": request,
"active_page": "",
"total_documents": total_documents,
"active_documents": active_documents,
"reviewed_documents": reviewed_documents,
"pending_review_documents": pending_review_documents,
"trashed_documents": trashed_documents,
"extracted_documents": extracted_documents,
"total_line_items": total_line_items,
"cocktail_count": cocktail_count,
"rated_cocktails": rated_cocktails,
"pending_cocktail_reviews": pending_cocktail_reviews,
"na_cocktails": na_cocktails,
"avg_cocktail_rating": avg_cocktail_rating,
"recent_documents": recent_documents,
"recent_line_items": recent_line_item_rows,
"current_user": getattr(request.state, "current_user", None),
},
)
finally:
db.close()
from starlette.middleware.sessions import SessionMiddleware
from app.routes.auth import router as auth_router
@app.middleware("http")
async def auth_session_guard(request, call_next):
session = request.scope.get("session", {}) or {}
current_user = session.get("current_user")
request.state.current_user = current_user
path = request.url.path
allowed_prefixes = (
"/login",
"/logout",
"/static",
"/health",
)
if path == "/favicon.ico" or any(path.startswith(prefix) for prefix in allowed_prefixes):
return await call_next(request)
if not current_user:
login_url = f"/login?next={path}"
return RedirectResponse(url=login_url, status_code=303)
return await call_next(request)
app.add_middleware(
SessionMiddleware,
secret_key="document-processor-change-this-secret",
session_cookie="document_processor_session",
same_site="lax",
https_only=False,
)
app.include_router(auth_router)