Coverage for server / validators / question_request_root_validators.py: 87%

381 statements  

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

1from typing import IO 

2 

3import filetype 

4from fastapi import HTTPException, status 

5from datetime import datetime, timezone 

6 

7from server.validators.question_bank.input_validations import Category 

8from server.validators.question_richtext import enforce_text_length 

9from server.validators.question_class_enum import ( 

10 ClassificationEnum, 

11 DifficultyEnum, 

12 ResponseEnum, 

13 StatusEnum, 

14 TypeEnum, 

15) 

16 

17 

18def validate_staar_fields(cls, values): 

19 values = is_not_empty(cls, values) 

20 values = validate_missing_staar_keys(cls, values) 

21 values["question_type"] = is_type_match(values["question_type"], "STAAR") 

22 values["grade_level"] = validate_grade_level(values["grade_level"]) 

23 values["category"] = validate_category(values["category"]) # affected 

24 values["keywords"] = validate_keywords(values["keywords"]) 

25 values["student_expectations"] = validate_student_expectations( 

26 values["student_expectations"] 

27 ) # affected 

28 values["response_type"] = set_response_type(values["response_type"]) 

29 values["question_content"] = validate_question_content(values["question_content"]) 

30 values["question_img"] = set_image(values["question_img"]) 

31 if "release_date" in values and values["release_date"]: 

32 values["release_date"] = validate_release_date(values["release_date"]) 

33 return values 

34 

35 

36def validate_staff_staar_fields(cls, values): 

37 if "assignmentType" not in values or values["assignmentType"] != "STAAR": 

38 return values 

39 

40 values = is_not_empty(cls, values) 

41 values = validate_staff_missing_staar_keys(cls, values) 

42 values["assignmentType"] = is_type_match(values["assignmentType"], "STAAR") 

43 values["gradeLevel"] = validate_grade_level(values["gradeLevel"]) 

44 values["category"] = validate_category(values["category"]) # affected 

45 values["keywords"] = validate_keywords(values["keywords"]) 

46 

47 values["studentExpectation"] = validate_student_expectations( 

48 values["studentExpectation"] 

49 ) # affected 

50 # values["responseType"] = set_response_type(values["responseType"]) 

51 values["question"] = validate_question_content(values["question"]) 

52 if "questionImages" in values and values["questionImages"]: 

53 values["questionImages"] = validate_image_urls(values["questionImages"]) 

54 if "releaseDate" in values and values["releaseDate"]: 

55 values["releaseDate"] = validate_release_date(values["releaseDate"]) 

56 return values 

57 

58 

59def validate_sat_tsi_act_fields(cls, values): 

60 """ 

61 Validate that SAT, TSI, and ACT tests do not contain releaseDate or category fields. 

62 These fields are not allowed for these test types. 

63 """ 

64 assignment_type = values.get("assignmentType") 

65 if assignment_type not in ["SAT", "TSI", "ACT"]: 

66 return values 

67 

68 # Check for prohibited fields 

69 prohibited_fields = ["releaseDate", "category"] 

70 for field in prohibited_fields: 

71 if field in values and values[field] is not None: 

72 raise HTTPException( 

73 status_code=status.HTTP_400_BAD_REQUEST, 

74 detail=f"The field '{field}' is not allowed for {assignment_type} tests. Please remove this field from your request." 

75 ) 

76 

77 return values 

78 

79 

80def validate_college_fields(cls, values): 

81 values = is_not_empty(cls, values) 

82 values = validate_missing_college_keys(cls, values) 

83 values["classification"] = set_classification_type(values["classification"]) 

84 values["test_code"] = validate_test_code(values["test_code"]) 

85 values["question_type"] = is_type_match(values["question_type"], "College Level") 

86 values["keywords"] = validate_keywords(values["keywords"]) 

87 values["response_type"] = set_response_type(values["response_type"]) 

88 values["question_content"] = validate_question_content(values["question_content"]) 

89 values["question_img"] = set_image(values["question_img"]) 

90 values["difficulty"] = validate_difficulty(values["difficulty"]) 

91 values["points"] = validate_points(values["points"], values["difficulty"]) 

92 return values 

93 

