Add controlled client subscription team reassignment
This commit is contained in:
@@ -1,22 +1,41 @@
|
|||||||
from __future__ import annotations
|
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 fastapi.responses import RedirectResponse
|
||||||
from sqlalchemy import func, select
|
from sqlalchemy import func, or_, select
|
||||||
from sqlalchemy.orm import selectinload
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
from app.core.db.common import CommonSessionLocal
|
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.security.session_auth import get_current_user
|
||||||
from app.core.templating import templates
|
from app.core.templating import templates
|
||||||
from app.modules.core.rbac.deps import get_user_permissions, get_user_roles
|
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.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"])
|
router = APIRouter(prefix="/services/subscriptions", tags=["client-service-subscriptions-ui"])
|
||||||
|
|
||||||
|
|
||||||
def _tenant_id(request, user):
|
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):
|
def _branch_id(request, user):
|
||||||
value = request.session.get("active_branch_id")
|
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(getattr(user, "branch_id", 0) or 0) or None
|
||||||
return int(value)
|
return int(value)
|
||||||
|
|
||||||
|
|
||||||
def _ctx(request, db, user, **extra):
|
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 = {
|
||||||
data.update(extra); return 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("")
|
@router.get("")
|
||||||
def subscription_master_list(request: Request, q: str = "", include_inactive: bool = False):
|
def subscription_master_list(request: Request, q: str = "", include_inactive: bool = False):
|
||||||
db = CommonSessionLocal()
|
db = CommonSessionLocal()
|
||||||
try:
|
try:
|
||||||
user = get_current_user(request, db=db)
|
user = get_current_user(request, db=db)
|
||||||
if not user: return RedirectResponse("/login",303)
|
if not user:
|
||||||
try: require_permission(db,user,"clients.view")
|
return RedirectResponse("/login", 303)
|
||||||
|
try:
|
||||||
|
require_permission(db, user, "clients.view")
|
||||||
except Exception:
|
except Exception:
|
||||||
from app.core.http_responses import ui_access_denied
|
return _denied()
|
||||||
return ui_access_denied()
|
|
||||||
tenant_id=_tenant_id(request,user); branch_id=_branch_id(request,user)
|
tenant_id = _tenant_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())
|
branch_id = _branch_id(request, user)
|
||||||
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))
|
counts = (
|
||||||
if branch_id: stmt=stmt.where(ClientServicePlan.branch_id==branch_id)
|
select(
|
||||||
if not include_inactive: stmt=stmt.where(ClientServicePlan.is_active.is_(True))
|
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():
|
if q.strip():
|
||||||
from app.modules.clients.models import Client
|
from app.modules.clients.models import Client
|
||||||
from app.modules.services.models import ServiceCatalogue
|
from app.modules.services.models import ServiceCatalogue
|
||||||
term = f"%{q.strip()}%"
|
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)))
|
stmt = (
|
||||||
rows=db.execute(stmt.order_by(ClientServicePlan.is_active.desc(),ClientServicePlan.id.desc())).all()
|
stmt.join(Client, Client.id == ClientServicePlan.client_id)
|
||||||
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))
|
.join(ServiceCatalogue, ServiceCatalogue.id == ClientServicePlan.service_catalogue_id)
|
||||||
finally: db.close()
|
.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}")
|
@router.get("/{plan_id}")
|
||||||
def subscription_master_detail(request: Request, plan_id: int):
|
def subscription_master_detail(request: Request, plan_id: int):
|
||||||
db = CommonSessionLocal()
|
db = CommonSessionLocal()
|
||||||
try:
|
try:
|
||||||
user = get_current_user(request, db=db)
|
user = get_current_user(request, db=db)
|
||||||
if not user: return RedirectResponse("/login",303)
|
if not user:
|
||||||
try: require_permission(db,user,"clients.view")
|
return RedirectResponse("/login", 303)
|
||||||
|
try:
|
||||||
|
require_permission(db, user, "clients.view")
|
||||||
except Exception:
|
except Exception:
|
||||||
from app.core.http_responses import ui_access_denied
|
return _denied()
|
||||||
return ui_access_denied()
|
|
||||||
tenant_id = _tenant_id(request, user)
|
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()
|
branch_id = _branch_id(request, user)
|
||||||
if not plan: return RedirectResponse("/services/subscriptions",303)
|
plan = _load_plan(db, plan_id=plan_id, tenant_id=tenant_id, branch_id=branch_id)
|
||||||
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()
|
if not plan:
|
||||||
return templates.TemplateResponse("modules/services/templates/services/subscriptions/detail.html",_ctx(request,db,user,title="Client Service Subscription",plan=plan,engagements=engagements))
|
return RedirectResponse("/services/subscriptions", 303)
|
||||||
finally: db.close()
|
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()
|
||||||
|
|||||||
@@ -20,6 +20,12 @@
|
|||||||
class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">
|
class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">
|
||||||
Back
|
Back
|
||||||
</a>
|
</a>
|
||||||
|
{% if can_edit_subscription %}
|
||||||
|
<a href="/services/subscriptions/{{ plan.id }}/edit"
|
||||||
|
class="rounded-xl border border-brand-300 px-4 py-2 text-sm font-medium text-brand-700 hover:bg-brand-50">
|
||||||
|
Edit Subscription
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
<a href="/services/engagements/new?client_id={{ plan.client_id }}"
|
<a href="/services/engagements/new?client_id={{ plan.client_id }}"
|
||||||
class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-medium text-white hover:bg-brand-700">
|
class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-medium text-white hover:bg-brand-700">
|
||||||
Create Engagement
|
Create Engagement
|
||||||
|
|||||||
@@ -0,0 +1,138 @@
|
|||||||
|
{% extends "ui/templates/base/layout.html" %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="space-y-6">
|
||||||
|
{% set _uiux_partner_role_text = (current_user_roles or [])|join('|')|lower %}
|
||||||
|
{% if 'partner' in _uiux_partner_role_text %}
|
||||||
|
{% include "ui/templates/components/partner_navigation_v2.html" %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-xl font-semibold text-slate-900">Edit Client Service Subscription</h2>
|
||||||
|
<p class="text-sm text-slate-500">
|
||||||
|
{{ plan.client.client_name }} — {{ plan.catalogue.service_name }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<a href="/services/subscriptions/{{ plan.id }}"
|
||||||
|
class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">
|
||||||
|
Back
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if saved %}
|
||||||
|
<div class="rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-800">
|
||||||
|
Subscription defaults saved. {{ updated_engagements }} open engagement(s) and {{ updated_tasks }} unfinished Staff task(s) were updated.
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if error %}
|
||||||
|
<div class="rounded-xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-800">
|
||||||
|
The subscription could not be saved. Please verify the selected team, dates, status and propagation option.
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<form method="post" action="/services/subscriptions/{{ plan.id }}/edit" class="space-y-6">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
|
|
||||||
|
<section class="rounded-2xl bg-white p-5 shadow-soft">
|
||||||
|
<h3 class="text-base font-semibold text-slate-900">Default Assignment Team</h3>
|
||||||
|
<p class="mt-1 text-sm text-slate-500">These defaults will be used for newly generated engagements.</p>
|
||||||
|
<div class="mt-5 grid gap-5 md:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<label class="mb-1 block text-sm font-medium text-slate-700">Engagement Partner</label>
|
||||||
|
<select name="default_partner_user_id" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">
|
||||||
|
<option value="">No default Partner</option>
|
||||||
|
{% for row in partners %}<option value="{{ row.id }}" {% if plan.default_partner_user_id == row.id %}selected{% endif %}>{{ row.full_name or row.email }}</option>{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="mb-1 block text-sm font-medium text-slate-700">Performing Partner</label>
|
||||||
|
<select name="default_performing_partner_user_id" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">
|
||||||
|
<option value="">Same as Engagement Partner</option>
|
||||||
|
{% for row in partners %}<option value="{{ row.id }}" {% if plan.default_performing_partner_user_id == row.id %}selected{% endif %}>{{ row.full_name or row.email }}</option>{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="mb-1 block text-sm font-medium text-slate-700">Manager</label>
|
||||||
|
<select name="default_manager_user_id" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">
|
||||||
|
<option value="">No default Manager</option>
|
||||||
|
{% for row in managers %}<option value="{{ row.id }}" {% if plan.default_manager_user_id == row.id %}selected{% endif %}>{{ row.full_name or row.email }}</option>{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="mb-1 block text-sm font-medium text-slate-700">Staff</label>
|
||||||
|
<select name="default_staff_user_id" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">
|
||||||
|
<option value="">No default Staff</option>
|
||||||
|
{% for row in staff_users %}<option value="{{ row.id }}" {% if plan.default_staff_user_id == row.id %}selected{% endif %}>{{ row.full_name or row.email }}</option>{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="mb-1 block text-sm font-medium text-slate-700">Review Partner</label>
|
||||||
|
<select name="default_review_partner_user_id" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">
|
||||||
|
<option value="">Not required / no default</option>
|
||||||
|
{% for row in review_partners %}<option value="{{ row.id }}" {% if plan.default_review_partner_user_id == row.id %}selected{% endif %}>{{ row.full_name or row.email }}</option>{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="rounded-2xl bg-white p-5 shadow-soft">
|
||||||
|
<h3 class="text-base font-semibold text-slate-900">Subscription Settings</h3>
|
||||||
|
<div class="mt-5 grid gap-5 md:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<label class="mb-1 block text-sm font-medium text-slate-700">Effective From</label>
|
||||||
|
<input type="date" name="effective_from" value="{{ plan.effective_from or '' }}" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="mb-1 block text-sm font-medium text-slate-700">Effective To</label>
|
||||||
|
<input type="date" name="effective_to" value="{{ plan.effective_to or '' }}" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="mb-1 block text-sm font-medium text-slate-700">Status</label>
|
||||||
|
<select name="status" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">
|
||||||
|
{% for value, label in subscription_statuses %}<option value="{{ value }}" {% if plan.status == value %}selected{% endif %}>{{ label }}</option>{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<label class="mt-7 inline-flex items-center gap-2 text-sm text-slate-700">
|
||||||
|
<input type="checkbox" name="auto_generate_periods" value="1" {% if plan.auto_generate_periods %}checked{% endif %} class="rounded border-slate-300">
|
||||||
|
Automatically generate recurring periods when the scheduler is enabled
|
||||||
|
</label>
|
||||||
|
<div class="md:col-span-2">
|
||||||
|
<label class="mb-1 block text-sm font-medium text-slate-700">Remarks</label>
|
||||||
|
<textarea name="remarks" rows="3" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">{{ plan.remarks or '' }}</textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="rounded-2xl border border-amber-200 bg-amber-50 p-5">
|
||||||
|
<h3 class="text-base font-semibold text-slate-900">Apply Team Change</h3>
|
||||||
|
<p class="mt-1 text-sm text-slate-600">Completed and locked engagements, completed tasks, evidence and review history are never changed.</p>
|
||||||
|
<div class="mt-4 space-y-3">
|
||||||
|
<label class="flex items-start gap-3 rounded-xl border border-amber-200 bg-white p-4">
|
||||||
|
<input type="radio" name="propagation_mode" value="future_only" checked class="mt-1">
|
||||||
|
<span><strong class="block text-sm text-slate-900">Future engagements only</strong><span class="text-sm text-slate-500">Save new defaults without changing any existing engagement.</span></span>
|
||||||
|
</label>
|
||||||
|
<label class="flex items-start gap-3 rounded-xl border border-amber-200 bg-white p-4">
|
||||||
|
<input type="radio" name="propagation_mode" value="open_engagements" class="mt-1">
|
||||||
|
<span><strong class="block text-sm text-slate-900">Future engagements and currently open engagements</strong><span class="text-sm text-slate-500">Update unlocked, non-completed engagement assignments and unfinished Staff tasks.</span></span>
|
||||||
|
</label>
|
||||||
|
<div class="grid gap-4 md:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<label class="mb-1 block text-sm font-medium text-slate-700">Open engagements effective from</label>
|
||||||
|
<input type="date" name="reassignment_effective_from" class="w-full rounded-xl border border-amber-300 bg-white px-3 py-2 text-sm">
|
||||||
|
<p class="mt-1 text-xs text-slate-500">Leave blank to update all open engagements under this subscription.</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="mb-1 block text-sm font-medium text-slate-700">Reason for reassignment</label>
|
||||||
|
<input type="text" name="reassignment_reason" maxlength="500" placeholder="Optional operational reason" class="w-full rounded-xl border border-amber-300 bg-white px-3 py-2 text-sm">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="flex justify-end gap-3">
|
||||||
|
<a href="/services/subscriptions/{{ plan.id }}" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">Cancel</a>
|
||||||
|
<button type="submit" class="rounded-xl bg-brand-600 px-5 py-2 text-sm font-medium text-white hover:bg-brand-700">Save Subscription</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
Reference in New Issue
Block a user