Coverage for server / routes / common / assignments.py: 97%

35 statements  

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

1from fastapi import APIRouter, Depends, HTTPException, Request, status 

2from server.authentication.auth0_bearer import Auth0Bearer 

3from server.models.assignment import Assignment, Submission, UpdateAssignmentRequest 

4from server.models.sharerequests import ShareRequest 

5from server.services.common.assignments import AssignmentsService 

6 

7 

8router = APIRouter() 

9 

10assignments_service = AssignmentsService() 

11 

12 

13from fastapi.responses import JSONResponse 

14 

15 

16@router.post( 

17 "/create", 

18 dependencies=[Depends(Auth0Bearer(access_levels=["teacher"]))], 

19 status_code=status.HTTP_200_OK, 

20 # Modified by Allan Ninal — 2026-09-24 (EI-T113). 

21 # `response_model=Assignment` was removed. The service returns an ENVELOPE — 

22 # {"detail": "Successfully Created Assignment", "new_assignment": Assignment} 

23 # — so FastAPI validated that envelope AS an Assignment, the model's root 

24 # validator reported "semester is required" (the envelope has no semester 

25 # key), and the request died with ResponseValidationError -> 500. 

26 # 

27 # The write had ALREADY succeeded by then, so every call created an 

28 # assignment and reported failure; a retrying client duplicated it. This was 

29 # the only response_model in the file — every other route here returns its 

30 # envelope unvalidated — so removing it matches the existing contract rather 

31 # than inventing one. 

32 response_description="Newly created assignment details", 

33 responses={ 

34 200: { 

35 "description": "Newly created assignment details", 

36 "content": { 

37 "application/json": { 

38 "example": { 

39 "title": "Assignment 123 from staff", 

40 "description": "test", 

41 "type": "STAAR", 

42 "category": "current", 

43 "semester": "spring", 

44 "assigned_class": ["6660821c60082c7a7abc6242"], 

45 "date_open": "2025-05-25T00:00:00.000Z", 

46 "date_close": "2025-10-27T00:00:00.000Z", 

47 "format": "standard", 

48 "questions": [ 

49 { 

50 "id": "66146bc250b7d3e9e30e0021", 

51 "category": "2", 

52 "topic": "topic2", 

53 }, 

54 { 

55 "id": "67a43228d70921ca626e99b9", 

56 "category": "1", 

57 "topic": "test", 

58 }, 

59 { 

60 "id": "67e91274bb615cb417807ef7", 

61 "category": "1", 

62 "topic": "This is topic", 

63 }, 

64 { 

65 "id": "67f12c007c6d257620daf333", 

66 "category": "1", 

67 "topic": "test", 

68 }, 

69 { 

70 "id": "67f2137ad036733d714b8344", 

71 "category": "1", 

72 "topic": "Test topic", 

73 }, 

74 { 

75 "id": "67fa816a4a7de71ac565bdab", 

76 "category": "1", 

77 "topic": "test", 

78 }, 

79 { 

80 "id": "68035aa9add1d22b4660d131", 

81 "category": "1", 

82 "topic": "Slope and Rate of Change", 

83 }, 

84 ], 

85 "settings": { 

86 "allowed_attempts": 3, 

87 "shuffle_questions": True, 

88 "shuffle_choices": True, 

89 "allow_calculator": True, 

90 "show_score_after_submit": True, 

91 "show_correct_answers_after_submit": True, 

92 "allow_feedback_after_submit": True, 

93 "time_allowed": "01:00:00", 

94 }, 

95 "passing_grade": 90, 

96 "copy_of": "684c36d4a688b290bbfe0b16", 

97 } 

98 } 

99 }, 

100 } 

101 }, 

102) 

103async def create_new_assignment(new_assigment: Assignment, request: Request) -> dict: 

104 """ 

105 Create a new assignment. 

106 

107 Args: 

108 new_assigment (Assignment): Assignment details to be created 

109 request (Request): The incoming request object containing user context 

110 

111 Returns: 

112 dict: {"detail": <message>, "new_assignment": <the created assignment>} 

113 """ 

114 return await assignments_service.create_new_assignment(new_assigment, request) 

115 

116 