94 

95def validate_mathworld_fields(cls, values): 

96 values = is_not_empty(cls, values) 

97 values = validate_missing_mathworld_keys(cls, values) 

98 values["question_type"] = is_type_match(values["question_type"], "MathWorld") 

99 values["grade_level"] = validate_grade_level(values["grade_level"]) 

100 values["category"] = validate_category(values["category"]) 

101 values["topic"] = validate_topic(values["topic"]) 

102 values["teks_code"] = validate_teks_code(values["teks_code"]) 

103 values["subject"] = validate_subject(values["subject"]) 

104 values["difficulty"] = validate_difficulty(values["difficulty"]) 

105 values["keywords"] = validate_keywords(values["keywords"]) 

106 values["student_expectations"] = validate_student_expectations( 

107 values["student_expectations"] 

108 ) 

109 values["points"] = validate_points(values["points"], values["difficulty"]) 

110 values["response_type"] = set_response_type(values["response_type"]) 

111 values["question_content"] = validate_question_content(values["question_content"]) 

112 values["question_img"] = set_image(values["question_img"]) 

113 return values 

114 

115 

116def validate_option_fields(cls, values): 

117 values = is_not_empty(cls, values) 

118 values = validate_missing_option_keys(cls, values) 

119 values["content"] = validate_option_content(values["content"]) 

120 values["letter "] = validate_option_letter(values["letter"]) 

121 values["is_answer"] = validate_is_answer(values["is_answer"]) 

122 values["image"] = set_option_image(values["image"]) 

123 if "unit" in values and values["unit"]: 

124 values["unit"] = validate_unit(values["unit"]) 

125 return values 

126 

127 

128def validate_updated_question_fields(cls, values): 

129 values = validate_missing_update_keys(cls, values) 

130 values["update_note"] = validate_update_note(values["update_note"]) 

131 return values 

132 

133 

134def validate_updated_status_fields(cls, values): 

135 # values['update_note'] = validate_update_note(values['update_note']) 

136 values["status"] = validate_question_status(values["status"]) 

137 values = validate_missing_update_keys(cls, values) 

138 return values 

139 

140 

141# individual validators 

142 

143 

144def is_not_empty(cls, values): 

145 """ 

146 Validate that required fields are not empty. 

147 Skips validation for optional fields. 

148 """ 

149 optional_fields = { 

150 "question_img", "image", "unit", "created_by", 

151 "updated_by", "release_date", "questionImages", 

152 "questionGraphs", "questionDetails", "drawingMode", 

153 "deletedDate", "deletedBy", "updatedDate", "updatedBy", 

154 "createdDate", "createdBy", "deleted", 

155 # Optional staff catalog id: a client that sends `questionId: null` 

156 # explicitly must not be told "the field is required" -- it is not. 

157 "questionId", 

158 } 

159 

160 # Add choices to optional fields if question type is Free-response or Graph 

161 question_type = values.get("questionType") 

162 if question_type in ["Free-response", "Graph", "Graph-Multiple-Select"]: 

163 optional_fields.add("choices") 

164 

165 for attr, value in values.items(): 

166 if attr in optional_fields: 

167 continue 

168 

169 if value is None: 

170 raise HTTPException( 

171 status.HTTP_400_BAD_REQUEST, 

172 detail=f"The field '{attr}' is required but was not provided" 

173 ) 

174 

175 if isinstance(value, str) and value.strip() == "": 

176 raise HTTPException( 

177 status.HTTP_400_BAD_REQUEST, 

178 detail=f"The field '{attr}' cannot be empty" 

179 ) 

180 

181 if isinstance(value, list) and len(value) == 0 and attr not in optional_fields: 

182 raise HTTPException( 

183 status.HTTP_400_BAD_REQUEST, 

184 detail=f"The field '{attr}' must contain at least one item" 

185 ) 

186 

187 return values 

188 

189 

190def validate_missing_staar_keys(cls, values): 

191 keys = values.keys() 

192 required_keys = [ 

193 "question_type", 

194 "grade_level", 

195 "category", 

196 "keywords", 

197 "student_expectations", 

198 "response_type", 

199 "question_content", 

200 ] 

