Files
2026-08-05 10:44:41 +05:30

232 lines
9.7 KiB
Python

from __future__ import annotations
from datetime import datetime, timezone
from pathlib import Path
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from fastapi import APIRouter, File, Form, Request, UploadFile
from fastapi.responses import FileResponse, JSONResponse, RedirectResponse
from sqlalchemy import select
from app.core.db.common import CommonSessionLocal
from app.core.http_responses import ui_access_denied, not_found_response
from app.core.security.csrf import get_or_create_csrf_token, validate_csrf
from app.core.security.session_auth import get_current_user
from app.core.templating import templates
from app.modules.core.rbac.deps import get_user_permissions, get_user_roles
from .parsers.registry import BANK_OPTIONS
from .service import (
can_use,
create_job_folder,
delete_owned_job,
enqueue_job,
ensure_worker_started,
get_owned_job,
get_owned_original_statement,
job_view,
list_user_jobs,
save_uploads,
)
router = APIRouter(prefix="/tools/bank-statement-analyzer", tags=["bank-statement-analyzer-ui"])
def _display_timezone_name(request: Request, db, user) -> str:
"""Resolve the active branch timezone, falling back to tenant and India defaults."""
from app.modules.core.tenancy.models import Branch, Tenant
branch_id = request.session.get("active_branch_id") or getattr(user, "branch_id", None)
tenant_id = request.session.get("active_tenant_id") or getattr(user, "tenant_id", None)
if branch_id not in (None, "", 0, "0"):
branch = db.scalar(select(Branch).where(Branch.id == int(branch_id)))
branch_timezone = (getattr(branch, "timezone", None) or "").strip() if branch else ""
if branch_timezone:
return branch_timezone
if tenant_id not in (None, "", 0, "0"):
tenant = db.scalar(select(Tenant).where(Tenant.id == int(tenant_id)))
tenant_timezone = (getattr(tenant, "default_timezone", None) or "").strip() if tenant else ""
if tenant_timezone:
return tenant_timezone
return "Asia/Kolkata"
def _format_local_datetime(value: datetime | None, timezone_name: str) -> str | None:
if value is None:
return None
try:
target_timezone = ZoneInfo(timezone_name)
except ZoneInfoNotFoundError:
target_timezone = ZoneInfo("Asia/Kolkata")
aware_value = value if value.tzinfo is not None else value.replace(tzinfo=timezone.utc)
return aware_value.astimezone(target_timezone).strftime("%d-%b-%Y %I:%M %p %Z")
def _localised_job_view(job, timezone_name: str) -> dict:
view = job_view(job)
view["submitted_at"] = _format_local_datetime(view.get("submitted_at"), timezone_name)
view["completed_at"] = _format_local_datetime(view.get("completed_at"), timezone_name)
view["expires_at"] = _format_local_datetime(view.get("expires_at"), timezone_name)
return view
def _ctx(request, db, user, **extra):
data = {
"request": request,
"current_user": user,
"current_user_roles": get_user_roles(db, user.id),
"current_user_permissions": get_user_permissions(db, user.id),
"csrf_token": get_or_create_csrf_token(request),
"title": "Bank Statement Analyzer",
"bank_options": BANK_OPTIONS,
"selected_bank": "auto",
"financial_year": "",
"classification_enabled": True,
}
data.update(extra)
return data
def _auth(request, db):
user = get_current_user(request, db=db)
if not user:
return None, None, RedirectResponse("/login", status_code=303)
roles = get_user_roles(db, user.id)
if not can_use(roles):
return user, roles, ui_access_denied("Bank Statement Analyzer is available only to Partner, Manager, Staff and Consultant roles.")
return user, roles, None
@router.get("")
def index(request: Request, job: str | None = None):
ensure_worker_started()
db = CommonSessionLocal()
try:
user, roles, denied = _auth(request, db)
if denied:
return denied
timezone_name = _display_timezone_name(request, db, user)
selected_job = get_owned_job(user.id, job) if job else None
recent = [_localised_job_view(item, timezone_name) for item in list_user_jobs(user.id, limit=8)]
active_job = _localised_job_view(selected_job, timezone_name) if selected_job else None
return templates.TemplateResponse("modules/bank_statement_analyzer/templates/bank_statement_analyzer/index.html", _ctx(request, db, user, error="", active_job=active_job, recent_jobs=recent, display_timezone=timezone_name))
finally:
db.close()
@router.post("/analyze")
async def analyze(request: Request, csrf_token: str = Form(...), bank_selection: str = Form("auto"), financial_year: str = Form(""), customer_name: str = Form(""), account_number: str = Form(""), enable_classification: str | None = Form(None), statements: list[UploadFile] = File(...)):
db = CommonSessionLocal()
selected_bank = bank_selection if bank_selection in dict(BANK_OPTIONS) else "auto"
classification_enabled = enable_classification == "1"
job_dir: Path | None = None
try:
user, roles, denied = _auth(request, db)
if denied:
return denied
validate_csrf(request, csrf_token)
job_id, input_dir, _output_dir = create_job_folder(user, roles)
job_dir = input_dir.parent
paths = await save_uploads(statements, input_dir)
enqueue_job(user=user, roles=roles, job_id=job_id, paths=paths, job_dir=job_dir, bank_selection=selected_bank, financial_year=financial_year, customer_override=customer_name, account_override=account_number, classification_enabled=classification_enabled)
return RedirectResponse(f"/tools/bank-statement-analyzer?job={job_id}#analysis-status", status_code=303)
except Exception as exc:
if job_dir:
import shutil
shutil.rmtree(job_dir, ignore_errors=True)
user = get_current_user(request, db=db)
if not user:
return RedirectResponse("/login", status_code=303)
timezone_name = _display_timezone_name(request, db, user)
recent = [_localised_job_view(item, timezone_name) for item in list_user_jobs(user.id, limit=8)]
return templates.TemplateResponse("modules/bank_statement_analyzer/templates/bank_statement_analyzer/index.html", _ctx(request, db, user, error=str(exc), active_job=None, recent_jobs=recent, selected_bank=selected_bank, financial_year=financial_year, classification_enabled=classification_enabled, display_timezone=timezone_name), status_code=400)
finally:
db.close()
@router.get("/jobs")
def jobs(request: Request):
ensure_worker_started()
db = CommonSessionLocal()
try:
user, roles, denied = _auth(request, db)
if denied:
return denied
timezone_name = _display_timezone_name(request, db, user)
items = [_localised_job_view(item, timezone_name) for item in list_user_jobs(user.id, limit=100)]
return templates.TemplateResponse("modules/bank_statement_analyzer/templates/bank_statement_analyzer/jobs.html", _ctx(request, db, user, jobs=items, display_timezone=timezone_name))
finally:
db.close()
@router.get("/jobs/{job_id}/status")
def status(job_id: str, request: Request):
ensure_worker_started()
db = CommonSessionLocal()
try:
user, roles, denied = _auth(request, db)
if denied:
return JSONResponse({"detail": "Access denied"}, status_code=403)
job = get_owned_job(user.id, job_id)
if not job:
return JSONResponse({"detail": "Analysis job not found"}, status_code=404)
view = job_view(job)
return JSONResponse({"id": view["id"], "status": view["status"], "progress_percent": view["progress_percent"], "queue_position": view["queue_position"], "estimated_wait": view["estimated_wait"], "download_ready": view["download_ready"], "error_message": view["error_message"]})
finally:
db.close()
@router.get("/jobs/{job_id}/download")
def download(job_id: str, request: Request):
db = CommonSessionLocal()
try:
user, roles, denied = _auth(request, db)
if denied:
return denied
job = get_owned_job(user.id, job_id)
if not job:
return not_found_response(request, "Analysis job not found or expired.")
view = job_view(job)
if not view["download_ready"]:
return not_found_response(request, "Analysis workbook is not ready or has expired.")
return FileResponse(path=job.output_file, filename=Path(job.output_file).name, media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
finally:
db.close()
@router.get("/jobs/{job_id}/statements/{file_index}/download")
def download_original_statement(job_id: str, file_index: int, request: Request):
db = CommonSessionLocal()
try:
user, roles, denied = _auth(request, db)
if denied:
return denied
resolved = get_owned_original_statement(user.id, job_id, file_index)
if not resolved:
return not_found_response(request, "Original statement is unavailable or has expired.")
_job, path = resolved
return FileResponse(
path=path,
filename=path.name,
media_type="application/pdf",
)
finally:
db.close()
@router.post("/jobs/{job_id}/delete")
def delete(job_id: str, request: Request, csrf_token: str = Form(...)):
db = CommonSessionLocal()
try:
user, roles, denied = _auth(request, db)
if denied:
return denied
validate_csrf(request, csrf_token)
delete_owned_job(user.id, job_id)
return RedirectResponse("/tools/bank-statement-analyzer/jobs", status_code=303)
finally:
db.close()