Coverage for server / models / themes.py: 99%

156 statements  

« prev     ^ index     » next       coverage.py v7.13.4, created at 2026-10-04 09:33 +0000

1""" 

2Theme Models Module 

3 

4This module contains Pydantic models and MongoDB document schemas for teacher profile themes 

5in the FastAPI application. It handles theme customization, storage, and management. 

6 

7Key Features: 

8- Theme definition models 

9- User-theme association models 

10- Theme customization and preference models 

11- Response models for API endpoints 

12 

13Technical Details: 

14- Uses Beanie for MongoDB ODM integration 

15- Implements Pydantic for data validation 

16- Supports both sync and async operations 

17""" 

18 

19from datetime import datetime, timezone 

20from typing import Any, Dict, List, Optional 

21from beanie import Document, PydanticObjectId, Indexed 

22from fastapi import HTTPException, status 

23from pydantic import BaseModel, Field, model_validator, field_validator 

24 

25from server.utilities.html_sanitizer import strip_html 

26 

27 

28# Longest theme_name in the live QA data is 18 characters ("Default Light" is 13), 

29# so this is deliberately generous: room for a real name, not for a payload. 

30MAX_THEME_NAME_LENGTH = 60 

31 

32 

33class ThemeColors(BaseModel): 

34 """ 

35 Model for theme color configuration. 

36 

37 Attributes: 

38 primary: Primary color (hex code) 

39 secondary: Secondary color (hex code) 

40 accent: Accent color (hex code) 

41 text: Text color (hex code) 

42 background: Background color (hex code) 

43 """ 

44 

45 primary: str = Field( 

46 ..., description="Primary color for the theme (hex code)", example="#4a6cf7" 

47 ) 

48 secondary: str = Field( 

49 ..., description="Secondary color for the theme (hex code)", example="#6c757d" 

50 ) 

51 accent: str = Field( 

52 ..., description="Accent color for the theme (hex code)", example="#00c6a9" 

53 ) 

54 text: str = Field( 

55 ..., description="Text color for the theme (hex code)", example="#333333" 

56 ) 

57 background: str = Field( 

58 ..., description="Background color for the theme (hex code)", example="#ffffff" 

59 ) 

60 

61 @field_validator("*") 

62 @classmethod 

63 def validate_hex_color(cls, value: str) -> str: 

64 """Validates that all color values are in proper hex format.""" 

65 import re 

66 

67 if value and not re.match(r"^#(?:[0-9a-fA-F]{3}){1,2}$", value): 

68 raise ValueError( 

69 f"Invalid hex color code: {value}. Must be in format #RGB or #RRGGBB" 

70 ) 

71 return value 

72 

73 

74class ThemeFont(BaseModel): 

75 """ 

76 Model for theme font configuration. 

77 

78 Attributes: 

79 family: Font family name 

80 size: Base font size with units 

81 heading_scale: Scale factor for headings 

82 """ 

83 

84 family: str = Field( 

85 ..., description="Font family for the theme", example="Roboto, sans-serif" 

86 ) 

87 size: str = Field(..., description="Base font size with units", example="16px") 

88 heading_scale: float = Field( 

89 ..., description="Scale factor for headings", example=1.2 

90 ) 

91 

92 @field_validator("size") 

93 @classmethod 

94 def validate_font_size(cls, value: str) -> str: 

95 """Validates that font size has proper format.""" 

96 import re 

97 

98 if value and not re.match(r"^\d+(\.\d+)?(px|rem|em|pt|%)$", value): 

99 raise ValueError( 

100 f"Invalid font size: {value}. Must include units (px, rem, em, pt, %)" 

101 ) 

102 return value 

103 

104 

105class ThemeLayout(BaseModel): 

106 """ 

107 Model for theme layout configuration. 

108 

109 Attributes: 

110 spacing: Spacing/padding size with units 

111 border_radius: Border radius for elements with units 

112 card_style: Style for card elements 

113 """ 

114 

115 spacing: str = Field( 

116 ..., description="Spacing/padding size with units", example="16px" 

117 ) 

118 border_radius: str = Field( 

119 ..., description="Border radius for elements with units", example="4px" 

120 ) 

