Coverage for app/routers/courses.py: 87.00%

100 statements  

« prev     ^ index     » next       coverage.py v7.14.1, created at 2026-07-25 15:51 +0000

1from uuid import UUID 

2 

3from fastapi import APIRouter, Depends, HTTPException, status 

4from sqlalchemy.orm import Session 

5 

6from app.crud import courses as crud_courses 

7from app.crud import users as crud_users 

8from app.database import get_db 

9from app.models import CourseTeacher, User, UserRole 

10from app.schemas import ( 

11 CourseCreate, 

12 CourseMembersUpdate, 

13 CourseResponse, 

14 CourseUpdate, 

15 CourseWithUsers, 

16 UserResponse, 

17) 

18from app.utils.capabilities import ensure_edit_course 

19from app.utils.keycloak_auth import get_current_user_keycloak 

20from app.utils.permissions import ( 

21 require_admin, 

22 require_staff, 

23) 

24 

25router = APIRouter() 

26 

27 

28def _find_course_teacher(db: Session, course_id: UUID, user_id: UUID): 

29 """Return the ``course_teachers`` row for this (course, user) pair, or None. 

30 

31 Shared by the add/remove course-teacher endpoints; each caller keeps 

32 its own None-handling (add treats None as "not yet a teacher", remove 

33 treats None as 404). 

34 """ 

35 return ( 

36 db.query(CourseTeacher) 

37 .filter( 

38 CourseTeacher.courseId == course_id, 

39 CourseTeacher.userId == user_id, 

40 ) 

41 .first() 

42 ) 

43 

44 

45# ---------------------------------------------------------------- 

46# GET ALL COURSES 

47# ---------------------------------------------------------------- 

48@router.get("/", response_model=list[CourseResponse]) 

49def list_courses( 

50 skip: int = 0, 

51 limit: int = 100, 

52 db: Session = Depends(get_db), 

53 current_user: User = Depends(get_current_user_keycloak) 

54): 

55 """ 

56 Get all courses 

57 - **Students**: Can view all courses 

58 - **Teachers/Admins**: Can view all courses 

59 """ 

60 courses = crud_courses.get_courses(db, skip=skip, limit=limit) 

61 return courses 

62 

63 

64# ---------------------------------------------------------------- 

65# GET COURSE BY ID 

66# ---------------------------------------------------------------- 

67@router.get("/{course_id}", response_model=CourseWithUsers) 

68def get_course( 

69 course_id: UUID, 

70 db: Session = Depends(get_db), 

71 current_user: User = Depends(get_current_user_keycloak) 

72): 

73 """Get course by ID with all users""" 

74 course = crud_courses.get_course(db, course_id) 

75 if not course: 

76 raise HTTPException( 

77 status_code=status.HTTP_404_NOT_FOUND, 

78 detail="Course not found" 

79 ) 

80 return course 

81 

82 

83# ---------------------------------------------------------------- 

84# CREATE COURSE (TEACHER/ADMIN ONLY) 

85# ---------------------------------------------------------------- 

86@router.post("/", response_model=CourseResponse, status_code=status.HTTP_201_CREATED) 

87def create_course( 

88 course: CourseCreate, 

89 db: Session = Depends(get_db), 

90 current_user: User = Depends(require_staff) 

91): 

92 """ 

93 Create a new course 

94 - **Requires**: TEACHER or ADMIN role 

95 

96 A creating teacher is automatically registered as a course-teacher of 

97 the new course via the ``course_teachers`` join table, so the 

98 edit/delete gate passes for them without an extra round trip. Admins 

99 are NOT auto-added (admin rights already cover edit/delete); they can 

100 opt in via ``POST /courses/{id}/teachers/{user_id}``. 

101 """ 

102 db_course = crud_courses.create_course(db, course) 

103 

104 # Auto-register the creating teacher as course-teacher. Skipped for 

105 # admins because admin rights are role-shaped, not course-scoped. 

106 if current_user.role == UserRole.TEACHER: 

107 db.add( 

108 CourseTeacher( 

109 courseId=db_course.courseId, 

110 userId=current_user.userId, 

111 ) 

112 ) 

113 db.commit() 

114 db.refresh(db_course) 

115 

116 return db_course 

117 

118 

119# ---------------------------------------------------------------- 

120# UPDATE COURSE (course-teacher of THIS course OR admin) 

121# ---------------------------------------------------------------- 

122@router.put("/{course_id}", response_model=CourseResponse) 

