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 collections import OrderedDict
from datetime import date, timedelta
from datetime import date, datetime, timedelta, timezone
from typing import Any
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)))
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)}
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 %}
</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="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">
@@ -45,6 +45,7 @@
<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">
<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>
{% 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>
+30
View File
@@ -62,6 +62,7 @@ from app.modules.consultants.service import (
)
from app.modules.consultants.portal_service import (
get_consultant_assignment_detail,
update_consultant_assignment,
get_consultant_document_centre,
get_consultant_work_board,
)
@@ -1837,3 +1838,32 @@ def consultant_documents_page(request: Request, q: str = ""):
)
finally:
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)
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)
status: Mapped[str] = mapped_column(String(30), nullable=False, default="pending", index=True)
priority: Mapped[str] = mapped_column(String(20), nullable=False, default="normal")
@@ -725,6 +738,8 @@ class ClientServiceTaskInstance(CommonBase):
catalogue = relationship("ServiceCatalogue")
template = relationship("FirmServiceTaskTemplate")
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])
documents = relationship(
"EngagementDocument",
+115
View File
@@ -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 }}">
+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.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()