Coverage for models/auth.py: 83.59%
165 statements
« prev ^ index » next coverage.py v7.10.6, created at 2026-04-15 13:02 +0000
« prev ^ index » next coverage.py v7.10.6, created at 2026-04-15 13:02 +0000
1"""
2Authentication and user-related Pydantic models.
3"""
5import re
7from pydantic import BaseModel, field_validator
10class UserSignup(BaseModel):
11 """Model for user signup requests."""
13 username: str # Primary login credential (e.g., gabe_ifa_35)
14 password: str
15 email: str # Required for password recovery
16 phone_number: str | None = None # Optional for SMS
17 display_name: str | None = None
18 invite_code: str | None = None
20 @field_validator("username")
21 @classmethod
22 def validate_username(cls, v):
23 """Validate username format: 3-50 chars, alphanumeric and underscores only."""
24 if not re.match(r"^[a-zA-Z0-9_]{3,50}$", v):
25 raise ValueError("Username must be 3-50 characters long and contain only letters, numbers, and underscores")
26 return v.lower() # Store usernames in lowercase for consistency
28 @field_validator("email")
29 @classmethod
30 def validate_email(cls, v):
31 """Validate email format."""
32 if not v or "@" not in v: 32 ↛ 33line 32 didn't jump to line 33 because the condition on line 32 was never true
33 raise ValueError("A valid email address is required")
34 return v
37class UserLogin(BaseModel):
38 """Model for user login requests."""
40 username: str # Changed from email
41 password: str
43 @field_validator("username")
44 @classmethod
45 def validate_username(cls, v):
46 """Ensure username is lowercase for lookup."""
47 return v.lower()
50class UserProfile(BaseModel):
51 """Model for user profile data."""
53 display_name: str | None = None
54 email: str | None = None
55 role: str | None = None
56 team_id: int | None = None
57 player_number: int | None = None
58 positions: list[str] | None = None
59 # Photo fields
60 photo_1_url: str | None = None
61 photo_2_url: str | None = None
62 photo_3_url: str | None = None
63 profile_photo_slot: int | None = None
64 # Customization fields
65 overlay_style: str = "badge"
66 primary_color: str = "#3B82F6"
67 text_color: str = "#FFFFFF"
68 accent_color: str = "#1D4ED8"
69 # Telegram/Discord handles for channel access
70 telegram_handle: str | None = None
71 discord_handle: str | None = None
74class RoleUpdate(BaseModel):
75 """Model for updating user roles."""
77 user_id: str
78 role: str
79 team_id: int | None = None
82class UserProfileUpdate(BaseModel):
83 """Model for updating user profile information."""
85 user_id: str
86 username: str | None = None
87 display_name: str | None = None
88 email: str | None = None
91class ForgotPasswordRequest(BaseModel):
92 """Model for forgot-password requests."""
94 identifier: str # Username or email address
95 email: str | None = None # Real email, supplied when account has none on file
97 @field_validator("identifier")
98 @classmethod
99 def validate_identifier(cls, v: str) -> str:
100 if not v or not v.strip():
101 raise ValueError("identifier must not be empty")
102 return v.strip()
104 @field_validator("email")
105 @classmethod
106 def validate_email(cls, v: str | None) -> str | None:
107 if v == "":
108 return None
109 if v is not None and "@" not in v:
110 raise ValueError("Invalid email format")
111 return v
114class ResetPasswordRequest(BaseModel):
115 """Model for password-reset submission."""
117 token: str
118 new_password: str
120 @field_validator("new_password")
121 @classmethod
122 def validate_password_length(cls, v: str) -> str:
123 if len(v) < 6:
124 raise ValueError("Password must be at least 6 characters")
125 return v
128class RefreshTokenRequest(BaseModel):
129 """Model for refresh token requests."""
131 refresh_token: str
134class ProfilePhotoSlot(BaseModel):
135 """Model for setting which photo slot is the profile picture."""
137 slot: int
139 @field_validator("slot")
140 @classmethod
141 def validate_slot(cls, v):
142 """Validate slot is 1, 2, or 3."""
143 if v not in (1, 2, 3):
144 raise ValueError("Slot must be 1, 2, or 3")
145 return v
148class PlayerCustomization(BaseModel):
149 """Model for player profile customization (colors, style, number, position, social media)."""
151 # Personal info
152 first_name: str | None = None
153 last_name: str | None = None
154 hometown: str | None = None
155 # Visual customization
156 overlay_style: str | None = None
157 primary_color: str | None = None
158 text_color: str | None = None
159 accent_color: str | None = None
160 player_number: int | None = None # Jersey number (1-99)
161 positions: list[str] | None = None
162 # Social media handles (username only, not full URLs)
163 instagram_handle: str | None = None
164 snapchat_handle: str | None = None
165 tiktok_handle: str | None = None
166 # Telegram/Discord handles for channel access
167 telegram_handle: str | None = None
168 discord_handle: str | None = None
170 @field_validator("instagram_handle", "snapchat_handle", "tiktok_handle")
171 @classmethod
172 def validate_social_handle(cls, v):
173 """Validate social media handle format."""
174 if v is not None:
175 # Remove @ if present
176 v = v.lstrip("@")
177 # Basic validation: alphanumeric, underscores, periods, max 30 chars
178 if len(v) > 30:
179 raise ValueError("Handle must be 30 characters or less")
180 if v and not re.match(r"^[a-zA-Z0-9_.]+$", v):
181 raise ValueError("Handle can only contain letters, numbers, underscores, and periods")
182 return v
184 @field_validator("overlay_style")
185 @classmethod
186 def validate_overlay_style(cls, v):
187 """Validate overlay style."""
188 if v is not None and v not in ("badge", "jersey", "caption", "none"):
189 raise ValueError("overlay_style must be 'badge', 'jersey', 'caption', or 'none'")
190 return v
192 @field_validator("primary_color", "text_color", "accent_color")
193 @classmethod
194 def validate_color(cls, v):
195 """Validate hex color format."""
196 if v is not None and not re.match(r"^#[0-9A-Fa-f]{6}$", v):
197 raise ValueError("Color must be a valid hex color (e.g., #3B82F6)")
198 return v
201class PlayerHistoryCreate(BaseModel):
202 """Model for creating a player team history entry."""
204 team_id: int
205 season_id: int
206 jersey_number: int | None = None
207 positions: list[str] | None = None
208 notes: str | None = None
209 is_current: bool = False
211 @field_validator("jersey_number")
212 @classmethod
213 def validate_jersey_number(cls, v):
214 """Validate jersey number is 1-99."""
215 if v is not None and (v < 1 or v > 99):
216 raise ValueError("Jersey number must be between 1 and 99")
217 return v
220class PlayerHistoryUpdate(BaseModel):
221 """Model for updating a player team history entry."""
223 jersey_number: int | None = None
224 positions: list[str] | None = None
225 notes: str | None = None
226 is_current: bool | None = None
228 @field_validator("jersey_number")
229 @classmethod
230 def validate_jersey_number(cls, v):
231 """Validate jersey number is 1-99."""
232 if v is not None and (v < 1 or v > 99):
233 raise ValueError("Jersey number must be between 1 and 99")
234 return v
237class AdminPlayerUpdate(BaseModel):
238 """Model for admin updating a player's profile info."""
240 display_name: str | None = None
241 player_number: int | None = None
242 positions: list[str] | None = None
244 @field_validator("player_number")
245 @classmethod
246 def validate_player_number(cls, v):
247 """Validate player number is 1-99."""
248 if v is not None and (v < 1 or v > 99):
249 raise ValueError("Player number must be between 1 and 99")
250 return v
253class AdminPlayerTeamAssignment(BaseModel):
254 """Model for admin assigning a player to a team."""
256 team_id: int
257 season_id: int
258 jersey_number: int | None = None
259 start_date: str | None = None
260 is_current: bool = True
262 @field_validator("jersey_number")
263 @classmethod
264 def validate_jersey_number(cls, v):
265 """Validate jersey number is 1-99."""
266 if v is not None and (v < 1 or v > 99):
267 raise ValueError("Jersey number must be between 1 and 99")
268 return v
271class AdminPlayerTeamEnd(BaseModel):
272 """Model for ending a player's team assignment."""
274 end_date: str