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

1from pydantic import BaseModel, Field, model_validator 

2from typing import List, Optional, Union 

3from datetime import datetime 

4 

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 

41 

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 

48 

49 

50class ObjectIdField(str): 

51 """Custom ObjectId type for Pydantic.""" 

52 

53 @classmethod 

54 def __get_validators__(cls): 

55 yield cls.validate 

56 

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") 

64 

65 @classmethod 

66 def __get_pydantic_json_schema__(cls, *args, **kwargs): 

67 return {"type": "string"} 

68 

69 

70class Item(BaseModel): 

71 id: int 

72 text: str = Field(..., max_length=RICH_EDITOR_HTML_MAX) 

73 graph: str = Field("") 

74 

75 

76class Select(BaseModel): 

77 id: int 

78 items: List[Item] 

79 

80 

81class Answer(BaseModel): 

82 id: Optional[int] = None 

83 answer: str = Field(..., max_length=RICH_EDITOR_HTML_MAX) 

84 

85 

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) 

99 

100 @model_validator(mode="before") 

101 def validate_answer_format(cls, values): 

102 # Handle None or missing values 

103 if not values: 

104 values = {} 

105 

106 # Set defaults if not present 

107 values.setdefault("answers", "") 

108 values.setdefault("answerDetails", None) 

109 values.setdefault("graph", "") 

110 

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 

115 

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 

123 

124 

125class GroupModel(BaseModel): 

126 """One independently-scored part of a Single-Stimulus or 

127 Multi-Part-Question question. 

128 

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 """ 

136 

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 

160 

161 

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 

220 

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 

250 

251 class Config: 

252 arbitrary_types_allowed = True 

253 

254 

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 

285 

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 

309 

310 class Config: 

311 arbitrary_types_allowed = True