201 for key in required_keys: 

202 if key not in keys: 

203 raise HTTPException( 

204 status.HTTP_400_BAD_REQUEST, detail=key + " is required" 

205 ) 

206 return values 

207 

208 

209def validate_staff_missing_staar_keys(cls, values): 

210 """ 

211 Validate that all required STAAR fields are present. 

212 """ 

213 required_keys = [ 

214 "questionType", 

215 "gradeLevel", 

216 "category", 

217 "keywords", 

218 "studentExpectation", 

219 "assignmentType", 

220 "question", 

221 "teksCode", 

222 "questionSubject", 

223 "correctAnswer", 

224 "difficulty", 

225 "points", 

226 "solutions" 

227 ] 

228 

229 missing_keys = [] 

230 for key in required_keys: 

231 if key not in values: 

232 missing_keys.append(key) 

233 

234 if missing_keys: 

235 if len(missing_keys) == 1: 

236 raise HTTPException( 

237 status.HTTP_400_BAD_REQUEST, 

238 detail=f"{missing_keys[0]} is required" 

239 ) 

240 else: 

241 raise HTTPException( 

242 status.HTTP_400_BAD_REQUEST, 

243 detail=f"The following fields are required: {', '.join(missing_keys)}" 

244 ) 

245 

246 return values 

247 

248 

249def validate_missing_college_keys(cls, values): 

250 keys = values.keys() 

251 required_keys = [ 

252 "question_type", 

253 "classification", 

254 "test_code", 

255 "keywords", 

256 "response_type", 

257 "question_content", 

258 "difficulty", 

259 "points", 

260 ] 

261 for key in required_keys: 

262 if key not in keys: 

263 raise HTTPException( 

264 status.HTTP_400_BAD_REQUEST, detail=key + " is required" 

265 ) 

266 return values 

267 

268 

269def validate_missing_mathworld_keys(cls, values): 

270 keys = values.keys() 

271 required_keys = [ 

272 "question_type", 

273 "grade_level", 

274 "teks_code", 

275 "subject", 

276 "topic", 

277 "category", 

278 "keywords", 

279 "student_expectations", 

280 "difficulty", 

281 "points", 

282 "response_type", 

283 "question_content", 

284 ] 

285 for key in required_keys: 

286 if key not in keys: 

287 raise HTTPException( 

288 status.HTTP_400_BAD_REQUEST, detail=key + " is required" 

289 ) 

290 return values 

291 

292 

293def validate_missing_option_keys(cls, values): 

294 keys = values.keys() 

295 required_keys = ["letter", "content", "is_answer"] 

296 for key in required_keys: 

297 if key not in keys: 

298 raise HTTPException( 

299 status.HTTP_400_BAD_REQUEST, 

300 detail=key + " is required in option object", 

301 ) 

302 return values 

303 

304 

305def validate_missing_update_keys(cls, values): 

306 keys = values.keys() 

307 required_keys = [] 

308 

309 if "status" not in keys or ( 

310 "status" in keys and values["status"] in ["Rejected", "Reported"] 

311 ): 

312 required_keys = ["update_note"] 

313 

314 for key in required_keys: 

315 if key not in keys: 

316 raise HTTPException( 

317 status.HTTP_400_BAD_REQUEST, detail=key + " is required" 

318 ) 

319 return values 

320 

321 

322def validate_category(v): 

323 if str(type(v)) == "<class 'str'>": 

324 v = v.strip() 

325 if v == "": 

326 raise HTTPException( 

327 status.HTTP_400_BAD_REQUEST, detail="category should not be empty" 

328 ) 

329 if v not in Category.__members__.values(): 

330 raise HTTPException( 

331 status_code=status.HTTP_400_BAD_REQUEST, 

332 detail=f"Valid category is a string from {', '.join(Category.__members__.values())}" 

333 ) 

334 

335 return v 

336 else: 

337 raise HTTPException( 

338 status.HTTP_400_BAD_REQUEST, detail="category must be a string" 

339 ) 

340 

341 

342def validate_topic(v): 

343 if str(type(v)) == "<class 'str'>": 

344 v = v.strip() 

345 if v == "": 

