Coverage for server / services / teacher / teacher_theme.py: 99%

95 statements  

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

1""" 

2Teacher Theme Service Module 

3 

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

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

6 

7The service provides methods for: 

8- Fetching a teacher's current theme 

9- Applying a theme to a teacher'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 TeacherThemeService: 

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

40 

41 def __init__(self): 

42 """Initialize the teacher 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 teacher. 

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 below and re-thrown as 

140 # 500 "Failed to fetch theme: 404: Theme not found". apply_theme, 

141 # update_theme and delete_theme in this file already do this — 

142 # get_current_theme was the only one of the four that did not. 

143 # Identical to the fix applied to studentThemeService. 

144 raise 

145 except Exception as e: 

146 raise HTTPException( 

147 status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, 

148 detail=safe_detail(e, "Failed to fetch theme"), 

149 ) 

150 

151 async def apply_theme( 

152 self, request: Request, theme_id: PydanticObjectId, color_mode: str 

153 ) -> ThemeResponse: 

154 """ 

155 Apply a theme to a teacher's profile. 

156 

157 Args: 

158 request: The HTTP request object containing user information 

159 theme_id: The ID of the theme to apply 

160 color_mode: The color mode to apply ("light" or "dark") 

161 

162 Returns: 

163 ThemeResponse: The applied theme details 

164 """ 

165 try: 

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

167 

168 # Find the theme to apply 

169 theme = await Theme.find_one({"_id": ObjectId(theme_id), "is_active": True}) 

170 if not theme: 

171 raise HTTPException( 

172 status_code=status.HTTP_404_NOT_FOUND, detail="Theme not found" 

173 ) 

174 

175 # Check if a UserTheme already exists for this user 

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

177 

178 if existing_user_theme: 

179 # Update the existing theme 

180 await UserTheme.find_one({"user_id": user_id}).update( 

181 { 

182 "$set": { 

183 "theme_id": theme.id, 

184 "color_mode": color_mode, 

185 "is_active": True, 

186 "updated_at": datetime.now(timezone.utc), 

187 } 

188 } 

189 ) 

190 else: 

191 # Create a new user-theme record 

192 user_theme = UserTheme( 

193 user_id=user_id, 

194 theme_id=theme.id, 

195 color_mode=color_mode, 

196 is_active=True, 

197 created_at=datetime.now(timezone.utc), 

198 updated_at=datetime.now(timezone.utc), 

199 ) 

200 await user_theme.save() 

201 

202 # Return the applied theme 

203 return ThemeResponse( 

204 _id=str(theme.id), 

205 theme_name=theme.theme_name, 

206 colors=theme.colors, 

207 font=theme.font, 

208 layout=theme.layout, 

209 is_default=theme.is_default, 

210 created_at=theme.created_at, 

211 updated_at=theme.updated_at, 

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 teacher. 

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 teacher, 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 )