/ backend / schemas / user.py
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