from __future__ import annotations from collections import defaultdict from datetime import date, datetime, timezone from typing import Any from fastapi import APIRouter, Form, Request from fastapi.responses import RedirectResponse from sqlalchemy import or_, select from sqlalchemy.orm import Session, selectinload from app.core.db.common import CommonSessionLocal 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 app.modules.documents.models import EngagementDocument from app.modules.clients.models import Client from app.modules.services.execution import CLOSED_TASK_STATUSES, TASK_PRIORITIES, TASK_STATUSES from app.modules.services.models import ( ClientServiceSubscription, ClientServiceTaskInstance, ServiceCatalogue, ServiceTaskComment, ) router = APIRouter(prefix="/partner", tags=["partner-workspace-ui"]) REVIEW_ACTIONS = { "approve": ("completed", "partner_review", "partner_review"), "send_rework": ("pending", "partner_review_note", "partner_review"), "clarification": ("blocked", "client_clarification", "internal"), } def _redirect_login(): return RedirectResponse(url="/login", status_code=303) def _redirect_denied(): from app.core.http_responses import ui_access_denied return ui_access_denied() def _is_partner_user(db: Session, current_user) -> bool: roles = set(get_user_roles(db, current_user.id)) return bool(roles.intersection({"Partner", "Firm Admin", "System Admin"})) def _base_ctx(request: Request, db: Session, current_user, **ctx): base = { "request": request, "current_user": current_user, "current_user_roles": get_user_roles(db, current_user.id), "current_user_permissions": get_user_permissions(db, current_user.id), "csrf_token": get_or_create_csrf_token(request), "task_statuses": TASK_STATUSES, "task_priorities": TASK_PRIORITIES, } base.update(ctx) return base def _render(request: Request, template_name: str, db: Session, current_user, **ctx): return templates.TemplateResponse(template_name, _base_ctx(request, db, current_user, **ctx)) def _active_tenant_branch(request: Request, current_user, roles: set[str]) -> tuple[int, int | None]: tenant_id = request.session.get("active_tenant_id") or current_user.tenant_id branch_id = request.session.get("active_branch_id") if "System Admin" not in roles: tenant_id = current_user.tenant_id if not roles.intersection({"System Admin", "Firm Admin"}): branch_id = current_user.branch_id if branch_id in (0, "0", "", None): branch_id = None return int(tenant_id), int(branch_id) if branch_id is not None else None def _active_financial_year(request: Request) -> str | None: value = request.session.get("active_financial_year") or getattr(request.state, "year_code", None) value = (value or "").strip() return value or None def _subscription_scope_filter(stmt, tenant_id: int, branch_id: int | None, current_user, roles: set[str], financial_year: str | None = None): stmt = stmt.where(ClientServiceSubscription.tenant_id == tenant_id, ClientServiceSubscription.is_active.is_(True)) if branch_id is not None: stmt = stmt.where(ClientServiceSubscription.branch_id == branch_id) if financial_year: stmt = stmt.where(ClientServiceSubscription.financial_year == financial_year.strip()) if not roles.intersection({"System Admin", "Firm Admin"}): stmt = stmt.where( or_( ClientServiceSubscription.assigned_partner_user_id == current_user.id, ClientServiceSubscription.review_partner_user_id == current_user.id, ) ) return stmt def _task_scope_filter(stmt, tenant_id: int, branch_id: int | None, current_user, roles: set[str], financial_year: str | None = None): stmt = stmt.where(ClientServiceTaskInstance.tenant_id == tenant_id, ClientServiceTaskInstance.is_active.is_(True)) if branch_id is not None: stmt = stmt.where(ClientServiceTaskInstance.branch_id == branch_id) if financial_year: stmt = stmt.where(ClientServiceTaskInstance.financial_year == financial_year.strip()) if not roles.intersection({"System Admin", "Firm Admin"}): stmt = stmt.where( ClientServiceTaskInstance.subscription.has( or_( ClientServiceSubscription.assigned_partner_user_id == current_user.id, ClientServiceSubscription.review_partner_user_id == current_user.id, ) ) ) return stmt def _task_status_label(task: ClientServiceTaskInstance) -> str: return dict(TASK_STATUSES).get(getattr(task, "status", ""), (getattr(task, "status", "") or "-").replace("_", " ").title()) def _task_priority_label(task: ClientServiceTaskInstance) -> str: return dict(TASK_PRIORITIES).get(getattr(task, "priority", ""), (getattr(task, "priority", "") or "normal").replace("_", " ").title()) def _engagement_label(subscription: ClientServiceSubscription | None, task: ClientServiceTaskInstance | None = None) -> str: catalogue = getattr(subscription, "catalogue", None) if subscription else getattr(task, "catalogue", None) service_name = getattr(catalogue, "service_name", None) or getattr(catalogue, "name", None) or "Engagement" fy = getattr(subscription, "financial_year", None) or getattr(task, "financial_year", None) return f"{service_name} ยท FY {fy}" if fy else str(service_name) def _decorate_task(task: ClientServiceTaskInstance, today: date) -> ClientServiceTaskInstance: client = getattr(task, "client", None) assignee = getattr(task, "assigned_to", None) subscription = getattr(task, "subscription", None) target = getattr(task, "internal_target_date", None) status = (task.status or "pending").strip().lower() is_closed = status in CLOSED_TASK_STATUSES task.status_label = _task_status_label(task) task.priority_label = _task_priority_label(task) task.client_display = getattr(client, "client_name", None) or "Unlinked Client" task.client_code_display = getattr(client, "client_code", None) or "" task.assignee_display = getattr(assignee, "full_name", None) or getattr(assignee, "email", None) or "Unassigned" task.engagement_label = _engagement_label(subscription, task) task.is_overdue = bool(target and target < today and not is_closed) task.is_due_today = bool(target and target == today and not is_closed) task.comment_count = len([c for c in getattr(task, "comments", []) if not getattr(c, "is_deleted", False)]) return task def _task_bucket(task: ClientServiceTaskInstance) -> str: status = (task.status or "pending").strip().lower() if status == "blocked": return "clarification_required" if status == "completed": return "pending_review" if status in CLOSED_TASK_STATUSES: return "completed" if status in {"pending", "rework", "rework_required"}: return "rework_sent" return "approved" def build_partner_payload(db: Session, request: Request, current_user, *, q: str = "") -> dict[str, Any]: roles = set(get_user_roles(db, current_user.id)) tenant_id, branch_id = _active_tenant_branch(request, current_user, roles) financial_year = _active_financial_year(request) today = date.today() task_stmt = ( select(ClientServiceTaskInstance) .options( selectinload(ClientServiceTaskInstance.client), selectinload(ClientServiceTaskInstance.catalogue), selectinload(ClientServiceTaskInstance.subscription).selectinload(ClientServiceSubscription.catalogue), selectinload(ClientServiceTaskInstance.subscription).selectinload(ClientServiceSubscription.assigned_manager), selectinload(ClientServiceTaskInstance.subscription).selectinload(ClientServiceSubscription.assigned_staff), selectinload(ClientServiceTaskInstance.assigned_to), selectinload(ClientServiceTaskInstance.comments).selectinload(ServiceTaskComment.created_by), ) ) task_stmt = _task_scope_filter(task_stmt, tenant_id, branch_id, current_user, roles, financial_year=financial_year) if q.strip(): like = f"%{q.strip()}%" task_stmt = task_stmt.where( or_( ClientServiceTaskInstance.task_name.ilike(like), ClientServiceTaskInstance.description.ilike(like), ClientServiceTaskInstance.client.has(or_(Client.client_name.ilike(like), Client.client_code.ilike(like))), ClientServiceTaskInstance.catalogue.has(or_(ServiceCatalogue.service_name.ilike(like), ServiceCatalogue.service_code.ilike(like))), ) ) tasks = db.execute( task_stmt.order_by( ClientServiceTaskInstance.internal_target_date.is_(None), ClientServiceTaskInstance.internal_target_date.asc(), ClientServiceTaskInstance.priority.desc(), ClientServiceTaskInstance.id.desc(), ) ).scalars().all() columns = [ {"code": "pending_review", "label": "Pending Review", "hint": "Completed tasks waiting for partner review", "tasks": []}, {"code": "clarification_required", "label": "Clarification Required", "hint": "Blocked tasks needing partner attention", "tasks": []}, {"code": "rework_sent", "label": "Rework Sent", "hint": "Pending/reopened after review notes", "tasks": []}, {"code": "approved", "label": "In Progress", "hint": "Work currently moving with the team", "tasks": []}, {"code": "completed", "label": "Completed", "hint": "Closed tasks", "tasks": []}, ] lookup = {c["code"]: c for c in columns} summary = {"total": len(tasks), "pending_review": 0, "blocked": 0, "overdue": 0, "due_today": 0, "completed": 0, "clients": set(), "engagements": set()} for task in tasks: _decorate_task(task, today) status = (task.status or "pending").lower() if getattr(task, "client_id", None): summary["clients"].add(task.client_id) if getattr(task, "subscription_id", None): summary["engagements"].add(task.subscription_id) if status == "completed": summary["pending_review"] += 1 if status == "blocked": summary["blocked"] += 1 if getattr(task, "is_overdue", False): summary["overdue"] += 1 if getattr(task, "is_due_today", False): summary["due_today"] += 1 if status in CLOSED_TASK_STATUSES: summary["completed"] += 1 lookup[_task_bucket(task)]["tasks"].append(task) summary["clients"] = len(summary["clients"]) summary["engagements"] = len(summary["engagements"]) for col in columns: col["count"] = len(col["tasks"]) engagement_stmt = ( select(ClientServiceSubscription) .options( selectinload(ClientServiceSubscription.client), selectinload(ClientServiceSubscription.catalogue), selectinload(ClientServiceSubscription.assigned_manager), selectinload(ClientServiceSubscription.assigned_staff), ) ) engagement_stmt = _subscription_scope_filter(engagement_stmt, tenant_id, branch_id, current_user, roles, financial_year=financial_year) engagements = db.execute( engagement_stmt.order_by(ClientServiceSubscription.current_due_date.is_(None), ClientServiceSubscription.current_due_date.asc(), ClientServiceSubscription.id.desc()).limit(25) ).scalars().all() task_counts_by_subscription: dict[int, dict[str, int]] = defaultdict(lambda: {"total": 0, "completed": 0, "open": 0}) for task in tasks: bucket = task_counts_by_subscription[int(task.subscription_id)] bucket["total"] += 1 if (task.status or "").lower() in CLOSED_TASK_STATUSES: bucket["completed"] += 1 else: bucket["open"] += 1 for engagement in engagements: engagement.display_label = _engagement_label(engagement) engagement.client_display = getattr(getattr(engagement, "client", None), "client_name", None) or "Unlinked Client" engagement.task_counts = task_counts_by_subscription.get(int(engagement.id), {"total": 0, "completed": 0, "open": 0}) due = getattr(engagement, "current_due_date", None) engagement.is_overdue = bool(due and due < today and (engagement.status or "").lower() not in {"completed", "closed", "locked"}) return {"summary": summary, "columns": columns, "engagements": engagements, "q": q, "today": today, "financial_year": financial_year} def _get_partner_task_or_redirect(db: Session, request: Request, current_user, task_id: int) -> ClientServiceTaskInstance | None: roles = set(get_user_roles(db, current_user.id)) tenant_id, branch_id = _active_tenant_branch(request, current_user, roles) financial_year = _active_financial_year(request) stmt = select(ClientServiceTaskInstance).options(selectinload(ClientServiceTaskInstance.subscription)).where(ClientServiceTaskInstance.id == int(task_id)) stmt = _task_scope_filter(stmt, tenant_id, branch_id, current_user, roles, financial_year=financial_year) return db.execute(stmt).scalar_one_or_none() @router.get("/dashboard") def partner_dashboard(request: Request, q: str = ""): db = CommonSessionLocal() try: current_user = get_current_user(request, db=db) if not current_user: return _redirect_login() if not _is_partner_user(db, current_user): return _redirect_denied() payload = build_partner_payload(db, request, current_user, q=q) return _render(request, "modules/partners/templates/partners/dashboard.html", db, current_user, title="Partner Workspace", payload=payload, q=q, errors=[]) finally: db.close() @router.get("/reviews") def partner_review_board(request: Request, q: str = ""): db = CommonSessionLocal() try: current_user = get_current_user(request, db=db) if not current_user: return _redirect_login() if not _is_partner_user(db, current_user): return _redirect_denied() payload = build_partner_payload(db, request, current_user, q=q) return _render(request, "modules/partners/templates/partners/review_board.html", db, current_user, title="Partner Review Board", payload=payload, q=q, errors=[]) finally: db.close() @router.get("/clients") def partner_clients(request: Request, q: str = ""): db = CommonSessionLocal() try: current_user = get_current_user(request, db=db) if not current_user: return _redirect_login() if not _is_partner_user(db, current_user): return _redirect_denied() payload = build_partner_payload(db, request, current_user, q=q) return _render(request, "modules/partners/templates/partners/clients.html", db, current_user, title="My Client Portfolio", payload=payload, q=q, errors=[]) finally: db.close() @router.get("/engagements/{engagement_id}") def partner_engagement_detail(request: Request, engagement_id: int): db = CommonSessionLocal() try: current_user = get_current_user(request, db=db) if not current_user: return _redirect_login() if not _is_partner_user(db, current_user): return _redirect_denied() roles = set(get_user_roles(db, current_user.id)) tenant_id, branch_id = _active_tenant_branch(request, current_user, roles) financial_year = _active_financial_year(request) engagement_stmt = select(ClientServiceSubscription).options( selectinload(ClientServiceSubscription.client), selectinload(ClientServiceSubscription.catalogue), selectinload(ClientServiceSubscription.assigned_manager), selectinload(ClientServiceSubscription.assigned_staff), ).where(ClientServiceSubscription.id == int(engagement_id)) engagement_stmt = _subscription_scope_filter(engagement_stmt, tenant_id, branch_id, current_user, roles, financial_year=financial_year) engagement = db.execute(engagement_stmt).scalar_one_or_none() if not engagement: return _redirect_denied() task_stmt = select(ClientServiceTaskInstance).options( selectinload(ClientServiceTaskInstance.client), selectinload(ClientServiceTaskInstance.catalogue), selectinload(ClientServiceTaskInstance.subscription).selectinload(ClientServiceSubscription.catalogue), selectinload(ClientServiceTaskInstance.assigned_to), selectinload(ClientServiceTaskInstance.comments).selectinload(ServiceTaskComment.created_by), ).where(ClientServiceTaskInstance.subscription_id == engagement.id, ClientServiceTaskInstance.is_active.is_(True)) tasks = db.execute(task_stmt.order_by(ClientServiceTaskInstance.sequence_no.asc(), ClientServiceTaskInstance.id.asc())).scalars().all() today = date.today() for task in tasks: _decorate_task(task, today) documents = db.execute( select(EngagementDocument) .where(EngagementDocument.engagement_id == engagement.id, EngagementDocument.is_deleted.is_(False)) .order_by(EngagementDocument.updated_at_utc.desc(), EngagementDocument.id.desc()) ).scalars().all() engagement.display_label = _engagement_label(engagement) return _render(request, "modules/partners/templates/partners/engagement_detail.html", db, current_user, title="Partner Engagement Review", engagement=engagement, tasks=tasks, documents=documents, errors=[], financial_year=financial_year) finally: db.close() @router.post("/tasks/{task_id}/review") def partner_review_task(request: Request, task_id: int, action: str = Form(...), message: str = Form(""), csrf_token: str = Form(...)): db = CommonSessionLocal() try: validate_csrf(request, csrf_token) current_user = get_current_user(request, db=db) if not current_user: return _redirect_login() if not _is_partner_user(db, current_user): return _redirect_denied() task = _get_partner_task_or_redirect(db, request, current_user, task_id) if not task: return _redirect_denied() new_status, comment_type, visibility = REVIEW_ACTIONS.get(action, REVIEW_ACTIONS["approve"]) task.status = new_status task.updated_by_user_id = current_user.id task.updated_at_utc = datetime.now(timezone.utc) note = (message or "").strip() if not note: note = { "approve": "Approved by partner.", "send_rework": "Sent back for rework by partner.", "clarification": "Clarification requested by partner.", }.get(action, "Partner review updated.") db.add(ServiceTaskComment( tenant_id=task.tenant_id, branch_id=task.branch_id, subscription_id=task.subscription_id, task_instance_id=task.id, comment_type=comment_type, visibility=visibility, message=note, created_by_user_id=current_user.id, )) db.commit() return RedirectResponse(url=f"/partner/engagements/{task.subscription_id}", status_code=303) finally: db.close()