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
This commit is contained in:
Bernt
2026-07-05 06:41:32 +00:00
parent f4f853d94b
commit aee0f09db8
19583 changed files with 1450867 additions and 1153 deletions
+117
View File
@@ -0,0 +1,117 @@
"""
LandveX Admin Backend — Audit Log API Routes
Endpoints for querying audit logs
"""
from typing import Optional, Dict, Any
from uuid import UUID
from fastapi import APIRouter, Depends, Query, HTTPException, status
from app.core.security import get_current_user, require_admin
from app.api.dependencies import get_audit_service, get_request_context
from app.services.audit_service import AuditService
from app.schemas.common import AuditLogResponse
router = APIRouter(prefix="/audit-logs", tags=["audit"])
@router.get(
"",
summary="List audit logs",
description="Query audit logs with filtering"
)
async def list_audit_logs(
limit: int = Query(50, ge=1, le=200),
offset: int = Query(0, ge=0),
entity_type: Optional[str] = Query(None, description="Filter by entity type"),
entity_id: Optional[str] = Query(None, description="Filter by entity ID"),
actor_id: Optional[str] = Query(None, description="Filter by actor"),
action: Optional[str] = Query(None, description="Filter by action type"),
audit_service: AuditService = Depends(get_audit_service),
current_user: Dict[str, Any] = Depends(require_admin)
):
"""List audit logs with filtering.
Admin access required.
"""
logs = await audit_service.get_recent(limit=limit)
# Apply filters in memory (for small datasets)
# In production, add database-level filtering
if entity_type:
logs = [l for l in logs if l.entity_type == entity_type]
if entity_id:
logs = [l for l in logs if l.entity_id == entity_id]
if actor_id:
logs = [l for l in logs if l.actor_id == actor_id]
if action:
from app.models.audit_log import AuditAction
try:
action_enum = AuditAction(action)
logs = [l for l in logs if l.action == action_enum]
except ValueError:
pass
return {
"items": [log.to_dict() for log in logs],
"total": len(logs),
"limit": limit,
"offset": offset
}
@router.get(
"/entity/{entity_type}/{entity_id}",
summary="Get entity audit history",
description="Get all audit logs for a specific entity"
)
async def get_entity_audit_logs(
entity_type: str,
entity_id: str,
limit: int = Query(100, ge=1, le=500),
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 specific entity."""
logs, total = await audit_service.get_by_entity(
entity_type=entity_type,
entity_id=entity_id,
limit=limit,
offset=offset
)
return {
"items": [log.to_dict() for log in logs],
"total": total,
"limit": limit,
"offset": offset
}
@router.get(
"/actor/{actor_id}",
summary="Get actor audit history",
description="Get all audit logs for a specific actor (user)"
)
async def get_actor_audit_logs(
actor_id: str,
limit: int = Query(100, ge=1, le=500),
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 specific actor."""
logs, total = await audit_service.get_by_actor(
actor_id=actor_id,
limit=limit,
offset=offset
)
return {
"items": [log.to_dict() for log in logs],
"total": total,
"limit": limit,
"offset": offset
}
@@ -0,0 +1,115 @@
"""
LandveX Admin Backend — API Dependencies
FastAPI dependencies for auth, database, and request context
"""
from typing import Optional, Dict, Any
from uuid import UUID
from fastapi import Depends, Request, HTTPException, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
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.services.tenant_service import TenantService
from app.services.audit_service import AuditService
from app.core.exceptions import TenantNotFoundException, UnauthorizedTenantAccessException
security = HTTPBearer(auto_error=False)
async def get_tenant_service(
db: AsyncSession = Depends(get_db)
) -> TenantService:
"""Dependency to get tenant service."""
return TenantService(db)
async def get_audit_service(
db: AsyncSession = Depends(get_db)
) -> AuditService:
"""Dependency to get audit service."""
return AuditService(db)
async def get_request_context(
request: Request,
current_user: Optional[Dict[str, Any]] = Depends(get_current_user)
) -> Dict[str, Any]:
"""Build request context for audit logging."""
context = {
"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", ""),
}
if current_user:
context["actor_id"] = current_user.get("id")
return context
async def get_current_tenant_id(
request: Request
) -> Optional[str]:
"""Extract tenant ID from request (header or path)."""
# Try header first
tenant_id = request.headers.get("X-Tenant-ID")
if tenant_id:
return tenant_id
# Try path parameter
tenant_id = request.path_params.get("tenant_id")
if tenant_id:
return tenant_id
return None
async def require_tenant_admin(
tenant_id: UUID,
current_user: Dict[str, Any] = Depends(get_current_user),
tenant_service: TenantService = Depends(get_tenant_service)
) -> Dict[str, Any]:
"""Require admin access to a specific tenant."""
user_role = current_user.get("role", "")
user_tenant = current_user.get("tenant_id")
# Global admin can access all tenants
if user_role == "admin":
return current_user
# Tenant admin can only access their own tenant
if user_role == "tenant_admin" and str(user_tenant) == str(tenant_id):
return current_user
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Admin access required for tenant {tenant_id}"
)
async def optional_auth(
credentials: HTTPAuthorizationCredentials = Depends(security)
) -> Optional[Dict[str, Any]]:
"""Optional authentication - returns user if token valid, None otherwise."""
if credentials is None:
return None
from app.core.security import decode_token
payload = decode_token(credentials.credentials)
if payload is None:
return None
return {
"id": payload.get("sub"),
"email": payload.get("email"),
"role": payload.get("role", "user"),
"tenant_id": payload.get("tenant_id"),
"permissions": payload.get("permissions", []),
}
+442
View File
@@ -0,0 +1,442 @@
"""
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
}