346 raise HTTPException( 

347 status.HTTP_400_BAD_REQUEST, detail="topic should not be empty" 

348 ) 

349 

350 return v 

351 else: 

352 raise HTTPException( 

353 status.HTTP_400_BAD_REQUEST, detail="topic must be a string" 

354 ) 

355 

356 

357def validate_grade_level(v): 

358 if str(type(v)) == "<class 'int'>": 

359 if v > 12 or v < 3: 

360 raise HTTPException( 

361 status.HTTP_400_BAD_REQUEST, 

362 detail="invalid grade level: should only be between 3 to 12", 

363 ) 

364 return v 

365 else: 

366 raise HTTPException( 

367 status.HTTP_400_BAD_REQUEST, detail="grade level must be an integer" 

368 ) 

369 

370 

371def validate_points(v, difficulty): 

372 if str(type(v)) == "<class 'int'>": 

373 if v > 100 or v < 1: 

374 raise HTTPException( 

375 status.HTTP_400_BAD_REQUEST, 

376 detail="invalid points value: should only be between 1 to 3", 

377 ) 

378 match difficulty: 

379 case "Easy": 

380 if v != 1: 

381 raise HTTPException( 

382 status.HTTP_400_BAD_REQUEST, 

383 detail="Difficulty level is incompatible with points assigned.", 

384 ) 

385 case "Average": 

386 if v != 2: 

387 raise HTTPException( 

388 status.HTTP_400_BAD_REQUEST, 

389 detail="Difficulty level is incompatible with points assigned.", 

390 ) 

391 case "Hard": 

392 if v != 3: 

393 raise HTTPException( 

394 status.HTTP_400_BAD_REQUEST, 

395 detail="Difficulty level is incompatible with points assigned.", 

396 ) 

397 return v 

398 else: 

399 raise HTTPException( 

400 status.HTTP_400_BAD_REQUEST, detail="points must be an integer" 

401 ) 

402 

403 

404def validate_subject(v): 

405 if str(type(v)) == "<class 'str'>": 

406 v = v.strip() 

407 if v == "": 

408 raise HTTPException( 

409 status.HTTP_400_BAD_REQUEST, detail="subject should not be empty" 

410 ) 

411 

412 if v not in ["Algebra I", "Algebra II", "Geometry", "Pre-Calculus"]: 

413 raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Invalid Subject") 

414 return v 

415 else: 

416 raise HTTPException( 

417 status.HTTP_400_BAD_REQUEST, detail="subject must be a string" 

418 ) 

419 

420 

421def validate_keywords(v): 

422 if str(type(v)) == "<class 'list'>": 

423 if len(v) > 10: 

424 raise HTTPException( 

425 status.HTTP_400_BAD_REQUEST, detail="Max number of keywords reached" 

426 ) 

427 if len(v) > 0: 

428 for value in v: 

429 if str(type(value)) == "<class 'str'>": 

430 if value.strip() == "": 

431 raise HTTPException( 

432 status.HTTP_400_BAD_REQUEST, 

433 detail="a value in keywords should not be an empty string", 

434 ) 

435 else: 

436 raise HTTPException( 

437 status.HTTP_400_BAD_REQUEST, 

438 detail="all values in keywords must be string", 

439 ) 

440 if len(value) > 50: 

441 raise HTTPException( 

442 status.HTTP_400_BAD_REQUEST, 

443 detail="Max length of keyword reached", 

444 ) 

445 return v 

446 else: 

447 raise HTTPException( 

448 status.HTTP_400_BAD_REQUEST, detail="keywords must not be empty" 

449 ) 

450 else: 

451 raise HTTPException( 

452 status.HTTP_400_BAD_REQUEST, detail="keywords must be a list" 

453 ) 

454 

455 

456def validate_student_expectations(v): 

457 if str(type(v)) == "<class 'str'>": 

458 if v.strip() == "": 

459 raise HTTPException( 

460 status.HTTP_400_BAD_REQUEST, 

461 detail="student_expectations should not be an empty string", 

462 ) 

463 else: 

464 raise HTTPException( 

465 status.HTTP_400_BAD_REQUEST, 

466 detail="student_expectations must be a string", 

467 ) 

