""" 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