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
« 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
17# ---------------------------------------------------------------------------
18# Rubric sub-models (EI-3182 — staff comprehensive assignment delivery)
19# ---------------------------------------------------------------------------
22class RubricLevel(BaseModel):
23 """One performance level within a rubric criterion."""
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 )
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()
46class RubricCriterion(BaseModel):
47 """A single graded dimension within a rubric."""
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 )
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()
67class Rubric(BaseModel):
68 """Grading rubric: an ordered list of scored criteria."""
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 )
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
87def _is_legacy_custom_test_type_string(value: str) -> bool:
88 normalized = (value or "").strip().lower().replace("_", "-")
89 return normalized == "teacher-test"
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
112class LateReviewStatus(str, Enum):
113 NONE = "none"
114 PENDING = "pending"
115 APPROVED = "approved"
116 REJECTED = "rejected"
119class AssignmentCategory(str, Enum):
120 CURRENT = "current"
121 ARCHIVED = "archived"
122 REQUESTED = "requested"
123 TRASHED = "trashed"
126class AssignmentFormat(str, Enum):
127 STANDARD = "standard"
128 SPECIAL = "special"
129 PRACTICE = "practice"
132class AllowedAttempts(int, Enum):
133 ONE = 1
134 TWO = 2
135 THREE = 3
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
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
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 )
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
187class QuestionModel(BaseModel):
188 id: PydanticObjectId
189 category: Optional[str] = None # Made optional
190 topic: Optional[str] = None # Made optional
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")
201VALID_ASSIGNMENT_SEMESTERS = ["fall", "spring", "summer 1", "summer 2"]
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()
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 )
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
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
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
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
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
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
341 @field_validator("type", mode="before")
342 @classmethod
343 def validate_type(cls, v):
344 return coerce_assignment_type(v)
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()
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()
370 @field_validator("semester")
371 @classmethod
372 def validate_semester(cls, v):
373 return validate_semester_value(v)
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()
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()
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
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
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 )
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
441 class Settings:
442 name = "assignments_collection"
443 indexes = [[("title", 1)]]
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 }
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
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)
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)
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 )
572 class Settings:
573 name = "assignments_collection"
574 indexes = [[("title", 1)]]
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 }
636class UpdateAssignmentRequest(UpdateAssignment):
637 """The PUT .../update HTTP request body (EI-T583).
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.
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 """
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
665class LateReviewRequest(BaseModel):
666 late_penalty: int = Field(0, ge=0, le=100)
669class LateRejectRequest(BaseModel):
670 reason: Optional[str] = Field(default=None, max_length=500)
673class TeacherCommentRequest(BaseModel):
674 comment: str = Field(..., min_length=1, max_length=2000)
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
685class UpdateQuestionPointsRequest(BaseModel):
686 points: float = Field(..., ge=0, le=1000)
689class MarkStudentAnswerRequest(BaseModel):
690 """One teacher's mark on one student's written answer.
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 """
697 points: float = Field(..., ge=0, le=1000)
698 feedback: Optional[str] = Field(default=None, max_length=2000)
701class Answer(BaseModel):
702 question_id: str
703 user_answer: str
704 is_correct: bool
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).
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)
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
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)
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
744 class Settings:
745 name = "submission_collection"
746 indexes = [[("title", 1)]]
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 }