Coverage for server / models / assignment.py: 96%

366 statements  

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

1from datetime import datetime 

2from typing import Dict, List, Optional, Union 

3from beanie import Document, Indexed, Link, PydanticObjectId 

4from bson.errors import InvalidId 

5from server.utilities.html_sanitizer import sanitize_answer_value 

6from pydantic import BaseModel, Field, model_validator, validator, field_validator 

7from datetime import timezone 

8from enum import Enum 

9from server.validators.assignment_validator import ( 

10 validate_assignment_fields, 

11 validate_submission_fields, 

12) 

13from server.utilities.exam_catalog import validate_assignment_feature_key_rules 

14from bson import ObjectId 

15 

16 

17# --------------------------------------------------------------------------- 

18# Rubric sub-models (EI-3182 — staff comprehensive assignment delivery) 

19# --------------------------------------------------------------------------- 

20 

21 

22class RubricLevel(BaseModel): 

23 """One performance level within a rubric criterion.""" 

24 

25 label: str = Field( 

26 ..., 

27 min_length=1, 

28 max_length=200, 

29 description="Performance level label, e.g. 'Excellent'.", 

30 ) 

31 points: float = Field(..., ge=0, description="Points awarded at this level (>= 0).") 

32 description: Optional[str] = Field( 

33 default=None, 

34 max_length=2000, 

35 description="Optional prose description of this level.", 

36 ) 

37 

38 @field_validator("label") 

39 @classmethod 

40 def label_not_blank(cls, v: str) -> str: 

41 if not v.strip(): 

42 raise ValueError("rubric level label cannot be blank or whitespace only") 

43 return v.strip() 

44 

45 

46class RubricCriterion(BaseModel): 

47 """A single graded dimension within a rubric.""" 

48 

49 name: str = Field( 

50 ..., min_length=1, max_length=200, description="Criterion name, e.g. 'Clarity'." 

51 ) 

52 levels: List[RubricLevel] = Field( 

53 ..., 

54 min_length=1, 

55 max_length=20, 

56 description="Performance levels for this criterion (>= 1, <= 20).", 

57 ) 

58 

59 @field_validator("name") 

60 @classmethod 

61 def name_not_blank(cls, v: str) -> str: 

62 if not v.strip(): 

63 raise ValueError("rubric criterion name cannot be blank or whitespace only") 

64 return v.strip() 

65 

66 

67class Rubric(BaseModel): 

68 """Grading rubric: an ordered list of scored criteria.""" 

69 

70 criteria: List[RubricCriterion] = Field( 

71 ..., 

72 min_length=1, 

73 max_length=50, 

74 description="Graded criteria (>= 1, <= 50). Each criterion carries its own performance levels.", 

75 ) 

76 

77 

78class AssignmentType(str, Enum): 

79 STAAR = "STAAR" 

80 ACT = "ACT" 

81 TSI = "TSI" 

82 SAT = "SAT" 

83 CUSTOM_TEST = "custom-test" 

84 TEACHER_TEST = "custom-test" # Legacy alias — same value as CUSTOM_TEST 

85 

86 

87def _is_legacy_custom_test_type_string(value: str) -> bool: 

88 normalized = (value or "").strip().lower().replace("_", "-") 

89 return normalized == "teacher-test" 

90 

91 

92def coerce_assignment_type(value) -> "AssignmentType": 

93 """Parse assignment type strings; exam types are uppercase; custom-test is lowercase.""" 

94 if isinstance(value, AssignmentType): 

95 return value 

96 if not isinstance(value, str) or not value.strip(): 

97 allowed = ", ".join(sorted({t.value for t in AssignmentType})) 

98 raise ValueError(f"Input should be one of: {allowed}") 

99 stripped = value.strip() 

100 if ( 

101 stripped.lower() == AssignmentType.CUSTOM_TEST.value 

102 or _is_legacy_custom_test_type_string(stripped) 

103 ): 

104 return AssignmentType.CUSTOM_TEST 

105 try: 

106 return AssignmentType(stripped.upper()) 

107 except ValueError as exc: 