123def update_course( 

124 course_id: UUID, 

125 course_update: CourseUpdate, 

126 db: Session = Depends(get_db), 

127 current_user: User = Depends(get_current_user_keycloak) 

128): 

129 """ 

130 Update a course 

131 

132 Only a designated course-teacher of THIS course (row in 

133 ``course_teachers``) or an admin may edit; others get 403 with the 

134 structured ``course_edit_forbidden`` payload. 404 takes precedence 

135 over 403 for nonexistent courses. 

136 """ 

137 course = crud_courses.get_course(db, course_id) 

138 if not course: 

139 raise HTTPException( 

140 status_code=status.HTTP_404_NOT_FOUND, 

141 detail="Course not found" 

142 ) 

143 ensure_edit_course(current_user, course, db) 

144 

145 updated = crud_courses.update_course(db, course_id, course_update) 

146 if not updated: 

147 # Concurrent delete between the 404-check and the update; 

148 # surface as 404 to keep the response contract stable. 

149 raise HTTPException( 

150 status_code=status.HTTP_404_NOT_FOUND, 

151 detail="Course not found" 

152 ) 

153 return updated 

154 

155 

156# ---------------------------------------------------------------- 

157# DELETE COURSE (course-teacher of THIS course OR admin) 

158# ---------------------------------------------------------------- 

159@router.delete("/{course_id}", status_code=status.HTTP_204_NO_CONTENT) 

160def delete_course( 

161 course_id: UUID, 

162 db: Session = Depends(get_db), 

163 current_user: User = Depends(get_current_user_keycloak) 

164): 

165 """ 

166 Delete a course 

167 

168 Only a designated course-teacher of THIS course or an admin may 

169 delete. Members are detached (``users.courseId = NULL``) before the 

170 row goes away — no user account is destroyed; ``course_teachers`` 

171 rows cascade away via ON DELETE CASCADE. 

172 """ 

173 course = crud_courses.get_course(db, course_id) 

174 if not course: 

175 raise HTTPException( 

176 status_code=status.HTTP_404_NOT_FOUND, 

177 detail="Course not found" 

178 ) 

179 ensure_edit_course(current_user, course, db) 

180 

181 success = crud_courses.delete_course(db, course_id) 

182 if not success: 

183 raise HTTPException( 

184 status_code=status.HTTP_404_NOT_FOUND, 

185 detail="Course not found" 

186 ) 

187 return None 

188 

189 

190# ---------------------------------------------------------------- 

191# COURSE MEMBERS 

192# ---------------------------------------------------------------- 

193# Members live as ``users.courseId``. The endpoints below treat that 

194# FK as a small membership API so the UI can manage enrollment without 

195# poking the user-update endpoint (which has its own role-change rules 

196# that don't apply to "join a course"). 

197 

198@router.get("/{course_id}/users", response_model=list[UserResponse]) 

199def list_course_members( 

200 course_id: UUID, 

201 db: Session = Depends(get_db), 

202 current_user: User = Depends(require_staff), 

203): 

204 """List the users currently enrolled in ``course_id``. 

205 

206 Teacher/Admin only — student rosters are management data. 

207 """ 

208 course = crud_courses.get_course(db, course_id) 

209 if not course: 

210 raise HTTPException( 

211 status_code=status.HTTP_404_NOT_FOUND, 

212 detail="Course not found" 

213 ) 

214 return crud_courses.get_course_members(db, course_id) 

215 

216 

217@router.post( 

218 "/{course_id}/users", 

219 response_model=list[UserResponse], 

220 status_code=status.HTTP_200_OK, 

221) 

222def add_course_members( 

223 course_id: UUID, 

224 payload: CourseMembersUpdate, 

225 db: Session = Depends(get_db), 

226 current_user: User = Depends(require_staff), 

227): 

228 """Add (or move) a batch of users into ``course_id``. 

229 

230 The frontend's add-modal uses the same Keycloak-backed user search 

231 as the deployment-team picker, so by the time we get here the 

232 user-ids exist in our DB. Returns the post-update member list of 

233 the course so the UI can refresh without a second round trip. 

234 """ 

235 course = crud_courses.get_course(db, course_id) 

236 if not course: 

237 raise HTTPException( 

238 status_code=status.HTTP_404_NOT_FOUND, 

239 detail="Course not found" 

240 ) 

241 

242 crud_courses.add_users_to_course(db, course_id, payload.userIds) 

243 return crud_courses.get_course_members(db, course_id) 

244 

245 

