Coverage for server / models / question_bank.py: 100%
180 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 pydantic import BaseModel, Field, model_validator
2from typing import List, Optional, Union
3from datetime import datetime
5# from beanie import Document
6from bson import ObjectId
7from server.validators.question_bank.input_validations import (
8 QuestionType,
9 AssignmentType,
10 Category,
11 DifficultyType,
12 DrawingMode,
13 Subject,
14 GradeLevel,
15)
16from server.validators.question_bank.field_validations import (
17 validate_question_field,
18 validate_choices_field,
19 validate_correct_answer_field,
20 validate_question_details_field,
21 validate_assignment_type_field,
22 validate_question_type_field,
23 validate_difficulty_field,
24 validate_solutions_field,
25 validate_teks_code_field,
26 validate_points_field,
27 validate_category_field,
28 validate_release_date_field,
29 validate_deleted_field,
30 validate_deleted_date_field,
31 validate_created_date_field,
32 validate_updated_date_field,
33 validate_grade_level_field,
34)
35from server.validators.question_request_root_validators import (
36 validate_staff_staar_fields,
37 validate_sat_tsi_act_fields,
38 validate_question_content,
39)
40from server.validators.question_richtext import RICH_EDITOR_HTML_MAX, enforce_text_length
42# The vocabulary of `source` (how a question entered the bank). Defined once because
43# TeacherQuestionService.create checks it too: question import hands the channel in as an
44# argument rather than inside the payload, so the model is not in that write path and a
45# second, drifting copy of the rule is exactly what would go unnoticed.
46SOURCE_PATTERN = r"^[a-z0-9_]+$"
47SOURCE_MAX_LENGTH = 32
50class ObjectIdField(str):
51 """Custom ObjectId type for Pydantic."""
53 @classmethod
54 def __get_validators__(cls):
55 yield cls.validate
57 @classmethod
58 def validate(cls, v):
59 if isinstance(v, ObjectId):
60 return str(v) # Return ObjectId as string
61 if isinstance(v, str) and ObjectId.is_valid(v):
62 return ObjectId(v) # Convert string to ObjectId
63 raise TypeError("ObjectId required")
65 @classmethod
66 def __get_pydantic_json_schema__(cls, *args, **kwargs):
67 return {"type": "string"}
70class Item(BaseModel):
71 id: int
72 text: str = Field(..., max_length=RICH_EDITOR_HTML_MAX)
73 graph: str = Field("")
76class Select(BaseModel):
77 id: int
78 items: List[Item]
81class Answer(BaseModel):
82 id: Optional[int] = None
83 answer: str = Field(..., max_length=RICH_EDITOR_HTML_MAX)
86class CorrectAnswer(BaseModel):
87 answers: Optional[Union[List[Answer], List[str], str]] = Field(default="")
88 answerDetails: Optional[str] = Field(default=None, max_length=RICH_EDITOR_HTML_MAX)
89 graph: Optional[str] = Field(default="")
90 graphFingerprint: Optional[str] = Field(default=None)
91 # Position-sensitive types only (Drop-down-Menu, and the Single-Stimulus
92 # "drop-down"/"drag-and-drop" group types — see answer_checking.py's
93 # _POSITION_SENSITIVE_TYPES): by default each blank/id is graded against the
94 # answer authored FOR that specific blank. Setting this opts the question
95 # into grading the answers as a set instead — a correct answer counts in
96 # ANY blank, not only the one it was assigned to at authoring time.
97 # Ignored by every other question type.
98 unordered: Optional[bool] = Field(default=False)
100 @model_validator(mode="before")
101 def validate_answer_format(cls, values):
102 # Handle None or missing values
103 if not values:
104 values = {}
106 # Set defaults if not present
107 values.setdefault("answers", "")
108 values.setdefault("answerDetails", None)
109 values.setdefault("graph", "")
111 # For Free-response, allow string answer (including empty string)
112 if isinstance(values.get("answers"), (str, type(None))):
113 values["answers"] = values.get("answers") or ""
114 return values
116 # For other types, ensure proper format
117 answers = values.get("answers")
118 if answers and isinstance(answers, list):
119 # Convert simple string answers to Answer objects
120 if all(isinstance(a, str) for a in answers):
121 values["answers"] = [Answer(id=i, answer=a) for i, a in enumerate(answers)]
122 return values
125class GroupModel(BaseModel):
126 """One independently-scored part of a Single-Stimulus or
127 Multi-Part-Question question.
129 The main question stays the same across every group; each group carries
130 its own interaction type, choices, and correct answer, and is scored on
131 its own (see server/services/common/answer_checking.py::
132 grade_single_stimulus_answer). Documentation-only: the real
133 create/update routes take a raw dict, and live validation for this shape
134 lives in TeacherQuestionService._validate_question_data, not this model.
135 """
137 group_id: str
138 page: int
139 type: str # "multiple-choice" | "checkbox" | "free-response" | "graph" | "drop-down" | "drag-and-drop"
140 # Multi-Part-Question only: this group's own individual question/prompt,
141 # in addition to the shared main `question` above — required for every
142 # group of that type, regardless of `type`; absent/unused for
143 # Single-Stimulus groups.
144 questionText: Optional[str] = None
145 # "multiple-choice"/"checkbox" groups use flat {id, text} choices — same
146 # shape a "drag-and-drop" group's ONE shared pool of draggable choices
147 # uses. "free-response"/"graph" groups carry their answer in
148 # correctAnswer.answers instead (a model-answer string / a Graph2D export
149 # string, respectively). "drop-down" and "drag-and-drop" groups both also
150 # carry their own `content` (a sentence-with-blanks string, separate from
151 # the shared main `question` — see DropdownMenuV2.jsx / DragDrop.jsx /
152 # SingleStimulusEditor.jsx on the client) and correctAnswer.answers
153 # holding one {id, answer} per blank — "drop-down" nests its choices per
154 # blank ({id, items}, a Select); "drag-and-drop" keys each blank answer by
155 # its 0-based position instead, since its choices are one flat shared pool.
156 content: Optional[str] = None
157 choices: List[Union[Item, Select]] = Field(default_factory=list)
158 correctAnswer: CorrectAnswer
159 points: int = 1
162class QuestionModelCreate(BaseModel):
163 assignmentType: AssignmentType # (STAAR, TSI, SAT, ACT, College Level, Erudition)
164 questionType: QuestionType # (Multiple Choice, Multi-Select, etc.)
165 question: str = Field(...) # temporarily removed max_length
166 questionSubject: Subject # (Math, Science, Social Studies, English)
167 questionDetails: Optional[str] = None
168 # EI-2845 (Allan Ninal 2026-08-16): questionTopic had NO length cap, so the API
169 # accepted topics of any size — 1000 and even 5000 characters were created with
170 # 201. 300 matches every sibling free-text field on this model (Item.text,
171 # Answer.answer, CorrectAnswer.answerDetails). Sampled 100 live questions on QA
172 # before choosing it: the longest real topic is 8 characters, so this rejects
173 # abuse without touching legitimate data.
174 questionTopic: Optional[str] = Field(default=None, max_length=300)
175 # Staff-assigned catalog id from a bulk import, e.g. "MATH-ALG-001". Optional so
176 # UI-created questions, which have none, are unaffected; a unique SPARSE index on
177 # teacher_questionbank stops two questions in THIS bank claiming the same id.
178 # Admin-Staff is a separate database, so the same id may exist there too.
179 questionId: Optional[str] = Field(default=None, max_length=128)
180 # How this question entered the bank -- "csv_upload", "docx_upload", "pasted" -- set
181 # by the import service from the stored job, never by the client. Optional because
182 # questions authored in the UI and everything predating the field have no channel to
183 # record, and asserting one would be a fabricated audit trail.
184 #
185 # Constrained rather than free text: it is read by audits and written to log lines,
186 # so an arbitrary string could forge a plausible-looking record.
187 #
188 # Unlike Admin-Staff, leaving this off QuestionModelUpdate is NOT enough to stop an
189 # edit rewriting it: the update endpoint takes a raw dict and spreads it into $set.
190 # TeacherQuestionService.update strips it explicitly -- see the note there.
191 source: Optional[str] = Field(
192 default=None, max_length=SOURCE_MAX_LENGTH, pattern=SOURCE_PATTERN
193 )
194 questionImages: Optional[List[str]] = Field(default_factory=list)
195 questionGraphs: Optional[List[str]] = Field(default_factory=list)
196 choices: Optional[List[Union[Item, Select]]] = Field(default=None)
197 groups: Optional[List[GroupModel]] = Field(default=None) # Single-Stimulus / Multi-Part-Question only
198 rows: Optional[List[Item]] = Field(default=None) # Grid-Question only — one entry per statement row
199 rowHeaderLabel: Optional[str] = Field(default=None) # Grid-Question only — first column header text, defaults to "Statement"
200 correctAnswer: CorrectAnswer
201 difficulty: DifficultyType
202 drawingMode: Optional[DrawingMode] = None
203 teksCode: Optional[str] = Field(default=None) # Required for STAAR
204 gradeLevel: Optional[GradeLevel]
205 solutions: str
206 studentExpectation: Optional[str] = None
207 stateCountry: Optional[str] = None
208 keywords: List[str]
209 # Required for STAAR
210 releaseDate: Optional[datetime] = None
211 points: int = Field(...)
212 category: Optional[Category] = None
213 createdDate: Optional[datetime] = None
214 createdBy: Optional[ObjectIdField] = None
215 deleted: Optional[bool] = False
216 deletedDate: Optional[datetime] = None
217 deletedBy: Optional[ObjectIdField] = None
218 updatedDate: Optional[datetime] = None
219 updatedBy: Optional[ObjectIdField] = None
221 @model_validator(mode="before")
222 def validate_fields(cls, values):
223 # Validate all fields
224 validate_staff_staar_fields(cls, values)
225 validate_sat_tsi_act_fields(cls, values)
226 if isinstance(values.get("question"), str):
227 validate_question_content(values["question"])
228 if isinstance(values.get("questionDetails"), str) and values["questionDetails"].strip():
229 enforce_text_length(values["questionDetails"], "question details")
230 if isinstance(values.get("solutions"), str) and values["solutions"].strip():
231 enforce_text_length(values["solutions"], "solutions")
232 validate_question_field(values)
233 validate_choices_field(values)
234 validate_correct_answer_field(values)
235 validate_solutions_field(values)
236 validate_question_details_field(values)
237 validate_assignment_type_field(values)
238 validate_question_type_field(values)
239 validate_difficulty_field(values)
240 validate_teks_code_field(values)
241 validate_grade_level_field(values)
242 validate_points_field(values)
243 validate_category_field(values)
244 validate_release_date_field(values)
245 validate_deleted_field(values)
246 validate_deleted_date_field(values)
247 validate_created_date_field(values)
248 validate_updated_date_field(values)
249 return values
251 class Config:
252 arbitrary_types_allowed = True
255class QuestionModelUpdate(BaseModel):
256 questionType: QuestionType
257 question: str = Field(...) # temporarily removed max_length
258 questionDetails: Optional[str]
259 questionSubject: Optional[str]
260 questionTopic: Optional[str] = Field(default=None, max_length=300) # EI-2845
261 questionId: Optional[str] = Field(default=None, max_length=128)
262 questionImages: Optional[List[str]]
263 questionGraphs: Optional[List[str]]
264 choices: Optional[List[Union[Item, Select]]] = None
265 groups: Optional[List[GroupModel]] = Field(default=None) # Single-Stimulus / Multi-Part-Question only
266 rows: Optional[List[Item]] = Field(default=None) # Grid-Question only — one entry per statement row
267 rowHeaderLabel: Optional[str] = Field(default=None) # Grid-Question only — first column header text, defaults to "Statement"
268 correctAnswer: CorrectAnswer
269 solutions: str
270 questionDetails: Optional[str] = Field(None, max_length=RICH_EDITOR_HTML_MAX)
271 assignmentType: AssignmentType
272 difficulty: DifficultyType
273 drawingMode: Optional[DrawingMode] = None
274 teksCode: Optional[str] = Field(None)
275 gradeLevel: Optional[GradeLevel]
276 studentExpectation: Optional[str] = None
277 stateCountry: Optional[str] = None
278 keywords: List[str]
279 # Required for STAAR
280 releaseDate: Optional[datetime] = None
281 points: int = Field(
282 ...,
283 )
284 category: Optional[Category] = None
286 @model_validator(mode="before")
287 def validate_fields(cls, values):
288 validate_question_field(values)
289 validate_choices_field(values)
290 validate_correct_answer_field(values)
291 validate_solutions_field(values)
292 validate_question_details_field(values)
293 validate_assignment_type_field(values)
294 validate_question_type_field(values)
295 validate_difficulty_field(values)
296 validate_teks_code_field(values)
297 validate_points_field(values)
298 validate_category_field(values)
299 validate_release_date_field(values)
300 validate_staff_staar_fields(cls, values)
301 validate_sat_tsi_act_fields(cls, values)
302 if isinstance(values.get("solutions"), str) and values["solutions"].strip():
303 enforce_text_length(values["solutions"], "solutions")
304 validate_deleted_field(values)
305 validate_deleted_date_field(values)
306 validate_created_date_field(values)
307 validate_updated_date_field(values)
308 return values
310 class Config:
311 arbitrary_types_allowed = True