108 allowed = ", ".join(sorted({t.value for t in AssignmentType})) 

109 raise ValueError(f"Input should be one of: {allowed}") from exc 

110 

111 

112class LateReviewStatus(str, Enum): 

113 NONE = "none" 

114 PENDING = "pending" 

115 APPROVED = "approved" 

116 REJECTED = "rejected" 

117 

118 

119class AssignmentCategory(str, Enum): 

120 CURRENT = "current" 

121 ARCHIVED = "archived" 

122 REQUESTED = "requested" 

123 TRASHED = "trashed" 

124 

125 

126class AssignmentFormat(str, Enum): 

127 STANDARD = "standard" 

128 SPECIAL = "special" 

129 PRACTICE = "practice" 

130 

131 

132class AllowedAttempts(int, Enum): 

133 ONE = 1 

134 TWO = 2 

135 THREE = 3 

136 

137 

138class AssignmentSettings(BaseModel): 

139 # None/empty means "unlimited time" — no attempt-duration limit is enforced. 

140 time_allowed: Optional[str] = None 

141 allowed_attempts: int = 1 

142 shuffle_questions: bool = False 

143 shuffle_choices: bool = False 

144 allow_calculator: bool = False 

145 show_score_after_submit: bool = False 

146 show_correct_answers_after_submit: bool = False 

147 allow_feedback_after_submit: bool = False 

148 allow_late_submissions: bool = False 

149 

150 @model_validator(mode="before") 

151 @classmethod 

152 def validate_allowed_attempts(cls, values): 

153 if ( 

154 not values 

155 or not isinstance(values, dict) 

156 or "allowed_attempts" not in values 

157 or values["allowed_attempts"] in (None, "") 

158 ): 

159 raise ValueError( 

160 "settings.allowed_attempts field is required and cannot be empty" 

161 ) 

162 return values 

163 

164 

165def default_staff_global_assignment_settings() -> "AssignmentSettings": 

166 """Defaults when a staff global assignment is created without a settings body.""" 

167 return AssignmentSettings( 

168 time_allowed="01:00:00", 

169 allowed_attempts=1, 

170 shuffle_questions=False, 

171 shuffle_choices=False, 

172 allow_calculator=False, 

173 show_score_after_submit=False, 

174 show_correct_answers_after_submit=False, 

175 allow_feedback_after_submit=False, 

176 ) 

177 

178 

179# Maximum questions one assignment may hold. Enforced by `validate_questions` below, 

180# which runs on LOAD as well as on create — so a writer that pushes past this cap does 

181# not just fail its own request, it makes the stored document unparseable everywhere. 

182# Exported so the adaptive next-item services can refuse the append up front 

183# (Modified by Allan Ninal — 2026-09-23, EI-T115). 

184MAX_ASSIGNMENT_QUESTIONS = 100 

185 

186 

187class QuestionModel(BaseModel): 

188 id: PydanticObjectId 

189 category: Optional[str] = None # Made optional 

190 topic: Optional[str] = None # Made optional 

191 

192 @model_validator(mode="after") 

193 def validate_id(self): 

194 try: 

195 ObjectId(self.id) 

196 return self 

197 except Exception: 

198 raise ValueError("Value error, Id must be of type PydanticObjectId") 

199 

200 

201VALID_ASSIGNMENT_SEMESTERS = ["fall", "spring", "summer 1", "summer 2"] 

202 

203 

204def validate_semester_value(v: str) -> str: 

205 """Shared `semester` allow-list check — one source of truth for every path 

206 that accepts it (create, create-on-behalf-of-staff via Assignment, and update 

207 via UpdateAssignment). EI-3449: `update()` previously had no equivalent check 

208 at all, so a script payload (or any other garbage) in `semester` was stored 

209 verbatim instead of being rejected like create() already rejects it.""" 

210 if not v or not v.strip(): 

211 raise ValueError("semester field cannot contain only whitespace") 

212 if v.lower() not in VALID_ASSIGNMENT_SEMESTERS: 

213 raise ValueError(f"semester must be one of: {', '.join(VALID_ASSIGNMENT_SEMESTERS)}") 

214 return v.strip() 

