Prepare ERP source for Gitea deployment

This commit is contained in:
A R R R Associates
2026-06-20 15:01:44 +05:30
commit 5c75eb6bd9
450 changed files with 67698 additions and 0 deletions
View File
@@ -0,0 +1,12 @@
{% set path = request.url.path %}
<div class="mb-6 overflow-x-auto rounded-2xl border border-slate-200 bg-white p-2 shadow-soft">
<div class="flex min-w-max gap-2 text-sm font-medium">
<a href="/partner/dashboard" class="rounded-xl px-4 py-2 transition {% if path == '/partner/dashboard' %}bg-brand-600 text-white{% else %}text-slate-700 hover:bg-slate-100{% endif %}">Overview</a>
<a href="/partner/reviews" class="rounded-xl px-4 py-2 transition {% if path.startswith('/partner/reviews') %}bg-brand-600 text-white{% else %}text-slate-700 hover:bg-slate-100{% endif %}">Review Board</a>
<a href="/partner/clients" class="rounded-xl px-4 py-2 transition {% if path.startswith('/partner/clients') %}bg-brand-600 text-white{% else %}text-slate-700 hover:bg-slate-100{% endif %}">My Clients</a>
<a href="/services/engagements" class="rounded-xl px-4 py-2 text-slate-700 transition hover:bg-slate-100">Engagements</a>
<a href="/documents" class="rounded-xl px-4 py-2 text-slate-700 transition hover:bg-slate-100">Documents</a>
<a href="/billing" class="rounded-xl px-4 py-2 text-slate-700 transition hover:bg-slate-100">Billing</a>
<a href="/alerts" class="rounded-xl px-4 py-2 transition {% if path.startswith('/alerts') %}bg-brand-600 text-white{% else %}text-slate-700 hover:bg-slate-100{% endif %}">My Alert</a>
</div>
</div>
@@ -0,0 +1,32 @@
{% extends "ui/templates/base/layout.html" %}
{% block content %}
{% include "modules/partners/templates/partners/_partner_tabs.html" %}
<div class="space-y-6">
<div class="rounded-3xl border border-slate-200 bg-white p-5 shadow-soft">
<div class="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<div><h2 class="text-xl font-semibold">My Client Portfolio</h2><p class="text-sm text-slate-500">Client engagements assigned to you as engagement partner or review partner.</p></div>
<form method="get" class="flex gap-2"><input name="q" value="{{ q or '' }}" placeholder="Search" class="w-72 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">Search</button></form>
</div>
</div>
<div class="grid gap-4 lg:grid-cols-2 xl:grid-cols-3">
{% for engagement in payload.engagements %}
<a href="/partner/engagements/{{ engagement.id }}" class="rounded-3xl border border-slate-200 bg-white p-5 shadow-soft transition hover:border-brand-300 hover:bg-brand-50/30">
<div class="flex items-start justify-between gap-3">
<div><h3 class="font-semibold text-slate-900">{{ engagement.client_display }}</h3><p class="mt-1 text-sm text-slate-500">{{ engagement.display_label }}</p></div>
<span class="rounded-full bg-slate-100 px-2.5 py-1 text-xs font-semibold text-slate-700">{{ engagement.status|replace('_',' ')|title }}</span>
</div>
<div class="mt-4 grid grid-cols-3 gap-2 text-center text-xs">
<div class="rounded-2xl bg-slate-50 p-3"><div class="text-lg font-semibold">{{ engagement.task_counts.total }}</div><div class="text-slate-500">Tasks</div></div>
<div class="rounded-2xl bg-slate-50 p-3"><div class="text-lg font-semibold">{{ engagement.task_counts.open }}</div><div class="text-slate-500">Open</div></div>
<div class="rounded-2xl bg-slate-50 p-3"><div class="text-lg font-semibold">{{ engagement.task_counts.completed }}</div><div class="text-slate-500">Done</div></div>
</div>
<div class="mt-4 text-xs text-slate-500">{% if engagement.current_due_date %}Due: {{ engagement.current_due_date }}{% else %}No due date configured{% endif %}{% if engagement.is_overdue %}<span class="ml-2 rounded-full bg-red-100 px-2 py-1 font-semibold text-red-700">Overdue</span>{% endif %}</div>
</a>
{% else %}
<div class="rounded-3xl border border-dashed border-slate-300 bg-white p-8 text-center text-sm text-slate-500 xl:col-span-3">No assigned client engagements found.</div>
{% endfor %}
</div>
</div>
{% endblock %}
@@ -0,0 +1,88 @@
{% extends "ui/templates/base/layout.html" %}
{% block content %}
{% include "modules/partners/templates/partners/_partner_tabs.html" %}
<div class="space-y-6">
<section class="rounded-3xl bg-gradient-to-r from-brand-700 to-slate-900 p-6 text-white shadow-soft">
<div class="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div>
<p class="text-sm uppercase tracking-[0.22em] text-brand-100">Partner Workspace</p>
<h2 class="mt-2 text-2xl font-semibold">Review, control and escalation dashboard</h2>
<p class="mt-2 max-w-3xl text-sm text-brand-100">Track assigned client engagements, pending reviews, blocked work, overdue items and documents requiring attention.</p>
{% if current_user_roles and ('Manager' in current_user_roles or 'Branch Manager' in current_user_roles or 'Firm Admin' in current_user_roles) %}
<p class="mt-3 inline-flex rounded-full bg-white/10 px-3 py-1 text-xs font-semibold text-white">Role-aware view: this partner login also carries team/administration responsibility.</p>
{% endif %}
</div>
<div class="flex flex-wrap gap-2">
<a href="/partner/reviews" class="rounded-xl bg-white px-4 py-2 text-sm font-semibold text-brand-700 hover:bg-brand-50">Open Review Board</a>
{% if current_user_roles and ('Manager' in current_user_roles or 'Branch Manager' in current_user_roles) %}<a href="/manager/dashboard" class="rounded-xl border border-white/40 px-4 py-2 text-sm font-semibold text-white hover:bg-white/10">Team Workspace</a>{% endif %}
</div>
</div>
</section>
<section class="grid gap-4 md:grid-cols-3 xl:grid-cols-6">
<a href="/partner/reviews" class="af-metric-card hover:border-brand-200"><div class="text-xs font-semibold uppercase text-slate-500">Tasks</div><div class="mt-2 text-3xl font-semibold">{{ payload.summary.total }}</div><div class="mt-1 text-xs text-slate-500">Visible portfolio</div></a>
<a href="/partner/reviews?status=pending_review" class="af-metric-card border-amber-200 bg-amber-50 hover:border-amber-300"><div class="text-xs font-semibold uppercase text-amber-700">Pending Review</div><div class="mt-2 text-3xl font-semibold text-amber-700">{{ payload.summary.pending_review }}</div><div class="mt-1 text-xs text-amber-700">Needs partner action</div></a>
<a href="/partner/reviews?status=blocked" class="af-metric-card border-red-200 bg-red-50 hover:border-red-300"><div class="text-xs font-semibold uppercase text-red-700">Blocked</div><div class="mt-2 text-3xl font-semibold text-red-700">{{ payload.summary.blocked }}</div><div class="mt-1 text-xs text-red-700">Escalation</div></a>
<a href="/partner/reviews?date_bucket=overdue" class="af-metric-card border-red-200 bg-red-50 hover:border-red-300"><div class="text-xs font-semibold uppercase text-red-700">Overdue</div><div class="mt-2 text-3xl font-semibold text-red-700">{{ payload.summary.overdue }}</div><div class="mt-1 text-xs text-red-700">High risk</div></a>
<a href="/partner/clients" class="af-metric-card hover:border-brand-200"><div class="text-xs font-semibold uppercase text-slate-500">Clients</div><div class="mt-2 text-3xl font-semibold">{{ payload.summary.clients }}</div><div class="mt-1 text-xs text-slate-500">Assigned clients</div></a>
<a href="/partner/reviews" class="af-metric-card hover:border-brand-200"><div class="text-xs font-semibold uppercase text-slate-500">Engagements</div><div class="mt-2 text-3xl font-semibold">{{ payload.summary.engagements }}</div><div class="mt-1 text-xs text-slate-500">Active portfolio</div></a>
</section>
<section class="grid gap-6 xl:grid-cols-[minmax(0,1.2fr)_420px]">
<div class="af-card">
<div class="flex items-center justify-between gap-3">
<div><h3 class="text-lg font-semibold text-slate-900">Attention Required</h3><p class="text-sm text-slate-500">Items that need partner review, decision or intervention.</p></div>
<a href="/partner/reviews" class="af-btn af-btn-primary">Open Review Board</a>
</div>
<div class="mt-5 space-y-3">
{% set shown = namespace(count=0) %}
{% for column in payload.columns %}
{% if column.code in ['pending_review', 'clarification_required', 'blocked'] %}
{% for task in column.tasks[:5] %}
{% set shown.count = shown.count + 1 %}
<a href="/partner/engagements/{{ task.subscription_id }}" class="block rounded-2xl border border-slate-200 p-4 transition hover:border-brand-300 hover:bg-brand-50/40">
<div class="flex items-start justify-between gap-3">
<div><div class="font-semibold text-slate-900">{{ task.task_name }}</div><div class="mt-1 text-sm text-slate-500">{{ task.client_display }} · {{ task.engagement_label }}</div></div>
<span class="af-badge {% if column.code == 'blocked' %}af-badge-danger{% elif column.code == 'pending_review' %}af-badge-warning{% else %}af-badge-info{% endif %}">{{ column.label }}</span>
</div>
<div class="mt-2 text-xs text-slate-500">Assigned to {{ task.assignee_display }}{% if task.internal_target_date %} · Target {{ task.internal_target_date }}{% endif %}</div>
</a>
{% endfor %}
{% endif %}
{% endfor %}
{% if shown.count == 0 %}<div class="rounded-2xl border border-dashed border-slate-300 p-8 text-center text-sm text-slate-500">No pending partner attention items found.</div>{% endif %}
</div>
</div>
<aside class="space-y-6">
<div class="af-card">
<div class="flex items-center justify-between gap-3"><div><h3 class="text-lg font-semibold text-slate-900">Client Portfolio</h3><p class="text-sm text-slate-500">Recent assigned engagements.</p></div><a href="/partner/clients" class="text-sm font-semibold text-brand-700 hover:underline">View all</a></div>
<div class="mt-4 space-y-3">
{% for engagement in payload.engagements[:6] %}
<a href="/partner/engagements/{{ engagement.id }}" class="block rounded-2xl border border-slate-200 p-4 hover:bg-slate-50">
<div class="font-semibold text-slate-900">{{ engagement.client_display }}</div>
<div class="mt-1 text-sm text-slate-500">{{ engagement.display_label }}</div>
<div class="mt-2 flex flex-wrap gap-2 text-xs"><span class="rounded-full bg-slate-100 px-2 py-1">Open {{ engagement.task_counts.open }}</span><span class="rounded-full bg-slate-100 px-2 py-1">Done {{ engagement.task_counts.completed }}</span>{% if engagement.is_overdue %}<span class="rounded-full bg-red-100 px-2 py-1 text-red-700">Overdue</span>{% endif %}</div>
</a>
{% else %}
<div class="rounded-2xl border border-dashed border-slate-300 p-6 text-center text-sm text-slate-500">No partner engagements found.</div>
{% endfor %}
</div>
</div>
{% if current_user_roles and ('Manager' in current_user_roles or 'Branch Manager' in current_user_roles or 'Firm Admin' in current_user_roles) %}
<div class="af-card border-brand-200 bg-brand-50/60">
<h3 class="font-semibold text-slate-900">Small Firm / Multi-role Actions</h3>
<p class="mt-1 text-sm text-slate-600">Use these when the partner is also handling manager-level follow-up.</p>
<div class="mt-4 grid gap-2 text-sm">
<a href="/manager/work" class="rounded-xl border border-brand-200 bg-white px-4 py-3 font-semibold text-brand-700 hover:bg-brand-50">Team Work Board</a>
<a href="/employees/progress" class="rounded-xl border border-brand-200 bg-white px-4 py-3 font-semibold text-brand-700 hover:bg-brand-50">Engagement Progress</a>
<a href="/employees/attendance" class="rounded-xl border border-brand-200 bg-white px-4 py-3 font-semibold text-brand-700 hover:bg-brand-50">Team Attendance</a>
</div>
</div>
{% endif %}
</aside>
</section>
</div>
{% endblock %}
@@ -0,0 +1,56 @@
{% extends "ui/templates/base/layout.html" %}
{% block content %}
{% include "modules/partners/templates/partners/_partner_tabs.html" %}
<div class="space-y-6">
<div class="rounded-3xl border border-slate-200 bg-white p-5 shadow-soft">
<div class="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div>
<p class="text-sm uppercase tracking-[0.18em] text-slate-400">Engagement Review</p>
<h2 class="mt-1 text-2xl font-semibold">{{ engagement.client.client_name if engagement.client else 'Client' }}</h2>
<p class="mt-1 text-sm text-slate-500">{{ engagement.display_label }}{% if engagement.current_due_date %} · Due {{ engagement.current_due_date }}{% endif %}</p>
</div>
<div class="flex flex-wrap gap-2"><a href="/documents?engagement_id={{ engagement.id }}" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Open Documents</a><a href="/services/engagements" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">All Engagements</a></div>
</div>
</div>
<div class="grid gap-6 xl:grid-cols-[minmax(0,1fr)_360px]">
<section class="space-y-4">
{% for task in tasks %}
<div class="rounded-3xl border border-slate-200 bg-white p-5 shadow-soft">
<div class="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div><h3 class="font-semibold text-slate-900">{{ loop.index }}. {{ task.task_name }}</h3><p class="mt-1 text-sm text-slate-500">{{ task.description or 'No description.' }}</p><div class="mt-3 flex flex-wrap gap-2 text-xs"><span class="rounded-full bg-slate-100 px-2.5 py-1">{{ task.status_label }}</span><span class="rounded-full bg-slate-100 px-2.5 py-1">{{ task.priority_label }}</span><span class="rounded-full bg-slate-100 px-2.5 py-1">Assigned: {{ task.assignee_display }}</span>{% if task.is_overdue %}<span class="rounded-full bg-red-100 px-2.5 py-1 font-semibold text-red-700">Overdue</span>{% endif %}</div></div>
<a href="/employees/work/tasks/{{ task.id }}/communication" class="rounded-xl border border-slate-300 px-3 py-1.5 text-xs font-semibold text-slate-700 hover:bg-slate-50">Communication</a>
</div>
<form method="post" action="/partner/tasks/{{ task.id }}/review" class="mt-4 grid gap-3 rounded-2xl bg-slate-50 p-4 lg:grid-cols-[180px_minmax(0,1fr)_auto]">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<select name="action" class="rounded-xl border border-slate-300 px-3 py-2 text-sm"><option value="approve">Approve</option><option value="send_rework">Send for Rework</option><option value="clarification">Request Clarification</option></select>
<input name="message" placeholder="Optional partner note" 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 hover:bg-brand-700">Submit</button>
</form>
{% if task.comments %}
<div class="mt-4 space-y-2">
{% for comment in task.comments[:3] %}
<div class="rounded-2xl border border-slate-200 p-3 text-sm"><div class="text-xs text-slate-500">{{ comment.comment_type|replace('_',' ')|title }} · {{ comment.created_at_utc }}</div><div class="mt-1 text-slate-700">{{ comment.message }}</div></div>
{% endfor %}
</div>
{% endif %}
</div>
{% else %}
<div class="rounded-3xl border border-dashed border-slate-300 bg-white p-8 text-center text-sm text-slate-500">No tasks found for this engagement.</div>
{% endfor %}
</section>
<aside class="rounded-3xl border border-slate-200 bg-white p-5 shadow-soft">
<div class="flex items-center justify-between gap-3"><div><h3 class="font-semibold">Documents</h3><p class="text-sm text-slate-500">Related engagement documents.</p></div><span class="rounded-full bg-slate-100 px-2.5 py-1 text-xs font-semibold">{{ documents|length }}</span></div>
<div class="mt-4 space-y-3">
{% for doc in documents %}
<div class="rounded-2xl border border-slate-200 p-4"><div class="font-medium text-slate-900">{{ doc.title }}</div><div class="mt-1 text-xs text-slate-500">{{ doc.document_type }} · v{{ doc.current_version_no }} · {{ doc.status|title }}</div></div>
{% else %}
<div class="rounded-2xl border border-dashed border-slate-300 p-6 text-center text-sm text-slate-500">No documents uploaded yet.</div>
{% endfor %}
</div>
</aside>
</div>
</div>
{% endblock %}
@@ -0,0 +1,31 @@
{% extends "ui/templates/base/layout.html" %}
{% block content %}
{% include "modules/partners/templates/partners/_partner_tabs.html" %}
<div class="space-y-6">
<div class="flex flex-col gap-3 rounded-3xl border border-slate-200 bg-white p-5 shadow-soft lg:flex-row lg:items-center lg:justify-between">
<div><h2 class="text-xl font-semibold">Partner Review Board</h2><p class="text-sm text-slate-500">Review completed work, blocked tasks, rework and in-progress engagement tasks.</p></div>
<form method="get" class="flex gap-2"><input name="q" value="{{ q or '' }}" placeholder="Search client, task or service" class="w-72 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">Search</button></form>
</div>
<div class="grid gap-4 xl:grid-cols-5">
{% for column in payload.columns %}
<section class="min-h-[420px] rounded-3xl border border-slate-200 bg-white p-4 shadow-soft">
<div class="mb-4 flex items-start justify-between gap-3"><div><h3 class="font-semibold">{{ column.label }}</h3><p class="text-xs text-slate-500">{{ column.hint }}</p></div><span class="rounded-full bg-slate-100 px-2.5 py-1 text-xs font-semibold">{{ column.count }}</span></div>
<div class="space-y-3">
{% for task in column.tasks %}
<div class="rounded-2xl border border-slate-200 p-4">
<div class="flex items-start justify-between gap-3"><div><a href="/work/engagements/{{ task.subscription_id }}" class="font-semibold text-slate-900 hover:text-brand-700">{{ task.task_name }}</a><div class="mt-1 text-xs text-slate-500">{{ task.client_display }}</div></div>{% if task.is_overdue %}<span class="rounded-full bg-red-100 px-2 py-1 text-[11px] font-semibold text-red-700">Overdue</span>{% endif %}</div>
<div class="mt-2 text-xs text-slate-500">{{ task.engagement_label }}</div>
<div class="mt-3 flex flex-wrap gap-2 text-[11px]"><span class="rounded-full bg-slate-100 px-2 py-1">{{ task.status_label }}</span><span class="rounded-full bg-slate-100 px-2 py-1">{{ task.priority_label }}</span><span class="rounded-full bg-slate-100 px-2 py-1">{{ task.assignee_display }}</span></div>
<a href="/work/engagements/{{ task.subscription_id }}" class="mt-3 inline-flex rounded-xl border border-slate-300 px-3 py-1.5 text-xs font-semibold text-slate-700 hover:bg-slate-50">Open Review Details</a>
</div>
{% else %}
<div class="rounded-2xl border border-dashed border-slate-300 p-6 text-center text-xs text-slate-500">No items.</div>
{% endfor %}
</div>
</section>
{% endfor %}
</div>
</div>
{% endblock %}
+408
View File
@@ -0,0 +1,408 @@
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()