from __future__ import annotations from collections import defaultdict from datetime import date, datetime, timezone from typing import Any from urllib.parse import urlencode 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, ClientBranch, ClientBusinessUnit from app.modules.registrations.models import ClientRegistration, RegistrationType 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, current_user) -> str: status = (task.status or "pending").strip().lower() subscription = getattr(task, "subscription", None) uid = int(getattr(current_user, "id", 0) or 0) is_engagement_partner = uid and uid == int(getattr(subscription, "assigned_partner_user_id", 0) or 0) is_review_partner = uid and uid == int(getattr(subscription, "review_partner_user_id", 0) or 0) normal_role = (getattr(task, "normal_review_role", None) or "").strip().lower() normal_status = (getattr(task, "normal_review_status", None) or "not_required").strip().lower() normal_partner_review = ( is_engagement_partner and getattr(task, "normal_review_required", False) and normal_role in {"partner", "manager_or_partner"} ) aqmm_partner_review = is_engagement_partner and getattr(task, "aqmm_partner_review_required", False) aqmm_review_partner_review = is_review_partner and getattr(task, "aqmm_review_partner_required", False) if status == "blocked" and getattr(task, "rework_status", "none") != "open": return "clarification_required" if ( (normal_partner_review and normal_status == "rework_required") or (aqmm_partner_review and getattr(task, "partner_review_status", "not_required") == "rework_required") or (aqmm_review_partner_review and getattr(task, "review_partner_review_status", "not_required") == "rework_required") or getattr(task, "rework_status", "none") == "open" ): return "rework_sent" if ( (normal_partner_review and normal_status == "pending") or (aqmm_partner_review and getattr(task, "partner_review_status", "not_required") == "pending") or (aqmm_review_partner_review and getattr(task, "review_partner_review_status", "not_required") == "pending") ): return "pending_review" if status in CLOSED_TASK_STATUSES: return "completed" return "approved" def _normalise_name(value: str | None) -> str: return " ".join((value or "").split()).casefold() def _partner_scope_display_map( db: Session, *, tenant_id: int, subscriptions: list[ClientServiceSubscription], ) -> dict[int, dict[str, str]]: """Resolve business/trade identity for the Partner Review Board. Business/trade name is always primary. The legal client name is shown only when it is genuinely different, preserving the engagement's existing scope. """ subscriptions = [row for row in subscriptions if row is not None] if not subscriptions: return {} business_ids = {int(row.business_unit_id) for row in subscriptions if getattr(row, "business_unit_id", None)} branch_ids = {int(row.client_branch_id) for row in subscriptions if getattr(row, "client_branch_id", None)} registration_ids = {int(row.registration_id) for row in subscriptions if getattr(row, "registration_id", None)} businesses: dict[int, ClientBusinessUnit] = {} if business_ids: businesses = { row.id: row for row in db.execute( select(ClientBusinessUnit).where( ClientBusinessUnit.tenant_id == tenant_id, ClientBusinessUnit.id.in_(business_ids), ) ).scalars().all() } branches: dict[int, ClientBranch] = {} if branch_ids: branches = { row.id: row for row in db.execute( select(ClientBranch).where( ClientBranch.tenant_id == tenant_id, ClientBranch.id.in_(branch_ids), ) ).scalars().all() } registrations: dict[int, ClientRegistration] = {} registration_types: dict[int, RegistrationType] = {} if registration_ids: for registration, registration_type in db.execute( select(ClientRegistration, RegistrationType) .join(RegistrationType, RegistrationType.id == ClientRegistration.registration_type_id) .where( ClientRegistration.tenant_id == tenant_id, ClientRegistration.id.in_(registration_ids), ) ).all(): registrations[registration.id] = registration registration_types[registration.id] = registration_type extra_business_ids = { int(row.business_unit_id) for row in registrations.values() if row.business_unit_id and row.business_unit_id not in businesses } if extra_business_ids: businesses.update({ row.id: row for row in db.execute( select(ClientBusinessUnit).where( ClientBusinessUnit.tenant_id == tenant_id, ClientBusinessUnit.id.in_(extra_business_ids), ) ).scalars().all() }) extra_branch_ids = { int(row.client_branch_id) for row in registrations.values() if row.client_branch_id and row.client_branch_id not in branches } if extra_branch_ids: branches.update({ row.id: row for row in db.execute( select(ClientBranch).where( ClientBranch.tenant_id == tenant_id, ClientBranch.id.in_(extra_branch_ids), ) ).scalars().all() }) result: dict[int, dict[str, str]] = {} for subscription in subscriptions: client = getattr(subscription, "client", None) client_name = (getattr(client, "client_name", None) or "").strip() client_trade_name = (getattr(client, "trade_name", None) or "").strip() primary = client_trade_name or client_name or "Unlinked Client" secondary_client = "" scope_context = "" scope_type = (getattr(subscription, "scope_type", None) or "client").strip().lower() if scope_type == "registration": registration = registrations.get(getattr(subscription, "registration_id", None)) registration_type = registration_types.get(getattr(subscription, "registration_id", None)) if registration: business = businesses.get(getattr(registration, "business_unit_id", None) or getattr(subscription, "business_unit_id", None)) branch = branches.get(getattr(registration, "client_branch_id", None) or getattr(subscription, "client_branch_id", None)) business_trade = ((getattr(business, "trade_name", None) or "").strip() if business else "") business_name = ((getattr(business, "business_name", None) or "").strip() if business else "") branch_name = ((getattr(branch, "branch_name", None) or "").strip() if branch else "") primary = ( (getattr(registration, "trade_name", None) or "").strip() or business_trade or business_name or branch_name or (getattr(registration, "legal_name", None) or "").strip() or client_trade_name or client_name or "Unlinked Client" ) reg_code = ((getattr(registration_type, "code", None) or "").strip() if registration_type else "") reg_no = (getattr(registration, "registration_number", None) or "").strip() scope_context = " · ".join(part for part in [reg_code, reg_no] if part) elif scope_type == "business_unit": business = businesses.get(getattr(subscription, "business_unit_id", None)) if business: primary = ((getattr(business, "trade_name", None) or "").strip() or (getattr(business, "business_name", None) or "").strip() or primary) scope_context = (getattr(business, "business_code", None) or "").strip() elif scope_type == "client_branch": branch = branches.get(getattr(subscription, "client_branch_id", None)) business = businesses.get(getattr(branch, "business_unit_id", None) if branch else getattr(subscription, "business_unit_id", None)) business_display = "" if business: business_display = ((getattr(business, "trade_name", None) or "").strip() or (getattr(business, "business_name", None) or "").strip()) if branch: primary = (getattr(branch, "branch_name", None) or "").strip() or business_display or primary scope_context = (getattr(branch, "branch_code", None) or "").strip() elif business_display: primary = business_display if client_name and _normalise_name(client_name) != _normalise_name(primary): secondary_client = client_name result[int(subscription.id)] = { "primary": primary, "client_name": client_name, "secondary_client": secondary_client, "scope_context": scope_context, } return result def _due_bucket_matches(due: date | None, bucket: str, today: date) -> bool: bucket = (bucket or "").strip().lower() if not bucket or bucket == "all": return True if bucket == "none": return due is None if due is None: return False delta = (due - today).days if bucket == "overdue": return delta < 0 if bucket == "today": return delta == 0 if bucket == "next_7": return 0 <= delta <= 7 if bucket == "next_15": return 0 <= delta <= 15 if bucket == "this_month": return due.year == today.year and due.month == today.month return True def _due_status(due: date | None, today: date) -> tuple[str, str]: if due is None: return "none", "No due date" delta = (due - today).days if delta < 0: days = abs(delta) return "overdue", f"Overdue by {days} day{'s' if days != 1 else ''}" if delta == 0: return "today", "Due today" if delta <= 7: return "soon", f"Due in {delta} day{'s' if delta != 1 else ''}" return "future", f"Due in {delta} days" def build_partner_payload( db: Session, request: Request, current_user, *, q: str = "", financial_year_filter: str | None = None, period: str = "", service_id: int | None = None, due_bucket: str = "", assignee_id: int | None = None, priority: str = "", ) -> dict[str, Any]: roles = set(get_user_roles(db, current_user.id)) tenant_id, branch_id = _active_tenant_branch(request, current_user, roles) active_financial_year = _active_financial_year(request) selected_fy = (financial_year_filter or "").strip() if not selected_fy: selected_fy = active_financial_year or "" financial_year = None if selected_fy.lower() == "all" else (selected_fy or None) today = date.today() # Filter choices are drawn from the same partner/tenant/branch scope, without # narrowing by FY so the board can move between years without changing the # global application FY context. option_stmt = select(ClientServiceSubscription).options( selectinload(ClientServiceSubscription.client), selectinload(ClientServiceSubscription.catalogue), ) option_stmt = _subscription_scope_filter(option_stmt, tenant_id, branch_id, current_user, roles, financial_year=None) option_engagements = db.execute(option_stmt).scalars().all() financial_year_options = sorted({(row.financial_year or "").strip() for row in option_engagements if (row.financial_year or "").strip()}, reverse=True) period_options = sorted({(row.period_label or "").strip() for row in option_engagements if (row.period_label or "").strip()}) service_map = {} for row in option_engagements: catalogue = getattr(row, "catalogue", None) if catalogue: service_map[int(catalogue.id)] = getattr(catalogue, "service_name", None) or getattr(catalogue, "name", None) or f"Service #{catalogue.id}" service_options = [{"id": key, "name": service_map[key]} for key in sorted(service_map, key=lambda x: str(service_map[x]).casefold())] task_stmt = ( select(ClientServiceTaskInstance) .options( selectinload(ClientServiceTaskInstance.client), selectinload(ClientServiceTaskInstance.catalogue), selectinload(ClientServiceTaskInstance.subscription).selectinload(ClientServiceSubscription.client), selectinload(ClientServiceTaskInstance.subscription).selectinload(ClientServiceSubscription.catalogue), selectinload(ClientServiceTaskInstance.subscription).selectinload(ClientServiceSubscription.assigned_manager), selectinload(ClientServiceTaskInstance.subscription).selectinload(ClientServiceSubscription.assigned_staff), selectinload(ClientServiceTaskInstance.subscription).selectinload(ClientServiceSubscription.review_partner), 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 period.strip(): task_stmt = task_stmt.where(ClientServiceTaskInstance.subscription.has(ClientServiceSubscription.period_label == period.strip())) if service_id: task_stmt = task_stmt.where(ClientServiceTaskInstance.service_catalogue_id == int(service_id)) if assignee_id: task_stmt = task_stmt.where(ClientServiceTaskInstance.assigned_to_user_id == int(assignee_id)) if priority.strip(): task_stmt = task_stmt.where(ClientServiceTaskInstance.priority == priority.strip()) 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() subscriptions_by_id = { int(task.subscription_id): task.subscription for task in tasks if getattr(task, "subscription_id", None) and getattr(task, "subscription", None) } scope_display_map = _partner_scope_display_map(db, tenant_id=tenant_id, subscriptions=list(subscriptions_by_id.values())) # Team choices are limited to people actually present in the current scoped # result set before search/due-date filtering. team_map: dict[int, str] = {} for task in tasks: assignee = getattr(task, "assigned_to", None) if assignee and getattr(assignee, "id", None): team_map[int(assignee.id)] = getattr(assignee, "full_name", None) or getattr(assignee, "email", None) or f"User #{assignee.id}" team_options = [{"id": key, "name": team_map[key]} for key in sorted(team_map, key=lambda x: str(team_map[x]).casefold())] filtered_tasks: list[ClientServiceTaskInstance] = [] q_norm = _normalise_name(q) for task in tasks: _decorate_task(task, today) subscription = getattr(task, "subscription", None) display = scope_display_map.get(int(task.subscription_id), {}) if getattr(task, "subscription_id", None) else {} task.business_display = display.get("primary") or task.client_display task.legal_client_display = display.get("secondary_client") or "" task.scope_context_display = display.get("scope_context") or "" task.period_label_display = (getattr(subscription, "period_label", None) or "").strip() or "Annual / Full year" task.financial_year_display = (getattr(subscription, "financial_year", None) or getattr(task, "financial_year", None) or "").strip() task.engagement_due_date = getattr(subscription, "current_due_date", None) task.engagement_original_due_date = getattr(subscription, "original_due_date", None) task.engagement_start_date = getattr(subscription, "start_date", None) task.engagement_end_date = getattr(subscription, "end_date", None) task.due_status_code, task.due_status_label = _due_status(task.engagement_due_date, today) if not _due_bucket_matches(task.engagement_due_date, due_bucket, today): continue if q_norm: searchable = " ".join([ task.business_display or "", task.legal_client_display or "", task.client_display or "", getattr(task, "task_name", None) or "", getattr(task, "description", None) or "", task.engagement_label or "", task.period_label_display or "", task.scope_context_display or "", ]) if q_norm not in _normalise_name(searchable): continue filtered_tasks.append(task) tasks = filtered_tasks 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: 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) bucket = _task_bucket(task, current_user) if bucket == "pending_review": summary["pending_review"] += 1 if status == "blocked": summary["blocked"] += 1 if task.due_status_code == "overdue": summary["overdue"] += 1 if task.due_status_code == "today": summary["due_today"] += 1 if status in CLOSED_TASK_STATUSES: summary["completed"] += 1 lookup[bucket]["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, "active_financial_year": active_financial_year, "filters": { "financial_year": selected_fy or "all", "period": period or "", "service_id": int(service_id) if service_id else None, "due_bucket": due_bucket or "", "assignee_id": int(assignee_id) if assignee_id else None, "priority": priority or "", }, "filter_options": { "financial_years": financial_year_options, "periods": period_options, "services": service_options, "teams": team_options, }, } 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 = "", fy: str = "", period: str = "", service_id: str = "", due: str = "", assignee_id: str = "", priority: str = "", page_size: int = 10, ): 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() page_size = page_size if page_size in {10, 25, 50} else 10 service_id_value = int(service_id) if str(service_id).strip().isdigit() else None assignee_id_value = int(assignee_id) if str(assignee_id).strip().isdigit() else None payload = build_partner_payload( db, request, current_user, q=q, financial_year_filter=fy, period=period, service_id=service_id_value, due_bucket=due, assignee_id=assignee_id_value, priority=priority, ) preserved = { "q": q.strip(), "fy": payload["filters"]["financial_year"], "period": period.strip(), "service_id": service_id_value or "", "due": due.strip(), "assignee_id": assignee_id_value or "", "priority": priority.strip(), "page_size": page_size, } filter_query = urlencode({k: v for k, v in preserved.items() if v not in (None, "")}) return _render( request, "modules/partners/templates/partners/review_board.html", db, current_user, title="Partner Review Board", payload=payload, q=q, page_size=page_size, filter_query=filter_query, 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()