aee0f09db8
- Datafabrik: Dockerfile fix, agentorkestrering fungerar - Vision: Identify-modell, FAISS, OCR alla testade - API: Alla 7 integrationstester passerade - Upplösare: Entitetsupplösning verifierad
332 lines
11 KiB
Python
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
|