from __future__ import annotations from collections import OrderedDict from datetime import date, timedelta from typing import Any from sqlalchemy import or_, select from sqlalchemy.orm import Session, selectinload from app.modules.clients.models import Client from app.modules.consultants.models import ClientConsultantLink, ConsultantProfile, ConsultantServiceRequest from app.modules.documents.models import EngagementDocument, PermanentClientDocument from app.modules.services.models import ( ClientServiceSubscription, ClientServiceTaskInstance, ServiceCatalogue, ServiceTaskComment, ) CONSULTANT_BOARD_COLUMNS = OrderedDict( [ ("assigned", "Assigned"), ("awaiting_documents", "Awaiting Documents"), ("in_progress", "In Progress"), ("submitted", "Submitted"), ("accepted", "Accepted"), ("closed", "Closed"), ] ) def _allowed_client_ids(db: Session, *, consultant: ConsultantProfile, require_communications: bool = False) -> list[int]: query = select(ClientConsultantLink.client_id).where( ClientConsultantLink.tenant_id == consultant.tenant_id, ClientConsultantLink.consultant_id == consultant.id, ClientConsultantLink.is_active.is_(True), ) if require_communications: query = query.where(ClientConsultantLink.can_view_communications.is_(True)) return [int(x) for x in db.execute(query).scalars().all()] def _board_key_for_status(status: str | None) -> str: value = (status or "pending").strip().lower() if value in {"blocked", "awaiting_documents", "document_pending", "clarification_required"}: return "awaiting_documents" if value in {"in_progress", "under_process", "processing", "started"}: return "in_progress" if value in {"pending_review", "ready_for_review", "submitted", "completed"}: return "submitted" if value in {"approved", "accepted"}: return "accepted" if value in {"closed", "locked", "cancelled", "inactive"}: return "closed" return "assigned" def _matches_search(*values: Any, q: str = "") -> bool: term = (q or "").strip().lower() if not term: return True return any(term in str(v or "").lower() for v in values) def get_consultant_work_board(db: Session, *, consultant: ConsultantProfile, q: str = "", status: str = "") -> dict: """Build consultant work board from consultant-visible firm task communications. The board intentionally uses existing task/comment visibility rules only. A consultant sees a task here only when the firm has linked the consultant to the client and has created a consultant-visible communication for that task. """ client_ids = _allowed_client_ids(db, consultant=consultant, require_communications=True) columns = {key: {"label": label, "items": []} for key, label in CONSULTANT_BOARD_COLUMNS.items()} latest_by_task: dict[int, dict] = {} if client_ids: rows = db.execute( select(ServiceTaskComment, ClientServiceTaskInstance, ClientServiceSubscription, Client, ServiceCatalogue) .join(ClientServiceTaskInstance, ClientServiceTaskInstance.id == ServiceTaskComment.task_instance_id) .join(ClientServiceSubscription, ClientServiceSubscription.id == ServiceTaskComment.subscription_id) .join(Client, Client.id == ClientServiceTaskInstance.client_id) .join(ServiceCatalogue, ServiceCatalogue.id == ClientServiceTaskInstance.service_catalogue_id) .where( ServiceTaskComment.tenant_id == consultant.tenant_id, ServiceTaskComment.visibility == "consultant", ServiceTaskComment.is_deleted.is_(False), ClientServiceTaskInstance.client_id.in_(client_ids), ClientServiceTaskInstance.tenant_id == consultant.tenant_id, ClientServiceTaskInstance.is_active.is_(True), ) .order_by(ServiceTaskComment.created_at_utc.desc(), ServiceTaskComment.id.desc()) .limit(300) ).all() consultant_user_id = int(getattr(consultant, "user_id", 0) or 0) for comment, task, subscription, client, catalogue in rows: if int(task.id) in latest_by_task: continue if not _matches_search(client.client_name, getattr(client, "client_code", ""), catalogue.service_name, task.task_name, comment.message, q=q): continue key = _board_key_for_status(task.status) if status and key != status: continue latest_by_task[int(task.id)] = { "comment": comment, "task": task, "subscription": subscription, "client": client, "catalogue": catalogue, "board_key": key, "last_message_from_consultant": int(getattr(comment, "created_by_user_id", 0) or 0) == consultant_user_id, "is_overdue": bool(getattr(task, "internal_target_date", None) and task.internal_target_date < date.today()), } for item in latest_by_task.values(): columns[item["board_key"]]["items"].append(item) service_requests = db.execute( select(ConsultantServiceRequest) .options( selectinload(ConsultantServiceRequest.managed_client), selectinload(ConsultantServiceRequest.firm_client), selectinload(ConsultantServiceRequest.service_catalogue), ) .where( ConsultantServiceRequest.tenant_id == consultant.tenant_id, ConsultantServiceRequest.consultant_id == consultant.id, ConsultantServiceRequest.is_active.is_(True), ) .order_by(ConsultantServiceRequest.created_at_utc.desc(), ConsultantServiceRequest.id.desc()) .limit(50) ).scalars().all() return { "columns": columns, "column_options": list(CONSULTANT_BOARD_COLUMNS.items()), "total_tasks": len(latest_by_task), "service_requests": service_requests, } def get_consultant_assignment_detail(db: Session, *, consultant: ConsultantProfile, task_id: int) -> dict | None: client_ids = _allowed_client_ids(db, consultant=consultant, require_communications=True) if not client_ids: return None row = db.execute( select(ClientServiceTaskInstance, ClientServiceSubscription, Client, ServiceCatalogue) .join(ClientServiceSubscription, ClientServiceSubscription.id == ClientServiceTaskInstance.subscription_id) .join(Client, Client.id == ClientServiceTaskInstance.client_id) .join(ServiceCatalogue, ServiceCatalogue.id == ClientServiceTaskInstance.service_catalogue_id) .where( ClientServiceTaskInstance.id == task_id, ClientServiceTaskInstance.tenant_id == consultant.tenant_id, ClientServiceTaskInstance.client_id.in_(client_ids), ClientServiceTaskInstance.is_active.is_(True), ) ).first() if not row: return None task, subscription, client, catalogue = row timeline = db.execute( select(ServiceTaskComment) .options(selectinload(ServiceTaskComment.created_by)) .where( ServiceTaskComment.tenant_id == consultant.tenant_id, ServiceTaskComment.task_instance_id == task.id, ServiceTaskComment.visibility == "consultant", ServiceTaskComment.is_deleted.is_(False), ) .order_by(ServiceTaskComment.created_at_utc.asc(), ServiceTaskComment.id.asc()) ).scalars().all() engagement_documents = db.execute( select(EngagementDocument) .options(selectinload(EngagementDocument.versions)) .where( EngagementDocument.tenant_id == consultant.tenant_id, EngagementDocument.client_id == client.id, EngagementDocument.engagement_id == subscription.id, EngagementDocument.is_deleted.is_(False), ) .order_by(EngagementDocument.document_type.asc(), EngagementDocument.title.asc()) .limit(50) ).scalars().all() permanent_documents = db.execute( select(PermanentClientDocument) .options(selectinload(PermanentClientDocument.versions)) .where( PermanentClientDocument.tenant_id == consultant.tenant_id, PermanentClientDocument.client_id == client.id, PermanentClientDocument.is_deleted.is_(False), ) .order_by(PermanentClientDocument.category.asc(), PermanentClientDocument.title.asc()) .limit(50) ).scalars().all() return { "task": task, "subscription": subscription, "client": client, "catalogue": catalogue, "timeline": timeline, "engagement_documents": engagement_documents, "permanent_documents": permanent_documents, } def get_consultant_document_centre(db: Session, *, consultant: ConsultantProfile, q: str = "") -> dict: client_ids = _allowed_client_ids(db, consultant=consultant, require_communications=False) if not client_ids: return {"engagement_documents": [], "permanent_documents": [], "total": 0} engagement_query = ( select(EngagementDocument) .options(selectinload(EngagementDocument.client), selectinload(EngagementDocument.engagement), selectinload(EngagementDocument.versions)) .where( EngagementDocument.tenant_id == consultant.tenant_id, EngagementDocument.client_id.in_(client_ids), EngagementDocument.is_deleted.is_(False), ) ) permanent_query = ( select(PermanentClientDocument) .options(selectinload(PermanentClientDocument.client), selectinload(PermanentClientDocument.versions)) .where( PermanentClientDocument.tenant_id == consultant.tenant_id, PermanentClientDocument.client_id.in_(client_ids), PermanentClientDocument.is_deleted.is_(False), ) ) if (q or "").strip(): term = f"%{q.strip()}%" engagement_query = engagement_query.where( or_(EngagementDocument.title.ilike(term), EngagementDocument.document_type.ilike(term), EngagementDocument.document_code.ilike(term)) ) permanent_query = permanent_query.where( or_(PermanentClientDocument.title.ilike(term), PermanentClientDocument.category.ilike(term), PermanentClientDocument.document_code.ilike(term)) ) engagement_documents = db.execute( engagement_query.order_by(EngagementDocument.created_at_utc.desc(), EngagementDocument.id.desc()).limit(200) ).scalars().all() permanent_documents = db.execute( permanent_query.order_by(PermanentClientDocument.created_at_utc.desc(), PermanentClientDocument.id.desc()).limit(200) ).scalars().all() return { "engagement_documents": engagement_documents, "permanent_documents": permanent_documents, "total": len(engagement_documents) + len(permanent_documents), }