""" Business logic för tenant-hantering med audit-logging och schema-isolering. """ from datetime import datetime, timezone, timedelta from typing import Optional, Dict, Any, List from uuid import UUID from sqlalchemy import select, func, and_, or_, desc, asc from sqlalchemy.ext.asyncio import AsyncSession from fastapi import HTTPException, status from app.config import get_settings from app.database import create_tenant_schema, schema_exists from app.models import Tenant, TenantType, TenantStatus, AuditAction from app.schemas import ( TenantCreate, TenantUpdate, TenantListParams, TenantActivateRequest, TenantSuspendRequest ) from app.services.audit_service import AuditService settings = get_settings() class TenantService: def __init__(self, db: AsyncSession): self.db = db self.audit = AuditService(db) # ─── READ ───────────────────────────────────────────────────────────────── async def get_by_id(self, tenant_id: UUID, include_deleted: bool = False) -> Optional[Tenant]: query = select(Tenant).where(Tenant.id == tenant_id) if not include_deleted: query = query.where(Tenant.is_deleted == "N") result = await self.db.execute(query) return result.scalar_one_or_none() async def get_by_slug(self, slug: str) -> Optional[Tenant]: result = await self.db.execute( select(Tenant).where(Tenant.slug == slug.lower().strip()).where(Tenant.is_deleted == "N") ) return result.scalar_one_or_none() async def get_by_org_number(self, org_number: str) -> Optional[Tenant]: cleaned = org_number.replace(" ", "").replace("-", "") result = await self.db.execute( select(Tenant).where(Tenant.org_number == cleaned).where(Tenant.is_deleted == "N") ) return result.scalar_one_or_none() async def list_tenants(self, params: TenantListParams) -> tuple[List[Tenant], int]: query = select(Tenant).where(Tenant.is_deleted == "N") count_query = select(func.count(Tenant.id)).where(Tenant.is_deleted == "N") if params.status: query = query.where(Tenant.status == params.status) count_query = count_query.where(Tenant.status == params.status) if params.tenant_type: query = query.where(Tenant.tenant_type == params.tenant_type) count_query = count_query.where(Tenant.tenant_type == params.tenant_type) if params.country: query = query.where(Tenant.country == params.country) count_query = count_query.where(Tenant.country == params.country) if params.city: query = query.where(Tenant.city.ilike(f"%{params.city}%")) count_query = count_query.where(Tenant.city.ilike(f"%{params.city}%")) if params.is_active is not None: if params.is_active: query = query.where(Tenant.status == TenantStatus.ACTIVE) count_query = count_query.where(Tenant.status == TenantStatus.ACTIVE) else: query = query.where(Tenant.status != TenantStatus.ACTIVE) count_query = count_query.where(Tenant.status != TenantStatus.ACTIVE) if params.search: search_filter = or_( Tenant.name.ilike(f"%{params.search}%"), Tenant.slug.ilike(f"%{params.search}%"), Tenant.city.ilike(f"%{params.search}%"), Tenant.org_number.ilike(f"%{params.search}%"), ) query = query.where(search_filter) count_query = count_query.where(search_filter) sort_column = getattr(Tenant, params.sort_by, Tenant.created_at) if params.sort_order == "desc": query = query.order_by(desc(sort_column)) else: query = query.order_by(asc(sort_column)) offset = (params.page - 1) * params.page_size query = query.offset(offset).limit(params.page_size) result = await self.db.execute(query) tenants = result.scalars().all() count_result = await self.db.execute(count_query) total = count_result.scalar() return list(tenants), total # ─── CREATE ─────────────────────────────────────────────────────────────── async def create_tenant( self, data: TenantCreate, actor_id: Optional[str] = None, request_context: Optional[Dict[str, Any]] = None ) -> Tenant: # Check slug uniqueness existing = await self.get_by_slug(data.slug) if existing: raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail=f"Tenant with slug '{data.slug}' already exists", ) # Check org_number uniqueness if data.org_number: existing_org = await self.get_by_org_number(data.org_number) if existing_org: raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail=f"Tenant with org number '{data.org_number}' already exists", ) schema_name = f"{settings.TENANT_SCHEMA_PREFIX}{data.slug}" if await schema_exists(schema_name): raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=f"Schema '{schema_name}' already exists in database", ) tenant = Tenant( name=data.name, slug=data.slug.lower().strip(), org_number=data.org_number, tenant_type=data.tenant_type, status=TenantStatus.PENDING, schema_name=schema_name, contact_email=data.contact_email, contact_phone=data.contact_phone, billing_email=data.billing_email, address=data.address, postal_code=data.postal_code, city=data.city, country=data.country, municipality_code=data.municipality_code, county_code=data.county_code, latitude=data.latitude, longitude=data.longitude, plan=data.plan, is_trial=data.is_trial, max_users=data.max_users, max_projects=data.max_projects, storage_quota_mb=data.storage_quota_mb, features=data.features or {}, settings=data.settings or {}, branding=data.branding or {}, description=data.description, website=data.website, created_by=actor_id, ) if data.is_trial and data.trial_days: tenant.trial_ends_at = datetime.now(timezone.utc) + timedelta(days=data.trial_days) tenant.status = TenantStatus.TRIAL self.db.add(tenant) await self.db.flush() await self.db.refresh(tenant) # Create schema try: await create_tenant_schema(schema_name) except Exception as e: await self.db.rollback() raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Failed to create tenant schema: {str(e)}", ) # Audit log rc = request_context or {} await self.audit.log( action=AuditAction.CREATE, entity_type="tenant", entity_id=str(tenant.id), actor_id=actor_id, new_values=tenant.to_dict(include_sensitive=True), tenant_id=str(tenant.id), tenant_schema=schema_name, **rc ) await self.audit.log( action=AuditAction.SCHEMA_CREATE, entity_type="tenant_schema", entity_id=str(tenant.id), actor_id=actor_id, new_values={"schema_name": schema_name}, tenant_id=str(tenant.id), tenant_schema=schema_name, **rc ) return tenant # ─── UPDATE ─────────────────────────────────────────────────────────────── async def update_tenant( self, tenant_id: UUID, data: TenantUpdate, actor_id: Optional[str] = None, request_context: Optional[Dict[str, Any]] = None ) -> Tenant: tenant = await self.get_by_id(tenant_id) if tenant is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Tenant not found") previous_values = tenant.to_dict(include_sensitive=True) changed_fields = [] update_data = data.model_dump(exclude_unset=True) for field, value in update_data.items(): if hasattr(tenant, field) and getattr(tenant, field) != value: setattr(tenant, field, value) changed_fields.append(field) if not changed_fields: return tenant tenant.updated_by = actor_id await self.db.flush() await self.db.refresh(tenant) rc = request_context or {} await self.audit.log( action=AuditAction.UPDATE, entity_type="tenant", entity_id=str(tenant.id), actor_id=actor_id, previous_values=previous_values, new_values=tenant.to_dict(include_sensitive=True), changed_fields=changed_fields, tenant_id=str(tenant.id), tenant_schema=tenant.schema_name, **rc ) return tenant # ─── DELETE ─────────────────────────────────────────────────────────────── async def delete_tenant( self, tenant_id: UUID, actor_id: Optional[str] = None, request_context: Optional[Dict[str, Any]] = None ) -> Tenant: tenant = await self.get_by_id(tenant_id) if tenant is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Tenant not found") if tenant.is_deleted == "Y": raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Tenant is already deleted") previous_values = tenant.to_dict(include_sensitive=True) tenant.is_deleted = "Y" tenant.deleted_at = datetime.now(timezone.utc) tenant.deleted_by = actor_id tenant.status = TenantStatus.CANCELLED await self.db.flush() rc = request_context or {} await self.audit.log( action=AuditAction.DELETE, entity_type="tenant", entity_id=str(tenant.id), actor_id=actor_id, previous_values=previous_values, new_values={"is_deleted": "Y", "deleted_at": tenant.deleted_at.isoformat()}, changed_fields=["is_deleted", "deleted_at", "status"], tenant_id=str(tenant.id), tenant_schema=tenant.schema_name, **rc ) return tenant async def hard_delete_tenant( self, tenant_id: UUID, actor_id: Optional[str] = None, request_context: Optional[Dict[str, Any]] = None ) -> None: tenant = await self.get_by_id(tenant_id, include_deleted=True) if tenant is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Tenant not found") rc = request_context or {} await self.audit.log( action=AuditAction.DELETE, entity_type="tenant", entity_id=str(tenant.id), actor_id=actor_id, previous_values=tenant.to_dict(include_sensitive=True), tenant_id=str(tenant.id), tenant_schema=tenant.schema_name, **rc ) await self.db.delete(tenant) await self.db.flush() # ─── STATUS MANAGEMENT ──────────────────────────────────────────────────── async def activate_tenant( self, tenant_id: UUID, data: TenantActivateRequest, actor_id: Optional[str] = None, request_context: Optional[Dict[str, Any]] = None ) -> Tenant: tenant = await self.get_by_id(tenant_id) if tenant is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Tenant not found") if tenant.status == TenantStatus.ACTIVE: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Tenant is already active") if tenant.status == TenantStatus.CANCELLED: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Cannot activate a cancelled tenant") previous_values = tenant.to_dict(include_sensitive=True) tenant.activate(activated_by=actor_id) tenant.plan_started_at = datetime.now(timezone.utc) await self.db.flush() await self.db.refresh(tenant) rc = request_context or {} await self.audit.log( action=AuditAction.ACTIVATE, entity_type="tenant", entity_id=str(tenant.id), actor_id=actor_id, previous_values=previous_values, new_values=tenant.to_dict(include_sensitive=True), changed_fields=["status", "activated_at", "activated_by", "plan_started_at"], tenant_id=str(tenant.id), tenant_schema=tenant.schema_name, **rc ) return tenant async def suspend_tenant( self, tenant_id: UUID, data: TenantSuspendRequest, actor_id: Optional[str] = None, request_context: Optional[Dict[str, Any]] = None ) -> Tenant: tenant = await self.get_by_id(tenant_id) if tenant is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Tenant not found") if tenant.status == TenantStatus.SUSPENDED: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Tenant is already suspended") if tenant.status == TenantStatus.CANCELLED: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Cannot suspend a cancelled tenant") previous_values = tenant.to_dict(include_sensitive=True) tenant.suspend(reason=data.reason, suspended_by=actor_id or "system") await self.db.flush() await self.db.refresh(tenant) rc = request_context or {} await self.audit.log( action=AuditAction.SUSPEND, entity_type="tenant", entity_id=str(tenant.id), actor_id=actor_id, previous_values=previous_values, new_values=tenant.to_dict(include_sensitive=True), changed_fields=["status", "suspended_at", "suspended_reason", "suspended_by"], tenant_id=str(tenant.id), tenant_schema=tenant.schema_name, **rc ) return tenant async def cancel_tenant( self, tenant_id: UUID, reason: Optional[str] = None, actor_id: Optional[str] = None, request_context: Optional[Dict[str, Any]] = None ) -> Tenant: tenant = await self.get_by_id(tenant_id) if tenant is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Tenant not found") if tenant.status == TenantStatus.CANCELLED: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Tenant is already cancelled") previous_values = tenant.to_dict(include_sensitive=True) tenant.cancel(reason=reason) await self.db.flush() await self.db.refresh(tenant) rc = request_context or {} await self.audit.log( action=AuditAction.CANCEL, entity_type="tenant", entity_id=str(tenant.id), actor_id=actor_id, previous_values=previous_values, new_values=tenant.to_dict(include_sensitive=True), changed_fields=["status", "cancelled_at", "cancellation_reason"], tenant_id=str(tenant.id), tenant_schema=tenant.schema_name, **rc ) return tenant # ─── STATISTICS ─────────────────────────────────────────────────────────── async def get_statistics(self) -> Dict[str, Any]: status_counts = {} for st in TenantStatus: result = await self.db.execute( select(func.count(Tenant.id)) .where(Tenant.status == st) .where(Tenant.is_deleted == "N") ) status_counts[st.value] = result.scalar() type_counts = {} for tt in TenantType: result = await self.db.execute( select(func.count(Tenant.id)) .where(Tenant.tenant_type == tt) .where(Tenant.is_deleted == "N") ) type_counts[tt.value] = result.scalar() plan_result = await self.db.execute( select(Tenant.plan, func.count(Tenant.id)) .where(Tenant.is_deleted == "N") .group_by(Tenant.plan) ) plan_counts = {plan: count for plan, count in plan_result.all()} total_result = await self.db.execute( select(func.count(Tenant.id)).where(Tenant.is_deleted == "N") ) total = total_result.scalar() return { "total_tenants": total, "active_tenants": status_counts.get("active", 0), "pending_tenants": status_counts.get("pending", 0), "suspended_tenants": status_counts.get("suspended", 0), "trial_tenants": status_counts.get("trial", 0), "cancelled_tenants": status_counts.get("cancelled", 0), "by_type": type_counts, "by_plan": plan_counts, }