Files
boc/landvex-admin-backend/app/routers/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

293 lines
12 KiB
Python

"""
Tenant CRUD-endpoints för LandveX Admin Backend.
Inkluderar audit-logging, validering, felhantering och schema-isolering.
"""
from typing import Optional, Dict, Any
from uuid import UUID
from fastapi import APIRouter, Depends, Request, HTTPException, status, Query
from app.database import get_db
from app.dependencies import get_current_user, require_admin
from app.schemas import (
TenantCreate, TenantUpdate, TenantOut, TenantDetailOut,
TenantListOut, TenantListParams, TenantStatusUpdate,
TenantActivateRequest, TenantSuspendRequest, TenantStats,
SuccessResponse, AuditLogListOut
)
from app.services.tenant_service import TenantService
from app.services.audit_service import AuditService
from app.models import User
router = APIRouter(prefix="/tenants", tags=["Tenants"])
def _request_context(request: Request, current_user: User) -> Dict[str, Any]:
"""Bygg request context för audit-logging."""
return {
"request_id": request.headers.get("X-Request-ID", ""),
"request_method": request.method,
"request_path": str(request.url.path),
"actor_ip": request.client.host if request.client else None,
"actor_user_agent": request.headers.get("user-agent", ""),
}
# ─── LIST ─────────────────────────────────────────────────────────────────────
@router.get("", response_model=TenantListOut)
async def list_tenants(
request: Request,
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
status: Optional[str] = Query(None),
tenant_type: Optional[str] = Query(None),
search: Optional[str] = Query(None, max_length=100),
country: Optional[str] = Query(None, max_length=2),
city: Optional[str] = Query(None, max_length=100),
sort_by: str = Query("created_at"),
sort_order: str = Query("desc", pattern=r"^(asc|desc)$"),
is_active: Optional[bool] = Query(None),
current_user: User = Depends(require_admin),
db=Depends(get_db),
):
"""Lista alla tenants med paginering, filtrering och sortering."""
from app.models import TenantStatus as TS, TenantType as TT
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
)
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}")
service = TenantService(db)
tenants, total = await service.list_tenants(params)
pages = (total + page_size - 1) // page_size
return TenantListOut(
items=[TenantOut.model_validate(t.to_dict()) for t in tenants],
total=total, page=page, page_size=page_size, pages=pages
)
# ─── STATS ────────────────────────────────────────────────────────────────────
@router.get("/stats", response_model=TenantStats)
async def get_tenant_stats(
current_user: User = Depends(require_admin),
db=Depends(get_db),
):
"""Hämta aggregerad statistik om alla tenants."""
service = TenantService(db)
stats = await service.get_statistics()
return TenantStats.model_validate(stats)
# ─── GET ONE ──────────────────────────────────────────────────────────────────
@router.get("/{tenant_id}", response_model=TenantDetailOut)
async def get_tenant(
tenant_id: UUID,
current_user: User = Depends(get_current_user),
db=Depends(get_db),
):
"""Hämta en tenant med ID. Admin ser allt, andra bara sin egen."""
service = TenantService(db)
tenant = await service.get_by_id(tenant_id)
if tenant is None:
raise HTTPException(status_code=404, detail="Tenant not found")
user_role = current_user.role.value if hasattr(current_user.role, 'value') else str(current_user.role)
user_tenant = str(current_user.tenant_id) if current_user.tenant_id else None
if user_role != "admin" and user_tenant != str(tenant_id):
raise HTTPException(status_code=403, detail="Access denied for this tenant")
include_sensitive = user_role == "admin"
return tenant.to_dict(include_sensitive=include_sensitive)
# ─── CREATE ───────────────────────────────────────────────────────────────────
@router.post("", response_model=TenantDetailOut, status_code=status.HTTP_201_CREATED)
async def create_tenant(
request: Request,
data: TenantCreate,
current_user: User = Depends(require_admin),
db=Depends(get_db),
):
"""Skapa en ny tenant (kommun eller företag)."""
service = TenantService(db)
tenant = await service.create_tenant(
data=data,
actor_id=str(current_user.id),
request_context=_request_context(request, current_user)
)
return tenant.to_dict(include_sensitive=True)
# ─── UPDATE ───────────────────────────────────────────────────────────────────
@router.patch("/{tenant_id}", response_model=TenantDetailOut)
async def update_tenant(
request: Request,
tenant_id: UUID,
data: TenantUpdate,
current_user: User = Depends(get_current_user),
db=Depends(get_db),
):
"""Uppdatera en tenant. Endast angivna fält uppdateras."""
user_role = current_user.role.value if hasattr(current_user.role, 'value') else str(current_user.role)
user_tenant = str(current_user.tenant_id) if current_user.tenant_id else None
if user_role != "admin" and user_tenant != str(tenant_id):
raise HTTPException(status_code=403, detail="Access denied for this tenant")
service = TenantService(db)
tenant = await service.update_tenant(
tenant_id=tenant_id,
data=data,
actor_id=str(current_user.id),
request_context=_request_context(request, current_user)
)
include_sensitive = user_role == "admin"
return tenant.to_dict(include_sensitive=include_sensitive)
# ─── DELETE (soft) ────────────────────────────────────────────────────────────
@router.delete("/{tenant_id}", response_model=SuccessResponse)
async def delete_tenant(
request: Request,
tenant_id: UUID,
current_user: User = Depends(require_admin),
db=Depends(get_db),
):
"""Mjuk-radera en tenant. Datat bevaras men markeras som borttaget."""
service = TenantService(db)
tenant = await service.delete_tenant(
tenant_id=tenant_id,
actor_id=str(current_user.id),
request_context=_request_context(request, current_user)
)
return SuccessResponse(
message=f"Tenant '{tenant.name}' has been deleted",
data={"tenant_id": str(tenant.id), "deleted_at": tenant.deleted_at.isoformat() if tenant.deleted_at else None}
)
# ─── HARD DELETE ──────────────────────────────────────────────────────────────
@router.delete("/{tenant_id}/hard", response_model=SuccessResponse)
async def hard_delete_tenant(
request: Request,
tenant_id: UUID,
current_user: User = Depends(require_admin),
db=Depends(get_db),
):
"""Permanent borttagning av en tenant. Åtgärden kan INTE ångras!"""
service = TenantService(db)
await service.hard_delete_tenant(
tenant_id=tenant_id,
actor_id=str(current_user.id),
request_context=_request_context(request, current_user)
)
return SuccessResponse(message=f"Tenant {tenant_id} has been permanently deleted")
# ─── ACTIVATE ─────────────────────────────────────────────────────────────────
@router.post("/{tenant_id}/activate", response_model=TenantDetailOut)
async def activate_tenant(
request: Request,
tenant_id: UUID,
data: TenantActivateRequest = TenantActivateRequest(),
current_user: User = Depends(require_admin),
db=Depends(get_db),
):
"""Aktivera en tenant (PENDING/TRIAL → ACTIVE)."""
service = TenantService(db)
tenant = await service.activate_tenant(
tenant_id=tenant_id,
data=data,
actor_id=str(current_user.id),
request_context=_request_context(request, current_user)
)
return tenant.to_dict(include_sensitive=True)
# ─── SUSPEND ──────────────────────────────────────────────────────────────────
@router.post("/{tenant_id}/suspend", response_model=TenantDetailOut)
async def suspend_tenant(
request: Request,
tenant_id: UUID,
data: TenantSuspendRequest,
current_user: User = Depends(require_admin),
db=Depends(get_db),
):
"""Suspendera en tenant tillfälligt. Kan återaktiveras senare."""
service = TenantService(db)
tenant = await service.suspend_tenant(
tenant_id=tenant_id,
data=data,
actor_id=str(current_user.id),
request_context=_request_context(request, current_user)
)
return tenant.to_dict(include_sensitive=True)
# ─── CANCEL ───────────────────────────────────────────────────────────────────
@router.post("/{tenant_id}/cancel", response_model=TenantDetailOut)
async def cancel_tenant(
request: Request,
tenant_id: UUID,
reason: Optional[str] = None,
current_user: User = Depends(require_admin),
db=Depends(get_db),
):
"""Avsluta en tenant-prenumeration. Typiskt oåterkallbart."""
service = TenantService(db)
tenant = await service.cancel_tenant(
tenant_id=tenant_id,
reason=reason,
actor_id=str(current_user.id),
request_context=_request_context(request, current_user)
)
return tenant.to_dict(include_sensitive=True)
# ─── AUDIT LOGS ───────────────────────────────────────────────────────────────
@router.get("/{tenant_id}/audit-logs", response_model=AuditLogListOut)
async def get_tenant_audit_logs(
tenant_id: UUID,
limit: int = Query(50, ge=1, le=200),
offset: int = Query(0, ge=0),
current_user: User = Depends(require_admin),
db=Depends(get_db),
):
"""Hämta audit-loggar för en specifik tenant."""
audit_service = AuditService(db)
logs, total = await audit_service.get_by_tenant(
tenant_id=str(tenant_id), limit=limit, offset=offset
)
return AuditLogListOut(
items=[log.to_dict() for log in logs],
total=total, limit=limit, offset=offset
)