Add partner engagement review and release workspace phase 5B

This commit is contained in:
A R R R Associates
2026-07-20 13:11:11 +05:30
parent 4c81659156
commit bca72ab2d9
4 changed files with 523 additions and 9 deletions
+284 -2
View File
@@ -12,8 +12,18 @@ 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
from app.modules.services.models import ClientServiceSubscription, ClientServiceTaskInstance, ServiceCatalogue
from app.modules.services.execution import (
CLOSED_TASK_STATUSES,
aqmm_task_summary_for_subscription,
closure_readiness_for_subscription,
)
from app.modules.services.models import (
ClientServiceSubscription,
ClientServiceTaskInstance,
ServiceCatalogue,
ServiceTaskComment,
)
from app.modules.employees.service import _engagement_sla, _engagement_team, _weighted_progress
try:
from app.modules.billing.models import BillingInvoice
@@ -319,6 +329,7 @@ def build_partner_dashboard_payload(db: Session, request, current_user) -> dict[
"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),
"clients": clients,
"staff_rows": staff,
"billing": billing,
@@ -347,3 +358,274 @@ def _wizard_cards() -> list[dict[str, str]]:
{"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, "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.
Review Partner work is kept separate from Engagement Partner work. System and
Firm Admin users may inspect the workspace, but review decisions are selected
from the engagement assignments rather than from a new permission model.
"""
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)
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 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"
# Partner review must follow any required Manager review.
if getattr(task, "aqmm_manager_review_required", False) and getattr(task, "manager_review_status", "not_required") != "reviewed":
return "not_ready"
# Review Partner review must follow required Engagement Partner review.
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)
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)),
"review_level": review_level,
"review_state": review_state,
"can_review": review_level is not None and review_state in {"pending_review", "reviewed", "rework"},
"is_exception": getattr(task, "checklist_response", None) == "NO" or getattr(task, "rework_status", "none") == "open",
"evidence_count": len(documents),
"comments": comments,
"is_locked": bool(getattr(task, "is_locked", False) or getattr(subscription, "is_locked", False)),
}
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())
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_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_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_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_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
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())
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:
cat = categories_by_name.setdefault(row["category"], {
"name": row["category"], "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 = list(categories_by_name.values())
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"])