Add phase 3 consultant task execution workflow
This commit is contained in:
@@ -136,6 +136,7 @@ def _load_tasks(db: Session, engagement: ClientServiceSubscription) -> list[Clie
|
||||
select(ClientServiceTaskInstance)
|
||||
.options(
|
||||
selectinload(ClientServiceTaskInstance.assigned_to),
|
||||
selectinload(ClientServiceTaskInstance.assigned_consultant),
|
||||
selectinload(ClientServiceTaskInstance.catalogue),
|
||||
selectinload(ClientServiceTaskInstance.comments).selectinload(ServiceTaskComment.created_by),
|
||||
)
|
||||
@@ -277,6 +278,7 @@ def load_unified_engagement_detail(db: Session, *, request, user, engagement_id:
|
||||
"permanent_documents": permanent_documents,
|
||||
"timeline": timeline,
|
||||
"document_requests": document_requests,
|
||||
"linked_consultants": get_linked_consultants_for_engagement(db, engagement=engagement) if access.can_update_tasks else [],
|
||||
"access": access,
|
||||
"role_context": access.role_context,
|
||||
"task_statuses": TASK_STATUSES,
|
||||
@@ -357,3 +359,116 @@ def update_document_request(db: Session, *, request_row: ServiceTaskDocumentRequ
|
||||
request_row.verified_at_utc = now
|
||||
request_row.verified_by_user_id = user_id
|
||||
return True
|
||||
|
||||
CONSULTANT_ASSIGNMENT_STATUSES = {
|
||||
"not_applicable", "offered", "accepted", "declined", "in_progress",
|
||||
"submitted", "rework_required", "approved", "cancelled",
|
||||
}
|
||||
|
||||
|
||||
def get_linked_consultants_for_engagement(db: Session, *, engagement: ClientServiceSubscription) -> list[dict]:
|
||||
today = date.today()
|
||||
rows = db.execute(
|
||||
select(ClientConsultantLink, ConsultantProfile)
|
||||
.join(ConsultantProfile, ConsultantProfile.id == ClientConsultantLink.consultant_id)
|
||||
.where(
|
||||
ClientConsultantLink.tenant_id == engagement.tenant_id,
|
||||
ClientConsultantLink.client_id == engagement.client_id,
|
||||
ClientConsultantLink.is_active.is_(True),
|
||||
ConsultantProfile.is_active.is_(True),
|
||||
or_(ClientConsultantLink.service_catalogue_id.is_(None), ClientConsultantLink.service_catalogue_id == engagement.service_catalogue_id),
|
||||
or_(ClientConsultantLink.effective_from.is_(None), ClientConsultantLink.effective_from <= today),
|
||||
or_(ClientConsultantLink.effective_to.is_(None), ClientConsultantLink.effective_to >= today),
|
||||
)
|
||||
.order_by(ClientConsultantLink.is_primary.desc(), ConsultantProfile.full_name.asc())
|
||||
).all()
|
||||
return [{"link": link, "consultant": consultant} for link, consultant in rows]
|
||||
|
||||
|
||||
def assign_task_to_consultant(
|
||||
db: Session,
|
||||
*,
|
||||
task: ClientServiceTaskInstance,
|
||||
access: WorkAccess,
|
||||
consultant_id: int | None,
|
||||
due_date: date | None,
|
||||
user_id: int,
|
||||
) -> bool:
|
||||
if not access.can_update_tasks or task.is_locked:
|
||||
return False
|
||||
if not consultant_id:
|
||||
task.execution_mode = "internal"
|
||||
task.assigned_consultant_id = None
|
||||
task.consultant_assignment_status = "not_applicable"
|
||||
task.consultant_due_date = None
|
||||
task.consultant_offered_at_utc = None
|
||||
task.consultant_accepted_at_utc = None
|
||||
task.consultant_declined_at_utc = None
|
||||
task.consultant_decline_reason = None
|
||||
task.consultant_started_at_utc = None
|
||||
task.consultant_submitted_at_utc = None
|
||||
task.consultant_submission_note = None
|
||||
task.consultant_approved_at_utc = None
|
||||
task.consultant_approved_by_user_id = None
|
||||
task.updated_by_user_id = user_id
|
||||
return True
|
||||
consultant = db.get(ConsultantProfile, int(consultant_id))
|
||||
if not consultant or not consultant.is_active or consultant.tenant_id != task.tenant_id:
|
||||
return False
|
||||
engagement = db.get(ClientServiceSubscription, task.subscription_id)
|
||||
if not engagement:
|
||||
return False
|
||||
link = _consultant_link_for_engagement(db, consultant=consultant, engagement=engagement)
|
||||
if not link or not link.can_act_for_client:
|
||||
return False
|
||||
now = datetime.now(timezone.utc)
|
||||
task.execution_mode = "consultant"
|
||||
task.assigned_consultant_id = consultant.id
|
||||
task.assigned_to_user_id = consultant.user_id
|
||||
task.consultant_assignment_status = "offered"
|
||||
task.consultant_due_date = due_date or task.internal_target_date
|
||||
task.consultant_offered_at_utc = now
|
||||
task.consultant_accepted_at_utc = None
|
||||
task.consultant_declined_at_utc = None
|
||||
task.consultant_decline_reason = None
|
||||
task.consultant_started_at_utc = None
|
||||
task.consultant_submitted_at_utc = None
|
||||
task.consultant_submission_note = None
|
||||
task.consultant_approved_at_utc = None
|
||||
task.consultant_approved_by_user_id = None
|
||||
task.status = "pending"
|
||||
task.updated_by_user_id = user_id
|
||||
add_task_comment(db, task=task, comment_type="assignment_update", visibility="consultant", message=f"Assignment offered to consultant. Due date: {task.consultant_due_date or 'Not specified'}.", user_id=user_id)
|
||||
return True
|
||||
|
||||
|
||||
def review_consultant_submission(db: Session, *, task: ClientServiceTaskInstance, access: WorkAccess, action: str, note: str, user_id: int) -> bool:
|
||||
if not access.can_update_tasks or task.execution_mode != "consultant" or task.consultant_assignment_status != "submitted" or task.is_locked:
|
||||
return False
|
||||
action = (action or "").strip().lower()
|
||||
note = (note or "").strip()
|
||||
now = datetime.now(timezone.utc)
|
||||
if action == "approve":
|
||||
task.consultant_assignment_status = "approved"
|
||||
task.consultant_approved_at_utc = now
|
||||
task.consultant_approved_by_user_id = user_id
|
||||
task.status = "completed"
|
||||
task.completed_at_utc = now
|
||||
task.rework_status = "none"
|
||||
task.rework_reason = None
|
||||
message = "Consultant submission approved by the firm."
|
||||
elif action == "rework":
|
||||
if not note:
|
||||
return False
|
||||
task.consultant_assignment_status = "rework_required"
|
||||
task.status = "in_progress"
|
||||
task.rework_status = "requested"
|
||||
task.rework_reason = note
|
||||
task.rework_requested_by_user_id = user_id
|
||||
task.rework_requested_at_utc = now
|
||||
message = f"Rework requested: {note}"
|
||||
else:
|
||||
return False
|
||||
task.updated_by_user_id = user_id
|
||||
add_task_comment(db, task=task, comment_type="review_update", visibility="consultant", message=message, user_id=user_id)
|
||||
return True
|
||||
|
||||
@@ -87,6 +87,33 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if access.can_update_tasks %}
|
||||
<div class="mt-3 rounded-2xl border border-indigo-100 bg-indigo-50 p-3">
|
||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||
<div class="text-xs font-semibold uppercase tracking-wide text-indigo-700">Consultant Execution</div>
|
||||
{% if task.execution_mode == 'consultant' %}<span class="rounded-full bg-white px-2 py-1 text-[11px] font-semibold text-indigo-700">{{ task.consultant_assignment_status.replace('_',' ').title() }}</span>{% endif %}
|
||||
</div>
|
||||
<form method="post" action="/work/tasks/{{ task.id }}/consultant-assignment" class="mt-3 grid gap-2 md:grid-cols-[1fr_auto_auto]">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<select name="consultant_id" class="rounded-xl border border-slate-300 bg-white px-3 py-2 text-sm">
|
||||
<option value="">Internal execution / remove consultant</option>
|
||||
{% for row in linked_consultants %}<option value="{{ row.consultant.id }}" {% if task.assigned_consultant_id == row.consultant.id %}selected{% endif %}>{{ row.consultant.full_name }}{% if row.link.is_primary %} (Primary){% endif %}</option>{% endfor %}
|
||||
</select>
|
||||
<input type="date" name="consultant_due_date" value="{{ task.consultant_due_date.isoformat() if task.consultant_due_date else '' }}" class="rounded-xl border border-slate-300 bg-white px-3 py-2 text-sm">
|
||||
<button class="rounded-xl border border-indigo-300 bg-white px-4 py-2 text-sm font-semibold text-indigo-700">Assign / Update</button>
|
||||
</form>
|
||||
{% if task.execution_mode == 'consultant' and task.assigned_consultant %}<div class="mt-2 text-xs text-slate-600">Assigned consultant: <strong>{{ task.assigned_consultant.full_name }}</strong>{% if task.consultant_submission_note %} • Submission: {{ task.consultant_submission_note }}{% endif %}</div>{% endif %}
|
||||
{% if task.consultant_assignment_status == 'submitted' %}
|
||||
<form method="post" action="/work/tasks/{{ task.id }}/consultant-review" class="mt-3 grid gap-2 md:grid-cols-[1fr_auto_auto]">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<input name="note" placeholder="Review note; required for rework" class="rounded-xl border border-slate-300 bg-white px-3 py-2 text-sm">
|
||||
<button name="action" value="rework" class="rounded-xl border border-amber-300 bg-white px-4 py-2 text-sm font-semibold text-amber-700">Request Rework</button>
|
||||
<button name="action" value="approve" class="rounded-xl bg-emerald-600 px-4 py-2 text-sm font-semibold text-white">Approve Submission</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if access.can_comment %}
|
||||
<form method="post" action="/work/tasks/{{ task.id }}/comment" class="mt-4 rounded-2xl bg-slate-50 p-3">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
|
||||
@@ -12,7 +12,7 @@ from app.core.security.session_auth import get_current_user
|
||||
from app.core.templating import templates
|
||||
from app.modules.core.rbac.deps import get_user_permissions, get_user_roles
|
||||
from app.modules.services.models import ServiceTaskDocumentRequest
|
||||
from app.modules.work_detail.service import create_document_request, get_task_for_action, load_unified_engagement_detail, save_task_comment, save_task_status, update_document_request
|
||||
from app.modules.work_detail.service import assign_task_to_consultant, create_document_request, get_task_for_action, load_unified_engagement_detail, review_consultant_submission, save_task_comment, save_task_status, update_document_request
|
||||
|
||||
router = APIRouter(prefix="/work", tags=["unified-work-detail-ui"])
|
||||
|
||||
@@ -221,3 +221,56 @@ async def unified_document_request_status(request: Request, request_id: int, csr
|
||||
db.rollback(); raise
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.post("/tasks/{task_id}/consultant-assignment")
|
||||
async def unified_consultant_assignment_update(request: Request, task_id: int, csrf_token: str = Form(...), consultant_id: str = Form(""), consultant_due_date: str = Form("")):
|
||||
try:
|
||||
validate_csrf(request, csrf_token)
|
||||
except PermissionError:
|
||||
return _csrf_rejected(request)
|
||||
db = CommonSessionLocal()
|
||||
try:
|
||||
user = get_current_user(request, db=db)
|
||||
if not user:
|
||||
return RedirectResponse(url="/login", status_code=303)
|
||||
task, access = get_task_for_action(db, user=user, task_id=task_id)
|
||||
if not task or not access:
|
||||
return RedirectResponse(url=_fallback_for_user(db, user), status_code=303)
|
||||
parsed_consultant_id = int(consultant_id) if consultant_id.strip() else None
|
||||
parsed_due = date.fromisoformat(consultant_due_date) if consultant_due_date else None
|
||||
if not assign_task_to_consultant(db, task=task, access=access, consultant_id=parsed_consultant_id, due_date=parsed_due, user_id=user.id):
|
||||
return RedirectResponse(url=f"/work/engagements/{task.subscription_id}?error=consultant_assignment_not_allowed", status_code=303)
|
||||
db.commit()
|
||||
return RedirectResponse(url=f"/work/engagements/{task.subscription_id}?consultant_assignment=updated", status_code=303)
|
||||
except (ValueError, TypeError):
|
||||
db.rollback()
|
||||
return RedirectResponse(url=f"/work/engagements/{task.subscription_id if 'task' in locals() and task else ''}?error=invalid_consultant_assignment", status_code=303)
|
||||
except Exception:
|
||||
db.rollback(); raise
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.post("/tasks/{task_id}/consultant-review")
|
||||
async def unified_consultant_submission_review(request: Request, task_id: int, csrf_token: str = Form(...), action: str = Form(...), note: str = Form("")):
|
||||
try:
|
||||
validate_csrf(request, csrf_token)
|
||||
except PermissionError:
|
||||
return _csrf_rejected(request)
|
||||
db = CommonSessionLocal()
|
||||
try:
|
||||
user = get_current_user(request, db=db)
|
||||
if not user:
|
||||
return RedirectResponse(url="/login", status_code=303)
|
||||
task, access = get_task_for_action(db, user=user, task_id=task_id)
|
||||
if not task or not access:
|
||||
return RedirectResponse(url=_fallback_for_user(db, user), status_code=303)
|
||||
if not review_consultant_submission(db, task=task, access=access, action=action, note=note, user_id=user.id):
|
||||
return RedirectResponse(url=f"/work/engagements/{task.subscription_id}?error=consultant_review_not_allowed", status_code=303)
|
||||
db.commit()
|
||||
return RedirectResponse(url=f"/work/engagements/{task.subscription_id}?consultant_review=updated", status_code=303)
|
||||
except Exception:
|
||||
db.rollback(); raise
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
Reference in New Issue
Block a user