Coverage for models/auth.py: 83.59%

165 statements  

« prev     ^ index     » next       coverage.py v7.10.6, created at 2026-04-13 14:11 +0000

1""" 

2Authentication and user-related Pydantic models. 

3""" 

4 

5import re 

6 

7from pydantic import BaseModel, field_validator 

8 

9 

10class UserSignup(BaseModel): 

11 """Model for user signup requests.""" 

12 

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 

19 

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 

27 

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 

35 

36 

37class UserLogin(BaseModel): 

38 """Model for user login requests.""" 

39 

40 username: str # Changed from email 

41 password: str 

42 

43 @field_validator("username") 

44 @classmethod 

45 def validate_username(cls, v): 

46 """Ensure username is lowercase for lookup.""" 

47 return v.lower() 

48 

49 

50class UserProfile(BaseModel): 

51 """Model for user profile data.""" 

52 

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 

72 

73 

74class RoleUpdate(BaseModel): 

75 """Model for updating user roles.""" 

76 

77 user_id: str 

78 role: str 

79 team_id: int | None = None 

80 

81 

82class UserProfileUpdate(BaseModel): 

83 """Model for updating user profile information.""" 

84 

85 user_id: str 

86 username: str | None = None 

87 display_name: str | None = None 

88 email: str | None = None 

89 

90 

91class ForgotPasswordRequest(BaseModel): 

92 """Model for forgot-password requests.""" 

93 

94 identifier: str # Username or email address 

95 email: str | None = None # Real email, supplied when account has none on file 

96 

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() 

103 

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 

112 

113 

114class ResetPasswordRequest(BaseModel): 

115 """Model for password-reset submission.""" 

116 

117 token: str 

118 new_password: str 

119 

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 

126 

127 

128class RefreshTokenRequest(BaseModel): 

129 """Model for refresh token requests.""" 

130 

131 refresh_token: str 

132 

133 

134class ProfilePhotoSlot(BaseModel): 

135 """Model for setting which photo slot is the profile picture.""" 

136 

137 slot: int 

138 

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 

146 

147 

148class PlayerCustomization(BaseModel): 

149 """Model for player profile customization (colors, style, number, position, social media).""" 

150 

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 

169 

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 

183 

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 

191 

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 

199 

200 

201class PlayerHistoryCreate(BaseModel): 

202 """Model for creating a player team history entry.""" 

203 

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 

210 

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 

218 

219 

220class PlayerHistoryUpdate(BaseModel): 

221 """Model for updating a player team history entry.""" 

222 

223 jersey_number: int | None = None 

224 positions: list[str] | None = None 

225 notes: str | None = None 

226 is_current: bool | None = None 

227 

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 

235 

236 

237class AdminPlayerUpdate(BaseModel): 

238 """Model for admin updating a player's profile info.""" 

239 

240 display_name: str | None = None 

241 player_number: int | None = None 

242 positions: list[str] | None = None 

243 

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 

251 

252 

253class AdminPlayerTeamAssignment(BaseModel): 

254 """Model for admin assigning a player to a team.""" 

255 

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 

261 

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 

269 

270 

271class AdminPlayerTeamEnd(BaseModel): 

272 """Model for ending a player's team assignment.""" 

273 

274 end_date: str