215 

216 

217class Assignment(Document): 

218 semester: str 

219 type: AssignmentType # Changed back to AssignmentType enum 

220 category: str # Changed from AssignmentCategory to str to accept template variables 

221 format: str # Changed from AssignmentFormat to str to accept template variables 

222 title: str 

223 description: str 

224 questions: List[Union[QuestionModel, str]] # Allow both QuestionModel and string 

225 assigned_class: List[PydanticObjectId] = Field(default_factory=list) 

226 date_open: Optional[datetime] = None 

227 date_close: Optional[datetime] = None 

228 settings: Optional[AssignmentSettings] = None 

229 deleted: Optional[bool] = False 

230 created_at: Optional[datetime] = None 

231 created_by: Optional[str] = None 

232 # EI-3450 / EI-3451 — set by the create paths, never by the client. Hash of 

233 # (created_by, assigned_class, title, date_open, date_close); a unique+SPARSE 

234 # index on it is what makes a retried or concurrent create idempotent. Sparse 

235 # matters: the 15k+ assignments that predate this field have no key at all and 

236 # are simply not indexed, so no backfill is needed to turn the guard on. 

237 # See server/utilities/assignment_dedupe.py for the full rationale. 

238 dedupe_key: Optional[str] = None 

239 updated_at: Optional[datetime] = None 

240 updated_by: Optional[str] = None 

241 deleted_at: Optional[datetime] = None 

242 deleted_by: Optional[str] = None 

243 passing_grade: Optional[int] = 0 

244 copy_of: Optional[PydanticObjectId] = None 

245 instructions: Optional[str] = Field(default=None, max_length=5000) 

246 rubric: Optional[Rubric] = None 

247 # Per-assignment point overrides, keyed by question id (string) -> points. 

248 # Lets a teacher re-weight a question for THIS assignment only, without 

249 # touching the shared question document in teacher_questionbank / 

250 # global_questionbank. Stored as its own top-level field (not nested inside 

251 # `questions`) so the generic assignment-edit flow — which always resends 

252 # the full `questions` array as {id, category, topic} and $sets it — never 

253 # clobbers it. 

254 question_points_overrides: Optional[Dict[str, float]] = Field(default_factory=dict) 

255 feature_key: Optional[str] = Field( 

256 default=None, 

257 description=( 

258 "Required for tiered practice globals (STAAR/TSI/SAT/ACT). " 

259 "Exactly one global assignment per key: teacher.exam.{staar|tsi|sat|act}.{1-6}." 

260 ), 

261 ) 

262 

263 @model_validator(mode="before") 

264 @classmethod 

265 def normalize_assigned_class_for_global(cls, data): 

266 """Allow omitting assigned_class for global bank templates (stored as []).""" 

267 if isinstance(data, dict): 

268 ac = data.get("assigned_class") 

269 if ac is None: 

270 data["assigned_class"] = [] 

271 elif isinstance(ac, list): 

272 data["assigned_class"] = [ 

273 item for item in ac if item is not None and item != "" 

274 ] 

275 return data 

276 

277 @model_validator(mode="before") 

278 @classmethod 

279 def validate_required_fields(cls, data): 

280 if isinstance(data, dict): 

281 required_fields = [ 

282 "semester", 

283 "type", 

284 "category", 

285 "format", 

286 "title", 

287 "description", 

288 "questions", 

289 ] 

290 for field in required_fields: 

291 if field not in data: 

292 raise ValueError(f"{field} is required") 

293 return data 

294 return data 

295 

296 @model_validator(mode="after") 

297 def validate_assigned_class_ids(self): 

298 if not self.assigned_class: 

299 return self 

300 for id in self.assigned_class: 

301 try: 

302 ObjectId(id) 

303 except Exception: 

304 raise ValueError("Id must be of type PydanticObjectId") 

305 return self 

306 

307 @model_validator(mode="after") 

308 def apply_global_assignment_defaults(self): 

309 if self.settings is None: 

310 if not self.assigned_class: 

311 object.__setattr__( 

312 self, 

313 "settings", 

314 default_staff_global_assignment_settings(), 

315 ) 

