Coverage for server / routes / teacher / teacher_question.py: 100%
78 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 typing import Annotated, Optional, List
2from enum import Enum
3from fastapi import (
4 APIRouter,
5 Body,
6 Depends,
7 File,
8 HTTPException,
9 Query,
10 Request,
11 UploadFile,
12 status,
13)
14from fastapi.openapi.models import Example
15from server.authentication.auth0_bearer import Auth0Bearer
16from server.services.growthbook import require_feature
18# from server.models.question import Question, UpdatedQuestion, UpdateQuestionStatus
19from server.utilities import sample_payloads
20from server.utilities.sample_payloads import teacher_questionbank_payload
21from server.models.question_bank import QuestionModelCreate, QuestionModelUpdate
22from server.services.teacher.teacher_question import TeacherQuestionService
23from server.utilities.user_id_helper import to_user_id
26router = APIRouter()
28teacher_question_service = TeacherQuestionService()
31# Define enums for the filter options
32class AssignmentType(str, Enum):
33 STAAR = "STAAR"
34 TSI = "TSI"
35 SAT = "SAT"
36 ACT = "ACT"
39class QuestionType(str, Enum):
40 MULTIPLE_CHOICE = "Multiple-choice"
41 CHECKBOX = "Checkbox"
42 FREE_RESPONSE = "Free-response"
43 GRAPH = "Graph"
44 DROP_DOWN_MENU = "Drop-down-Menu"
45 DRAG_AND_DROP = "Drag-and-Drop"
46 EMBEDDED_MULTIPLE_CHOICE = "Embedded-Multiple-Choice"
47 SINGLE_STIMULUS = "Single-Stimulus"
48 MULTI_PART_QUESTION = "Multi-Part-Question"
49 GRID_QUESTION = "Grid-Question"
50 GRAPH_MULTIPLE_SELECT = "Graph-Multiple-Select"
53@router.get(
54 "/all/fetch",
55 dependencies=[Depends(Auth0Bearer(access_levels=["teacher"]))],
56 status_code=status.HTTP_200_OK,
57 response_description="As a teacher, I can fetch all questions accessible to me",
58 description="As a teacher, I can fetch all questions accessible to me",
59 summary="As a teacher, I can fetch all questions accessible to me",
60)
61async def teacher_questions_fetch(
62 request: Request,
63 assignment_types: Optional[List[AssignmentType]] = Query(
64 None, description="Filter by assignment types"
65 ),
66 question_types: Optional[List[QuestionType]] = Query(
67 None, description="Filter by question types"
68 ),
69 categories: Optional[List[str]] = Query(None, description="Filter by categories"),
70 difficulties: Optional[List[str]] = Query(
71 None, description="Filter by difficulty levels"
72 ),
73 grade_levels: Optional[List[int]] = Query(
74 None, description="Filter by grade levels"
75 ),
76 subjects: Optional[List[str]] = Query(None, description="Filter by subjects"),
77) -> dict:
78 """
79 Retrieve all questions accessible to teachers.
81 This endpoint returns a paginated list of questions that the authenticated teacher
82 can access, including questions they've created and shared questions from other teachers.
83 Results can be filtered and sorted using query parameters.
85 Args:
86 request (Request): The incoming request object containing:
87 - JWT token in Authorization header
88 - Teacher authentication context
89 - Query parameters for filtering:
90 - assignment_types (List[AssignmentType], optional): Filter by assignment types
91 - question_types (List[QuestionType], optional): Filter by question types
92 - subject (str): Filter by subject area
93 - difficulty (str): Filter by difficulty level
94 - search (str): Search question text/title
95 - page (int): Page number for pagination
96 - limit (int): Items per page
98 Returns:
99 dict: Paginated question listing containing:
100 - items (list): List of question objects with:
101 - id (str): Unique question identifier
102 - title (str): Question title/summary
103 - content (str): Full question text
104 - type (str): Question type (multiple choice, essay, etc)
105 - difficulty (str): Difficulty rating
106 - subject (str): Subject area
107 - author (dict): Teacher who created the question
108 - created_at (datetime): Creation timestamp
109 - total (int): Total number of matching questions
110 - page (int): Current page number
111 - pages (int): Total number of pages
112 - has_more (bool): Whether more pages exist
114 Raises:
115 HTTPException:
116 - 401: Invalid or missing authentication token
117 - 422: Invalid query parameters
118 - 500: Error retrieving questions
120 The 403 this previously documented did not exist: no code path produced it,
121 and the missing check it implied was EI-SEC-010. There is nothing to forbid
122 now — the bank returned is always the caller's own.
123 """
124 # EI-SEC-010. This route used to accept `teacher_id` as a query parameter and
125 # fall back to the caller's own id only when the client sent nothing, with no
126 # ownership check anywhere — so `?teacher_id=<any other teacher>` returned
127 # that teacher's entire question bank to any authenticated teacher.
128 #
129 # The parameter is removed rather than guarded: nothing legitimate sent it.
130 # No caller exists in either SPA or in admin-staff-automation, and the
131 # cross-teacher sharing this route's docstring mentions is served by a
132 # different endpoint (/v1/teacher/question/staff/questions/all/fetch). Its
133 # only consumers were two automation edge tests probing malformed and unknown
134 # ids, updated alongside this change.
135 #
136 # Deriving the owner from the token unconditionally is what
137 # teacher_questions_fetch_all (line ~536) in this same file already does.
138 teacher_id = to_user_id(request.state.user_details["uuid"])
140 request.state.assignment_types = assignment_types
141 request.state.question_types = question_types
142 request.state.categories = categories
143 request.state.difficulties = difficulties
144 request.state.grade_levels = grade_levels
145 request.state.subjects = subjects
146 return await teacher_question_service.fetch(request, teacher_id)
149@router.get(
150 "/staff/questions/all/fetch",
151 dependencies=[
152 Depends(Auth0Bearer(access_levels=["teacher"])),
153 Depends(require_feature("teacher.erudition_tests_library")),
154 ],
155 status_code=status.HTTP_200_OK,
156 response_description="As a teacher, I can fetch all staff questions accessible to me",
157 description="As a teacher, I can fetch all staff questions accessible to me",
158 summary="As a teacher, I can fetch all staff questions accessible to me",
159)
160async def teacher_staff_questions_fetch(
161 request: Request,
162 assignment_types: Optional[List[AssignmentType]] = Query(
163 None, description="Filter by assignment types"
164 ),
165 question_types: Optional[List[QuestionType]] = Query(
166 None, description="Filter by question types"
167 ),
168 categories: Optional[List[str]] = Query(None, description="Filter by categories"),
169 difficulties: Optional[List[str]] = Query(
170 None, description="Filter by difficulty levels"
171 ),
172 grade_levels: Optional[List[int]] = Query(
173 None, description="Filter by grade levels"
174 ),
175 subjects: Optional[List[str]] = Query(None, description="Filter by subjects"),
176) -> dict:
177 """
178 Retrieve all staff questions accessible to teachers.
180 This endpoint returns a paginated list of staff questions that the authenticated teacher can access.
181 Results can be filtered and sorted using query parameters.
183 Args:
184 request (Request): The incoming request object containing:
185 - JWT token in Authorization header
186 - Teacher authentication context
187 - Query parameters for filtering:
188 - assignment_types (List[AssignmentType], optional): Filter by assignment types
189 - question_types (List[QuestionType], optional): Filter by question types
190 - subject (str): Filter by subject area
191 - difficulty (str): Filter by difficulty level
192 - search (str): Search question text/title
193 - page (int): Page number for pagination
194 - limit (int): Items per page
196 Returns:
197 dict: Paginated question listing containing:
198 - items (list): List of question objects with:
199 - id (str): Unique question identifier
200 - title (str): Question title/summary
201 - content (str): Full question text
202 - type (str): Question type (multiple choice, essay, etc)
203 - difficulty (str): Difficulty rating
204 - subject (str): Subject area
205 - author (dict): Teacher who created the question
206 - created_at (datetime): Creation timestamp
207 - total (int): Total number of matching questions
208 - page (int): Current page number
209 - pages (int): Total number of pages
210 - has_more (bool): Whether more pages exist
212 Raises:
213 HTTPException:
214 - 401: Invalid or missing authentication token
215 - 403: Insufficient permissions to access questions
216 - 422: Invalid query parameters
217 - 500: Error retrieving questions
218 """
219 request.state.assignment_types = assignment_types
220 request.state.question_types = question_types
221 request.state.categories = categories
222 request.state.difficulties = difficulties
223 request.state.grade_levels = grade_levels
224 request.state.subjects = subjects
225 return await teacher_question_service.staff_questions_fetch(request)
228@router.post(
229 "/create",
230 dependencies=[
231 Depends(Auth0Bearer(access_levels=["teacher"])),
232 Depends(require_feature("teacher.create_own_questions")),
233 ],
234 status_code=status.HTTP_201_CREATED,
235 response_description="As a teacher, I can create a new question",
236 description="As a teacher, I can create a new question",
237 summary="As a teacher, I can create a new question",
238)
239async def teacher_create_question(
240 request: Request,
241 question_data: Annotated[
242 dict,
243 Body(
244 description="Question data must match one of these templates: Multiple-choice, Checkbox, Free-response, Graph, Drop-down-Menu, Drag-and-Drop, or Embedded-Multiple-Choice",
245 openapi_examples={
246 k: Example(**v)
247 for k, v in teacher_questionbank_payload.items()
248 if k
249 in {
250 "Multiple-choice",
251 "Checkbox",
252 "Free-response",
253 "Graph",
254 "Drop-down-Menu",
255 "Drag-and-Drop",
256 "Embedded-Multiple-Choice",
257 "Single-Stimulus",
258 "Multi-Part-Question",
259 "Grid-Question",
260 "Graph-Multiple-Select",
261 }
262 },
263 ),
264 ],
265) -> dict:
266 """
267 Create a new question in the question bank.
269 This endpoint allows teachers to create a new question with specified details. The question
270 will be associated with the authenticated teacher as the author. Supports multiple question
271 types including Multiple-choice, Checkbox, Free-response, Graph, Drop-down-Menu,
272 Drag-and-Drop, and Embedded-Multiple-Choice.
274 Args:
275 request (Request): The incoming request object containing:
276 - JWT token in Authorization header
277 - Teacher authentication context
278 - Teacher role and permissions
279 question_data (dict): Question details for creation including:
280 - question (str, required): Main question text/prompt, supports HTML formatting
281 - choices (list[dict], required for Multiple-choice/Checkbox):
282 - id (int): Choice identifier
283 - text (str): Choice text, supports HTML formatting
284 - correctAnswer (dict, required):
285 - answers (Union[str, list[str]]): Correct answer(s)
286 - answerDetails (str, optional): Explanation for the correct answer
287 - graph (str, optional): Graph data for Graph type questions
288 - questionDetails (str, optional): Additional details or context for the question
289 - assignmentType (str, required): Type of assignment (e.g., "STAAR")
290 - questionType (str, required): One of:
291 - "Multiple-choice"
292 - "Checkbox"
293 - "Free-response"
294 - "Graph"
295 - "Drop-down-Menu"
296 - "Drag-and-Drop"
297 - "Embedded-Multiple-Choice"
298 - difficulty (str, required): Question difficulty (e.g., "Easy", "Advance")
299 - teksCode (str, required): TEKS standard code
300 - points (str/int, required): Point value for the question
301 - category (str, required): Question category identifier
303 For Drop-down-Menu and Drag-and-Drop types:
304 - choices (list[dict]): List of choice groups:
305 - id (int): Group identifier
306 - items (list): Available items for this group
308 For Embedded-Multiple-Choice: `question` is a passage with the answer
309 choices marked inline as `<span data-embedded-choice-id="...">...</span>`;
310 `choices` is a flat list of `{id, text}` (one per marked span) and
311 `correctAnswer.answers` is a single-element list holding the correct
312 choice's `id` (not its text — ids disambiguate a phrase that appears
313 more than once in the passage).
315 Returns:
316 dict: Created question details including:
317 - id (str): Unique question identifier
318 - question (str): Question text
319 - choices (list): Answer choices if applicable
320 - correctAnswer (dict): Correct answer information
321 - questionDetails (str): Additional question details
322 - assignmentType (str): Assignment type
323 - questionType (str): Question type
324 - difficulty (str): Difficulty level
325 - teksCode (str): TEKS code
326 - points (int): Point value
327 - category (str): Question category
328 - author (dict): Teacher who created it
329 - created_at (datetime): Creation timestamp
330 - status (str): Current status
332 Raises:
333 HTTPException:
334 - 401: Invalid or missing authentication token
335 - 403: Insufficient permissions to create questions
336 - 422: Invalid question details:
337 - Missing required fields
338 - Invalid question type
339 - Missing choices for Multiple-choice/Checkbox
340 - Invalid choice format
341 - Missing or invalid correct answer format
342 - 500: Error creating question
343 """
345 return await teacher_question_service.create(request, question_data)
348@router.get(
349 "/{question_id}/fetch",
350 dependencies=[Depends(Auth0Bearer(access_levels=["teacher"]))],
351 status_code=status.HTTP_200_OK,
352 response_description="As a teacher, I can fetch a specific question by its ID",
353 description="As a teacher, I can fetch a specific question by its ID",
354 summary="As a teacher, I can fetch a specific question by its ID",
355)
356async def teacher_fetch_question(question_id: str, request: Request) -> dict:
357 """
358 Retrieve detailed information about a specific question by its ID.
360 This endpoint returns comprehensive details about a question identified by its unique ID.
361 Only teachers with appropriate permissions can access the question data.
363 Args:
364 question_id (str): Unique identifier of the question to retrieve
365 request (Request): The incoming request object containing:
366 - JWT token in Authorization header
367 - Teacher authentication context
368 - Teacher role and permissions
370 Returns:
371 dict: Question details including:
372 - id (str): Unique question identifier
373 - title (str): Question title/summary
374 - content (str): Full question text/prompt
375 - type (str): Question type (e.g. multiple choice, essay)
376 - difficulty (str): Difficulty rating
377 - subject (str): Subject area/discipline
378 - options (list): Answer choices for multiple choice questions
379 - correct_answer (str/list): Correct answer(s)
380 - points (int): Point value
381 - tags (list): Categorization tags
382 - author (dict): Teacher who created the question
383 - created_at (datetime): Creation timestamp
384 - updated_at (datetime): Last modification timestamp
385 - status (str): Current question status
387 Raises:
388 HTTPException:
389 - 401: Invalid or missing authentication token
390 - 403: Insufficient permissions to access question
391 - 404: Question not found
392 - 500: Error retrieving question data
393 """
394 return await teacher_question_service.detail_fetch(question_id, request)
397@router.put(
398 "/{question_id}/update",
399 dependencies=[Depends(Auth0Bearer(access_levels=["teacher"]))],
400 status_code=status.HTTP_200_OK,
401 response_description="As a teacher, I can update a specific question by its ID",
402 description="As a teacher, I can update a specific question by its ID",
403 summary="As a teacher, I can update a specific question by its ID",
404)
405async def teacher_question_update(
406 question_id: str,
407 question_data: Annotated[
408 dict,
409 Body(
410 description="Question data must match one of these templates: Multiple-choice, Checkbox, Free-response, Graph, Drop-down-Menu, Drag-and-Drop, or Embedded-Multiple-Choice",
411 openapi_examples={
412 k: Example(**v)
413 for k, v in teacher_questionbank_payload.items()
414 if k
415 in {
416 "Multiple-choice",
417 "Checkbox",
418 "Free-response",
419 "Graph",
420 "Drop-down-Menu",
421 "Drag-and-Drop",
422 "Embedded-Multiple-Choice",
423 "Single-Stimulus",
424 "Multi-Part-Question",
425 "Grid-Question",
426 "Graph-Multiple-Select",
427 }
428 },
429 ),
430 ],
431 request: Request,
432) -> dict:
433 """
434 Update a specific question by its ID.
436 This endpoint allows teachers to modify an existing question's content, answers, and metadata.
437 Teachers can only update questions they have created.
439 Args:
440 question_id (str): Unique identifier of the question to update
441 question_data (dict): Updated question details including:
442 - question (str, required): Main question text/prompt, supports HTML formatting
443 - choices (list[dict], required for Multiple-choice/Checkbox):
444 - id (int): Choice identifier
445 - text (str): Choice text, supports HTML formatting
446 - correctAnswer (dict, required):
447 - answers (Union[str, list[str]]): Correct answer(s)
448 - answerDetails (str, optional): Explanation for the correct answer
449 - graph (str, optional): Graph data for Graph type questions
450 - questionDetails (str, optional): Additional details or context
451 - assignmentType (str, required): Type of assignment (e.g., "STAAR")
452 - questionType (str, required): Question format type
453 - difficulty (str, required): Question difficulty level
454 - teksCode (str, required): TEKS standard code
455 - points (str/int, required): Point value
456 - category (str, required): Question category
457 request (Request): The incoming request object containing teacher context
459 Returns:
460 dict: Updated question object containing:
461 - updated_question (dict): Complete updated question details
462 - _id (str): Question identifier
463 - question (str): Updated question text
464 - choices (list): Modified answer choices
465 - correctAnswer (dict): New correct answer data
466 - other fields: Additional updated metadata
468 Raises:
469 HTTPException:
470 - 400: Invalid question ID format
471 - 401: Invalid or missing authentication token
472 - 403: Insufficient permissions to update question
473 - 404: Question not found or unauthorized access
474 - 422: Invalid request body format
475 - 500: Database or server error
476 """
477 return await teacher_question_service.update(question_id, question_data, request)
480@router.delete(
481 "/{question_id}/delete",
482 dependencies=[Depends(Auth0Bearer(access_levels=["teacher"]))],
483 status_code=status.HTTP_200_OK,
484 response_description="As a teacher, I can delete a specific question by its ID",
485 description="As a teacher, I can delete a specific question by its ID",
486 summary="As a teacher, I can delete a specific question by its ID",
487)
488async def teacher_question_delete(question_id: str, request: Request) -> dict:
489 """
490 Delete a specific question by its ID.
492 Args:
493 question_id (str): Unique identifier of the question to delete
494 request (Request): The incoming request object containing teacher context
496 Returns:
497 dict: Deletion confirmation message
499 Raises:
500 HTTPException:
501 - 401: Invalid or missing authentication token
502 - 403: Insufficient permissions to delete question
503 - 404: Question not found
504 - 500: Error deleting question
505 """
506 return await teacher_question_service.delete(question_id, request)
509@router.get(
510 "/filter-options",
511 dependencies=[Depends(Auth0Bearer(access_levels=["teacher"]))],
512 status_code=status.HTTP_200_OK,
513 response_description="As a teacher, I can get filter options with counts",
514 description="As a teacher, I can get filter options with counts",
515 summary="Get filter options with counts",
516)
517async def teacher_filter_options(request: Request) -> dict:
518 """
519 Get available filter options with counts.
521 This endpoint returns counts of questions for each filter option,
522 such as assignment types, question types, categories, and difficulties.
524 Args:
525 request (Request): The incoming request object containing:
526 - JWT token in Authorization header
527 - Teacher authentication context
529 Returns:
530 dict: Filter options with counts:
531 - assignmentTypes: Count by assignment type (e.g., {"STAAR": 5, "TSI": 3})
532 - questionTypes: Count by question type (e.g., {"Multiple-choice": 7})
533 - categories: Count by category (e.g., {"math": 4, "science": 2})
534 - difficulties: Count by difficulty (e.g., {"easy": 3, "medium": 4})
535 - available_assignment_types: List of all possible assignment types from enum
536 - available_question_types: List of all possible question types from enum
538 Raises:
539 HTTPException:
540 - 401: Invalid or missing authentication token
541 - 403: Insufficient permissions
542 - 500: Error retrieving filter options
543 """
544 # Get filter options with counts from the service
545 filter_options = await teacher_question_service.get_filter_options(request)
547 # Add available options from enums to help with checkbox rendering
548 filter_options["available_assignment_types"] = [
549 {"value": type.value, "label": type.name} for type in AssignmentType
550 ]
551 filter_options["available_question_types"] = [
552 {"value": type.value, "label": type.name.replace("_", " ").title()}
553 for type in QuestionType
554 ]
556 return filter_options
559@router.get(
560 "/clear-filters",
561 dependencies=[Depends(Auth0Bearer(access_levels=["teacher"]))],
562 status_code=status.HTTP_200_OK,
563 response_description="As a teacher, I can clear all filters",
564 description="As a teacher, I can clear all filters",
565 summary="Clear all filters",
566)
567async def teacher_clear_filters(request: Request) -> dict:
568 """
569 Clear all filters and return all questions.
571 This endpoint is a convenience method that returns all questions
572 without any filtering, equivalent to the "Clear All" button functionality.
574 Args:
575 request (Request): The incoming request object containing:
576 - JWT token in Authorization header
577 - Teacher authentication context
579 Returns:
580 dict: Same response as the /all/fetch endpoint but without any filters applied
582 Raises:
583 HTTPException:
584 - 401: Invalid or missing authentication token
585 - 403: Insufficient permissions
586 - 500: Error retrieving questions
587 """
588 # Clear all filter states so no filters are applied
589 request.state.assignment_types = None
590 request.state.question_types = None
591 request.state.categories = None
592 request.state.difficulties = None
593 teacher_id = to_user_id(request.state.user_details["uuid"])
594 return await teacher_question_service.fetch(request, teacher_id)
597@router.post(
598 "/image/upload",
599 dependencies=[
600 Depends(Auth0Bearer(access_levels=["teacher"])),
601 Depends(require_feature("teacher.create_own_questions")),
602 ],
603 status_code=status.HTTP_201_CREATED,
604 response_description="As a teacher, I can upload an image to embed in a question",
605 description="As a teacher, I can upload an image to embed in a question",
606 summary="As a teacher, I can upload an image to embed in a question",
607)
608async def teacher_upload_question_image(
609 request: Request,
610 file: UploadFile = File(
611 ..., description="Image to embed in a question. PNG or JPEG, 10MB maximum."
612 ),
613) -> dict:
614 """
615 Upload an image for use inside a question.
617 Stores the file in object storage and returns a public CDN URL for the editor to
618 insert as an ``<img src>``. Previously the editor inlined images as base64 ``data:``
619 URIs, which embedded the entire image in the saved question HTML.
621 Gated on the same feature as question creation: uploading an image is part of
622 authoring a question, so the two should not be able to disagree.
624 Args:
625 request (Request): The incoming request object containing:
626 - JWT token in Authorization header
627 - Teacher authentication context
628 file (UploadFile): The image file. Type is validated from its magic bytes,
629 not the declared content type.
631 Returns:
632 dict: Upload result containing:
633 - success (bool): Always True on a 201
634 - message (str): Human-readable confirmation
635 - id (str): Stored filename, unique per upload
636 - url (str): Public CDN URL to embed
637 - path (str): Object key within the bucket
638 - original_filename (str): Filename as supplied by the client
639 - size (int): Size in bytes
640 - content_type (str): Detected MIME type
642 Raises:
643 HTTPException:
644 - 413: File exceeds the 10MB limit
645 - 415: File is not a supported image, or its type cannot be determined
646 - 500: Object storage rejected the upload
647 """
648 return await teacher_question_service.upload_question_image(request, file)