"""``TenantService`` — tenant lifecycle and membership (``TENANCY.md`` §3, §6, §8).""" from __future__ import annotations import secrets from datetime import timedelta from typing import Any from django.utils import timezone from django.utils.text import slugify from infrasynth.shared.exceptions import ( ENTITLEMENT_TENANT_SUSPENDED, AuthError, EntitlementError, NotFoundError, ValidationAppError, ) from infrasynth.shared.settings_utils import get_setting from .models import PlatformStaff, Tenant, TenantInvitation, TenantMembership from .signals import ( membership_created, membership_revoked, tenant_archived, tenant_created, tenant_reinstated, tenant_suspended, tenant_switched, tenant_updated, ) __all__ = ["TenantService"] _INVITATION_TTL_DAYS = 7 class TenantService: """Public API for tenant resolution, membership, and lifecycle.""" # --- resolution --------------------------------------------------------- def get_active_memberships(self, user: Any) -> list[TenantMembership]: if not user or not user.is_authenticated: return [] return list( TenantMembership.objects.filter( user=user, is_active=True, tenant__status__in=[Tenant.Status.ACTIVE, Tenant.Status.TRIALING], ).select_related("tenant") ) def get_membership(self, user: Any, tenant_id: Any) -> TenantMembership | None: if not user or not user.is_authenticated: return None return ( TenantMembership.objects.filter(user=user, tenant_id=tenant_id, is_active=True) .select_related("tenant") .first() ) def select_tenant(self, user: Any, tenant_id: Any) -> Tenant: """Validates active membership and returns the tenant (or raises).""" membership = self.get_membership(user, tenant_id) if membership is None: # A cross-tenant id must not reveal existence. raise NotFoundError("No active workspace membership for this user.") tenant = membership.tenant if tenant.status == Tenant.Status.ARCHIVED: raise AuthError( "This workspace has been archived.", code="AUTH_TENANT_ARCHIVED", status=403, ) if tenant.status == Tenant.Status.SUSPENDED: raise EntitlementError( "This workspace is suspended.", code=ENTITLEMENT_TENANT_SUSPENDED, status=403, app=None, ) return tenant def switch_tenant(self, user: Any, tenant_id: Any, *, previous_tenant_id: Any = None) -> Tenant: tenant = self.select_tenant(user, tenant_id) tenant_switched.send( sender=Tenant, tenant_id=str(tenant.pk), user_id=getattr(user, "pk", None), previous_tenant_id=str(previous_tenant_id) if previous_tenant_id else None, ) return tenant def is_platform_staff(self, user: Any) -> bool: if not user or not user.is_authenticated: return False return PlatformStaff.objects.filter(user=user).exists() # --- creation / membership --------------------------------------------- def create_tenant(self, name: str, owner: Any, slug: str | None = None) -> Tenant: if not owner or not owner.is_authenticated: raise ValidationAppError("An authenticated owner is required.", code="VALIDATION_OWNER_REQUIRED") tenant = Tenant.objects.create( slug=self._unique_slug(slug or slugify(name) or "workspace"), name=name, locale=get_setting("INFRASYNTH_TENANCY", "DEFAULT_LOCALE", "es"), timezone=get_setting("INFRASYNTH_TENANCY", "DEFAULT_TIMEZONE", "UTC"), ) membership = TenantMembership.objects.create( tenant=tenant, user=owner, role="owner", is_owner=True, ) tenant_created.send(sender=Tenant, tenant_id=str(tenant.pk), slug=tenant.slug, name=tenant.name) membership_created.send( sender=TenantMembership, tenant_id=str(tenant.pk), membership_id=membership.pk, user_id=getattr(owner, "pk", None), role="owner", ) return tenant def add_member(self, tenant: Tenant, user: Any, role: str = "member", is_owner: bool = False) -> TenantMembership: membership, created = TenantMembership.objects.get_or_create( tenant=tenant, user=user, defaults={"role": role, "is_owner": is_owner, "is_active": True}, ) if not created and not membership.is_active: membership.is_active = True membership.role = role membership.save(update_fields=["is_active", "role"]) membership_created.send( sender=TenantMembership, tenant_id=str(tenant.pk), membership_id=membership.pk, user_id=getattr(user, "pk", None), role=role, ) return membership def remove_member(self, tenant: Tenant, user: Any) -> bool: membership = TenantMembership.objects.filter(tenant=tenant, user=user, is_active=True).first() if membership is None: return False membership.is_active = False membership.save(update_fields=["is_active"]) membership_revoked.send( sender=TenantMembership, tenant_id=str(tenant.pk), membership_id=membership.pk, user_id=getattr(user, "pk", None), ) return True # --- invitations -------------------------------------------------------- def invite(self, tenant: Tenant, email: str, role: str = "member", invited_by: Any = None) -> TenantInvitation: invitation = TenantInvitation.objects.create( tenant=tenant, email=email, role=role, token=secrets.token_urlsafe(32), invited_by=invited_by if getattr(invited_by, "is_authenticated", False) else None, expires_at=timezone.now() + timedelta(days=_INVITATION_TTL_DAYS), ) return invitation def accept_invitation(self, token: str, user: Any) -> TenantMembership: # Invitations are looked up by an unguessable token, so all_objects is safe here. invitation = TenantInvitation.all_objects.filter(token=token).select_related("tenant").first() if invitation is None or invitation.is_expired() or invitation.accepted_at is not None: raise NotFoundError("This invitation is invalid or has expired.") membership = self.add_member(invitation.tenant, user, role=invitation.role) invitation.accepted_at = timezone.now() invitation.save(update_fields=["accepted_at"]) return membership # --- lifecycle ---------------------------------------------------------- def update_tenant(self, tenant: Tenant, *, actor: Any = None, **changes: Any) -> Tenant: """Updates the editable tenant fields and emits ``tenant_updated``. Only ``name``/``locale``/``timezone``/``metadata`` are applied; ``None`` values are ignored. No signal (and no write) when nothing changes. """ allowed = {"name", "locale", "timezone", "metadata"} applied = {key: value for key, value in changes.items() if key in allowed and value is not None} if not applied: return tenant for field, value in applied.items(): setattr(tenant, field, value) tenant.save(update_fields=list(applied)) tenant_updated.send( sender=Tenant, tenant_id=str(tenant.pk), changes={key: str(value) for key, value in applied.items()}, actor_id=getattr(actor, "pk", None), ) return tenant def suspend(self, tenant: Tenant, reason: str = "") -> None: if tenant.status == Tenant.Status.SUSPENDED: return tenant.status = Tenant.Status.SUSPENDED tenant.suspended_at = timezone.now() tenant.save(update_fields=["status", "suspended_at"]) tenant_suspended.send(sender=Tenant, tenant_id=str(tenant.pk), reason=reason) def reinstate(self, tenant: Tenant) -> None: tenant.status = Tenant.Status.ACTIVE tenant.suspended_at = None tenant.save(update_fields=["status", "suspended_at"]) tenant_reinstated.send(sender=Tenant, tenant_id=str(tenant.pk)) def offboard(self, tenant: Tenant) -> None: """Archives a tenant and revokes memberships. Hard delete is a later step.""" TenantMembership.objects.filter(tenant=tenant, is_active=True).update(is_active=False) tenant.status = Tenant.Status.ARCHIVED tenant.archived_at = timezone.now() tenant.save(update_fields=["status", "archived_at"]) tenant_archived.send(sender=Tenant, tenant_id=str(tenant.pk)) # --- helpers ------------------------------------------------------------ def _unique_slug(self, base: str) -> str: base = base or "workspace" slug = base suffix = 1 while Tenant.objects.filter(slug=slug).exists(): suffix += 1 slug = f"{base}-{suffix}" return slug