from __future__ import annotations from fastapi import APIRouter, Body, Depends, HTTPException, Query, status from sqlalchemy import and_, func, or_ from sqlalchemy.orm import Session from govoplan_core.admin.common import AdminValidationError, slugify from govoplan_core.admin.settings import get_system_settings from govoplan_core.auth import ApiPrincipal, get_api_principal, has_scope, require_scope from govoplan_core.audit.logging import audit_event from govoplan_core.core.access import ( CAPABILITY_ACCESS_TENANT_PROVISIONER, CAPABILITY_AUTH_TENANT_CONTEXT_SWITCHER, TenantAccessProvisioner, TenantContextSwitcher, ) from govoplan_core.core.change_sequence import ChangeSequenceEntry, decode_sequence_watermark, encode_sequence_watermark, record_change, sequence_watermark_is_expired from govoplan_core.core.runtime import get_registry from govoplan_core.db.session import get_session from govoplan_core.i18n import ( i18n_settings, normalize_enabled_language_codes, system_enabled_language_codes, system_i18n_payload, tenant_enabled_language_codes, update_i18n_settings, ) from govoplan_core.security.time import utc_now from govoplan_core.tenancy.service import ( assert_tenant_governance_override_allowed, effective_tenant_governance, tenant_counts, ) from govoplan_tenancy.backend.db.models import Tenant from govoplan_tenancy.backend.lifecycle import ( TENANT_EVENT_CREATED, TENANT_EVENT_DELETION_REQUESTED, TENANT_EVENT_ERASURE_COMPLETED, tenant_lifecycle_event, tenant_lifecycle_event_type, ) from .schemas import ( TenantAdminItem, TenantContextSwitchRequest, TenantContextSwitchResponse, TenantCreateRequest, TenantDeleteRequest, TenantDeletionPlanResponse, TenantLifecycleIssue, TenantLifecycleResponse, TenantListDeltaResponse, TenantListResponse, TenantOwnerCandidate, TenantOwnerCandidateListResponse, TenantSettingsItem, TenantSettingsDeltaResponse, TenantSettingsUpdateRequest, TenantUpdateRequest, ) router = APIRouter(prefix="/admin", tags=["admin"]) tenant_router = APIRouter(prefix="/tenancy", tags=["tenancy"]) TENANCY_MODULE_ID = "tenancy" TENANT_LIST_COLLECTION = "tenancy.tenants" TENANT_LIST_RESOURCE = "tenant" TENANT_SETTINGS_COLLECTION = "tenancy.tenant_settings" TENANT_SETTINGS_RESOURCE = "tenant_settings_section" ADMIN_MODULE_ID = "admin" ADMIN_SYSTEM_SETTINGS_COLLECTION = "admin.system_settings" TENANT_SETTINGS_SECTIONS = ("identity", "locale", "languages", "settings") def _tenant_access_provisioner() -> TenantAccessProvisioner: registry = get_registry() if registry is None or not hasattr(registry, "has_capability") or not registry.has_capability(CAPABILITY_ACCESS_TENANT_PROVISIONER): raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Access tenant provisioner capability is not configured") capability = registry.require_capability(CAPABILITY_ACCESS_TENANT_PROVISIONER) if not isinstance(capability, TenantAccessProvisioner): raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Access tenant provisioner capability is invalid") return capability def _tenant_context_switcher() -> TenantContextSwitcher: registry = get_registry() if registry is None or not hasattr(registry, "has_capability") or not registry.has_capability(CAPABILITY_AUTH_TENANT_CONTEXT_SWITCHER): raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Tenant context switcher capability is not configured") capability = registry.require_capability(CAPABILITY_AUTH_TENANT_CONTEXT_SWITCHER) if not isinstance(capability, TenantContextSwitcher): raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Tenant context switcher capability is invalid") return capability def _require_permission(principal: ApiPrincipal, scope: str) -> None: if not has_scope(principal, scope): raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=f"Missing scope: {scope}") def _tenant_item(session: Session, tenant: Tenant) -> TenantAdminItem: governance = effective_tenant_governance(session, tenant) return TenantAdminItem( id=tenant.id, slug=tenant.slug, name=tenant.name, description=tenant.description, default_locale=tenant.default_locale, settings=tenant.settings or {}, allow_custom_groups=tenant.allow_custom_groups, allow_custom_roles=tenant.allow_custom_roles, allow_api_keys=tenant.allow_api_keys, effective_governance={ "allow_custom_groups": governance.allow_custom_groups, "allow_custom_roles": governance.allow_custom_roles, "allow_api_keys": governance.allow_api_keys, }, is_active=tenant.is_active, counts=tenant_counts(session, tenant.id), created_at=tenant.created_at, updated_at=tenant.updated_at, ) def _tenant_deletion_plan(session: Session, tenant: Tenant, principal: ApiPrincipal, *, mode: str = "retire") -> TenantDeletionPlanResponse: issues: list[TenantLifecycleIssue] = [] counts = tenant_counts(session, tenant.id) destructive = mode == "destroy" if tenant.id == principal.tenant_id: issues.append(TenantLifecycleIssue( severity="blocker", code="active_tenant", message=f"Switch to another tenant before {'deleting' if destructive else 'retiring'} the active tenant.", module_id=TENANCY_MODULE_ID, )) if not tenant.is_active: issues.append(TenantLifecycleIssue( severity="info", code="already_inactive", message="Tenant is already inactive; retiring it again will only refresh lifecycle metadata.", module_id=TENANCY_MODULE_ID, )) has_tenant_data = any(value for value in counts.values()) if destructive and has_tenant_data: issues.append(TenantLifecycleIssue( severity="blocker", code="tenant_data_present", message="Tenant-owned data exists; retire the tenant or remove its data before destructive deletion.", module_id=TENANCY_MODULE_ID, )) elif has_tenant_data: issues.append(TenantLifecycleIssue( severity="warning", code="tenant_data_retained", message="Tenant-owned data will be retained. This operation is non-destructive.", module_id=TENANCY_MODULE_ID, )) registry = get_registry() if registry is not None and hasattr(registry, "delete_veto_providers"): for provider in registry.delete_veto_providers("tenant"): try: provider(session, tenant.id, tenant.id) except Exception as exc: issues.append(TenantLifecycleIssue( severity="blocker", code="module_veto", message=str(exc), )) return TenantDeletionPlanResponse( tenant_id=tenant.id, action="destroy" if destructive else "retire", allowed=not any(issue.severity == "blocker" for issue in issues), destructive_supported=destructive and not has_tenant_data, issues=issues, counts=counts, ) def _tenant_settings_item(session: Session, tenant: Tenant) -> TenantSettingsItem: system_settings = get_system_settings(session) system_payload = system_i18n_payload(system_settings) system_enabled = system_enabled_language_codes(system_settings.settings, default_locale=system_settings.default_locale) enabled = tenant_enabled_language_codes(tenant.settings, system_enabled, default_locale=tenant.default_locale) return TenantSettingsItem( id=tenant.id, slug=tenant.slug, name=tenant.name, default_locale=tenant.default_locale, available_languages=system_payload["available_languages"], system_enabled_language_codes=system_enabled, enabled_language_codes=enabled, settings=tenant.settings or {}, ) def _tenant_settings_sections(item: TenantSettingsItem) -> dict[str, Any]: payload = item.model_dump(mode="json") return { "identity": {"id": payload["id"], "slug": payload["slug"], "name": payload["name"]}, "locale": {"default_locale": payload["default_locale"]}, "languages": { "available_languages": payload["available_languages"], "system_enabled_language_codes": payload["system_enabled_language_codes"], "enabled_language_codes": payload["enabled_language_codes"], }, "settings": payload["settings"], } def _record_tenant_settings_section_changes( session: Session, *, tenant_id: str, before: dict[str, Any], after: dict[str, Any], principal: ApiPrincipal, ) -> None: for section in TENANT_SETTINGS_SECTIONS: if before.get(section) == after.get(section): continue record_change( session, module_id=TENANCY_MODULE_ID, collection=TENANT_SETTINGS_COLLECTION, resource_type=TENANT_SETTINGS_RESOURCE, resource_id=section, operation="updated", tenant_id=tenant_id, actor_type="user", actor_id=principal.user.id, payload={"section": section}, ) def _record_tenant_list_change(session: Session, *, tenant: Tenant, operation: str, principal: ApiPrincipal) -> None: record_change( session, module_id=TENANCY_MODULE_ID, collection=TENANT_LIST_COLLECTION, resource_type=TENANT_LIST_RESOURCE, resource_id=tenant.id, operation=operation, tenant_id=None, actor_type="user", actor_id=principal.user.id, payload={"slug": tenant.slug, "name": tenant.name, "is_active": tenant.is_active}, ) def _tenant_list_delta_query(session: Session, *, since_sequence: int): return session.query(ChangeSequenceEntry).filter( ChangeSequenceEntry.id > since_sequence, ChangeSequenceEntry.module_id == TENANCY_MODULE_ID, ChangeSequenceEntry.collection == TENANT_LIST_COLLECTION, ) def _tenant_list_watermark(session: Session) -> str: sequence_id = _tenant_list_delta_query(session, since_sequence=0).with_entities(func.max(ChangeSequenceEntry.id)).scalar() return encode_sequence_watermark(int(sequence_id or 0)) def _tenant_list_delta_entries(session: Session, *, since: str, limit: int): try: since_sequence = decode_sequence_watermark(since) except ValueError as exc: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc if sequence_watermark_is_expired( session, since=since_sequence, tenant_id=None, module_id=TENANCY_MODULE_ID, collections=(TENANT_LIST_COLLECTION,), ): return None, False entries_plus_one = _tenant_list_delta_query(session, since_sequence=since_sequence).order_by(ChangeSequenceEntry.id.asc()).limit(limit + 1).all() has_more = len(entries_plus_one) > limit return entries_plus_one[:limit], has_more def _tenant_list_response_watermark(session: Session, *, entries, has_more: bool) -> str: return encode_sequence_watermark(entries[-1].id) if has_more and entries else _tenant_list_watermark(session) def _tenant_list_deleted_entries(entries: list[ChangeSequenceEntry], visible_tenant_ids: set[str]): return [ {"id": entry.resource_id, "resource_type": entry.resource_type or TENANT_LIST_RESOURCE} for entry in entries if entry.resource_id and (entry.operation == "deleted" or entry.resource_id not in visible_tenant_ids) ] def _tenant_settings_delta_query(session: Session, *, tenant_id: str, since_sequence: int): return session.query(ChangeSequenceEntry).filter( ChangeSequenceEntry.id > since_sequence, or_( and_( ChangeSequenceEntry.module_id == TENANCY_MODULE_ID, ChangeSequenceEntry.collection == TENANT_SETTINGS_COLLECTION, ChangeSequenceEntry.tenant_id == tenant_id, ), and_( ChangeSequenceEntry.module_id == ADMIN_MODULE_ID, ChangeSequenceEntry.collection == ADMIN_SYSTEM_SETTINGS_COLLECTION, ChangeSequenceEntry.resource_id == "languages", ), ), ) def _tenant_settings_watermark(session: Session, *, tenant_id: str) -> str: sequence_id = _tenant_settings_delta_query(session, tenant_id=tenant_id, since_sequence=0).with_entities(func.max(ChangeSequenceEntry.id)).scalar() return encode_sequence_watermark(int(sequence_id or 0)) def _tenant_settings_delta_entries(session: Session, *, tenant_id: str, since: str, limit: int): try: since_sequence = decode_sequence_watermark(since) except ValueError as exc: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc if sequence_watermark_is_expired( session, since=since_sequence, tenant_id=tenant_id, module_id=TENANCY_MODULE_ID, collections=(TENANT_SETTINGS_COLLECTION,), ): return None, False entries_plus_one = _tenant_settings_delta_query( session, tenant_id=tenant_id, since_sequence=since_sequence, ).order_by(ChangeSequenceEntry.id.asc()).limit(limit + 1).all() has_more = len(entries_plus_one) > limit return entries_plus_one[:limit], has_more def _tenant_settings_response_watermark(session: Session, *, tenant_id: str, entries, has_more: bool) -> str: return encode_sequence_watermark(entries[-1].id) if has_more and entries else _tenant_settings_watermark(session, tenant_id=tenant_id) @tenant_router.post("/switch-tenant", response_model=TenantContextSwitchResponse) def switch_tenant_context( payload: TenantContextSwitchRequest, session: Session = Depends(get_session), principal: ApiPrincipal = Depends(get_api_principal), ): try: switched = _tenant_context_switcher().switch_tenant_context(session, principal=principal, tenant_id=payload.tenant_id) except ValueError as exc: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc except LookupError as exc: raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(exc)) from exc session.commit() return TenantContextSwitchResponse( account_id=switched.account_id, membership_id=switched.membership_id, tenant_id=switched.tenant_id, session_id=switched.session_id, ) @router.get("/tenants", response_model=TenantListResponse) def list_tenants( session: Session = Depends(get_session), principal: ApiPrincipal = Depends(require_scope("system:tenants:read")), ): tenants = session.query(Tenant).order_by(Tenant.name.asc()).all() return TenantListResponse(tenants=[_tenant_item(session, tenant) for tenant in tenants]) def _full_tenant_list_delta_response(session: Session) -> TenantListDeltaResponse: tenants = session.query(Tenant).order_by(Tenant.name.asc()).all() return TenantListDeltaResponse( tenants=[_tenant_item(session, tenant) for tenant in tenants], deleted=[], watermark=_tenant_list_watermark(session), has_more=False, full=True, ) @router.get("/tenants/delta", response_model=TenantListDeltaResponse) def list_tenants_delta( since: str | None = None, limit: int = Query(default=100, ge=1, le=500), session: Session = Depends(get_session), principal: ApiPrincipal = Depends(require_scope("system:tenants:read")), ): del principal if since is None: return _full_tenant_list_delta_response(session) entries, has_more = _tenant_list_delta_entries(session, since=since, limit=limit) if entries is None: return _full_tenant_list_delta_response(session) changed_ids = [entry.resource_id for entry in entries if entry.resource_id and entry.operation != "deleted"] tenants = [] if changed_ids: tenants = session.query(Tenant).filter(Tenant.id.in_(changed_ids)).order_by(Tenant.name.asc()).all() visible_tenant_ids = {tenant.id for tenant in tenants} return TenantListDeltaResponse( tenants=[_tenant_item(session, tenant) for tenant in tenants], deleted=_tenant_list_deleted_entries(entries, visible_tenant_ids), watermark=_tenant_list_response_watermark(session, entries=entries, has_more=has_more), has_more=has_more, full=False, ) @router.get("/tenants/owner-candidates", response_model=TenantOwnerCandidateListResponse) def list_tenant_owner_candidates( session: Session = Depends(get_session), principal: ApiPrincipal = Depends(require_scope("system:tenants:create")), ): del principal accounts = _tenant_access_provisioner().tenant_owner_candidates(session) return TenantOwnerCandidateListResponse( accounts=[ TenantOwnerCandidate(account_id=account.account_id, email=account.email, display_name=account.display_name) for account in accounts ] ) @router.post("/tenants", response_model=TenantAdminItem, status_code=status.HTTP_201_CREATED) def create_tenant( payload: TenantCreateRequest, session: Session = Depends(get_session), principal: ApiPrincipal = Depends(require_scope("system:tenants:create")), ): tenant_slug = slugify(payload.slug) if session.query(Tenant).filter(Tenant.slug == tenant_slug).count(): raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="A tenant with this slug already exists") system_defaults = get_system_settings(session) try: assert_tenant_governance_override_allowed(session, field="allow_custom_groups", value=payload.allow_custom_groups) assert_tenant_governance_override_allowed(session, field="allow_custom_roles", value=payload.allow_custom_roles) assert_tenant_governance_override_allowed(session, field="allow_api_keys", value=payload.allow_api_keys) except AdminValidationError as exc: raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc tenant = Tenant( slug=tenant_slug, name=payload.name.strip(), description=payload.description.strip() if payload.description else None, default_locale=payload.default_locale.strip() or system_defaults.default_locale, settings=payload.settings, allow_custom_groups=payload.allow_custom_groups, allow_custom_roles=payload.allow_custom_roles, allow_api_keys=payload.allow_api_keys, is_active=True, ) session.add(tenant) session.flush() owner_account_id = payload.owner_account_id or principal.account_id try: _tenant_access_provisioner().ensure_tenant_owner_membership( session, tenant=tenant, owner_account_id=owner_account_id, ) except AdminValidationError as exc: raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc audit_event( session, tenant_id=tenant.id, user_id=principal.user.id, action=TENANT_EVENT_CREATED, object_type="tenant", object_id=tenant.id, scope="system", details=tenant_lifecycle_event( "created", tenant_id=tenant.id, tenant_slug=tenant.slug, tenant_name=tenant.name, actor_account_id=principal.account_id, requested_by_account_id=owner_account_id, details={"creator_account_id": principal.account_id, "owner_account_id": owner_account_id}, ).audit_details(), ) _record_tenant_list_change(session, tenant=tenant, operation="created", principal=principal) session.commit() return _tenant_item(session, tenant) @router.patch("/tenants/{tenant_id}", response_model=TenantAdminItem) def update_tenant( tenant_id: str, payload: TenantUpdateRequest, session: Session = Depends(get_session), principal: ApiPrincipal = Depends(get_api_principal), ): non_status_fields = {"name", "description", "default_locale", "settings", "allow_custom_groups", "allow_custom_roles", "allow_api_keys"} if payload.model_fields_set.intersection(non_status_fields): _require_permission(principal, "system:tenants:update") if payload.is_active is not None: _require_permission(principal, "system:tenants:suspend") tenant = session.get(Tenant, tenant_id) if tenant is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Tenant not found") was_active = tenant.is_active before_sections = _tenant_settings_sections(_tenant_settings_item(session, tenant)) if payload.name is not None: tenant.name = payload.name.strip() if "description" in payload.model_fields_set: tenant.description = payload.description.strip() if payload.description and payload.description.strip() else None if payload.default_locale is not None: tenant.default_locale = payload.default_locale.strip() or "en" if payload.settings is not None: tenant.settings = payload.settings try: if "allow_custom_groups" in payload.model_fields_set: assert_tenant_governance_override_allowed(session, field="allow_custom_groups", value=payload.allow_custom_groups) tenant.allow_custom_groups = payload.allow_custom_groups if "allow_custom_roles" in payload.model_fields_set: assert_tenant_governance_override_allowed(session, field="allow_custom_roles", value=payload.allow_custom_roles) tenant.allow_custom_roles = payload.allow_custom_roles if "allow_api_keys" in payload.model_fields_set: assert_tenant_governance_override_allowed(session, field="allow_api_keys", value=payload.allow_api_keys) tenant.allow_api_keys = payload.allow_api_keys except AdminValidationError as exc: raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc if payload.is_active is not None: if not payload.is_active and tenant.id == principal.tenant_id: raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail="Switch to another tenant before suspending the active tenant.", ) tenant.is_active = payload.is_active session.add(tenant) audit_event( session, tenant_id=tenant.id, user_id=principal.user.id, action="tenant.updated", scope="system", object_type="tenant", object_id=tenant.id, details=payload.model_dump(exclude_unset=True), ) if payload.is_active is not None and payload.is_active != was_active: lifecycle_phase = "resumed" if payload.is_active else "suspended" audit_event( session, tenant_id=tenant.id, user_id=principal.user.id, action=tenant_lifecycle_event_type(lifecycle_phase), scope="system", object_type="tenant", object_id=tenant.id, details=tenant_lifecycle_event( lifecycle_phase, tenant_id=tenant.id, tenant_slug=tenant.slug, tenant_name=tenant.name, actor_account_id=principal.account_id, details={"previous_is_active": was_active, "is_active": tenant.is_active}, ).audit_details(), ) after_sections = _tenant_settings_sections(_tenant_settings_item(session, tenant)) _record_tenant_settings_section_changes(session, tenant_id=tenant.id, before=before_sections, after=after_sections, principal=principal) _record_tenant_list_change(session, tenant=tenant, operation="updated", principal=principal) session.commit() return _tenant_item(session, tenant) @router.get("/tenants/{tenant_id}/deletion-plan", response_model=TenantDeletionPlanResponse) def tenant_deletion_plan( tenant_id: str, session: Session = Depends(get_session), principal: ApiPrincipal = Depends(require_scope("system:tenants:suspend")), ): tenant = session.get(Tenant, tenant_id) if tenant is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Tenant not found") return _tenant_deletion_plan(session, tenant, principal) @router.delete("/tenants/{tenant_id}", response_model=TenantLifecycleResponse) def retire_tenant( tenant_id: str, payload: TenantDeleteRequest | None = Body(default=None), session: Session = Depends(get_session), principal: ApiPrincipal = Depends(require_scope("system:tenants:suspend")), ): request = payload or TenantDeleteRequest() tenant = session.get(Tenant, tenant_id) if tenant is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Tenant not found") plan = _tenant_deletion_plan(session, tenant, principal, mode=request.mode) if not plan.allowed: action_label = "destroyed" if request.mode == "destroy" else "retired" raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail={"message": f"Tenant cannot be {action_label}.", "plan": plan.model_dump(mode="json")}, ) if request.mode == "destroy": deleted_item = _tenant_item(session, tenant) deletion_event = tenant_lifecycle_event( "deletion_requested", tenant_id=tenant.id, tenant_slug=tenant.slug, tenant_name=tenant.name, actor_account_id=principal.account_id, reason=request.reason, counts=plan.counts, details={"mode": request.mode}, ) audit_event( session, tenant_id=tenant.id, user_id=principal.user.id, action=TENANT_EVENT_DELETION_REQUESTED, scope="system", object_type="tenant", object_id=tenant.id, details=deletion_event.audit_details(), ) audit_event( session, tenant_id=tenant.id, user_id=principal.user.id, action="tenant.destroyed", scope="system", object_type="tenant", object_id=tenant.id, details={"reason": request.reason, "counts": plan.counts}, ) audit_event( session, tenant_id=tenant.id, user_id=principal.user.id, action=TENANT_EVENT_ERASURE_COMPLETED, scope="system", object_type="tenant", object_id=tenant.id, details=tenant_lifecycle_event( "erasure_completed", tenant_id=tenant.id, tenant_slug=tenant.slug, tenant_name=tenant.name, actor_account_id=principal.account_id, reason=request.reason, counts=plan.counts, details={"mode": request.mode}, ).audit_details(), ) _record_tenant_list_change(session, tenant=tenant, operation="deleted", principal=principal) session.delete(tenant) session.commit() return TenantLifecycleResponse(item=deleted_item, plan=plan) settings_payload = dict(tenant.settings or {}) lifecycle_payload = dict(settings_payload.get("lifecycle") or {}) lifecycle_payload.update({ "state": "retired", "retired_at": utc_now().isoformat(), "retired_by_user_id": principal.user.id, }) if request.reason: lifecycle_payload["reason"] = request.reason.strip() settings_payload["lifecycle"] = lifecycle_payload tenant.settings = settings_payload tenant.is_active = False session.add(tenant) audit_event( session, tenant_id=tenant.id, user_id=principal.user.id, action=TENANT_EVENT_DELETION_REQUESTED, scope="system", object_type="tenant", object_id=tenant.id, details=tenant_lifecycle_event( "deletion_requested", tenant_id=tenant.id, tenant_slug=tenant.slug, tenant_name=tenant.name, actor_account_id=principal.account_id, reason=request.reason, counts=plan.counts, details={"mode": request.mode}, ).audit_details(), ) audit_event( session, tenant_id=tenant.id, user_id=principal.user.id, action="tenant.retired", scope="system", object_type="tenant", object_id=tenant.id, details={"reason": request.reason, "counts": plan.counts}, ) _record_tenant_list_change(session, tenant=tenant, operation="updated", principal=principal) session.commit() return TenantLifecycleResponse(item=_tenant_item(session, tenant), plan=plan) @router.get("/tenant/settings", response_model=TenantSettingsItem) def get_tenant_settings( session: Session = Depends(get_session), principal: ApiPrincipal = Depends(require_scope("admin:settings:read")), ): tenant = session.get(Tenant, principal.tenant_id) if tenant is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Tenant not found") return _tenant_settings_item(session, tenant) def _full_tenant_settings_delta_response(session: Session, tenant: Tenant) -> TenantSettingsDeltaResponse: item = _tenant_settings_item(session, tenant) return TenantSettingsDeltaResponse( item=item, sections=_tenant_settings_sections(item), changed_sections=list(TENANT_SETTINGS_SECTIONS), deleted=[], watermark=_tenant_settings_watermark(session, tenant_id=tenant.id), has_more=False, full=True, ) @router.get("/tenant/settings/delta", response_model=TenantSettingsDeltaResponse) def get_tenant_settings_delta( since: str | None = None, limit: int = Query(default=100, ge=1, le=500), session: Session = Depends(get_session), principal: ApiPrincipal = Depends(require_scope("admin:settings:read")), ): tenant = session.get(Tenant, principal.tenant_id) if tenant is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Tenant not found") if since is None: return _full_tenant_settings_delta_response(session, tenant) entries, has_more = _tenant_settings_delta_entries(session, tenant_id=tenant.id, since=since, limit=limit) if entries is None: return _full_tenant_settings_delta_response(session, tenant) changed = set() for entry in entries: if entry.module_id == ADMIN_MODULE_ID and entry.collection == ADMIN_SYSTEM_SETTINGS_COLLECTION and entry.resource_id == "languages": changed.add("languages") elif entry.resource_type == TENANT_SETTINGS_RESOURCE: changed.add(entry.resource_id) changed_sections = [section for section in TENANT_SETTINGS_SECTIONS if section in changed] item = _tenant_settings_item(session, tenant) sections = _tenant_settings_sections(item) return TenantSettingsDeltaResponse( item=None, sections={section: sections[section] for section in changed_sections}, changed_sections=changed_sections, deleted=[], watermark=_tenant_settings_response_watermark(session, tenant_id=tenant.id, entries=entries, has_more=has_more), has_more=has_more, full=False, ) @router.patch("/tenant/settings", response_model=TenantSettingsItem) def update_tenant_settings( payload: TenantSettingsUpdateRequest, session: Session = Depends(get_session), principal: ApiPrincipal = Depends(require_scope("admin:settings:write")), ): tenant = session.get(Tenant, principal.tenant_id) if tenant is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Tenant not found") before_sections = _tenant_settings_sections(_tenant_settings_item(session, tenant)) system_settings = get_system_settings(session) system_enabled = system_enabled_language_codes(system_settings.settings, default_locale=system_settings.default_locale) current_i18n = i18n_settings(tenant.settings) raw_enabled = payload.enabled_language_codes if "enabled_language_codes" in payload.model_fields_set else current_i18n.get("enabled_language_codes") enabled = normalize_enabled_language_codes( raw_enabled, [{"code": code} for code in system_enabled], default_locale=payload.default_locale, fallback_codes=system_enabled, ) tenant.default_locale = payload.default_locale.strip() or enabled[0] tenant.settings = update_i18n_settings(tenant.settings, enabled_language_codes=enabled) session.add(tenant) audit_event( session, tenant_id=tenant.id, user_id=principal.user.id, action="tenant.settings.updated", object_type="tenant", object_id=tenant.id, details={"default_locale": tenant.default_locale, "enabled_language_codes": enabled}, ) after_sections = _tenant_settings_sections(_tenant_settings_item(session, tenant)) _record_tenant_settings_section_changes(session, tenant_id=tenant.id, before=before_sections, after=after_sections, principal=principal) session.commit() return _tenant_settings_item(session, tenant)