Add phase 2 consultant visibility communications and document requests

This commit is contained in:
A R R R Associates
2026-07-22 23:55:52 +05:30
parent 8f51d16c7d
commit e9db81b32f
8 changed files with 278 additions and 149 deletions
+41 -113
View File
@@ -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<date.today())}
columns[key]["items"].append(item); 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()).limit(50)).scalars().all()
return {"columns":columns,"column_options":list(CONSULTANT_BOARD_COLUMNS.items()),"total_tasks":len(items),"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)
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)}
@@ -15,7 +15,7 @@
<div class="rounded-2xl border border-slate-200 bg-white p-5 shadow-soft">
<div class="grid gap-4 md:grid-cols-4">
<div><div class="text-xs uppercase tracking-wide text-slate-500">Task Status</div><div class="mt-1 font-semibold text-slate-900">{{ task.status.replace('_',' ').title() }}</div></div>
<div><div class="text-xs uppercase tracking-wide text-slate-500">Priority</div><div class="mt-1 font-semibold text-slate-900">{{ task.priority.replace('_',' ').title() }}</div></div>
{% if link.can_view_assignee and task.assigned_to %}<div><div class="text-xs uppercase tracking-wide text-slate-500">Assigned To</div><div class="mt-1 font-semibold text-slate-900">{{ task.assigned_to.full_name or task.assigned_to.email }}</div></div>{% endif %}<div><div class="text-xs uppercase tracking-wide text-slate-500">Priority</div><div class="mt-1 font-semibold text-slate-900">{{ task.priority.replace('_',' ').title() }}</div></div>
<div><div class="text-xs uppercase tracking-wide text-slate-500">Financial Year</div><div class="mt-1 font-semibold text-slate-900">{{ task.financial_year }}</div></div>
<div><div class="text-xs uppercase tracking-wide text-slate-500">Engagement Status</div><div class="mt-1 font-semibold text-slate-900">{{ subscription.status.replace('_',' ').title() }}</div></div>
</div>
@@ -39,13 +39,13 @@
</div>
</div>
<form method="post" action="/consultant/assignments/{{ task.id }}/reply" class="rounded-2xl border border-slate-200 bg-white p-5 shadow-soft">
{% if link.can_reply_to_clarifications %}<form method="post" action="/consultant/assignments/{{ task.id }}/reply" class="rounded-2xl border border-slate-200 bg-white p-5 shadow-soft">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<h3 class="font-semibold text-slate-900">Send Reply / Submit Update</h3>
{% if errors %}<div class="mt-3 rounded-xl border border-red-200 bg-red-50 p-3 text-sm text-red-700">{{ errors|join(' ') }}</div>{% endif %}
<textarea name="message" rows="5" required placeholder="Type clarification reply, submission note, or work update" class="mt-4 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm"></textarea>
<div class="mt-4 flex justify-end"><button class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white hover:bg-brand-700">Send Update</button></div>
</form>
</form>{% endif %}
</div>
<aside class="space-y-6">
@@ -61,7 +61,7 @@
{% else %}<div class="p-4 text-sm text-slate-500">No shared engagement documents found.</div>{% endfor %}
</div>
</div>
<div class="rounded-2xl border border-slate-200 bg-white shadow-soft">
{% if link.can_view_permanent_documents %}<div class="rounded-2xl border border-slate-200 bg-white shadow-soft">
<div class="border-b border-slate-200 px-4 py-3"><h3 class="font-semibold text-slate-900">Permanent Documents</h3></div>
<div class="divide-y divide-slate-100">
{% for doc in permanent_documents %}
@@ -71,7 +71,7 @@
</div>
{% else %}<div class="p-4 text-sm text-slate-500">No shared permanent documents found.</div>{% endfor %}
</div>
</div>
</div>{% endif %}
</aside>
</div>
</div>
@@ -48,7 +48,7 @@
<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>
<div class="mt-3 line-clamp-3 rounded-xl bg-slate-50 p-2 text-xs text-slate-600">{{ item.comment.message }}</div>
{% if item.comment %}<div class="mt-3 line-clamp-3 rounded-xl bg-slate-50 p-2 text-xs text-slate-600">{{ item.comment.message }}</div>{% else %}<div class="mt-3 rounded-xl bg-blue-50 p-2 text-xs text-blue-700">Visible through your active client/service link.</div>{% endif %}
</a>
{% else %}
<div class="rounded-xl border border-dashed border-slate-300 bg-white p-4 text-center text-xs text-slate-500">No items</div>