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

184 statements  

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

1# class_operations.py 

2from datetime import datetime, timezone 

3from typing import Annotated, List, Optional 

4from beanie import Document, Indexed, PydanticObjectId, init_beanie 

5from fastapi import HTTPException, Query, Request, status 

6from motor.motor_asyncio import AsyncIOMotorClient 

7from pydantic import ( 

8 BaseModel, 

9 Field, 

10 field_validator, 

11 validator, 

12 EmailStr, 

13 model_serializer, 

14 ConfigDict, 

15 model_validator, 

16) 

17from server.models.users import User 

18from server.utilities.html_sanitizer import to_plain_text 

19from enum import Enum 

20 

21 

22class Semesters(str, Enum): 

23 SPRING = "spring" 

24 SUMMER_1 = "summer 1" 

25 SUMMER_2 = "summer 2" 

26 FALL = "fall" 

27 

28 

29class DaysOfWeek(str, Enum): 

30 MONDAY = "Monday" 

31 TUESDAY = "Tuesday" 

32 WEDNESDAY = "Wednesday" 

33 THURSDAY = "Thursday" 

34 FRIDAY = "Friday" 

35 SATURDAY = "Saturday" 

36 SUNDAY = "Sunday" 

37 

38 

39class TeacherModel(BaseModel): 

40 """ 

41 Represents a teacher's basic information in MongoDB. 

42 

43 This model provides core validation for essential teacher data fields. 

44 It serves as a lightweight representation of teacher information, particularly 

45 useful for list views and basic identification purposes. 

46 

47 Attributes: 

48 id (PydanticObjectId): MongoDB's unique identifier, aliased as "_id" 

49 subscriber_id (str, optional): External subscription identifier, defaults to None 

50 first_name (str): Teacher's first name 

51 last_name (str): Teacher's last name 

52 email (EmailStr): Teacher's email address for communication and identification 

53 

54 Example: 

55 >>> teacher = TeacherModel( 

56 ... _id="663f7bcfbad258b4bbdc6a8a", 

57 ... first_name="Jimrie", 

58 ... last_name="Teacher", 

59 ... email="jimteacher@gmail.com" 

60 ... ) 

61 

62 Note: 

63 - This is a simplified version of the teacher model containing only essential fields 

64 - The `id` field uses an alias "_id" to match MongoDB's document structure 

65 - `populate_by_name=True` allows both `id` and `_id` to be used when creating instances 

66 - Email validation is handled by Pydantic's EmailStr type 

67 """ 

68 

69 id: PydanticObjectId = Field(alias="_id") 

70 subscriber_id: Optional[str] = None 

71 auth0_user_id: Optional[str] = None 

72 first_name: str 

73 last_name: str 

74 email: EmailStr 

75 

76 class Config: 

77 populate_by_name = True 

78 json_schema_extra = { 

79 "example": { 

80 "_id": "663f7bcfbad258b4bbdc6a8a", 

81 "subscriber_id": "123456", 

82 "first_name": "Jimrie", 

83 "last_name": "Teacher", 

84 "email": "jimteacher@gmail.com", 

85 } 

86 } 

87 

88 

89class StudentModel(BaseModel): 

90 id: PydanticObjectId = Field(alias="_id") 

91 subscriber_id: Optional[str] = None 

92 auth0_user_id: Optional[str] = None 

93 first_name: str 

94 middle_name: Optional[str] = None 

95 last_name: str 

96 email: str 

97 status: Optional[str] = None 

98 is_requesting_to_leave: Optional[bool] = False 

99 

100 

101class Schedule(BaseModel): 

102 day: DaysOfWeek 

103 time_start: str 

104 time_end: str 

105 

106 @field_validator("time_start", "time_end") 

107 def validate_time(cls, value: str) -> str: 

108 try: 

109 parsed = datetime.strptime(value.strip(), "%I:%M %p") 

110 return parsed.strftime("%I:%M %p") # normalized format 

111 except ValueError: 

112 candidate = value.strip()[:7] 

113 expected_format = "HH:MM AM/PM" 

114 try: 

115 parsed = datetime.strptime(candidate, "%I:%M %p") 

116 expected_format = parsed.strftime("%I:%M %p") 

117 except Exception: 

118 pass 

119 

120 raise HTTPException( 

121 status_code=status.HTTP_400_BAD_REQUEST, 

122 detail=f"Invalid time format '{value}'. Expected format like '{expected_format}'.", 

123 ) 

124 

125 @model_validator(mode="after") 

126 def check_time_order(self) -> "Schedule": 

127 start = datetime.strptime(self.time_start, "%I:%M %p").time() 

128 end = datetime.strptime(self.time_end, "%I:%M %p").time() 

