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
« prev ^ index » next coverage.py v7.13.4, created at 2026-10-04 09:33 +0000
1"""
2Theme Models Module
4This module contains Pydantic models and MongoDB document schemas for teacher profile themes
5in the FastAPI application. It handles theme customization, storage, and management.
7Key Features:
8- Theme definition models
9- User-theme association models
10- Theme customization and preference models
11- Response models for API endpoints
13Technical Details:
14- Uses Beanie for MongoDB ODM integration
15- Implements Pydantic for data validation
16- Supports both sync and async operations
17"""
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
25from server.utilities.html_sanitizer import strip_html
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
33class ThemeColors(BaseModel):
34 """
35 Model for theme color configuration.
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 """
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 )
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
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
74class ThemeFont(BaseModel):
75 """
76 Model for theme font configuration.
78 Attributes:
79 family: Font family name
80 size: Base font size with units
81 heading_scale: Scale factor for headings
82 """
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 )
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
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
105class ThemeLayout(BaseModel):
106 """
107 Model for theme layout configuration.
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 """
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 )
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
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
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."""
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 )
160class ThemeLayoutUpdate(ThemeLayout):
161 """Partial layout settings for an update; unsent fields keep their stored value."""
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 )
174class Theme(Document):
175 """
176 MongoDB document schema for theme definitions.
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 """
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
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
213 class Settings:
214 """Database settings for the Theme model."""
216 name = "themes_collection"
217 indexes = [[("_id", 1)], [("is_default", 1)], [("created_by", 1)]]
220class UserTheme(Document):
221 """
222 MongoDB document schema for mapping users to their applied themes.
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 """
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
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
249 class Settings:
250 """Database settings for the UserTheme model."""
252 name = "user_themes_collection"
253 indexes = [
254 [("user_id", 1)],
255 [("theme_id", 1)],
256 [("user_id", 1), ("is_active", 1)],
257 ]
260class ThemeResponse(BaseModel):
261 """
262 Response model for theme data.
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 """
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"
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 }
291class ThemeUpdate(BaseModel):
292 """
293 Model for updating an existing theme.
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 """
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
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.
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.
324 `None` means "leave this field alone" — ThemeUpdate is a PARTIAL update —
325 so only a value that was actually sent is checked.
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
336 value = strip_html(value) or ""
337 value = value.strip()
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
355class ThemeApply(BaseModel):
356 """
357 Model for applying a theme to a user profile.
359 Attributes:
360 theme_id: MongoDB ObjectId of the theme to apply
361 color_mode: Color mode preference ("light" or "dark")
362 """
364 theme_id: PydanticObjectId
365 color_mode: str = Field(..., description="Color mode preference", example="light")
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.
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
382 # Check for null/None values
383 if value is None:
384 raise ValueError("theme_id is required and cannot be null")
386 # Convert to string for validation
387 str_value = str(value).strip()
389 # Check for empty string
390 if not str_value or str_value == "":
391 raise ValueError("theme_id is required and cannot be empty")
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")
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 ]
408 for pattern in xss_patterns:
409 if re.search(pattern, str_value, re.IGNORECASE):
410 raise ValueError("theme_id contains invalid characters")
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 )
418 return value
420 @field_validator("color_mode")
421 @classmethod
422 def validate_color_mode(cls, value: str) -> str:
423 """
424 Validates color_mode input.
426 Validates for:
427 - Empty/null values
428 - Valid options ("light" or "dark")
429 - XSS prevention
430 """
431 import re
432 import html
434 # Check for null/None
435 if value is None:
436 raise ValueError("color_mode is required and cannot be null")
438 # Convert and strip
439 str_value = str(value).strip().lower()
441 # Check for empty string
442 if not str_value:
443 raise ValueError("color_mode is required and cannot be empty")
445 # XSS prevention
446 if re.search(r'[<>"\'&;]', str_value):
447 raise ValueError("color_mode contains invalid characters")
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)}")
454 return str_value