Allow partners to execute tasks assigned to them
This commit is contained in:
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
||||
+41
-2
@@ -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 & 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 & 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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user