From a5cf33bc7a805f6cb354df4c1940695144f60e7f Mon Sep 17 00:00:00 2001 From: A R R R Associates Date: Thu, 6 Aug 2026 22:32:11 +0530 Subject: [PATCH] Add subscription registration scope mapping --- app/modules/services/subscriptions_ui.py | 403 +++++++++++++++++- .../services/subscriptions/detail.html | 61 +++ .../services/subscriptions/scope.html | 223 ++++++++++ 3 files changed, 686 insertions(+), 1 deletion(-) create mode 100644 app/modules/services/templates/services/subscriptions/scope.html diff --git a/app/modules/services/subscriptions_ui.py b/app/modules/services/subscriptions_ui.py index 30fbd9d..c271cfc 100644 --- a/app/modules/services/subscriptions_ui.py +++ b/app/modules/services/subscriptions_ui.py @@ -34,7 +34,8 @@ from app.modules.services.client_services import ( recurrence_requires_period, review_partner_required_for_engagement, ) -from app.modules.clients.models import Client +from app.modules.clients.models import Client, ClientBusinessUnit, ClientBranch +from app.modules.registrations.models import ClientRegistration, RegistrationType from app.modules.services.due_dates import apply_due_date_rule_to_subscription from app.modules.services.scope_targets import list_scope_targets, resolve_scope_target from app.modules.services.models import ( @@ -74,6 +75,85 @@ def _partner_scope_id(db, user) -> int | None: roles = set(get_user_roles(db, user.id)) return int(user.id) if "Partner" in roles else None + +def _registration_scope_context(db, *, tenant_id: int, plan: ClientServicePlan): + businesses = db.execute( + select(ClientBusinessUnit).where( + ClientBusinessUnit.tenant_id == tenant_id, + ClientBusinessUnit.client_id == plan.client_id, + ClientBusinessUnit.is_active.is_(True), + ).order_by( + ClientBusinessUnit.is_primary.desc(), + ClientBusinessUnit.business_name, + ) + ).scalars().all() + + branches = db.execute( + select(ClientBranch).where( + ClientBranch.tenant_id == tenant_id, + ClientBranch.client_id == plan.client_id, + ClientBranch.is_active.is_(True), + ).order_by( + ClientBranch.is_primary.desc(), + ClientBranch.branch_name, + ) + ).scalars().all() + + registration_rows = db.execute( + select(ClientRegistration, RegistrationType) + .join( + RegistrationType, + RegistrationType.id == ClientRegistration.registration_type_id, + ) + .where( + ClientRegistration.tenant_id == tenant_id, + ClientRegistration.client_id == plan.client_id, + ClientRegistration.status.in_(("active", "valid", "registered")), + RegistrationType.is_active.is_(True), + ) + .order_by( + RegistrationType.code, + ClientRegistration.registration_number, + ) + ).all() + + registrations = [] + registration_type_by_id = {} + for registration, registration_type in registration_rows: + registrations.append(registration) + registration_type_by_id[registration.id] = registration_type + + business_by_id = {row.id: row for row in businesses} + branch_by_id = {row.id: row for row in branches} + registration_by_id = {row.id: row for row in registrations} + + return { + "businesses": businesses, + "branches": branches, + "registrations": registrations, + "registration_type_by_id": registration_type_by_id, + "scope_business": business_by_id.get(plan.business_unit_id), + "scope_branch": branch_by_id.get(plan.client_branch_id), + "scope_registration": registration_by_id.get(plan.registration_id), + "scope_registration_type": ( + registration_type_by_id.get(plan.registration_id) + if plan.registration_id + else None + ), + } + + +def _scope_key_for(scope_type: str, target_id: int) -> str: + prefixes = { + "client": "CLIENT", + "business_unit": "BUSINESS", + "client_branch": "BRANCH", + "registration": "REGISTRATION", + } + if scope_type not in prefixes: + raise ValueError("scope_type") + return f"{prefixes[scope_type]}:{int(target_id)}" + def _ctx(request, db, user, **extra): data = { "request": request, @@ -522,6 +602,322 @@ def subscription_bulk_submit( finally: db.close() + + +@router.get("/{plan_id}/scope") +def subscription_scope_edit_page( + request: Request, + plan_id: int, + saved: int = 0, + updated_engagements: 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) + + scope_context = _registration_scope_context( + db, + tenant_id=tenant_id, + plan=plan, + ) + error_messages = { + "scope_type": "Select a valid subscription scope.", + "business_unit": "Select a valid Business Unit belonging to this client.", + "client_branch": "Select a valid Client Branch belonging to this client.", + "registration": "Select a valid active registration belonging to this client.", + "registration_type": "The selected registration type does not match the Service Catalogue requirement.", + "duplicate_plan": "Another subscription already exists for this service and selected scope.", + "duplicate_engagement": "An engagement already exists for the selected scope, financial year and period.", + "propagation": "Select a valid propagation option.", + } + return templates.TemplateResponse( + "modules/services/templates/services/subscriptions/scope.html", + _ctx( + request, + db, + user, + title="Map Subscription Scope", + plan=plan, + saved=saved, + updated_engagements=updated_engagements, + error_message=error_messages.get(error, ""), + **scope_context, + ), + ) + finally: + db.close() + + +@router.post("/{plan_id}/scope") +def subscription_scope_edit_submit( + request: Request, + plan_id: int, + scope_type: str = Form(...), + business_unit_id: str = Form(""), + client_branch_id: str = Form(""), + registration_id: str = Form(""), + propagation_mode: str = Form("open_engagements"), + scope_change_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) + + normalized_scope = ( + scope_type or "" + ).strip().lower().replace("-", "_").replace(" ", "_") + if normalized_scope not in { + "client", + "business_unit", + "client_branch", + "registration", + }: + return RedirectResponse( + f"/services/subscriptions/{plan.id}/scope?error=scope_type", + 303, + ) + if propagation_mode not in {"subscription_only", "open_engagements"}: + return RedirectResponse( + f"/services/subscriptions/{plan.id}/scope?error=propagation", + 303, + ) + + selected_business_id = _optional_int(business_unit_id) + selected_branch_id = _optional_int(client_branch_id) + selected_registration_id = _optional_int(registration_id) + + business = None + client_branch = None + registration = None + registration_type = None + + if selected_business_id: + business = db.get(ClientBusinessUnit, selected_business_id) + if ( + not business + or business.tenant_id != tenant_id + or business.client_id != plan.client_id + or not business.is_active + ): + return RedirectResponse( + f"/services/subscriptions/{plan.id}/scope?error=business_unit", + 303, + ) + + if selected_branch_id: + client_branch = db.get(ClientBranch, selected_branch_id) + if ( + not client_branch + or client_branch.tenant_id != tenant_id + or client_branch.client_id != plan.client_id + or not client_branch.is_active + ): + return RedirectResponse( + f"/services/subscriptions/{plan.id}/scope?error=client_branch", + 303, + ) + selected_business_id = client_branch.business_unit_id + business = db.get(ClientBusinessUnit, selected_business_id) + + if selected_registration_id: + registration = db.get(ClientRegistration, selected_registration_id) + if ( + not registration + or registration.tenant_id != tenant_id + or registration.client_id != plan.client_id + or registration.status not in {"active", "valid", "registered"} + ): + return RedirectResponse( + f"/services/subscriptions/{plan.id}/scope?error=registration", + 303, + ) + registration_type = db.get( + RegistrationType, + registration.registration_type_id, + ) + selected_business_id = ( + registration.business_unit_id or selected_business_id + ) + selected_branch_id = ( + registration.client_branch_id or selected_branch_id + ) + if selected_business_id: + business = db.get(ClientBusinessUnit, selected_business_id) + if selected_branch_id: + client_branch = db.get(ClientBranch, selected_branch_id) + + if normalized_scope == "client": + target_id = plan.client_id + selected_business_id = None + selected_branch_id = None + selected_registration_id = None + elif normalized_scope == "business_unit": + if not business: + return RedirectResponse( + f"/services/subscriptions/{plan.id}/scope?error=business_unit", + 303, + ) + target_id = business.id + selected_branch_id = None + selected_registration_id = None + elif normalized_scope == "client_branch": + if not client_branch: + return RedirectResponse( + f"/services/subscriptions/{plan.id}/scope?error=client_branch", + 303, + ) + target_id = client_branch.id + selected_registration_id = None + else: + if not registration or not registration_type: + return RedirectResponse( + f"/services/subscriptions/{plan.id}/scope?error=registration", + 303, + ) + required_type = ( + getattr(plan.catalogue, "required_registration_type", None) + or "" + ).strip().upper() + actual_type = (registration_type.code or "").strip().upper() + if required_type and required_type != actual_type: + return RedirectResponse( + f"/services/subscriptions/{plan.id}/scope?error=registration_type", + 303, + ) + target_id = registration.id + + new_scope_key = _scope_key_for(normalized_scope, target_id) + + conflicting_plan = db.execute( + select(ClientServicePlan).where( + ClientServicePlan.tenant_id == tenant_id, + ClientServicePlan.service_catalogue_id + == plan.service_catalogue_id, + ClientServicePlan.scope_key == new_scope_key, + ClientServicePlan.id != plan.id, + ) + ).scalar_one_or_none() + if conflicting_plan: + return RedirectResponse( + f"/services/subscriptions/{plan.id}/scope?error=duplicate_plan", + 303, + ) + + open_engagements = [] + if propagation_mode == "open_engagements": + open_engagements = db.execute( + select(ClientServiceSubscription).where( + ClientServiceSubscription.tenant_id == tenant_id, + ClientServiceSubscription.service_plan_id == plan.id, + ) + ).scalars().all() + open_engagements = [ + row for row in open_engagements if _engagement_is_open(row) + ] + + for row in open_engagements: + conflict = db.execute( + select(ClientServiceSubscription.id).where( + ClientServiceSubscription.tenant_id == tenant_id, + ClientServiceSubscription.service_catalogue_id + == plan.service_catalogue_id, + ClientServiceSubscription.scope_key == new_scope_key, + ClientServiceSubscription.financial_year + == row.financial_year, + ClientServiceSubscription.period_label + == row.period_label, + ClientServiceSubscription.id != row.id, + ) + ).scalar_one_or_none() + if conflict: + return RedirectResponse( + f"/services/subscriptions/{plan.id}/scope?error=duplicate_engagement", + 303, + ) + + old_scope = plan.scope_key or f"CLIENT:{plan.client_id}" + plan.scope_type = normalized_scope + plan.scope_key = new_scope_key + plan.business_unit_id = selected_business_id + plan.client_branch_id = selected_branch_id + plan.registration_id = selected_registration_id + plan.updated_by_user_id = user.id + plan.updated_at_utc = datetime.now(timezone.utc) + + updated_engagements = 0 + if propagation_mode == "open_engagements": + for row in open_engagements: + row.scope_type = normalized_scope + row.scope_key = new_scope_key + row.business_unit_id = selected_business_id + row.client_branch_id = selected_branch_id + row.registration_id = selected_registration_id + row.updated_by_user_id = user.id + row.updated_at_utc = datetime.now(timezone.utc) + if scope_change_reason.strip(): + note = ( + f"Subscription scope changed from {old_scope} " + f"to {new_scope_key} on " + f"{datetime.now(timezone.utc).date().isoformat()} " + f"by user {user.id}: " + f"{scope_change_reason.strip()}" + ) + row.remarks = ( + f"{row.remarks.strip()}\n{note}" + if row.remarks + else note + ) + updated_engagements += 1 + + db.commit() + return RedirectResponse( + f"/services/subscriptions/{plan.id}/scope?saved=1" + f"&updated_engagements={updated_engagements}", + 303, + ) + except Exception: + db.rollback() + raise + finally: + db.close() + @router.get("/{plan_id}/edit") def subscription_master_edit_page( request: Request, @@ -796,6 +1192,11 @@ def subscription_master_detail(request: Request, plan_id: int): plan=plan, engagements=engagements, can_edit_subscription="clients.edit" in permissions, + **_registration_scope_context( + db, + tenant_id=tenant_id, + plan=plan, + ), ), ) finally: diff --git a/app/modules/services/templates/services/subscriptions/detail.html b/app/modules/services/templates/services/subscriptions/detail.html index 3a110e2..d90c363 100644 --- a/app/modules/services/templates/services/subscriptions/detail.html +++ b/app/modules/services/templates/services/subscriptions/detail.html @@ -25,6 +25,10 @@ class="rounded-xl border border-brand-300 px-4 py-2 text-sm font-medium text-brand-700 hover:bg-brand-50"> Edit Subscription + + Map Scope + {% endif %} @@ -33,6 +37,63 @@ + +
+ +
+
+
Scope Type
+
+ {{ (plan.scope_type or 'client')|replace('_', ' ')|title }} +
+
+
+
Client
+
{{ plan.client.client_name }}
+
+
+
Business Unit
+
+ {{ scope_business.business_name if scope_business else '-' }} +
+
+
+
Client Branch
+
+ {{ scope_branch.branch_name if scope_branch else '-' }} +
+
+
+
Registration Type
+
+ {{ scope_registration_type.name if scope_registration_type else '-' }} +
+
+
+
Registration Number
+
+ {{ scope_registration.registration_number if scope_registration else '-' }} +
+ {% if scope_registration and scope_registration.trade_name %} +
{{ scope_registration.trade_name }}
+ {% endif %} +
+
+
+

Subscription Defaults

diff --git a/app/modules/services/templates/services/subscriptions/scope.html b/app/modules/services/templates/services/subscriptions/scope.html new file mode 100644 index 0000000..fbed923 --- /dev/null +++ b/app/modules/services/templates/services/subscriptions/scope.html @@ -0,0 +1,223 @@ +{% 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 %} + +
+
+

Map Subscription Scope

+

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

+
+ + Back to Subscription + +
+ + {% if error_message %} +
+ {{ error_message }} +
+ {% endif %} + + {% if saved %} +
+ Subscription scope updated. Open engagements updated: {{ updated_engagements }}. +
+ {% endif %} + +
+

Current Scope

+
+
+
Scope Type
+
{{ (plan.scope_type or 'client')|replace('_',' ')|title }}
+
+
+
Business Unit
+
{{ scope_business.business_name if scope_business else '-' }}
+
+
+
Client Branch
+
{{ scope_branch.branch_name if scope_branch else '-' }}
+
+
+
Registration
+
+ {% if scope_registration_type and scope_registration %} + {{ scope_registration_type.code }} — {{ scope_registration.registration_number }} + {% else %}-{% endif %} +
+
+
+
+ +
+ + +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + + {% if plan.catalogue.required_registration_type %} +

+ Required by Service Catalogue: {{ plan.catalogue.required_registration_type }} +

+ {% endif %} +
+
+ +
+ +
+ + +
+
+ +
+ + +
+ +
+ This action does not change the Client, subscription ID, engagement IDs, due dates, + assignment team, tasks, evidence or review history. +
+ +
+ + Cancel + + +
+
+
+ + +{% endblock %}