diff --git a/backend/api/models/course.py b/backend/api/models/course.py index 57ff10ff..6c657c2c 100644 --- a/backend/api/models/course.py +++ b/backend/api/models/course.py @@ -31,15 +31,20 @@ def __str__(self) -> str: """The string representation of the course.""" return str(self.name) - def clone(self, year=None) -> Self: - # To-do: add more control over the cloning process. - return Course( + def clone(self, clone_assistants=True) -> Self: + """Clone the course to the next academic start year""" + course = Course( name=self.name, description=self.description, - academic_startyear=year or self.academic_startyear + 1, + academic_startyear=self.academic_startyear + 1, parent_course=self ) + if clone_assistants: + course.assistants.add(self.assistants) + + return course + @property def academic_year(self) -> str: """The academic year of the course.""" diff --git a/backend/api/permissions/course_permissions.py b/backend/api/permissions/course_permissions.py index cabf379f..fd0c0f9c 100644 --- a/backend/api/permissions/course_permissions.py +++ b/backend/api/permissions/course_permissions.py @@ -2,8 +2,7 @@ from rest_framework.request import Request from rest_framework.viewsets import ViewSet from authentication.models import User -from api.models.teacher import Teacher -from api.models.assistant import Assistant +from api.permissions.role_permissions import is_student, is_assistant, is_teacher from api.models.course import Course @@ -13,37 +12,64 @@ def has_permission(self, request: Request, view: ViewSet) -> bool: """Check if user has permission to view a general course endpoint.""" user: User = request.user + # Logged-in users can fetch course information. if request.method in SAFE_METHODS: - # Logged-in users can fetch course lists. return request.user.is_authenticated - # We only allow teachers to create new courses. - return hasattr(user, "teacher") and user.teacher.exists() + # Only teachers can create courses. + return is_teacher(user) def has_object_permission(self, request: Request, view: ViewSet, course: Course) -> bool: """Check if user has permission to view a detailed course endpoint""" user: User = request.user + # Logged-in users can fetch course details. if request.method in SAFE_METHODS: - # Logged-in users can fetch course details. return user.is_authenticated - # We only allow teachers and assistants to modify specified courses. - role: Teacher | Assistant = hasattr(user, "teacher") and user.teacher or \ - hasattr(user, "assistant") and user.assistant + # We only allow teachers and assistants to modify their own courses. + return is_teacher(user) and user.teacher.courses.contains(course) or \ + is_assistant(user) and user.assistant.courses.contains(course) - return role and \ - role.courses.filter(id=course.id).exists() - -class CourseTeacherPermission(CoursePermission): - """Permission class for teacher-only course endpoints.""" +class CourseAssistantPermission(CoursePermission): + """Permission class for assistant related endpoints.""" def has_object_permission(self, request: Request, view: ViewSet, course: Course) -> bool: user: User = request.user + # Logged-in users can fetch course assistants. + if request.method in SAFE_METHODS: + return user.is_authenticated + + # Only teachers can modify assistants of their own courses. + return is_teacher(user) and user.teacher.courses.contains(course) + + +class CourseStudentPermission(CoursePermission): + """Permission class for student related endpoints.""" + def has_object_permission(self, request: Request, view: ViewSet, course: Course): + user: User = request.user + + # Logged-in users can fetch course students. + if request.method in SAFE_METHODS: + return user.is_authenticated + + # Only students can add or remove themselves from a course. + if is_student(user) and request.data.get("id") == user.id: + return True + + # Teachers and assistants can add and remove any student. + return super().has_object_permission(request, view, course) + + +class CourseProjectPermission(CoursePermission): + """Permission class for project related endpoints.""" + def has_object_permission(self, request: Request, view: ViewSet, course: Course): + user: User = request.user + + # Logged-in users can fetch course projects. if request.method in SAFE_METHODS: - # Logged-in users can still fetch course details. return user.is_authenticated - return hasattr(user, "teacher") and \ - user.teacher.courses.filter(id=course.id).exists() + # Teachers and assistants can modify projects. + return super().has_object_permission(request, view, course) diff --git a/backend/api/permissions/role_permissions.py b/backend/api/permissions/role_permissions.py index 94ace65c..b196747c 100644 --- a/backend/api/permissions/role_permissions.py +++ b/backend/api/permissions/role_permissions.py @@ -1,28 +1,36 @@ from rest_framework.permissions import IsAuthenticated +from rest_framework.request import Request +from authentication.models import User from api.models.student import Student from api.models.assistant import Assistant from api.models.teacher import Teacher +def is_student(user: User): + return Student.objects.filter(id=user.id).exists() + +def is_assistant(user: User): + return Assistant.objects.filter(id=user.id).exists() + +def is_teacher(user: User): + return Teacher.objects.filter(id=user.id).exists() + class IsStudent(IsAuthenticated): - def has_permission(self, request, view): + def has_permission(self, request: Request, view): """Returns true if the request contains a user, with said user being a student""" - return super().has_permission(request, view) and \ - Student.objects.filter(id=request.user.id).exists() + return super().has_permission(request, view) and is_student(request.user) class IsTeacher(IsAuthenticated): - def has_permission(self, request, view): + def has_permission(self, request: Request, view): """Returns true if the request contains a user, with said user being a student""" - return super().has_permission(request, view) and \ - Teacher.objects.filter(id=request.user.id).exists() + return super().has_permission(request, view) and is_teacher(request.user) class IsAssistant(IsAuthenticated): def has_permission(self, request, view): """Returns true if the request contains a user, with said user being a student""" - return super().has_permission(request, view) and \ - Assistant.objects.filter(id=request.user.id).exists() + return super().has_permission(request, view) and is_assistant(request.user) diff --git a/backend/api/views/course_view.py b/backend/api/views/course_view.py index aa2d9219..13dfbae4 100644 --- a/backend/api/views/course_view.py +++ b/backend/api/views/course_view.py @@ -1,14 +1,15 @@ from django.utils.translation import gettext from rest_framework import viewsets from rest_framework.exceptions import NotFound -from rest_framework.permissions import IsAdminUser, IsAuthenticated +from rest_framework.permissions import IsAdminUser from rest_framework.decorators import action from rest_framework.response import Response from rest_framework.request import Request from api.models.course import Course from api.models.assistant import Assistant -from api.permissions.course_permissions import CoursePermission, CourseTeacherPermission -from api.permissions.role_permissions import IsStudent +from api.models.student import Student +from api.permissions.course_permissions import CoursePermission, CourseAssistantPermission, CourseStudentPermission +from api.permissions.role_permissions import IsTeacher from api.serializers.course_serializer import CourseSerializer from api.serializers.teacher_serializer import TeacherSerializer from api.serializers.assistant_serializer import AssistantSerializer @@ -22,7 +23,7 @@ class CourseViewSet(viewsets.ModelViewSet): serializer_class = CourseSerializer permission_classes = [IsAdminUser | CoursePermission] - @action(detail=True, permission_classes=[IsAdminUser | CourseTeacherPermission]) + @action(detail=True, permission_classes=[IsAdminUser | CourseAssistantPermission]) def assistants(self, request: Request, **_): """Returns a list of assistants for the given course""" course = self.get_object() @@ -76,22 +77,8 @@ def _remove_assistant(self, request: Request, **_): # Not found raise NotFound(gettext("assistants.error.404")) - @action(detail=True, methods=["get"]) - def teachers(self, request, **_): - """Returns a list of teachers for the given course""" - # This automatically fetches the course from the URL. - # It automatically gives back a 404 HTTP response in case of not found. - course = self.get_object() - teachers = course.teachers.all() - # Serialize the teacher objects - serializer = TeacherSerializer( - teachers, many=True, context={"request": request} - ) - - return Response(serializer.data) - - @action(detail=True, methods=["get"]) + @action(detail=True, methods=["get"], permission_classes=[IsAdminUser | CourseStudentPermission]) def students(self, request, **_): """Returns a list of students for the given course""" course = self.get_object() @@ -104,6 +91,58 @@ def students(self, request, **_): return Response(serializer.data) + @students.mapping.post + @students.mapping.put + def _add_student(self, request: Request, **_): + """Add a student to the course""" + course = self.get_object() + + try: + # Add student to course + student = Student.objects.get( + id=request.data.get("id") + ) + + course.students.add(student) + + return Response({ + "message": gettext("courses.success.students.add") + }) + except Student.DoesNotExist: + raise NotFound(gettext("students.error.404")) + + @students.mapping.delete + def _remove_student(self, request: Request, **_): + """Remove a student from the course""" + course = self.get_object() + + try: + # Add student to course + student = Student.objects.get( + id=request.data.get("id") + ) + + course.students.remove(student) + + return Response({ + "message": gettext("courses.success.students.remove") + }) + except Student.DoesNotExist: + raise NotFound(gettext("students.error.404")) + + @action(detail=True, methods=["get"]) + def teachers(self, request, **_): + """Returns a list of teachers for the given course""" + course = self.get_object() + teachers = course.teachers.all() + + # Serialize the teacher objects + serializer = TeacherSerializer( + teachers, many=True, context={"request": request} + ) + + return Response(serializer.data) + @action(detail=True, methods=["get"]) def projects(self, request, **_): """Returns a list of projects for the given course""" @@ -117,35 +156,21 @@ def projects(self, request, **_): return Response(serializer.data) - @action(detail=True, methods=["post"], permission_classes=[IsStudent]) - def join(self, request, **_): - """Enrolls the authenticated student in the project""" - # Add the course to the student's enrollment list. - self.get_object().students.add( - request.user.student - ) - - return Response({ - "message": gettext("courses.success.join") - }) - - @action(detail=True, methods=["post"], permission_classes=[IsAdminUser | CourseTeacherPermission]) + @action(detail=True, methods=["post"], permission_classes=[IsAdminUser | IsTeacher]) def clone(self, request: Request, **__): """Copy the course to a new course with the same fields""" course: Course = self.get_object() try: - course_serializer = CourseSerializer( - course.child_course, context={"request": request} - ) + course = course.child_course except Course.DoesNotExist: - course_serializer = CourseSerializer( - course.clone( - year=request.data.get("academic_startyear") - ), - context={"request": request} + course = course.clone( + clone_assistants=request.data.get("clone_assistants") ) - course_serializer.save() + course.save() + + # Return serialized cloned course + course_serializer = CourseSerializer(course, context={"request": request}) return Response(course_serializer.data)