246@router.delete( 

247 "/{course_id}/users/{user_id}", 

248 status_code=status.HTTP_204_NO_CONTENT, 

249) 

250def remove_course_member( 

251 course_id: UUID, 

252 user_id: UUID, 

253 db: Session = Depends(get_db), 

254 current_user: User = Depends(require_staff), 

255): 

256 """Remove ``user_id`` from ``course_id``. 

257 

258 Returns 404 if the user isn't actually enrolled in this course — 

259 we never silently detach somebody from a different course. 

260 """ 

261 course = crud_courses.get_course(db, course_id) 

262 if not course: 

263 raise HTTPException( 

264 status_code=status.HTTP_404_NOT_FOUND, 

265 detail="Course not found" 

266 ) 

267 

268 removed = crud_courses.remove_user_from_course(db, course_id, user_id) 

269 if not removed: 

270 raise HTTPException( 

271 status_code=status.HTTP_404_NOT_FOUND, 

272 detail="User is not a member of this course" 

273 ) 

274 return None 

275 

276 

277# ---------------------------------------------------------------- 

278# COURSE TEACHERS (admin-only management) 

279# ---------------------------------------------------------------- 

280# The ``course_teachers`` join table is the source of truth for the 

281# course-teacher capability. ``POST /courses`` auto-registers the 

282# creating teacher; all other roster mutations are admin-only so one 

283# course-teacher cannot remove another. 

284 

285 

286@router.post( 

287 "/{course_id}/teachers/{user_id}", 

288 status_code=status.HTTP_204_NO_CONTENT, 

289) 

290def add_course_teacher( 

291 course_id: UUID, 

292 user_id: UUID, 

293 db: Session = Depends(get_db), 

294 current_user: User = Depends(require_admin), 

295): 

296 """Register ``user_id`` as a course-teacher of ``course_id``. 

297 

298 Admin-only. The target user must already exist and have role 

299 ``TEACHER`` — adding a student or admin to this table would 

300 contradict the ``is_course_teacher`` gate (which requires 

301 ``role == TEACHER``) and create a row that the capability 

302 function silently ignores. We refuse those up-front with a 

303 structured 422. 

304 

305 Idempotent: re-adding an existing pair is a no-op (the composite 

306 primary key catches the duplicate, but we check up-front to keep 

307 the success/no-op path distinct from "user not found"). 

308 """ 

309 course = crud_courses.get_course(db, course_id) 

310 if not course: 

311 raise HTTPException( 

312 status_code=status.HTTP_404_NOT_FOUND, 

313 detail="Course not found", 

314 ) 

315 

316 target = crud_users.get_user(db, user_id) 

317 if not target: 

318 raise HTTPException( 

319 status_code=status.HTTP_404_NOT_FOUND, 

320 detail="User not found", 

321 ) 

322 if target.role != UserRole.TEACHER: 

323 raise HTTPException( 

324 status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, 

325 detail={ 

326 "code": "role_required", 

327 "required": [UserRole.TEACHER.value], 

328 }, 

329 ) 

330 

331 existing = _find_course_teacher(db, course_id, user_id) 

332 if existing is None: 

333 db.add(CourseTeacher(courseId=course_id, userId=user_id)) 

334 db.commit() 

335 return None 

336 

337 

338@router.delete( 

339 "/{course_id}/teachers/{user_id}", 

340 status_code=status.HTTP_204_NO_CONTENT, 

341) 

342def remove_course_teacher( 

343 course_id: UUID, 

344 user_id: UUID, 

345 db: Session = Depends(get_db), 

346 current_user: User = Depends(require_admin), 

347): 

348 """Revoke ``user_id``'s course-teacher status on ``course_id``. 

349 

350 Admin-only. Returns 404 if the user wasn't a course-teacher of 

351 this course — we never silently delete from a different course. 

352 Note this does NOT remove the user from ``users.courseId``; 

353 course membership and course-teacher status are independent 

354 facets (a teacher can stop teaching a course but stay enrolled 

355 as a regular member, or vice versa). 

356 """ 

357 course = crud_courses.get_course(db, course_id) 

358 if not course: 

359 raise HTTPException( 

360 status_code=status.HTTP_404_NOT_FOUND, 

361 detail="Course not found", 

362 ) 

363 

364 row = _find_course_teacher(db, course_id, user_id) 

365 if row is None: 

366 raise HTTPException( 

367 status_code=status.HTTP_404_NOT_FOUND, 

368 detail="User is not a teacher of this course", 

369 ) 

370 

371 db.delete(row) 

372 db.commit() 

373 return None