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

1""" 

2student Theme Service Module 

3 

4This module contains the service layer for managing student profile themes, 

5handling theme creation, retrieval, updates, and deletion. 

6 

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""" 

13 

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 

19 

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 

36 

37 

38class studentThemeService: 

39 """Service for managing student profile themes.""" 

40 

41 def __init__(self): 

42 """Initialize the student theme service.""" 

43 pass 

44 

45 async def get_default_theme(self) -> Theme: 

46 """ 

47 Get the default theme. 

48 

49 Returns: 

50 Theme: The default theme 

51 

52 Raises: 

53 HTTPException: If no default theme is found 

54 """ 

55 default_theme = await Theme.find_one({"is_default": True, "is_active": True}) 

56 

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

79 

80 return default_theme 

81 

82 async def get_current_theme(self, request: Request) -> Dict: 

83 """ 

84 Get the current theme for a student. 

85 

86 Args: 

87 request: The HTTP request object containing user information 

88 

89 Returns: 

90 ThemeResponse: The current theme details 

91 

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"]) 

97 

98 # Find active user theme 

99 user_theme = await UserTheme.find_one( 

100 {"user_id": user_id, "is_active": True} 

101 ) 

102 

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 

120 

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 } 

135 

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 ) 

149 

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. 

155 

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

160 

161 Returns: 

162 ThemeResponse: The applied theme details 

163 """ 

164 try: 

165 user_id = to_user_id(request.state.user_details["uuid"]) 

166 

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 ) 

173 

174 # Check if a UserTheme already exists for this user 

175 existing_user_theme = await UserTheme.find_one({"user_id": user_id}) 

176 

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

200 

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 ) 

213 

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 ) 

221 

222 async def update_theme( 

223 self, request: Request, theme_data: ThemeUpdate 

224 ) -> ThemeResponse: 

225 """ 

226 Update the current theme settings for a student. 

227 

228 Args: 

229 request: The HTTP request object containing user information 

230 theme_data: Partial theme data to update (only non-None fields applied) 

231 

232 Returns: 

233 ThemeResponse: The updated theme details 

234 

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"]) 

240 

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 ) 

250 

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 ) 

259 

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 

275 

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) 

290 

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 ) 

304 

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 ) 

316 

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 ) 

324 

325 async def delete_theme(self, request: Request) -> Dict: 

326 """ 

327 Soft-delete the active theme for a student, reverting to default. 

328 

329 Args: 

330 request: The HTTP request object containing user information 

331 

332 Returns: 

333 Dict: The default theme details after deletion 

334 

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"]) 

340 

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 ) 

350 

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 ) 

360 

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 } 

377 

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 )