import re import uuid from datetime import datetime from typing import Literal, Optional from pydantic import BaseModel, ConfigDict, EmailStr, Field, field_validator UserStatus = Literal["PENDING", "ACTIVE", "REJECTED", "DISABLED"] PASSWORD_REGEX = re.compile(r"^(?=.*[A-Za-z])(?=.*\d).{8,}$") class UserDisplay(BaseModel): id: uuid.UUID email: EmailStr full_name: str avatar_url: Optional[str] = None model_config = ConfigDict(from_attributes=True) class _PasswordValidator(BaseModel): password: Optional[str] = Field(default=None, min_length=8, max_length=72) @field_validator("password") @classmethod def validate_password_strength(cls, v: Optional[str]) -> Optional[str]: if v is None: return v if not PASSWORD_REGEX.match(v): raise ValueError("密码需至少 8 位且包含字母和数字") return v class UserRegisterRequest(_PasswordValidator): password: str = Field(min_length=8, max_length=72) email: EmailStr full_name: str = Field(min_length=1) clinical_department: str = Field(min_length=1) class UserCreate(_PasswordValidator): password: str = Field(min_length=8, max_length=72) email: EmailStr full_name: str = Field(min_length=1) clinical_department: str = Field(min_length=1) status: UserStatus = "ACTIVE" class UserRead(BaseModel): id: uuid.UUID email: EmailStr username: str full_name: str clinical_department: str status: UserStatus is_active: bool is_admin: bool = False created_at: datetime approved_at: Optional[datetime] = None approved_by: Optional[uuid.UUID] = None avatar_url: Optional[str] = None model_config = ConfigDict(from_attributes=True) class UserUpdate(_PasswordValidator): email: Optional[EmailStr] = None full_name: Optional[str] = None clinical_department: Optional[str] = None status: Optional[UserStatus] = None password: Optional[str] = None is_active: Optional[bool] = None avatar_url: Optional[str] = None class UserResponse(UserRead): pass class UserAdminReviewRequest(BaseModel): action: Literal["approve", "reject"] class AdminUserListResponse(BaseModel): items: list[UserResponse] total: int class UserSelfUpdate(_PasswordValidator): full_name: Optional[str] = None clinical_department: Optional[str] = None password: Optional[str] = None current_password: Optional[str] = None avatar_url: Optional[str] = None class Token(BaseModel): access_token: str token_type: str = "bearer" class TokenPayload(BaseModel): sub: uuid.UUID exp: Optional[int] = None