From e9db81b32f4972d7122b18f1f62dc975f2713863 Mon Sep 17 00:00:00 2001 From: A R R R Associates Date: Wed, 22 Jul 2026 23:55:52 +0530 Subject: [PATCH] Add phase 2 consultant visibility communications and document requests --- ...consultant_visibility_document_requests.py | 35 ++++ app/modules/consultants/portal_service.py | 154 +++++------------- .../consultants/assignment_detail.html | 10 +- .../templates/consultants/work_board.html | 2 +- app/modules/services/models.py | 44 +++++ app/modules/work_detail/service.py | 110 +++++++++---- .../work_detail/engagement_detail.html | 16 +- app/modules/work_detail/ui.py | 56 ++++++- 8 files changed, 278 insertions(+), 149 deletions(-) create mode 100644 alembic/versions/20260723_phase2_consultant_visibility_document_requests.py diff --git a/alembic/versions/20260723_phase2_consultant_visibility_document_requests.py b/alembic/versions/20260723_phase2_consultant_visibility_document_requests.py new file mode 100644 index 0000000..03b6dc2 --- /dev/null +++ b/alembic/versions/20260723_phase2_consultant_visibility_document_requests.py @@ -0,0 +1,35 @@ +"""Phase 2 consultant visibility, communications and document requests. + +Revision ID: 20260723_phase2_consultant_visibility_document_requests +Revises: 20260722_phase1_client_consultant_linkage +""" +from alembic import op +import sqlalchemy as sa +revision = "20260723_phase2_consultant_visibility_document_requests" +down_revision = "20260722_phase1_client_consultant_linkage" +branch_labels = None +depends_on = None + +def upgrade(): + op.create_table( + "service_task_document_requests", + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), + sa.Column("tenant_id", sa.Integer(), sa.ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False), + sa.Column("branch_id", sa.Integer(), sa.ForeignKey("branches.id"), nullable=True), + sa.Column("subscription_id", sa.Integer(), sa.ForeignKey("client_service_subscriptions.id", ondelete="CASCADE"), nullable=False), + sa.Column("task_instance_id", sa.Integer(), sa.ForeignKey("client_service_task_instances.id", ondelete="CASCADE"), nullable=False), + sa.Column("client_id", sa.Integer(), sa.ForeignKey("clients.id", ondelete="CASCADE"), nullable=False), + sa.Column("request_type", sa.String(30), nullable=False, server_default="document"), + sa.Column("title", sa.String(200), nullable=False), sa.Column("description", sa.Text(), nullable=True), + sa.Column("requested_from", sa.String(30), nullable=False, server_default="client_and_consultant"), + sa.Column("due_date", sa.Date(), nullable=True), sa.Column("status", sa.String(30), nullable=False, server_default="pending"), + sa.Column("response_note", sa.Text(), nullable=True), sa.Column("received_at_utc", sa.DateTime(timezone=True), nullable=True), sa.Column("verified_at_utc", sa.DateTime(timezone=True), nullable=True), + sa.Column("requested_by_user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True), + sa.Column("responded_by_user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True), + sa.Column("verified_by_user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True), + sa.Column("created_at_utc", sa.DateTime(timezone=True), nullable=False), sa.Column("updated_at_utc", sa.DateTime(timezone=True), nullable=False), sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.true()), + ) + for name, cols in [("ix_task_doc_requests_tenant",["tenant_id"]),("ix_task_doc_requests_subscription",["subscription_id"]),("ix_task_doc_requests_task",["task_instance_id"]),("ix_task_doc_requests_client",["client_id"]),("ix_task_doc_requests_status",["status"]),("ix_task_doc_requests_due",["due_date"])]: op.create_index(name,"service_task_document_requests",cols) + +def downgrade(): + op.drop_table("service_task_document_requests") diff --git a/app/modules/consultants/portal_service.py b/app/modules/consultants/portal_service.py index 77e3cf9..3c6f458 100644 --- a/app/modules/consultants/portal_service.py +++ b/app/modules/consultants/portal_service.py @@ -63,82 +63,28 @@ def _matches_search(*values: Any, q: str = "") -> bool: 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) + links = db.execute(select(ClientConsultantLink).where(ClientConsultantLink.tenant_id == consultant.tenant_id, ClientConsultantLink.consultant_id == consultant.id, ClientConsultantLink.is_active.is_(True), ClientConsultantLink.can_view_engagements.is_(True), ClientConsultantLink.can_view_task_status.is_(True))).scalars().all() + links_by_client: dict[int, list[ClientConsultantLink]] = {} + for link in links: + links_by_client.setdefault(int(link.client_id), []).append(link) 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, - } + items=[] + if links_by_client: + rows=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.tenant_id==consultant.tenant_id, ClientServiceTaskInstance.client_id.in_(list(links_by_client)), ClientServiceTaskInstance.is_active.is_(True)).order_by(ClientServiceTaskInstance.internal_target_date.asc(), ClientServiceTaskInstance.id.desc()).limit(500)).all() + for task, subscription, client, catalogue in rows: + allowed=next((ln for ln in links_by_client[int(client.id)] if ln.service_catalogue_id in (None, task.service_catalogue_id)),None) + if not allowed or not _matches_search(client.client_name, getattr(client,"client_code",""), catalogue.service_name, task.task_name, q=q): continue + key=_board_key_for_status(task.status) + if status and key!=status: continue + latest=db.execute(select(ServiceTaskComment).where(ServiceTaskComment.task_instance_id==task.id, ServiceTaskComment.visibility=="consultant", ServiceTaskComment.is_deleted.is_(False)).order_by(ServiceTaskComment.created_at_utc.desc()).limit(1)).scalars().first() + item={"comment":latest,"task":task,"subscription":subscription,"client":client,"catalogue":catalogue,"board_key":key,"link":allowed,"is_overdue":bool(task.internal_target_date and task.internal_target_date dict | None: - client_ids = _allowed_client_ids(db, consultant=consultant, require_communications=True) + client_ids = _allowed_client_ids(db, consultant=consultant, require_communications=False) if not client_ids: return None row = db.execute( @@ -156,6 +102,9 @@ def get_consultant_assignment_detail(db: Session, *, consultant: ConsultantProfi if not row: return None task, subscription, client, catalogue = row + link = db.execute(select(ClientConsultantLink).where(ClientConsultantLink.tenant_id == consultant.tenant_id, ClientConsultantLink.client_id == client.id, ClientConsultantLink.consultant_id == consultant.id, ClientConsultantLink.is_active.is_(True), ClientConsultantLink.can_view_engagements.is_(True), or_(ClientConsultantLink.service_catalogue_id.is_(None), ClientConsultantLink.service_catalogue_id == task.service_catalogue_id))).scalars().first() + if not link: + return None timeline = db.execute( select(ServiceTaskComment) .options(selectinload(ServiceTaskComment.created_by)) @@ -179,6 +128,8 @@ def get_consultant_assignment_detail(db: Session, *, consultant: ConsultantProfi .order_by(EngagementDocument.document_type.asc(), EngagementDocument.title.asc()) .limit(50) ).scalars().all() + if not (link.can_view_final_documents or link.can_upload_documents): + engagement_documents = [] permanent_documents = db.execute( select(PermanentClientDocument) .options(selectinload(PermanentClientDocument.versions)) @@ -190,7 +141,10 @@ def get_consultant_assignment_detail(db: Session, *, consultant: ConsultantProfi .order_by(PermanentClientDocument.category.asc(), PermanentClientDocument.title.asc()) .limit(50) ).scalars().all() + if not link.can_view_permanent_documents: + permanent_documents = [] return { + "link": link, "task": task, "subscription": subscription, "client": client, @@ -202,44 +156,18 @@ def get_consultant_assignment_detail(db: Session, *, consultant: ConsultantProfi 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), - } + links=db.execute(select(ClientConsultantLink).where(ClientConsultantLink.tenant_id==consultant.tenant_id,ClientConsultantLink.consultant_id==consultant.id,ClientConsultantLink.is_active.is_(True))).scalars().all() + engagement_ids={int(x.client_id) for x in links if x.can_view_final_documents or x.can_upload_documents} + permanent_ids={int(x.client_id) for x in links if x.can_view_permanent_documents} + engagement_documents=[]; permanent_documents=[] + if engagement_ids: + stmt=select(EngagementDocument).options(selectinload(EngagementDocument.client),selectinload(EngagementDocument.engagement),selectinload(EngagementDocument.versions)).where(EngagementDocument.tenant_id==consultant.tenant_id,EngagementDocument.client_id.in_(engagement_ids),EngagementDocument.is_deleted.is_(False)) + if q.strip(): + term=f"%{q.strip()}%"; stmt=stmt.where(or_(EngagementDocument.title.ilike(term),EngagementDocument.document_type.ilike(term),EngagementDocument.document_code.ilike(term))) + engagement_documents=db.execute(stmt.order_by(EngagementDocument.created_at_utc.desc()).limit(200)).scalars().all() + if permanent_ids: + stmt=select(PermanentClientDocument).options(selectinload(PermanentClientDocument.client),selectinload(PermanentClientDocument.versions)).where(PermanentClientDocument.tenant_id==consultant.tenant_id,PermanentClientDocument.client_id.in_(permanent_ids),PermanentClientDocument.is_deleted.is_(False)) + if q.strip(): + 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)} diff --git a/app/modules/consultants/templates/consultants/assignment_detail.html b/app/modules/consultants/templates/consultants/assignment_detail.html index d1d4cc5..a0faefb 100644 --- a/app/modules/consultants/templates/consultants/assignment_detail.html +++ b/app/modules/consultants/templates/consultants/assignment_detail.html @@ -15,7 +15,7 @@
Task Status
{{ task.status.replace('_',' ').title() }}
-
Priority
{{ task.priority.replace('_',' ').title() }}
+ {% if link.can_view_assignee and task.assigned_to %}
Assigned To
{{ task.assigned_to.full_name or task.assigned_to.email }}
{% endif %}
Priority
{{ task.priority.replace('_',' ').title() }}
Financial Year
{{ task.financial_year }}
Engagement Status
{{ subscription.status.replace('_',' ').title() }}
@@ -39,13 +39,13 @@
-
+ {% if link.can_reply_to_clarifications %}

Send Reply / Submit Update

{% if errors %}
{{ errors|join(' ') }}
{% endif %}
-
+ {% endif %} diff --git a/app/modules/consultants/templates/consultants/work_board.html b/app/modules/consultants/templates/consultants/work_board.html index 31d68a4..ad25bdc 100644 --- a/app/modules/consultants/templates/consultants/work_board.html +++ b/app/modules/consultants/templates/consultants/work_board.html @@ -48,7 +48,7 @@ {{ task.priority.replace('_',' ').title() }} {% if task.internal_target_date %}Due {{ task.internal_target_date.strftime('%d-%m-%Y') }}{% endif %} -
{{ item.comment.message }}
+ {% if item.comment %}
{{ item.comment.message }}
{% else %}
Visible through your active client/service link.
{% endif %} {% else %}
No items
diff --git a/app/modules/services/models.py b/app/modules/services/models.py index 3864fc9..b2068d8 100644 --- a/app/modules/services/models.py +++ b/app/modules/services/models.py @@ -732,6 +732,13 @@ class ClientServiceTaskInstance(CommonBase): viewonly=True, order_by="EngagementDocument.updated_at_utc.desc()", ) + document_requests = relationship( + "ServiceTaskDocumentRequest", + back_populates="task", + cascade="all, delete-orphan", + passive_deletes=True, + order_by="ServiceTaskDocumentRequest.created_at_utc.desc()", + ) comments = relationship( "ServiceTaskComment", back_populates="task", @@ -741,6 +748,43 @@ class ClientServiceTaskInstance(CommonBase): ) +class ServiceTaskDocumentRequest(CommonBase): + """Live document/clarification requirement raised against an engagement task.""" + + __tablename__ = "service_task_document_requests" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + tenant_id: Mapped[int] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True) + branch_id: Mapped[int | None] = mapped_column(ForeignKey("branches.id"), nullable=True, index=True) + subscription_id: Mapped[int] = mapped_column(ForeignKey("client_service_subscriptions.id", ondelete="CASCADE"), nullable=False, index=True) + task_instance_id: Mapped[int] = mapped_column(ForeignKey("client_service_task_instances.id", ondelete="CASCADE"), nullable=False, index=True) + client_id: Mapped[int] = mapped_column(ForeignKey("clients.id", ondelete="CASCADE"), nullable=False, index=True) + + request_type: Mapped[str] = mapped_column(String(30), nullable=False, default="document", index=True) + title: Mapped[str] = mapped_column(String(200), nullable=False) + description: Mapped[str | None] = mapped_column(Text, nullable=True) + requested_from: Mapped[str] = mapped_column(String(30), nullable=False, default="client_and_consultant", index=True) + due_date: Mapped[date | None] = mapped_column(Date, nullable=True, index=True) + status: Mapped[str] = mapped_column(String(30), nullable=False, default="pending", index=True) + response_note: Mapped[str | None] = mapped_column(Text, nullable=True) + received_at_utc: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + verified_at_utc: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + + requested_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True) + responded_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True) + verified_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True) + created_at_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False) + updated_at_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc), nullable=False) + is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, index=True) + + task = relationship("ClientServiceTaskInstance", back_populates="document_requests") + subscription = relationship("ClientServiceSubscription") + client = relationship("Client") + requested_by = relationship("User", foreign_keys=[requested_by_user_id]) + responded_by = relationship("User", foreign_keys=[responded_by_user_id]) + verified_by = relationship("User", foreign_keys=[verified_by_user_id]) + + class ServiceTaskComment(CommonBase): """Communication timeline entry linked to a service task instance.""" diff --git a/app/modules/work_detail/service.py b/app/modules/work_detail/service.py index 33b813c..8cbfec0 100644 --- a/app/modules/work_detail/service.py +++ b/app/modules/work_detail/service.py @@ -24,6 +24,7 @@ from app.modules.services.models import ( ClientServiceSubscription, ClientServiceTaskInstance, ServiceTaskComment, + ServiceTaskDocumentRequest, ) MANAGEMENT_ROLES = {"System Admin", "Firm Admin"} @@ -42,6 +43,12 @@ class WorkAccess: allowed_comment_types: list[tuple[str, str]] allowed_visibilities: list[tuple[str, str]] back_url: str + can_view_assignee: bool = True + can_view_document_requests: bool = True + can_create_document_requests: bool = False + can_update_document_requests: bool = False + can_view_engagement_documents: bool = True + can_view_permanent_documents: bool = True def _roles(db: Session, user) -> set[str]: @@ -72,31 +79,24 @@ def _current_consultant(db: Session, user) -> ConsultantProfile | None: ).scalar_one_or_none() -def _consultant_can_view_engagement(db: Session, *, consultant: ConsultantProfile, engagement: ClientServiceSubscription) -> bool: - linked = db.execute( - select(ClientConsultantLink.id).where( +def _consultant_link_for_engagement(db: Session, *, consultant: ConsultantProfile, engagement: ClientServiceSubscription) -> ClientConsultantLink | None: + today = date.today() + return db.execute( + select(ClientConsultantLink).where( ClientConsultantLink.tenant_id == consultant.tenant_id, ClientConsultantLink.client_id == engagement.client_id, ClientConsultantLink.consultant_id == consultant.id, ClientConsultantLink.is_active.is_(True), - ClientConsultantLink.can_view_communications.is_(True), + ClientConsultantLink.can_view_engagements.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), ) - ).first() - if not linked: - return False - visible_comment = db.execute( - select(ServiceTaskComment.id) - .join(ClientServiceTaskInstance, ClientServiceTaskInstance.id == ServiceTaskComment.task_instance_id) - .where( - ServiceTaskComment.tenant_id == consultant.tenant_id, - ServiceTaskComment.subscription_id == engagement.id, - ServiceTaskComment.visibility == "consultant", - ServiceTaskComment.is_deleted.is_(False), - ClientServiceTaskInstance.is_active.is_(True), - ) - .limit(1) - ).first() - return bool(visible_comment) + ).scalars().first() + + +def _consultant_can_view_engagement(db: Session, *, consultant: ConsultantProfile, engagement: ClientServiceSubscription) -> bool: + return _consultant_link_for_engagement(db, consultant=consultant, engagement=engagement) is not None def _is_assigned_staff(engagement: ClientServiceSubscription, tasks: list[ClientServiceTaskInstance], user) -> bool: @@ -148,7 +148,7 @@ def _load_tasks(db: Session, engagement: ClientServiceSubscription) -> list[Clie ).scalars().all() -def _load_documents(db: Session, engagement: ClientServiceSubscription) -> tuple[list[EngagementDocument], list[PermanentClientDocument]]: +def _load_documents(db: Session, engagement: ClientServiceSubscription, access: WorkAccess) -> tuple[list[EngagementDocument], list[PermanentClientDocument]]: engagement_documents = db.execute( select(EngagementDocument) .options(selectinload(EngagementDocument.versions)) @@ -160,6 +160,8 @@ def _load_documents(db: Session, engagement: ClientServiceSubscription) -> tuple ) .order_by(EngagementDocument.document_type.asc(), EngagementDocument.title.asc()) ).unique().scalars().all() + if not access.can_view_engagement_documents: + engagement_documents = [] permanent_documents = db.execute( select(PermanentClientDocument) .options(selectinload(PermanentClientDocument.versions)) @@ -171,6 +173,8 @@ def _load_documents(db: Session, engagement: ClientServiceSubscription) -> tuple .order_by(PermanentClientDocument.category.asc(), PermanentClientDocument.title.asc()) .limit(50) ).unique().scalars().all() + if not access.can_view_permanent_documents: + permanent_documents = [] return engagement_documents, permanent_documents @@ -194,7 +198,7 @@ def _load_timeline(db: Session, engagement: ClientServiceSubscription, access: W def _build_access(db: Session, *, user, engagement: ClientServiceSubscription, tasks: list[ClientServiceTaskInstance]) -> WorkAccess | None: roles = _roles(db, user) if roles.intersection(MANAGEMENT_ROLES): - return WorkAccess("admin", True, True, TASK_COMMENT_TYPES, TASK_COMMENT_VISIBILITIES, "/services/work-tracker") + return WorkAccess("admin", True, True, TASK_COMMENT_TYPES, TASK_COMMENT_VISIBILITIES, "/services/work-tracker", can_create_document_requests=True, can_update_document_requests=True) if roles.intersection(CLIENT_ROLES): client_row = _current_client_row(db, user) @@ -204,20 +208,30 @@ def _build_access(db: Session, *, user, engagement: ClientServiceSubscription, t if roles.intersection(CONSULTANT_ROLES): consultant = _current_consultant(db, user) - if consultant and _consultant_can_view_engagement(db, consultant=consultant, engagement=engagement): - return WorkAccess("consultant", False, True, [("consultant_clarification", "Consultant Clarification")], [("consultant", "Consultant")], "/consultant/work") + if consultant: + link = _consultant_link_for_engagement(db, consultant=consultant, engagement=engagement) + if link: + return WorkAccess( + "consultant", False, bool(link.can_reply_to_clarifications), + [("consultant_clarification", "Consultant Clarification")], [("consultant", "Consultant")], "/consultant/work", + can_view_assignee=bool(link.can_view_assignee), + can_view_document_requests=bool(link.can_view_document_requests), + can_update_document_requests=bool(link.can_reply_to_clarifications or link.can_act_for_client), + can_view_engagement_documents=bool(link.can_view_final_documents or link.can_upload_documents), + can_view_permanent_documents=bool(link.can_view_permanent_documents), + ) return None if roles.intersection(PARTNER_ROLES) and _is_partner_for_engagement(engagement, user): - return WorkAccess("partner", True, True, TASK_COMMENT_TYPES, TASK_COMMENT_VISIBILITIES, "/partner/reviews") + return WorkAccess("partner", True, True, TASK_COMMENT_TYPES, TASK_COMMENT_VISIBILITIES, "/partner/reviews", can_create_document_requests=True, can_update_document_requests=True) if roles.intersection(MANAGER_ROLES): if _is_manager_for_engagement(engagement, user) or (engagement.tenant_id == user.tenant_id and (engagement.branch_id in (None, user.branch_id))): - return WorkAccess("manager", True, True, TASK_COMMENT_TYPES, TASK_COMMENT_VISIBILITIES, "/manager/work") + return WorkAccess("manager", True, True, TASK_COMMENT_TYPES, TASK_COMMENT_VISIBILITIES, "/manager/work", can_create_document_requests=True, can_update_document_requests=True) if roles.intersection(STAFF_ROLES) or roles.intersection({"Employee"}): if _is_assigned_staff(engagement, tasks, user): - return WorkAccess("staff", True, True, [("internal_note", "Internal Note"), ("client_clarification", "Client Clarification")], [("internal", "Internal"), ("client", "Client")], "/employee/work") + return WorkAccess("staff", True, True, [("internal_note", "Internal Note"), ("client_clarification", "Client Clarification")], [("internal", "Internal"), ("client", "Client")], "/employee/work", can_create_document_requests=True, can_update_document_requests=True) return None @@ -235,7 +249,13 @@ def load_unified_engagement_detail(db: Session, *, request, user, engagement_id: if not access: return None - engagement_documents, permanent_documents = _load_documents(db, engagement) + engagement_documents, permanent_documents = _load_documents(db, engagement, access) + document_requests = db.execute( + select(ServiceTaskDocumentRequest) + .options(selectinload(ServiceTaskDocumentRequest.task), selectinload(ServiceTaskDocumentRequest.requested_by), selectinload(ServiceTaskDocumentRequest.responded_by)) + .where(ServiceTaskDocumentRequest.tenant_id == engagement.tenant_id, ServiceTaskDocumentRequest.subscription_id == engagement.id, ServiceTaskDocumentRequest.is_active.is_(True)) + .order_by(ServiceTaskDocumentRequest.status.asc(), ServiceTaskDocumentRequest.due_date.asc(), ServiceTaskDocumentRequest.id.desc()) + ).scalars().all() if access.can_view_document_requests else [] timeline = _load_timeline(db, engagement, access) today = date.today() for task in tasks: @@ -256,6 +276,7 @@ def load_unified_engagement_detail(db: Session, *, request, user, engagement_id: "engagement_documents": engagement_documents, "permanent_documents": permanent_documents, "timeline": timeline, + "document_requests": document_requests, "access": access, "role_context": access.role_context, "task_statuses": TASK_STATUSES, @@ -303,3 +324,36 @@ def save_task_comment(db: Session, *, task: ClientServiceTaskInstance, access: W clean_visibility = visibility if visibility in allowed_visibilities else next(iter(allowed_visibilities), "internal") row = add_task_comment(db, task=task, comment_type=clean_type, visibility=clean_visibility, message=message, user_id=user_id) return row is not None + + +def create_document_request(db: Session, *, task: ClientServiceTaskInstance, access: WorkAccess, title: str, description: str, requested_from: str, due_date: date | None, request_type: str, user_id: int) -> ServiceTaskDocumentRequest | None: + if not access.can_create_document_requests or not (title or "").strip(): + return None + allowed_from = {"client", "consultant", "client_and_consultant"} + allowed_types = {"document", "clarification", "approval", "information"} + row = ServiceTaskDocumentRequest(tenant_id=task.tenant_id, branch_id=task.branch_id, subscription_id=task.subscription_id, task_instance_id=task.id, client_id=task.client_id, request_type=request_type if request_type in allowed_types else "document", title=title.strip(), description=(description or "").strip() or None, requested_from=requested_from if requested_from in allowed_from else "client_and_consultant", due_date=due_date, status="pending", requested_by_user_id=user_id) + db.add(row) + visibility = "consultant" if row.requested_from == "consultant" else "client" + if row.requested_from == "client_and_consultant": + add_task_comment(db, task=task, comment_type="document_request", visibility="client", message=f"{row.title}: {row.description or ''}".strip(), user_id=user_id) + add_task_comment(db, task=task, comment_type="document_request", visibility="consultant", message=f"{row.title}: {row.description or ''}".strip(), user_id=user_id) + else: + add_task_comment(db, task=task, comment_type="document_request", visibility=visibility, message=f"{row.title}: {row.description or ''}".strip(), user_id=user_id) + return row + + +def update_document_request(db: Session, *, request_row: ServiceTaskDocumentRequest, access: WorkAccess, status: str, response_note: str, user_id: int) -> bool: + if not access.can_update_document_requests: + return False + allowed = {"pending", "received", "clarification_required", "verified", "rejected", "closed"} + clean = status if status in allowed else request_row.status + request_row.status = clean + request_row.response_note = (response_note or "").strip() or request_row.response_note + request_row.responded_by_user_id = user_id + now = datetime.now(timezone.utc) + if clean in {"received", "verified", "closed"} and not request_row.received_at_utc: + request_row.received_at_utc = now + if clean in {"verified", "closed"}: + request_row.verified_at_utc = now + request_row.verified_by_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 0d3b553..32aaee3 100644 --- a/app/modules/work_detail/templates/work_detail/engagement_detail.html +++ b/app/modules/work_detail/templates/work_detail/engagement_detail.html @@ -41,6 +41,17 @@ + {% if access.can_view_document_requests %} +
+

Document & Clarification Requests

Live requirements for this engagement.

+
+ {% for req in document_requests %} +
{{ req.title }}
{{ req.request_type.replace('_',' ').title() }} • From {{ req.requested_from.replace('_',' ').title() }}{% if req.due_date %} • Due {{ req.due_date.strftime('%d-%m-%Y') }}{% endif %}
{% if req.description %}
{{ req.description }}
{% endif %}
{{ req.status.replace('_',' ').title() }}
+ {% if access.can_update_document_requests %}
{% endif %} +
{% else %}
No pending document or clarification requests.
{% endfor %} +
+
+ {% endif %}
@@ -59,7 +70,7 @@ {{ task.status_label }} {{ task.priority_label }} {% if task.internal_target_date %}Target {{ task.internal_target_date.strftime('%d-%m-%Y') }}{% endif %} - {% if task.assigned_to %}Assigned: {{ task.assigned_to.full_name or task.assigned_to.email }}{% endif %} + {% if access.can_view_assignee and task.assigned_to %}Assigned: {{ task.assigned_to.full_name or task.assigned_to.email }}{% endif %}
{% if access.can_update_tasks %} @@ -90,6 +101,9 @@
+ {% if access.can_create_document_requests %} +
Create document / clarification request
+ {% endif %} {% endif %} {% else %} diff --git a/app/modules/work_detail/ui.py b/app/modules/work_detail/ui.py index f0fae27..61ff040 100644 --- a/app/modules/work_detail/ui.py +++ b/app/modules/work_detail/ui.py @@ -1,5 +1,7 @@ from __future__ import annotations +from datetime import date + from fastapi import APIRouter, Form, Request from fastapi.responses import RedirectResponse from app.core.http_responses import forbidden_response, not_found_response @@ -9,7 +11,8 @@ from app.core.security.csrf import get_or_create_csrf_token, validate_csrf 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.work_detail.service import get_task_for_action, load_unified_engagement_detail, save_task_comment, save_task_status +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 router = APIRouter(prefix="/work", tags=["unified-work-detail-ui"]) @@ -167,3 +170,54 @@ async def unified_task_comment_add( raise finally: db.close() + + +@router.post("/tasks/{task_id}/document-requests") +async def unified_document_request_add(request: Request, task_id: int, csrf_token: str = Form(...), title: str = Form(...), description: str = Form(""), requested_from: str = Form("client_and_consultant"), due_date: str = Form(""), request_type: str = Form("document")): + 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_due = date.fromisoformat(due_date) if due_date else None + row = create_document_request(db, task=task, access=access, title=title, description=description, requested_from=requested_from, due_date=parsed_due, request_type=request_type, user_id=user.id) + if not row: + return RedirectResponse(url=f"/work/engagements/{task.subscription_id}?error=request_not_allowed", status_code=303) + db.commit() + return RedirectResponse(url=f"/work/engagements/{task.subscription_id}?request=created", status_code=303) + except Exception: + db.rollback(); raise + finally: + db.close() + + +@router.post("/document-requests/{request_id}/status") +async def unified_document_request_status(request: Request, request_id: int, csrf_token: str = Form(...), status: str = Form(...), response_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) + row = db.get(ServiceTaskDocumentRequest, request_id) + if not row or not row.is_active: + return not_found_response(request, "Document request not found") + task, access = get_task_for_action(db, user=user, task_id=row.task_instance_id) + if not task or not access or not update_document_request(db, request_row=row, access=access, status=status, response_note=response_note, user_id=user.id): + return RedirectResponse(url=_fallback_for_user(db, user), status_code=303) + db.commit() + return RedirectResponse(url=f"/work/engagements/{row.subscription_id}?request=updated", status_code=303) + except Exception: + db.rollback(); raise + finally: + db.close()