Separate normal task review from AQMM review

This commit is contained in:
A R R R Associates
2026-08-11 09:05:09 +05:30
parent 20fd30ff3d
commit c3684b7afa
17 changed files with 435 additions and 98 deletions
+31 -10
View File
@@ -446,16 +446,21 @@ def _partner_task_category(task: ClientServiceTaskInstance) -> str:
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.
"""
"""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):
@@ -464,6 +469,14 @@ def _partner_review_level_for_task(task: ClientServiceTaskInstance, subscription
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":
@@ -476,10 +489,8 @@ def _partner_review_state(task: ClientServiceTaskInstance, review_level: str | N
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:
@@ -523,7 +534,10 @@ def _partner_task_payload(task: ClientServiceTaskInstance, subscription: ClientS
"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_exception": getattr(task, "checklist_response", None) == "NO" or getattr(task, "rework_status", "none") == "open",
@@ -562,12 +576,18 @@ def _partner_review_queue_rows(db: Session, request, current_user, *, limit: int
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_partner, pending_review_partner, rework, blockers, exceptions)):
if not any((pending_normal_partner, pending_partner, pending_review_partner, rework, blockers, exceptions)):
continue
weighted = _weighted_progress(tasks)
sla = _engagement_sla(sub, tasks)
@@ -576,6 +596,7 @@ def _partner_review_queue_rows(db: Session, request, current_user, *, limit: int
"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,
@@ -585,7 +606,7 @@ def _partner_review_queue_rows(db: Session, request, current_user, *, limit: int
"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)
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
@@ -63,7 +63,7 @@
<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="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_kind_label or 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 &amp; 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>
@@ -80,7 +80,7 @@
<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>
<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.normal_review_note if active.review_level == 'normal_partner' else (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 &amp; 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 %}
+19 -10
View File
@@ -15,7 +15,7 @@ from app.modules.partner_dashboard.service import (
get_next_partner_review_task_id,
get_partner_review_workspace,
)
from app.modules.services.execution import apply_task_review
from app.modules.services.execution import apply_normal_task_review, apply_task_review
from app.modules.alerts.workflow_escalations import update_workflow_escalation
router = APIRouter(prefix="/partner", tags=["partner-dashboard-v2-ui"])
@@ -137,7 +137,7 @@ def partner_review_task_submit(
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"}:
if not active.get("can_review") or active.get("review_level") not in {"normal_partner", "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
@@ -149,14 +149,23 @@ def partner_review_task_submit(
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,
)
if active.get("review_level") == "normal_partner":
apply_normal_task_review(
db,
task=task,
decision=decision,
note=review_note,
user_id=current_user.id,
)
else:
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()