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
@@ -0,0 +1,112 @@
"""separate normal operational review from AQMM review
Revision ID: 20260811_normal_review
Revises: 20260808_task_role_eligibility
Create Date: 2026-08-11
"""
from alembic import op
import sqlalchemy as sa
revision = "20260811_normal_review"
down_revision = "20260808_task_role_eligibility"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("service_default_task_templates", sa.Column("normal_review_role", sa.String(length=30), nullable=True))
op.add_column("firm_service_task_templates", sa.Column("normal_review_role", sa.String(length=30), nullable=True))
op.add_column("client_service_task_instances", sa.Column("normal_review_required", sa.Boolean(), nullable=False, server_default=sa.false()))
op.add_column("client_service_task_instances", sa.Column("normal_review_role", sa.String(length=30), nullable=True))
op.add_column("client_service_task_instances", sa.Column("normal_review_status", sa.String(length=30), nullable=False, server_default="not_required"))
op.add_column("client_service_task_instances", sa.Column("normal_review_note", sa.Text(), nullable=True))
op.add_column("client_service_task_instances", sa.Column("normal_reviewed_by_user_id", sa.Integer(), nullable=True))
op.add_column("client_service_task_instances", sa.Column("normal_reviewed_at_utc", sa.DateTime(timezone=True), nullable=True))
op.create_foreign_key(
"fk_task_instance_normal_reviewed_by",
"client_service_task_instances",
"users",
["normal_reviewed_by_user_id"],
["id"],
ondelete="SET NULL",
)
op.create_index("ix_task_instance_normal_review_required", "client_service_task_instances", ["normal_review_required"])
op.create_index("ix_task_instance_normal_review_role", "client_service_task_instances", ["normal_review_role"])
op.create_index("ix_task_instance_normal_review_status", "client_service_task_instances", ["normal_review_status"])
op.create_index("ix_task_instance_normal_reviewed_by", "client_service_task_instances", ["normal_reviewed_by_user_id"])
# Preserve the old intent of requires_review while making the reviewer explicit.
# Reviewer inference follows task hierarchy: Staff -> Manager, Manager -> Partner.
role_case = """
CASE
WHEN NOT requires_review THEN NULL
WHEN lower(coalesce(default_role_name,'')) IN ('manager','branch manager')
AND lower(coalesce(eligible_role_names,'')) LIKE '%partner%' THEN 'partner'
WHEN lower(coalesce(default_role_name,'')) NOT IN ('manager','branch manager','partner')
AND (lower(coalesce(eligible_role_names,'')) LIKE '%manager%' OR lower(coalesce(eligible_role_names,'')) LIKE '%branch manager%') THEN 'manager'
WHEN lower(coalesce(eligible_role_names,'')) LIKE '%partner%'
AND lower(coalesce(eligible_role_names,'')) NOT LIKE '%manager%' THEN 'partner'
ELSE 'manager'
END
"""
op.execute(sa.text(f"UPDATE service_default_task_templates SET normal_review_role = {role_case} WHERE requires_review = TRUE"))
op.execute(sa.text(f"UPDATE firm_service_task_templates SET normal_review_role = {role_case} WHERE requires_review = TRUE"))
# Snapshot normal-review configuration into every existing engagement task.
op.execute(sa.text("""
UPDATE client_service_task_instances AS t
SET normal_review_required = f.requires_review,
normal_review_role = f.normal_review_role
FROM firm_service_task_templates AS f
WHERE t.firm_task_template_id = f.id
"""))
# Preserve prior generic approvals where they were already recorded in the legacy
# manager/partner fields. Otherwise completed/submitted work becomes Pending Review;
# unstarted work remains Not Ready. AQMM fields themselves are not changed.
op.execute(sa.text("""
UPDATE client_service_task_instances
SET normal_review_status = CASE
WHEN NOT normal_review_required THEN 'not_required'
WHEN normal_review_role IN ('manager','manager_or_partner') AND manager_review_status = 'reviewed' THEN 'reviewed'
WHEN normal_review_role = 'partner' AND partner_review_status = 'reviewed' THEN 'reviewed'
WHEN rework_status = 'open' THEN 'rework_required'
WHEN submitted_for_review_at_utc IS NOT NULL OR lower(coalesce(status,'')) = 'completed' THEN 'pending'
ELSE 'not_ready'
END,
normal_review_note = CASE
WHEN normal_review_role IN ('manager','manager_or_partner') AND manager_review_status IN ('reviewed','rework_required') THEN manager_review_note
WHEN normal_review_role = 'partner' AND partner_review_status IN ('reviewed','rework_required') THEN partner_review_note
ELSE NULL
END,
normal_reviewed_by_user_id = CASE
WHEN normal_review_role IN ('manager','manager_or_partner') AND manager_review_status = 'reviewed' THEN manager_reviewed_by_user_id
WHEN normal_review_role = 'partner' AND partner_review_status = 'reviewed' THEN partner_reviewed_by_user_id
ELSE NULL
END,
normal_reviewed_at_utc = CASE
WHEN normal_review_role IN ('manager','manager_or_partner') AND manager_review_status = 'reviewed' THEN manager_reviewed_at_utc
WHEN normal_review_role = 'partner' AND partner_review_status = 'reviewed' THEN partner_reviewed_at_utc
ELSE NULL
END
"""))
op.alter_column("client_service_task_instances", "normal_review_required", server_default=None)
op.alter_column("client_service_task_instances", "normal_review_status", server_default=None)
def downgrade() -> None:
op.drop_index("ix_task_instance_normal_reviewed_by", table_name="client_service_task_instances")
op.drop_index("ix_task_instance_normal_review_status", table_name="client_service_task_instances")
op.drop_index("ix_task_instance_normal_review_role", table_name="client_service_task_instances")
op.drop_index("ix_task_instance_normal_review_required", table_name="client_service_task_instances")
op.drop_constraint("fk_task_instance_normal_reviewed_by", "client_service_task_instances", type_="foreignkey")
op.drop_column("client_service_task_instances", "normal_reviewed_at_utc")
op.drop_column("client_service_task_instances", "normal_reviewed_by_user_id")
op.drop_column("client_service_task_instances", "normal_review_note")
op.drop_column("client_service_task_instances", "normal_review_status")
op.drop_column("client_service_task_instances", "normal_review_role")
op.drop_column("client_service_task_instances", "normal_review_required")
op.drop_column("firm_service_task_templates", "normal_review_role")
op.drop_column("service_default_task_templates", "normal_review_role")
+36 -28
View File
@@ -3489,34 +3489,34 @@ def _employee_task_category(task: ClientServiceTaskInstance) -> str:
def _i1_required_review_levels(task: ClientServiceTaskInstance) -> list[str]:
"""Return configured review levels for I1 using existing task-template controls."""
"""Return normal and AQMM review gates without mixing their state."""
levels: list[str] = []
if getattr(task, "aqmm_manager_review_required", False):
levels.append("manager")
if getattr(task, "aqmm_partner_review_required", False):
levels.append("partner")
if getattr(task, "aqmm_review_partner_required", False):
levels.append("review_partner")
normal_role = (getattr(task, "normal_review_role", None) or "").strip().lower()
if getattr(task, "normal_review_required", False):
if normal_role == "manager":
levels.append("normal_manager")
elif normal_role == "partner":
levels.append("normal_partner")
elif normal_role == "manager_or_partner":
levels.append("normal_manager_or_partner")
# Preserve the pre-existing generic requires_review flag. Where no explicit
# reviewer flag was configured, route to Manager when available, otherwise
# Engagement Partner. No new review table/state is introduced.
template = getattr(task, "template", None)
if not levels and getattr(template, "requires_review", False):
subscription = getattr(task, "subscription", None)
if getattr(subscription, "assigned_manager_user_id", None):
levels.append("manager")
elif getattr(subscription, "assigned_partner_user_id", None):
levels.append("partner")
if getattr(task, "aqmm_manager_review_required", False):
levels.append("aqmm_manager")
if getattr(task, "aqmm_partner_review_required", False):
levels.append("aqmm_partner")
if getattr(task, "aqmm_review_partner_required", False):
levels.append("aqmm_review_partner")
return levels
def _i1_review_level_pending(task: ClientServiceTaskInstance, level: str) -> bool:
if level == "manager":
if level.startswith("normal_"):
return (getattr(task, "normal_review_status", None) or "not_required") != "reviewed"
if level == "aqmm_manager":
return (getattr(task, "manager_review_status", None) or "not_required") != "reviewed"
if level == "partner":
if level == "aqmm_partner":
return (getattr(task, "partner_review_status", None) or "not_required") != "reviewed"
if level == "review_partner":
if level == "aqmm_review_partner":
return (getattr(task, "review_partner_review_status", None) or "not_required") != "reviewed"
return False
@@ -3531,9 +3531,12 @@ def _i1_review_label(task: ClientServiceTaskInstance) -> str | None:
if not pending:
return "Approved"
labels = {
"manager": "Manager",
"partner": "Partner",
"review_partner": "Review Partner",
"normal_manager": "Manager",
"normal_partner": "Partner",
"normal_manager_or_partner": "Manager / Partner",
"aqmm_manager": "AQMM Manager",
"aqmm_partner": "AQMM Partner",
"aqmm_review_partner": "AQMM Review Partner",
}
return "Awaiting " + " + ".join(labels[level] for level in pending) + " approval"
@@ -3554,9 +3557,12 @@ def _i1_approval_blocker(
if not pending:
continue
labels = {
"manager": "Manager",
"partner": "Partner",
"review_partner": "Review Partner",
"normal_manager": "Manager",
"normal_partner": "Partner",
"normal_manager_or_partner": "Manager / Partner",
"aqmm_manager": "AQMM Manager",
"aqmm_partner": "AQMM Partner",
"aqmm_review_partner": "AQMM Review Partner",
}
return {
"task_id": int(row.id),
@@ -3620,6 +3626,8 @@ def _workflow_task_weight(task: ClientServiceTaskInstance) -> int:
weight += 2
if bool(getattr(task, "aqmm_blocks_final_release", False)):
weight += 2
if bool(getattr(task, "normal_review_required", False)):
weight += 1
if any(bool(getattr(task, name, False)) for name in (
"aqmm_manager_review_required",
"aqmm_partner_review_required",
@@ -3971,7 +3979,7 @@ def save_employee_workflow_task(
engagement_tasks_for_gate = db.execute(
_employee_work_task_query(
db, scope, assigned_only=True, financial_year=financial_year
db, scope, assigned_only=False, financial_year=financial_year
)
.where(ClientServiceTaskInstance.subscription_id == task.subscription_id)
.order_by(ClientServiceTaskInstance.sequence_no.asc(), ClientServiceTaskInstance.id.asc())
@@ -4025,7 +4033,7 @@ def save_employee_workflow_task(
engagement_tasks = db.execute(
_employee_work_task_query(
db, scope, assigned_only=True, financial_year=financial_year
db, scope, assigned_only=False, financial_year=financial_year
)
.where(ClientServiceTaskInstance.subscription_id == task.subscription_id)
.order_by(ClientServiceTaskInstance.sequence_no.asc(), ClientServiceTaskInstance.id.asc())
+43 -13
View File
@@ -309,7 +309,19 @@ def _advanced_engagement_rows(tasks: list[ClientServiceTaskInstance]) -> list[di
team = _engagement_team(subscription)
status_values = {(getattr(t, "status", None) or "pending").strip().lower() for t in group}
completed = sum(1 for t in group if (getattr(t, "status", None) or "pending").strip().lower() in CLOSED_TASK_STATUSES)
review_pending = sum(1 for t in group if (getattr(t, "manager_review_status", None) or "") == "pending" or (getattr(t, "partner_review_status", None) or "") == "pending" or (getattr(t, "review_partner_review_status", None) or "") == "pending")
review_pending = sum(
1
for t in group
if (
getattr(t, "normal_review_required", False)
and (getattr(t, "normal_review_role", None) or "").strip().lower() in {"manager", "manager_or_partner"}
and (getattr(t, "normal_review_status", None) or "not_required") in {"pending", "rework_required"}
)
or (
getattr(t, "aqmm_manager_review_required", False)
and (getattr(t, "manager_review_status", None) or "not_required") in {"pending", "rework_required"}
)
)
if completed == len(group) and group:
status = "completed"
elif "blocked" in status_values or getattr(subscription, "workflow_pause_reason", None):
@@ -482,19 +494,33 @@ _MANAGER_REVIEW_DONE = {"reviewed"}
_MANAGER_REVIEW_REWORK = {"rework_required"}
def _manager_review_task_state(task: ClientServiceTaskInstance) -> str:
def _manager_review_task_state(task: ClientServiceTaskInstance) -> tuple[str, str | None]:
normal_role = (getattr(task, "normal_review_role", None) or "").strip().lower()
normal_status = (getattr(task, "normal_review_status", None) or "not_required").strip().lower()
manager_status = (getattr(task, "manager_review_status", None) or "not_required").strip().lower()
task_status = (getattr(task, "status", None) or "pending").strip().lower()
rework_status = (getattr(task, "rework_status", None) or "none").strip().lower()
if manager_status in _MANAGER_REVIEW_REWORK or rework_status == "open":
return "rework"
if manager_status in _MANAGER_REVIEW_DONE:
return "reviewed"
if manager_status in _MANAGER_REVIEW_PENDING or task_status in REVIEW_STATUSES:
return "pending_review"
if getattr(task, "aqmm_manager_review_required", False) and task_status in CLOSED_TASK_STATUSES:
return "pending_review"
return "not_ready"
if getattr(task, "normal_review_required", False) and normal_role in {"manager", "manager_or_partner"}:
if normal_status == "rework_required" or rework_status == "open":
return "rework", "normal"
if normal_status == "reviewed":
if not getattr(task, "aqmm_manager_review_required", False):
return "reviewed", "normal"
elif normal_status == "pending":
return "pending_review", "normal"
if getattr(task, "aqmm_manager_review_required", False):
if manager_status in _MANAGER_REVIEW_REWORK or rework_status == "open":
return "rework", "aqmm_manager"
if manager_status in _MANAGER_REVIEW_DONE:
return "reviewed", "aqmm_manager"
if manager_status in _MANAGER_REVIEW_PENDING:
return "pending_review", "aqmm_manager"
task_status = (getattr(task, "status", None) or "pending").strip().lower()
if task_status in CLOSED_TASK_STATUSES:
return "pending_review", "aqmm_manager"
return "not_ready", None
def _manager_review_category(task: ClientServiceTaskInstance) -> str:
@@ -502,7 +528,7 @@ def _manager_review_category(task: ClientServiceTaskInstance) -> str:
def _manager_review_task_row(task: ClientServiceTaskInstance) -> dict[str, Any]:
state = _manager_review_task_state(task)
state, review_kind = _manager_review_task_state(task)
comments = [c for c in (getattr(task, "comments", None) or []) if not getattr(c, "is_deleted", False)]
documents = list(getattr(task, "documents", None) or [])
assignee = getattr(task, "assigned_to", None)
@@ -516,6 +542,10 @@ def _manager_review_task_row(task: ClientServiceTaskInstance) -> dict[str, Any]:
"task_status_label": _status_label(getattr(task, "status", None)),
"review_state": state,
"review_state_label": state.replace("_", " ").title(),
"review_kind": review_kind,
"review_kind_label": "Normal Review" if review_kind == "normal" else ("AQMM Manager Review" if review_kind == "aqmm_manager" else ""),
"normal_review_status": (getattr(task, "normal_review_status", None) or "not_required").strip().lower(),
"normal_review_note": getattr(task, "normal_review_note", None) or "",
"manager_review_status": (getattr(task, "manager_review_status", None) or "not_required").strip().lower(),
"manager_review_note": getattr(task, "manager_review_note", None) or "",
"rework_status": (getattr(task, "rework_status", None) or "none").strip().lower(),
@@ -57,7 +57,7 @@
<div><div class="text-xs font-semibold uppercase text-slate-500">{{ task.category }}</div><h2 class="mt-1 text-xl font-semibold text-slate-900">{{ task.task_name }}</h2><p class="mt-2 whitespace-pre-line text-sm text-slate-600">{{ task.description }}</p></div>
<div class="flex flex-wrap gap-2"><span class="rounded-full bg-slate-100 px-3 py-1 text-xs font-semibold">{{ task.task_status_label }}</span>{% if task.is_aqmm_task %}<span class="rounded-full bg-indigo-100 px-3 py-1 text-xs font-semibold text-indigo-700">AQMM</span>{% endif %}{% if task.blocks_final_release %}<span class="rounded-full bg-red-100 px-3 py-1 text-xs font-semibold text-red-700">Final Gate</span>{% endif %}</div>
</div>
<div class="mt-5 grid gap-3 sm:grid-cols-2 lg:grid-cols-4"><div class="rounded-xl bg-slate-50 p-3"><div class="text-xs text-slate-500">Prepared By</div><div class="mt-1 font-semibold">{{ task.assigned_to }}</div></div><div class="rounded-xl bg-slate-50 p-3"><div class="text-xs text-slate-500">Evidence</div><div class="mt-1 font-semibold">{{ task.evidence_count }} document(s)</div></div><div class="rounded-xl bg-slate-50 p-3"><div class="text-xs text-slate-500">Comments</div><div class="mt-1 font-semibold">{{ task.comment_count }}</div></div><div class="rounded-xl bg-slate-50 p-3"><div class="text-xs text-slate-500">Manager Review</div><div class="mt-1 font-semibold">{{ task.review_state_label }}</div></div></div>
<div class="mt-5 grid gap-3 sm:grid-cols-2 lg:grid-cols-4"><div class="rounded-xl bg-slate-50 p-3"><div class="text-xs text-slate-500">Prepared By</div><div class="mt-1 font-semibold">{{ task.assigned_to }}</div></div><div class="rounded-xl bg-slate-50 p-3"><div class="text-xs text-slate-500">Evidence</div><div class="mt-1 font-semibold">{{ task.evidence_count }} document(s)</div></div><div class="rounded-xl bg-slate-50 p-3"><div class="text-xs text-slate-500">Comments</div><div class="mt-1 font-semibold">{{ task.comment_count }}</div></div><div class="rounded-xl bg-slate-50 p-3"><div class="text-xs text-slate-500">Manager Review</div><div class="mt-1 font-semibold">{{ task.review_state_label }}</div><div class="mt-1 text-xs text-slate-500">{{ task.review_kind_label }}</div></div></div>
</section>
<section class="grid gap-4 lg:grid-cols-2">
@@ -66,12 +66,12 @@
</section>
<section class="af-card">
<h3 class="font-semibold text-slate-900">Manager Review Decision</h3>
<h3 class="font-semibold text-slate-900">{{ task.review_kind_label or "Manager Review" }} Decision</h3>
{% if task.is_locked or workspace.is_locked %}<div class="mt-3 rounded-xl border border-amber-200 bg-amber-50 p-3 text-sm text-amber-800">This task or engagement is locked. Review is read-only.</div>{% endif %}
<form method="post" action="/manager/reviews/engagements/{{ workspace.subscription_id }}/tasks/{{ task.id }}" class="mt-4 space-y-4">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<div class="grid gap-3 md:grid-cols-2"><label class="block"><span class="text-sm font-semibold text-slate-700">Decision</span><select name="decision" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2" {% if task.is_locked or workspace.is_locked or task.review_state == 'not_ready' %}disabled{% endif %}><option value="reviewed">Approve / Reviewed</option><option value="rework_required">Return for Rework</option></select></label><label class="block"><span class="text-sm font-semibold text-slate-700">Review state</span><input class="mt-1 w-full rounded-xl border border-slate-200 bg-slate-50 px-3 py-2" value="{{ task.review_state_label }}" readonly></label></div>
<label class="block"><span class="text-sm font-semibold text-slate-700">Manager review note</span><textarea name="review_note" rows="5" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2" placeholder="Required when returning for rework; recommended for significant conclusions." {% if task.is_locked or workspace.is_locked or task.review_state == 'not_ready' %}disabled{% endif %}>{{ task.manager_review_note }}</textarea></label>
<label class="block"><span class="text-sm font-semibold text-slate-700">Manager review note</span><textarea name="review_note" rows="5" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2" placeholder="Required when returning for rework; recommended for significant conclusions." {% if task.is_locked or workspace.is_locked or task.review_state == 'not_ready' %}disabled{% endif %}>{{ task.normal_review_note if task.review_kind == 'normal' else task.manager_review_note }}</textarea></label>
<div class="flex flex-wrap gap-2"><button type="submit" name="workflow_action" value="save" class="af-btn af-btn-secondary" {% if task.is_locked or workspace.is_locked or task.review_state == 'not_ready' %}disabled{% endif %}>Save Review</button><button type="submit" name="workflow_action" value="save_next" class="af-btn af-btn-primary" {% if task.is_locked or workspace.is_locked or task.review_state == 'not_ready' %}disabled{% endif %}>Save &amp; Next Review</button><a href="/manager/dashboard?tab=review-queue" class="af-btn af-btn-secondary">Close Workspace</a></div>
</form>
</section>
+18 -9
View File
@@ -15,7 +15,7 @@ from app.modules.manager_dashboard.service import (
get_manager_review_workspace,
get_next_manager_review_task_id,
)
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="/manager", tags=["manager-dashboard-v2-ui"])
@@ -160,14 +160,23 @@ def manager_review_task_submit(
if not task:
return RedirectResponse(url="/manager/dashboard?tab=review-queue", status_code=303)
try:
apply_task_review(
db,
task=task,
review_level="manager",
decision=decision,
note=review_note,
user_id=current_user.id,
)
if active.get("review_kind") == "normal":
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="manager",
decision=decision,
note=review_note,
user_id=current_user.id,
)
db.commit()
except ValueError:
db.rollback()
+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()
@@ -111,7 +111,7 @@
<tr class="align-top hover:bg-slate-50/70">
<td class="px-4 py-4 text-sm">
<a href="/work/engagements/{{ task.subscription_id }}" class="font-semibold text-slate-900 hover:text-brand-700">{{ task.client_display }}</a>
<a href="/partner/reviews/engagements/{{ task.subscription_id }}" class="font-semibold text-slate-900 hover:text-brand-700">{{ task.client_display }}</a>
<div class="mt-1 text-xs text-slate-500">{{ task.engagement_label }}</div>
<div class="mt-1 text-[11px] text-slate-400">Engagement #{{ task.subscription_id }}</div>
</td>
@@ -157,7 +157,7 @@
<div class="mt-1 text-xs text-slate-500">{% if task.internal_target_date %}Target {{ task.internal_target_date }}{% else %}No task target{% endif %}</div>
</td>
<td class="px-4 py-4 text-right">
<a href="/work/engagements/{{ task.subscription_id }}" class="inline-flex rounded-xl border border-slate-300 px-3 py-2 text-xs font-semibold text-slate-700 hover:bg-slate-50">Open Review</a>
<a href="/partner/reviews/engagements/{{ task.subscription_id }}" class="inline-flex rounded-xl border border-slate-300 px-3 py-2 text-xs font-semibold text-slate-700 hover:bg-slate-50">Open Review</a>
</td>
</tr>
{% endif %}
+33 -14
View File
@@ -152,23 +152,40 @@ def _decorate_task(task: ClientServiceTaskInstance, today: date) -> ClientServic
return task
def _task_bucket(task: ClientServiceTaskInstance) -> str:
def _task_bucket(task: ClientServiceTaskInstance, current_user) -> str:
status = (task.status or "pending").strip().lower()
if status == "blocked":
subscription = getattr(task, "subscription", None)
uid = int(getattr(current_user, "id", 0) or 0)
is_engagement_partner = uid and uid == int(getattr(subscription, "assigned_partner_user_id", 0) or 0)
is_review_partner = uid and uid == int(getattr(subscription, "review_partner_user_id", 0) or 0)
normal_role = (getattr(task, "normal_review_role", None) or "").strip().lower()
normal_status = (getattr(task, "normal_review_status", None) or "not_required").strip().lower()
normal_partner_review = (
is_engagement_partner
and getattr(task, "normal_review_required", False)
and normal_role in {"partner", "manager_or_partner"}
)
aqmm_partner_review = is_engagement_partner and getattr(task, "aqmm_partner_review_required", False)
aqmm_review_partner_review = is_review_partner and getattr(task, "aqmm_review_partner_required", False)
if status == "blocked" and getattr(task, "rework_status", "none") != "open":
return "clarification_required"
if status == "completed":
if (
(normal_partner_review and normal_status == "rework_required")
or (aqmm_partner_review and getattr(task, "partner_review_status", "not_required") == "rework_required")
or (aqmm_review_partner_review and getattr(task, "review_partner_review_status", "not_required") == "rework_required")
or getattr(task, "rework_status", "none") == "open"
):
return "rework_sent"
if (
(normal_partner_review and normal_status == "pending")
or (aqmm_partner_review and getattr(task, "partner_review_status", "not_required") == "pending")
or (aqmm_review_partner_review and getattr(task, "review_partner_review_status", "not_required") == "pending")
):
return "pending_review"
if status in CLOSED_TASK_STATUSES:
return "completed"
if status in {"rework", "rework_required"}:
return "rework_sent"
if status == "pending":
has_partner_rework = any(
(getattr(comment, "comment_type", "") or "").strip().lower() == "partner_review_note"
and not getattr(comment, "is_deleted", False)
for comment in getattr(task, "comments", [])
)
return "rework_sent" if has_partner_rework else "approved"
return "approved"
@@ -186,6 +203,7 @@ def build_partner_payload(db: Session, request: Request, current_user, *, q: str
selectinload(ClientServiceTaskInstance.subscription).selectinload(ClientServiceSubscription.catalogue),
selectinload(ClientServiceTaskInstance.subscription).selectinload(ClientServiceSubscription.assigned_manager),
selectinload(ClientServiceTaskInstance.subscription).selectinload(ClientServiceSubscription.assigned_staff),
selectinload(ClientServiceTaskInstance.subscription).selectinload(ClientServiceSubscription.review_partner),
selectinload(ClientServiceTaskInstance.assigned_to),
selectinload(ClientServiceTaskInstance.comments).selectinload(ServiceTaskComment.created_by),
)
@@ -227,7 +245,8 @@ def build_partner_payload(db: Session, request: Request, current_user, *, q: str
summary["clients"].add(task.client_id)
if getattr(task, "subscription_id", None):
summary["engagements"].add(task.subscription_id)
if status == "completed":
bucket = _task_bucket(task, current_user)
if bucket == "pending_review":
summary["pending_review"] += 1
if status == "blocked":
summary["blocked"] += 1
@@ -237,7 +256,7 @@ def build_partner_payload(db: Session, request: Request, current_user, *, q: str
summary["due_today"] += 1
if status in CLOSED_TASK_STATUSES:
summary["completed"] += 1
lookup[_task_bucket(task)]["tasks"].append(task)
lookup[bucket]["tasks"].append(task)
summary["clients"] = len(summary["clients"])
summary["engagements"] = len(summary["engagements"])
+87
View File
@@ -31,6 +31,29 @@ TASK_STATUSES = [
OPEN_TASK_STATUSES = {"pending", "in_progress", "blocked"}
CLOSED_TASK_STATUSES = {"completed", "not_applicable", "cancelled"}
NORMAL_REVIEW_ROLES = {"manager", "partner", "manager_or_partner"}
def normal_review_role_for_template(template) -> str | None:
if not getattr(template, "requires_review", False):
return None
explicit = (getattr(template, "normal_review_role", None) or "").strip().lower()
if explicit in NORMAL_REVIEW_ROLES:
return explicit
default_role = (getattr(template, "default_role_name", None) or "").strip().lower()
eligible = {
part.strip().lower()
for part in (getattr(template, "eligible_role_names", None) or "").split(",")
if part.strip()
}
if default_role in {"manager", "branch manager"} and "partner" in eligible:
return "partner"
if default_role not in {"manager", "branch manager", "partner"} and ({"manager", "branch manager"} & eligible):
return "manager"
if "partner" in eligible and not ({"manager", "branch manager"} & eligible):
return "partner"
return "manager"
TASK_COMMENT_TYPES = [
("internal_note", "Internal Note"),
@@ -307,6 +330,9 @@ def generate_tasks_for_subscription(db: Session, *, subscription: ClientServiceS
response_type=(getattr(template, "response_type", "NONE") or "NONE").upper(),
evidence_required=getattr(template, "evidence_required", False),
remarks_required_if_no=getattr(template, "remarks_required_if_no", False),
normal_review_required=bool(getattr(template, "requires_review", False)),
normal_review_role=normal_review_role_for_template(template),
normal_review_status="not_ready" if getattr(template, "requires_review", False) else "not_required",
is_aqmm_task=getattr(template, "is_aqmm_task", False),
aqmm_mandatory=getattr(template, "aqmm_mandatory", False),
aqmm_evidence_required=getattr(template, "aqmm_evidence_required", False),
@@ -372,6 +398,12 @@ def _copy_template_snapshot_to_task(
task.response_type = (getattr(template, "response_type", "NONE") or "NONE").upper()
task.evidence_required = getattr(template, "evidence_required", False)
task.remarks_required_if_no = getattr(template, "remarks_required_if_no", False)
task.normal_review_required = bool(getattr(template, "requires_review", False))
task.normal_review_role = normal_review_role_for_template(template)
if not task.normal_review_required:
task.normal_review_status = "not_required"
elif (getattr(task, "normal_review_status", None) or "not_required") == "not_required":
task.normal_review_status = "not_ready"
task.is_aqmm_task = getattr(template, "is_aqmm_task", False)
task.aqmm_mandatory = getattr(template, "aqmm_mandatory", False)
task.aqmm_evidence_required = getattr(template, "aqmm_evidence_required", False)
@@ -465,6 +497,9 @@ def sync_open_engagement_tasks_for_service(
response_type=(getattr(template, "response_type", "NONE") or "NONE").upper(),
evidence_required=getattr(template, "evidence_required", False),
remarks_required_if_no=getattr(template, "remarks_required_if_no", False),
normal_review_required=bool(getattr(template, "requires_review", False)),
normal_review_role=normal_review_role_for_template(template),
normal_review_status="not_ready" if getattr(template, "requires_review", False) else "not_required",
is_aqmm_task=getattr(template, "is_aqmm_task", False),
aqmm_mandatory=getattr(template, "aqmm_mandatory", False),
aqmm_evidence_required=getattr(template, "aqmm_evidence_required", False),
@@ -1323,6 +1358,11 @@ def submit_task_for_review(db: Session, *, task: ClientServiceTaskInstance, note
now = datetime.now(timezone.utc)
task.submitted_for_review_by_user_id = user_id
task.submitted_for_review_at_utc = now
if getattr(task, "normal_review_required", False):
task.normal_review_status = "pending"
task.normal_review_note = None
task.normal_reviewed_by_user_id = None
task.normal_reviewed_at_utc = None
if getattr(task, "aqmm_manager_review_required", False) and task.manager_review_status != "reviewed":
task.manager_review_status = "pending"
if getattr(task, "aqmm_partner_review_required", False) and task.partner_review_status != "reviewed":
@@ -1339,6 +1379,53 @@ def submit_task_for_review(db: Session, *, task: ClientServiceTaskInstance, note
recalculate_task_aqmm_status(db, task)
def apply_normal_task_review(
db: Session,
*,
task: ClientServiceTaskInstance,
decision: str,
note: str,
user_id: int,
) -> None:
if getattr(task, "is_locked", False) or getattr(getattr(task, "subscription", None), "is_locked", False):
return
if not getattr(task, "normal_review_required", False):
raise ValueError("Normal review is not required for this task.")
clean_decision = (decision or "reviewed").strip().lower()
if clean_decision not in {"reviewed", "rework_required"}:
clean_decision = "reviewed"
clean_note = (note or "").strip()
if clean_decision == "rework_required" and not clean_note:
raise ValueError("Rework reason is required.")
now = datetime.now(timezone.utc)
task.normal_review_status = clean_decision
task.normal_review_note = clean_note or task.normal_review_note
task.normal_reviewed_by_user_id = user_id
task.normal_reviewed_at_utc = now
if clean_decision == "rework_required":
task.rework_status = "open"
task.rework_reason = clean_note
task.rework_requested_by_user_id = user_id
task.rework_requested_at_utc = now
task.rework_resolved_at_utc = None
task.status = "blocked"
comment_type = "rework_note"
else:
if getattr(task, "rework_status", "none") == "open":
task.rework_status = "resolved"
task.rework_resolved_at_utc = now
comment_type = "manager_review_note" if (getattr(task, "normal_review_role", "") or "").startswith("manager") else "partner_review_note"
task.updated_by_user_id = user_id
_append_system_task_comment(
db,
task=task,
comment_type=comment_type,
message=clean_note or ("Normal review approved." if clean_decision == "reviewed" else clean_note),
user_id=user_id,
)
def apply_task_review(
db: Session,
*,
+10
View File
@@ -114,6 +114,7 @@ class ServiceDefaultTaskTemplate(CommonBase):
eligible_role_names: Mapped[str | None] = mapped_column(String(200), nullable=True)
is_mandatory: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
requires_review: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
normal_review_role: Mapped[str | None] = mapped_column(String(30), nullable=True)
# AQMM task tags. These flags allow the existing service checklist to become
# the engagement quality checklist for assurance engagements, without creating
@@ -183,6 +184,7 @@ class FirmServiceTaskTemplate(CommonBase):
eligible_role_names: Mapped[str | None] = mapped_column(String(200), nullable=True)
is_mandatory: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
requires_review: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
normal_review_role: Mapped[str | None] = mapped_column(String(30), nullable=True)
# AQMM task tags copied into generated ClientServiceTaskInstance rows.
is_aqmm_task: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False, index=True)
@@ -783,6 +785,14 @@ class ClientServiceTaskInstance(CommonBase):
submitted_for_review_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True, index=True)
submitted_for_review_at_utc: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
# Normal operational review is intentionally separate from AQMM review.
normal_review_required: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, index=True)
normal_review_role: Mapped[str | None] = mapped_column(String(30), nullable=True, index=True)
normal_review_status: Mapped[str] = mapped_column(String(30), nullable=False, default="not_required", index=True)
normal_review_note: Mapped[str | None] = mapped_column(Text, nullable=True)
normal_reviewed_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True)
normal_reviewed_at_utc: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
manager_review_status: Mapped[str] = mapped_column(String(30), nullable=False, default="not_required", index=True)
manager_review_note: Mapped[str | None] = mapped_column(Text, nullable=True)
manager_reviewed_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True, index=True)
@@ -56,6 +56,14 @@
<input type="checkbox" name="requires_review" {% if task.requires_review %}checked{% endif %}>
Review
</label>
<label class="inline-flex items-center gap-2">
<span>Normal reviewer</span>
<select name="normal_review_role" class="rounded-lg border border-slate-300 px-2 py-1 text-xs">
<option value="manager" {% if (task.normal_review_role or 'manager') == 'manager' %}selected{% endif %}>Manager</option>
<option value="partner" {% if task.normal_review_role == 'partner' %}selected{% endif %}>Partner</option>
<option value="manager_or_partner" {% if task.normal_review_role == 'manager_or_partner' %}selected{% endif %}>Manager / Partner</option>
</select>
</label>
<label class="inline-flex items-center gap-2">
<input type="checkbox" name="is_active" {% if task.is_active %}checked{% endif %}>
Active
@@ -19,7 +19,7 @@
<div class="mt-1 text-xs text-slate-500">
Role: {{ task.default_role_name or '-' }} ·
Mandatory: {{ 'Yes' if task.is_mandatory else 'No' }} ·
Review: {{ 'Yes' if task.requires_review else 'No' }} · AQMM: {{ 'Yes' if task.is_aqmm_task else 'No' }}
Review: {{ ((task.normal_review_role or 'manager').replace('_',' ').title()) if task.requires_review else 'No' }} · AQMM: {{ 'Yes' if task.is_aqmm_task else 'No' }}
</div>
</div>
<div class="flex items-center gap-3">
@@ -44,7 +44,7 @@
<div><label class="mb-2 block text-sm font-medium text-slate-700">Task Name</label><input type="text" name="task_name" required class="w-full rounded-xl border border-slate-300 px-4 py-2 text-sm"></div>
<div><label class="mb-2 block text-sm font-medium text-slate-700">Default Role</label><input type="text" name="default_role_name" class="w-full rounded-xl border border-slate-300 px-4 py-2 text-sm"></div>
<div><label class="mb-2 block text-sm font-medium text-slate-700">Sequence No</label><input type="number" min="1" name="sequence_no" class="w-full rounded-xl border border-slate-300 px-4 py-2 text-sm"></div>
<div class="flex items-center gap-6 pt-7 text-sm text-slate-700"><label class="inline-flex items-center gap-2"><input type="checkbox" name="is_mandatory" checked> Mandatory</label><label class="inline-flex items-center gap-2"><input type="checkbox" name="requires_review"> Review</label><label class="inline-flex items-center gap-2"><input type="checkbox" name="is_active" checked> Active</label></div>
<div class="flex items-center gap-6 pt-7 text-sm text-slate-700"><label class="inline-flex items-center gap-2"><input type="checkbox" name="is_mandatory" checked> Mandatory</label><label class="inline-flex items-center gap-2"><input type="checkbox" name="requires_review"> Review</label><label class="inline-flex items-center gap-2">Normal reviewer <select name="normal_review_role" class="rounded border border-slate-300 px-2 py-1 text-xs"><option value="manager">Manager</option><option value="partner">Partner</option><option value="manager_or_partner">Manager / Partner</option></select></label><label class="inline-flex items-center gap-2"><input type="checkbox" name="is_active" checked> Active</label></div>
<div class="md:col-span-2"><label class="mb-2 block text-sm font-medium text-slate-700">Description</label><textarea name="description" rows="4" class="w-full rounded-xl border border-slate-300 px-4 py-2 text-sm"></textarea></div>
<div class="md:col-span-2 rounded-2xl border border-indigo-200 bg-indigo-50 p-4">
<div class="text-sm font-semibold text-indigo-950">AQMM / Quality Control Tagging</div>
@@ -56,6 +56,14 @@
<input type="checkbox" name="requires_review" {% if task.requires_review %}checked{% endif %}>
Review
</label>
<label class="inline-flex items-center gap-2">
<span>Normal reviewer</span>
<select name="normal_review_role" class="rounded-lg border border-slate-300 px-2 py-1 text-xs">
<option value="manager" {% if (task.normal_review_role or 'manager') == 'manager' %}selected{% endif %}>Manager</option>
<option value="partner" {% if task.normal_review_role == 'partner' %}selected{% endif %}>Partner</option>
<option value="manager_or_partner" {% if task.normal_review_role == 'manager_or_partner' %}selected{% endif %}>Manager / Partner</option>
</select>
</label>
<label class="inline-flex items-center gap-2">
<input type="checkbox" name="is_active" {% if task.is_active %}checked{% endif %}>
Active
@@ -81,7 +81,7 @@
<label class="mb-1 block text-xs font-semibold uppercase tracking-wide text-slate-500">Options</label>
<div class="flex flex-wrap gap-3 rounded-xl border border-slate-200 px-3 py-2 text-xs text-slate-700">
<label class="inline-flex items-center gap-1"><input type="checkbox" name="is_mandatory" checked> Mandatory</label>
<label class="inline-flex items-center gap-1"><input type="checkbox" name="requires_review"> Review</label>
<label class="inline-flex items-center gap-1"><input type="checkbox" name="requires_review"> Review</label><label class="inline-flex items-center gap-1">Normal reviewer <select name="normal_review_role" class="rounded border border-slate-300 px-1 py-0.5"><option value="manager">Manager</option><option value="partner">Partner</option><option value="manager_or_partner">Manager / Partner</option></select></label>
<label class="inline-flex items-center gap-1"><input type="checkbox" name="is_active" checked> Active</label>
</div>
</div>
@@ -165,7 +165,7 @@
<td class="px-4 py-3 text-xs text-slate-600">
<div class="flex flex-wrap gap-1">
{% if task.is_mandatory %}<span class="rounded-full bg-slate-100 px-2 py-1">Mandatory</span>{% endif %}
{% if task.requires_review %}<span class="rounded-full bg-amber-100 px-2 py-1 text-amber-800">Review</span>{% endif %}
{% if task.requires_review %}<span class="rounded-full bg-amber-100 px-2 py-1 text-amber-800">Review: {{ (task.normal_review_role or "manager").replace("_"," ").title() }}</span>{% endif %}
{% if task.is_aqmm_task %}<span class="rounded-full bg-indigo-100 px-2 py-1 text-indigo-800">AQMM</span>{% endif %}
{% if task.aqmm_mandatory %}<span class="rounded-full bg-rose-100 px-2 py-1 text-rose-800">AQMM Mandatory</span>{% endif %}
{% if task.aqmm_evidence_required %}<span class="rounded-full bg-sky-100 px-2 py-1 text-sky-800">Evidence</span>{% endif %}
+19 -3
View File
@@ -138,6 +138,15 @@ def _validated_registration_type_code(db, value: str, *, existing_value: str | N
raise ValueError("registration_type")
def _normal_review_role_value(value: str, *, requires_review: bool) -> str | None:
if not requires_review:
return None
clean = (value or "").strip().lower()
if clean in {"manager", "partner", "manager_or_partner"}:
return clean
return "manager"
def _redirect_denied():
from app.core.http_responses import ui_access_denied
return ui_access_denied()
@@ -948,6 +957,7 @@ def _copy_system_defaults_if_firm_tasks_empty(
eligible_role_names=getattr(default, "eligible_role_names", None) or default.default_role_name,
is_mandatory=default.is_mandatory,
requires_review=default.requires_review,
normal_review_role=getattr(default, "normal_review_role", None),
is_aqmm_task=getattr(default, "is_aqmm_task", False),
aqmm_mandatory=getattr(default, "aqmm_mandatory", False),
aqmm_evidence_required=getattr(default, "aqmm_evidence_required", False),
@@ -1241,7 +1251,7 @@ def task_templates_detail(request: Request, catalogue_id: int):
@router.post('/templates/{catalogue_id}/tasks/new')
def task_template_create_submit(request: Request, catalogue_id: int, task_name: str = Form(...), description: str = Form(''), default_role_name: str = Form(''), eligible_roles: list[str] = Form([]), sequence_no: int | None = Form(None), is_mandatory: str | None = Form(None), requires_review: str | None = Form(None), task_category: str = Form(''), response_required: str | None = Form(None), response_type: str = Form('NONE'), evidence_required: str | None = Form(None), remarks_required_if_no: str | None = Form(None), is_aqmm_task: str | None = Form(None), aqmm_mandatory: str | None = Form(None), aqmm_evidence_required: str | None = Form(None), aqmm_manager_review_required: str | None = Form(None), aqmm_partner_review_required: str | None = Form(None), aqmm_review_partner_required: str | None = Form(None), aqmm_blocks_final_release: str | None = Form(None), aqmm_reference: str = Form(''), is_active: str | None = Form(None), csrf_token: str = Form(...)):
def task_template_create_submit(request: Request, catalogue_id: int, task_name: str = Form(...), description: str = Form(''), default_role_name: str = Form(''), eligible_roles: list[str] = Form([]), sequence_no: int | None = Form(None), is_mandatory: str | None = Form(None), requires_review: str | None = Form(None), normal_review_role: str = Form('manager'), task_category: str = Form(''), response_required: str | None = Form(None), response_type: str = Form('NONE'), evidence_required: str | None = Form(None), remarks_required_if_no: str | None = Form(None), is_aqmm_task: str | None = Form(None), aqmm_mandatory: str | None = Form(None), aqmm_evidence_required: str | None = Form(None), aqmm_manager_review_required: str | None = Form(None), aqmm_partner_review_required: str | None = Form(None), aqmm_review_partner_required: str | None = Form(None), aqmm_blocks_final_release: str | None = Form(None), aqmm_reference: str = Form(''), is_active: str | None = Form(None), csrf_token: str = Form(...)):
validate_csrf(request, csrf_token)
db = CommonSessionLocal()
try:
@@ -1267,6 +1277,7 @@ def task_template_create_submit(request: Request, catalogue_id: int, task_name:
sequence_no=sequence_no or next_task_sequence(db, tenant_id=tenant_id, catalogue_id=catalogue_id),
is_mandatory=is_mandatory is not None,
requires_review=requires_review is not None,
normal_review_role=_normal_review_role_value(normal_review_role, requires_review=requires_review is not None),
task_category=task_category.strip() or None,
response_required=response_required is not None,
response_type=(response_type.strip().upper() or "NONE"),
@@ -1351,7 +1362,7 @@ def default_templates_detail(request: Request, catalogue_id: int):
@router.post('/catalogue/{catalogue_id}/defaults/new')
def default_template_create_submit(request: Request, catalogue_id: int, task_name: str = Form(...), description: str = Form(''), default_role_name: str = Form(''), sequence_no: int | None = Form(None), is_mandatory: str | None = Form(None), requires_review: str | None = Form(None), task_category: str = Form(''), response_required: str | None = Form(None), response_type: str = Form('NONE'), evidence_required: str | None = Form(None), remarks_required_if_no: str | None = Form(None), is_aqmm_task: str | None = Form(None), aqmm_mandatory: str | None = Form(None), aqmm_evidence_required: str | None = Form(None), aqmm_manager_review_required: str | None = Form(None), aqmm_partner_review_required: str | None = Form(None), aqmm_review_partner_required: str | None = Form(None), aqmm_blocks_final_release: str | None = Form(None), aqmm_reference: str = Form(''), is_active: str | None = Form(None), csrf_token: str = Form(...)):
def default_template_create_submit(request: Request, catalogue_id: int, task_name: str = Form(...), description: str = Form(''), default_role_name: str = Form(''), sequence_no: int | None = Form(None), is_mandatory: str | None = Form(None), requires_review: str | None = Form(None), normal_review_role: str = Form('manager'), task_category: str = Form(''), response_required: str | None = Form(None), response_type: str = Form('NONE'), evidence_required: str | None = Form(None), remarks_required_if_no: str | None = Form(None), is_aqmm_task: str | None = Form(None), aqmm_mandatory: str | None = Form(None), aqmm_evidence_required: str | None = Form(None), aqmm_manager_review_required: str | None = Form(None), aqmm_partner_review_required: str | None = Form(None), aqmm_review_partner_required: str | None = Form(None), aqmm_blocks_final_release: str | None = Form(None), aqmm_reference: str = Form(''), is_active: str | None = Form(None), csrf_token: str = Form(...)):
validate_csrf(request, csrf_token)
db = CommonSessionLocal()
try:
@@ -1364,7 +1375,7 @@ def default_template_create_submit(request: Request, catalogue_id: int, task_nam
catalogue = get_catalogue(db, catalogue_id)
if not catalogue:
return RedirectResponse(url='/services/defaults', status_code=303)
row = ServiceDefaultTaskTemplate(service_catalogue_id=catalogue_id, task_name=task_name.strip(), description=description.strip() or None, default_role_name=default_role_name.strip() or None, sequence_no=sequence_no or next_default_task_sequence(db, catalogue_id=catalogue_id), is_mandatory=is_mandatory is not None, requires_review=requires_review is not None, task_category=task_category.strip() or None, response_required=response_required is not None, response_type=(response_type.strip().upper() or "NONE"), evidence_required=evidence_required is not None, remarks_required_if_no=remarks_required_if_no is not None, is_aqmm_task=is_aqmm_task is not None, aqmm_mandatory=aqmm_mandatory is not None, aqmm_evidence_required=aqmm_evidence_required is not None, aqmm_manager_review_required=aqmm_manager_review_required is not None, aqmm_partner_review_required=aqmm_partner_review_required is not None, aqmm_review_partner_required=aqmm_review_partner_required is not None, aqmm_blocks_final_release=aqmm_blocks_final_release is not None, aqmm_reference=aqmm_reference.strip() or None, is_active=is_active is not None)
row = ServiceDefaultTaskTemplate(service_catalogue_id=catalogue_id, task_name=task_name.strip(), description=description.strip() or None, default_role_name=default_role_name.strip() or None, sequence_no=sequence_no or next_default_task_sequence(db, catalogue_id=catalogue_id), is_mandatory=is_mandatory is not None, requires_review=requires_review is not None, normal_review_role=_normal_review_role_value(normal_review_role, requires_review=requires_review is not None), task_category=task_category.strip() or None, response_required=response_required is not None, response_type=(response_type.strip().upper() or "NONE"), evidence_required=evidence_required is not None, remarks_required_if_no=remarks_required_if_no is not None, is_aqmm_task=is_aqmm_task is not None, aqmm_mandatory=aqmm_mandatory is not None, aqmm_evidence_required=aqmm_evidence_required is not None, aqmm_manager_review_required=aqmm_manager_review_required is not None, aqmm_partner_review_required=aqmm_partner_review_required is not None, aqmm_review_partner_required=aqmm_review_partner_required is not None, aqmm_blocks_final_release=aqmm_blocks_final_release is not None, aqmm_reference=aqmm_reference.strip() or None, is_active=is_active is not None)
db.add(row); db.commit()
return RedirectResponse(url=f'/services/catalogue/{catalogue_id}/defaults', status_code=303)
finally:
@@ -1403,6 +1414,7 @@ def copy_defaults_to_firm(request: Request, catalogue_id: int, csrf_token: str =
default_role_name=d.default_role_name,
is_mandatory=d.is_mandatory,
requires_review=d.requires_review,
normal_review_role=getattr(d, "normal_review_role", None),
task_category=getattr(d, "task_category", None),
response_required=getattr(d, "response_required", False),
response_type=getattr(d, "response_type", "NONE") or "NONE",
@@ -1707,6 +1719,7 @@ def firm_task_template_edit_submit(
sequence_no: int = Form(1),
is_mandatory: str | None = Form(None),
requires_review: str | None = Form(None),
normal_review_role: str = Form('manager'),
task_category: str = Form(''),
response_required: str | None = Form(None),
response_type: str = Form('NONE'),
@@ -1753,6 +1766,7 @@ def firm_task_template_edit_submit(
task.sequence_no = sequence_no
task.is_mandatory = is_mandatory is not None
task.requires_review = requires_review is not None
task.normal_review_role = _normal_review_role_value(normal_review_role, requires_review=task.requires_review)
task.task_category = task_category.strip() or None
task.response_required = response_required is not None
task.response_type = response_type.strip().upper() or "NONE"
@@ -1849,6 +1863,7 @@ def default_task_template_edit_submit(
sequence_no: int = Form(1),
is_mandatory: str | None = Form(None),
requires_review: str | None = Form(None),
normal_review_role: str = Form('manager'),
task_category: str = Form(''),
response_required: str | None = Form(None),
response_type: str = Form('NONE'),
@@ -1895,6 +1910,7 @@ def default_task_template_edit_submit(
task.sequence_no = sequence_no
task.is_mandatory = is_mandatory is not None
task.requires_review = requires_review is not None
task.normal_review_role = _normal_review_role_value(normal_review_role, requires_review=task.requires_review)
task.task_category = task_category.strip() or None
task.response_required = response_required is not None
task.response_type = response_type.strip().upper() or "NONE"