from __future__ import annotations from datetime import date, datetime, timezone from fastapi import APIRouter, Form, Request from fastapi.responses import RedirectResponse 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, 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.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 ) def _branch_id(request, user): value = request.session.get("active_branch_id") if value in (None, "", 0, "0"): 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), "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() try: user = get_current_user(request, db=db) if not user: return RedirectResponse("/login", 303) try: require_permission(db, user, "clients.view") except Exception: 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() @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() try: user = get_current_user(request, db=db) if not user: return RedirectResponse("/login", 303) try: require_permission(db, user, "clients.view") 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) 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()