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)