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

332 lines
11 KiB
Python

"""
Pydantic-scheman för LandveX Admin Backend.
Request/response-validering för tenants, users, auth, invitations, audit.
"""
from datetime import datetime
from typing import Optional, Dict, Any, List
from uuid import UUID
from pydantic import BaseModel, EmailStr, Field, ConfigDict, field_validator
from app.models import UserRole, UserStatus, TenantType, TenantStatus
# ─── Shared ───────────────────────────────────────────────────────────────────
class UserBase(BaseModel):
email: EmailStr
first_name: str = Field(..., min_length=1, max_length=100)
last_name: str = Field(..., min_length=1, max_length=100)
role: UserRole = UserRole.VIEWER
# ─── Tenant ───────────────────────────────────────────────────────────────────
class TenantBase(BaseModel):
name: str = Field(..., min_length=2, max_length=255)
slug: str = Field(..., min_length=3, max_length=100, pattern=r"^[a-z0-9_-]+$")
tenant_type: TenantType = TenantType.MUNICIPALITY
contact_email: EmailStr
contact_phone: Optional[str] = Field(None, max_length=50)
address: Optional[str] = Field(None, max_length=255)
postal_code: Optional[str] = Field(None, max_length=20)
city: Optional[str] = Field(None, max_length=100)
country: str = Field(default="SE", max_length=2)
description: Optional[str] = Field(None, max_length=5000)
website: Optional[str] = Field(None, max_length=255)
municipality_code: Optional[str] = Field(None, max_length=4)
county_code: Optional[str] = Field(None, max_length=2)
latitude: Optional[str] = Field(None, max_length=20)
longitude: Optional[str] = Field(None, max_length=20)
class TenantCreate(TenantBase):
org_number: Optional[str] = Field(None, max_length=20)
billing_email: Optional[EmailStr] = None
plan: str = Field(default="basic", max_length=50)
is_trial: bool = False
trial_days: Optional[int] = Field(None, ge=1, le=90)
max_users: int = Field(default=10, ge=1, le=10000)
max_projects: int = Field(default=5, ge=1, le=1000)
storage_quota_mb: int = Field(default=1024, ge=100, le=1000000)
features: Optional[Dict[str, Any]] = Field(default_factory=dict)
settings: Optional[Dict[str, Any]] = Field(default_factory=dict)
branding: Optional[Dict[str, Any]] = Field(default_factory=dict)
@field_validator("org_number")
@classmethod
def validate_org_number(cls, v):
if v is None:
return None
cleaned = v.replace(" ", "").replace("-", "")
if len(cleaned) != 10 or not cleaned.isdigit():
raise ValueError("Invalid Swedish organization number (must be 10 digits)")
return cleaned
@field_validator("slug")
@classmethod
def validate_slug(cls, v):
return v.lower().strip()
class TenantUpdate(BaseModel):
name: Optional[str] = Field(None, min_length=2, max_length=255)
contact_email: Optional[EmailStr] = None
contact_phone: Optional[str] = Field(None, max_length=50)
billing_email: Optional[EmailStr] = None
address: Optional[str] = Field(None, max_length=255)
postal_code: Optional[str] = Field(None, max_length=20)
city: Optional[str] = Field(None, max_length=100)
description: Optional[str] = Field(None, max_length=5000)
website: Optional[str] = Field(None, max_length=255)
plan: Optional[str] = Field(None, max_length=50)
max_users: Optional[int] = Field(None, ge=1, le=10000)
max_projects: Optional[int] = Field(None, ge=1, le=1000)
storage_quota_mb: Optional[int] = Field(None, ge=100, le=1000000)
features: Optional[Dict[str, Any]] = None
settings: Optional[Dict[str, Any]] = None
branding: Optional[Dict[str, Any]] = None
latitude: Optional[str] = Field(None, max_length=20)
longitude: Optional[str] = Field(None, max_length=20)
class TenantStatusUpdate(BaseModel):
status: TenantStatus
reason: Optional[str] = Field(None, max_length=2000)
class TenantActivateRequest(BaseModel):
activate_now: bool = True
skip_schema_creation: bool = False
class TenantSuspendRequest(BaseModel):
reason: str = Field(..., min_length=1, max_length=2000)
class TenantOut(TenantBase):
model_config = ConfigDict(from_attributes=True)
id: UUID
org_number: Optional[str]
schema_name: str
status: TenantStatus
plan: str
is_trial: bool
trial_ends_at: Optional[datetime]
activated_at: Optional[datetime]
suspended_at: Optional[datetime]
cancelled_at: Optional[datetime]
created_at: datetime
updated_at: datetime
is_active: bool
features: Dict[str, Any]
settings: Dict[str, Any]
branding: Dict[str, Any]
class TenantDetailOut(TenantOut):
billing_email: Optional[str]
max_users: int
max_projects: int
storage_quota_mb: int
plan_started_at: Optional[datetime]
plan_expires_at: Optional[datetime]
suspended_reason: Optional[str]
cancellation_reason: Optional[str]
activated_by: Optional[str]
suspended_by: Optional[str]
created_by: Optional[str]
updated_by: Optional[str]
class TenantListOut(BaseModel):
items: List[TenantOut]
total: int
page: int
page_size: int
pages: int
class TenantStats(BaseModel):
total_tenants: int
active_tenants: int
pending_tenants: int
suspended_tenants: int
trial_tenants: int
cancelled_tenants: int
by_type: Dict[str, int]
by_plan: Dict[str, int]
class TenantListParams(BaseModel):
model_config = ConfigDict(from_attributes=True)
page: int = Field(default=1, ge=1)
page_size: int = Field(default=20, ge=1, le=100)
status: Optional[TenantStatus] = None
tenant_type: Optional[TenantType] = None
search: Optional[str] = Field(None, max_length=100)
country: Optional[str] = Field(None, max_length=2)
city: Optional[str] = Field(None, max_length=100)
sort_by: str = "created_at"
sort_order: str = Field(default="desc", pattern=r"^(asc|desc)$")
is_active: Optional[bool] = None
# ─── User ─────────────────────────────────────────────────────────────────────
class UserCreate(UserBase):
password: Optional[str] = Field(None, min_length=8)
tenant_id: Optional[UUID] = None
class UserUpdate(BaseModel):
email: Optional[EmailStr] = None
first_name: Optional[str] = Field(None, min_length=1, max_length=100)
last_name: Optional[str] = Field(None, min_length=1, max_length=100)
role: Optional[UserRole] = None
status: Optional[UserStatus] = None
tenant_id: Optional[UUID] = None
class UserOut(UserBase):
model_config = ConfigDict(from_attributes=True)
id: UUID
status: UserStatus
is_superuser: bool
last_login_at: Optional[datetime] = None
tenant_id: Optional[UUID] = None
created_at: datetime
updated_at: datetime
class UserDetailOut(UserOut):
tenant: Optional[TenantOut] = None
class UserListOut(BaseModel):
items: List[UserOut]
total: int
# ─── Auth ─────────────────────────────────────────────────────────────────────
class Token(BaseModel):
access_token: str
refresh_token: str
token_type: str = "bearer"
expires_in: int
class TokenPayload(BaseModel):
sub: Optional[str] = None
type: Optional[str] = None
class LoginRequest(BaseModel):
email: EmailStr
password: str
class RefreshRequest(BaseModel):
refresh_token: str
class ChangePasswordRequest(BaseModel):
current_password: str
new_password: str = Field(..., min_length=8)
# ─── Invitation ───────────────────────────────────────────────────────────────
class InvitationCreate(BaseModel):
email: EmailStr
role: UserRole = UserRole.VIEWER
tenant_id: Optional[UUID] = None
class InvitationOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: UUID
email: str
role: UserRole
token: str
expires_at: datetime
accepted_at: Optional[datetime] = None
created_at: datetime
invited_by_id: UUID
tenant_id: Optional[UUID] = None
class AcceptInvitationRequest(BaseModel):
token: str
first_name: str = Field(..., min_length=1, max_length=100)
last_name: str = Field(..., min_length=1, max_length=100)
password: str = Field(..., min_length=8)
# ─── Password reset ───────────────────────────────────────────────────────────
class PasswordResetRequest(BaseModel):
email: EmailStr
class PasswordResetConfirm(BaseModel):
token: str
new_password: str = Field(..., min_length=8)
# ─── Audit Log ────────────────────────────────────────────────────────────────
class AuditLogOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: str
timestamp: str
action: str
entity_type: str
entity_id: str
actor_id: Optional[str]
actor_type: str
actor_ip: Optional[str]
previous_values: Optional[Dict[str, Any]]
new_values: Optional[Dict[str, Any]]
changed_fields: Optional[List[str]]
success: bool
error_message: Optional[str]
tenant_id: Optional[str]
tenant_schema: Optional[str]
request_id: Optional[str]
request_method: Optional[str]
request_path: Optional[str]
class AuditLogListOut(BaseModel):
items: List[AuditLogOut]
total: int
limit: int
offset: int
# ─── Common ───────────────────────────────────────────────────────────────────
class ErrorResponse(BaseModel):
error: str
details: Optional[Dict[str, Any]] = None
request_id: Optional[str] = None
class SuccessResponse(BaseModel):
success: bool = True
message: str
data: Optional[Dict[str, Any]] = None
class HealthResponse(BaseModel):
status: str
version: str
components: Dict[str, str]
timestamp: str