""" Admin API schemas — Pydantic v2. All admin-facing request/response shapes live here to keep the admin router clean and testable in isolation. """ import re from datetime import datetime from typing import Optional, Literal from pydantic import BaseModel, ConfigDict, Field, field_validator from app.schemas.auth import _validate_username, _validate_password_strength # --------------------------------------------------------------------------- # User list / detail # --------------------------------------------------------------------------- class UserListItem(BaseModel): id: int username: str email: Optional[str] = None first_name: Optional[str] = None last_name: Optional[str] = None role: str is_active: bool last_login_at: Optional[datetime] = None last_password_change_at: Optional[datetime] = None totp_enabled: bool mfa_enforce_pending: bool created_at: datetime active_sessions: int = 0 model_config = ConfigDict(from_attributes=True) class UserListResponse(BaseModel): users: list[UserListItem] total: int class UserDetailResponse(UserListItem): preferred_name: Optional[str] = None must_change_password: bool = False locked_until: Optional[datetime] = None # --------------------------------------------------------------------------- # Mutating user requests # --------------------------------------------------------------------------- class CreateUserRequest(BaseModel): """Admin-created user — allows role selection (unlike public RegisterRequest).""" model_config = ConfigDict(extra="forbid") username: str password: str role: Literal["admin", "standard", "public_event_manager"] = "standard" email: Optional[str] = Field(None, max_length=254) first_name: Optional[str] = Field(None, max_length=100) last_name: Optional[str] = Field(None, max_length=100) preferred_name: Optional[str] = Field(None, max_length=100) @field_validator("username") @classmethod def validate_username(cls, v: str) -> str: return _validate_username(v) @field_validator("password") @classmethod def validate_password(cls, v: str) -> str: return _validate_password_strength(v) @field_validator("email") @classmethod def validate_email(cls, v: str | None) -> str | None: if v is None: return None v = v.strip().lower() if not v: return None # Basic format check: must have exactly one @, with non-empty local and domain parts if not re.match(r"^[^\s@]+@[^\s@]+\.[^\s@]+$", v): raise ValueError("Invalid email format") return v @field_validator("first_name", "last_name", "preferred_name") @classmethod def validate_name_fields(cls, v: str | None) -> str | None: if v is None: return None v = v.strip() if not v: return None # Reject ASCII control characters if re.search(r"[\x00-\x1f]", v): raise ValueError("Name must not contain control characters") return v class UpdateUserRoleRequest(BaseModel): model_config = ConfigDict(extra="forbid") role: Literal["admin", "standard", "public_event_manager"] class ToggleActiveRequest(BaseModel): model_config = ConfigDict(extra="forbid") is_active: bool class ToggleMfaEnforceRequest(BaseModel): model_config = ConfigDict(extra="forbid") enforce: bool # --------------------------------------------------------------------------- # System config # --------------------------------------------------------------------------- class SystemConfigResponse(BaseModel): allow_registration: bool enforce_mfa_new_users: bool model_config = ConfigDict(from_attributes=True) class SystemConfigUpdate(BaseModel): model_config = ConfigDict(extra="forbid") allow_registration: Optional[bool] = None enforce_mfa_new_users: Optional[bool] = None # --------------------------------------------------------------------------- # Admin dashboard # --------------------------------------------------------------------------- class RecentLoginItem(BaseModel): username: str last_login_at: Optional[datetime] = None model_config = ConfigDict(from_attributes=True) class RecentAuditItem(BaseModel): action: str actor_username: Optional[str] = None target_username: Optional[str] = None created_at: datetime model_config = ConfigDict(from_attributes=True) class AdminDashboardResponse(BaseModel): total_users: int active_users: int admin_count: int active_sessions: int mfa_adoption_rate: float recent_logins: list[RecentLoginItem] recent_audit_entries: list[RecentAuditItem] # --------------------------------------------------------------------------- # Password reset # --------------------------------------------------------------------------- class ResetPasswordResponse(BaseModel): message: str temporary_password: str class DeleteUserResponse(BaseModel): message: str deleted_username: str # --------------------------------------------------------------------------- # Audit log # --------------------------------------------------------------------------- class AuditLogEntry(BaseModel): id: int actor_username: Optional[str] = None target_username: Optional[str] = None action: str detail: Optional[str] = None ip_address: Optional[str] = None created_at: datetime class AuditLogResponse(BaseModel): entries: list[AuditLogEntry] total: int