Files
arrr-erp/app/modules/partner_dashboard/service.py
T
2026-09-24 12:45:00 +05:30

782 lines
38 KiB
Python

from __future__ import annotations
from datetime import date, timedelta
from decimal import Decimal
from typing import Any
from sqlalchemy import exists, func, or_, select
from sqlalchemy.orm import Session, selectinload
from app.modules.clients.association_models import ClientAssociation
from app.modules.clients.models import Client
from app.modules.alerts.workflow_escalations import list_workflow_escalations
from app.modules.core.iam.models import User
from app.modules.core.rbac.models import Role, UserRole
from app.modules.core.tenancy.models import Branch, Tenant
from app.modules.employees.models import Employee
from app.modules.services.execution import (
CLOSED_TASK_STATUSES,
aqmm_task_summary_for_subscription,
closure_readiness_for_subscription,
synchronise_approved_engagement_acceptance_tasks,
)
from app.modules.services.models import (
ClientServiceSubscription,
ClientServiceTaskInstance,
ServiceCatalogue,
ServiceTaskComment,
FirmServiceTaskTemplate,
ServiceTaskCategory,
)
from app.modules.employees.service import _engagement_sla, _engagement_team, _weighted_progress
try:
from app.modules.billing.models import BillingInvoice
except Exception: # pragma: no cover
BillingInvoice = None
PARTNER_ROLES = {"Partner", "System Admin"}
def _count(db: Session, stmt) -> int:
return int(db.execute(stmt).scalar() or 0)
def _money(value: Any) -> Decimal:
return Decimal(str(value or "0"))
def get_user_role_names(db: Session, user_id: int) -> list[str]:
return list(
db.execute(
select(Role.name)
.join(UserRole, UserRole.role_id == Role.id)
.where(UserRole.user_id == int(user_id), Role.is_active.is_(True))
.order_by(Role.name.asc())
).scalars().all()
)
def can_access_partner_dashboard(db: Session, current_user) -> bool:
roles = set(get_user_role_names(db, current_user.id))
return bool(roles.intersection(PARTNER_ROLES))
def _active_scope(db: Session, request, current_user, roles: set[str]) -> dict[str, Any]:
tenant_id = request.session.get("active_tenant_id") or getattr(current_user, "tenant_id", None)
branch_id = request.session.get("active_branch_id") or getattr(current_user, "branch_id", None)
if "System Admin" not in roles:
tenant_id = getattr(current_user, "tenant_id", None)
# Partner operations are branch-centric. For normal partner users, do not allow
# accidental all-branch view unless the branch context is genuinely missing.
if "System Admin" not in roles:
branch_id = getattr(current_user, "branch_id", None) or branch_id
if branch_id in (None, "", 0, "0"):
branch_id = None
tenant = db.get(Tenant, int(tenant_id)) if tenant_id else None
branch = db.get(Branch, int(branch_id)) if branch_id else None
return {
"tenant_id": int(tenant_id) if tenant_id else None,
"branch_id": int(branch_id) if branch_id else None,
"tenant": tenant,
"branch": branch,
}
def _financial_year(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 _is_scoped_partner(roles: set[str]) -> bool:
"""Return True only for a normal Partner login.
System Admin and Firm Admin retain their existing administrative scope. A
normal Partner is always ownership-scoped, even when a branch is selected.
"""
return "Partner" in roles and "System Admin" not in roles and "Firm Admin" not in roles
def _partner_client_access_expression(*, partner_user_id: int, tenant_id: int | None):
association_access = exists(
select(ClientAssociation.id).where(
ClientAssociation.client_id == Client.id,
ClientAssociation.partner_user_id == int(partner_user_id),
or_(
ClientAssociation.firm_tenant_id == tenant_id,
ClientAssociation.firm_tenant_id.is_(None),
) if tenant_id is not None else ClientAssociation.id.is_not(None),
)
)
review_access = exists(
select(ClientServiceSubscription.id).where(
ClientServiceSubscription.client_id == Client.id,
ClientServiceSubscription.review_partner_user_id == int(partner_user_id),
ClientServiceSubscription.is_active.is_(True),
ClientServiceSubscription.tenant_id == tenant_id if tenant_id is not None else ClientServiceSubscription.id.is_not(None),
)
)
return or_(
Client.partner_id == int(partner_user_id),
association_access,
review_access,
)
def _task_scope(stmt, tenant_id: int | None, branch_id: int | None, current_user, roles: set[str], fy: str | None = None):
if tenant_id:
stmt = stmt.where(ClientServiceTaskInstance.tenant_id == tenant_id)
stmt = stmt.where(ClientServiceTaskInstance.is_active.is_(True))
if branch_id is not None:
stmt = stmt.where(ClientServiceTaskInstance.branch_id == branch_id)
if fy:
stmt = stmt.where(ClientServiceTaskInstance.financial_year == fy)
if _is_scoped_partner(roles):
stmt = stmt.where(
ClientServiceTaskInstance.subscription.has(
or_(
ClientServiceSubscription.assigned_partner_user_id == current_user.id,
ClientServiceSubscription.performing_partner_user_id == current_user.id,
ClientServiceSubscription.review_partner_user_id == current_user.id,
)
)
)
return stmt
def _subscription_scope(stmt, tenant_id: int | None, branch_id: int | None, current_user, roles: set[str], fy: str | None = None):
if tenant_id:
stmt = stmt.where(ClientServiceSubscription.tenant_id == tenant_id)
stmt = stmt.where(ClientServiceSubscription.is_active.is_(True))
if branch_id is not None:
stmt = stmt.where(ClientServiceSubscription.branch_id == branch_id)
if fy:
stmt = stmt.where(ClientServiceSubscription.financial_year == fy)
if _is_scoped_partner(roles):
stmt = stmt.where(
or_(
ClientServiceSubscription.assigned_partner_user_id == current_user.id,
ClientServiceSubscription.performing_partner_user_id == current_user.id,
ClientServiceSubscription.review_partner_user_id == current_user.id,
)
)
return stmt
def _client_scope(stmt, tenant_id: int | None, branch_id: int | None, current_user, roles: set[str]):
if tenant_id:
stmt = stmt.where(Client.tenant_id == tenant_id)
stmt = stmt.where(Client.is_active.is_(True))
if branch_id is not None:
stmt = stmt.where(Client.branch_id == branch_id)
if _is_scoped_partner(roles):
stmt = stmt.where(
_partner_client_access_expression(
partner_user_id=int(current_user.id),
tenant_id=tenant_id,
)
)
return stmt
def _is_open_status(status: str | None) -> bool:
return (status or "pending").strip().lower() not in CLOSED_TASK_STATUSES
def _task_label(task: ClientServiceTaskInstance) -> dict[str, Any]:
client = getattr(task, "client", None)
catalogue = getattr(task, "catalogue", None)
assignee = getattr(task, "assigned_to", None)
status = (task.status or "pending").replace("_", " ").title()
return {
"id": task.id,
"subscription_id": task.subscription_id,
"client_name": getattr(client, "client_name", None) or "Unlinked Client",
"client_code": getattr(client, "client_code", None) or "",
"service_name": getattr(catalogue, "service_name", None) or "Service",
"task_name": task.task_name,
"period": task.financial_year or "-",
"due_date": task.internal_target_date,
"status": task.status or "pending",
"status_label": status,
"priority": task.priority or "normal",
"assigned_to": getattr(assignee, "full_name", None) or getattr(assignee, "email", None) or "Unassigned",
"is_overdue": bool(task.internal_target_date and task.internal_target_date < date.today() and _is_open_status(task.status)),
"href": f"/work/engagements/{task.subscription_id}" if task.subscription_id else "/partner/reviews",
}
def _load_tasks(db: Session, tenant_id: int | None, branch_id: int | None, current_user, roles: set[str], fy: str | None) -> list[ClientServiceTaskInstance]:
stmt = (
select(ClientServiceTaskInstance)
.options(
selectinload(ClientServiceTaskInstance.client),
selectinload(ClientServiceTaskInstance.catalogue),
selectinload(ClientServiceTaskInstance.assigned_to),
selectinload(ClientServiceTaskInstance.subscription).selectinload(ClientServiceSubscription.catalogue),
)
)
stmt = _task_scope(stmt, tenant_id, branch_id, current_user, roles, fy)
stmt = stmt.order_by(
ClientServiceTaskInstance.internal_target_date.is_(None),
ClientServiceTaskInstance.internal_target_date.asc(),
ClientServiceTaskInstance.priority.desc(),
ClientServiceTaskInstance.id.desc(),
).limit(300)
return list(db.execute(stmt).scalars().all())
def _load_clients(db: Session, tenant_id: int | None, branch_id: int | None, current_user, roles: set[str]) -> list[dict[str, Any]]:
stmt = _client_scope(select(Client), tenant_id, branch_id, current_user, roles)
clients = db.execute(stmt.order_by(Client.client_name.asc()).limit(5000)).scalars().all()
out: list[dict[str, Any]] = []
for client in clients:
task_count = _count(db, _task_scope(select(func.count(ClientServiceTaskInstance.id)), tenant_id, branch_id, current_user, roles).where(ClientServiceTaskInstance.client_id == client.id))
overdue_count = _count(
db,
_task_scope(select(func.count(ClientServiceTaskInstance.id)), tenant_id, branch_id, current_user, roles)
.where(ClientServiceTaskInstance.client_id == client.id, ClientServiceTaskInstance.internal_target_date < date.today(), ClientServiceTaskInstance.status.notin_(list(CLOSED_TASK_STATUSES))),
)
service_count = _count(db, _subscription_scope(select(func.count(ClientServiceSubscription.id)), tenant_id, branch_id, current_user, roles).where(ClientServiceSubscription.client_id == client.id))
out.append({
"id": client.id,
"client_name": client.client_name,
"client_code": client.client_code,
"pan": client.pan,
"gstin": client.gstin,
"email": client.email,
"mobile": client.mobile,
"status": client.status or ("active" if client.is_active else "inactive"),
"service_count": service_count,
"task_count": task_count,
"overdue_count": overdue_count,
"href": f"/clients/{client.id}",
})
return out
def _staff_rows(db: Session, tenant_id: int | None, branch_id: int | None, tasks: list[ClientServiceTaskInstance]) -> list[dict[str, Any]]:
if not tenant_id:
return []
stmt = (
select(User)
.join(UserRole, UserRole.user_id == User.id)
.join(Role, Role.id == UserRole.role_id)
.where(
User.tenant_id == tenant_id,
Role.is_active.is_(True),
Role.name.in_(("Staff", "Branch Manager")),
)
.distinct()
)
if branch_id is not None:
stmt = stmt.where(User.branch_id == branch_id)
users = db.execute(
stmt.order_by(User.full_name.asc(), User.email.asc()).limit(100)
).scalars().all()
by_user: dict[int, dict[str, int]] = {}
for task in tasks:
uid = getattr(task, "assigned_to_user_id", None)
if not uid:
continue
bucket = by_user.setdefault(int(uid), {"active": 0, "overdue": 0, "review": 0, "client_pending": 0})
if _is_open_status(task.status):
bucket["active"] += 1
if task.internal_target_date and task.internal_target_date < date.today() and _is_open_status(task.status):
bucket["overdue"] += 1
if (task.status or "").lower() == "completed":
bucket["review"] += 1
if (task.status or "").lower() == "blocked":
bucket["client_pending"] += 1
rows: list[dict[str, Any]] = []
for user in users:
stats = by_user.get(int(user.id), {"active": 0, "overdue": 0, "review": 0, "client_pending": 0})
total = stats["active"] + stats["review"]
rows.append({
"id": user.id,
"name": user.full_name or user.email,
"email": user.email,
"designation": user.designation,
"is_active": bool(user.is_active),
"active": stats["active"],
"overdue": stats["overdue"],
"review": stats["review"],
"client_pending": stats["client_pending"],
"load_status": "Heavy" if total >= 40 else ("Balanced" if total >= 10 else "Light"),
})
rows.sort(key=lambda r: (not r["is_active"], -(r["active"] + r["review"]), -r["overdue"], r["name"].lower()))
return rows[:25]
def _billing_rows(
db: Session,
tenant_id: int | None,
branch_id: int | None,
fy: str | None,
current_user,
roles: set[str],
) -> dict[str, Any]:
if BillingInvoice is None or not tenant_id:
return {"available": False, "invoices": [], "invoice_count": 0, "draft_count": 0, "outstanding": Decimal("0")}
stmt = select(BillingInvoice).where(BillingInvoice.tenant_id == tenant_id)
if branch_id is not None:
stmt = stmt.where(BillingInvoice.branch_id == branch_id)
if fy:
stmt = stmt.where(BillingInvoice.financial_year == fy)
if _is_scoped_partner(roles):
allowed_client_ids = select(Client.id).where(
Client.tenant_id == tenant_id,
_partner_client_access_expression(
partner_user_id=int(current_user.id),
tenant_id=tenant_id,
),
)
stmt = stmt.where(BillingInvoice.client_id.in_(allowed_client_ids))
invoices = list(db.execute(stmt.order_by(BillingInvoice.invoice_date.desc(), BillingInvoice.id.desc()).limit(25)).scalars().all())
outstanding = sum((_money(getattr(inv, "balance_amount", 0)) for inv in invoices), Decimal("0"))
return {
"available": True,
"invoices": invoices,
"invoice_count": len(invoices),
"draft_count": len([i for i in invoices if (getattr(i, "status", "") or "").upper() == "DRAFT"]),
"outstanding": outstanding,
}
def build_partner_dashboard_payload(db: Session, request, current_user) -> dict[str, Any]:
roles = set(get_user_role_names(db, current_user.id))
scope = _active_scope(db, request, current_user, roles)
tenant_id = scope["tenant_id"]
branch_id = scope["branch_id"]
fy = _financial_year(request)
today = date.today()
next_week = today + timedelta(days=7)
tasks = _load_tasks(db, tenant_id, branch_id, current_user, roles, fy)
task_rows = [_task_label(t) for t in tasks]
open_rows = [r for r in task_rows if r["status"] not in CLOSED_TASK_STATUSES]
overdue_rows = [r for r in open_rows if r["due_date"] and r["due_date"] < today]
due_today_rows = [r for r in open_rows if r["due_date"] == today]
due_week_rows = [r for r in open_rows if r["due_date"] and today <= r["due_date"] <= next_week]
client_pending_rows = [r for r in open_rows if r["status"] == "blocked"]
review_rows = [r for r in task_rows if r["status"] == "completed"]
clients = _load_clients(db, tenant_id, branch_id, current_user, roles)
staff = _staff_rows(db, tenant_id, branch_id, tasks)
billing = _billing_rows(db, tenant_id, branch_id, fy, current_user, roles)
unified_escalations = list_workflow_escalations(db, tenant_id=tenant_id, branch_id=branch_id, assigned_to_user_id=current_user.id)
subscriptions_count = _count(db, _subscription_scope(select(func.count(ClientServiceSubscription.id)), tenant_id, branch_id, current_user, roles, fy)) if tenant_id else 0
overview = {
"tenant": scope["tenant"],
"branch": scope["branch"],
"tenant_id": tenant_id,
"branch_id": branch_id,
"financial_year": fy,
"client_count": len(clients),
"subscription_count": subscriptions_count,
"open_task_count": len(open_rows),
"overdue_count": len(overdue_rows),
"due_today_count": len(due_today_rows),
"due_week_count": len(due_week_rows),
"client_pending_count": len(client_pending_rows),
"review_pending_count": len(review_rows),
"staff_count": len(staff),
"invoice_count": billing["invoice_count"],
"outstanding": billing["outstanding"],
"today": today,
"unified_escalation_count": len(unified_escalations),
"escalations_over_3_days": len([r for r in unified_escalations if r["age_days"] >= 3]),
}
return {
"roles": sorted(roles),
"overview": overview,
"branch_work": task_rows[:80],
"due_today": due_today_rows[:25],
"due_week": due_week_rows[:25],
"overdue": overdue_rows[:25],
"client_pending": client_pending_rows[:25],
"review_queue": review_rows[:50],
"partner_engagement_review_queue": _partner_review_queue_rows(db, request, current_user),
"unified_escalations": unified_escalations,
"clients": clients,
"staff_rows": staff,
"billing": billing,
"reports": _report_cards(),
"wizards": _wizard_cards(),
}
def _report_cards() -> list[dict[str, str]]:
return [
{"group": "Work Reports", "title": "Branch Work Status", "desc": "Due today, due this week, overdue and blocked work for the active branch.", "href": "/partner/dashboard?tab=branch-work"},
{"group": "Work Reports", "title": "Overdue Task Report", "desc": "Tasks past internal target date requiring partner escalation.", "href": "/partner/dashboard?tab=branch-work"},
{"group": "Review Reports", "title": "Partner Review Queue", "desc": "Completed work waiting for partner review or final sign-off.", "href": "/partner/dashboard?tab=review"},
{"group": "Client Reports", "title": "Branch Client Health", "desc": "Clients, services, open tasks and overdue items in the branch.", "href": "/partner/dashboard?tab=clients"},
{"group": "Staff Reports", "title": "Staff Workload", "desc": "Branch team workload, overdue count and client pending count.", "href": "/partner/dashboard?tab=staff"},
{"group": "Billing Reports", "title": "Billing Control", "desc": "Invoices raised, drafts and outstanding amount for the active branch.", "href": "/partner/dashboard?tab=billing"},
]
def _wizard_cards() -> list[dict[str, str]]:
return [
{"title": "Add / Manage Clients", "desc": "Open client master for branch client creation and edits.", "href": "/clients"},
{"title": "Client Service Subscriptions", "desc": "Assign firm services to branch clients and monitor engagements.", "href": "/services/engagements"},
{"title": "Generate / Track Tasks", "desc": "Use work tracker for generated compliance and audit tasks.", "href": "/work"},
{"title": "Partner Review Board", "desc": "Review completed work and send rework or clarifications.", "href": "/partner/reviews"},
{"title": "Documents", "desc": "Open branch and engagement documents.", "href": "/documents"},
{"title": "Billing", "desc": "Raise invoices and track collection follow-up.", "href": "/billing"},
]
# -----------------------------------------------------------------------------
# Phase 5B - Partner engagement review and release workspace
# -----------------------------------------------------------------------------
def _display_user(user) -> str:
return getattr(user, "full_name", None) or getattr(user, "email", None) or "Not assigned"
def _partner_task_category(task: ClientServiceTaskInstance) -> str:
return (getattr(task, "_resolved_task_category", None) or getattr(task, "task_category", None) or "General Workflow").strip() or "General Workflow"
def _partner_review_level_for_task(task: ClientServiceTaskInstance, subscription: ClientServiceSubscription, current_user) -> str | None:
"""Return the review level the current partner is authorised to perform."""
uid = int(getattr(current_user, "id", 0) or 0)
is_review_partner = uid and uid == int(getattr(subscription, "review_partner_user_id", 0) or 0)
is_engagement_partner = uid and uid == int(getattr(subscription, "assigned_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()
if (
is_engagement_partner
and getattr(task, "normal_review_required", False)
and normal_role in {"partner", "manager_or_partner"}
and normal_status in {"pending", "reviewed", "rework_required"}
):
return "normal_partner"
if is_review_partner and getattr(task, "aqmm_review_partner_required", False):
return "review_partner"
if is_engagement_partner and getattr(task, "aqmm_partner_review_required", False):
return "partner"
return None
def _partner_review_state(task: ClientServiceTaskInstance, review_level: str | None) -> str:
if review_level == "normal_partner":
status = (getattr(task, "normal_review_status", "not_required") or "not_required").strip().lower()
if status == "reviewed":
return "reviewed"
if status == "rework_required" or getattr(task, "rework_status", "none") == "open":
return "rework"
return "pending_review" if status == "pending" else "not_ready"
if getattr(task, "rework_status", "none") == "open":
return "rework"
if review_level == "review_partner":
status = getattr(task, "review_partner_review_status", "not_required")
elif review_level == "partner":
status = getattr(task, "partner_review_status", "not_required")
else:
return "read_only"
if status == "reviewed":
return "reviewed"
if status == "rework_required":
return "rework"
if getattr(task, "aqmm_manager_review_required", False) and getattr(task, "manager_review_status", "not_required") != "reviewed":
return "not_ready"
if review_level == "review_partner" and getattr(task, "aqmm_partner_review_required", False) and getattr(task, "partner_review_status", "not_required") != "reviewed":
return "not_ready"
if (getattr(task, "status", "pending") or "pending") not in CLOSED_TASK_STATUSES and getattr(task, "submitted_for_review_at_utc", None) is None:
return "not_ready"
return "pending_review"
def _partner_task_payload(task: ClientServiceTaskInstance, subscription: ClientServiceSubscription, current_user) -> dict[str, Any]:
review_level = _partner_review_level_for_task(task, subscription, current_user)
review_state = _partner_review_state(task, review_level)
comments = [c for c in (getattr(task, "comments", None) or []) if not getattr(c, "is_deleted", False)]
documents = [d for d in (getattr(task, "documents", None) or []) if not getattr(d, "is_deleted", False)]
assignee = getattr(task, "assigned_to", None)
status_code = (getattr(task, "status", None) or "pending").strip().lower()
is_locked = bool(getattr(task, "is_locked", False) or getattr(subscription, "is_locked", False))
is_task_assignee = int(getattr(task, "assigned_to_user_id", 0) or 0) == int(getattr(current_user, "id", 0) or 0)
can_execute = bool(
is_task_assignee
and not is_locked
and status_code not in {"completed", "not_applicable"}
)
return {
"id": task.id,
"task_name": task.task_name,
"description": task.description,
"sequence_no": task.sequence_no,
"category": _partner_task_category(task),
"status": task.status or "pending",
"status_label": (task.status or "pending").replace("_", " ").title(),
"assigned_to": _display_user(assignee),
"response_type": getattr(task, "response_type", "NONE") or "NONE",
"checklist_response": getattr(task, "checklist_response", None),
"checklist_text_response": getattr(task, "checklist_text_response", None),
"checklist_number_response": getattr(task, "checklist_number_response", None),
"checklist_date_response": getattr(task, "checklist_date_response", None),
"checklist_remarks": getattr(task, "checklist_remarks", None),
"work_remarks": getattr(task, "remarks", None),
"manager_review_status": getattr(task, "manager_review_status", "not_required"),
"manager_review_note": getattr(task, "manager_review_note", None),
"partner_review_status": getattr(task, "partner_review_status", "not_required"),
"partner_review_note": getattr(task, "partner_review_note", None),
"review_partner_review_status": getattr(task, "review_partner_review_status", "not_required"),
"review_partner_review_note": getattr(task, "review_partner_review_note", None),
"rework_status": getattr(task, "rework_status", "none"),
"rework_reason": getattr(task, "rework_reason", None),
"is_aqmm_task": bool(getattr(task, "is_aqmm_task", False)),
"aqmm_reference": getattr(task, "aqmm_reference", None),
"aqmm_status": getattr(task, "aqmm_status", "not_required"),
"blocks_final_release": bool(getattr(task, "aqmm_blocks_final_release", False)),
"partner_review_required": bool(getattr(task, "aqmm_partner_review_required", False)),
"review_partner_required": bool(getattr(task, "aqmm_review_partner_required", False)),
"normal_review_status": getattr(task, "normal_review_status", "not_required"),
"normal_review_note": getattr(task, "normal_review_note", None),
"review_level": review_level,
"review_kind_label": "Normal Review" if review_level == "normal_partner" else ("AQMM Review Partner" if review_level == "review_partner" else ("AQMM Partner Review" if review_level == "partner" else "")),
"review_state": review_state,
"can_review": review_level is not None and review_state in {"pending_review", "reviewed", "rework"},
"is_task_assignee": is_task_assignee,
"can_execute": can_execute,
"execution_mode_label": "Assigned to you for execution" if can_execute else "",
"is_exception": getattr(task, "checklist_response", None) == "NO" or getattr(task, "rework_status", "none") == "open",
"evidence_count": len(documents),
"comments": comments,
"is_locked": is_locked,
}
def _partner_review_queue_rows(db: Session, request, current_user, *, limit: int = 80) -> list[dict[str, Any]]:
roles = set(get_user_role_names(db, current_user.id))
scope = _active_scope(db, request, current_user, roles)
fy = _financial_year(request)
stmt = (
select(ClientServiceSubscription)
.options(
selectinload(ClientServiceSubscription.client),
selectinload(ClientServiceSubscription.catalogue),
selectinload(ClientServiceSubscription.assigned_partner),
selectinload(ClientServiceSubscription.assigned_manager),
selectinload(ClientServiceSubscription.assigned_staff),
selectinload(ClientServiceSubscription.review_partner),
)
)
stmt = _subscription_scope(stmt, scope["tenant_id"], scope["branch_id"], current_user, roles, fy)
if "System Admin" not in roles and "Firm Admin" not in roles:
stmt = stmt.where(or_(
ClientServiceSubscription.assigned_partner_user_id == current_user.id,
ClientServiceSubscription.review_partner_user_id == current_user.id,
))
subscriptions = list(db.execute(stmt.order_by(ClientServiceSubscription.current_due_date.is_(None), ClientServiceSubscription.current_due_date.asc(), ClientServiceSubscription.id.desc()).limit(limit)).scalars().all())
repaired_setup_rows = 0
for sub in subscriptions:
repaired_setup_rows += synchronise_approved_engagement_acceptance_tasks(
db,
subscription=sub,
actor_user_id=int(current_user.id),
)
if repaired_setup_rows:
db.commit()
rows: list[dict[str, Any]] = []
for sub in subscriptions:
task_stmt = select(ClientServiceTaskInstance).where(
ClientServiceTaskInstance.subscription_id == sub.id,
ClientServiceTaskInstance.is_active.is_(True),
)
tasks = list(db.execute(task_stmt.order_by(ClientServiceTaskInstance.sequence_no.asc(), ClientServiceTaskInstance.id.asc())).scalars().all())
pending_normal_partner = sum(
1 for t in tasks
if getattr(t, "normal_review_required", False)
and (getattr(t, "normal_review_role", None) or "").strip().lower() in {"partner", "manager_or_partner"}
and (getattr(t, "normal_review_status", None) or "not_required") in {"pending", "rework_required"}
)
pending_partner = sum(1 for t in tasks if getattr(t, "aqmm_partner_review_required", False) and getattr(t, "partner_review_status", "not_required") != "reviewed")
pending_review_partner = sum(1 for t in tasks if getattr(t, "aqmm_review_partner_required", False) and getattr(t, "review_partner_review_status", "not_required") != "reviewed")
rework = sum(1 for t in tasks if getattr(t, "rework_status", "none") == "open")
blockers = sum(1 for t in tasks if getattr(t, "aqmm_blocks_final_release", False) and getattr(t, "aqmm_status", "not_required") != "completed")
exceptions = sum(1 for t in tasks if getattr(t, "checklist_response", None) == "NO")
if not any((pending_normal_partner, pending_partner, pending_review_partner, rework, blockers, exceptions)):
continue
weighted = _weighted_progress(tasks)
sla = _engagement_sla(sub, tasks)
rows.append({
"subscription_id": sub.id,
"client_name": getattr(getattr(sub, "client", None), "client_name", None) or "Unlinked Client",
"service_name": getattr(getattr(sub, "catalogue", None), "service_name", None) or "Service",
"financial_year": sub.financial_year or "-",
"pending_normal_partner": pending_normal_partner,
"pending_partner": pending_partner,
"pending_review_partner": pending_review_partner,
"rework_count": rework,
"release_blockers": blockers,
"exception_count": exceptions,
"weighted_progress": weighted,
"sla": sla,
"href": f"/partner/reviews/engagements/{sub.id}",
})
rows.sort(key=lambda r: (r["release_blockers"], r["rework_count"], r["pending_normal_partner"] + r["pending_partner"] + r["pending_review_partner"]), reverse=True)
return rows
def get_partner_review_workspace(db: Session, request, current_user, *, subscription_id: int, active_task_id: int | None = None) -> dict[str, Any] | None:
roles = set(get_user_role_names(db, current_user.id))
scope = _active_scope(db, request, current_user, roles)
fy = _financial_year(request)
stmt = (
select(ClientServiceSubscription)
.options(
selectinload(ClientServiceSubscription.client),
selectinload(ClientServiceSubscription.catalogue),
selectinload(ClientServiceSubscription.assigned_partner),
selectinload(ClientServiceSubscription.assigned_manager),
selectinload(ClientServiceSubscription.assigned_staff),
selectinload(ClientServiceSubscription.review_partner),
)
.where(ClientServiceSubscription.id == int(subscription_id))
)
stmt = _subscription_scope(stmt, scope["tenant_id"], scope["branch_id"], current_user, roles, fy)
subscription = db.execute(stmt).scalar_one_or_none()
if not subscription:
return None
if not roles.intersection({"System Admin", "Firm Admin"}) and int(current_user.id) not in {
int(getattr(subscription, "assigned_partner_user_id", 0) or 0),
int(getattr(subscription, "review_partner_user_id", 0) or 0),
}:
return None
repaired_setup_rows = synchronise_approved_engagement_acceptance_tasks(
db,
subscription=subscription,
actor_user_id=int(current_user.id),
)
if repaired_setup_rows:
db.commit()
task_stmt = (
select(ClientServiceTaskInstance)
.options(
selectinload(ClientServiceTaskInstance.assigned_to),
selectinload(ClientServiceTaskInstance.comments).selectinload(ServiceTaskComment.created_by),
selectinload(ClientServiceTaskInstance.documents),
)
.where(
ClientServiceTaskInstance.subscription_id == subscription.id,
ClientServiceTaskInstance.is_active.is_(True),
)
.order_by(ClientServiceTaskInstance.sequence_no.asc(), ClientServiceTaskInstance.id.asc())
)
tasks = list(db.execute(task_stmt).scalars().all())
template_ids = {int(t.firm_task_template_id) for t in tasks if getattr(t, "firm_task_template_id", None) is not None}
resolved_categories: dict[int, tuple[str, int]] = {}
if template_ids:
for template_id, legacy_name, master_name, sort_order in db.execute(
select(
FirmServiceTaskTemplate.id,
FirmServiceTaskTemplate.task_category,
ServiceTaskCategory.name,
ServiceTaskCategory.sort_order,
)
.outerjoin(ServiceTaskCategory, ServiceTaskCategory.id == FirmServiceTaskTemplate.task_category_id)
.where(FirmServiceTaskTemplate.id.in_(template_ids))
).all():
name = (master_name or legacy_name or "").strip()
if name:
resolved_categories[int(template_id)] = (name, int(sort_order or 100))
for task in tasks:
template_id = getattr(task, "firm_task_template_id", None)
resolved = resolved_categories.get(int(template_id)) if template_id is not None else None
task._resolved_task_category = resolved[0] if resolved else ((getattr(task, "task_category", None) or "").strip() or "General Workflow")
task._resolved_task_category_sort = resolved[1] if resolved else 100
task_rows = [_partner_task_payload(task, subscription, current_user) for task in tasks]
categories_by_name: dict[str, dict[str, Any]] = {}
for row in task_rows:
source_task = next((t for t in tasks if int(t.id) == int(row["id"])), None)
cat = categories_by_name.setdefault(row["category"], {
"name": row["category"], "sort_order": int(getattr(source_task, "_resolved_task_category_sort", 100) or 100), "tasks": [], "total": 0, "pending": 0,
"reviewed": 0, "rework": 0, "exceptions": 0, "blockers": 0,
})
cat["tasks"].append(row)
cat["total"] += 1
if row["review_state"] == "pending_review": cat["pending"] += 1
if row["review_state"] == "reviewed": cat["reviewed"] += 1
if row["review_state"] == "rework": cat["rework"] += 1
if row["is_exception"]: cat["exceptions"] += 1
if row["blocks_final_release"] and row["aqmm_status"] != "completed": cat["blockers"] += 1
categories = sorted(categories_by_name.values(), key=lambda item: (int(item.get("sort_order", 100)), item["name"].lower()))
for cat in categories:
denominator = cat["pending"] + cat["reviewed"] + cat["rework"]
cat["progress_percent"] = int(round((cat["reviewed"] / denominator) * 100)) if denominator else 100
actionable = [r for r in task_rows if r["can_review"]]
active = next((r for r in task_rows if active_task_id and r["id"] == int(active_task_id)), None)
if active is None:
active = next((r for r in actionable if r["review_state"] == "pending_review"), None)
if active is None:
active = next((r for r in actionable if r["review_state"] in {"rework", "reviewed"}), None)
if active is None and task_rows:
active = task_rows[0]
weighted = _weighted_progress(tasks)
sla = _engagement_sla(subscription, tasks)
team = _engagement_team(subscription)
aqmm = aqmm_task_summary_for_subscription(db, subscription_id=subscription.id)
closure = closure_readiness_for_subscription(db, subscription=subscription)
release_ready = bool(
closure["normal_tasks_completed"]
and closure["aqmm_acceptance_completed"]
and closure["aqmm_tasks_completed"]
and closure["evidence_review_completed"]
and closure["udin_completed"]
)
return {
"subscription": subscription,
"client_name": getattr(getattr(subscription, "client", None), "client_name", None) or "Unlinked Client",
"service_name": getattr(getattr(subscription, "catalogue", None), "service_name", None) or "Service",
"financial_year": subscription.financial_year or "-",
"categories": categories,
"tasks": task_rows,
"active_task": active,
"weighted_progress": weighted,
"sla": sla,
"team": team,
"aqmm": aqmm,
"closure": closure,
"release_ready": release_ready,
"is_locked": bool(getattr(subscription, "is_locked", False)),
"pending_partner_count": sum(1 for r in task_rows if r["review_level"] == "partner" and r["review_state"] == "pending_review"),
"pending_review_partner_count": sum(1 for r in task_rows if r["review_level"] == "review_partner" and r["review_state"] == "pending_review"),
"rework_count": sum(1 for r in task_rows if r["review_state"] == "rework"),
"exception_count": sum(1 for r in task_rows if r["is_exception"]),
"release_blocker_count": len(aqmm.get("blockers") or []),
"documents_href": f"/documents/engagements/{subscription.id}",
}
def get_next_partner_review_task_id(workspace: dict[str, Any], current_task_id: int) -> int | None:
rows = [r for r in workspace.get("tasks", []) if r.get("can_review") and r.get("review_state") == "pending_review"]
if not rows:
return None
for index, row in enumerate(rows):
if int(row["id"]) == int(current_task_id):
return int(rows[index + 1]["id"]) if index + 1 < len(rows) else None
return int(rows[0]["id"])