468 

469 return v 

470 

471 

472def validate_question_content(v): 

473 if str(type(v)) == "<class 'str'>": 

474 if v.strip() == "": 

475 raise HTTPException( 

476 status.HTTP_400_BAD_REQUEST, 

477 detail="question content should not be empty", 

478 ) 

479 return enforce_text_length(v, "question content") 

480 else: 

481 raise HTTPException( 

482 status.HTTP_400_BAD_REQUEST, detail="question content must be a string" 

483 ) 

484 

485 

486def validate_release_date(v): 

487 if not isinstance(v, str): 

488 raise HTTPException( 

489 status.HTTP_400_BAD_REQUEST, detail="release date must be a string" 

490 ) 

491 

492 if v.strip() == "": 

493 raise HTTPException( 

494 status.HTTP_400_BAD_REQUEST, detail="release date should not be empty" 

495 ) 

496 

497 try: 

498 # Convert the string to datetime 

499 release_date = datetime.fromisoformat(v.replace("Z", "+00:00")) 

500 except ValueError: 

501 raise HTTPException( 

502 status.HTTP_400_BAD_REQUEST, 

503 detail="release date invalid format: expected ISO 8601 (e.g., 2025-01-01T00:00:00Z)", 

504 ) 

505 

506 # Check if the date is in the future 

507 now_utc = datetime.now(timezone.utc) 

508 if release_date > now_utc: 

509 raise HTTPException( 

510 status.HTTP_400_BAD_REQUEST, 

511 detail="release date invalid - date should not be in the future", 

512 ) 

513 

514 return release_date # ✅ Return as datetime, not string 

515 

516 

517def set_response_type(v): 

518 if str(type(v)) == "<class 'str'>": 

519 v = v.strip().title() 

520 if v in [response.value for response in ResponseEnum]: 

521 return v 

522 else: 

523 if v == "": 

524 raise HTTPException( 

525 status.HTTP_400_BAD_REQUEST, 

526 detail="response_type should not be an empty string", 

527 ) 

528 else: 

529 raise HTTPException( 

530 status.HTTP_400_BAD_REQUEST, detail="invalid response type" 

531 ) 

532 else: 

533 raise HTTPException( 

534 status.HTTP_400_BAD_REQUEST, detail="response_type must be a string" 

535 ) 

536 

537 

538def is_type_match(v, question_type): 

539 if str(type(v)) == "<class 'str'>": 

540 if question_type == "STAAR": 

541 v = v.strip().upper() 

542 if v == question_type and v in [q_type.value for q_type in TypeEnum]: 

543 return v 

544 else: 

545 raise HTTPException( 

546 status.HTTP_400_BAD_REQUEST, 

547 detail="question type must match to the endpoint use: " 

548 + question_type, 

549 ) 

550 else: 

551 v = v.strip().title() 

552 if v == question_type.title() and v in [ 

553 q_type.value for q_type in TypeEnum 

554 ]: 

555 return v 

556 else: 

557 raise HTTPException( 

558 status.HTTP_400_BAD_REQUEST, 

559 detail="question type must match to the endpoint use: " 

560 + question_type, 

561 ) 

562 

563 else: 

564 raise HTTPException( 

565 status.HTTP_400_BAD_REQUEST, detail="question_type must be a string" 

566 ) 

567 

568 

569def set_image(v): 

570 if str(type(v)) == "<class 'str'>": 

571 if len(v) > 0: 

572 if len(v.strip()) == "": 

573 raise HTTPException( 

574 status.HTTP_400_BAD_REQUEST, 

575 detail="question image value not allowed", 

576 ) 

577 

578 raise HTTPException( 

579 status.HTTP_400_BAD_REQUEST, 

580 detail="invalid image insertion: image must be added through the Form, not in payload.", 

581 ) 

582 return v 

583 else: 

584 raise HTTPException( 

585 status.HTTP_400_BAD_REQUEST, detail="image must be a string" 

586 ) 

587 

588 

589def validate_image_urls(v): 

590 if str(type(v)) == "<class 'list'>": 

591 for value in v: 

592 if str(type(value)) == "<class 'str'>": 

593 if value.strip() == "": 

