diff --git a/.gitignore b/.gitignore index 73b3aa42..0409767f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,11 +1,14 @@ .tool-versions .env .venv +.idea +.vscode data/* data/nginx/ssl/* data/postres* data/redis/* +/node_modules backend/staticfiles/* !data/nginx/ssl/.gitkeep diff --git a/backend/api/models/teacher.py b/backend/api/models/teacher.py index 89f3d471..6866dd7b 100644 --- a/backend/api/models/teacher.py +++ b/backend/api/models/teacher.py @@ -16,3 +16,7 @@ class Teacher(User): related_name="teachers", blank=True, ) + + def has_course(self, course: Course) -> bool: + """Checks if the teacher has the given course.""" + return self.courses.contains(course) diff --git a/backend/api/permissions/assistant_permissions.py b/backend/api/permissions/assistant_permissions.py new file mode 100644 index 00000000..5cb76fc1 --- /dev/null +++ b/backend/api/permissions/assistant_permissions.py @@ -0,0 +1,23 @@ +from rest_framework.permissions import BasePermission +from rest_framework.request import Request +from rest_framework.viewsets import ViewSet +from api.permissions.role_permissions import is_teacher, is_assistant +from api.models.assistant import Assistant + + +class AssistantPermission(BasePermission): + """Permission class used as default policy for assistant endpoint.""" + def has_permission(self, request: Request, view: ViewSet) -> bool: + """Check if user has permission to view a general assistant endpoint.""" + user = request.user + + if view.action == "list": + # Only teachers can query the assistant list. + return user.is_authenticated and is_teacher(user) + + return is_teacher(user) or is_assistant(user) + + def has_object_permission(self, request: Request, view: ViewSet, assistant: Assistant) -> bool: + # Teachers can view the details of all assistants. + # Users can view their own assistant object. + return is_teacher(request.user) or request.user.id == assistant.id diff --git a/backend/api/permissions/course_permissions.py b/backend/api/permissions/course_permissions.py index 5bce23e7..a6c5ef19 100644 --- a/backend/api/permissions/course_permissions.py +++ b/backend/api/permissions/course_permissions.py @@ -22,11 +22,11 @@ def has_permission(self, request: Request, view: ViewSet) -> bool: 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 + user = request.user # Logged-in users can fetch course details. if request.method in SAFE_METHODS: - return user.is_authenticated + return is_student(user) or is_teacher(user) or is_assistant(user) # We only allow teachers and assistants to modify their own courses. return is_teacher(user) and user.teacher.courses.contains(course) or \ @@ -44,7 +44,7 @@ def has_object_permission(self, request: Request, view: ViewSet, course: Course) return user.is_authenticated # Only teachers can modify assistants of their own courses. - return is_teacher(user) and user.teacher.courses.contains(course) + return is_teacher(user) and user.teacher.has_course(course) class CourseStudentPermission(CoursePermission): diff --git a/backend/api/permissions/role_permissions.py b/backend/api/permissions/role_permissions.py index 1e9c275c..2c50aa5b 100644 --- a/backend/api/permissions/role_permissions.py +++ b/backend/api/permissions/role_permissions.py @@ -1,5 +1,6 @@ from rest_framework.permissions import IsAuthenticated from rest_framework.request import Request +from rest_framework.viewsets import ViewSet from authentication.models import User from api.models.student import Student from api.models.assistant import Assistant @@ -37,3 +38,12 @@ 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) + + +class IsSameUser(IsAuthenticated): + def has_permission(self, request, view): + return False + + def has_object_permission(self, request: Request, view: ViewSet, user: User): + """Returns true if the request's user is the same as the given user""" + return super().has_permission(request, view) and user.id == request.user.id diff --git a/backend/api/serializers/assistant_serializer.py b/backend/api/serializers/assistant_serializer.py index 639b184b..c5569924 100644 --- a/backend/api/serializers/assistant_serializer.py +++ b/backend/api/serializers/assistant_serializer.py @@ -27,6 +27,6 @@ class Meta: class AssistantIDSerializer(serializers.Serializer): - assistant_id = serializers.PrimaryKeyRelatedField( + assistant = serializers.PrimaryKeyRelatedField( queryset=Assistant.objects.all() ) diff --git a/backend/api/views/admin_view.py b/backend/api/views/admin_view.py index ad0862d0..a1292c88 100644 --- a/backend/api/views/admin_view.py +++ b/backend/api/views/admin_view.py @@ -1,8 +1,28 @@ -from rest_framework import viewsets -from authentication.serializers import UserSerializer +from django.utils.translation import gettext +from rest_framework.viewsets import ReadOnlyModelViewSet +from rest_framework.response import Response +from rest_framework.request import Request +from rest_framework.permissions import IsAdminUser +from authentication.serializers import UserSerializer, UserIDSerializer from authentication.models import User -class AdminViewSet(viewsets.ReadOnlyModelViewSet): +class AdminViewSet(ReadOnlyModelViewSet): queryset = User.objects.filter(is_staff=True) serializer_class = UserSerializer + permission_classes = [IsAdminUser] + + def create(self, request: Request) -> Response: + """ + Make the provided user admin by setting `is_staff` = true. + """ + serializer = UserIDSerializer( + data=request.data + ) + + if serializer.is_valid(raise_exception=True): + serializer.validated_data["user"].make_admin() + + return Response({ + "message": gettext("admins.success.add") + }) diff --git a/backend/api/views/assistant_view.py b/backend/api/views/assistant_view.py index ea75fc8b..e20bec81 100644 --- a/backend/api/views/assistant_view.py +++ b/backend/api/views/assistant_view.py @@ -1,32 +1,28 @@ -from rest_framework import viewsets, status from rest_framework.decorators import action from rest_framework.response import Response +from rest_framework.viewsets import ReadOnlyModelViewSet +from rest_framework.permissions import IsAdminUser +from api.permissions.assistant_permissions import AssistantPermission from ..models.assistant import Assistant from ..serializers.assistant_serializer import AssistantSerializer from ..serializers.course_serializer import CourseSerializer -class AssistantViewSet(viewsets.ModelViewSet): +class AssistantViewSet(ReadOnlyModelViewSet): + queryset = Assistant.objects.all() serializer_class = AssistantSerializer + permission_classes = [IsAdminUser | AssistantPermission] @action(detail=True, methods=["get"]) - def courses(self, request, pk=None): + def courses(self, request, **_): """Returns a list of courses for the given assistant""" + assistant = self.get_object() + courses = assistant.courses - try: - queryset = Assistant.objects.get(id=pk) - courses = queryset.courses.all() - - # Serialize the course objects - serializer = CourseSerializer( - courses, many=True, context={"request": request} - ) - return Response(serializer.data) + # Serialize the course objects + serializer = CourseSerializer( + courses, many=True, context={"request": request} + ) - except Assistant.DoesNotExist: - # Invalid assistant ID - return Response( - status=status.HTTP_404_NOT_FOUND, - data={"message": "Assistant not found"}, - ) + return Response(serializer.data) diff --git a/backend/api/views/course_view.py b/backend/api/views/course_view.py index 991aeff9..cc16a1f5 100644 --- a/backend/api/views/course_view.py +++ b/backend/api/views/course_view.py @@ -51,7 +51,7 @@ def _add_assistant(self, request: Request, **_): if serializer.is_valid(raise_exception=True): course.assistants.add( - serializer.validated_data["assistant_id"] + serializer.validated_data["assistant"] ) return Response({ @@ -70,7 +70,7 @@ def _remove_assistant(self, request: Request, **_): if serializer.is_valid(raise_exception=True): course.assistants.remove( - serializer.validated_data["assistant_id"] + serializer.validated_data["assistant"] ) return Response({ diff --git a/backend/api/views/group_view.py b/backend/api/views/group_view.py index ef82db0d..98550fdb 100644 --- a/backend/api/views/group_view.py +++ b/backend/api/views/group_view.py @@ -95,8 +95,7 @@ def _remove_student(self, request, **_): @submissions.mapping.post @submissions.mapping.put def _add_submission(self, request: Request, **_): - """Add an submission to the group""" - + """Add a submission to the group""" group: Group = self.get_object() # Add submission to course diff --git a/backend/api/views/project_view.py b/backend/api/views/project_view.py index 08eb61d3..65a9c8ef 100644 --- a/backend/api/views/project_view.py +++ b/backend/api/views/project_view.py @@ -1,14 +1,14 @@ from django.utils.translation import gettext from rest_framework.mixins import CreateModelMixin, RetrieveModelMixin, UpdateModelMixin, DestroyModelMixin from rest_framework.permissions import IsAdminUser -from rest_framework import viewsets +from rest_framework.viewsets import GenericViewSet from rest_framework.decorators import action from rest_framework.response import Response from api.permissions.project_permissions import ProjectGroupPermission, ProjectPermission from api.models.group import Group from api.models.submission import Submission -from ..models.project import Project -from ..serializers.checks_serializer import StructureCheckSerializer, ExtraCheckSerializer +from api.models.project import Project +from api.serializers.checks_serializer import StructureCheckSerializer, ExtraCheckSerializer from api.serializers.project_serializer import ProjectSerializer, TeacherCreateGroupSerializer, SubmissionStatusSerializer from api.serializers.group_serializer import GroupSerializer from api.serializers.submission_serializer import SubmissionSerializer @@ -18,7 +18,7 @@ class ProjectViewSet(CreateModelMixin, RetrieveModelMixin, UpdateModelMixin, DestroyModelMixin, - viewsets.GenericViewSet): + GenericViewSet): queryset = Project.objects.all() serializer_class = ProjectSerializer @@ -39,12 +39,9 @@ def groups(self, request, **_): return Response(serializer.data) - """ - @action(detail=True, permission_classes=[IsAdminUser]) + @action(detail=True) def submissions(self, request, **_): - # Returns a list of subbmisions for the given project - # This automatically fetches the group from the URL. - # It automatically gives back a 404 HTTP response in case of not found. + """Returns a list of submissions for the given project""" project = self.get_object() submissions = project.submissions.all() @@ -54,7 +51,6 @@ def submissions(self, request, **_): ) return Response(serializer.data) - """ @groups.mapping.post def _create_groups(self, request, **_): diff --git a/backend/api/views/student_view.py b/backend/api/views/student_view.py index 4fe6f92c..8517a0b8 100644 --- a/backend/api/views/student_view.py +++ b/backend/api/views/student_view.py @@ -1,52 +1,40 @@ from rest_framework import viewsets, status from rest_framework.decorators import action from rest_framework.response import Response -from ..models.student import Student -from ..serializers.student_serializer import StudentSerializer -from ..serializers.course_serializer import CourseSerializer -from ..serializers.group_serializer import GroupSerializer +from rest_framework.permissions import IsAdminUser +from api.permissions.role_permissions import IsSameUser, IsTeacher +from api.models.student import Student +from api.serializers.student_serializer import StudentSerializer +from api.serializers.course_serializer import CourseSerializer +from api.serializers.group_serializer import GroupSerializer class StudentViewSet(viewsets.ModelViewSet): queryset = Student.objects.all() serializer_class = StudentSerializer + permission_classes = [IsAdminUser | IsTeacher | IsSameUser] - @action(detail=True, methods=["get"]) - def courses(self, request, pk=None): + @action(detail=True) + def courses(self, request, **_): """Returns a list of courses for the given student""" + student = self.get_object() + courses = student.courses.all() - try: - queryset = Student.objects.get(id=pk) - courses = queryset.courses.all() + # Serialize the course objects + serializer = CourseSerializer( + courses, many=True, context={"request": request} + ) - # Serialize the course objects - serializer = CourseSerializer( - courses, many=True, context={"request": request} - ) - return Response(serializer.data) + return Response(serializer.data) - except Student.DoesNotExist: - # Invalid student ID - return Response( - status=status.HTTP_404_NOT_FOUND, data={"message": "Student not found"} - ) - - @action(detail=True, methods=["get"]) - def groups(self, request, pk=None): + @action(detail=True) + def groups(self, request, **_): """Returns a list of groups for the given student""" - - try: - queryset = Student.objects.get(id=pk) - groups = queryset.groups.all() - - # Serialize the group objects - serializer = GroupSerializer( - groups, many=True, context={"request": request} - ) - return Response(serializer.data) - - except Student.DoesNotExist: - # Invalid student ID - return Response( - status=status.HTTP_404_NOT_FOUND, data={"message": "Student not found"} - ) + student = self.get_object() + groups = student.groups.all() + + # Serialize the group objects + serializer = GroupSerializer( + groups, many=True, context={"request": request} + ) + return Response(serializer.data) diff --git a/backend/api/views/teacher_view.py b/backend/api/views/teacher_view.py index 49038133..c16d4167 100644 --- a/backend/api/views/teacher_view.py +++ b/backend/api/views/teacher_view.py @@ -1,31 +1,30 @@ -from rest_framework import viewsets, status +from rest_framework import status from rest_framework.decorators import action from rest_framework.response import Response -from ..models.teacher import Teacher -from ..serializers.teacher_serializer import TeacherSerializer -from ..serializers.course_serializer import CourseSerializer +from rest_framework.viewsets import ReadOnlyModelViewSet +from rest_framework.permissions import IsAdminUser +from api.models.course import Course +from api.models.teacher import Teacher +from api.serializers.teacher_serializer import TeacherSerializer +from api.serializers.course_serializer import CourseSerializer +from api.permissions.role_permissions import IsSameUser -class TeacherViewSet(viewsets.ModelViewSet): + +class TeacherViewSet(ReadOnlyModelViewSet): queryset = Teacher.objects.all() serializer_class = TeacherSerializer + permission_classes = [IsAdminUser | IsSameUser] @action(detail=True, methods=["get"]) def courses(self, request, pk=None): """Returns a list of courses for the given teacher""" + teacher = self.get_object() + courses = teacher.courses.all() - try: - queryset = Teacher.objects.get(id=pk) - courses = queryset.courses.all() - - # Serialize the course objects - serializer = CourseSerializer( - courses, many=True, context={"request": request} - ) - return Response(serializer.data) + # Serialize the course objects + serializer = CourseSerializer( + courses, many=True, context={"request": request} + ) - except Teacher.DoesNotExist: - # Invalid teacher ID - return Response( - status=status.HTTP_404_NOT_FOUND, data={"message": "Teacher not found"} - ) + return Response(serializer.data) diff --git a/backend/api/views/user_view.py b/backend/api/views/user_view.py index 7868eacf..f584f6fe 100644 --- a/backend/api/views/user_view.py +++ b/backend/api/views/user_view.py @@ -1,19 +1,21 @@ from api.permissions.notification_permissions import NotificationPermission +from api.permissions.role_permissions import IsSameUser from authentication.models import User from authentication.serializers import UserSerializer from notifications.models import Notification from notifications.serializers import NotificationSerializer from rest_framework.decorators import action -from rest_framework.mixins import ListModelMixin, RetrieveModelMixin from rest_framework.request import Request from rest_framework.response import Response from rest_framework.status import HTTP_200_OK -from rest_framework.viewsets import GenericViewSet +from rest_framework.viewsets import ReadOnlyModelViewSet +from rest_framework.permissions import IsAdminUser -class UserViewSet(ListModelMixin, RetrieveModelMixin, GenericViewSet): +class UserViewSet(ReadOnlyModelViewSet): queryset = User.objects.all() serializer_class = UserSerializer + permission_classes = [IsAdminUser | IsSameUser] @action(detail=True, methods=["get"], permission_classes=[NotificationPermission]) def notifications(self, request: Request, pk: str): diff --git a/backend/authentication/serializers.py b/backend/authentication/serializers.py index 3005d86f..86c44cf7 100644 --- a/backend/authentication/serializers.py +++ b/backend/authentication/serializers.py @@ -12,6 +12,7 @@ ModelSerializer, Serializer, ValidationError, + PrimaryKeyRelatedField ) from rest_framework_simplejwt.settings import api_settings from rest_framework_simplejwt.tokens import AccessToken, RefreshToken @@ -108,3 +109,9 @@ class Meta: def get_or_create(self, validated_data: dict) -> Tuple[User, bool]: """Create or fetch the user based on the validated data.""" return User.objects.get_or_create(**validated_data) + + +class UserIDSerializer(Serializer): + user = PrimaryKeyRelatedField( + queryset=User.objects.all() + )