121 card_style: str = Field( 

122 ..., description="Style for card elements", example="shadow" 

123 ) 

124 

125 @field_validator("spacing", "border_radius") 

126 @classmethod 

127 def validate_size_with_units(cls, value: str) -> str: 

128 """Validates that size values have proper format with units.""" 

129 import re 

130 

131 if value and not re.match(r"^\d+(\.\d+)?(px|rem|em|pt|%)$", value): 

132 raise ValueError( 

133 f"Invalid size value: {value}. Must include units (px, rem, em, pt, %)" 

134 ) 

135 return value 

136 

137 

138# #375 (Allan Ninal, 2026-10-04): update-only twins of ThemeFont / ThemeLayout. 

139# WHAT: every nested field is Optional, and the validators are inherited, so a 

140# value that IS sent is still checked exactly as on create. 

141# WHY: ThemeUpdate used ThemeFont / ThemeLayout directly, whose fields are all 

142# required, so a partial update (only font.size, only layout.border_radius) 

143# was rejected. A CREATE (the Theme document, ThemeResponse) still uses the 

144# originals and still requires every field. The services merge what was 

145# sent over the STORED theme (server/services/common/theme_ownership.py). 

146class ThemeFontUpdate(ThemeFont): 

147 """Partial font settings for an update; unsent fields keep their stored value.""" 

148 

149 family: Optional[str] = Field( 

150 None, description="Font family for the theme", example="Roboto, sans-serif" 

151 ) 

152 size: Optional[str] = Field( 

153 None, description="Base font size with units", example="16px" 

154 ) 

155 heading_scale: Optional[float] = Field( 

156 None, description="Scale factor for headings", example=1.2 

157 ) 

158 

159 

160class ThemeLayoutUpdate(ThemeLayout): 

161 """Partial layout settings for an update; unsent fields keep their stored value.""" 

162 

163 spacing: Optional[str] = Field( 

164 None, description="Spacing/padding size with units", example="16px" 

165 ) 

166 border_radius: Optional[str] = Field( 

167 None, description="Border radius for elements with units", example="4px" 

168 ) 

169 card_style: Optional[str] = Field( 

170 None, description="Style for card elements", example="shadow" 

171 ) 

172 

173 

174class Theme(Document): 

175 """ 

176 MongoDB document schema for theme definitions. 

177 

178 Attributes: 

179 theme_name: Display name of the theme 

180 description: Brief description of the theme 

181 is_default: Flag indicating if this is a default theme 

182 is_active: Flag for soft delete functionality 

183 created_at: Creation timestamp 

184 updated_at: Last update timestamp 

185 created_by: Reference to creator (if custom theme) 

186 colors: Theme color configuration 

187 font: Theme font configuration 

188 layout: Theme layout configuration 

189 custom_properties: Additional custom theme properties 

190 """ 

191 

192 theme_name: str 

193 description: Optional[str] = None 

194 is_default: bool = False 

195 is_active: bool = True 

196 color_mode: Optional[str] = "light" 

197 created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) 

198 updated_at: Optional[datetime] = None 

199 created_by: Optional[PydanticObjectId] = None 

200 colors: ThemeColors 

201 font: ThemeFont 

202 layout: ThemeLayout 

203 custom_properties: Optional[Dict[str, Any]] = None 

204 

205 @field_validator("updated_at", mode="before") 

206 @classmethod 

207 def set_updated_at_now(cls, value): 

208 """Sets the updated_at timestamp to current UTC time if not provided.""" 

209 if value is None: 

210 return datetime.now(timezone.utc) 

211 return value 

212 

213 class Settings: 

214 """Database settings for the Theme model.""" 

215 

216 name = "themes_collection" 

217 indexes = [[("_id", 1)], [("is_default", 1)], [("created_by", 1)]] 

218 

219 

220class UserTheme(Document): 

221 """ 

222 MongoDB document schema for mapping users to their applied themes. 

223 

224 Attributes: 

225 user_id: Reference to the user (teacher) 

226 theme_id: Reference to the applied theme document 

227 is_active: Whether this theme is currently active 

228 created_at: When this theme was applied 

229 updated_at: Last time theme settings were updated 

230 custom_settings: Optional user-specific theme customizations 

231 """ 