594 raise HTTPException( 

595 status.HTTP_400_BAD_REQUEST, 

596 detail="a value in questionImages should not be an empty string", 

597 ) 

598 else: 

599 raise HTTPException( 

600 status.HTTP_400_BAD_REQUEST, 

601 detail="all values in questionImages must be string", 

602 ) 

603 if len(v) > 50: 

604 raise HTTPException( 

605 status.HTTP_400_BAD_REQUEST, 

606 detail="Max length of questionImages reached", 

607 ) 

608 return v 

609 else: 

610 raise HTTPException( 

611 status.HTTP_400_BAD_REQUEST, detail="questionImages must be a list" 

612 ) 

613 

614 

615def validate_test_code(v): 

616 if str(type(v)) == "<class 'str'>": 

617 if len(v) > 6: 

618 raise HTTPException( 

619 status.HTTP_400_BAD_REQUEST, 

620 detail="test code must not exceed 6 characters", 

621 ) 

622 return v 

623 else: 

624 raise HTTPException( 

625 status.HTTP_400_BAD_REQUEST, detail="test code must be a string" 

626 ) 

627 

628 

629def set_classification_type(v): 

630 if str(type(v)) == "<class 'str'>": 

631 v = v.strip().upper() 

632 if v in [classification.value for classification in ClassificationEnum]: 

633 return v 

634 else: 

635 raise HTTPException( 

636 status.HTTP_400_BAD_REQUEST, detail="invalid classification type" 

637 ) 

638 else: 

639 raise HTTPException( 

640 status.HTTP_400_BAD_REQUEST, detail="classification value must be a string" 

641 ) 

642 

643 

644def validate_teks_code(v): 

645 if str(type(v)) == "<class 'str'>": 

646 v = v.strip() 

647 if len(v) > 6: 

648 raise HTTPException( 

649 status.HTTP_400_BAD_REQUEST, 

650 detail="teks code must not exceed 6 characters", 

651 ) 

652 if v not in [ 

653 "A.1", 

654 "A.2", 

655 "A.3", 

656 "A.4", 

657 "A.5", 

658 "A.6", 

659 "A.7", 

660 "A.8", 

661 "A.9", 

662 "A.10", 

663 "A.11", 

664 "A.12", 

665 ]: 

666 raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Invalid Teks Code") 

667 return v 

668 else: 

669 raise HTTPException( 

670 status.HTTP_400_BAD_REQUEST, detail="teks code must be a string" 

671 ) 

672 

673 

674def validate_difficulty(v): 

675 if str(type(v)) == "<class 'str'>": 

676 v = v.strip().title() 

677 if v in [difficulty.value for difficulty in DifficultyEnum]: 

678 return v 

679 else: 

680 raise HTTPException( 

681 status.HTTP_400_BAD_REQUEST, detail="invalid difficulty level" 

682 ) 

683 else: 

684 raise HTTPException( 

685 status.HTTP_400_BAD_REQUEST, detail="difficulty value must be a string" 

686 ) 

687 

688 

689def validate_option_content(v): 

690 if str(type(v)) == "<class 'str'>": 

691 if v.strip() == "": 

692 raise HTTPException( 

693 status.HTTP_400_BAD_REQUEST, detail="option content should not be empty" 

694 ) 

695 return enforce_text_length(v, "option content") 

696 else: 

697 raise HTTPException( 

698 status.HTTP_400_BAD_REQUEST, detail="option content must be a string" 

699 ) 

700 

701 

702def validate_unit(v): 

703 if v: 

704 if str(type(v)) == "<class 'str'>": 

705 if len(v) != 0: 

706 if v.strip() == "": 

707 raise HTTPException( 

708 status.HTTP_400_BAD_REQUEST, 

709 detail="unit should not be all whitespaces", 

710 ) 

711 if len(v.strip()) > 20: 

712 raise HTTPException( 

713 status.HTTP_400_BAD_REQUEST, 

714 detail="unit content should not exceed 20 characters", 

715 ) 

716 return v.strip() 

717 else: 

718 raise HTTPException( 

719 status.HTTP_400_BAD_REQUEST, detail="unit must be a string" 

720 ) 

