Files
boc/landvex-admin-backend/app/api/tenants.py
T
Bernt aee0f09db8 landvex: Fixar och tester klara för alla komponenter
- Datafabrik: Dockerfile fix, agentorkestrering fungerar
- Vision: Identify-modell, FAISS, OCR alla testade
- API: Alla 7 integrationstester passerade
- Upplösare: Entitetsupplösning verifierad
2026-07-05 06:41:32 +00:00

443 lines
15 KiB
Python

"""
LandveX Admin Backend — Tenant API Routes
CRUD endpoints for tenant management with validation and audit logging
"""
from typing import Optional, Dict, Any
from uuid import UUID
from fastapi import APIRouter, Depends, Request, HTTPException, status, Query
from fastapi.responses import JSONResponse
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.session import get_db
from app.core.security import get_current_user, require_admin
from app.core.exceptions import (
LandvexException,
TenantNotFoundException,
TenantAlreadyExistsException,
TenantValidationException,
)
from app.api.dependencies import (
get_tenant_service,
get_audit_service,
get_request_context,
require_tenant_admin,
)
from app.services.tenant_service import TenantService
from app.services.audit_service import AuditService
from app.schemas.tenant import (
TenantCreate, TenantUpdate, TenantResponse, TenantDetailResponse,
TenantListResponse, TenantListParams, TenantStatusUpdate,
TenantActivateRequest, TenantSuspendRequest, TenantStats,
)
from app.schemas.common import ErrorResponse, SuccessResponse
router = APIRouter(prefix="/tenants", tags=["tenants"])
# ─── Exception Handlers ───────────────────────────────────────────────────────
@router.exception_handler(LandvexException)
async def landvex_exception_handler(request: Request, exc: LandvexException):
"""Handle Landvex exceptions."""
return JSONResponse(
status_code=exc.status_code,
content={
"error": exc.message,
"details": exc.details,
"request_id": request.headers.get("X-Request-ID", ""),
}
)
# ─── CREATE ───────────────────────────────────────────────────────────────────
@router.post(
"",
response_model=TenantDetailResponse,
status_code=status.HTTP_201_CREATED,
summary="Create a new tenant",
description="Create a new municipality or company tenant with schema isolation"
)
async def create_tenant(
data: TenantCreate,
db: AsyncSession = Depends(get_db),
tenant_service: TenantService = Depends(get_tenant_service),
current_user: Dict[str, Any] = Depends(require_admin),
request_context: Dict[str, Any] = Depends(get_request_context)
):
"""Create a new tenant.
- **name**: Display name of the tenant
- **slug**: URL-friendly unique identifier
- **tenant_type**: Type of tenant (municipality, company, etc.)
- **contact_email**: Primary contact email
"""
try:
tenant = await tenant_service.create(
data=data,
actor_id=current_user.get("id"),
request_context=request_context
)
return tenant.to_dict(include_sensitive=True)
except TenantAlreadyExistsException as e:
raise HTTPException(status_code=e.status_code, detail=e.message)
except TenantValidationException as e:
raise HTTPException(status_code=e.status_code, detail=e.message)
# ─── READ ─────────────────────────────────────────────────────────────────────
@router.get(
"",
response_model=TenantListResponse,
summary="List all tenants",
description="List tenants with pagination, filtering, and sorting"
)
async def list_tenants(
page: int = Query(1, ge=1, description="Page number"),
page_size: int = Query(20, ge=1, le=100, description="Items per page"),
status: Optional[str] = Query(None, description="Filter by status"),
tenant_type: Optional[str] = Query(None, description="Filter by type"),
search: Optional[str] = Query(None, max_length=100, description="Search query"),
country: Optional[str] = Query(None, max_length=2, description="Filter by country code"),
city: Optional[str] = Query(None, max_length=100, description="Filter by city"),
sort_by: str = Query("created_at", description="Sort field"),
sort_order: str = Query("desc", pattern=r"^(asc|desc)$", description="Sort direction"),
is_active: Optional[bool] = Query(None, description="Filter by active status"),
db: AsyncSession = Depends(get_db),
tenant_service: TenantService = Depends(get_tenant_service),
current_user: Dict[str, Any] = Depends(get_current_user)
):
"""List all tenants with filtering and pagination."""
from app.models.tenant import TenantStatus as TS, TenantType as TT
# Build params
params = TenantListParams(
page=page,
page_size=page_size,
sort_by=sort_by,
sort_order=sort_order,
search=search,
country=country,
city=city,
is_active=is_active
)
# Parse enum values
if status:
try:
params.status = TS(status)
except ValueError:
raise HTTPException(status_code=400, detail=f"Invalid status: {status}")
if tenant_type:
try:
params.tenant_type = TT(tenant_type)
except ValueError:
raise HTTPException(status_code=400, detail=f"Invalid tenant_type: {tenant_type}")
tenants, total = await tenant_service.list(params)
pages = (total + page_size - 1) // page_size
return TenantListResponse(
items=[TenantResponse.model_validate(t.to_dict()) for t in tenants],
total=total,
page=page,
page_size=page_size,
pages=pages
)
@router.get(
"/stats",
response_model=TenantStats,
summary="Get tenant statistics",
description="Get aggregated statistics about all tenants"
)
async def get_tenant_stats(
tenant_service: TenantService = Depends(get_tenant_service),
current_user: Dict[str, Any] = Depends(require_admin)
):
"""Get tenant statistics."""
stats = await tenant_service.get_statistics()
return TenantStats.model_validate(stats)
@router.get(
"/{tenant_id}",
response_model=TenantDetailResponse,
summary="Get tenant by ID",
description="Get detailed information about a specific tenant"
)
async def get_tenant(
tenant_id: UUID,
tenant_service: TenantService = Depends(get_tenant_service),
current_user: Dict[str, Any] = Depends(get_current_user)
):
"""Get a tenant by ID."""
try:
tenant = await tenant_service.get_by_id(tenant_id)
# Check access (admin can see all, others only their tenant)
user_role = current_user.get("role", "")
user_tenant = current_user.get("tenant_id")
if user_role != "admin" and str(user_tenant) != str(tenant_id):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Access denied for this tenant"
)
# Return sensitive data only for admins
include_sensitive = user_role == "admin"
return tenant.to_dict(include_sensitive=include_sensitive)
except TenantNotFoundException as e:
raise HTTPException(status_code=e.status_code, detail=e.message)
# ─── UPDATE ───────────────────────────────────────────────────────────────────
@router.put(
"/{tenant_id}",
response_model=TenantDetailResponse,
summary="Update tenant",
description="Update tenant information"
)
async def update_tenant(
tenant_id: UUID,
data: TenantUpdate,
tenant_service: TenantService = Depends(get_tenant_service),
current_user: Dict[str, Any] = Depends(get_current_user),
request_context: Dict[str, Any] = Depends(get_request_context)
):
"""Update a tenant.
Only provided fields will be updated. Omit fields to keep current values.
"""
try:
# Check permissions
user_role = current_user.get("role", "")
user_tenant = current_user.get("tenant_id")
if user_role != "admin" and str(user_tenant) != str(tenant_id):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Access denied for this tenant"
)
tenant = await tenant_service.update(
tenant_id=tenant_id,
data=data,
actor_id=current_user.get("id"),
request_context=request_context
)
include_sensitive = user_role == "admin"
return tenant.to_dict(include_sensitive=include_sensitive)
except TenantNotFoundException as e:
raise HTTPException(status_code=e.status_code, detail=e.message)
except TenantValidationException as e:
raise HTTPException(status_code=e.status_code, detail=e.message)
# ─── DELETE ───────────────────────────────────────────────────────────────────
@router.delete(
"/{tenant_id}",
response_model=SuccessResponse,
summary="Delete tenant",
description="Soft delete a tenant (can be restored by admin)"
)
async def delete_tenant(
tenant_id: UUID,
tenant_service: TenantService = Depends(get_tenant_service),
current_user: Dict[str, Any] = Depends(require_admin),
request_context: Dict[str, Any] = Depends(get_request_context)
):
"""Soft delete a tenant.
The tenant data is preserved but marked as deleted.
Use hard delete for permanent removal (admin only).
"""
try:
tenant = await tenant_service.delete(
tenant_id=tenant_id,
actor_id=current_user.get("id"),
request_context=request_context
)
return SuccessResponse(
message=f"Tenant '{tenant.name}' has been deleted",
data={"tenant_id": str(tenant.id), "deleted_at": tenant.deleted_at.isoformat()}
)
except TenantNotFoundException as e:
raise HTTPException(status_code=e.status_code, detail=e.message)
except TenantValidationException as e:
raise HTTPException(status_code=e.status_code, detail=e.message)
@router.delete(
"/{tenant_id}/hard",
response_model=SuccessResponse,
summary="Hard delete tenant",
description="Permanently delete a tenant and all associated data"
)
async def hard_delete_tenant(
tenant_id: UUID,
tenant_service: TenantService = Depends(get_tenant_service),
current_user: Dict[str, Any] = Depends(require_admin),
request_context: Dict[str, Any] = Depends(get_request_context)
):
"""Hard delete a tenant (permanent removal).
**Warning**: This action cannot be undone. All tenant data will be permanently deleted.
"""
try:
await tenant_service.hard_delete(
tenant_id=tenant_id,
actor_id=current_user.get("id"),
request_context=request_context
)
return SuccessResponse(
message=f"Tenant {tenant_id} has been permanently deleted"
)
except TenantNotFoundException as e:
raise HTTPException(status_code=e.status_code, detail=e.message)
# ─── STATUS MANAGEMENT ────────────────────────────────────────────────────────
@router.post(
"/{tenant_id}/activate",
response_model=TenantDetailResponse,
summary="Activate tenant",
description="Activate a pending or trial tenant"
)
async def activate_tenant(
tenant_id: UUID,
data: TenantActivateRequest = TenantActivateRequest(),
tenant_service: TenantService = Depends(get_tenant_service),
current_user: Dict[str, Any] = Depends(require_admin),
request_context: Dict[str, Any] = Depends(get_request_context)
):
"""Activate a tenant.
Changes status from PENDING or TRIAL to ACTIVE.
"""
try:
tenant = await tenant_service.activate(
tenant_id=tenant_id,
data=data,
actor_id=current_user.get("id"),
request_context=request_context
)
return tenant.to_dict(include_sensitive=True)
except TenantNotFoundException as e:
raise HTTPException(status_code=e.status_code, detail=e.message)
except TenantValidationException as e:
raise HTTPException(status_code=e.status_code, detail=e.message)
@router.post(
"/{tenant_id}/suspend",
response_model=TenantDetailResponse,
summary="Suspend tenant",
description="Temporarily suspend a tenant"
)
async def suspend_tenant(
tenant_id: UUID,
data: TenantSuspendRequest,
tenant_service: TenantService = Depends(get_tenant_service),
current_user: Dict[str, Any] = Depends(require_admin),
request_context: Dict[str, Any] = Depends(get_request_context)
):
"""Suspend a tenant.
Temporarily disables tenant access. Can be reactivated later.
"""
try:
tenant = await tenant_service.suspend(
tenant_id=tenant_id,
data=data,
actor_id=current_user.get("id"),
request_context=request_context
)
return tenant.to_dict(include_sensitive=True)
except TenantNotFoundException as e:
raise HTTPException(status_code=e.status_code, detail=e.message)
except TenantValidationException as e:
raise HTTPException(status_code=e.status_code, detail=e.message)
@router.post(
"/{tenant_id}/cancel",
response_model=TenantDetailResponse,
summary="Cancel tenant",
description="Cancel a tenant subscription"
)
async def cancel_tenant(
tenant_id: UUID,
reason: Optional[str] = None,
tenant_service: TenantService = Depends(get_tenant_service),
current_user: Dict[str, Any] = Depends(require_admin),
request_context: Dict[str, Any] = Depends(get_request_context)
):
"""Cancel a tenant subscription.
Marks tenant as cancelled. This is typically irreversible.
"""
try:
tenant = await tenant_service.cancel(
tenant_id=tenant_id,
reason=reason,
actor_id=current_user.get("id"),
request_context=request_context
)
return tenant.to_dict(include_sensitive=True)
except TenantNotFoundException as e:
raise HTTPException(status_code=e.status_code, detail=e.message)
except TenantValidationException as e:
raise HTTPException(status_code=e.status_code, detail=e.message)
# ─── AUDIT LOGS ───────────────────────────────────────────────────────────────
@router.get(
"/{tenant_id}/audit-logs",
summary="Get tenant audit logs",
description="Get audit log history for a specific tenant"
)
async def get_tenant_audit_logs(
tenant_id: UUID,
limit: int = Query(50, ge=1, le=200),
offset: int = Query(0, ge=0),
audit_service: AuditService = Depends(get_audit_service),
current_user: Dict[str, Any] = Depends(require_admin)
):
"""Get audit logs for a tenant."""
logs, total = await audit_service.get_by_tenant(
tenant_id=str(tenant_id),
limit=limit,
offset=offset
)
return {
"items": [log.to_dict() for log in logs],
"total": total,
"limit": limit,
"offset": offset
}