409 lines
19 KiB
Python
409 lines
19 KiB
Python
from __future__ import annotations
|
|
|
|
from collections import defaultdict
|
|
from datetime import date, datetime, timezone
|
|
from typing import Any
|
|
|
|
from fastapi import APIRouter, Form, Request
|
|
from fastapi.responses import RedirectResponse
|
|
from sqlalchemy import or_, select
|
|
from sqlalchemy.orm import Session, selectinload
|
|
|
|
from app.core.db.common import CommonSessionLocal
|
|
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.documents.models import EngagementDocument
|
|
from app.modules.clients.models import Client
|
|
from app.modules.services.execution import CLOSED_TASK_STATUSES, TASK_PRIORITIES, TASK_STATUSES
|
|
from app.modules.services.models import (
|
|
ClientServiceSubscription,
|
|
ClientServiceTaskInstance,
|
|
ServiceCatalogue,
|
|
ServiceTaskComment,
|
|
)
|
|
|
|
router = APIRouter(prefix="/partner", tags=["partner-workspace-ui"])
|
|
|
|
REVIEW_ACTIONS = {
|
|
"approve": ("completed", "partner_review", "partner_review"),
|
|
"send_rework": ("pending", "partner_review_note", "partner_review"),
|
|
"clarification": ("blocked", "client_clarification", "internal"),
|
|
}
|
|
|
|
|
|
def _redirect_login():
|
|
return RedirectResponse(url="/login", status_code=303)
|
|
|
|
|
|
def _redirect_denied():
|
|
return RedirectResponse(url="/employee/dashboard", status_code=303)
|
|
|
|
|
|
def _is_partner_user(db: Session, current_user) -> bool:
|
|
roles = set(get_user_roles(db, current_user.id))
|
|
return bool(roles.intersection({"Partner", "Firm Admin", "System Admin"}))
|
|
|
|
|
|
def _base_ctx(request: Request, db: Session, current_user, **ctx):
|
|
base = {
|
|
"request": request,
|
|
"current_user": current_user,
|
|
"current_user_roles": get_user_roles(db, current_user.id),
|
|
"current_user_permissions": get_user_permissions(db, current_user.id),
|
|
"csrf_token": get_or_create_csrf_token(request),
|
|
"task_statuses": TASK_STATUSES,
|
|
"task_priorities": TASK_PRIORITIES,
|
|
}
|
|
base.update(ctx)
|
|
return base
|
|
|
|
|
|
def _render(request: Request, template_name: str, db: Session, current_user, **ctx):
|
|
return templates.TemplateResponse(template_name, _base_ctx(request, db, current_user, **ctx))
|
|
|
|
|
|
def _active_tenant_branch(request: Request, current_user, roles: set[str]) -> tuple[int, int | None]:
|
|
tenant_id = request.session.get("active_tenant_id") or current_user.tenant_id
|
|
branch_id = request.session.get("active_branch_id")
|
|
if "System Admin" not in roles:
|
|
tenant_id = current_user.tenant_id
|
|
if not roles.intersection({"System Admin", "Firm Admin"}):
|
|
branch_id = current_user.branch_id
|
|
if branch_id in (0, "0", "", None):
|
|
branch_id = None
|
|
return int(tenant_id), int(branch_id) if branch_id is not None else None
|
|
|
|
|
|
|
|
|
|
def _active_financial_year(request: Request) -> str | None:
|
|
value = request.session.get("active_financial_year") or getattr(request.state, "year_code", None)
|
|
value = (value or "").strip()
|
|
return value or None
|
|
|
|
def _subscription_scope_filter(stmt, tenant_id: int, branch_id: int | None, current_user, roles: set[str], financial_year: str | None = None):
|
|
stmt = stmt.where(ClientServiceSubscription.tenant_id == tenant_id, ClientServiceSubscription.is_active.is_(True))
|
|
if branch_id is not None:
|
|
stmt = stmt.where(ClientServiceSubscription.branch_id == branch_id)
|
|
if financial_year:
|
|
stmt = stmt.where(ClientServiceSubscription.financial_year == financial_year.strip())
|
|
if not roles.intersection({"System Admin", "Firm Admin"}):
|
|
stmt = stmt.where(
|
|
or_(
|
|
ClientServiceSubscription.assigned_partner_user_id == current_user.id,
|
|
ClientServiceSubscription.review_partner_user_id == current_user.id,
|
|
)
|
|
)
|
|
return stmt
|
|
|
|
|
|
def _task_scope_filter(stmt, tenant_id: int, branch_id: int | None, current_user, roles: set[str], financial_year: str | None = None):
|
|
stmt = stmt.where(ClientServiceTaskInstance.tenant_id == tenant_id, ClientServiceTaskInstance.is_active.is_(True))
|
|
if branch_id is not None:
|
|
stmt = stmt.where(ClientServiceTaskInstance.branch_id == branch_id)
|
|
if financial_year:
|
|
stmt = stmt.where(ClientServiceTaskInstance.financial_year == financial_year.strip())
|
|
if not roles.intersection({"System Admin", "Firm Admin"}):
|
|
stmt = stmt.where(
|
|
ClientServiceTaskInstance.subscription.has(
|
|
or_(
|
|
ClientServiceSubscription.assigned_partner_user_id == current_user.id,
|
|
ClientServiceSubscription.review_partner_user_id == current_user.id,
|
|
)
|
|
)
|
|
)
|
|
return stmt
|
|
|
|
|
|
def _task_status_label(task: ClientServiceTaskInstance) -> str:
|
|
return dict(TASK_STATUSES).get(getattr(task, "status", ""), (getattr(task, "status", "") or "-").replace("_", " ").title())
|
|
|
|
|
|
def _task_priority_label(task: ClientServiceTaskInstance) -> str:
|
|
return dict(TASK_PRIORITIES).get(getattr(task, "priority", ""), (getattr(task, "priority", "") or "normal").replace("_", " ").title())
|
|
|
|
|
|
def _engagement_label(subscription: ClientServiceSubscription | None, task: ClientServiceTaskInstance | None = None) -> str:
|
|
catalogue = getattr(subscription, "catalogue", None) if subscription else getattr(task, "catalogue", None)
|
|
service_name = getattr(catalogue, "service_name", None) or getattr(catalogue, "name", None) or "Engagement"
|
|
fy = getattr(subscription, "financial_year", None) or getattr(task, "financial_year", None)
|
|
return f"{service_name} · FY {fy}" if fy else str(service_name)
|
|
|
|
|
|
def _decorate_task(task: ClientServiceTaskInstance, today: date) -> ClientServiceTaskInstance:
|
|
client = getattr(task, "client", None)
|
|
assignee = getattr(task, "assigned_to", None)
|
|
subscription = getattr(task, "subscription", None)
|
|
target = getattr(task, "internal_target_date", None)
|
|
status = (task.status or "pending").strip().lower()
|
|
is_closed = status in CLOSED_TASK_STATUSES
|
|
task.status_label = _task_status_label(task)
|
|
task.priority_label = _task_priority_label(task)
|
|
task.client_display = getattr(client, "client_name", None) or "Unlinked Client"
|
|
task.client_code_display = getattr(client, "client_code", None) or ""
|
|
task.assignee_display = getattr(assignee, "full_name", None) or getattr(assignee, "email", None) or "Unassigned"
|
|
task.engagement_label = _engagement_label(subscription, task)
|
|
task.is_overdue = bool(target and target < today and not is_closed)
|
|
task.is_due_today = bool(target and target == today and not is_closed)
|
|
task.comment_count = len([c for c in getattr(task, "comments", []) if not getattr(c, "is_deleted", False)])
|
|
return task
|
|
|
|
|
|
def _task_bucket(task: ClientServiceTaskInstance) -> str:
|
|
status = (task.status or "pending").strip().lower()
|
|
if status == "blocked":
|
|
return "clarification_required"
|
|
if status == "completed":
|
|
return "pending_review"
|
|
if status in CLOSED_TASK_STATUSES:
|
|
return "completed"
|
|
if status in {"pending", "rework", "rework_required"}:
|
|
return "rework_sent"
|
|
return "approved"
|
|
|
|
|
|
def build_partner_payload(db: Session, request: Request, current_user, *, q: str = "") -> dict[str, Any]:
|
|
roles = set(get_user_roles(db, current_user.id))
|
|
tenant_id, branch_id = _active_tenant_branch(request, current_user, roles)
|
|
financial_year = _active_financial_year(request)
|
|
today = date.today()
|
|
|
|
task_stmt = (
|
|
select(ClientServiceTaskInstance)
|
|
.options(
|
|
selectinload(ClientServiceTaskInstance.client),
|
|
selectinload(ClientServiceTaskInstance.catalogue),
|
|
selectinload(ClientServiceTaskInstance.subscription).selectinload(ClientServiceSubscription.catalogue),
|
|
selectinload(ClientServiceTaskInstance.subscription).selectinload(ClientServiceSubscription.assigned_manager),
|
|
selectinload(ClientServiceTaskInstance.subscription).selectinload(ClientServiceSubscription.assigned_staff),
|
|
selectinload(ClientServiceTaskInstance.assigned_to),
|
|
selectinload(ClientServiceTaskInstance.comments).selectinload(ServiceTaskComment.created_by),
|
|
)
|
|
)
|
|
task_stmt = _task_scope_filter(task_stmt, tenant_id, branch_id, current_user, roles, financial_year=financial_year)
|
|
if q.strip():
|
|
like = f"%{q.strip()}%"
|
|
task_stmt = task_stmt.where(
|
|
or_(
|
|
ClientServiceTaskInstance.task_name.ilike(like),
|
|
ClientServiceTaskInstance.description.ilike(like),
|
|
ClientServiceTaskInstance.client.has(or_(Client.client_name.ilike(like), Client.client_code.ilike(like))),
|
|
ClientServiceTaskInstance.catalogue.has(or_(ServiceCatalogue.service_name.ilike(like), ServiceCatalogue.service_code.ilike(like))),
|
|
)
|
|
)
|
|
tasks = db.execute(
|
|
task_stmt.order_by(
|
|
ClientServiceTaskInstance.internal_target_date.is_(None),
|
|
ClientServiceTaskInstance.internal_target_date.asc(),
|
|
ClientServiceTaskInstance.priority.desc(),
|
|
ClientServiceTaskInstance.id.desc(),
|
|
)
|
|
).scalars().all()
|
|
|
|
columns = [
|
|
{"code": "pending_review", "label": "Pending Review", "hint": "Completed tasks waiting for partner review", "tasks": []},
|
|
{"code": "clarification_required", "label": "Clarification Required", "hint": "Blocked tasks needing partner attention", "tasks": []},
|
|
{"code": "rework_sent", "label": "Rework Sent", "hint": "Pending/reopened after review notes", "tasks": []},
|
|
{"code": "approved", "label": "In Progress", "hint": "Work currently moving with the team", "tasks": []},
|
|
{"code": "completed", "label": "Completed", "hint": "Closed tasks", "tasks": []},
|
|
]
|
|
lookup = {c["code"]: c for c in columns}
|
|
summary = {"total": len(tasks), "pending_review": 0, "blocked": 0, "overdue": 0, "due_today": 0, "completed": 0, "clients": set(), "engagements": set()}
|
|
|
|
for task in tasks:
|
|
_decorate_task(task, today)
|
|
status = (task.status or "pending").lower()
|
|
if getattr(task, "client_id", None):
|
|
summary["clients"].add(task.client_id)
|
|
if getattr(task, "subscription_id", None):
|
|
summary["engagements"].add(task.subscription_id)
|
|
if status == "completed":
|
|
summary["pending_review"] += 1
|
|
if status == "blocked":
|
|
summary["blocked"] += 1
|
|
if getattr(task, "is_overdue", False):
|
|
summary["overdue"] += 1
|
|
if getattr(task, "is_due_today", False):
|
|
summary["due_today"] += 1
|
|
if status in CLOSED_TASK_STATUSES:
|
|
summary["completed"] += 1
|
|
lookup[_task_bucket(task)]["tasks"].append(task)
|
|
|
|
summary["clients"] = len(summary["clients"])
|
|
summary["engagements"] = len(summary["engagements"])
|
|
for col in columns:
|
|
col["count"] = len(col["tasks"])
|
|
|
|
engagement_stmt = (
|
|
select(ClientServiceSubscription)
|
|
.options(
|
|
selectinload(ClientServiceSubscription.client),
|
|
selectinload(ClientServiceSubscription.catalogue),
|
|
selectinload(ClientServiceSubscription.assigned_manager),
|
|
selectinload(ClientServiceSubscription.assigned_staff),
|
|
)
|
|
)
|
|
engagement_stmt = _subscription_scope_filter(engagement_stmt, tenant_id, branch_id, current_user, roles, financial_year=financial_year)
|
|
engagements = db.execute(
|
|
engagement_stmt.order_by(ClientServiceSubscription.current_due_date.is_(None), ClientServiceSubscription.current_due_date.asc(), ClientServiceSubscription.id.desc()).limit(25)
|
|
).scalars().all()
|
|
|
|
task_counts_by_subscription: dict[int, dict[str, int]] = defaultdict(lambda: {"total": 0, "completed": 0, "open": 0})
|
|
for task in tasks:
|
|
bucket = task_counts_by_subscription[int(task.subscription_id)]
|
|
bucket["total"] += 1
|
|
if (task.status or "").lower() in CLOSED_TASK_STATUSES:
|
|
bucket["completed"] += 1
|
|
else:
|
|
bucket["open"] += 1
|
|
|
|
for engagement in engagements:
|
|
engagement.display_label = _engagement_label(engagement)
|
|
engagement.client_display = getattr(getattr(engagement, "client", None), "client_name", None) or "Unlinked Client"
|
|
engagement.task_counts = task_counts_by_subscription.get(int(engagement.id), {"total": 0, "completed": 0, "open": 0})
|
|
due = getattr(engagement, "current_due_date", None)
|
|
engagement.is_overdue = bool(due and due < today and (engagement.status or "").lower() not in {"completed", "closed", "locked"})
|
|
|
|
return {"summary": summary, "columns": columns, "engagements": engagements, "q": q, "today": today, "financial_year": financial_year}
|
|
|
|
|
|
def _get_partner_task_or_redirect(db: Session, request: Request, current_user, task_id: int) -> ClientServiceTaskInstance | None:
|
|
roles = set(get_user_roles(db, current_user.id))
|
|
tenant_id, branch_id = _active_tenant_branch(request, current_user, roles)
|
|
financial_year = _active_financial_year(request)
|
|
stmt = select(ClientServiceTaskInstance).options(selectinload(ClientServiceTaskInstance.subscription)).where(ClientServiceTaskInstance.id == int(task_id))
|
|
stmt = _task_scope_filter(stmt, tenant_id, branch_id, current_user, roles, financial_year=financial_year)
|
|
return db.execute(stmt).scalar_one_or_none()
|
|
|
|
|
|
@router.get("/dashboard")
|
|
def partner_dashboard(request: Request, q: str = ""):
|
|
db = CommonSessionLocal()
|
|
try:
|
|
current_user = get_current_user(request, db=db)
|
|
if not current_user:
|
|
return _redirect_login()
|
|
if not _is_partner_user(db, current_user):
|
|
return _redirect_denied()
|
|
payload = build_partner_payload(db, request, current_user, q=q)
|
|
return _render(request, "modules/partners/templates/partners/dashboard.html", db, current_user, title="Partner Workspace", payload=payload, q=q, errors=[])
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
@router.get("/reviews")
|
|
def partner_review_board(request: Request, q: str = ""):
|
|
db = CommonSessionLocal()
|
|
try:
|
|
current_user = get_current_user(request, db=db)
|
|
if not current_user:
|
|
return _redirect_login()
|
|
if not _is_partner_user(db, current_user):
|
|
return _redirect_denied()
|
|
payload = build_partner_payload(db, request, current_user, q=q)
|
|
return _render(request, "modules/partners/templates/partners/review_board.html", db, current_user, title="Partner Review Board", payload=payload, q=q, errors=[])
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
@router.get("/clients")
|
|
def partner_clients(request: Request, q: str = ""):
|
|
db = CommonSessionLocal()
|
|
try:
|
|
current_user = get_current_user(request, db=db)
|
|
if not current_user:
|
|
return _redirect_login()
|
|
if not _is_partner_user(db, current_user):
|
|
return _redirect_denied()
|
|
payload = build_partner_payload(db, request, current_user, q=q)
|
|
return _render(request, "modules/partners/templates/partners/clients.html", db, current_user, title="My Client Portfolio", payload=payload, q=q, errors=[])
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
@router.get("/engagements/{engagement_id}")
|
|
def partner_engagement_detail(request: Request, engagement_id: int):
|
|
db = CommonSessionLocal()
|
|
try:
|
|
current_user = get_current_user(request, db=db)
|
|
if not current_user:
|
|
return _redirect_login()
|
|
if not _is_partner_user(db, current_user):
|
|
return _redirect_denied()
|
|
roles = set(get_user_roles(db, current_user.id))
|
|
tenant_id, branch_id = _active_tenant_branch(request, current_user, roles)
|
|
financial_year = _active_financial_year(request)
|
|
engagement_stmt = select(ClientServiceSubscription).options(
|
|
selectinload(ClientServiceSubscription.client),
|
|
selectinload(ClientServiceSubscription.catalogue),
|
|
selectinload(ClientServiceSubscription.assigned_manager),
|
|
selectinload(ClientServiceSubscription.assigned_staff),
|
|
).where(ClientServiceSubscription.id == int(engagement_id))
|
|
engagement_stmt = _subscription_scope_filter(engagement_stmt, tenant_id, branch_id, current_user, roles, financial_year=financial_year)
|
|
engagement = db.execute(engagement_stmt).scalar_one_or_none()
|
|
if not engagement:
|
|
return _redirect_denied()
|
|
|
|
task_stmt = select(ClientServiceTaskInstance).options(
|
|
selectinload(ClientServiceTaskInstance.client),
|
|
selectinload(ClientServiceTaskInstance.catalogue),
|
|
selectinload(ClientServiceTaskInstance.subscription).selectinload(ClientServiceSubscription.catalogue),
|
|
selectinload(ClientServiceTaskInstance.assigned_to),
|
|
selectinload(ClientServiceTaskInstance.comments).selectinload(ServiceTaskComment.created_by),
|
|
).where(ClientServiceTaskInstance.subscription_id == engagement.id, ClientServiceTaskInstance.is_active.is_(True))
|
|
tasks = db.execute(task_stmt.order_by(ClientServiceTaskInstance.sequence_no.asc(), ClientServiceTaskInstance.id.asc())).scalars().all()
|
|
today = date.today()
|
|
for task in tasks:
|
|
_decorate_task(task, today)
|
|
documents = db.execute(
|
|
select(EngagementDocument)
|
|
.where(EngagementDocument.engagement_id == engagement.id, EngagementDocument.is_deleted.is_(False))
|
|
.order_by(EngagementDocument.updated_at_utc.desc(), EngagementDocument.id.desc())
|
|
).scalars().all()
|
|
engagement.display_label = _engagement_label(engagement)
|
|
return _render(request, "modules/partners/templates/partners/engagement_detail.html", db, current_user, title="Partner Engagement Review", engagement=engagement, tasks=tasks, documents=documents, errors=[], financial_year=financial_year)
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
@router.post("/tasks/{task_id}/review")
|
|
def partner_review_task(request: Request, task_id: int, action: str = Form(...), message: str = Form(""), csrf_token: str = Form(...)):
|
|
db = CommonSessionLocal()
|
|
try:
|
|
validate_csrf(request, csrf_token)
|
|
current_user = get_current_user(request, db=db)
|
|
if not current_user:
|
|
return _redirect_login()
|
|
if not _is_partner_user(db, current_user):
|
|
return _redirect_denied()
|
|
task = _get_partner_task_or_redirect(db, request, current_user, task_id)
|
|
if not task:
|
|
return _redirect_denied()
|
|
new_status, comment_type, visibility = REVIEW_ACTIONS.get(action, REVIEW_ACTIONS["approve"])
|
|
task.status = new_status
|
|
task.updated_by_user_id = current_user.id
|
|
task.updated_at_utc = datetime.now(timezone.utc)
|
|
note = (message or "").strip()
|
|
if not note:
|
|
note = {
|
|
"approve": "Approved by partner.",
|
|
"send_rework": "Sent back for rework by partner.",
|
|
"clarification": "Clarification requested by partner.",
|
|
}.get(action, "Partner review updated.")
|
|
db.add(ServiceTaskComment(
|
|
tenant_id=task.tenant_id,
|
|
branch_id=task.branch_id,
|
|
subscription_id=task.subscription_id,
|
|
task_instance_id=task.id,
|
|
comment_type=comment_type,
|
|
visibility=visibility,
|
|
message=note,
|
|
created_by_user_id=current_user.id,
|
|
))
|
|
db.commit()
|
|
return RedirectResponse(url=f"/partner/engagements/{task.subscription_id}", status_code=303)
|
|
finally:
|
|
db.close()
|