Add phase 3 consultant task execution workflow

This commit is contained in:
A R R R Associates
2026-07-23 11:37:04 +05:30
parent e9db81b32f
commit 0f7d6f4396
9 changed files with 365 additions and 2 deletions
@@ -0,0 +1,52 @@
"""Phase 3 consultant task execution.
Revision ID: 20260723_phase3_consultant_task_execution
Revises: 20260723_phase2_consultant_visibility_document_requests
"""
from alembic import op
import sqlalchemy as sa
revision = "20260723_phase3_consultant_task_execution"
down_revision = "20260723_phase2_consultant_visibility_document_requests"
branch_labels = None
depends_on = None
def upgrade():
op.add_column("client_service_task_instances", sa.Column("execution_mode", sa.String(30), nullable=False, server_default="internal"))
op.add_column("client_service_task_instances", sa.Column("assigned_consultant_id", sa.Integer(), nullable=True))
op.add_column("client_service_task_instances", sa.Column("consultant_assignment_status", sa.String(30), nullable=False, server_default="not_applicable"))
op.add_column("client_service_task_instances", sa.Column("consultant_due_date", sa.Date(), nullable=True))
op.add_column("client_service_task_instances", sa.Column("consultant_offered_at_utc", sa.DateTime(timezone=True), nullable=True))
op.add_column("client_service_task_instances", sa.Column("consultant_accepted_at_utc", sa.DateTime(timezone=True), nullable=True))
op.add_column("client_service_task_instances", sa.Column("consultant_declined_at_utc", sa.DateTime(timezone=True), nullable=True))
op.add_column("client_service_task_instances", sa.Column("consultant_decline_reason", sa.Text(), nullable=True))
op.add_column("client_service_task_instances", sa.Column("consultant_started_at_utc", sa.DateTime(timezone=True), nullable=True))
op.add_column("client_service_task_instances", sa.Column("consultant_submitted_at_utc", sa.DateTime(timezone=True), nullable=True))
op.add_column("client_service_task_instances", sa.Column("consultant_submission_note", sa.Text(), nullable=True))
op.add_column("client_service_task_instances", sa.Column("consultant_approved_at_utc", sa.DateTime(timezone=True), nullable=True))
op.add_column("client_service_task_instances", sa.Column("consultant_approved_by_user_id", sa.Integer(), nullable=True))
op.create_foreign_key("fk_task_instance_assigned_consultant", "client_service_task_instances", "consultant_profiles", ["assigned_consultant_id"], ["id"], ondelete="SET NULL")
op.create_foreign_key("fk_task_instance_consultant_approved_by", "client_service_task_instances", "users", ["consultant_approved_by_user_id"], ["id"], ondelete="SET NULL")
op.create_index("ix_task_instance_execution_mode", "client_service_task_instances", ["execution_mode"])
op.create_index("ix_task_instance_assigned_consultant", "client_service_task_instances", ["assigned_consultant_id"])
op.create_index("ix_task_instance_consultant_assignment_status", "client_service_task_instances", ["consultant_assignment_status"])
op.create_index("ix_task_instance_consultant_due_date", "client_service_task_instances", ["consultant_due_date"])
op.create_index("ix_task_instance_consultant_approved_by", "client_service_task_instances", ["consultant_approved_by_user_id"])
def downgrade():
op.drop_index("ix_task_instance_consultant_approved_by", table_name="client_service_task_instances")
op.drop_index("ix_task_instance_consultant_due_date", table_name="client_service_task_instances")
op.drop_index("ix_task_instance_consultant_assignment_status", table_name="client_service_task_instances")
op.drop_index("ix_task_instance_assigned_consultant", table_name="client_service_task_instances")
op.drop_index("ix_task_instance_execution_mode", table_name="client_service_task_instances")
op.drop_constraint("fk_task_instance_consultant_approved_by", "client_service_task_instances", type_="foreignkey")
op.drop_constraint("fk_task_instance_assigned_consultant", "client_service_task_instances", type_="foreignkey")
for column in [
"consultant_approved_by_user_id", "consultant_approved_at_utc", "consultant_submission_note",
"consultant_submitted_at_utc", "consultant_started_at_utc", "consultant_decline_reason",
"consultant_declined_at_utc", "consultant_accepted_at_utc", "consultant_offered_at_utc",
"consultant_due_date", "consultant_assignment_status", "assigned_consultant_id", "execution_mode",
]:
op.drop_column("client_service_task_instances", column)
+53 -1
View File
@@ -1,7 +1,7 @@
from __future__ import annotations from __future__ import annotations
from collections import OrderedDict from collections import OrderedDict
from datetime import date, timedelta from datetime import date, datetime, timedelta, timezone
from typing import Any from typing import Any
from sqlalchemy import or_, select from sqlalchemy import or_, select
@@ -171,3 +171,55 @@ def get_consultant_document_centre(db: Session, *, consultant: ConsultantProfile
term=f"%{q.strip()}%"; stmt=stmt.where(or_(PermanentClientDocument.title.ilike(term),PermanentClientDocument.category.ilike(term),PermanentClientDocument.document_code.ilike(term))) term=f"%{q.strip()}%"; stmt=stmt.where(or_(PermanentClientDocument.title.ilike(term),PermanentClientDocument.category.ilike(term),PermanentClientDocument.document_code.ilike(term)))
permanent_documents=db.execute(stmt.order_by(PermanentClientDocument.created_at_utc.desc()).limit(200)).scalars().all() permanent_documents=db.execute(stmt.order_by(PermanentClientDocument.created_at_utc.desc()).limit(200)).scalars().all()
return {"engagement_documents":engagement_documents,"permanent_documents":permanent_documents,"total":len(engagement_documents)+len(permanent_documents)} return {"engagement_documents":engagement_documents,"permanent_documents":permanent_documents,"total":len(engagement_documents)+len(permanent_documents)}
def consultant_can_execute_task(*, consultant: ConsultantProfile, task: ClientServiceTaskInstance) -> bool:
return bool(task.execution_mode == "consultant" and task.assigned_consultant_id == consultant.id and task.is_active and not task.is_locked)
def update_consultant_assignment(db: Session, *, consultant: ConsultantProfile, task: ClientServiceTaskInstance, action: str, note: str, user_id: int) -> tuple[bool, str | None]:
if not consultant_can_execute_task(consultant=consultant, task=task):
return False, "This task is not assigned to your consultant profile."
action = (action or "").strip().lower()
note = (note or "").strip()
current = (task.consultant_assignment_status or "not_applicable").lower()
now = datetime.now(timezone.utc)
message = None
if action == "accept" and current == "offered":
task.consultant_assignment_status = "accepted"
task.consultant_accepted_at_utc = now
task.consultant_declined_at_utc = None
task.consultant_decline_reason = None
task.status = "pending"
message = "Consultant accepted the assignment."
elif action == "decline" and current == "offered":
if not note:
return False, "Decline reason is required."
task.consultant_assignment_status = "declined"
task.consultant_declined_at_utc = now
task.consultant_decline_reason = note
task.status = "pending"
message = f"Consultant declined the assignment: {note}"
elif action == "start" and current in {"accepted", "rework_required"}:
task.consultant_assignment_status = "in_progress"
task.consultant_started_at_utc = task.consultant_started_at_utc or now
task.status = "in_progress"
if current == "rework_required":
task.rework_status = "in_progress"
message = "Consultant started work on the assignment."
elif action == "submit" and current in {"accepted", "in_progress", "rework_required"}:
if not note:
return False, "Submission note is required."
task.consultant_assignment_status = "submitted"
task.consultant_submitted_at_utc = now
task.consultant_submission_note = note
task.status = "pending_review"
if task.rework_status in {"requested", "in_progress"}:
task.rework_status = "resolved"
task.rework_resolved_at_utc = now
message = f"Consultant submitted the assignment for firm review: {note}"
else:
return False, "This action is not allowed for the current assignment status."
task.updated_by_user_id = user_id
db.add(ServiceTaskComment(tenant_id=task.tenant_id, branch_id=task.branch_id, subscription_id=task.subscription_id, task_instance_id=task.id, comment_type="consultant_execution", visibility="consultant", message=message, created_by_user_id=user_id))
return True, None
@@ -22,6 +22,24 @@
{% if task.description %}<div class="mt-5 rounded-xl bg-slate-50 p-4 text-sm text-slate-700 whitespace-pre-line">{{ task.description }}</div>{% endif %} {% if task.description %}<div class="mt-5 rounded-xl bg-slate-50 p-4 text-sm text-slate-700 whitespace-pre-line">{{ task.description }}</div>{% endif %}
</div> </div>
{% if task.execution_mode == 'consultant' and task.assigned_consultant_id == consultant.id %}
<div class="rounded-2xl border border-indigo-200 bg-indigo-50 p-5 shadow-soft">
<div class="flex flex-wrap items-start justify-between gap-3">
<div><h3 class="font-semibold text-slate-900">Consultant Assignment Execution</h3><p class="mt-1 text-sm text-slate-600">Status: <span class="font-semibold">{{ task.consultant_assignment_status.replace('_',' ').title() }}</span>{% if task.consultant_due_date %} • Due {{ task.consultant_due_date.strftime('%d-%m-%Y') }}{% endif %}</p></div>
</div>
{% if task.rework_reason %}<div class="mt-3 rounded-xl border border-amber-200 bg-amber-50 p-3 text-sm text-amber-800"><strong>Rework:</strong> {{ task.rework_reason }}</div>{% endif %}
<form method="post" action="/consultant/assignments/{{ task.id }}/execution" class="mt-4 space-y-3">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<textarea name="note" rows="3" placeholder="Reason for decline or submission note" class="w-full rounded-xl border border-slate-300 bg-white px-3 py-2 text-sm"></textarea>
<div class="flex flex-wrap gap-2">
{% if task.consultant_assignment_status == 'offered' %}<button name="action" value="accept" class="rounded-xl bg-emerald-600 px-4 py-2 text-sm font-semibold text-white">Accept Assignment</button><button name="action" value="decline" class="rounded-xl border border-red-300 bg-white px-4 py-2 text-sm font-semibold text-red-700">Decline</button>{% endif %}
{% if task.consultant_assignment_status in ['accepted','rework_required'] %}<button name="action" value="start" class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white">Start Work</button>{% endif %}
{% if task.consultant_assignment_status in ['accepted','in_progress','rework_required'] %}<button name="action" value="submit" class="rounded-xl bg-indigo-600 px-4 py-2 text-sm font-semibold text-white">Submit for Firm Review</button>{% endif %}
</div>
</form>
</div>
{% endif %}
<div class="rounded-2xl border border-slate-200 bg-white shadow-soft"> <div class="rounded-2xl border border-slate-200 bg-white shadow-soft">
<div class="border-b border-slate-200 px-5 py-4"><h3 class="font-semibold text-slate-900">Consultant Communication Timeline</h3></div> <div class="border-b border-slate-200 px-5 py-4"><h3 class="font-semibold text-slate-900">Consultant Communication Timeline</h3></div>
<div class="divide-y divide-slate-100"> <div class="divide-y divide-slate-100">
@@ -45,6 +45,7 @@
<div class="mt-3 text-sm font-medium text-slate-800">{{ task.task_name }}</div> <div class="mt-3 text-sm font-medium text-slate-800">{{ task.task_name }}</div>
<div class="mt-2 flex flex-wrap gap-1 text-[11px] font-semibold"> <div class="mt-2 flex flex-wrap gap-1 text-[11px] font-semibold">
<span class="rounded-full bg-slate-100 px-2 py-1 text-slate-600">{{ task.status.replace('_',' ').title() }}</span> <span class="rounded-full bg-slate-100 px-2 py-1 text-slate-600">{{ task.status.replace('_',' ').title() }}</span>
{% if task.execution_mode == 'consultant' and task.assigned_consultant_id == consultant.id %}<span class="rounded-full bg-indigo-50 px-2 py-1 text-indigo-700">Execution: {{ task.consultant_assignment_status.replace('_',' ').title() }}</span>{% endif %}
<span class="rounded-full bg-slate-100 px-2 py-1 text-slate-600">{{ task.priority.replace('_',' ').title() }}</span> <span class="rounded-full bg-slate-100 px-2 py-1 text-slate-600">{{ task.priority.replace('_',' ').title() }}</span>
{% if task.internal_target_date %}<span class="rounded-full bg-slate-100 px-2 py-1 text-slate-600">Due {{ task.internal_target_date.strftime('%d-%m-%Y') }}</span>{% endif %} {% if task.internal_target_date %}<span class="rounded-full bg-slate-100 px-2 py-1 text-slate-600">Due {{ task.internal_target_date.strftime('%d-%m-%Y') }}</span>{% endif %}
</div> </div>
+30
View File
@@ -62,6 +62,7 @@ from app.modules.consultants.service import (
) )
from app.modules.consultants.portal_service import ( from app.modules.consultants.portal_service import (
get_consultant_assignment_detail, get_consultant_assignment_detail,
update_consultant_assignment,
get_consultant_document_centre, get_consultant_document_centre,
get_consultant_work_board, get_consultant_work_board,
) )
@@ -1837,3 +1838,32 @@ def consultant_documents_page(request: Request, q: str = ""):
) )
finally: finally:
db.close() db.close()
@portal_router.post("/assignments/{task_id}/execution")
async def consultant_assignment_execution(request: Request, task_id: int, csrf_token: str = Form(...)):
validate_csrf(request, csrf_token)
form = await request.form()
db = CommonSessionLocal()
try:
user = get_current_user(request, db=db)
if not user:
return RedirectResponse(url="/login", status_code=303)
if not (_has_perm(db, user, "consultants.portal.view") or _is_consultant(db, user)):
return _redirect_denied()
consultant = _get_own_consultant_or_dashboard(request, db, user)
if not consultant:
return RedirectResponse(url="/consultant/dashboard", status_code=303)
detail = get_consultant_assignment_detail(db, consultant=consultant, task_id=task_id)
if not detail:
return RedirectResponse(url="/consultant/work", status_code=303)
ok, error = update_consultant_assignment(db, consultant=consultant, task=detail["task"], action=str(form.get("action") or ""), note=str(form.get("note") or ""), user_id=user.id)
if not ok:
refreshed = get_consultant_assignment_detail(db, consultant=consultant, task_id=task_id) or detail
return _render(request, "modules/consultants/templates/consultants/assignment_detail.html", db, user, title="Consultant Work Detail", consultant=consultant, **refreshed, errors=[error or "Action could not be completed."])
db.commit()
return RedirectResponse(url=f"/consultant/assignments/{task_id}?execution=updated", status_code=303)
except Exception:
db.rollback(); raise
finally:
db.close()
+15
View File
@@ -659,6 +659,19 @@ class ClientServiceTaskInstance(CommonBase):
default_role_name: Mapped[str | None] = mapped_column(String(100), nullable=True) default_role_name: Mapped[str | None] = mapped_column(String(100), nullable=True)
assigned_to_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True, index=True) assigned_to_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True, index=True)
execution_mode: Mapped[str] = mapped_column(String(30), nullable=False, default="internal", index=True)
assigned_consultant_id: Mapped[int | None] = mapped_column(ForeignKey("consultant_profiles.id", ondelete="SET NULL"), nullable=True, index=True)
consultant_assignment_status: Mapped[str] = mapped_column(String(30), nullable=False, default="not_applicable", index=True)
consultant_due_date: Mapped[date | None] = mapped_column(Date, nullable=True, index=True)
consultant_offered_at_utc: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
consultant_accepted_at_utc: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
consultant_declined_at_utc: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
consultant_decline_reason: Mapped[str | None] = mapped_column(Text, nullable=True)
consultant_started_at_utc: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
consultant_submitted_at_utc: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
consultant_submission_note: Mapped[str | None] = mapped_column(Text, nullable=True)
consultant_approved_at_utc: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
consultant_approved_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True)
internal_target_date: Mapped[date | None] = mapped_column(Date, nullable=True, index=True) internal_target_date: Mapped[date | None] = mapped_column(Date, nullable=True, index=True)
status: Mapped[str] = mapped_column(String(30), nullable=False, default="pending", index=True) status: Mapped[str] = mapped_column(String(30), nullable=False, default="pending", index=True)
priority: Mapped[str] = mapped_column(String(20), nullable=False, default="normal") priority: Mapped[str] = mapped_column(String(20), nullable=False, default="normal")
@@ -725,6 +738,8 @@ class ClientServiceTaskInstance(CommonBase):
catalogue = relationship("ServiceCatalogue") catalogue = relationship("ServiceCatalogue")
template = relationship("FirmServiceTaskTemplate") template = relationship("FirmServiceTaskTemplate")
assigned_to = relationship("User", foreign_keys=[assigned_to_user_id]) assigned_to = relationship("User", foreign_keys=[assigned_to_user_id])
assigned_consultant = relationship("ConsultantProfile", foreign_keys=[assigned_consultant_id])
consultant_approved_by = relationship("User", foreign_keys=[consultant_approved_by_user_id])
locked_by = relationship("User", foreign_keys=[locked_by_user_id]) locked_by = relationship("User", foreign_keys=[locked_by_user_id])
documents = relationship( documents = relationship(
"EngagementDocument", "EngagementDocument",
+115
View File
@@ -136,6 +136,7 @@ def _load_tasks(db: Session, engagement: ClientServiceSubscription) -> list[Clie
select(ClientServiceTaskInstance) select(ClientServiceTaskInstance)
.options( .options(
selectinload(ClientServiceTaskInstance.assigned_to), selectinload(ClientServiceTaskInstance.assigned_to),
selectinload(ClientServiceTaskInstance.assigned_consultant),
selectinload(ClientServiceTaskInstance.catalogue), selectinload(ClientServiceTaskInstance.catalogue),
selectinload(ClientServiceTaskInstance.comments).selectinload(ServiceTaskComment.created_by), 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, "permanent_documents": permanent_documents,
"timeline": timeline, "timeline": timeline,
"document_requests": document_requests, "document_requests": document_requests,
"linked_consultants": get_linked_consultants_for_engagement(db, engagement=engagement) if access.can_update_tasks else [],
"access": access, "access": access,
"role_context": access.role_context, "role_context": access.role_context,
"task_statuses": TASK_STATUSES, "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_at_utc = now
request_row.verified_by_user_id = user_id request_row.verified_by_user_id = user_id
return True 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 %} {% endif %}
</div> </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 %} {% if access.can_comment %}
<form method="post" action="/work/tasks/{{ task.id }}/comment" class="mt-4 rounded-2xl bg-slate-50 p-3"> <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 }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token }}">
+54 -1
View File
@@ -12,7 +12,7 @@ from app.core.security.session_auth import get_current_user
from app.core.templating import templates from app.core.templating import templates
from app.modules.core.rbac.deps import get_user_permissions, get_user_roles from app.modules.core.rbac.deps import get_user_permissions, get_user_roles
from app.modules.services.models import ServiceTaskDocumentRequest 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"]) 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 db.rollback(); raise
finally: finally:
db.close() 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()