diff --git a/app/modules/services/subscriptions_ui.py b/app/modules/services/subscriptions_ui.py index 16ce501..2d3dba0 100644 --- a/app/modules/services/subscriptions_ui.py +++ b/app/modules/services/subscriptions_ui.py @@ -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() diff --git a/app/modules/services/templates/services/subscriptions/detail.html b/app/modules/services/templates/services/subscriptions/detail.html index 1eae650..3a110e2 100644 --- a/app/modules/services/templates/services/subscriptions/detail.html +++ b/app/modules/services/templates/services/subscriptions/detail.html @@ -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"> Back + {% if can_edit_subscription %} + + Edit Subscription + + {% endif %} Create Engagement diff --git a/app/modules/services/templates/services/subscriptions/edit.html b/app/modules/services/templates/services/subscriptions/edit.html new file mode 100644 index 0000000..79a8225 --- /dev/null +++ b/app/modules/services/templates/services/subscriptions/edit.html @@ -0,0 +1,138 @@ +{% extends "ui/templates/base/layout.html" %} +{% block content %} +
+ {% 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 %} + +
+
+

Edit Client Service Subscription

+

+ {{ plan.client.client_name }} — {{ plan.catalogue.service_name }} +

+
+
+ Back + +
+ + {% if saved %} +
+ Subscription defaults saved. {{ updated_engagements }} open engagement(s) and {{ updated_tasks }} unfinished Staff task(s) were updated. +
+ {% endif %} + {% if error %} +
+ The subscription could not be saved. Please verify the selected team, dates, status and propagation option. +
+ {% endif %} + +
+ + +
+

Default Assignment Team

+

These defaults will be used for newly generated engagements.

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ +
+

Subscription Settings

+
+
+ + +
+
+ + +
+
+ + +
+ +
+ + +
+
+
+ +
+

Apply Team Change

+

Completed and locked engagements, completed tasks, evidence and review history are never changed.

+
+ + +
+
+ + +

Leave blank to update all open engagements under this subscription.

+
+
+ + +
+
+
+
+ +
+ Cancel + +
+
+
+{% endblock %}