277 lines
11 KiB
Python
277 lines
11 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 .client_context import analyzer_client_context, validate_selected_client_and_engagement
|
|
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,
|
|
"analyzer_clients": [],
|
|
"analyzer_engagements": [],
|
|
"selected_client_id": None,
|
|
"selected_engagement_id": None,
|
|
}
|
|
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
|
|
client_context = analyzer_client_context(db, request=request, user=user, roles=roles)
|
|
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,
|
|
analyzer_clients=client_context["clients"],
|
|
analyzer_engagements=client_context["engagements"],
|
|
),
|
|
)
|
|
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(""), client_id: str = Form(""), engagement_id: str = Form(""), confirm_same_client: str | None = Form(None), 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)
|
|
resolved_client_id = int(client_id) if client_id.strip() else None
|
|
resolved_engagement_id = int(engagement_id) if engagement_id.strip() else None
|
|
validate_selected_client_and_engagement(
|
|
db,
|
|
request=request,
|
|
user=user,
|
|
roles=roles,
|
|
client_id=resolved_client_id,
|
|
engagement_id=resolved_engagement_id,
|
|
)
|
|
|
|
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,
|
|
client_id=resolved_client_id,
|
|
engagement_id=resolved_engagement_id,
|
|
ownership_confirmation=(confirm_same_client == "1"),
|
|
)
|
|
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)]
|
|
client_context = analyzer_client_context(db, request=request, user=user, roles=get_user_roles(db, user.id))
|
|
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()
|