aee0f09db8
- Datafabrik: Dockerfile fix, agentorkestrering fungerar - Vision: Identify-modell, FAISS, OCR alla testade - API: Alla 7 integrationstester passerade - Upplösare: Entitetsupplösning verifierad
484 lines
18 KiB
Python
484 lines
18 KiB
Python
"""
|
|
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,
|
|
}
|