721 

722 return v.strip() 

723 

724 

725def validate_option_letter(v): 

726 if str(type(v)) == "<class 'str'>": 

727 if v.strip() == "": 

728 raise HTTPException( 

729 status.HTTP_400_BAD_REQUEST, detail="option letter should not be empty" 

730 ) 

731 if len(v.strip()) > 1: 

732 raise HTTPException( 

733 status.HTTP_400_BAD_REQUEST, 

734 detail="option letter should only be 1 character long", 

735 ) 

736 return v.strip() 

737 else: 

738 raise HTTPException( 

739 status.HTTP_400_BAD_REQUEST, detail="option letter must be a string" 

740 ) 

741 

742 

743def validate_is_answer(v): 

744 if str(type(v)) == "<class 'bool'>": 

745 return v 

746 else: 

747 raise HTTPException( 

748 status.HTTP_400_BAD_REQUEST, detail="is_answer should be type boolean" 

749 ) 

750 

751 

752def set_option_image(v): 

753 if str(type(v)) == "<class 'str'>": 

754 if len(v) > 0: 

755 if len(v.strip()) == "": 

756 raise HTTPException( 

757 status.HTTP_400_BAD_REQUEST, detail="option image value not allowed" 

758 ) 

759 

760 raise HTTPException( 

761 status.HTTP_400_BAD_REQUEST, 

762 detail="invalid option image insertion: image must be added through the Form, not in payload.", 

763 ) 

764 return v 

765 else: 

766 raise HTTPException( 

767 status.HTTP_400_BAD_REQUEST, detail="image must be a string" 

768 ) 

769 

770 

771def validate_update_note(v): 

772 if v: 

773 if v.strip() == "": 

774 raise HTTPException( 

775 status.HTTP_400_BAD_REQUEST, detail="update_note is required" 

776 ) 

777 

778 return v 

779 

780 

781def validate_question_status(v): 

782 v = v.strip().title() 

783 if v in [status.value for status in StatusEnum]: 

784 return v.title() 

785 else: 

786 raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="invalid status") 

787 

788 

789def validate_file_size_type(file: IO): 

790 """ 

791 Validate file size and type for uploaded images. 

792  

793 Args: 

794 file (IO): File-like object to validate 

795  

796 Raises: 

797 HTTPException: 

798 - 413: If file exceeds size limit 

799 - 415: If file type is unsupported or cannot be determined 

800 """ 

801 FILE_SIZE = 10485760 # 10MB 

802 file_position = file.tell() # Save current position 

803 

804 try: 

805 # Validate file type 

806 accepted_file_types = ["image/png", "image/jpeg", "png", "jpeg", "jpg"] 

807 file_info = filetype.guess(file) 

808 if file_info is None: 

809 raise HTTPException( 

810 status_code=status.HTTP_415_UNSUPPORTED_MEDIA_TYPE, 

811 detail="Unable to determine file type. Please upload a valid JPG or PNG image.", 

812 ) 

813 

814 detected_content_type = file_info.extension.lower() 

815 

816 if hasattr(file, 'content_type'): 

817 content_type = file.content_type 

818 else: 

819 # Default to a safe value if content_type isn't available 

820 content_type = detected_content_type 

821 

822 if ( 

823 content_type not in accepted_file_types 

824 or detected_content_type not in accepted_file_types 

825 ): 

826 raise HTTPException( 

827 status_code=status.HTTP_415_UNSUPPORTED_MEDIA_TYPE, 

828 detail=f"Unsupported file type. Detected: {detected_content_type}. Please upload a JPG or PNG image.", 

829 ) 

830 

831 # Validate file size 

832 file.seek(0) # Reset to beginning for size check 

833 real_file_size = 0 

834 for chunk in file: 

835 real_file_size += len(chunk) 

836 if real_file_size > FILE_SIZE: 

837 raise HTTPException( 

838 status_code=status.HTTP_413_CONTENT_TOO_LARGE, 

839 detail=f"File size exceeds the maximum limit of {FILE_SIZE/1048576:.1f}MB." 

840 ) 

841 finally: 

842 # Always restore the file position 

843 file.seek(file_position)