117@router.get( 

118 "/view/{assignment_uuid}", 

119 dependencies=[Depends(Auth0Bearer(access_levels=["teacher"]))], 

120 status_code=status.HTTP_200_OK, 

121 response_description="Returns detailed information about a specific assignment", 

122 tags=["Assignments"], 

123) 

124async def get_specific_assignment_by_id( 

125 assignment_uuid: str, # Path parameter for assignment identification 

126 request: Request, # FastAPI request object containing user context 

127) -> dict: 

128 """ 

129 Retrieve a specific assignment by its UUID. This endpoint is restricted to teachers only 

130 and requires JWT authentication. 

131 

132 Args: 

133 assignment_uuid (str): Unique identifier of the assignment. Must be a valid UUID string 

134 that exists in the database. 

135 request (Request): The incoming request object containing user context, including: 

136 - JWT token in headers 

137 - User authentication information 

138 - User role and permissions 

139 

140 Returns: 

141 dict: Assignment details including: 

142 - assignment_id: str (UUID of the assignment) 

143 - title: str (Title of the assignment) 

144 - description: str (Detailed description) 

145 - due_date: datetime (Assignment deadline) 

146 - created_by: str (Teacher's ID who created it) 

147 - created_at: datetime (Creation timestamp) 

148 - status: str (Current status of the assignment) 

149 - questions: list (List of questions and their details) 

150 - settings: dict (Assignment configuration settings) 

151 

152 Raises: 

153 HTTPException(404): If the assignment UUID doesn't exist 

154 HTTPException(403): If the user doesn't have permission to view this assignment 

155 HTTPException(401): If the authentication token is invalid or expired 

156 

157 Example: 

158 GET /assignments/view/123e4567-e89b-12d3-a456-426614174000 

159 """ 

160 # Delegate the actual retrieval to the assignments service 

161 # The service layer handles database interactions and permission checks 

162 return await assignments_service.assignment_view_fetch(assignment_uuid, request) 

163 

164 

165@router.get( 

166 "/adoptive/{assignment_uuid}/next_item", 

167 dependencies=[Depends(Auth0Bearer(access_levels=["teacher"]))], 

168 status_code=status.HTTP_200_OK, 

169) 

170async def adoptive_next_item( 

171 request: Request, 

172 assignment_uuid: str, 

173 prev_difficulty: str, 

174 prev_remarks: str, 

175 question_classification: str, 

176): 

177 """ 

178 Get next item for adaptive testing based on previous responses. 

179 

180 Args: 

181 request (Request): The incoming request object containing user context 

182 assignment_uuid (str): Unique identifier of the assignment 

183 prev_difficulty (str): Difficulty level of previous question 

184 prev_remarks (str): Remarks from previous question 

185 question_classification (str): Classification of question type 

186 

187 Returns: 

188 dict: Next question details 

189 """ 

190 return await assignments_service.assignment_adaptive_fetch( 

191 request, assignment_uuid, prev_difficulty, prev_remarks, question_classification 

192 ) 

193 

194 

195@router.post( 

196 "/answer", 

197 dependencies=[Depends(Auth0Bearer(access_levels=["teacher", "student"]))], 

198 status_code=status.HTTP_200_OK, 

199) 

200async def answer_assignment(student_assignment_response: Submission, request: Request): 

201 """ 

202 Submit an answer for an assignment. 

203 

204 Args: 

205 student_assignment_response (Submission): Student's submission details 

206 request (Request): The incoming request object containing user context 

207 

208 Returns: 

209 dict: Submission result and feedback 

210 """ 

211 # assignments_service.answer_assignment does not exist and never has, so 

212 # every call to POST /v1/assignments/answer raised AttributeError and 

213 # answered 500. The method that records a submission is 

214 # assignment_answer_update, whose signature — (submission: Submission, 

215 # request: Request) — matches what this route already passes, argument for 

216 # argument. Found 2026-09-22 by pylint E1101 once the pre-commit hook was 

217 # repaired; the route is in the OpenAPI spec and had no test. 

218 return await assignments_service.assignment_answer_update( 

219 student_assignment_response, request 

220 ) 

221 

222 

223@router.get( 

224 "/review/{submission_id}", 

225 dependencies=[Depends(Auth0Bearer(access_levels=["teacher", "student"]))], 

226 status_code=status.HTTP_200_OK, 

227) 

