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:
@@ -0,0 +1,331 @@
|
||||
"""
|
||||
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
|
||||
Reference in New Issue
Block a user