Add controlled client subscription team reassignment

This commit is contained in:
A R R R Associates
2026-08-05 15:09:32 +05:30
parent 956560850d
commit 6a6ca2a4a7
3 changed files with 577 additions and 35 deletions
+433 -35
View File
@@ -1,22 +1,41 @@
from __future__ import annotations
from fastapi import APIRouter, Request
from datetime import date, datetime, timezone
from fastapi import APIRouter, Form, Request
from fastapi.responses import RedirectResponse
from sqlalchemy import func, select
from sqlalchemy import func, or_, select
from sqlalchemy.orm import selectinload
from app.core.db.common import CommonSessionLocal
from app.core.security.csrf import get_or_create_csrf_token
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.core.rbac.permission_guard import require_permission
from app.modules.services.models import ClientServicePlan, ClientServiceSubscription
from app.modules.services.client_services import (
SUBSCRIPTION_STATUSES,
list_assignable_users,
list_review_partners,
parse_date,
)
from app.modules.services.models import (
ClientServicePlan,
ClientServiceSubscription,
ClientServiceTaskInstance,
)
router = APIRouter(prefix="/services/subscriptions", tags=["client-service-subscriptions-ui"])
def _tenant_id(request, user):
return int(request.session.get("active_tenant_id") or request.session.get("selected_tenant_id") or request.session.get("tenant_id") or user.tenant_id)
return int(
request.session.get("active_tenant_id")
or request.session.get("selected_tenant_id")
or request.session.get("tenant_id")
or user.tenant_id
)
def _branch_id(request, user):
value = request.session.get("active_branch_id")
@@ -24,47 +43,426 @@ def _branch_id(request, user):
return int(getattr(user, "branch_id", 0) or 0) or None
return int(value)
def _ctx(request, db, user, **extra):
data={"request":request,"current_user":user,"current_user_roles":get_user_roles(db,user.id),"current_user_permissions":get_user_permissions(db,user.id),"csrf_token":get_or_create_csrf_token(request)}
data.update(extra); return data
data = {
"request": request,
"current_user": user,
"current_user_roles": get_user_roles(db, user.id),
"current_user_permissions": get_user_permissions(db, user.id),
"csrf_token": get_or_create_csrf_token(request),
"subscription_statuses": SUBSCRIPTION_STATUSES,
}
data.update(extra)
return data
def _denied():
from app.core.http_responses import ui_access_denied
return ui_access_denied()
def _load_plan(db, *, plan_id: int, tenant_id: int, branch_id: int | None):
stmt = (
select(ClientServicePlan)
.options(
selectinload(ClientServicePlan.client),
selectinload(ClientServicePlan.catalogue),
selectinload(ClientServicePlan.default_partner),
selectinload(ClientServicePlan.default_performing_partner),
selectinload(ClientServicePlan.default_manager),
selectinload(ClientServicePlan.default_staff),
selectinload(ClientServicePlan.default_review_partner),
)
.where(
ClientServicePlan.id == plan_id,
ClientServicePlan.tenant_id == tenant_id,
)
)
if branch_id:
stmt = stmt.where(ClientServicePlan.branch_id == branch_id)
return db.execute(stmt).scalar_one_or_none()
def _optional_int(value: str) -> int | None:
value = (value or "").strip()
return int(value) if value else None
def _allowed_id(value: int | None, allowed: set[int], field_name: str) -> int | None:
if value is not None and value not in allowed:
raise ValueError(field_name)
return value
def _engagement_is_open(row: ClientServiceSubscription) -> bool:
return (
not bool(getattr(row, "is_locked", False))
and (getattr(row, "status", "") or "").lower() not in {"completed", "cancelled", "inactive"}
)
def _engagement_is_effective(row: ClientServiceSubscription, effective_from: date | None) -> bool:
if not effective_from:
return True
comparison_date = (
getattr(row, "start_date", None)
or getattr(row, "current_due_date", None)
or getattr(row, "original_due_date", None)
)
return comparison_date is None or comparison_date >= effective_from
@router.get("")
def subscription_master_list(request: Request, q: str = "", include_inactive: bool = False):
db=CommonSessionLocal()
db = CommonSessionLocal()
try:
user=get_current_user(request, db=db)
if not user: return RedirectResponse("/login",303)
try: require_permission(db,user,"clients.view")
user = get_current_user(request, db=db)
if not user:
return RedirectResponse("/login", 303)
try:
require_permission(db, user, "clients.view")
except Exception:
from app.core.http_responses import ui_access_denied
return ui_access_denied()
tenant_id=_tenant_id(request,user); branch_id=_branch_id(request,user)
counts=(select(ClientServiceSubscription.service_plan_id, func.count(ClientServiceSubscription.id).label("engagement_count"), func.max(ClientServiceSubscription.current_due_date).label("latest_due_date")).where(ClientServiceSubscription.service_plan_id.is_not(None)).group_by(ClientServiceSubscription.service_plan_id).subquery())
stmt=(select(ClientServicePlan, counts.c.engagement_count, counts.c.latest_due_date).options(selectinload(ClientServicePlan.client),selectinload(ClientServicePlan.catalogue),selectinload(ClientServicePlan.default_partner),selectinload(ClientServicePlan.default_performing_partner),selectinload(ClientServicePlan.default_manager),selectinload(ClientServicePlan.default_staff),selectinload(ClientServicePlan.default_review_partner)).outerjoin(counts,counts.c.service_plan_id==ClientServicePlan.id).where(ClientServicePlan.tenant_id==tenant_id))
if branch_id: stmt=stmt.where(ClientServicePlan.branch_id==branch_id)
if not include_inactive: stmt=stmt.where(ClientServicePlan.is_active.is_(True))
return _denied()
tenant_id = _tenant_id(request, user)
branch_id = _branch_id(request, user)
counts = (
select(
ClientServiceSubscription.service_plan_id,
func.count(ClientServiceSubscription.id).label("engagement_count"),
func.max(ClientServiceSubscription.current_due_date).label("latest_due_date"),
)
.where(ClientServiceSubscription.service_plan_id.is_not(None))
.group_by(ClientServiceSubscription.service_plan_id)
.subquery()
)
stmt = (
select(ClientServicePlan, counts.c.engagement_count, counts.c.latest_due_date)
.options(
selectinload(ClientServicePlan.client),
selectinload(ClientServicePlan.catalogue),
selectinload(ClientServicePlan.default_partner),
selectinload(ClientServicePlan.default_performing_partner),
selectinload(ClientServicePlan.default_manager),
selectinload(ClientServicePlan.default_staff),
selectinload(ClientServicePlan.default_review_partner),
)
.outerjoin(counts, counts.c.service_plan_id == ClientServicePlan.id)
.where(ClientServicePlan.tenant_id == tenant_id)
)
if branch_id:
stmt = stmt.where(ClientServicePlan.branch_id == branch_id)
if not include_inactive:
stmt = stmt.where(ClientServicePlan.is_active.is_(True))
if q.strip():
from app.modules.clients.models import Client
from app.modules.services.models import ServiceCatalogue
term=f"%{q.strip()}%"
stmt=stmt.join(Client,Client.id==ClientServicePlan.client_id).join(ServiceCatalogue,ServiceCatalogue.id==ClientServicePlan.service_catalogue_id).where((Client.client_name.ilike(term)) | (Client.client_code.ilike(term)) | (ServiceCatalogue.service_name.ilike(term)) | (ServiceCatalogue.service_code.ilike(term)))
rows=db.execute(stmt.order_by(ClientServicePlan.is_active.desc(),ClientServicePlan.id.desc())).all()
return templates.TemplateResponse("modules/services/templates/services/subscriptions/list.html",_ctx(request,db,user,title="Client Service Subscriptions",rows=rows,q=q,include_inactive=include_inactive))
finally: db.close()
term = f"%{q.strip()}%"
stmt = (
stmt.join(Client, Client.id == ClientServicePlan.client_id)
.join(ServiceCatalogue, ServiceCatalogue.id == ClientServicePlan.service_catalogue_id)
.where(
(Client.client_name.ilike(term))
| (Client.client_code.ilike(term))
| (ServiceCatalogue.service_name.ilike(term))
| (ServiceCatalogue.service_code.ilike(term))
)
)
rows = db.execute(
stmt.order_by(ClientServicePlan.is_active.desc(), ClientServicePlan.id.desc())
).all()
return templates.TemplateResponse(
"modules/services/templates/services/subscriptions/list.html",
_ctx(
request,
db,
user,
title="Client Service Subscriptions",
rows=rows,
q=q,
include_inactive=include_inactive,
),
)
finally:
db.close()
@router.get("/{plan_id}/edit")
def subscription_master_edit_page(
request: Request,
plan_id: int,
saved: int = 0,
updated_engagements: int = 0,
updated_tasks: int = 0,
error: str = "",
):
db = CommonSessionLocal()
try:
user = get_current_user(request, db=db)
if not user:
return RedirectResponse("/login", 303)
try:
require_permission(db, user, "clients.edit")
except Exception:
return _denied()
tenant_id = _tenant_id(request, user)
branch_id = _branch_id(request, user)
plan = _load_plan(db, plan_id=plan_id, tenant_id=tenant_id, branch_id=branch_id)
if not plan:
return RedirectResponse("/services/subscriptions", 303)
partners = list_assignable_users(
db, tenant_id=tenant_id, branch_id=plan.branch_id, role_names=("Partner",)
)
managers = list_assignable_users(
db, tenant_id=tenant_id, branch_id=plan.branch_id, role_names=("Branch Manager",)
)
staff_users = list_assignable_users(
db, tenant_id=tenant_id, branch_id=plan.branch_id, role_names=("Staff",)
)
review_partners = list_review_partners(
db, tenant_id=tenant_id, branch_id=plan.branch_id
)
return templates.TemplateResponse(
"modules/services/templates/services/subscriptions/edit.html",
_ctx(
request,
db,
user,
title="Edit Client Service Subscription",
plan=plan,
partners=partners,
managers=managers,
staff_users=staff_users,
review_partners=review_partners,
saved=saved,
updated_engagements=updated_engagements,
updated_tasks=updated_tasks,
error=error,
),
)
finally:
db.close()
@router.post("/{plan_id}/edit")
def subscription_master_edit_submit(
request: Request,
plan_id: int,
default_partner_user_id: str = Form(""),
default_performing_partner_user_id: str = Form(""),
default_manager_user_id: str = Form(""),
default_staff_user_id: str = Form(""),
default_review_partner_user_id: str = Form(""),
effective_from: str = Form(""),
effective_to: str = Form(""),
status: str = Form("active"),
auto_generate_periods: str | None = Form(None),
remarks: str = Form(""),
propagation_mode: str = Form("future_only"),
reassignment_effective_from: str = Form(""),
reassignment_reason: str = Form(""),
csrf_token: str = Form(...),
):
validate_csrf(request, csrf_token)
db = CommonSessionLocal()
try:
user = get_current_user(request, db=db)
if not user:
return RedirectResponse("/login", 303)
try:
require_permission(db, user, "clients.edit")
except Exception:
return _denied()
tenant_id = _tenant_id(request, user)
branch_id = _branch_id(request, user)
plan = _load_plan(db, plan_id=plan_id, tenant_id=tenant_id, branch_id=branch_id)
if not plan:
return RedirectResponse("/services/subscriptions", 303)
partners = list_assignable_users(
db, tenant_id=tenant_id, branch_id=plan.branch_id, role_names=("Partner",)
)
managers = list_assignable_users(
db, tenant_id=tenant_id, branch_id=plan.branch_id, role_names=("Branch Manager",)
)
staff_users = list_assignable_users(
db, tenant_id=tenant_id, branch_id=plan.branch_id, role_names=("Staff",)
)
review_partners = list_review_partners(
db, tenant_id=tenant_id, branch_id=plan.branch_id
)
try:
new_partner_id = _allowed_id(
_optional_int(default_partner_user_id), {row.id for row in partners}, "partner"
)
requested_performing_id = _allowed_id(
_optional_int(default_performing_partner_user_id),
{row.id for row in partners},
"performing_partner",
)
new_performing_id = requested_performing_id or new_partner_id
new_manager_id = _allowed_id(
_optional_int(default_manager_user_id), {row.id for row in managers}, "manager"
)
new_staff_id = _allowed_id(
_optional_int(default_staff_user_id), {row.id for row in staff_users}, "staff"
)
new_review_partner_id = _allowed_id(
_optional_int(default_review_partner_user_id),
{row.id for row in review_partners},
"review_partner",
)
plan_effective_from = parse_date(effective_from)
plan_effective_to = parse_date(effective_to)
change_effective_from = parse_date(reassignment_effective_from)
except (TypeError, ValueError) as exc:
code = str(exc) if str(exc) else "validation"
db.rollback()
return RedirectResponse(
f"/services/subscriptions/{plan.id}/edit?error={code}", status_code=303
)
allowed_statuses = {item[0] for item in SUBSCRIPTION_STATUSES}
normalized_status = (status or "active").strip().lower()
if normalized_status not in allowed_statuses:
return RedirectResponse(
f"/services/subscriptions/{plan.id}/edit?error=status", status_code=303
)
if plan_effective_from and plan_effective_to and plan_effective_to < plan_effective_from:
return RedirectResponse(
f"/services/subscriptions/{plan.id}/edit?error=dates", status_code=303
)
if propagation_mode not in {"future_only", "open_engagements"}:
return RedirectResponse(
f"/services/subscriptions/{plan.id}/edit?error=propagation", status_code=303
)
old_staff_id = plan.default_staff_user_id
plan.default_partner_user_id = new_partner_id
plan.default_performing_partner_user_id = new_performing_id
plan.default_manager_user_id = new_manager_id
plan.default_staff_user_id = new_staff_id
plan.default_review_partner_user_id = new_review_partner_id
plan.effective_from = plan_effective_from
plan.effective_to = plan_effective_to
plan.auto_generate_periods = auto_generate_periods is not None
plan.status = normalized_status
plan.is_active = normalized_status not in {"inactive", "cancelled"}
plan.remarks = remarks.strip() or None
plan.updated_by_user_id = user.id
plan.updated_at_utc = datetime.now(timezone.utc)
updated_engagement_count = 0
updated_task_count = 0
if propagation_mode == "open_engagements":
engagements = db.execute(
select(ClientServiceSubscription).where(
ClientServiceSubscription.service_plan_id == plan.id,
ClientServiceSubscription.tenant_id == tenant_id,
)
).scalars().all()
affected_ids: list[int] = []
for row in engagements:
if not _engagement_is_open(row):
continue
if not _engagement_is_effective(row, change_effective_from):
continue
row.assigned_partner_user_id = new_partner_id
row.performing_partner_user_id = new_performing_id
row.assigned_manager_user_id = new_manager_id
row.assigned_staff_user_id = new_staff_id
row.review_partner_user_id = new_review_partner_id
row.updated_by_user_id = user.id
row.updated_at_utc = datetime.now(timezone.utc)
if reassignment_reason.strip():
note = (
f"Subscription team updated on {datetime.now(timezone.utc).date().isoformat()} "
f"by user {user.id}: {reassignment_reason.strip()}"
)
row.remarks = f"{row.remarks.strip()}\n{note}" if row.remarks else note
affected_ids.append(row.id)
updated_engagement_count += 1
if affected_ids and old_staff_id != new_staff_id:
task_stmt = select(ClientServiceTaskInstance).where(
ClientServiceTaskInstance.subscription_id.in_(affected_ids),
ClientServiceTaskInstance.is_active.is_(True),
ClientServiceTaskInstance.is_locked.is_(False),
ClientServiceTaskInstance.status.notin_(("completed", "cancelled")),
)
if old_staff_id is None:
task_stmt = task_stmt.where(
ClientServiceTaskInstance.assigned_to_user_id.is_(None),
func.lower(func.coalesce(ClientServiceTaskInstance.default_role_name, "")) == "staff",
)
else:
task_stmt = task_stmt.where(
ClientServiceTaskInstance.assigned_to_user_id == old_staff_id
)
tasks = db.execute(task_stmt).scalars().all()
for task in tasks:
task.assigned_to_user_id = new_staff_id
task.updated_by_user_id = user.id
task.updated_at_utc = datetime.now(timezone.utc)
updated_task_count += 1
db.commit()
return RedirectResponse(
f"/services/subscriptions/{plan.id}/edit?saved=1"
f"&updated_engagements={updated_engagement_count}"
f"&updated_tasks={updated_task_count}",
status_code=303,
)
except Exception:
db.rollback()
raise
finally:
db.close()
@router.get("/{plan_id}")
def subscription_master_detail(request: Request, plan_id: int):
db=CommonSessionLocal()
db = CommonSessionLocal()
try:
user=get_current_user(request, db=db)
if not user: return RedirectResponse("/login",303)
try: require_permission(db,user,"clients.view")
user = get_current_user(request, db=db)
if not user:
return RedirectResponse("/login", 303)
try:
require_permission(db, user, "clients.view")
except Exception:
from app.core.http_responses import ui_access_denied
return ui_access_denied()
tenant_id=_tenant_id(request,user)
plan=db.execute(select(ClientServicePlan).options(selectinload(ClientServicePlan.client),selectinload(ClientServicePlan.catalogue),selectinload(ClientServicePlan.default_partner),selectinload(ClientServicePlan.default_performing_partner),selectinload(ClientServicePlan.default_manager),selectinload(ClientServicePlan.default_staff),selectinload(ClientServicePlan.default_review_partner)).where(ClientServicePlan.id==plan_id,ClientServicePlan.tenant_id==tenant_id)).scalar_one_or_none()
if not plan: return RedirectResponse("/services/subscriptions",303)
engagements=db.execute(select(ClientServiceSubscription).where(ClientServiceSubscription.service_plan_id==plan.id).order_by(ClientServiceSubscription.financial_year.desc(),ClientServiceSubscription.period_label.asc(),ClientServiceSubscription.id.desc())).scalars().all()
return templates.TemplateResponse("modules/services/templates/services/subscriptions/detail.html",_ctx(request,db,user,title="Client Service Subscription",plan=plan,engagements=engagements))
finally: db.close()
return _denied()
tenant_id = _tenant_id(request, user)
branch_id = _branch_id(request, user)
plan = _load_plan(db, plan_id=plan_id, tenant_id=tenant_id, branch_id=branch_id)
if not plan:
return RedirectResponse("/services/subscriptions", 303)
engagements = db.execute(
select(ClientServiceSubscription)
.where(ClientServiceSubscription.service_plan_id == plan.id)
.order_by(
ClientServiceSubscription.financial_year.desc(),
ClientServiceSubscription.period_label.asc(),
ClientServiceSubscription.id.desc(),
)
).scalars().all()
permissions = set(get_user_permissions(db, user.id))
return templates.TemplateResponse(
"modules/services/templates/services/subscriptions/detail.html",
_ctx(
request,
db,
user,
title="Client Service Subscription",
plan=plan,
engagements=engagements,
can_edit_subscription="clients.edit" in permissions,
),
)
finally:
db.close()