user.py
1 """ 2 User request/response schemas. 3 4 This file teaches you about PYDANTIC for request/response validation. 5 """ 6 from pydantic import BaseModel, EmailStr, field_validator 7 from datetime import datetime 8 import uuid 9 10 11 # ============================================================================= 12 # LEARNING GUIDE: Pydantic Schemas 13 # ============================================================================= 14 # 15 # What is Pydantic? 16 # → A library for DATA VALIDATION using Python type hints 17 # → Ensures incoming data matches expected types and constraints 18 # → Automatically converts JSON to Python objects (and back) 19 # 20 # Why do we need schemas separate from models? 21 # → SQLAlchemy models = DATABASE structure (what's stored) 22 # → Pydantic schemas = API structure (what's sent/received) 23 # → They're often similar but serve different purposes! 24 # 25 # Example: 26 # Database has: email, password_hash, magic_link_token 27 # API response should NOT expose: password_hash, magic_link_token 28 # So we create a schema with only the safe fields! 29 # 30 # ============================================================================= 31 # THREE TYPES OF SCHEMAS: 32 # ============================================================================= 33 # 34 # 1. CREATE schemas (e.g., UserCreate) 35 # → Used when CREATING a new resource (POST requests) 36 # → Contains all fields needed to create the resource 37 # → Often has validation rules 38 # 39 # 2. UPDATE schemas (e.g., UserUpdate) 40 # → Used when UPDATING an existing resource (PATCH requests) 41 # → All fields are OPTIONAL (partial updates) 42 # → Same validation rules as CREATE 43 # 44 # 3. RESPONSE schemas (e.g., UserResponse) 45 # → Used when RETURNING data to client (GET/POST/PATCH responses) 46 # → Contains fields safe to expose publicly 47 # → Has model_config = {"from_attributes": True} to convert from SQLAlchemy models 48 # 49 # ============================================================================= 50 # FIELD TYPES IN PYDANTIC: 51 # ============================================================================= 52 # 53 # Required field: 54 # name: str → Must be provided, cannot be None 55 # 56 # Optional field (can be None): 57 # pronouns: str | None = None → Can be omitted or set to None 58 # 59 # Optional field with default: 60 # active: bool = True → If omitted, defaults to True 61 # 62 # Special types: 63 # EmailStr → Validates email format 64 # uuid.UUID → Validates UUID format 65 # datetime → Validates datetime format 66 # 67 # ============================================================================= 68 # VALIDATORS: Custom validation logic 69 # ============================================================================= 70 # 71 # Validators let you transform or validate field values: 72 # 73 # @field_validator('city') 74 # @classmethod 75 # def normalize_city(cls, v: str) -> str: 76 # """Normalize city to lowercase and remove whitespace""" 77 # return v.lower().strip() 78 # 79 # Breaking it down: 80 # - @field_validator('city') = run this function on the 'city' field 81 # - @classmethod = it's a class method (required by Pydantic) 82 # - cls = the schema class (UserCreate) 83 # - v = the value being validated 84 # - return = the transformed value 85 # 86 # You can also validate multiple fields at once: 87 # @field_validator('city', 'name') 88 # @classmethod 89 # def trim_strings(cls, v: str) -> str: 90 # return v.strip() 91 # 92 # ============================================================================= 93 94 95 class UserCreate(BaseModel): 96 """ 97 Request body for POST /users (signup). 98 99 This schema defines what data is required when creating a new user. 100 """ 101 # ===================== 102 # Required fields 103 # ===================== 104 email: EmailStr # EmailStr validates the email format automatically! 105 name: str 106 city: str 107 108 # ===================== 109 # Optional fields (can be None or omitted) 110 # ===================== 111 pronouns: str | None = None 112 birth_year: int | None = None 113 bio: str | None = None 114 telegram_chat_id: str | None = None 115 contact_info: str | None = None 116 invited_by_id: uuid.UUID | None = None 117 118 # ===================== 119 # VALIDATORS: Transform and validate input 120 # ===================== 121 122 # TODO: Add a validator for 'city' to normalize it 123 # Pattern: 124 # @field_validator('city') 125 # @classmethod 126 # def normalize_city(cls, v: str) -> str: 127 # """Normalize city to lowercase and trim whitespace""" 128 # return v.lower().strip() 129 130 # TODO: Add a validator for 'telegram_chat_id' to ensure it's numeric 131 # Telegram chat IDs are numeric strings like "123456789" 132 # Pattern: 133 # @field_validator('telegram_chat_id') 134 # @classmethod 135 # def validate_telegram_id(cls, v: str | None) -> str | None: 136 # if v is None: 137 # return None 138 # if not v.isdigit(): 139 # raise ValueError("Telegram chat ID must be numeric") 140 # return v 141 142 # TODO: Add a validator for 'birth_year' to ensure it's reasonable 143 # Should be between 1900 and current year 144 # Pattern: 145 # @field_validator('birth_year') 146 # @classmethod 147 # def validate_birth_year(cls, v: int | None) -> int | None: 148 # if v is None: 149 # return None 150 # if v < 1900 or v > 2025: 151 # raise ValueError("Birth year must be between 1900 and 2025") 152 # return v 153 154 155 class UserUpdate(BaseModel): 156 """ 157 Request body for PATCH /users/me (update profile). 158 159 UPDATE schemas have ALL fields as optional (allows partial updates). 160 Users can update just one field without providing all the others. 161 """ 162 # All fields optional for partial updates 163 name: str | None = None 164 pronouns: str | None = None 165 birth_year: int | None = None 166 bio: str | None = None 167 city: str | None = None 168 telegram_chat_id: str | None = None 169 contact_info: str | None = None 170 171 # TODO: Add the same validators as UserCreate 172 # Tip: You can apply validators to multiple fields by listing them: 173 # @field_validator('city') 174 # @classmethod 175 # def normalize_city(cls, v: str | None) -> str | None: 176 # if v is None: 177 # return None 178 # return v.lower().strip() 179 180 181 class UserResponse(BaseModel): 182 """ 183 User data returned in API responses. 184 185 RESPONSE schemas define what's SAFE to send to clients. 186 Notice: NO email, NO magic_link_token (privacy/security!) 187 """ 188 id: uuid.UUID 189 name: str 190 pronouns: str | None 191 birth_year: int | None 192 city: str 193 bio: str | None 194 telegram_chat_id: str | None 195 contact_info: str | None 196 can_offer_housing: bool 197 created_at: datetime 198 199 # Note: email is NEVER exposed in API responses (privacy) 200 # Note: magic_link_token is NEVER exposed (security) 201 202 # ============================================================================= 203 # model_config: Special configuration for this schema 204 # ============================================================================= 205 # 206 # {"from_attributes": True} allows Pydantic to convert SQLAlchemy models 207 # to this schema by reading attributes (instead of expecting a dict) 208 # 209 # Without this: 210 # UserResponse(**user.__dict__) ← You'd have to do this 211 # 212 # With this: 213 # UserResponse.model_validate(user) ← Much cleaner! 214 # 215 # This is essential when returning database models from API endpoints! 216 # 217 model_config = {"from_attributes": True} 218 219 # TODO: Add invited_by as a nested object 220 # This would show who invited this user 221 # Pattern: 222 # invited_by: "UserSearchResult | None" = None 223 # (Need to use string annotation because UserSearchResult is defined below) 224 225 226 class UserSearchResult(BaseModel): 227 """ 228 Simplified user data for search results. 229 230 Used in the "invited by" dropdown when signing up. 231 Shows just enough info to identify someone without exposing everything. 232 """ 233 id: uuid.UUID 234 name: str 235 pronouns: str | None 236 birth_year: int | None 237 city: str 238 contact_info: str | None 239 240 model_config = {"from_attributes": True} 241