236 lines
8.0 KiB
Python
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)
|