Add partner engagement review and release workspace phase 5B
This commit is contained in:
@@ -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"])
|
||||
|
||||
@@ -1,6 +1,50 @@
|
||||
<div class="rounded-3xl border border-slate-200 bg-white p-5 shadow-soft">
|
||||
<div class="flex items-center justify-between"><div><h2 class="text-lg font-semibold text-slate-900">Partner Review Queue</h2><p class="text-sm text-slate-500">Completed work awaiting partner approval, rework decision or final sign-off.</p></div><a href="/partner/reviews" class="rounded-2xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white">Open Review Board</a></div>
|
||||
<div class="mt-5 grid gap-4 lg:grid-cols-2">
|
||||
{% for item in review_queue %}<a href="{{ item.href }}" class="rounded-3xl border border-slate-200 p-5 hover:bg-slate-50"><div class="flex items-start justify-between gap-3"><div><h3 class="font-semibold text-slate-900">{{ item.task_name }}</h3><p class="mt-1 text-xs text-slate-500">{{ item.client_name }} · {{ item.service_name }}</p></div><span class="rounded-full bg-brand-100 px-2.5 py-1 text-xs font-semibold text-brand-700">Review</span></div><div class="mt-3 text-xs text-slate-500">Prepared by: {{ item.assigned_to }} · Due: {{ item.due_date or '-' }}</div></a>{% else %}<div class="rounded-3xl border border-dashed border-slate-300 p-8 text-center text-sm text-slate-500 lg:col-span-2">No completed tasks waiting for partner review.</div>{% endfor %}
|
||||
<div class="space-y-6">
|
||||
<div class="rounded-3xl border border-slate-200 bg-white p-5 shadow-soft">
|
||||
<div class="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-slate-900">Partner Engagement Review Queue</h2>
|
||||
<p class="text-sm text-slate-500">Engagement-level Partner and Review Partner review, significant exceptions and final-release blockers.</p>
|
||||
</div>
|
||||
<a href="/partner/reviews" class="rounded-2xl border border-slate-300 px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Open Existing Review Board</a>
|
||||
</div>
|
||||
|
||||
<div class="mt-5 space-y-4">
|
||||
{% for item in partner_engagement_review_queue %}
|
||||
<article class="rounded-3xl border border-slate-200 p-5">
|
||||
<div class="flex flex-col gap-4 xl:flex-row xl:items-center xl:justify-between">
|
||||
<div class="min-w-0">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<h3 class="font-semibold text-slate-900">{{ item.client_name }}</h3>
|
||||
<span class="rounded-full bg-slate-100 px-2.5 py-1 text-xs font-semibold text-slate-700">{{ item.financial_year }}</span>
|
||||
{% if item.release_blockers %}<span class="rounded-full bg-rose-100 px-2.5 py-1 text-xs font-semibold text-rose-700">{{ item.release_blockers }} release blocker{{ '' if item.release_blockers == 1 else 's' }}</span>{% endif %}
|
||||
{% if item.exception_count %}<span class="rounded-full bg-amber-100 px-2.5 py-1 text-xs font-semibold text-amber-700">{{ item.exception_count }} exception{{ '' if item.exception_count == 1 else 's' }}</span>{% endif %}
|
||||
</div>
|
||||
<p class="mt-1 text-sm text-slate-500">{{ item.service_name }}</p>
|
||||
<div class="mt-3 flex flex-wrap gap-2 text-xs">
|
||||
<span class="rounded-full bg-indigo-50 px-2.5 py-1 font-semibold text-indigo-700">Partner pending: {{ item.pending_partner }}</span>
|
||||
<span class="rounded-full bg-purple-50 px-2.5 py-1 font-semibold text-purple-700">Review Partner pending: {{ item.pending_review_partner }}</span>
|
||||
<span class="rounded-full bg-rose-50 px-2.5 py-1 font-semibold text-rose-700">Rework: {{ item.rework_count }}</span>
|
||||
<span class="rounded-full bg-slate-100 px-2.5 py-1 font-semibold text-slate-700">{{ item.sla.label }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full xl:w-72">
|
||||
<div class="flex items-center justify-between text-xs text-slate-500"><span>Weighted progress</span><span>{{ item.weighted_progress.progress_percent }}%</span></div>
|
||||
<div class="mt-2 h-2 rounded-full bg-slate-100"><div class="h-2 rounded-full bg-brand-600" style="width: {{ item.weighted_progress.progress_percent }}%"></div></div>
|
||||
<a href="{{ item.href }}" class="mt-4 inline-flex w-full justify-center rounded-2xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white">Start / Continue Partner Review</a>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
{% else %}
|
||||
<div class="rounded-3xl border border-dashed border-slate-300 p-8 text-center text-sm text-slate-500">No engagements are awaiting Partner or Review Partner action.</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-3xl border border-slate-200 bg-white p-5 shadow-soft">
|
||||
<h2 class="text-lg font-semibold text-slate-900">Existing Task-Level Review Queue</h2>
|
||||
<p class="text-sm text-slate-500">Retained for compatibility with the existing Partner workflow.</p>
|
||||
<div class="mt-5 grid gap-4 lg:grid-cols-2">
|
||||
{% for item in review_queue %}<a href="{{ item.href }}" class="rounded-3xl border border-slate-200 p-5 hover:bg-slate-50"><div class="flex items-start justify-between gap-3"><div><h3 class="font-semibold text-slate-900">{{ item.task_name }}</h3><p class="mt-1 text-xs text-slate-500">{{ item.client_name }} · {{ item.service_name }}</p></div><span class="rounded-full bg-brand-100 px-2.5 py-1 text-xs font-semibold text-brand-700">Review</span></div><div class="mt-3 text-xs text-slate-500">Prepared by: {{ item.assigned_to }} · Due: {{ item.due_date or '-' }}</div></a>{% else %}<div class="rounded-3xl border border-dashed border-slate-300 p-8 text-center text-sm text-slate-500 lg:col-span-2">No completed tasks waiting in the legacy task queue.</div>{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+93
@@ -0,0 +1,93 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
{% block content %}
|
||||
{% set active = workspace.active_task %}
|
||||
<div class="space-y-6">
|
||||
<div class="rounded-3xl border border-slate-200 bg-white p-5 shadow-soft">
|
||||
<div class="flex flex-col gap-4 xl:flex-row xl:items-start xl:justify-between">
|
||||
<div>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<h1 class="text-2xl font-semibold text-slate-900">Partner Engagement Review & Release</h1>
|
||||
{% if workspace.is_locked %}<span class="rounded-full bg-slate-900 px-3 py-1 text-xs font-semibold text-white">Locked</span>{% endif %}
|
||||
</div>
|
||||
<p class="mt-1 text-sm text-slate-500">{{ workspace.client_name }} · {{ workspace.service_name }} · {{ workspace.financial_year }}</p>
|
||||
<div class="mt-4 flex flex-wrap gap-2 text-xs">
|
||||
<span class="rounded-full bg-indigo-50 px-3 py-1.5 font-semibold text-indigo-700">Partner pending: {{ workspace.pending_partner_count }}</span>
|
||||
<span class="rounded-full bg-purple-50 px-3 py-1.5 font-semibold text-purple-700">Review Partner pending: {{ workspace.pending_review_partner_count }}</span>
|
||||
<span class="rounded-full bg-amber-50 px-3 py-1.5 font-semibold text-amber-700">Exceptions: {{ workspace.exception_count }}</span>
|
||||
<span class="rounded-full bg-rose-50 px-3 py-1.5 font-semibold text-rose-700">Release blockers: {{ workspace.release_blocker_count }}</span>
|
||||
<span class="rounded-full bg-slate-100 px-3 py-1.5 font-semibold text-slate-700">{{ workspace.sla.label }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<a href="{{ workspace.documents_href }}" class="rounded-2xl bg-emerald-600 px-4 py-2 text-sm font-semibold text-white">Final Documents / UDIN</a>
|
||||
<a href="/partner/dashboard?tab=review" class="rounded-2xl border border-slate-300 px-4 py-2 text-sm font-semibold text-slate-700">Close Workspace</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-5 grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
<div class="rounded-2xl bg-slate-50 p-4"><div class="text-xs uppercase tracking-wide text-slate-500">Weighted Progress</div><div class="mt-2 text-2xl font-semibold">{{ workspace.weighted_progress.progress_percent }}%</div><div class="mt-2 h-2 rounded-full bg-white"><div class="h-2 rounded-full bg-brand-600" style="width: {{ workspace.weighted_progress.progress_percent }}%"></div></div></div>
|
||||
<div class="rounded-2xl bg-slate-50 p-4"><div class="text-xs uppercase tracking-wide text-slate-500">AQMM Status</div><div class="mt-2 text-lg font-semibold">{{ workspace.aqmm.status.replace('_',' ').title() }}</div><div class="mt-1 text-xs text-slate-500">{{ workspace.aqmm.mandatory_done }}/{{ workspace.aqmm.mandatory }} mandatory controls passed</div></div>
|
||||
<div class="rounded-2xl bg-slate-50 p-4"><div class="text-xs uppercase tracking-wide text-slate-500">Release Readiness</div><div class="mt-2 text-lg font-semibold {{ 'text-emerald-700' if workspace.release_ready else 'text-amber-700' }}">{{ 'Ready for final document release' if workspace.release_ready else 'Blocked / pending controls' }}</div></div>
|
||||
<div class="rounded-2xl bg-slate-50 p-4"><div class="text-xs uppercase tracking-wide text-slate-500">SLA & Ageing</div><div class="mt-2 text-lg font-semibold">{{ workspace.sla.label }}</div><div class="mt-1 text-xs text-slate-500">Age: {{ workspace.sla.age_days }} days{% if workspace.sla.days_overdue %} · {{ workspace.sla.days_overdue }} overdue{% endif %}</div></div>
|
||||
</div>
|
||||
|
||||
<div class="mt-5 grid gap-3 md:grid-cols-2 xl:grid-cols-4">
|
||||
{% for member in workspace.team %}<div class="rounded-2xl border border-slate-200 p-3"><div class="text-xs text-slate-500">{{ member.role }}</div><div class="mt-1 text-sm font-semibold text-slate-900">{{ member.name }}</div></div>{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if request.query_params.get('reviewed') %}<div class="rounded-2xl border border-emerald-200 bg-emerald-50 p-4 text-sm text-emerald-800">Review decision saved.</div>{% endif %}
|
||||
{% if request.query_params.get('review_error') %}<div class="rounded-2xl border border-red-200 bg-red-50 p-4 text-sm text-red-800">The review could not be saved. Check readiness, review note, assignment and lock status.</div>{% endif %}
|
||||
|
||||
<div class="grid gap-6 xl:grid-cols-[380px_minmax(0,1fr)]">
|
||||
<aside class="space-y-4">
|
||||
{% for category in workspace.categories %}
|
||||
<section class="rounded-3xl border border-slate-200 bg-white p-4 shadow-soft">
|
||||
<div class="flex items-start justify-between gap-3"><div><h2 class="font-semibold text-slate-900">{{ category.name }}</h2><p class="mt-1 text-xs text-slate-500">{{ category.reviewed }} reviewed · {{ category.pending }} pending · {{ category.rework }} rework</p></div><span class="rounded-full bg-slate-100 px-2 py-1 text-xs font-semibold">{{ category.progress_percent }}%</span></div>
|
||||
<div class="mt-3 h-1.5 rounded-full bg-slate-100"><div class="h-1.5 rounded-full bg-brand-600" style="width: {{ category.progress_percent }}%"></div></div>
|
||||
<div class="mt-4 space-y-2">
|
||||
{% for task in category.tasks %}
|
||||
<a href="/partner/reviews/engagements/{{ workspace.subscription.id }}?task_id={{ task.id }}" class="block rounded-2xl border p-3 {{ 'border-brand-400 bg-brand-50' if active and active.id == task.id else 'border-slate-200 hover:bg-slate-50' }}">
|
||||
<div class="flex items-start justify-between gap-2"><div class="text-sm font-semibold text-slate-900">{{ task.task_name }}</div>{% if task.blocks_final_release %}<span class="rounded-full bg-slate-900 px-2 py-0.5 text-[10px] font-semibold text-white">Final Gate</span>{% endif %}</div>
|
||||
<div class="mt-2 flex flex-wrap gap-1 text-[10px]">
|
||||
<span class="rounded-full bg-slate-100 px-2 py-1">{{ task.status_label }}</span>
|
||||
<span class="rounded-full px-2 py-1 {{ 'bg-indigo-100 text-indigo-700' if task.review_state == 'pending_review' else ('bg-emerald-100 text-emerald-700' if task.review_state == 'reviewed' else ('bg-rose-100 text-rose-700' if task.review_state == 'rework' else 'bg-slate-100 text-slate-600')) }}">{{ task.review_state.replace('_',' ').title() }}</span>
|
||||
{% if task.is_exception %}<span class="rounded-full bg-amber-100 px-2 py-1 text-amber-700">Exception</span>{% endif %}
|
||||
</div>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</section>
|
||||
{% endfor %}
|
||||
</aside>
|
||||
|
||||
<main class="rounded-3xl border border-slate-200 bg-white p-5 shadow-soft">
|
||||
{% if active %}
|
||||
<div class="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between"><div><div class="text-xs font-semibold uppercase tracking-wide text-brand-700">{{ active.category }}</div><h2 class="mt-1 text-xl font-semibold text-slate-900">{{ active.task_name }}</h2><p class="mt-2 text-sm text-slate-500">{{ active.description or 'No additional procedure description.' }}</p></div><div class="flex flex-wrap gap-2 text-xs"><span class="rounded-full bg-slate-100 px-3 py-1.5">Assigned: {{ active.assigned_to }}</span>{% if active.review_level %}<span class="rounded-full bg-indigo-100 px-3 py-1.5 font-semibold text-indigo-700">{{ active.review_level.replace('_',' ').title() }}</span>{% endif %}</div></div>
|
||||
|
||||
<div class="mt-6 grid gap-4 md:grid-cols-2">
|
||||
<section class="rounded-2xl border border-slate-200 p-4"><h3 class="font-semibold text-slate-900">Staff Response & Conclusion</h3><dl class="mt-3 space-y-2 text-sm"><div><dt class="text-slate-500">Response</dt><dd class="font-semibold">{{ active.checklist_response or active.checklist_text_response or active.checklist_number_response or active.checklist_date_response or '-' }}</dd></div><div><dt class="text-slate-500">Checklist remarks</dt><dd class="whitespace-pre-wrap">{{ active.checklist_remarks or '-' }}</dd></div><div><dt class="text-slate-500">Work remarks</dt><dd class="whitespace-pre-wrap">{{ active.work_remarks or '-' }}</dd></div></dl></section>
|
||||
<section class="rounded-2xl border border-slate-200 p-4"><h3 class="font-semibold text-slate-900">Manager Conclusion</h3><dl class="mt-3 space-y-2 text-sm"><div><dt class="text-slate-500">Status</dt><dd class="font-semibold">{{ active.manager_review_status.replace('_',' ').title() }}</dd></div><div><dt class="text-slate-500">Note</dt><dd class="whitespace-pre-wrap">{{ active.manager_review_note or '-' }}</dd></div><div><dt class="text-slate-500">Evidence</dt><dd>{{ active.evidence_count }} file{{ '' if active.evidence_count == 1 else 's' }} · <a class="font-semibold text-brand-700" href="/documents/tasks/{{ active.id }}">Open Evidence</a></dd></div></dl></section>
|
||||
</div>
|
||||
|
||||
{% if active.rework_reason %}<div class="mt-4 rounded-2xl border border-rose-200 bg-rose-50 p-4 text-sm text-rose-800"><strong>Open/previous rework:</strong> {{ active.rework_reason }}</div>{% endif %}
|
||||
{% if active.checklist_response == 'NO' %}<div class="mt-4 rounded-2xl border border-amber-200 bg-amber-50 p-4 text-sm text-amber-800"><strong>Exception response:</strong> The staff response is NO. Review the remarks, evidence and reporting consequence before approval.</div>{% endif %}
|
||||
|
||||
<section class="mt-6 rounded-2xl border border-slate-200 p-4"><h3 class="font-semibold text-slate-900">Communication Timeline</h3><div class="mt-3 space-y-3">{% for comment in active.comments %}<div class="rounded-xl bg-slate-50 p-3 text-sm"><div class="text-xs text-slate-500">{{ comment.comment_type.replace('_',' ').title() }} · {{ comment.created_at_utc }}</div><div class="mt-1 whitespace-pre-wrap">{{ comment.message }}</div></div>{% else %}<div class="text-sm text-slate-500">No communication recorded.</div>{% endfor %}</div></section>
|
||||
|
||||
{% if active.can_review and not active.is_locked and not workspace.is_locked %}
|
||||
<form method="post" action="/partner/reviews/engagements/{{ workspace.subscription.id }}/tasks/{{ active.id }}" class="mt-6 rounded-2xl border border-slate-200 p-4">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<label class="text-sm font-semibold text-slate-800">Partner conclusion</label>
|
||||
<div class="mt-3 grid gap-3 sm:grid-cols-2"><label class="rounded-2xl border border-slate-200 p-3"><input type="radio" name="decision" value="reviewed" checked> <span class="ml-2 font-semibold">Approve / Reviewed</span></label><label class="rounded-2xl border border-slate-200 p-3"><input type="radio" name="decision" value="rework_required"> <span class="ml-2 font-semibold">Return for Rework</span></label></div>
|
||||
<textarea name="review_note" rows="4" placeholder="Partner conclusion, clarification or mandatory rework reason" class="mt-3 w-full rounded-2xl border border-slate-300 px-3 py-2 text-sm">{{ active.partner_review_note if active.review_level == 'partner' else active.review_partner_review_note or '' }}</textarea>
|
||||
<div class="mt-4 flex flex-wrap gap-2"><button name="workflow_action" value="save" class="rounded-2xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white">Save Review</button><button name="workflow_action" value="save_next" class="rounded-2xl bg-slate-900 px-4 py-2 text-sm font-semibold text-white">Save & Next Review</button><a href="/partner/dashboard?tab=review" class="rounded-2xl border border-slate-300 px-4 py-2 text-sm font-semibold text-slate-700">Close Workspace</a></div>
|
||||
</form>
|
||||
{% else %}
|
||||
<div class="mt-6 rounded-2xl border border-slate-200 bg-slate-50 p-4 text-sm text-slate-600">{% if workspace.is_locked or active.is_locked %}This engagement or task is locked and cannot be reviewed.{% elif active.review_state == 'not_ready' %}This task is visible for context but is not ready for your review. Required execution or prior review is pending.{% else %}This task is read-only for your current engagement role.{% endif %}</div>
|
||||
{% endif %}
|
||||
{% else %}<div class="py-16 text-center text-sm text-slate-500">No tasks are available in this engagement.</div>{% endif %}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -1,15 +1,21 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi import APIRouter, Form, Request
|
||||
from fastapi.responses import RedirectResponse
|
||||
|
||||
from app.core.db.common import CommonSessionLocal
|
||||
from app.core.http_responses import ui_access_denied
|
||||
from app.core.security.csrf import get_or_create_csrf_token
|
||||
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.partner_dashboard.service import build_partner_dashboard_payload, can_access_partner_dashboard
|
||||
from app.modules.partner_dashboard.service import (
|
||||
build_partner_dashboard_payload,
|
||||
can_access_partner_dashboard,
|
||||
get_next_partner_review_task_id,
|
||||
get_partner_review_workspace,
|
||||
)
|
||||
from app.modules.services.execution import apply_task_review
|
||||
|
||||
router = APIRouter(prefix="/partner", tags=["partner-dashboard-v2-ui"])
|
||||
|
||||
@@ -73,3 +79,92 @@ def dashboard_tab(request: Request, tab_name: str):
|
||||
)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.get("/reviews/engagements/{subscription_id}")
|
||||
def partner_review_workspace(request: Request, subscription_id: int, task_id: int | None = None):
|
||||
db = CommonSessionLocal()
|
||||
try:
|
||||
current_user = get_current_user(request, db=db)
|
||||
if not current_user:
|
||||
return RedirectResponse(url="/login", status_code=303)
|
||||
if not can_access_partner_dashboard(db, current_user):
|
||||
return ui_access_denied()
|
||||
workspace = get_partner_review_workspace(db, request, current_user, subscription_id=subscription_id, active_task_id=task_id)
|
||||
if not workspace:
|
||||
return RedirectResponse(url="/partner/dashboard?tab=review", status_code=303)
|
||||
return templates.TemplateResponse(
|
||||
"modules/partner_dashboard/templates/partner_dashboard/partner_review_workspace.html",
|
||||
{
|
||||
"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),
|
||||
"title": "Partner Engagement Review & Release",
|
||||
"workspace": workspace,
|
||||
},
|
||||
)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.post("/reviews/engagements/{subscription_id}/tasks/{task_id}")
|
||||
def partner_review_task_submit(
|
||||
request: Request,
|
||||
subscription_id: int,
|
||||
task_id: int,
|
||||
decision: str = Form("reviewed"),
|
||||
review_note: str = Form(""),
|
||||
workflow_action: str = Form("save"),
|
||||
csrf_token: str = Form(...),
|
||||
):
|
||||
validate_csrf(request, csrf_token)
|
||||
db = CommonSessionLocal()
|
||||
try:
|
||||
current_user = get_current_user(request, db=db)
|
||||
if not current_user:
|
||||
return RedirectResponse(url="/login", status_code=303)
|
||||
if not can_access_partner_dashboard(db, current_user):
|
||||
return ui_access_denied()
|
||||
workspace = get_partner_review_workspace(db, request, current_user, subscription_id=subscription_id, active_task_id=task_id)
|
||||
if not workspace:
|
||||
return RedirectResponse(url="/partner/dashboard?tab=review", status_code=303)
|
||||
active = workspace.get("active_task") or {}
|
||||
if int(active.get("id") or 0) != int(task_id):
|
||||
return RedirectResponse(url=f"/partner/reviews/engagements/{subscription_id}?review_error=task", status_code=303)
|
||||
if active.get("is_locked") or workspace.get("is_locked"):
|
||||
return RedirectResponse(url=f"/partner/reviews/engagements/{subscription_id}?task_id={task_id}&review_error=locked", status_code=303)
|
||||
if not active.get("can_review") or active.get("review_level") not in {"partner", "review_partner"}:
|
||||
return RedirectResponse(url=f"/partner/reviews/engagements/{subscription_id}?task_id={task_id}&review_error=not_ready", status_code=303)
|
||||
|
||||
from sqlalchemy import select
|
||||
from app.modules.services.models import ClientServiceTaskInstance
|
||||
task = db.execute(select(ClientServiceTaskInstance).where(
|
||||
ClientServiceTaskInstance.id == int(task_id),
|
||||
ClientServiceTaskInstance.subscription_id == int(subscription_id),
|
||||
)).scalar_one_or_none()
|
||||
if not task:
|
||||
return RedirectResponse(url="/partner/dashboard?tab=review", status_code=303)
|
||||
try:
|
||||
apply_task_review(
|
||||
db,
|
||||
task=task,
|
||||
review_level=active["review_level"],
|
||||
decision=decision,
|
||||
note=review_note,
|
||||
user_id=current_user.id,
|
||||
)
|
||||
db.commit()
|
||||
except ValueError:
|
||||
db.rollback()
|
||||
return RedirectResponse(url=f"/partner/reviews/engagements/{subscription_id}?task_id={task_id}&review_error=validation", status_code=303)
|
||||
|
||||
if workflow_action == "save_next":
|
||||
refreshed = get_partner_review_workspace(db, request, current_user, subscription_id=subscription_id)
|
||||
next_id = get_next_partner_review_task_id(refreshed, task_id) if refreshed else None
|
||||
if next_id:
|
||||
return RedirectResponse(url=f"/partner/reviews/engagements/{subscription_id}?task_id={next_id}&reviewed=1", status_code=303)
|
||||
return RedirectResponse(url=f"/partner/reviews/engagements/{subscription_id}?task_id={task_id}&reviewed=1", status_code=303)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
Reference in New Issue
Block a user