228async def review_submission(submission_id: str, request: Request): 

229 """ 

230 Retrieve a specific submission for review. 

231 

232 Args: 

233 submission_id (str): Unique identifier of the submission 

234 request (Request): The incoming request object containing user context 

235 

236 Returns: 

237 dict: Submission details with answers and feedback 

238 """ 

239 return await assignments_service.assignment_review_fetch(submission_id, request) 

240 

241 

242@router.post( 

243 "/share", 

244 dependencies=[Depends(Auth0Bearer(access_levels=["teacher"]))], 

245 status_code=status.HTTP_200_OK, 

246) 

247async def share_assignment(share_request: ShareRequest, request: Request): 

248 """ 

249 Share an assignment with other users. 

250 

251 Args: 

252 share_request (ShareRequest): Details about sharing preferences 

253 request (Request): The incoming request object containing user context 

254 

255 Returns: 

256 dict: Sharing confirmation details 

257 """ 

258 return await assignments_service.assignment_share(share_request, request) 

259 

260 

261@router.get( 

262 "/show_analytics/{assignment_uuid}", 

263 dependencies=[Depends(Auth0Bearer(access_levels=["teacher"]))], 

264 status_code=status.HTTP_200_OK, 

265) 

266async def show_assignment_analytics(assignment_uuid: str, request: Request): 

267 """ 

268 Get analytics for a specific assignment. 

269 

270 Args: 

271 assignment_uuid (str): Unique identifier of the assignment 

272 

273 Returns: 

274 dict: Analytics data including completion rates, scores, and performance metrics 

275 """ 

276 return await assignments_service.assignment_analytics_fetch( 

277 assignment_uuid, request 

278 ) 

279 

280 

281@router.put( 

282 "/update/{assignment_uuid}", 

283 dependencies=[Depends(Auth0Bearer(access_levels=["teacher"]))], 

284 status_code=status.HTTP_200_OK, 

285) 

286async def update_assignment_details( 

287 assignment_uuid: str, updated_assignment: UpdateAssignmentRequest, request: Request 

288): 

289 """ 

290 Update an existing assignment's details. 

291 

292 Args: 

293 assignment_uuid (str): Unique identifier of the assignment 

294 updated_assignment (UpdateAssignmentRequest): Updated assignment details 

295 (``semester`` required at the HTTP boundary — EI-T771 / EI-T583) 

296 request (Request): The incoming request object containing user context 

297 

298 Returns: 

299 dict: Updated assignment details 

300 """ 

301 return await assignments_service.assignment_update( 

302 assignment_uuid, updated_assignment, request 

303 ) 

304 

305 

306@router.delete( 

307 # Modified by Allan Ninal — 2026-09-24 (EI-T121). 

308 # WAS: "/delete/{assignment_id}" while the handler below takes 

309 # `assignment_uuid`. The names did not match, so FastAPI bound NOTHING 

310 # from the path and treated `assignment_uuid` as a REQUIRED QUERY 

311 # parameter. Every call to the documented path answered 

312 # 422 {"field": "assignment_uuid", "msg": "Field required"} — the 

313 # endpoint could not delete anything through its own URL, only through 

314 # the accidental form /delete/<ignored>?assignment_uuid=<id>. 

315 # Renaming the PATH (rather than the handler argument) is the safe 

316 # direction: every caller already uses the path form — the automation 

317 # endpoint helper, the auth-sweep tests, EI-T777 — and nothing anywhere uses 

318 # the query form, because it was never documented. It also matches the 

319 # sibling route "/update/{assignment_uuid}". 

320 "/delete/{assignment_uuid}", 

321 dependencies=[Depends(Auth0Bearer(access_levels=["teacher"]))], 

322 status_code=status.HTTP_200_OK, 

323) 

324async def delete_assignment_by_id(assignment_uuid: str, request: Request): 

325 """ 

326 Delete a specific assignment. 

327 

328 Args: 

329 assignment_uuid (str): Unique identifier of the assignment to delete 

330 request (Request): The incoming request object containing user context 

331 

332 Returns: 

333 dict: Deletion confirmation 

334 """ 

335 return await assignments_service.assignment_delete(assignment_uuid, request)