Allow partners to execute tasks assigned to them

This commit is contained in:
A R R R Associates
2026-09-22 19:10:30 +05:30
parent 581f5faf80
commit b176f60c11
3 changed files with 130 additions and 3 deletions
+9 -1
View File
@@ -193,6 +193,11 @@ def _task_label(task: ClientServiceTaskInstance) -> dict[str, Any]:
catalogue = getattr(task, "catalogue", None)
assignee = getattr(task, "assigned_to", None)
status = (task.status or "pending").replace("_", " ").title()
status_code = (getattr(task, "status", None) or "pending").strip().lower()
is_locked = bool(getattr(task, "is_locked", False) or getattr(subscription, "is_locked", False))
is_task_assignee = int(getattr(task, "assigned_to_user_id", 0) or 0) == int(getattr(current_user, "id", 0) or 0)
can_execute = bool(is_task_assignee and not is_locked and status_code not in {"completed", "not_applicable"})
return {
"id": task.id,
"subscription_id": task.subscription_id,
@@ -543,10 +548,13 @@ def _partner_task_payload(task: ClientServiceTaskInstance, subscription: ClientS
"review_kind_label": "Normal Review" if review_level == "normal_partner" else ("AQMM Review Partner" if review_level == "review_partner" else ("AQMM Partner Review" if review_level == "partner" else "")),
"review_state": review_state,
"can_review": review_level is not None and review_state in {"pending_review", "reviewed", "rework"},
"is_task_assignee": is_task_assignee,
"can_execute": can_execute,
"execution_mode_label": "Assigned to you for execution" if can_execute else "",
"is_exception": getattr(task, "checklist_response", None) == "NO" or getattr(task, "rework_status", "none") == "open",
"evidence_count": len(documents),
"comments": comments,
"is_locked": bool(getattr(task, "is_locked", False) or getattr(subscription, "is_locked", False)),
"is_locked": is_locked,
}
@@ -38,7 +38,9 @@
</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('executed') %}<div class="rounded-2xl border border-emerald-200 bg-emerald-50 p-4 text-sm text-emerald-800">Assigned Partner task saved successfully.</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 %}
{% if request.query_params.get('execution_error') %}<div class="rounded-2xl border border-red-200 bg-red-50 p-4 text-sm text-red-800">The assigned task could not be saved. Check prior task approvals, required response/evidence, assignment and lock status.</div>{% endif %}
<div class="grid gap-6 xl:grid-cols-[380px_minmax(0,1fr)]">
<aside class="space-y-4">
@@ -64,7 +66,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_kind_label or 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.can_execute %}<span class="rounded-full bg-emerald-100 px-3 py-1.5 font-semibold text-emerald-700">Assigned to you for execution</span>{% endif %}{% 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>
@@ -76,6 +78,43 @@
<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_execute and not active.is_locked and not workspace.is_locked %}
<form method="post" action="/partner/reviews/engagements/{{ workspace.subscription.id }}/tasks/{{ active.id }}/execute" class="mt-6 rounded-2xl border border-emerald-200 bg-emerald-50/40 p-4">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<div class="flex flex-col gap-1"><h3 class="font-semibold text-slate-900">Partner Task Execution</h3><p class="text-sm text-slate-600">You are the assigned performer for this task. Complete it here; existing AQMM, evidence and review controls remain unchanged.</p></div>
{% set response_type = (active.response_type or 'NONE')|upper %}
{% if response_type in ['YES_NO', 'YES_NO_NA'] %}
<div class="mt-4"><label class="text-sm font-semibold text-slate-800">Response</label><div class="mt-2 flex flex-wrap gap-2">
<label><input type="radio" name="checklist_response" value="YES" {% if active.checklist_response == 'YES' %}checked{% endif %}> <span class="ml-1">Yes</span></label>
<label><input type="radio" name="checklist_response" value="NO" {% if active.checklist_response == 'NO' %}checked{% endif %}> <span class="ml-1">No</span></label>
{% if response_type == 'YES_NO_NA' %}<label><input type="radio" name="checklist_response" value="NA" {% if active.checklist_response == 'NA' %}checked{% endif %}> <span class="ml-1">N/A</span></label>{% endif %}
</div></div>
{% elif response_type == 'TEXT' %}
<div class="mt-4"><label class="text-sm font-semibold text-slate-800">Response</label><textarea name="checklist_text_response" rows="3" class="mt-2 w-full rounded-2xl border border-slate-300 px-3 py-2 text-sm">{{ active.checklist_text_response or '' }}</textarea></div>
{% elif response_type == 'NUMBER' %}
<div class="mt-4"><label class="text-sm font-semibold text-slate-800">Response</label><input type="number" step="any" name="checklist_number_response" value="{{ active.checklist_number_response if active.checklist_number_response is not none else '' }}" class="mt-2 w-full rounded-2xl border border-slate-300 px-3 py-2 text-sm"></div>
{% elif response_type == 'DATE' %}
<div class="mt-4"><label class="text-sm font-semibold text-slate-800">Response</label><input type="date" name="checklist_date_response" value="{{ active.checklist_date_response or '' }}" class="mt-2 rounded-2xl border border-slate-300 px-3 py-2 text-sm"></div>
{% else %}
<input type="hidden" name="checklist_response" value=""><input type="hidden" name="checklist_text_response" value=""><input type="hidden" name="checklist_number_response" value=""><input type="hidden" name="checklist_date_response" value="">
{% endif %}
<div class="mt-4 grid gap-4 md:grid-cols-2">
<div><label class="text-sm font-semibold text-slate-800">Checklist remarks</label><textarea name="checklist_remarks" rows="3" class="mt-2 w-full rounded-2xl border border-slate-300 px-3 py-2 text-sm">{{ active.checklist_remarks or '' }}</textarea></div>
<div><label class="text-sm font-semibold text-slate-800">Work / conclusion remarks</label><textarea name="remarks" rows="3" class="mt-2 w-full rounded-2xl border border-slate-300 px-3 py-2 text-sm">{{ active.work_remarks or '' }}</textarea></div>
</div>
<div class="mt-4 flex flex-wrap items-center gap-2">
<select name="status" class="rounded-2xl border border-slate-300 bg-white px-3 py-2 text-sm">
<option value="in_progress" {% if active.status == 'in_progress' %}selected{% endif %}>Save as In Progress</option>
<option value="completed">Complete Task</option>
<option value="blocked">Blocked</option>
<option value="not_applicable">Not Applicable</option>
</select>
<button class="rounded-2xl bg-emerald-700 px-4 py-2 text-sm font-semibold text-white hover:bg-emerald-800">Save Task</button>
<a href="/documents/tasks/{{ active.id }}" class="rounded-2xl border border-slate-300 bg-white px-4 py-2 text-sm font-semibold text-slate-700">Open / Upload Evidence</a>
</div>
</form>
{% endif %}
{% 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 }}">
@@ -85,7 +124,7 @@
<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 %}
<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>
<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.can_execute %}This task is assigned to you for execution. Use the Partner Task Execution section above.{% 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>
+80
View File
@@ -16,6 +16,7 @@ from app.modules.partner_dashboard.service import (
get_partner_review_workspace,
)
from app.modules.services.execution import apply_normal_task_review, apply_task_review
from app.modules.employees.service import build_employee_scope, save_employee_workflow_task
from app.modules.alerts.workflow_escalations import update_workflow_escalation
router = APIRouter(prefix="/partner", tags=["partner-dashboard-v2-ui"])
@@ -111,6 +112,85 @@ def partner_review_workspace(request: Request, subscription_id: int, task_id: in
db.close()
@router.post("/reviews/engagements/{subscription_id}/tasks/{task_id}/execute")
def partner_execute_assigned_task(
request: Request,
subscription_id: int,
task_id: int,
status: str = Form("in_progress"),
remarks: str = Form(""),
checklist_response: str = Form(""),
checklist_text_response: str = Form(""),
checklist_number_response: str = Form(""),
checklist_date_response: str = Form(""),
checklist_remarks: str = Form(""),
csrf_token: str = Form(...),
):
"""Execute a task from Partner Operations when the Partner is its actual assignee.
This deliberately reuses the existing employee task-save service so checklist
validation, evidence/approval gates, AQMM recalculation, review submission, locks
and audit timestamps remain identical to the existing execution workflow.
"""
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) or not active.get("can_execute"):
return RedirectResponse(
url=f"/partner/reviews/engagements/{subscription_id}?task_id={task_id}&execution_error=not_assigned",
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}&execution_error=locked",
status_code=303,
)
tenant_id = request.session.get("active_tenant_id") or current_user.tenant_id
branch_id = request.session.get("active_branch_id")
scope = build_employee_scope(db, current_user, tenant_id=tenant_id, branch_id=branch_id)
try:
saved_task, _next_task = save_employee_workflow_task(
db,
scope,
task_id,
status=status,
remarks=remarks,
checklist_response=checklist_response,
checklist_text_response=checklist_text_response,
checklist_number_response=checklist_number_response,
checklist_date_response=checklist_date_response,
checklist_remarks=checklist_remarks,
actor_user_id=current_user.id,
financial_year=None,
)
except ValueError as exc:
db.rollback()
return RedirectResponse(
url=f"/partner/reviews/engagements/{subscription_id}?task_id={task_id}&execution_error=validation",
status_code=303,
)
return RedirectResponse(
url=f"/partner/reviews/engagements/{subscription_id}?task_id={saved_task.id}&executed=1",
status_code=303,
)
finally:
db.close()
@router.post("/reviews/engagements/{subscription_id}/tasks/{task_id}")
def partner_review_task_submit(
request: Request,