Coverage for server / services / student / student_theme.py: 98%
95 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"""
2student Theme Service Module
4This module contains the service layer for managing student profile themes,
5handling theme creation, retrieval, updates, and deletion.
7The service provides methods for:
8- Fetching a student's current theme
9- Applying a theme to a student's profile
10- Updating theme settings
11- Deleting a theme and reverting to default
12"""
14from beanie import PydanticObjectId
15from bson import ObjectId
16from typing import Dict, List, Optional, Any
17from datetime import datetime, timezone
18from fastapi import HTTPException, Request, status
20from server.utilities.user_id_helper import to_user_id
21from server.models.themes import (
22 Theme,
23 ThemeColors,
24 ThemeFont,
25 ThemeLayout,
26 ThemeResponse,
27 ThemeUpdate,
28 UserTheme,
29)
30from server.services.common.theme_ownership import (
31 clone_theme_for_user,
32 is_owned_by,
33 merge_nested,
34)
35from server.utilities.error_detail import safe_detail
38class studentThemeService:
39 """Service for managing student profile themes."""
41 def __init__(self):
42 """Initialize the student theme service."""
43 pass
45 async def get_default_theme(self) -> Theme:
46 """
47 Get the default theme.
49 Returns:
50 Theme: The default theme
52 Raises:
53 HTTPException: If no default theme is found
54 """
55 default_theme = await Theme.find_one({"is_default": True, "is_active": True})
57 if not default_theme:
58 # If no default theme exists, create one
59 default_theme = Theme(
60 theme_name="Default Light",
61 description="Default light theme",
62 is_default=True,
63 color_mode="light",
64 colors=ThemeColors(
65 primary="#4a6cf7",
66 secondary="#6c757d",
67 accent="#00c6a9",
68 text="#333333",
69 background="#ffffff",
70 ),
71 font=ThemeFont(
72 family="Roboto, sans-serif", size="16px", heading_scale=1.2
73 ),
74 layout=ThemeLayout(
75 spacing="16px", border_radius="4px", card_style="shadow"
76 ),
77 )
78 await default_theme.save()
80 return default_theme
82 async def get_current_theme(self, request: Request) -> Dict:
83 """
84 Get the current theme for a student.
86 Args:
87 request: The HTTP request object containing user information
89 Returns:
90 ThemeResponse: The current theme details
92 Raises:
93 HTTPException: If no theme is found or an error occurs
94 """
95 try:
96 user_id = to_user_id(request.state.user_details["uuid"])
98 # Find active user theme
99 user_theme = await UserTheme.find_one(
100 {"user_id": user_id, "is_active": True}
101 )
103 if user_theme:
104 # Get the theme details
105 theme = await Theme.find_one(
106 {"_id": ObjectId(user_theme.theme_id), "is_active": True}
107 )
108 if not theme:
109 raise HTTPException(
110 status_code=status.HTTP_404_NOT_FOUND, detail="Theme not found"
111 )
112 # Use color_mode from UserTheme if available, otherwise fall back to Theme's color_mode
113 color_mode = (
114 user_theme.color_mode if user_theme.color_mode else theme.color_mode
115 )
116 else:
117 # If no theme is set, get the default theme
118 theme = await self.get_default_theme()
119 color_mode = theme.color_mode
121 # Convert to response model
122 return {
123 "theme_details": ThemeResponse(
124 _id=str(theme.id),
125 theme_name=theme.theme_name,
126 colors=theme.colors,
127 font=theme.font,
128 layout=theme.layout,
129 is_default=theme.is_default,
130 created_at=theme.created_at,
131 updated_at=theme.updated_at,
132 color_mode=color_mode,
133 ),
134 }
136 except HTTPException:
137 # Let deliberate HTTP errors through. Without this, the 404 raised
138 # above ("Theme not found", when the applied theme has been deleted or
139 # deactivated) was caught by the handler below and re-thrown as a
140 # 500 "Failed to fetch theme: 404: Theme not found". apply_theme,
141 # update_theme and delete_theme all already do this — get_current_theme
142 # was the only one of the four that did not.
143 raise
144 except Exception as e:
145 raise HTTPException(
146 status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
147 detail=safe_detail(e, "Failed to fetch theme"),
148 )
150 async def apply_theme(
151 self, request: Request, theme_id: PydanticObjectId, color_mode: str
152 ) -> ThemeResponse:
153 """
154 Apply a theme to a student's profile.
156 Args:
157 request: The HTTP request object containing user information
158 theme_id: The ID of the theme to apply
159 color_mode: The color mode to apply ("light" or "dark")
161 Returns:
162 ThemeResponse: The applied theme details
163 """
164 try:
165 user_id = to_user_id(request.state.user_details["uuid"])
167 # Find the theme to apply
168 theme = await Theme.find_one({"_id": theme_id, "is_active": True})
169 if not theme:
170 raise HTTPException(
171 status_code=status.HTTP_404_NOT_FOUND, detail="Theme not found"
172 )
174 # Check if a UserTheme already exists for this user
175 existing_user_theme = await UserTheme.find_one({"user_id": user_id})
177 if existing_user_theme:
178 # Update the existing theme
179 await UserTheme.find_one({"user_id": user_id}).update(
180 {
181 "$set": {
182 "theme_id": theme.id,
183 "color_mode": color_mode,
184 "is_active": True,
185 "updated_at": datetime.now(timezone.utc),
186 }
187 }
188 )
189 else:
190 # Create a new user-theme record
191 user_theme = UserTheme(
192 user_id=user_id,
193 theme_id=theme.id,
194 color_mode=color_mode,
195 is_active=True,
196 created_at=datetime.now(timezone.utc),
197 updated_at=datetime.now(timezone.utc),
198 )
199 await user_theme.save()
201 # Return the applied theme
202 return ThemeResponse(
203 _id=str(theme.id),
204 theme_name=theme.theme_name,
205 colors=theme.colors,
206 font=theme.font,
207 layout=theme.layout,
208 is_default=theme.is_default,
209 created_at=theme.created_at,
210 updated_at=theme.updated_at,
211 color_mode=color_mode,
212 )
214 except HTTPException:
215 raise
216 except Exception as e:
217 raise HTTPException(
218 status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
219 detail=safe_detail(e, "Failed to apply theme"),
220 )
222 async def update_theme(
223 self, request: Request, theme_data: ThemeUpdate
224 ) -> ThemeResponse:
225 """
226 Update the current theme settings for a student.
228 Args:
229 request: The HTTP request object containing user information
230 theme_data: Partial theme data to update (only non-None fields applied)
232 Returns:
233 ThemeResponse: The updated theme details
235 Raises:
236 HTTPException: 404 if no active theme or theme not found, 500 on error
237 """
238 try:
239 user_id = to_user_id(request.state.user_details["uuid"])
241 # Find active user theme
242 user_theme = await UserTheme.find_one(
243 {"user_id": user_id, "is_active": True}
244 )
245 if not user_theme:
246 raise HTTPException(
247 status_code=status.HTTP_404_NOT_FOUND,
248 detail="No active theme found for this user",
249 )
251 # Find the theme document
252 theme = await Theme.find_one(
253 {"_id": ObjectId(user_theme.theme_id), "is_active": True}
254 )
255 if not theme:
256 raise HTTPException(
257 status_code=status.HTTP_404_NOT_FOUND, detail="Theme not found"
258 )
260 # Apply partial updates (only non-None fields)
261 update_fields = {}
262 if theme_data.theme_name is not None:
263 update_fields["theme_name"] = theme_data.theme_name
264 if theme_data.colors is not None:
265 update_fields["colors"] = theme_data.colors.model_dump()
266 if theme_data.font is not None:
267 # #375 (Allan Ninal, 2026-10-04): merge over the stored font, so a
268 # partial update (only font.size) keeps the unsent fields.
269 update_fields["font"] = merge_nested(theme.font, theme_data.font)
270 if theme_data.layout is not None:
271 # #375 (Allan Ninal, 2026-10-04): same merge for the layout.
272 update_fields["layout"] = merge_nested(theme.layout, theme_data.layout)
273 if theme_data.custom_properties is not None:
274 update_fields["custom_properties"] = theme_data.custom_properties
276 if update_fields:
277 # Modified by Allan Ninal — 2026-09-23
278 # WHAT: edit in place ONLY a theme this user owns; editing a
279 # shared preset clones it into one they own first.
280 # WHY: `themes_collection` is a CATALOG many users point at, and
281 # this wrote straight into the row the caller's UserTheme
282 # referenced — so one user renaming "their" theme renamed it
283 # for everyone on it. Found live: the teacher-student
284 # default was sitting under the name "Updated-1790105988"
285 # (test debris) for its 23 user_themes, and the admin-staff
286 # twin was "Theme-6633" for 963 of 970. OWASP API1:2023
287 # (BOLA) — the write path mutated whatever an id resolved to
288 # without asking whose it was.
289 update_fields["updated_at"] = datetime.now(timezone.utc)
291 if is_owned_by(theme, user_id):
292 await Theme.find_one({"_id": theme.id}).update(
293 {"$set": update_fields}
294 )
295 theme = await Theme.find_one({"_id": theme.id})
296 else:
297 theme = await clone_theme_for_user(theme, user_id, update_fields)
298 await user_theme.set(
299 {
300 "theme_id": theme.id,
301 "updated_at": update_fields["updated_at"],
302 }
303 )
305 return ThemeResponse(
306 _id=str(theme.id),
307 theme_name=theme.theme_name,
308 colors=theme.colors,
309 font=theme.font,
310 layout=theme.layout,
311 is_default=theme.is_default,
312 created_at=theme.created_at,
313 updated_at=theme.updated_at,
314 color_mode=user_theme.color_mode or theme.color_mode,
315 )
317 except HTTPException:
318 raise
319 except Exception as e:
320 raise HTTPException(
321 status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
322 detail=safe_detail(e, "Failed to update theme"),
323 )
325 async def delete_theme(self, request: Request) -> Dict:
326 """
327 Soft-delete the active theme for a student, reverting to default.
329 Args:
330 request: The HTTP request object containing user information
332 Returns:
333 Dict: The default theme details after deletion
335 Raises:
336 HTTPException: 404 if no active theme found, 500 on error
337 """
338 try:
339 user_id = to_user_id(request.state.user_details["uuid"])
341 # Find active user theme
342 user_theme = await UserTheme.find_one(
343 {"user_id": user_id, "is_active": True}
344 )
345 if not user_theme:
346 raise HTTPException(
347 status_code=status.HTTP_404_NOT_FOUND,
348 detail="No active theme found to delete",
349 )
351 # Soft delete: deactivate the user theme
352 await UserTheme.find_one({"_id": user_theme.id}).update(
353 {
354 "$set": {
355 "is_active": False,
356 "updated_at": datetime.now(timezone.utc),
357 }
358 }
359 )
361 # Return the default theme
362 default_theme = await self.get_default_theme()
363 return {
364 "message": "Theme deleted successfully",
365 "theme_details": ThemeResponse(
366 _id=str(default_theme.id),
367 theme_name=default_theme.theme_name,
368 colors=default_theme.colors,
369 font=default_theme.font,
370 layout=default_theme.layout,
371 is_default=default_theme.is_default,
372 created_at=default_theme.created_at,
373 updated_at=default_theme.updated_at,
374 color_mode=default_theme.color_mode,
375 ),
376 }
378 except HTTPException:
379 raise
380 except Exception as e:
381 raise HTTPException(
382 status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
383 detail=safe_detail(e, "Failed to delete theme"),
384 )