Add phase 2 consultant visibility communications and document requests
This commit is contained in:
@@ -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")
|
||||
@@ -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>
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -41,6 +41,17 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if access.can_view_document_requests %}
|
||||
<div class="rounded-3xl 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">Document & Clarification Requests</h3><p class="text-xs text-slate-500">Live requirements for this engagement.</p></div>
|
||||
<div class="divide-y divide-slate-100">
|
||||
{% for req in document_requests %}
|
||||
<div class="p-5"><div class="flex flex-wrap justify-between gap-3"><div><div class="font-semibold text-slate-900">{{ req.title }}</div><div class="text-xs text-slate-500">{{ req.request_type.replace('_',' ').title() }} • From {{ req.requested_from.replace('_',' ').title() }}{% if req.due_date %} • Due {{ req.due_date.strftime('%d-%m-%Y') }}{% endif %}</div>{% if req.description %}<div class="mt-2 text-sm text-slate-600">{{ req.description }}</div>{% endif %}</div><span class="h-fit rounded-full bg-slate-100 px-3 py-1 text-xs font-semibold text-slate-700">{{ req.status.replace('_',' ').title() }}</span></div>
|
||||
{% if access.can_update_document_requests %}<form method="post" action="/work/document-requests/{{ req.id }}/status" class="mt-3 grid gap-2 md:grid-cols-[auto_1fr_auto]"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><select name="status" class="rounded-xl border border-slate-300 px-3 py-2 text-sm"><option value="pending">Pending</option><option value="received">Received</option><option value="clarification_required">Clarification Required</option><option value="verified">Verified</option><option value="rejected">Rejected</option><option value="closed">Closed</option></select><input name="response_note" value="{{ req.response_note or '' }}" placeholder="Response / verification note" class="rounded-xl border border-slate-300 px-3 py-2 text-sm"><button class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-semibold">Update</button></form>{% endif %}
|
||||
</div>{% else %}<div class="p-5 text-sm text-slate-500">No pending document or clarification requests.</div>{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="grid gap-6 xl:grid-cols-[minmax(0,1fr)_360px]">
|
||||
<div class="space-y-4">
|
||||
<div class="rounded-3xl border border-slate-200 bg-white shadow-soft">
|
||||
@@ -59,7 +70,7 @@
|
||||
<span class="rounded-full bg-slate-100 px-2 py-1 text-slate-700">{{ task.status_label }}</span>
|
||||
<span class="rounded-full bg-slate-100 px-2 py-1 text-slate-700">{{ task.priority_label }}</span>
|
||||
{% if task.internal_target_date %}<span class="rounded-full {% if task.is_overdue_display %}bg-red-50 text-red-700{% else %}bg-slate-100 text-slate-700{% endif %} px-2 py-1">Target {{ task.internal_target_date.strftime('%d-%m-%Y') }}</span>{% endif %}
|
||||
{% if task.assigned_to %}<span class="rounded-full bg-slate-100 px-2 py-1 text-slate-700">Assigned: {{ task.assigned_to.full_name or task.assigned_to.email }}</span>{% endif %}
|
||||
{% if access.can_view_assignee and task.assigned_to %}<span class="rounded-full bg-slate-100 px-2 py-1 text-slate-700">Assigned: {{ task.assigned_to.full_name or task.assigned_to.email }}</span>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% if access.can_update_tasks %}
|
||||
@@ -90,6 +101,9 @@
|
||||
<button class="rounded-xl border border-slate-300 bg-white px-4 py-2 text-xs font-semibold text-slate-700 hover:bg-slate-50">Add</button>
|
||||
</div>
|
||||
</form>
|
||||
{% if access.can_create_document_requests %}
|
||||
<details class="mt-3 rounded-2xl border border-dashed border-slate-300 p-3"><summary class="cursor-pointer text-xs font-semibold text-slate-700">Create document / clarification request</summary><form method="post" action="/work/tasks/{{ task.id }}/document-requests" class="mt-3 grid gap-2"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><div class="grid gap-2 md:grid-cols-3"><input name="title" required placeholder="Requirement title" class="rounded-xl border border-slate-300 px-3 py-2 text-sm"><select name="request_type" class="rounded-xl border border-slate-300 px-3 py-2 text-sm"><option value="document">Document</option><option value="clarification">Clarification</option><option value="approval">Approval</option><option value="information">Information</option></select><select name="requested_from" class="rounded-xl border border-slate-300 px-3 py-2 text-sm"><option value="client_and_consultant">Client and Consultant</option><option value="client">Client</option><option value="consultant">Consultant</option></select></div><div class="grid gap-2 md:grid-cols-[1fr_auto_auto]"><input name="description" placeholder="Description" class="rounded-xl border border-slate-300 px-3 py-2 text-sm"><input type="date" name="due_date" class="rounded-xl border border-slate-300 px-3 py-2 text-sm"><button class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white">Create Request</button></div></form></details>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% else %}
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user