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
« prev ^ index » next coverage.py v7.14.1, created at 2026-07-25 15:51 +0000
1from uuid import UUID
3from fastapi import APIRouter, Depends, HTTPException, status
4from sqlalchemy.orm import Session
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)
25router = APIRouter()
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.
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 )
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
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
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
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)
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)
116 return db_course
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
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)
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
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
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)
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
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").
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``.
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)
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``.
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 )
242 crud_courses.add_users_to_course(db, course_id, payload.userIds)
243 return crud_courses.get_course_members(db, course_id)
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``.
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 )
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
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.
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``.
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.
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 )
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 )
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
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``.
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 )
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 )
371 db.delete(row)
372 db.commit()
373 return None