232 

233 user_id: PydanticObjectId 

234 theme_id: PydanticObjectId 

235 is_active: bool = True 

236 created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) 

237 updated_at: Optional[datetime] = None 

238 custom_settings: Optional[Dict[str, Any]] = None 

239 color_mode: Optional[str] = None 

240 

241 @field_validator("updated_at", mode="before") 

242 @classmethod 

243 def set_updated_at_now(cls, value): 

244 """Sets the updated_at timestamp to current UTC time if not provided.""" 

245 if value is None: 

246 return datetime.now(timezone.utc) 

247 return value 

248 

249 class Settings: 

250 """Database settings for the UserTheme model.""" 

251 

252 name = "user_themes_collection" 

253 indexes = [ 

254 [("user_id", 1)], 

255 [("theme_id", 1)], 

256 [("user_id", 1), ("is_active", 1)], 

257 ] 

258 

259 

260class ThemeResponse(BaseModel): 

261 """ 

262 Response model for theme data. 

263 

264 Attributes: 

265 _id: MongoDB ObjectId of the theme 

266 theme_name: Display name of the theme 

267 colors: Theme color configuration 

268 font: Theme font configuration 

269 layout: Theme layout configuration 

270 is_default: Whether this is a default theme 

271 created_at: Creation timestamp 

272 updated_at: Last update timestamp 

273 """ 

274 

275 id: Optional[str] = Field(alias="_id") 

276 theme_name: str 

277 colors: ThemeColors 

278 font: ThemeFont 

279 layout: ThemeLayout 

280 is_default: bool 

281 created_at: datetime 

282 updated_at: Optional[datetime] = None 

283 color_mode: Optional[str] = "light" 

284 

285 model_config = { 

286 "populate_by_name": True, # allow using "id" or "_id" interchangeably 

287 "from_attributes": True, # allows creating from ORM/Beanie objects 

288 } 

289 

290 

291class ThemeUpdate(BaseModel): 

292 """ 

293 Model for updating an existing theme. 

294 

295 Attributes: 

296 theme_name: Updated theme name 

297 colors: Updated color configuration 

298 font: Updated font configuration 

299 layout: Updated layout configuration 

300 custom_properties: Additional custom properties 

301 """ 

302 

303 theme_name: Optional[str] = None 

304 colors: Optional[ThemeColors] = None 

305 # #375 (Allan Ninal, 2026-10-04): the update-only twins, so font/layout can be partial. 

306 font: Optional[ThemeFontUpdate] = None 

307 layout: Optional[ThemeLayoutUpdate] = None 

308 custom_properties: Optional[Dict[str, Any]] = None 

309 

310 @field_validator("theme_name") 

311 @classmethod 

312 def validate_theme_name(cls, value: Optional[str]) -> Optional[str]: 

313 """Reject a blank or over-long theme name, and never store markup. 

314 

315 Added by Allan Ninal — 2026-09-23 (EI-T384 student / EI-T305 teacher) 

316 WHAT: theme_name was `Optional[str] = None` with no rule of any kind, so a 

317 blank name and an absurdly long one were both saved without complaint. 

318 WHY: this one model binds BOTH PUT /v1/student/account/theme/update and 

319 PUT /v1/teacher/account/theme/update, so one validator closes both 

320 cases. It lives here rather than on the `Theme` document because the 

321 services persist with a raw `find_one(...).update({"$set": ...})`, 

322 which no document validator ever sees. 

323 

324 `None` means "leave this field alone" — ThemeUpdate is a PARTIAL update — 

325 so only a value that was actually sent is checked. 

326 

327 Order follows the ContactPerson fix (PR #326): strip markup FIRST, so pure 

328 markup collapses to "" and is refused as blank, markup wrapping text is 

329 stored as inert text, and the length limit applies to the VISIBLE text 

330 rather than being spent on tags. 400 (not 422) because that is the code 

331 these routes already document as "Invalid theme data". 

332 """ 

333 if value is None: 

334 return None 

335 

336 value = strip_html(value) or "" 