316 else: 

317 raise ValueError("settings is required") 

318 return self 

319 

320 @model_validator(mode="after") 

321 def validate_exam_catalog_feature_key(self): 

322 validate_assignment_feature_key_rules( 

323 feature_key=self.feature_key, 

324 assignment_type=self.type, 

325 format_value=self.format, 

326 ) 

327 if self.feature_key: 

328 object.__setattr__(self, "feature_key", self.feature_key.strip().lower()) 

329 return self 

330 

331 @field_validator("created_by", "updated_by", "deleted_by", mode="before") 

332 @classmethod 

333 def coerce_user_ref_to_str(cls, v): 

334 # These owner/audit fields are typed as str, but historical documents store 

335 # them as ObjectId. Coerce so the model parses cleanly (EI-3279: analytics 

336 # was reaching a 400 ValidationError on ObjectId `created_by`). 

337 if isinstance(v, ObjectId): 

338 return str(v) 

339 return v 

340 

341 @field_validator("type", mode="before") 

342 @classmethod 

343 def validate_type(cls, v): 

344 return coerce_assignment_type(v) 

345 

346 @field_validator("title") 

347 @classmethod 

348 def validate_title(cls, v): 

349 if not v or not v.strip(): 

350 raise ValueError("title field cannot contain only whitespace") 

351 # EI-1218 — reject special-characters-only titles (e.g. "!!!", "###$@") 

352 if not any(c.isalnum() for c in v): 

353 raise ValueError("title must contain at least one letter or number") 

354 if len(v.strip()) > 100: 

355 raise ValueError("title must be less than 100 characters") 

356 return v.strip() 

357 

358 @field_validator("description") 

359 @classmethod 

360 def validate_description(cls, v): 

361 if not v or not v.strip(): 

362 raise ValueError("description field cannot contain only whitespace") 

363 # EI-1218 — reject special-characters-only descriptions 

364 if not any(c.isalnum() for c in v): 

365 raise ValueError("description must contain at least one letter or number") 

366 if len(v.strip()) > 500: 

367 raise ValueError("description must be less than 500 characters") 

368 return v.strip() 

369 

370 @field_validator("semester") 

371 @classmethod 

372 def validate_semester(cls, v): 

373 return validate_semester_value(v) 

374 

375 @field_validator("category") 

376 @classmethod 

377 def validate_category(cls, v): 

378 valid_categories = ["current", "archived", "requested", "trashed"] 

379 if not isinstance(v, str): 

380 raise ValueError( 

381 "Input should be 'current', 'archived', 'requested' or 'trashed'" 

382 ) 

383 if not v or not v.strip(): 

384 raise ValueError("category field cannot contain only whitespace") 

385 if v.lower() not in valid_categories: 

386 raise ValueError( 

387 "Input should be 'current', 'archived', 'requested' or 'trashed'" 

388 ) 

389 return v.strip() 

390 

391 @field_validator("format") 

392 @classmethod 

393 def validate_format(cls, v): 

394 valid_formats = ["standard", "special", "practice"] 

395 if not v or not v.strip(): 

396 raise ValueError("format field cannot contain only whitespace") 

397 if v.lower() not in valid_formats: 

398 raise ValueError(f"format must be one of: {', '.join(valid_formats)}") 

399 return v.strip() 

400 

401 @field_validator("questions") 

402 @classmethod 

403 def validate_questions(cls, v): 

404 if not v: 

405 raise ValueError("questions list cannot be empty") 

406 if len(v) > MAX_ASSIGNMENT_QUESTIONS: 

407 raise ValueError( 

408 f"maximum number of questions allowed is {MAX_ASSIGNMENT_QUESTIONS}" 

409 ) 

410 return v 

411 

412 @field_validator("passing_grade") 

413 @classmethod 

414 def validate_passing_grade(cls, v): 

415 if v is not None: 

416 if not isinstance(v, int): 

417 raise ValueError("passing_grade must be an integer") 

418 if v < 0 or v > 100: 

419 raise ValueError("passing_grade must be between 0 and 100") 

420 return v 

421 

