From 4c239be2229c510dcaea307a683239ffedb7e9f7 Mon Sep 17 00:00:00 2001 From: Bram Meir Date: Fri, 8 Mar 2024 14:34:54 +0100 Subject: [PATCH 01/15] chore: define group permissions --- backend/api/permissions/group_permissions.py | 36 ++++++++++++++++++++ backend/api/views/group_view.py | 35 +++++++++---------- 2 files changed, 52 insertions(+), 19 deletions(-) create mode 100644 backend/api/permissions/group_permissions.py diff --git a/backend/api/permissions/group_permissions.py b/backend/api/permissions/group_permissions.py new file mode 100644 index 00000000..ff37dcaa --- /dev/null +++ b/backend/api/permissions/group_permissions.py @@ -0,0 +1,36 @@ +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.models.teacher import Teacher +from api.models.assistant import Assistant +from api.models.student import Student + + +class GroupPermission(BasePermission): + + def has_permission(self, request: Request, view: ViewSet) -> bool: + """Check if user has permission to view a general group endpoint.""" + user: User = request.user + + # The general group endpoint is not accessible for any role. + + # We only allow teachers and assistants to create new groups. + return user.teacher.exists() or user.assistant.exists() + + def has_object_permission(self, request: Request, view: ViewSet, group) -> bool: + """Check if user has permission to view a detailed group endpoint""" + user: User = request.user + course = group.course + role: Teacher | Assistant | Student = user.teacher or user.assistant or user.student + + if request.method in SAFE_METHODS: + # Users linked to the course linked to the group can fetch group details. + return role is not None and \ + role.courses.filter(id=course.id).exists() + + # We only allow teachers and assistants to modify specified groups. + role = user.teacher or user.assistant + + return role is not None and \ + role.courses.filter(id=course.id).exists() diff --git a/backend/api/views/group_view.py b/backend/api/views/group_view.py index 0402f198..a4aede83 100644 --- a/backend/api/views/group_view.py +++ b/backend/api/views/group_view.py @@ -1,31 +1,28 @@ -from rest_framework import viewsets, status +from rest_framework import viewsets +from rest_framework.permissions import IsAdminUser from rest_framework.decorators import action from rest_framework.response import Response -from ..models.group import Group -from ..serializers.group_serializer import GroupSerializer -from ..serializers.student_serializer import StudentSerializer +from api.models.group import Group +from api.permissions.group_permissions import GroupPermission +from api.serializers.group_serializer import GroupSerializer +from api.serializers.student_serializer import StudentSerializer class GroupViewSet(viewsets.ModelViewSet): queryset = Group.objects.all() serializer_class = GroupSerializer + permission_classes = [IsAdminUser | GroupPermission] @action(detail=True, methods=["get"]) def students(self, request, pk=None): """Returns a list of students for the given group""" + # This automatically fetches the group from the URL. + # It automatically gives back a 404 HTTP response in case of not found. + group = self.get_object() + students = group.students.all() - try: - queryset = Group.objects.get(id=pk) - students = queryset.students.all() - - # Serialize the student objects - serializer = StudentSerializer( - students, many=True, context={"request": request} - ) - return Response(serializer.data) - - except Group.DoesNotExist: - # Invalid group ID - return Response( - status=status.HTTP_404_NOT_FOUND, data={"message": "Group not found"} - ) + # Serialize the student objects + serializer = StudentSerializer( + students, many=True, context={"request": request} + ) + return Response(serializer.data) From 9b69892d5cb10fc704f7b784dab30202dfba5aae Mon Sep 17 00:00:00 2001 From: Bram Meir Date: Fri, 8 Mar 2024 14:51:35 +0100 Subject: [PATCH 02/15] fix: no access to general group endpoint --- backend/api/permissions/group_permissions.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/backend/api/permissions/group_permissions.py b/backend/api/permissions/group_permissions.py index ff37dcaa..7f4e89f2 100644 --- a/backend/api/permissions/group_permissions.py +++ b/backend/api/permissions/group_permissions.py @@ -14,6 +14,8 @@ def has_permission(self, request: Request, view: ViewSet) -> bool: user: User = request.user # The general group endpoint is not accessible for any role. + if request.method in SAFE_METHODS: + return False # We only allow teachers and assistants to create new groups. return user.teacher.exists() or user.assistant.exists() @@ -30,7 +32,7 @@ def has_object_permission(self, request: Request, view: ViewSet, group) -> bool: role.courses.filter(id=course.id).exists() # We only allow teachers and assistants to modify specified groups. - role = user.teacher or user.assistant + teacher_assistant_role: Teacher | Assistant = user.teacher or user.assistant - return role is not None and \ - role.courses.filter(id=course.id).exists() + return teacher_assistant_role is not None and \ + teacher_assistant_role.courses.filter(id=course.id).exists() From 1e2ab1fad8e19f315ea0d7536b94ada48759d58f Mon Sep 17 00:00:00 2001 From: Bram Meir Date: Fri, 8 Mar 2024 16:56:27 +0100 Subject: [PATCH 03/15] chore: join group for student + /group not accessible --- backend/api/fixtures/groups.yaml | 12 ++++++++ backend/api/fixtures/projects.yaml | 12 ++++++++ backend/api/models/group.py | 4 +++ backend/api/models/student.py | 4 +++ backend/api/permissions/group_permissions.py | 30 +++++++++++++++----- backend/api/views/group_view.py | 26 +++++++++++++++++ 6 files changed, 81 insertions(+), 7 deletions(-) diff --git a/backend/api/fixtures/groups.yaml b/backend/api/fixtures/groups.yaml index 35b2d571..40657f3f 100644 --- a/backend/api/fixtures/groups.yaml +++ b/backend/api/fixtures/groups.yaml @@ -6,3 +6,15 @@ students: - '1' - '2' +- model: api.group + pk: 3 + fields: + project: 123456 + score: 8 + students: [] +- model: api.group + pk: 2 + fields: + project: 1 + score: 8 + students: [] diff --git a/backend/api/fixtures/projects.yaml b/backend/api/fixtures/projects.yaml index 2b7eca2b..6e16ad73 100644 --- a/backend/api/fixtures/projects.yaml +++ b/backend/api/fixtures/projects.yaml @@ -10,3 +10,15 @@ group_size: 3 max_score: 20 course: 2 +- model: api.project + pk: 1 + fields: + name: sel3 + description: make a project + visible: true + archived: false + start_date: 2024-02-26 00:00:00+00:00 + deadline: 2024-02-27 00:00:00+00:00 + group_size: 3 + max_score: 20 + course: 1 diff --git a/backend/api/models/group.py b/backend/api/models/group.py index b963aa89..a03ab0a8 100644 --- a/backend/api/models/group.py +++ b/backend/api/models/group.py @@ -28,3 +28,7 @@ class Group(models.Model): # Score of the group score = models.FloatField(blank=True, null=True) + + def is_full(self) -> bool: + """Check if the group is full.""" + return self.students.count() >= self.project.group_size diff --git a/backend/api/models/student.py b/backend/api/models/student.py index c619d924..fea5cf73 100644 --- a/backend/api/models/student.py +++ b/backend/api/models/student.py @@ -19,3 +19,7 @@ class Student(User): related_name="students", blank=True, ) + + def is_enrolled_in_group(self, project): + """Check if the student is enrolled in a group for the given project.""" + return self.groups.filter(project=project).exists() diff --git a/backend/api/permissions/group_permissions.py b/backend/api/permissions/group_permissions.py index 7f4e89f2..6548d3a3 100644 --- a/backend/api/permissions/group_permissions.py +++ b/backend/api/permissions/group_permissions.py @@ -13,26 +13,42 @@ def has_permission(self, request: Request, view: ViewSet) -> bool: """Check if user has permission to view a general group endpoint.""" user: User = request.user - # The general group endpoint is not accessible for any role. - if request.method in SAFE_METHODS: + # The general group endpoint that lists all groups is not accessible for any role. + if view.action == "list": return False + elif request.method in SAFE_METHODS: + return True # We only allow teachers and assistants to create new groups. - return user.teacher.exists() or user.assistant.exists() + return hasattr(user, "teacher") and user.teacher.exists() or hasattr(user, "assistant") and user.assistant.exists() def has_object_permission(self, request: Request, view: ViewSet, group) -> bool: """Check if user has permission to view a detailed group endpoint""" user: User = request.user - course = group.course - role: Teacher | Assistant | Student = user.teacher or user.assistant or user.student + course = group.project.course + teacher_assistant_role: Teacher | Assistant = hasattr(user, "teacher") and user.teacher or \ + hasattr(user, "assistant") and user.assistant if request.method in SAFE_METHODS: + role: Teacher | Assistant | Student = teacher_assistant_role or hasattr(user, "student") and user.student + # Users linked to the course linked to the group can fetch group details. return role is not None and \ role.courses.filter(id=course.id).exists() # We only allow teachers and assistants to modify specified groups. - teacher_assistant_role: Teacher | Assistant = user.teacher or user.assistant - return teacher_assistant_role is not None and \ teacher_assistant_role.courses.filter(id=course.id).exists() + + +class GroupStudentPermission(BasePermission): + """Permission class for student-only group endpoints""" + + def has_object_permission(self, request: Request, view: ViewSet, group) -> bool: + user: User = request.user + course = group.project.course + student: Student = user.student + + # We only allow students to join groups. + return student is not None and \ + student.courses.filter(id=course.id).exists() diff --git a/backend/api/views/group_view.py b/backend/api/views/group_view.py index a4aede83..1d5b1655 100644 --- a/backend/api/views/group_view.py +++ b/backend/api/views/group_view.py @@ -1,9 +1,13 @@ +from django.utils.translation import gettext +from rest_framework.exceptions import ValidationError from rest_framework import viewsets from rest_framework.permissions import IsAdminUser from rest_framework.decorators import action from rest_framework.response import Response from api.models.group import Group from api.permissions.group_permissions import GroupPermission +from api.permissions.group_permissions import GroupStudentPermission +from api.permissions.role_permissions import IsStudent from api.serializers.group_serializer import GroupSerializer from api.serializers.student_serializer import StudentSerializer @@ -26,3 +30,25 @@ def students(self, request, pk=None): students, many=True, context={"request": request} ) return Response(serializer.data) + + @action(detail=True, methods=["post"], permission_classes=[GroupStudentPermission]) + def join(self, request, pk=None): + """Enrolls the authenticated student in the group""" + group = self.get_object() + student = request.user.student + + # Make sure the group is not full + if group.is_full(): + raise ValidationError(gettext("group.errors.full")) + + # Make sure the student is not already in a group + if student.is_enrolled_in_group(group.project): + raise ValidationError(gettext("group.errors.already_in_group")) + + # Add the student to the group + group.students.add(student) + + return Response({ + "message": gettext("group.success.joined"), + }) + From 25a45ef6a12442550467a16b6c1306a34c68b3e4 Mon Sep 17 00:00:00 2001 From: Bram Meir Date: Fri, 8 Mar 2024 20:36:22 +0100 Subject: [PATCH 04/15] chore: init testing permission logic --- backend/api/permissions/group_permissions.py | 19 ++- backend/api/tests/test_group.py | 160 ++++++++++++++++++- backend/api/views/group_view.py | 69 +++++++- 3 files changed, 237 insertions(+), 11 deletions(-) diff --git a/backend/api/permissions/group_permissions.py b/backend/api/permissions/group_permissions.py index 6548d3a3..bfd74fb8 100644 --- a/backend/api/permissions/group_permissions.py +++ b/backend/api/permissions/group_permissions.py @@ -20,14 +20,15 @@ def has_permission(self, request: Request, view: ViewSet) -> bool: return True # We only allow teachers and assistants to create new groups. - return hasattr(user, "teacher") and user.teacher.exists() or hasattr(user, "assistant") and user.assistant.exists() + return hasattr(user, "teacher") and user.teacher or \ + hasattr(user, "assistant") and user.assistant def has_object_permission(self, request: Request, view: ViewSet, group) -> bool: """Check if user has permission to view a detailed group endpoint""" user: User = request.user course = group.project.course teacher_assistant_role: Teacher | Assistant = hasattr(user, "teacher") and user.teacher or \ - hasattr(user, "assistant") and user.assistant + hasattr(user, "assistant") and user.assistant if request.method in SAFE_METHODS: role: Teacher | Assistant | Student = teacher_assistant_role or hasattr(user, "student") and user.student @@ -52,3 +53,17 @@ def has_object_permission(self, request: Request, view: ViewSet, group) -> bool: # We only allow students to join groups. return student is not None and \ student.courses.filter(id=course.id).exists() + + +class GroupInstructorPermission(BasePermission): + """Permission class for teacher/assistant-only group endpoints""" + + def has_object_permission(self, request: Request, view: ViewSet, group) -> bool: + user: User = request.user + course = group.project.course + role: Teacher | Assistant = hasattr(user, "teacher") and user.teacher or \ + hasattr(user, "assistant") and user.assistant + + # We only allow teachers and assistants to modify specified groups. + return role is not None and \ + role.courses.filter(id=course.id).exists() diff --git a/backend/api/tests/test_group.py b/backend/api/tests/test_group.py index e90e80de..81321714 100644 --- a/backend/api/tests/test_group.py +++ b/backend/api/tests/test_group.py @@ -1,5 +1,6 @@ import json from datetime import timedelta +from django.utils.translation import gettext from django.urls import reverse from django.utils import timezone from rest_framework.test import APITestCase @@ -9,6 +10,7 @@ from api.models.group import Group from api.models.course import Course from django.conf import settings +from api.models.teacher import Teacher def create_course(name, academic_startyear, description=None, parent_course=None): @@ -23,11 +25,11 @@ def create_course(name, academic_startyear, description=None, parent_course=None ) -def create_project(name, description, days, course): +def create_project(name, description, days, course, group_size=2): """Create a Project with the given arguments.""" deadline = timezone.now() + timedelta(days=days) return Project.objects.create( - name=name, description=description, deadline=deadline, course=course + name=name, description=description, deadline=deadline, course=course, group_size=group_size ) @@ -267,3 +269,157 @@ def test_group_students(self): self.assertEqual(content["first_name"], student2.first_name) self.assertEqual(content["last_name"], student2.last_name) self.assertEqual(content["email"], student2.email) + + +class GroupModelTestsAsTeacher(APITestCase): + def setUp(self) -> None: + self.user = Teacher.objects.create( + id="teacher", + first_name="Bobke", + last_name="Peeters", + username="bpeeters", + email="Test@gmail.com" + ) + + self.client.force_authenticate( + self.user + ) + + def test_assign_student_to_group(self): + """Able to assign a student to a group.""" + course = create_course(name="sel2", academic_startyear=2023) + + project = create_project( + name="Project 1", description="Description 1", days=7, course=course + ) + student = create_student( + id=5, first_name="John", last_name="Doe", email="John.Doe@gmail.com" + ) + + # Add this teacher to the course + course.teachers.add(self.user) + + group = create_group(project=project, score=10) + + response = self.client.post( + reverse("group-students", args=[str(group.id)]), + {"student_id": student.id}, + follow=True, + ) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.accepted_media_type, "application/json") + + # Make sure the student is in the group now + self.assertTrue(group.students.filter(id=student.id).exists()) + + def test_remove_student_from_group(self): + """Able to remove a student from a group.""" + course = create_course(name="sel2", academic_startyear=2023) + + project = create_project( + name="Project 1", description="Description 1", days=7, course=course + ) + student = create_student( + id=5, first_name="John", last_name="Doe", email="John.Doe@gmail.com" + ) + + # Add this teacher to the course + course.teachers.add(self.user) + + group = create_group(project=project, score=10) + group.students.add(student) + + response = self.client.delete( + reverse("group-students", args=[str(group.id)]), + {"student_id": student.id}, + follow=True, + ) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.accepted_media_type, "application/json") + + # Make sure the student is not in the group anymore + self.assertFalse(group.students.filter(id=student.id).exists()) + + +class GroupModelTestsAsStudent(APITestCase): + def setUp(self) -> None: + self.user = Student.objects.create( + id="student", + first_name="Bobke", + last_name="Peeters", + username="bpeeters", + email="Bobke.Peeters@gmail.com" + ) + + self.client.force_authenticate( + self.user + ) + + def test_join_group(self): + """Able to join a group as a student.""" + course = create_course(name="sel2", academic_startyear=2023) + project = create_project( + name="Project 1", description="Description 1", days=7, course=course + ) + group = create_group(project=project, score=10) + + # Try to join a group that is part of a course the student is not enrolled in + response = self.client.post( + reverse("group-join", args=[str(group.id)]), + follow=True, + ) + + # Make sure that you can not join a group if you are not enrolled in the course + self.assertEqual(response.status_code, 403) + + # Add the student to the course + course.students.add(self.user) + + # Join the group now that the student is enrolled in the course + response = self.client.post( + reverse("group-join", args=[str(group.id)]), + follow=True, + ) + + self.assertEqual(response.status_code, 200) + + # Make sure the student is in the group now + self.assertTrue(group.students.filter(id=self.user.id).exists()) + + # Try to join a second group + group2 = create_group(project=project, score=10) + + response = self.client.post( + reverse("group-join", args=[str(group2.id)]), + follow=True, + ) + + # Make sure you can only be in one group at a time + self.assertEqual(response.status_code, 400) + # self.assertEqual(content_json, gettext("group.errors.already_in_group")) + + def test_join_full_group(self): + """Able to join a group as a student.""" + course = create_course(name="sel2", academic_startyear=2023) + project = create_project( + name="Project 1", description="Description 1", days=7, course=course, group_size=1 + ) + group = create_group(project=project, score=10) + student = create_student( + id=5, first_name="Bernard", last_name="Doe", email="Bernard.Doe@gmail.com" + ) + group.students.add(student) + + # Add the student to the course + course.students.add(self.user) + + # Join the group + response = self.client.post( + reverse("group-join", args=[str(group.id)]), + follow=True, + ) + + self.assertEqual(response.status_code, 400) + # self.assertEqual(response.data['detail'], gettext("group.errors.full")) diff --git a/backend/api/views/group_view.py b/backend/api/views/group_view.py index 1d5b1655..46f9e00a 100644 --- a/backend/api/views/group_view.py +++ b/backend/api/views/group_view.py @@ -5,9 +5,10 @@ from rest_framework.decorators import action from rest_framework.response import Response from api.models.group import Group +from api.models.student import Student from api.permissions.group_permissions import GroupPermission from api.permissions.group_permissions import GroupStudentPermission -from api.permissions.role_permissions import IsStudent +from api.permissions.group_permissions import GroupInstructorPermission from api.serializers.group_serializer import GroupSerializer from api.serializers.student_serializer import StudentSerializer @@ -18,7 +19,7 @@ class GroupViewSet(viewsets.ModelViewSet): permission_classes = [IsAdminUser | GroupPermission] @action(detail=True, methods=["get"]) - def students(self, request, pk=None): + def students(self, request, **_): """Returns a list of students for the given group""" # This automatically fetches the group from the URL. # It automatically gives back a 404 HTTP response in case of not found. @@ -30,9 +31,64 @@ def students(self, request, pk=None): students, many=True, context={"request": request} ) return Response(serializer.data) - + + @students.mapping.post + @students.mapping.put + @action(detail=True, permission_classes=[GroupInstructorPermission]) + def _assign_student(self, request, **_): + """Assigns a student to the group""" + group = self.get_object() + student_id = request.data.get("student_id") + + try: + student = Student.objects.get(id=student_id) + + # Make sure the group is not full + if group.is_full(): + raise ValidationError(gettext("group.errors.full")) + + # Make sure the student is not already in a group + if student.is_enrolled_in_group(group.project): + raise ValidationError(gettext("group.errors.already_in_group")) + + # Add the student to the group + group.students.add(student) + + return Response({ + "message": gettext("group.success.student.add"), + }) + + except Student.DoesNotExist: + # Student not found + raise ValidationError(gettext("students.errors.404")) + + @students.mapping.delete + @action(detail=True, permission_classes=[GroupInstructorPermission]) + def _remove_student(self, request, **_): + """Removes a student from the group""" + group = self.get_object() + student_id = request.data.get("student_id") + + try: + student = Student.objects.get(id=student_id) + + # Check if student is part of the group + if not group.students.filter(id=student_id).exists(): + raise ValidationError(gettext("group.errors.student_not_in_group")) + + # Remove the student from the group + group.students.remove(student) + + return Response({ + "message": gettext("group.success.student.remove"), + }) + + except Student.DoesNotExist: + # Student not found + raise ValidationError(gettext("students.errors.404")) + @action(detail=True, methods=["post"], permission_classes=[GroupStudentPermission]) - def join(self, request, pk=None): + def join(self, request, **_): """Enrolls the authenticated student in the group""" group = self.get_object() student = request.user.student @@ -40,15 +96,14 @@ def join(self, request, pk=None): # Make sure the group is not full if group.is_full(): raise ValidationError(gettext("group.errors.full")) - + # Make sure the student is not already in a group if student.is_enrolled_in_group(group.project): raise ValidationError(gettext("group.errors.already_in_group")) - + # Add the student to the group group.students.add(student) return Response({ "message": gettext("group.success.joined"), }) - From e6049d0b8473d937bbe291ea246359bd429c6042 Mon Sep 17 00:00:00 2001 From: Bram Meir Date: Sat, 9 Mar 2024 12:27:12 +0100 Subject: [PATCH 05/15] chore: refactor validation strat + testing --- backend/api/permissions/group_permissions.py | 60 +++++------ backend/api/serializers/group_serializer.py | 50 ++++++++- backend/api/tests/test_group.py | 108 +++++++++++++++++-- backend/api/views/group_view.py | 87 +++++---------- 4 files changed, 197 insertions(+), 108 deletions(-) diff --git a/backend/api/permissions/group_permissions.py b/backend/api/permissions/group_permissions.py index bfd74fb8..fb445a93 100644 --- a/backend/api/permissions/group_permissions.py +++ b/backend/api/permissions/group_permissions.py @@ -2,9 +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.models.student import Student +from api.permissions.role_permissions import is_student, is_assistant, is_teacher class GroupPermission(BasePermission): @@ -14,56 +12,46 @@ def has_permission(self, request: Request, view: ViewSet) -> bool: user: User = request.user # The general group endpoint that lists all groups is not accessible for any role. - if view.action == "list": + if request.method in SAFE_METHODS: return False - elif request.method in SAFE_METHODS: - return True # We only allow teachers and assistants to create new groups. - return hasattr(user, "teacher") and user.teacher or \ - hasattr(user, "assistant") and user.assistant + return is_teacher(user) or is_assistant(user) def has_object_permission(self, request: Request, view: ViewSet, group) -> bool: """Check if user has permission to view a detailed group endpoint""" user: User = request.user course = group.project.course - teacher_assistant_role: Teacher | Assistant = hasattr(user, "teacher") and user.teacher or \ - hasattr(user, "assistant") and user.assistant if request.method in SAFE_METHODS: - role: Teacher | Assistant | Student = teacher_assistant_role or hasattr(user, "student") and user.student - - # Users linked to the course linked to the group can fetch group details. - return role is not None and \ - role.courses.filter(id=course.id).exists() + # Users that are linked to the course can view the group. + return is_teacher(user) and user.teacher.courses.filter(id=course.id).exists() or \ + is_assistant(user) and user.assistant.courses.filter(id=course.id).exists() or \ + is_student(user) and user.student.courses.filter(id=course.id).exists() # We only allow teachers and assistants to modify specified groups. - return teacher_assistant_role is not None and \ - teacher_assistant_role.courses.filter(id=course.id).exists() + return is_teacher(user) and user.teacher.courses.filter(id=course.id).exists() or \ + is_assistant(user) and user.assistant.courses.filter(id=course.id).exists() class GroupStudentPermission(BasePermission): - """Permission class for student-only group endpoints""" + """Permission class for student related group endpoints""" def has_object_permission(self, request: Request, view: ViewSet, group) -> bool: user: User = request.user course = group.project.course - student: Student = user.student - - # We only allow students to join groups. - return student is not None and \ - student.courses.filter(id=course.id).exists() - -class GroupInstructorPermission(BasePermission): - """Permission class for teacher/assistant-only group endpoints""" - - def has_object_permission(self, request: Request, view: ViewSet, group) -> bool: - user: User = request.user - course = group.project.course - role: Teacher | Assistant = hasattr(user, "teacher") and user.teacher or \ - hasattr(user, "assistant") and user.assistant - - # We only allow teachers and assistants to modify specified groups. - return role is not None and \ - role.courses.filter(id=course.id).exists() + if request.method in SAFE_METHODS: + # Users related to the course can view the students of the group. + return is_teacher(user) and user.teacher.courses.filter(id=course.id).exists() or \ + is_assistant(user) and user.assistant.courses.filter(id=course.id).exists() or \ + is_student(user) and user.student.courses.filter(id=course.id).exists() + + # Students can only add and remove themselves from a group. + if is_student(user) and request.data.get("student_id") == user.id: + # Make sure the student is actually part of the course. + return user.student.courses.filter(id=course.id).exists() + + # Teachers and assistants can add and remove any student from a group + return is_teacher(user) and user.teacher.courses.filter(id=course.id).exists() or \ + is_assistant(user) and user.assistant.courses.filter(id=course.id).exists() diff --git a/backend/api/serializers/group_serializer.py b/backend/api/serializers/group_serializer.py index d3b0ecfa..2e8f33ed 100644 --- a/backend/api/serializers/group_serializer.py +++ b/backend/api/serializers/group_serializer.py @@ -1,5 +1,9 @@ +from django.utils.translation import gettext from rest_framework import serializers -from ..models.group import Group +from rest_framework.exceptions import ValidationError +from api.models.group import Group +from api.models.student import Student +from api.serializers.student_serializer import StudentIDSerializer class GroupSerializer(serializers.ModelSerializer): @@ -15,3 +19,47 @@ class GroupSerializer(serializers.ModelSerializer): class Meta: model = Group fields = ["id", "project", "students", "score"] + + +class StudentJoinGroupSerializer(StudentIDSerializer): + + def validate(self, data): + # The validator needs the group context. + if "group" not in self.context: + raise ValidationError(gettext("group.error.context")) + + # Get the group and student + group: Group = self.context["group"] + student: Student = data["student_id"] + + # Make sure the group is not already full + if group.is_full(): + raise serializers.ValidationError(gettext("group.errors.full")) + + # Make sure the student is part of the course + if not group.project.course.students.filter(id=student.id).exists(): + raise ValidationError(gettext("group.errors.not_in_course")) + + # Make sure the student is not already in a group + if student.is_enrolled_in_group(group.project): + raise serializers.ValidationError(gettext("group.errors.already_in_group")) + + return data + + +class StudentLeaveGroupSerializer(StudentIDSerializer): + + def validate(self, data): + # The validator needs the group context. + if "group" not in self.context: + raise ValidationError(gettext("group.error.context")) + + # Get the group and student + group: Group = self.context["group"] + student: Student = data["student_id"] + + # Make sure the student was in the group + if not group.students.filter(id=student.id).exists(): + raise ValidationError(gettext("group.errors.not_present")) + + return data diff --git a/backend/api/tests/test_group.py b/backend/api/tests/test_group.py index 81321714..c67ee942 100644 --- a/backend/api/tests/test_group.py +++ b/backend/api/tests/test_group.py @@ -296,8 +296,9 @@ def test_assign_student_to_group(self): id=5, first_name="John", last_name="Doe", email="John.Doe@gmail.com" ) - # Add this teacher to the course + # Add this teacher and student to the course course.teachers.add(self.user) + course.students.add(student) group = create_group(project=project, score=10) @@ -367,7 +368,8 @@ def test_join_group(self): # Try to join a group that is part of a course the student is not enrolled in response = self.client.post( - reverse("group-join", args=[str(group.id)]), + reverse("group-students", args=[str(group.id)]), + {"student_id": self.user.id}, follow=True, ) @@ -379,7 +381,8 @@ def test_join_group(self): # Join the group now that the student is enrolled in the course response = self.client.post( - reverse("group-join", args=[str(group.id)]), + reverse("group-students", args=[str(group.id)]), + {"student_id": self.user.id}, follow=True, ) @@ -392,16 +395,16 @@ def test_join_group(self): group2 = create_group(project=project, score=10) response = self.client.post( - reverse("group-join", args=[str(group2.id)]), + reverse("group-students", args=[str(group2.id)]), + {"student_id": self.user.id}, follow=True, ) # Make sure you can only be in one group at a time self.assertEqual(response.status_code, 400) - # self.assertEqual(content_json, gettext("group.errors.already_in_group")) def test_join_full_group(self): - """Able to join a group as a student.""" + """Not able to join a full group as a student.""" course = create_course(name="sel2", academic_startyear=2023) project = create_project( name="Project 1", description="Description 1", days=7, course=course, group_size=1 @@ -417,9 +420,98 @@ def test_join_full_group(self): # Join the group response = self.client.post( - reverse("group-join", args=[str(group.id)]), + reverse("group-students", args=[str(group.id)]), + {"student_id": self.user.id}, follow=True, ) self.assertEqual(response.status_code, 400) - # self.assertEqual(response.data['detail'], gettext("group.errors.full")) + + def test_leave_group(self): + """Able to leave a group as a student.""" + course = create_course(name="sel2", academic_startyear=2023) + project = create_project( + name="Project 1", description="Description 1", days=7, course=course + ) + group = create_group(project=project, score=10) + + # Add the student to the course + course.students.add(self.user) + + # Join the group + response = self.client.post( + reverse("group-students", args=[str(group.id)]), + {"student_id": self.user.id}, + follow=True, + ) + + self.assertEqual(response.status_code, 200) + + # Make sure the student is in the group now + self.assertTrue(group.students.filter(id=self.user.id).exists()) + + # Leave the group + response = self.client.delete( + reverse("group-students", args=[str(group.id)]), + {"student_id": self.user.id}, + follow=True, + ) + + self.assertEqual(response.status_code, 200) + + # Make sure the student is not in the group anymore + self.assertFalse(group.students.filter(id=self.user.id).exists()) + + def try_to_assign_other_student_to_group(self): + """Not able to assign another student to a group.""" + course = create_course(name="sel2", academic_startyear=2023) + + project = create_project( + name="Project 1", description="Description 1", days=7, course=course + ) + student = create_student( + id=5, first_name="John", last_name="Doe", email="John.Doe@gmail.com" + ) + + # Add this student to the course + course.students.add(student) + course.students.add(self.user) + + group = create_group(project=project, score=10) + + response = self.client.post( + reverse("group-students", args=[str(group.id)]), + {"student_id": student.id}, + follow=True, + ) + + # Make sure that you are not able to assign another student to a group + self.assertEqual(response.status_code, 403) + + def try_to_delete_other_student_from_group(self): + """Not able to remove another student from a group.""" + course = create_course(name="sel2", academic_startyear=2023) + + project = create_project( + name="Project 1", description="Description 1", days=7, course=course + ) + student = create_student( + id=5, first_name="John", last_name="Doe", email="John.Doe@gmail.com" + ) + + # Add this student to the course + course.students.add(student) + course.students.add(self.user) + + group = create_group(project=project, score=10) + group.students.add(student) + group.students.add(self.user) + + response = self.client.delete( + reverse("group-students", args=[str(group.id)]), + {"student_id": student.id}, + follow=True, + ) + + # Make sure that you are not able to remove another student from a group + self.assertEqual(response.status_code, 403) diff --git a/backend/api/views/group_view.py b/backend/api/views/group_view.py index 46f9e00a..0d5c2f47 100644 --- a/backend/api/views/group_view.py +++ b/backend/api/views/group_view.py @@ -1,16 +1,14 @@ from django.utils.translation import gettext -from rest_framework.exceptions import ValidationError from rest_framework import viewsets from rest_framework.permissions import IsAdminUser from rest_framework.decorators import action from rest_framework.response import Response from api.models.group import Group -from api.models.student import Student from api.permissions.group_permissions import GroupPermission from api.permissions.group_permissions import GroupStudentPermission -from api.permissions.group_permissions import GroupInstructorPermission from api.serializers.group_serializer import GroupSerializer from api.serializers.student_serializer import StudentSerializer +from api.serializers.group_serializer import StudentJoinGroupSerializer, StudentLeaveGroupSerializer class GroupViewSet(viewsets.ModelViewSet): @@ -18,7 +16,7 @@ class GroupViewSet(viewsets.ModelViewSet): serializer_class = GroupSerializer permission_classes = [IsAdminUser | GroupPermission] - @action(detail=True, methods=["get"]) + @action(detail=True, methods=["get"], permission_classes=[IsAdminUser | GroupStudentPermission]) def students(self, request, **_): """Returns a list of students for the given group""" # This automatically fetches the group from the URL. @@ -34,76 +32,39 @@ def students(self, request, **_): @students.mapping.post @students.mapping.put - @action(detail=True, permission_classes=[GroupInstructorPermission]) - def _assign_student(self, request, **_): - """Assigns a student to the group""" + def _add_student(self, request, **_): + """Add a student to the group""" group = self.get_object() - student_id = request.data.get("student_id") - try: - student = Student.objects.get(id=student_id) - - # Make sure the group is not full - if group.is_full(): - raise ValidationError(gettext("group.errors.full")) - - # Make sure the student is not already in a group - if student.is_enrolled_in_group(group.project): - raise ValidationError(gettext("group.errors.already_in_group")) - - # Add the student to the group - group.students.add(student) + serializer = StudentJoinGroupSerializer( + data=request.data, context={"group": group} + ) - return Response({ - "message": gettext("group.success.student.add"), - }) + # Validate the serializer + if serializer.is_valid(raise_exception=True): + group.students.add( + serializer.validated_data["student_id"] + ) - except Student.DoesNotExist: - # Student not found - raise ValidationError(gettext("students.errors.404")) + return Response({ + "message": gettext("group.success.student.add"), + }) @students.mapping.delete - @action(detail=True, permission_classes=[GroupInstructorPermission]) def _remove_student(self, request, **_): """Removes a student from the group""" group = self.get_object() - student_id = request.data.get("student_id") - - try: - student = Student.objects.get(id=student_id) - - # Check if student is part of the group - if not group.students.filter(id=student_id).exists(): - raise ValidationError(gettext("group.errors.student_not_in_group")) - # Remove the student from the group - group.students.remove(student) - - return Response({ - "message": gettext("group.success.student.remove"), - }) - - except Student.DoesNotExist: - # Student not found - raise ValidationError(gettext("students.errors.404")) - - @action(detail=True, methods=["post"], permission_classes=[GroupStudentPermission]) - def join(self, request, **_): - """Enrolls the authenticated student in the group""" - group = self.get_object() - student = request.user.student - - # Make sure the group is not full - if group.is_full(): - raise ValidationError(gettext("group.errors.full")) - - # Make sure the student is not already in a group - if student.is_enrolled_in_group(group.project): - raise ValidationError(gettext("group.errors.already_in_group")) + serializer = StudentLeaveGroupSerializer( + data=request.data, context={"group": group} + ) - # Add the student to the group - group.students.add(student) + # Validate the serializer + if serializer.is_valid(raise_exception=True): + group.students.remove( + serializer.validated_data["student_id"] + ) return Response({ - "message": gettext("group.success.joined"), + "message": gettext("group.success.student.remove"), }) From 9b797c893c2f4a5d80222abab7595278e6cb5f89 Mon Sep 17 00:00:00 2001 From: Bram Meir Date: Sat, 9 Mar 2024 12:36:55 +0100 Subject: [PATCH 06/15] chore: test score updates only for teachers --- backend/api/tests/test_group.py | 56 +++++++++++++++++++++++++++++++-- 1 file changed, 54 insertions(+), 2 deletions(-) diff --git a/backend/api/tests/test_group.py b/backend/api/tests/test_group.py index c67ee942..c75bc427 100644 --- a/backend/api/tests/test_group.py +++ b/backend/api/tests/test_group.py @@ -343,6 +343,32 @@ def test_remove_student_from_group(self): # Make sure the student is not in the group anymore self.assertFalse(group.students.filter(id=student.id).exists()) + def test_update_score_of_group(self): + """Able to update the score of a group.""" + course = create_course(name="sel2", academic_startyear=2023) + + project = create_project( + name="Project 1", description="Description 1", days=7, course=course + ) + + # Add this teacher to the course + course.teachers.add(self.user) + + group = create_group(project=project, score=10) + + response = self.client.patch( + reverse("group-detail", args=[str(group.id)]), + {"score": 20}, + follow=True, + ) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.accepted_media_type, "application/json") + + # Make sure the score of the group is updated + group.refresh_from_db() + self.assertEqual(group.score, 20) + class GroupModelTestsAsStudent(APITestCase): def setUp(self) -> None: @@ -462,7 +488,7 @@ def test_leave_group(self): # Make sure the student is not in the group anymore self.assertFalse(group.students.filter(id=self.user.id).exists()) - def try_to_assign_other_student_to_group(self): + def test_try_to_assign_other_student_to_group(self): """Not able to assign another student to a group.""" course = create_course(name="sel2", academic_startyear=2023) @@ -488,7 +514,7 @@ def try_to_assign_other_student_to_group(self): # Make sure that you are not able to assign another student to a group self.assertEqual(response.status_code, 403) - def try_to_delete_other_student_from_group(self): + def test_try_to_delete_other_student_from_group(self): """Not able to remove another student from a group.""" course = create_course(name="sel2", academic_startyear=2023) @@ -515,3 +541,29 @@ def try_to_delete_other_student_from_group(self): # Make sure that you are not able to remove another student from a group self.assertEqual(response.status_code, 403) + + def test_try_to_update_score_of_group(self): + """Not able to update the score of a group.""" + course = create_course(name="sel2", academic_startyear=2023) + + project = create_project( + name="Project 1", description="Description 1", days=7, course=course + ) + + # Add this student to the course + course.students.add(self.user) + + group = create_group(project=project, score=10) + group.students.add(self.user) + + response = self.client.patch( + reverse("group-detail", args=[str(group.id)]), + {"score": 20}, + follow=True, + ) + + # Make sure that you are not able to update the score of a group + self.assertEqual(response.status_code, 403) + + group.refresh_from_db() + self.assertEqual(group.score, 10) From 5c8728df824744651947bb23a527a8a2f7c88322 Mon Sep 17 00:00:00 2001 From: Bram Meir Date: Sat, 9 Mar 2024 16:28:05 +0100 Subject: [PATCH 07/15] chore: make groups as teacher for project --- backend/api/permissions/group_permissions.py | 4 +- .../api/permissions/project_permissions.py | 55 +++++++++++ backend/api/tests/test_project.py | 89 ++++++++++++++++++ backend/api/views/project_view.py | 91 ++++++++++--------- 4 files changed, 195 insertions(+), 44 deletions(-) create mode 100644 backend/api/permissions/project_permissions.py diff --git a/backend/api/permissions/group_permissions.py b/backend/api/permissions/group_permissions.py index fb445a93..67edde69 100644 --- a/backend/api/permissions/group_permissions.py +++ b/backend/api/permissions/group_permissions.py @@ -12,8 +12,10 @@ def has_permission(self, request: Request, view: ViewSet) -> bool: user: User = request.user # The general group endpoint that lists all groups is not accessible for any role. - if request.method in SAFE_METHODS: + if view.action == "list": return False + elif request.method in SAFE_METHODS: + return True # We only allow teachers and assistants to create new groups. return is_teacher(user) or is_assistant(user) diff --git a/backend/api/permissions/project_permissions.py b/backend/api/permissions/project_permissions.py new file mode 100644 index 00000000..654c2ac8 --- /dev/null +++ b/backend/api/permissions/project_permissions.py @@ -0,0 +1,55 @@ +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 + + +class ProjectPermission(BasePermission): + """Permission class for project related endpoints""" + + def has_permission(self, request: Request, view: ViewSet) -> bool: + """Check if user has permission to view a general project endpoint.""" + user: User = request.user + + # The general project endpoint that lists all projects is not accessible for any role. + if view.action == "list": + return False + elif request.method in SAFE_METHODS: + return True + + # We only allow teachers and assistants to create new projects. + return is_teacher(user) or is_assistant(user) + + def has_object_permission(self, request: Request, view: ViewSet, project) -> bool: + """Check if user has permission to view a detailed project endpoint""" + user: User = request.user + course = project.course + + if request.method in SAFE_METHODS: + # Users that are linked to the course can view the project. + return is_teacher(user) and user.teacher.courses.filter(id=course.id).exists() or \ + is_assistant(user) and user.assistant.courses.filter(id=course.id).exists() or \ + is_student(user) and user.student.courses.filter(id=course.id).exists() + + # We only allow teachers and assistants to modify specified projects. + return is_teacher(user) and user.teacher.courses.filter(id=course.id).exists() or \ + is_assistant(user) and user.assistant.courses.filter(id=course.id).exists() + + +class ProjectGroupPermission(BasePermission): + """Permission class for project related group endpoints""" + + def has_object_permission(self, request: Request, view: ViewSet, project) -> bool: + user: User = request.user + course = project.course + + if request.method in SAFE_METHODS: + # Users that are linked to the course can view the group. + return is_teacher(user) and user.teacher.courses.filter(id=course.id).exists() or \ + is_assistant(user) and user.assistant.courses.filter(id=course.id).exists() or \ + is_student(user) and user.student.courses.filter(id=course.id).exists() + + # We only allow teachers and assistants to create new groups. + return is_teacher(user) and user.teacher.courses.filter(id=course.id).exists() or \ + is_assistant(user) and user.assistant.courses.filter(id=course.id).exists() diff --git a/backend/api/tests/test_project.py b/backend/api/tests/test_project.py index a5885712..b7e1058c 100644 --- a/backend/api/tests/test_project.py +++ b/backend/api/tests/test_project.py @@ -5,6 +5,8 @@ from authentication.models import User from api.models.project import Project from api.models.course import Course +from api.models.teacher import Teacher +from api.models.student import Student from api.models.checks import StructureCheck, ExtraCheck from api.models.extension import FileExtension from django.conf import settings @@ -454,3 +456,90 @@ def test_project_extra_checks(self): "project-detail", args=[str(project.id)] )) self.assertEqual(content_json["run_script"], settings.TESTING_BASE_LINK + checks.run_script.url) + + +class ProjectModelTestsAsTeacher(APITestCase): + def setUp(self) -> None: + self.user = Teacher.objects.create( + id="teacher", + first_name="Bobke", + last_name="Peeters", + username="bpeeters", + email="Test@gmail.com" + ) + + self.client.force_authenticate( + self.user + ) + + def test_create_groups(self): + """Able to create groups for a project.""" + course = create_course(id=3, name="test course", academic_startyear=2024) + project = create_project( + name="test", + description="descr", + visible=True, + archived=False, + days=7, + course=course, + ) + + response = self.client.post( + reverse("project-groups", args=[str(project.id)]), + data={"number_groups": 3}, + follow=True, + ) + + # Make sure you can not make groups for a project that is not yours + self.assertEqual(response.status_code, 403) + + # Add the teacher to the course + course.teachers.add(self.user) + + response = self.client.post( + reverse("project-groups", args=[str(project.id)]), + data={"number_groups": 3}, + follow=True, + ) + + self.assertEqual(response.status_code, 200) + + # Assert that the groups were created + self.assertEqual(project.groups.count(), 3) + + +class ProjectModelTestsAsStudent(APITestCase): + def setUp(self) -> None: + self.user = Student.objects.create( + id="student", + first_name="Bobke", + last_name="Peeters", + username="bpeeters", + email="Bobke.Peeters@gmail.com" + ) + + self.client.force_authenticate( + self.user + ) + + def test_try_to_create_groups(self): + """Not able to create groups for a project.""" + course = create_course(id=3, name="test course", academic_startyear=2024) + project = create_project( + name="test", + description="descr", + visible=True, + archived=False, + days=7, + course=course, + ) + course.students.add(self.user) + + response = self.client.post( + reverse("project-groups", args=[str(project.id)]), + data={"number_groups": 3}, + follow=True, + ) + + # Make sure you can not make groups as a student + self.assertEqual(response.status_code, 403) diff --git a/backend/api/views/project_view.py b/backend/api/views/project_view.py index 0b041875..f748be17 100644 --- a/backend/api/views/project_view.py +++ b/backend/api/views/project_view.py @@ -1,8 +1,10 @@ +from django.utils.translation import gettext +from rest_framework.permissions import IsAdminUser from rest_framework import viewsets from rest_framework.decorators import action from rest_framework.response import Response -from rest_framework.exceptions import NotFound -from django.utils.translation import gettext_lazy as _ +from api.permissions.project_permissions import ProjectGroupPermission, ProjectPermission +from api.models.group import Group from ..models.project import Project from ..serializers.project_serializer import ProjectSerializer from ..serializers.group_serializer import GroupSerializer @@ -12,57 +14,60 @@ class ProjectViewSet(viewsets.ModelViewSet): queryset = Project.objects.all() serializer_class = ProjectSerializer + permission_classes = [IsAdminUser | ProjectPermission] # GroupPermission has exact the same logic as for a project - @action(detail=True, methods=["get"]) - def groups(self, request, pk=None): + @action(detail=True, methods=["get"], permission_classes=[IsAdminUser | ProjectGroupPermission]) + def groups(self, request, **_): """Returns a list of groups 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. + project = self.get_object() + groups = project.groups.all() - try: - queryset = Project.objects.get(id=pk) - groups = queryset.groups.all() - - # Serialize the group objects - serializer = GroupSerializer( - groups, many=True, context={"request": request} - ) - return Response(serializer.data) + # Serialize the group objects + serializer = GroupSerializer( + groups, many=True, context={"request": request} + ) - except Project.DoesNotExist: - # Invalid project ID - raise NotFound(_('project.status.not_found')) + return Response(serializer.data) - @action(detail=True, methods=["get"]) - def structure_checks(self, request, pk=None): - """Returns the structure checks for the given project""" + @groups.mapping.post + def _create_groups(self, request, **_): + """Create a number of groups for the project""" + project = self.get_object() - try: - queryset = Project.objects.get(id=pk) - checks = queryset.structure_checks.all() + # Get the number of groups to create + num_groups = int(request.data.get("number_groups", 0)) - # Serialize the check objects - serializer = StructureCheckSerializer( - checks, many=True, context={"request": request} + # Create the groups + for _ in range(max(0, num_groups)): + Group.objects.create( + project=project ) - return Response(serializer.data) - - except Project.DoesNotExist: - # Invalid project ID - raise NotFound(_('project.status.not_found')) + return Response({ + "message": gettext("project.success.groups.created"), + }) @action(detail=True, methods=["get"]) - def extra_checks(self, request, pk=None): - """Returns the extra checks for the given project""" + def structure_checks(self, request, **_): + """Returns the structure checks for the given project""" + project = self.get_object() + checks = project.structure_checks.all() - try: - queryset = Project.objects.get(id=pk) - checks = queryset.extra_checks.all() + # Serialize the check objects + serializer = StructureCheckSerializer( + checks, many=True, context={"request": request} + ) + return Response(serializer.data) - # Serialize the check objects - serializer = ExtraCheckSerializer( - checks, many=True, context={"request": request} - ) - return Response(serializer.data) + @action(detail=True, methods=["get"]) + def extra_checks(self, request, **_): + """Returns the extra checks for the given project""" + project = self.get_object() + checks = project.extra_checks.all() - except Project.DoesNotExist: - # Invalid project ID - raise NotFound(_('project.status.not_found')) + # Serialize the check objects + serializer = ExtraCheckSerializer( + checks, many=True, context={"request": request} + ) + return Response(serializer.data) From 7b7aa158c6a804f99b873196cf969fade039a77a Mon Sep 17 00:00:00 2001 From: Bram Meir Date: Sat, 9 Mar 2024 16:37:07 +0100 Subject: [PATCH 08/15] chore: validate number of groups --- backend/api/serializers/project_serializer.py | 4 ++++ backend/api/views/project_view.py | 23 +++++++++++++------ 2 files changed, 20 insertions(+), 7 deletions(-) diff --git a/backend/api/serializers/project_serializer.py b/backend/api/serializers/project_serializer.py index 5f4ee39f..a906177c 100644 --- a/backend/api/serializers/project_serializer.py +++ b/backend/api/serializers/project_serializer.py @@ -39,3 +39,7 @@ class Meta: "course", "groups" ] + + +class TeacherCreateGroupSerializer(serializers.Serializer): + number_groups = serializers.IntegerField(min_value=1) diff --git a/backend/api/views/project_view.py b/backend/api/views/project_view.py index f748be17..232221da 100644 --- a/backend/api/views/project_view.py +++ b/backend/api/views/project_view.py @@ -9,6 +9,8 @@ from ..serializers.project_serializer import ProjectSerializer from ..serializers.group_serializer import GroupSerializer from ..serializers.checks_serializer import StructureCheckSerializer, ExtraCheckSerializer +from api.serializers.project_serializer import ProjectSerializer, TeacherCreateGroupSerializer +from api.serializers.group_serializer import GroupSerializer class ProjectViewSet(viewsets.ModelViewSet): @@ -36,14 +38,21 @@ def _create_groups(self, request, **_): """Create a number of groups for the project""" project = self.get_object() - # Get the number of groups to create - num_groups = int(request.data.get("number_groups", 0)) + serializer = TeacherCreateGroupSerializer( + data=request.data, context={"project": project} + ) + + # Validate the serializer + if serializer.is_valid(raise_exception=True): + # Get the number of groups to create + num_groups = serializer.validated_data["number_groups"] + + # Create the groups + for _ in range(num_groups): + Group.objects.create( + project=project + ) - # Create the groups - for _ in range(max(0, num_groups)): - Group.objects.create( - project=project - ) return Response({ "message": gettext("project.success.groups.created"), }) From 3a4cd9d2dce65224d631b32e3c607a3d86dc3086 Mon Sep 17 00:00:00 2001 From: Bram Meir Date: Sat, 9 Mar 2024 16:56:27 +0100 Subject: [PATCH 09/15] chore: simplify --- backend/api/permissions/group_permissions.py | 18 ++++++++---------- backend/api/permissions/project_permissions.py | 18 ++++++++---------- 2 files changed, 16 insertions(+), 20 deletions(-) diff --git a/backend/api/permissions/group_permissions.py b/backend/api/permissions/group_permissions.py index 67edde69..b8ee92a8 100644 --- a/backend/api/permissions/group_permissions.py +++ b/backend/api/permissions/group_permissions.py @@ -24,16 +24,15 @@ def has_object_permission(self, request: Request, view: ViewSet, group) -> bool: """Check if user has permission to view a detailed group endpoint""" user: User = request.user course = group.project.course + teacher_or_assitant = is_teacher(user) and user.teacher.courses.filter(id=course.id).exists() or \ + is_assistant(user) and user.assistant.courses.filter(id=course.id).exists() if request.method in SAFE_METHODS: # Users that are linked to the course can view the group. - return is_teacher(user) and user.teacher.courses.filter(id=course.id).exists() or \ - is_assistant(user) and user.assistant.courses.filter(id=course.id).exists() or \ - is_student(user) and user.student.courses.filter(id=course.id).exists() + return teacher_or_assitant or (is_student(user) and user.student.courses.filter(id=course.id).exists()) # We only allow teachers and assistants to modify specified groups. - return is_teacher(user) and user.teacher.courses.filter(id=course.id).exists() or \ - is_assistant(user) and user.assistant.courses.filter(id=course.id).exists() + return teacher_or_assitant class GroupStudentPermission(BasePermission): @@ -42,12 +41,12 @@ class GroupStudentPermission(BasePermission): def has_object_permission(self, request: Request, view: ViewSet, group) -> bool: user: User = request.user course = group.project.course + teacher_or_assitant = is_teacher(user) and user.teacher.courses.filter(id=course.id).exists() or \ + is_assistant(user) and user.assistant.courses.filter(id=course.id).exists() if request.method in SAFE_METHODS: # Users related to the course can view the students of the group. - return is_teacher(user) and user.teacher.courses.filter(id=course.id).exists() or \ - is_assistant(user) and user.assistant.courses.filter(id=course.id).exists() or \ - is_student(user) and user.student.courses.filter(id=course.id).exists() + return teacher_or_assitant or (is_student(user) and user.student.courses.filter(id=course.id).exists()) # Students can only add and remove themselves from a group. if is_student(user) and request.data.get("student_id") == user.id: @@ -55,5 +54,4 @@ def has_object_permission(self, request: Request, view: ViewSet, group) -> bool: return user.student.courses.filter(id=course.id).exists() # Teachers and assistants can add and remove any student from a group - return is_teacher(user) and user.teacher.courses.filter(id=course.id).exists() or \ - is_assistant(user) and user.assistant.courses.filter(id=course.id).exists() + return teacher_or_assitant diff --git a/backend/api/permissions/project_permissions.py b/backend/api/permissions/project_permissions.py index 654c2ac8..99552375 100644 --- a/backend/api/permissions/project_permissions.py +++ b/backend/api/permissions/project_permissions.py @@ -25,16 +25,15 @@ def has_object_permission(self, request: Request, view: ViewSet, project) -> boo """Check if user has permission to view a detailed project endpoint""" user: User = request.user course = project.course + teacher_or_assistant = is_teacher(user) and user.teacher.courses.filter(id=course.id).exists() or \ + is_assistant(user) and user.assistant.courses.filter(id=course.id).exists() if request.method in SAFE_METHODS: # Users that are linked to the course can view the project. - return is_teacher(user) and user.teacher.courses.filter(id=course.id).exists() or \ - is_assistant(user) and user.assistant.courses.filter(id=course.id).exists() or \ - is_student(user) and user.student.courses.filter(id=course.id).exists() + return teacher_or_assistant or (is_student(user) and user.student.courses.filter(id=course.id).exists()) # We only allow teachers and assistants to modify specified projects. - return is_teacher(user) and user.teacher.courses.filter(id=course.id).exists() or \ - is_assistant(user) and user.assistant.courses.filter(id=course.id).exists() + return teacher_or_assistant class ProjectGroupPermission(BasePermission): @@ -43,13 +42,12 @@ class ProjectGroupPermission(BasePermission): def has_object_permission(self, request: Request, view: ViewSet, project) -> bool: user: User = request.user course = project.course + teacher_or_assistant = is_teacher(user) and user.teacher.courses.filter(id=course.id).exists() or \ + is_assistant(user) and user.assistant.courses.filter(id=course.id).exists() if request.method in SAFE_METHODS: # Users that are linked to the course can view the group. - return is_teacher(user) and user.teacher.courses.filter(id=course.id).exists() or \ - is_assistant(user) and user.assistant.courses.filter(id=course.id).exists() or \ - is_student(user) and user.student.courses.filter(id=course.id).exists() + return teacher_or_assistant or (is_student(user) and user.student.courses.filter(id=course.id).exists()) # We only allow teachers and assistants to create new groups. - return is_teacher(user) and user.teacher.courses.filter(id=course.id).exists() or \ - is_assistant(user) and user.assistant.courses.filter(id=course.id).exists() + return teacher_or_assistant From 4b0ab18644cf287e0442ecae813d4256d62b1adc Mon Sep 17 00:00:00 2001 From: Bram Meir Date: Sat, 9 Mar 2024 22:36:27 +0100 Subject: [PATCH 10/15] chore: check score is below max_score project --- backend/api/serializers/group_serializer.py | 13 +++++++++++-- backend/api/tests/test_group.py | 19 ++++++++++++++++--- 2 files changed, 27 insertions(+), 5 deletions(-) diff --git a/backend/api/serializers/group_serializer.py b/backend/api/serializers/group_serializer.py index 2e8f33ed..92847e7f 100644 --- a/backend/api/serializers/group_serializer.py +++ b/backend/api/serializers/group_serializer.py @@ -20,6 +20,15 @@ class Meta: model = Group fields = ["id", "project", "students", "score"] + def validate(self, data): + # Make sure the score of the group is lower or equal to the maximum score + group: Group = self.instance + + if "score" in data and data["score"] > group.project.max_score: + raise ValidationError(gettext("group.errors.score_exceeds_max")) + + return data + class StudentJoinGroupSerializer(StudentIDSerializer): @@ -34,7 +43,7 @@ def validate(self, data): # Make sure the group is not already full if group.is_full(): - raise serializers.ValidationError(gettext("group.errors.full")) + raise ValidationError(gettext("group.errors.full")) # Make sure the student is part of the course if not group.project.course.students.filter(id=student.id).exists(): @@ -42,7 +51,7 @@ def validate(self, data): # Make sure the student is not already in a group if student.is_enrolled_in_group(group.project): - raise serializers.ValidationError(gettext("group.errors.already_in_group")) + raise ValidationError(gettext("group.errors.already_in_group")) return data diff --git a/backend/api/tests/test_group.py b/backend/api/tests/test_group.py index c75bc427..3d9409d0 100644 --- a/backend/api/tests/test_group.py +++ b/backend/api/tests/test_group.py @@ -25,11 +25,11 @@ def create_course(name, academic_startyear, description=None, parent_course=None ) -def create_project(name, description, days, course, group_size=2): +def create_project(name, description, days, course, group_size=2, max_score=20): """Create a Project with the given arguments.""" deadline = timezone.now() + timedelta(days=days) return Project.objects.create( - name=name, description=description, deadline=deadline, course=course, group_size=group_size + name=name, description=description, deadline=deadline, course=course, group_size=group_size, max_score=max_score ) @@ -348,7 +348,7 @@ def test_update_score_of_group(self): course = create_course(name="sel2", academic_startyear=2023) project = create_project( - name="Project 1", description="Description 1", days=7, course=course + name="Project 1", description="Description 1", days=7, course=course, max_score=20 ) # Add this teacher to the course @@ -369,6 +369,19 @@ def test_update_score_of_group(self): group.refresh_from_db() self.assertEqual(group.score, 20) + # Try to update the score of a group to a score higher than the maximum score + response = self.client.patch( + reverse("group-detail", args=[str(group.id)]), + {"score": 30}, + follow=True, + ) + + self.assertEqual(response.status_code, 400) + + # Make sure the score of the group is not updated + group.refresh_from_db() + self.assertEqual(group.score, 20) + class GroupModelTestsAsStudent(APITestCase): def setUp(self) -> None: From b1da49b0bd768b83254b4ed88258410237e1a4a5 Mon Sep 17 00:00:00 2001 From: Bram Meir Date: Sat, 9 Mar 2024 22:37:56 +0100 Subject: [PATCH 11/15] fix: linting --- backend/api/tests/test_group.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/backend/api/tests/test_group.py b/backend/api/tests/test_group.py index 3d9409d0..581cca89 100644 --- a/backend/api/tests/test_group.py +++ b/backend/api/tests/test_group.py @@ -29,7 +29,8 @@ def create_project(name, description, days, course, group_size=2, max_score=20): """Create a Project with the given arguments.""" deadline = timezone.now() + timedelta(days=days) return Project.objects.create( - name=name, description=description, deadline=deadline, course=course, group_size=group_size, max_score=max_score + name=name, description=description, deadline=deadline, course=course, + group_size=group_size, max_score=max_score ) From 1e464dc287a1c43502712a547b674f4fe266354e Mon Sep 17 00:00:00 2001 From: Bram Meir Date: Sat, 9 Mar 2024 22:39:31 +0100 Subject: [PATCH 12/15] fix: linting --- backend/api/tests/test_group.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/api/tests/test_group.py b/backend/api/tests/test_group.py index 581cca89..2aa29126 100644 --- a/backend/api/tests/test_group.py +++ b/backend/api/tests/test_group.py @@ -29,7 +29,7 @@ def create_project(name, description, days, course, group_size=2, max_score=20): """Create a Project with the given arguments.""" deadline = timezone.now() + timedelta(days=days) return Project.objects.create( - name=name, description=description, deadline=deadline, course=course, + name=name, description=description, deadline=deadline, course=course, group_size=group_size, max_score=max_score ) From fd78dbb0ca721280f7dda9867f7f642750c8d83d Mon Sep 17 00:00:00 2001 From: Bram Meir Date: Sat, 9 Mar 2024 23:06:39 +0100 Subject: [PATCH 13/15] chore: no list action for groups and projects --- backend/api/permissions/group_permissions.py | 4 +- .../api/permissions/project_permissions.py | 4 +- backend/api/tests/test_project.py | 86 ++++--------------- backend/api/views/group_view.py | 10 ++- backend/api/views/project_view.py | 8 +- 5 files changed, 36 insertions(+), 76 deletions(-) diff --git a/backend/api/permissions/group_permissions.py b/backend/api/permissions/group_permissions.py index b8ee92a8..64eea5b1 100644 --- a/backend/api/permissions/group_permissions.py +++ b/backend/api/permissions/group_permissions.py @@ -12,9 +12,7 @@ def has_permission(self, request: Request, view: ViewSet) -> bool: user: User = request.user # The general group endpoint that lists all groups is not accessible for any role. - if view.action == "list": - return False - elif request.method in SAFE_METHODS: + if request.method in SAFE_METHODS: return True # We only allow teachers and assistants to create new groups. diff --git a/backend/api/permissions/project_permissions.py b/backend/api/permissions/project_permissions.py index 99552375..aca5aee1 100644 --- a/backend/api/permissions/project_permissions.py +++ b/backend/api/permissions/project_permissions.py @@ -13,9 +13,7 @@ def has_permission(self, request: Request, view: ViewSet) -> bool: user: User = request.user # The general project endpoint that lists all projects is not accessible for any role. - if view.action == "list": - return False - elif request.method in SAFE_METHODS: + if request.method in SAFE_METHODS: return True # We only allow teachers and assistants to create new projects. diff --git a/backend/api/tests/test_project.py b/backend/api/tests/test_project.py index b7e1058c..b698a5e4 100644 --- a/backend/api/tests/test_project.py +++ b/backend/api/tests/test_project.py @@ -181,14 +181,6 @@ def test_deadline_passed_with_past_Project(self): ) self.assertIs(past_project.deadline_passed(), True) - def test_no_projects(self): - """Able to retrieve no projects before creating any.""" - response = self.client.get(reverse("group-list"), follow=True) - self.assertEqual(response.status_code, 200) - self.assertEqual(response.accepted_media_type, "application/json") - content_json = json.loads(response.content.decode("utf-8")) - self.assertEqual(content_json, []) - def test_project_exists(self): """ Able to retrieve a single project after creating it. @@ -204,60 +196,17 @@ def test_project_exists(self): course=course, ) - response = self.client.get(reverse("project-list"), follow=True) - - self.assertEqual(response.status_code, 200) - self.assertEqual(response.accepted_media_type, "application/json") - - content_json = json.loads(response.content.decode("utf-8")) - - self.assertEqual(len(content_json), 1) - - retrieved_project = content_json[0] - - expected_course_url = settings.TESTING_BASE_LINK + reverse( - "course-detail", args=[str(course.id)] - ) - - self.assertEqual(retrieved_project["name"], project.name) - self.assertEqual(retrieved_project["description"], project.description) - self.assertEqual(retrieved_project["visible"], project.visible) - self.assertEqual(retrieved_project["archived"], project.archived) - self.assertEqual(retrieved_project["course"], expected_course_url) - - def test_multiple_project(self): - """ - Able to retrieve multiple projects after creating it. - """ - course = create_course(id=3, name="test course", academic_startyear=2024) - project = create_project( - name="test project", - description="test description", - visible=True, - archived=False, - days=7, - course=course, - ) - - project2 = create_project( - name="test project2", - description="test description2", - visible=True, - archived=False, - days=7, - course=course, + response = self.client.get( + reverse("project-detail", args=[str(project.id)]), + follow=True ) - response = self.client.get(reverse("project-list"), follow=True) - self.assertEqual(response.status_code, 200) self.assertEqual(response.accepted_media_type, "application/json") content_json = json.loads(response.content.decode("utf-8")) - self.assertEqual(len(content_json), 2) - - retrieved_project = content_json[0] + retrieved_project = content_json expected_course_url = settings.TESTING_BASE_LINK + reverse( "course-detail", args=[str(course.id)] @@ -296,16 +245,17 @@ def test_project_course(self): course=course, ) - response = self.client.get(reverse("project-list"), follow=True) + response = self.client.get( + reverse("project-detail", args=[str(project.id)]), + follow=True + ) self.assertEqual(response.status_code, 200) self.assertEqual(response.accepted_media_type, "application/json") content_json = json.loads(response.content.decode("utf-8")) - self.assertEqual(len(content_json), 1) - - retrieved_project = content_json[0] + retrieved_project = content_json self.assertEqual(retrieved_project["name"], project.name) self.assertEqual(retrieved_project["description"], project.description) @@ -353,16 +303,17 @@ def test_project_structure_checks(self): blocked_extensions=[fileExtension2, fileExtension3], ) - response = self.client.get(reverse("project-list"), follow=True) + response = self.client.get( + reverse("project-detail", args=[str(project.id)]), + follow=True + ) self.assertEqual(response.status_code, 200) self.assertEqual(response.accepted_media_type, "application/json") content_json = json.loads(response.content.decode("utf-8")) - self.assertEqual(len(content_json), 1) - - retrieved_project = content_json[0] + retrieved_project = content_json expected_course_url = settings.TESTING_BASE_LINK + reverse( "course-detail", args=[str(course.id)] @@ -429,16 +380,17 @@ def test_project_extra_checks(self): run_script="testscript.sh", ) - response = self.client.get(reverse("project-list"), follow=True) + response = self.client.get( + reverse("project-detail", args=[str(project.id)]), + follow=True + ) self.assertEqual(response.status_code, 200) self.assertEqual(response.accepted_media_type, "application/json") content_json = json.loads(response.content.decode("utf-8")) - self.assertEqual(len(content_json), 1) - - retrieved_project = content_json[0] + retrieved_project = content_json response = self.client.get(retrieved_project["extra_checks"], follow=True) diff --git a/backend/api/views/group_view.py b/backend/api/views/group_view.py index 0d5c2f47..7c86b3db 100644 --- a/backend/api/views/group_view.py +++ b/backend/api/views/group_view.py @@ -1,5 +1,6 @@ from django.utils.translation import gettext -from rest_framework import viewsets +from rest_framework.mixins import CreateModelMixin, RetrieveModelMixin, UpdateModelMixin, DestroyModelMixin +from rest_framework.viewsets import GenericViewSet from rest_framework.permissions import IsAdminUser from rest_framework.decorators import action from rest_framework.response import Response @@ -11,7 +12,12 @@ from api.serializers.group_serializer import StudentJoinGroupSerializer, StudentLeaveGroupSerializer -class GroupViewSet(viewsets.ModelViewSet): +class GroupViewSet(CreateModelMixin, + RetrieveModelMixin, + UpdateModelMixin, + DestroyModelMixin, + GenericViewSet): + queryset = Group.objects.all() serializer_class = GroupSerializer permission_classes = [IsAdminUser | GroupPermission] diff --git a/backend/api/views/project_view.py b/backend/api/views/project_view.py index 232221da..b7a36db4 100644 --- a/backend/api/views/project_view.py +++ b/backend/api/views/project_view.py @@ -1,4 +1,5 @@ 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.decorators import action @@ -13,7 +14,12 @@ from api.serializers.group_serializer import GroupSerializer -class ProjectViewSet(viewsets.ModelViewSet): +class ProjectViewSet(CreateModelMixin, + RetrieveModelMixin, + UpdateModelMixin, + DestroyModelMixin, + viewsets.GenericViewSet): + queryset = Project.objects.all() serializer_class = ProjectSerializer permission_classes = [IsAdminUser | ProjectPermission] # GroupPermission has exact the same logic as for a project From 6b21dcda7b907cb9d683f255c7170a5a7df28a3d Mon Sep 17 00:00:00 2001 From: EwoutV Date: Sat, 9 Mar 2024 23:44:34 +0100 Subject: [PATCH 14/15] chore: rebase --- backend/api/tests/test_group.py | 85 ------------------------------- backend/api/tests/test_project.py | 12 ----- 2 files changed, 97 deletions(-) diff --git a/backend/api/tests/test_group.py b/backend/api/tests/test_group.py index 2aa29126..16ffa2df 100644 --- a/backend/api/tests/test_group.py +++ b/backend/api/tests/test_group.py @@ -1,6 +1,5 @@ import json from datetime import timedelta -from django.utils.translation import gettext from django.urls import reverse from django.utils import timezone from rest_framework.test import APITestCase @@ -57,90 +56,6 @@ def setUp(self) -> None: User.get_dummy_admin() ) - def test_no_groups(self): - """Able to retrieve no groups before creating any.""" - response = self.client.get(reverse("group-list"), follow=True) - self.assertEqual(response.status_code, 200) - self.assertEqual(response.accepted_media_type, "application/json") - content_json = json.loads(response.content.decode("utf-8")) - self.assertEqual(content_json, []) - - def test_group_exists(self): - """Able to retrieve a single group after creating it.""" - course = create_course(name="sel2", academic_startyear=2023) - - project = create_project( - name="Project 1", description="Description 1", days=7, course=course - ) - - student = create_student( - id=1, first_name="John", last_name="Doe", email="john.doe@example.com" - ) - - group = create_group(project=project, score=10) - group.students.add(student) - - response = self.client.get(reverse("group-list"), follow=True) - self.assertEqual(response.status_code, 200) - self.assertEqual(response.accepted_media_type, "application/json") - content_json = json.loads(response.content.decode("utf-8")) - self.assertEqual(len(content_json), 1) - - retrieved_group = content_json[0] - expected_project_url = settings.TESTING_BASE_LINK + reverse( - "project-detail", args=[str(project.id)] - ) - - self.assertEqual(retrieved_group["project"], expected_project_url) - self.assertEqual(int(retrieved_group["id"]), group.id) - self.assertEqual(retrieved_group["score"], group.score) - - def test_multiple_groups(self): - """Able to retrieve multiple groups after creating them.""" - course = create_course(name="sel2", academic_startyear=2023) - - project1 = create_project( - name="Project 1", description="Description 1", days=7, course=course - ) - project2 = create_project( - name="Project 2", description="Description 2", days=7, course=course - ) - - student1 = create_student( - id=2, first_name="Bart", last_name="Rex", email="bart.rex@example.com" - ) - student2 = create_student( - id=3, first_name="Jane", last_name="Doe", email="jane.doe@example.com" - ) - - group1 = create_group(project=project1, score=10) - group1.students.add(student1) - - group2 = create_group(project=project2, score=10) - group2.students.add(student1, student2) - - response = self.client.get(reverse("group-list"), follow=True) - self.assertEqual(response.status_code, 200) - self.assertEqual(response.accepted_media_type, "application/json") - content_json = json.loads(response.content.decode("utf-8")) - self.assertEqual(len(content_json), 2) - - retrieved_group1, retrieved_group2 = content_json - expected_project_url1 = settings.TESTING_BASE_LINK + reverse( - "project-detail", args=[str(project1.id)] - ) - expected_project_url2 = settings.TESTING_BASE_LINK + reverse( - "project-detail", args=[str(project2.id)] - ) - - self.assertEqual(retrieved_group1["project"], expected_project_url1) - self.assertEqual(int(retrieved_group1["id"]), group1.id) - self.assertEqual(retrieved_group1["score"], group1.score) - - self.assertEqual(retrieved_group2["project"], expected_project_url2) - self.assertEqual(int(retrieved_group2["id"]), group2.id) - self.assertEqual(retrieved_group2["score"], group2.score) - def test_group_detail_view(self): """Able to retrieve details of a single group.""" course = create_course(name="sel2", academic_startyear=2023) diff --git a/backend/api/tests/test_project.py b/backend/api/tests/test_project.py index b698a5e4..27b8fb79 100644 --- a/backend/api/tests/test_project.py +++ b/backend/api/tests/test_project.py @@ -218,18 +218,6 @@ def test_project_exists(self): self.assertEqual(retrieved_project["archived"], project.archived) self.assertEqual(retrieved_project["course"], expected_course_url) - retrieved_project = content_json[1] - - expected_course_url = settings.TESTING_BASE_LINK + reverse( - "course-detail", args=[str(course.id)] - ) - - self.assertEqual(retrieved_project["name"], project2.name) - self.assertEqual(retrieved_project["description"], project2.description) - self.assertEqual(retrieved_project["visible"], project2.visible) - self.assertEqual(retrieved_project["archived"], project2.archived) - self.assertEqual(retrieved_project["course"], expected_course_url) - def test_project_course(self): """ Able to retrieve a course of a project after creating it. From b4510355876f22209d980f5c41927dd08fdc8a5b Mon Sep 17 00:00:00 2001 From: EwoutV Date: Sat, 9 Mar 2024 23:45:18 +0100 Subject: [PATCH 15/15] chore: linting --- backend/api/views/project_view.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/backend/api/views/project_view.py b/backend/api/views/project_view.py index b7a36db4..761496f1 100644 --- a/backend/api/views/project_view.py +++ b/backend/api/views/project_view.py @@ -7,8 +7,6 @@ from api.permissions.project_permissions import ProjectGroupPermission, ProjectPermission from api.models.group import Group from ..models.project import Project -from ..serializers.project_serializer import ProjectSerializer -from ..serializers.group_serializer import GroupSerializer from ..serializers.checks_serializer import StructureCheckSerializer, ExtraCheckSerializer from api.serializers.project_serializer import ProjectSerializer, TeacherCreateGroupSerializer from api.serializers.group_serializer import GroupSerializer