129 if end <= start: 

130 raise HTTPException( 

131 status_code=status.HTTP_400_BAD_REQUEST, 

132 detail="time_end must be greater than time_start", 

133 ) 

134 return self 

135 

136 

137class ClassModel(Document): 

138 title: str 

139 description: Optional[str] = "" 

140 section: str 

141 semester: str = "Fall" 

142 class_code: Annotated[str, Indexed(unique=True)] 

143 schedules: Optional[List[Schedule]] = [] 

144 status: str = "VISIBLE" 

145 teacher: Optional[TeacherModel] = None 

146 students: Optional[List[StudentModel]] = [] 

147 # Bare storage-bucket object key (e.g. "class-images/<class_id>/<file>.jpg"), 

148 # not a URL — a presigned display URL is minted at read time via class_photo_url(). 

149 class_photo: Optional[str] = None 

150 # Soft-delete flag (mirrors Assignment.deleted). Defaults to False so 

151 # existing documents without the field behave as live classes. 

152 # `class_code` remains reserved against reissue while `deleted=True`, 

153 # because `generate_unique_code` does not filter on this flag. 

154 deleted: bool = False 

155 # One live class per (teacher, title, section), enforced by a partial unique 

156 # index — see server/utilities/class_dedupe.py. None on a deleted class. 

157 dedupe_key: Optional[str] = None 

158 created_at: Optional[datetime] = None 

159 updated_at: Optional[datetime] = None 

160 

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

162 @classmethod 

163 def set_updated_at_now(cls, v): 

164 return v or datetime.now(timezone.utc) 

165 

166 @field_validator("created_at", mode="before") 

167 @classmethod 

168 def set_created_at_now(cls, v): 

169 return v or datetime.now(timezone.utc) 

170 

171 class Settings: 

172 name = "class_collection" 

173 indexes = [ 

174 [("class_code", 1)], 

175 [("updated_at", -1), ("created_at", -1), ("_id", -1)], 

176 ] 

177 

178 

179class RegisterClass(BaseModel): 

180 title: str 

181 description: Optional[str] = "" 

182 section: str 

183 semester: Semesters 

184 class_code: Optional[str] = "" 

185 schedules: List[Schedule] 

186 status: str = "VISIBLE" 

187 teacher: Optional[TeacherModel] = None 

188 students: Optional[List[StudentModel]] = [] 

189 created_at: Optional[datetime] = Field( 

190 default_factory=lambda: datetime.now(timezone.utc) 

191 ) 

192 updated_at: Optional[datetime] = Field( 

193 default_factory=lambda: datetime.now(timezone.utc) 

194 ) 

195 

196 @field_validator("*", mode="before") 

197 def blank_strings(cls, value): 

198 if isinstance(value, str): 

199 value = value.strip() 

200 if value == "": 

201 return None 

202 return value 

203 

204 class Config: 

205 json_schema_extra = { 

206 "example": { 

207 "title": "Sample Title", 

208 "description": "Sample description", 

209 "section": "SEC-A", 

210 "semester": "Fall", 

211 "schedules": [ 

212 { 

213 "day": "Monday", 

214 "time_start": "8:00 am", 

215 "time_end": "5:00 pm", 

216 } 

217 ], 

218 } 

219 } 

220 

221 @field_validator("title", "description") 

222 @classmethod 

223 def _strip_markup(cls, value, info): 

224 """Store class metadata as plain text — never as markup. 

225 

226 Added by Allan Ninal — 2026-09-28 (EI-T475 create / EI-T496 update). 

227 WHAT: `title` and `description` had no rule at all, so a script payload was 

228 persisted byte-for-byte: creating a class with 

229 `<script>alert('XSS')</script>` stored exactly that, and updating one 

230 did the same. Verified live on QA 2026-09-27. 

231 WHY: `to_plain_text` (a thin wrapper over the house `strip_html`) is the rule for plain-text metadata — it is 

232 applied to `theme_name` (models/themes.py), to the user fields in 

233 models/users.py, and to the assignment comment/reason in 

234 services/teacher/teacher_assignment.py. Class fields were simply 

235 missed, and an inconsistent guard is the thing that lets a payload 

236 through. 

237 

238 Order follows the ContactPerson fix (PR #326) and the theme_name validator: 

239 strip markup FIRST, so pure markup collapses to "" and is refused, while 

240 markup wrapping text is kept as inert text. 

241 

242 NOTE on impact, stated honestly: no execution path was found for these 

243 values in the Teacher/Student SPA — its 30 `dangerouslySetInnerHTML` uses 

244 are all question/assignment surfaces, and React escapes class titles. This 

245 closes the gap at rest and brings the fields in line with their siblings; 

246 it is not a fix for a demonstrated stored-XSS. 

247 """ 

