diff --git a/backend/api/models/course.py b/backend/api/models/course.py index f4ec41f2..6c657c2c 100644 --- a/backend/api/models/course.py +++ b/backend/api/models/course.py @@ -1,3 +1,4 @@ +from typing import Self from django.db import models @@ -30,6 +31,20 @@ def __str__(self) -> str: """The string representation of the course.""" return str(self.name) + 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=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/__init__.py b/backend/api/permissions/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/api/permissions/course_permissions.py b/backend/api/permissions/course_permissions.py new file mode 100644 index 00000000..3c2543b5 --- /dev/null +++ b/backend/api/permissions/course_permissions.py @@ -0,0 +1,79 @@ +from rest_framework.permissions import BasePermission, SAFE_METHODS +from rest_framework.request import Request +from rest_framework.viewsets import ViewSet +from authentication.models import User +from api.permissions.role_permissions import is_student, is_assistant, is_teacher +from api.models.course import Course + + +class CoursePermission(BasePermission): + """Permission class used as default policy for course endpoints.""" + + 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: + return request.user.is_authenticated + + # 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: + return user.is_authenticated + + # 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) + + +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: + return user.is_authenticated + + # 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 new file mode 100644 index 00000000..1e9c275c --- /dev/null +++ b/backend/api/permissions/role_permissions.py @@ -0,0 +1,39 @@ +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: Request, view): + """Returns true if the request contains a user, + with said user being a student""" + return super().has_permission(request, view) and is_student(request.user) + + +class IsTeacher(IsAuthenticated): + 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 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 is_assistant(request.user) diff --git a/backend/api/signals.py b/backend/api/signals.py index 85f94211..6395ea75 100644 --- a/backend/api/signals.py +++ b/backend/api/signals.py @@ -2,9 +2,9 @@ from api.models.student import Student -def user_creation(user: User, attributes: dict, **kwargs): +def user_creation(user: User, attributes: dict, **_): """Upon user creation, auto-populate additional properties""" - student_id = attributes.get("ugentStudentID") + student_id: str = attributes.get("ugentStudentID") - if student_id: + if student_id is not None: Student(user_ptr=user, student_id=student_id).save_base(raw=True) diff --git a/backend/api/views/course_view.py b/backend/api/views/course_view.py index 54b1fcf2..416ab1f5 100644 --- a/backend/api/views/course_view.py +++ b/backend/api/views/course_view.py @@ -1,94 +1,175 @@ -from rest_framework import viewsets, status +from django.utils.translation import gettext +from rest_framework import viewsets +from rest_framework.exceptions import NotFound +from rest_framework.permissions import IsAdminUser from rest_framework.decorators import action from rest_framework.response import Response -from ..models.course import Course -from ..serializers.course_serializer import CourseSerializer -from ..serializers.teacher_serializer import TeacherSerializer -from ..serializers.assistant_serializer import AssistantSerializer -from ..serializers.student_serializer import StudentSerializer -from ..serializers.project_serializer import ProjectSerializer +from rest_framework.request import Request +from api.models.course import Course +from api.models.assistant import Assistant +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 +from api.serializers.student_serializer import StudentSerializer +from api.serializers.project_serializer import ProjectSerializer class CourseViewSet(viewsets.ModelViewSet): + """Actions for general course logic""" queryset = Course.objects.all() serializer_class = CourseSerializer + permission_classes = [IsAdminUser | CoursePermission] - @action(detail=True, methods=["get"]) - def teachers(self, request, pk=None): - """Returns a list of teachers for the given course""" + @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() + assistants = course.assistants.all() - try: - queryset = Course.objects.get(id=pk) - teachers = queryset.teachers.all() + # Serialize assistants + serializer = AssistantSerializer( + assistants, many=True, context={"request": request} + ) - # Serialize the teacher objects - serializer = TeacherSerializer( - teachers, many=True, context={"request": request} - ) - return Response(serializer.data) + return Response(serializer.data) - except Course.DoesNotExist: - # Invalid course ID - return Response( - status=status.HTTP_404_NOT_FOUND, data={"message": "Course not found"} + @assistants.mapping.post + @assistants.mapping.put + def _add_assistant(self, request: Request, **_): + """Add an assistant to the course""" + course = self.get_object() + + try: + # Add assistant to course + assistant = Assistant.objects.get( + id=request.data.get("id") ) - @action(detail=True, methods=["get"]) - def assistants(self, request, pk=None): - """Returns a list of assistants for the given course""" + course.assistants.add(assistant) - try: - queryset = Course.objects.get(id=pk) - assistants = queryset.assistants.all() + return Response({ + "message": gettext("courses.success.assistants.add") + }) + except Assistant.DoesNotExist: + # Not found + raise NotFound(gettext("assistants.error.404")) - # Serialize the assistant objects - serializer = AssistantSerializer( - assistants, many=True, context={"request": request} - ) - return Response(serializer.data) + @assistants.mapping.delete + def _remove_assistant(self, request: Request, **_): + """Remove an assistant from the course""" + course = self.get_object() - except Course.DoesNotExist: - # Invalid course ID - return Response( - status=status.HTTP_404_NOT_FOUND, data={"message": "Course not found"} + try: + # Add assistant to course + assistant = Assistant.objects.get( + id=request.data.get("id") ) - @action(detail=True, methods=["get"]) - def students(self, request, pk=None): + course.assistants.remove(assistant) + + return Response({ + "message": gettext("courses.success.assistants.delete") + }) + except Assistant.DoesNotExist: + # Not found + raise NotFound(gettext("assistants.error.404")) + + @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() + students = course.students.all() - try: - queryset = Course.objects.get(id=pk) - students = queryset.students.all() + # Serialize the student objects + serializer = StudentSerializer( + students, many=True, context={"request": 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() - # Serialize the student objects - serializer = StudentSerializer( - students, many=True, context={"request": request} + try: + # Add student to course + student = Student.objects.get( + id=request.data.get("id") ) - return Response(serializer.data) - except Course.DoesNotExist: - # Invalid course ID - return Response( - status=status.HTTP_404_NOT_FOUND, data={"message": "Course not found"} + 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, pk=None): + def projects(self, request, **_): """Returns a list of projects for the given course""" + course = self.get_object() + projects = course.projects.all() - try: - queryset = Course.objects.get(id=pk) - projects = queryset.projects.all() + # Serialize the project objects + serializer = ProjectSerializer( + projects, many=True, context={"request": request} + ) - # Serialize the project objects - serializer = ProjectSerializer( - projects, many=True, context={"request": request} - ) - return Response(serializer.data) + return Response(serializer.data) + @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 = course.child_course except Course.DoesNotExist: - # Invalid course ID - return Response( - status=status.HTTP_404_NOT_FOUND, data={"message": "Course not found"} + course = course.clone( + clone_assistants=request.data.get("clone_assistants") ) + + course.save() + + # Return serialized cloned course + course_serializer = CourseSerializer(course, context={"request": request}) + + return Response(course_serializer.data) diff --git a/backend/authentication/models.py b/backend/authentication/models.py index 8a8787f4..066d6fbb 100644 --- a/backend/authentication/models.py +++ b/backend/authentication/models.py @@ -1,5 +1,4 @@ from datetime import MINYEAR -from typing import Self, Type from django.db import models from django.db.models import CharField, EmailField, IntegerField, DateTimeField, BooleanField, Model from django.contrib.auth.models import AbstractBaseUser, AbstractUser, PermissionsMixin @@ -35,12 +34,6 @@ class User(AbstractBaseUser): USERNAME_FIELD = "username" EMAIL_FIELD = "email" - def has_role(self, model: Type[Self]): - """Simple generic implementation of roles. - This function looks if there exists a model (inheriting from User) with the same ID. - """ - model.objects.exists(self.id) - @staticmethod def get_dummy_admin(): return User(