422 @field_validator("date_open", "date_close", mode="after") 

423 @classmethod 

424 def normalize_dates_to_utc(cls, v): 

425 if v is None: 

426 return v 

427 return ( 

428 v.replace(tzinfo=timezone.utc) 

429 if v.tzinfo is None 

430 else v.astimezone(timezone.utc) 

431 ) 

432 

433 @model_validator(mode="after") 

434 def validate_dates(self): 

435 if hasattr(self, "date_open") and hasattr(self, "date_close"): 

436 if self.date_open and self.date_close: 

437 if self.date_open > self.date_close: 

438 raise ValueError("date_open must be before date_close") 

439 return self 

440 

441 class Settings: 

442 name = "assignments_collection" 

443 indexes = [[("title", 1)]] 

444 

445 class Config: 

446 json_schema_extra = { 

447 "example": { 

448 "title": "Assignment 123 from staff", 

449 "description": "test", 

450 "type": "STAAR", 

451 "category": "current", 

452 "semester": "spring", 

453 "assigned_class": ["6660821c60082c7a7abc6242"], 

454 "date_open": "2025-05-25T00:00:00.000Z", 

455 "date_close": "2025-10-27T00:00:00.000Z", 

456 "format": "standard", 

457 "questions": [ 

458 { 

459 "id": "66146bc250b7d3e9e30e0021", 

460 "category": "2", 

461 "topic": "topic2", 

462 }, 

463 { 

464 "id": "67a43228d70921ca626e99b9", 

465 "category": "1", 

466 "topic": "test", 

467 }, 

468 { 

469 "id": "67e91274bb615cb417807ef7", 

470 "category": "1", 

471 "topic": "This is topic", 

472 }, 

473 { 

474 "id": "67f12c007c6d257620daf333", 

475 "category": "1", 

476 "topic": "test", 

477 }, 

478 { 

479 "id": "67f2137ad036733d714b8344", 

480 "category": "1", 

481 "topic": "Test topic", 

482 }, 

483 { 

484 "id": "67fa816a4a7de71ac565bdab", 

485 "category": "1", 

486 "topic": "test", 

487 }, 

488 { 

489 "id": "68035aa9add1d22b4660d131", 

490 "category": "1", 

491 "topic": "Slope and Rate of Change", 

492 }, 

493 ], 

494 # Every key here was written twice, so the first seven values were 

495 # discarded by Python before anyone could read them and the documented 

496 # example showed settings the API never returns. These are the values 

497 # that survived, so the example is unchanged — it is now just true. 

498 "settings": { 

499 "allowed_attempts": 3, 

500 "shuffle_questions": True, 

501 "shuffle_choices": True, 

502 "allow_calculator": True, 

503 "show_score_after_submit": True, 

504 "show_correct_answers_after_submit": True, 

505 "allow_feedback_after_submit": True, 

506 "time_allowed": "01:00:00", 

507 }, 

508 "passing_grade": True, 

509 "copy_of": "684c36d4a688b290bbfe0b16", 

510 } 

511 } 

512 

513 

514class UpdateAssignment(BaseModel): 

515 semester: Optional[str] = None 

516 type: Optional[AssignmentType] = None 

517 category: Optional[AssignmentCategory] = None 

518 format: Optional[AssignmentFormat] = None 

519 title: Optional[str] = None 

520 description: Optional[str] = None 

521 # Accept both object refs and bare id strings, mirroring Assignment.questions. 

522 # The update path re-parses the stored doc through UpdateAssignment, so this 

523 # must tolerate bare-string question ids or an assignment created with them 

524 # raises ValidationError → 500 on update (same fix as mathmatterstx EI-2496). 

525 questions: Optional[List[Union[QuestionModel, str]]] = [] 

526 assigned_class: Optional[List[PydanticObjectId]] = [] 

527 date_open: Optional[datetime] = None 

528 date_close: Optional[datetime] = None 

529 settings: Optional[AssignmentSettings] = None 

530 updated_at: Optional[datetime] = None 

531 updated_by: Optional[str] = None 

532 passing_grade: Optional[int] = 0 

