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
« 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
22class Semesters(str, Enum):
23 SPRING = "spring"
24 SUMMER_1 = "summer 1"
25 SUMMER_2 = "summer 2"
26 FALL = "fall"
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"
39class TeacherModel(BaseModel):
40 """
41 Represents a teacher's basic information in MongoDB.
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.
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
54 Example:
55 >>> teacher = TeacherModel(
56 ... _id="663f7bcfbad258b4bbdc6a8a",
57 ... first_name="Jimrie",
58 ... last_name="Teacher",
59 ... email="jimteacher@gmail.com"
60 ... )
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 """
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
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 }
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
101class Schedule(BaseModel):
102 day: DaysOfWeek
103 time_start: str
104 time_end: str
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
120 raise HTTPException(
121 status_code=status.HTTP_400_BAD_REQUEST,
122 detail=f"Invalid time format '{value}'. Expected format like '{expected_format}'.",
123 )
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
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
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)
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)
171 class Settings:
172 name = "class_collection"
173 indexes = [
174 [("class_code", 1)],
175 [("updated_at", -1), ("created_at", -1), ("_id", -1)],
176 ]
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 )
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
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 }
221 @field_validator("title", "description")
222 @classmethod
223 def _strip_markup(cls, value, info):
224 """Store class metadata as plain text — never as markup.
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.
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.
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
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 )
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 }
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)
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
302 # Skip processing for description field
303 elif info.field_name == "description":
304 return v
306 # General handling for other fields
307 if isinstance(v, str):
308 v = v.strip()
309 if v == "":
310 return None
312 return v
314 @field_validator("title", "description")
315 @classmethod
316 def _strip_markup(cls, value, info):
317 """Store class metadata as plain text — never as markup.
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.
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.
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
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
365class LeaveRequestData(BaseModel):
366 is_granted: bool
369class UpdateStudentStatus(BaseModel):
370 student_id: str
373class StudentClassListResponse(BaseModel):
374 data: List[ClassModel]
375 count: int
376 total: int
377 page: int
378 no_of_pages: int
381class StudentFetchbyclasscodeResponse(BaseModel):
382 Class: Optional[ClassModel] = None
383 message: Optional[str] = None