337 value = value.strip() 

338 

339 if not value: 

340 raise HTTPException( 

341 status_code=status.HTTP_400_BAD_REQUEST, 

342 detail="theme_name cannot be blank", 

343 ) 

344 if len(value) > MAX_THEME_NAME_LENGTH: 

345 raise HTTPException( 

346 status_code=status.HTTP_400_BAD_REQUEST, 

347 detail=( 

348 "theme_name must be no more than " 

349 f"{MAX_THEME_NAME_LENGTH} characters long" 

350 ), 

351 ) 

352 return value 

353 

354 

355class ThemeApply(BaseModel): 

356 """ 

357 Model for applying a theme to a user profile. 

358 

359 Attributes: 

360 theme_id: MongoDB ObjectId of the theme to apply 

361 color_mode: Color mode preference ("light" or "dark") 

362 """ 

363 

364 theme_id: PydanticObjectId 

365 color_mode: str = Field(..., description="Color mode preference", example="light") 

366 

367 @field_validator("theme_id", mode="before") 

368 @classmethod 

369 def validate_theme_id(cls, value: Any) -> Any: 

370 """ 

371 Validates theme_id input with comprehensive boundary checks. 

372 

373 Validates for: 

374 - Empty/null values 

375 - Excessive length (max 24 chars for MongoDB ObjectId) 

376 - Invalid characters (XSS prevention) 

377 - Valid MongoDB ObjectId format 

378 """ 

379 import re 

380 import html 

381 

382 # Check for null/None values 

383 if value is None: 

384 raise ValueError("theme_id is required and cannot be null") 

385 

386 # Convert to string for validation 

387 str_value = str(value).strip() 

388 

389 # Check for empty string 

390 if not str_value or str_value == "": 

391 raise ValueError("theme_id is required and cannot be empty") 

392 

393 # Check for excessive length (MongoDB ObjectId is 24 hex characters) 

394 # Allow some buffer but reject obviously invalid long strings 

395 if len(str_value) > 100: 

396 raise ValueError("theme_id exceeds maximum length of 100 characters") 

397 

398 # XSS prevention - check for dangerous characters/patterns 

399 # MongoDB ObjectIds should only contain hexadecimal characters (0-9, a-f, A-F) 

400 xss_patterns = [ 

401 r"<[^>]*>", # HTML tags 

402 r"javascript:", # JavaScript protocol 

403 r"on\w+\s*=", # Event handlers 

404 r"&[#\w]+;", # HTML entities 

405 r"[\x00-\x1f\x7f]", # Control characters 

406 ] 

407 

408 for pattern in xss_patterns: 

409 if re.search(pattern, str_value, re.IGNORECASE): 

410 raise ValueError("theme_id contains invalid characters") 

411 

412 # Validate MongoDB ObjectId format (24 hexadecimal characters) 

413 if not re.match(r"^[0-9a-fA-F]{24}$", str_value): 

414 raise ValueError( 

415 "theme_id must be a valid 24-character hexadecimal MongoDB ObjectId" 

416 ) 

417 

418 return value 

419 

420 @field_validator("color_mode") 

421 @classmethod 

422 def validate_color_mode(cls, value: str) -> str: 

423 """ 

424 Validates color_mode input. 

425 

426 Validates for: 

427 - Empty/null values 

428 - Valid options ("light" or "dark") 

429 - XSS prevention 

430 """ 

431 import re 

432 import html 

433 

434 # Check for null/None 

435 if value is None: 

436 raise ValueError("color_mode is required and cannot be null") 

437 

438 # Convert and strip 

439 str_value = str(value).strip().lower() 

440 

441 # Check for empty string 

442 if not str_value: 

443 raise ValueError("color_mode is required and cannot be empty") 

444 

445 # XSS prevention 

446 if re.search(r'[<>"\'&;]', str_value): 

447 raise ValueError("color_mode contains invalid characters") 

448 

449 # Validate allowed values 

450 allowed_modes = ["light", "dark"] 

451 if str_value not in allowed_modes: 

452 raise ValueError(f"color_mode must be one of: {', '.join(allowed_modes)}") 

453 

454 return str_value