533 instructions: Optional[str] = Field(default=None, max_length=5000) 

534 rubric: Optional[Rubric] = None 

535 question_points_overrides: Optional[Dict[str, float]] = None 

536 feature_key: Optional[str] = None 

537 

538 @field_validator("type", mode="before") 

539 @classmethod 

540 def validate_update_type(cls, v): 

541 if v is None: 

542 return v 

543 return coerce_assignment_type(v) 

544 

545 @field_validator("semester") 

546 @classmethod 

547 def validate_update_semester(cls, v): 

548 # EI-3449: semester stays optional on THIS model — it's re-used 

549 # internally for partial construction (unit tests covering other 

550 # fields, and the DB round-trip re-parse in 

551 # teacher_assignment.py:update / assignments.py:assignment_update), 

552 # where requiring it would break callers that never touch it. The 

553 # HTTP PUT .../update request body itself requires semester via 

554 # UpdateAssignmentRequest below (EI-T583) — this validator still 

555 # applies the same allow-list check WHEN a value is present, either 

556 # way. 

557 if v is None: 

558 return v 

559 return validate_semester_value(v) 

560 

561 @field_validator("date_open", "date_close", mode="after") 

562 @classmethod 

563 def normalize_dates_to_utc(cls, v): 

564 if v is None: 

565 return v 

566 return ( 

567 v.replace(tzinfo=timezone.utc) 

568 if v.tzinfo is None 

569 else v.astimezone(timezone.utc) 

570 ) 

571 

572 class Settings: 

573 name = "assignments_collection" 

574 indexes = [[("title", 1)]] 

575 

576 class Config: 

577 json_schema_extra = { 

578 "example": { 

579 "title": "Assignment 123 from staff", 

580 "description": "test", 

581 "type": "STAAR", 

582 "category": "current", 

583 "semester": "spring", 

584 "assigned_class": ["6660821c60082c7a7abc6242"], 

585 "date_open": "2025-05-25T00:00:00.000Z", 

586 "date_close": "2025-10-27T00:00:00.000Z", 

587 "format": "standard", 

588 "questions": [ 

589 { 

590 "id": "67a43228d70921ca626e99b9", 

591 "category": "1", 

592 "topic": "test", 

593 }, 

594 { 

595 "id": "67e91274bb615cb417807ef7", 

596 "category": "1", 

597 "topic": "This is topic", 

598 }, 

599 { 

600 "id": "67f12c007c6d257620daf333", 

601 "category": "1", 

602 "topic": "test", 

603 }, 

604 { 

605 "id": "67f2137ad036733d714b8344", 

606 "category": "1", 

607 "topic": "Test topic", 

608 }, 

609 { 

610 "id": "67fa816a4a7de71ac565bdab", 

611 "category": "1", 

612 "topic": "test", 

613 }, 

614 { 

615 "id": "68035aa9add1d22b4660d131", 

616 "category": "1", 

617 "topic": "Slope and Rate of Change", 

618 }, 

619 ], 

620 "settings": { 

621 "allowed_attempts": 1, 

622 "shuffle_questions": False, 

623 "shuffle_choices": False, 

624 "allow_calculator": False, 

625 "show_score_after_submit": False, 

626 "show_correct_answers_after_submit": False, 

627 "allow_feedback_after_submit": False, 

628 "time_allowed": "01:00:00", 

629 }, 

630 "passing_grade": True, 

631 "copy_of": "684c36d4a688b290bbfe0b16", 

632 } 

633 } 

634 

635 

636class UpdateAssignmentRequest(UpdateAssignment): 

637 """The PUT .../update HTTP request body (EI-T583). 

638 

639 `UpdateAssignment` itself must keep `semester` optional — it's reused 

640 for partial, non-HTTP construction (the DB round-trip re-parse in 

641 teacher_assignment.py:update / assignments.py:assignment_update, and 

642 direct construction in tests/unit/models/test_assignment.py covering 

643 unrelated fields like instructions/rubric/questions). Requiring it 

644 there would break every one of those callers. 

645 

646 The actual client-facing update REQUEST, however, must require 

647 `semester` just like create() / create_on_behalf_of_staff() already do 

648 — omitting it used to be accepted and silently left the existing value 

649 unchanged. This subclass is what the route type-hints instead of the 

650 base model, so only the HTTP boundary is affected. 

651 """ 

