diff --git a/alembic/versions/20260723_phase3_consultant_task_execution.py b/alembic/versions/20260723_phase3_consultant_task_execution.py new file mode 100644 index 0000000..6d2db84 --- /dev/null +++ b/alembic/versions/20260723_phase3_consultant_task_execution.py @@ -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) diff --git a/app/modules/consultants/portal_service.py b/app/modules/consultants/portal_service.py index 3c6f458..d8c2e94 100644 --- a/app/modules/consultants/portal_service.py +++ b/app/modules/consultants/portal_service.py @@ -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 diff --git a/app/modules/consultants/templates/consultants/assignment_detail.html b/app/modules/consultants/templates/consultants/assignment_detail.html index a0faefb..f0fdc1e 100644 --- a/app/modules/consultants/templates/consultants/assignment_detail.html +++ b/app/modules/consultants/templates/consultants/assignment_detail.html @@ -22,6 +22,24 @@ {% if task.description %}
{{ task.description }}
{% endif %} + {% if task.execution_mode == 'consultant' and task.assigned_consultant_id == consultant.id %} +
+
+

Consultant Assignment Execution

Status: {{ task.consultant_assignment_status.replace('_',' ').title() }}{% if task.consultant_due_date %} • Due {{ task.consultant_due_date.strftime('%d-%m-%Y') }}{% endif %}

+
+ {% if task.rework_reason %}
Rework: {{ task.rework_reason }}
{% endif %} +
+ + +
+ {% if task.consultant_assignment_status == 'offered' %}{% endif %} + {% if task.consultant_assignment_status in ['accepted','rework_required'] %}{% endif %} + {% if task.consultant_assignment_status in ['accepted','in_progress','rework_required'] %}{% endif %} +
+
+
+ {% endif %} +

Consultant Communication Timeline

diff --git a/app/modules/consultants/templates/consultants/work_board.html b/app/modules/consultants/templates/consultants/work_board.html index ad25bdc..60230de 100644 --- a/app/modules/consultants/templates/consultants/work_board.html +++ b/app/modules/consultants/templates/consultants/work_board.html @@ -45,6 +45,7 @@
{{ task.task_name }}
{{ task.status.replace('_',' ').title() }} + {% if task.execution_mode == 'consultant' and task.assigned_consultant_id == consultant.id %}Execution: {{ task.consultant_assignment_status.replace('_',' ').title() }}{% endif %} {{ task.priority.replace('_',' ').title() }} {% if task.internal_target_date %}Due {{ task.internal_target_date.strftime('%d-%m-%Y') }}{% endif %}
diff --git a/app/modules/consultants/ui.py b/app/modules/consultants/ui.py index 7bb09a5..eba7bef 100644 --- a/app/modules/consultants/ui.py +++ b/app/modules/consultants/ui.py @@ -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() diff --git a/app/modules/services/models.py b/app/modules/services/models.py index b2068d8..e264576 100644 --- a/app/modules/services/models.py +++ b/app/modules/services/models.py @@ -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", diff --git a/app/modules/work_detail/service.py b/app/modules/work_detail/service.py index 8cbfec0..dc868aa 100644 --- a/app/modules/work_detail/service.py +++ b/app/modules/work_detail/service.py @@ -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 diff --git a/app/modules/work_detail/templates/work_detail/engagement_detail.html b/app/modules/work_detail/templates/work_detail/engagement_detail.html index 32aaee3..f2244ee 100644 --- a/app/modules/work_detail/templates/work_detail/engagement_detail.html +++ b/app/modules/work_detail/templates/work_detail/engagement_detail.html @@ -87,6 +87,33 @@ {% endif %}
+ {% if access.can_update_tasks %} +
+
+
Consultant Execution
+ {% if task.execution_mode == 'consultant' %}{{ task.consultant_assignment_status.replace('_',' ').title() }}{% endif %} +
+
+ + + + +
+ {% if task.execution_mode == 'consultant' and task.assigned_consultant %}
Assigned consultant: {{ task.assigned_consultant.full_name }}{% if task.consultant_submission_note %} • Submission: {{ task.consultant_submission_note }}{% endif %}
{% endif %} + {% if task.consultant_assignment_status == 'submitted' %} +
+ + + + +
+ {% endif %} +
+ {% endif %} + {% if access.can_comment %}
diff --git a/app/modules/work_detail/ui.py b/app/modules/work_detail/ui.py index 61ff040..d42ec0a 100644 --- a/app/modules/work_detail/ui.py +++ b/app/modules/work_detail/ui.py @@ -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()