248 if value is None: 

249 return None 

250 cleaned = (to_plain_text(value) or "").strip() 

251 if not cleaned and info.field_name == "title": 

252 raise HTTPException( 

253 status_code=status.HTTP_400_BAD_REQUEST, 

254 detail="title cannot be blank", 

255 ) 

256 return cleaned 

257 

258 

259class UpdateClassModel(BaseModel): 

260 title: str 

261 description: Optional[str] = None 

262 semester: str 

263 section: str 

264 class_code: Optional[str] = None 

265 schedules: Optional[List[Schedule]] = [] 

266 updated_at: Optional[datetime] = Field( 

267 default_factory=lambda: datetime.now(timezone.utc) 

268 ) 

269 

270 class Config: 

271 json_schema_extra = { 

272 "example": { 

273 "title": "Sample Title", 

274 "description": "Sample description", 

275 "section": "SEC-A", 

276 "semester": "Fall", 

277 "class_code": "VCgGhv", 

278 "schedules": [ 

279 { 

280 "day": "Monday", 

281 "time_start": "8:00 am", 

282 "time_end": "5:00 pm", 

283 } 

284 ], 

285 } 

286 } 

287 

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

289 @classmethod 

290 def set_updated_at_now(cls, v): 

291 return v or datetime.now(timezone.utc) 

292 

293 @field_validator("*", mode="before") 

294 def blank_strings(cls, v, info): 

295 # Handle class_code field specifically 

296 if info.field_name == "class_code": 

297 if isinstance(v, str): 

298 v = v.strip() 

299 if v == "": 

300 return "" # Explicitly handle empty string for class_code 

301 

302 # Skip processing for description field 

303 elif info.field_name == "description": 

304 return v 

305 

306 # General handling for other fields 

307 if isinstance(v, str): 

308 v = v.strip() 

309 if v == "": 

310 return None 

311 

312 return v 

313 

314 @field_validator("title", "description") 

315 @classmethod 

316 def _strip_markup(cls, value, info): 

317 """Store class metadata as plain text — never as markup. 

318 

319 Added by Allan Ninal — 2026-09-28 (EI-T475 create / EI-T496 update). 

320 WHAT: `title` and `description` had no rule at all, so a script payload was 

321 persisted byte-for-byte: creating a class with 

322 `<script>alert('XSS')</script>` stored exactly that, and updating one 

323 did the same. Verified live on QA 2026-09-27. 

324 WHY: `to_plain_text` (a thin wrapper over the house `strip_html`) is the rule for plain-text metadata — it is 

325 applied to `theme_name` (models/themes.py), to the user fields in 

326 models/users.py, and to the assignment comment/reason in 

327 services/teacher/teacher_assignment.py. Class fields were simply 

328 missed, and an inconsistent guard is the thing that lets a payload 

329 through. 

330 

331 Order follows the ContactPerson fix (PR #326) and the theme_name validator: 

332 strip markup FIRST, so pure markup collapses to "" and is refused, while 

333 markup wrapping text is kept as inert text. 

334 

335 NOTE on impact, stated honestly: no execution path was found for these 

336 values in the Teacher/Student SPA — its 30 `dangerouslySetInnerHTML` uses 

337 are all question/assignment surfaces, and React escapes class titles. This 

338 closes the gap at rest and brings the fields in line with their siblings; 

339 it is not a fix for a demonstrated stored-XSS. 

340 """ 

341 if value is None: 

342 return None 

343 cleaned = (to_plain_text(value) or "").strip() 

344 if not cleaned and info.field_name == "title": 

345 raise HTTPException( 

346 status_code=status.HTTP_400_BAD_REQUEST, 

347 detail="title cannot be blank", 

348 ) 

349 return cleaned 

350 

351 

352class ClassModelRequestSchema(BaseModel): 

353 title: str 

354 description: str 

355 section: str 

356 semester: str = "Fall" 

357 class_code: Optional[str] = "" 

358 schedules: List[Schedule] 

359 status: str = "VISIBLE" 

360 teacher: Optional[TeacherModel] = None 

361 created_at: Optional[datetime] = None 

362 updated_at: Optional[datetime] = None 

363 

364 

365class LeaveRequestData(BaseModel): 

366 is_granted: bool 

367 

368 

369class UpdateStudentStatus(BaseModel): 

370 student_id: str 

371 

372 

373class StudentClassListResponse(BaseModel): 

374 data: List[ClassModel] 

375 count: int 

376 total: int 

377 page: int 

378 no_of_pages: int 

379 

380 

381class StudentFetchbyclasscodeResponse(BaseModel): 

382 Class: Optional[ClassModel] = None 

383 message: Optional[str] = None