652 

653 @model_validator(mode="before") 

654 @classmethod 

655 def validate_required_semester(cls, data): 

656 # Matches the exact "X is required" contract create()'s 

657 # validate_required_fields already uses (Assignment, above), so the 

658 # 422 shape is identical across create / create-on-behalf-of-staff / 

659 # update. 

660 if isinstance(data, dict) and "semester" not in data: 

661 raise ValueError("semester is required") 

662 return data 

663 

664 

665class LateReviewRequest(BaseModel): 

666 late_penalty: int = Field(0, ge=0, le=100) 

667 

668 

669class LateRejectRequest(BaseModel): 

670 reason: Optional[str] = Field(default=None, max_length=500) 

671 

672 

673class TeacherCommentRequest(BaseModel): 

674 comment: str = Field(..., min_length=1, max_length=2000) 

675 

676 @field_validator("comment", mode="after") 

677 @classmethod 

678 def strip_and_require_nonblank(cls, value: str) -> str: 

679 stripped = value.strip() 

680 if not stripped: 

681 raise ValueError("Comment cannot be blank.") 

682 return stripped 

683 

684 

685class UpdateQuestionPointsRequest(BaseModel): 

686 points: float = Field(..., ge=0, le=1000) 

687 

688 

689class MarkStudentAnswerRequest(BaseModel): 

690 """One teacher's mark on one student's written answer. 

691 

692 `points` is bounded again in the service against the question's OWN value — 1000 is 

693 only a sanity ceiling here, and awarding a 5-point question 40 marks would otherwise 

694 quietly inflate a grade past 100%. 

695 """ 

696 

697 points: float = Field(..., ge=0, le=1000) 

698 feedback: Optional[str] = Field(default=None, max_length=2000) 

699 

700 

701class Answer(BaseModel): 

702 question_id: str 

703 user_answer: str 

704 is_correct: bool 

705 

706 @field_validator("user_answer") 

707 @classmethod 

708 def _neutralise_markup(cls, value): 

709 """Sanitise the answer's markup before it is stored (EI-T717 / EI-T721). 

710 

711 Added by Allan Ninal — 2026-09-23 

712 WHAT: same rule as AnswerItem.studentAnswer, on the OTHER student-answer 

713 model. 

714 WHY: EI-T717/T721 named /answers/save and /answers/submit, which bind 

715 `AnswerItem`. But a student answer also reaches storage through 

716 `Submission.answers` -> this model, on POST /v1/assignments/answer 

717 and POST /v1/teacher/assignment/answer/create. Fixing one model and 

718 leaving the other would have closed two doors out of four and left 

719 the defect reachable, so both are covered. 

720 """ 

721 return sanitize_answer_value(value) 

722 

723 

724class Submission(Document): 

725 assignment_id: str 

726 student_id: Optional[str] = None 

727 total_score: Optional[int] = 0 

728 remarks: Optional[str] = None 

729 answers: List[Answer] 

730 submitted_at: Optional[datetime] = None 

731 

732 @field_validator("submitted_at", mode="before") 

733 @classmethod 

734 def set_submitted_at_now(cls, v): 

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

736 

737 @model_validator(mode="before") 

738 @classmethod 

739 def validate_required_fields(cls, data): 

740 if isinstance(data, dict): 

741 return validate_submission_fields(data) 

742 return data 

743 

744 class Settings: 

745 name = "submission_collection" 

746 indexes = [[("title", 1)]] 

747 

748 class Config: 

749 json_schema_extra = { 

750 "example": { 

751 "assignment_id": "5eb7cf5a86d9755df3a6c593", 

752 "total_score": 20, 

753 "remarks": "FAILED", 

754 "answers": [ 

755 { 

756 "question_id": "5eb7cf5a86d9755df3a6c593", 

757 "user_answer": "string", 

758 "is_correct": True, 

759 } 

760 ], 

761 } 

762 }