diff --git a/backend/api/permissions/course_permissions.py b/backend/api/permissions/course_permissions.py index a6c5ef19..4a87fca6 100644 --- a/backend/api/permissions/course_permissions.py +++ b/backend/api/permissions/course_permissions.py @@ -65,19 +65,3 @@ def has_object_permission(self, request: Request, view: ViewSet, course: Course) # Teachers and assistants can add and remove any student. return super().has_object_permission(request, view, course) - - -class CourseProjectPermission(CoursePermission): - """Permission class for project related endpoints.""" - def has_permission(self, request: Request, view: ViewSet) -> bool: - return request.user and request.user.is_authenticated - - def has_object_permission(self, request: Request, view: ViewSet, course: Course): - user: User = request.user - - # Logged-in users can fetch course projects. - if request.method in SAFE_METHODS: - return user.is_authenticated - - # Teachers and assistants can modify projects. - return super().has_object_permission(request, view, course) diff --git a/backend/api/serializers/project_serializer.py b/backend/api/serializers/project_serializer.py index 47af0ed5..d2ade098 100644 --- a/backend/api/serializers/project_serializer.py +++ b/backend/api/serializers/project_serializer.py @@ -4,11 +4,9 @@ from api.models.group import Group from rest_framework.exceptions import ValidationError from django.utils import timezone -from api.models.submission import Submission, SubmissionFile -from api.models.checks import FileExtension, StructureCheck +from api.models.checks import FileExtension from api.serializers.submission_serializer import SubmissionSerializer from api.serializers.checks_serializer import StructureCheckSerializer -from rest_framework.request import Request class ProjectSerializer(serializers.ModelSerializer): @@ -33,6 +31,11 @@ class ProjectSerializer(serializers.ModelSerializer): read_only=True ) + submissions = serializers.HyperlinkedIdentityField( + view_name="project-submissions", + read_only=True + ) + class Meta: model = Project fields = [ @@ -49,7 +52,8 @@ class Meta: "structure_checks", "extra_checks", "course", - "groups" + "groups", + "submissions" ] def validate(self, data): diff --git a/backend/api/tests/test_assistant.py b/backend/api/tests/test_assistant.py index 81332915..b59ab048 100644 --- a/backend/api/tests/test_assistant.py +++ b/backend/api/tests/test_assistant.py @@ -3,6 +3,7 @@ from django.urls import reverse from rest_framework.test import APITestCase from api.models.assistant import Assistant +from api.models.teacher import Teacher from api.models.course import Course from authentication.models import Faculty, User @@ -287,3 +288,44 @@ def test_assistant_courses(self): self.assertEqual(content["name"], course2.name) self.assertEqual(int(content["academic_startyear"]), course2.academic_startyear) self.assertEqual(content["description"], course2.description) + + +class AssitantModelAsTeacherTests(APITestCase): + def setUp(self) -> None: + self.user = Teacher.objects.create( + id=1, + first_name="John", + last_name="Doe", + username="john_doe", + email="John.Doe@gmail.com" + ) + + self.client.force_authenticate(self.user) + + def test_retrieve_assistant_list(self): + """ + Able to retrieve assistant list as a teacher. + """ + # Create an assistant for testing with the name "Bob Peeters" + create_assistant( + id=5, first_name="Bob", last_name="Peeters", email="Bob.Peeters@gmail.com" + ) + + create_assistant( + id=6, first_name="Jane", last_name="Doe", email="Jane.Doe@gmail.com" + ) + + # Make a GET request to retrieve the assistant details + response = self.client.get(reverse("assistant-list"), follow=True) + + # Check if the response was successful + self.assertEqual(response.status_code, 200) + + # Assert that the response is JSON + self.assertEqual(response.accepted_media_type, "application/json") + + # Parse the JSON content from the response + content_json = json.loads(response.content.decode("utf-8")) + + # Assert that the parsed JSON is a list with multiple assistant + self.assertEqual(len(content_json), 2) diff --git a/backend/api/tests/test_course.py b/backend/api/tests/test_course.py index a2c3d165..ce6919c1 100644 --- a/backend/api/tests/test_course.py +++ b/backend/api/tests/test_course.py @@ -84,6 +84,27 @@ def create_course(name, academic_startyear, description=None, parent_course=None ) +def get_course(): + """ + Return a random course to use in tests. + """ + return create_course(name="Chemistry 101", academic_startyear=2023, description="An introductory chemistry course.") + + +def get_assistant(): + """ + Return a random assistant to use in tests. + """ + return create_assistant(id=5, first_name="Simon", last_name="Mignolet", email="Simon.Mignolet@gmail.com") + + +def get_student(): + """ + Return a random student to use in tests. + """ + return create_student(id=5, first_name="Simon", last_name="Mignolet", email="Simon.Mignolet@gmai.com") + + class CourseModelTests(APITestCase): def setUp(self) -> None: self.client.force_authenticate( @@ -440,3 +461,376 @@ def test_course_project(self): self.assertEqual(content["description"], project2.description) self.assertEqual(content["visible"], project2.visible) self.assertEqual(content["archived"], project2.archived) + + +class CourseModelTestsAsStudent(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_add_assistant(self): + """ + Students should not be able to add assistants. + """ + course = get_course() + course.students.add(self.user) + + assistant = get_assistant() + + response = self.client.post( + reverse("course-assistants", args=[str(course.id)]), + data={"assistant": assistant.id}, + follow=True, + ) + + self.assertEqual(response.status_code, 403) + + self.assertFalse(course.assistants.filter(id=assistant.id).exists()) + + def test_try_remove_assistant(self): + """ + Students should not be able to remove assistants. + """ + course = get_course() + course.students.add(self.user) + + assistant = get_assistant() + + course.assistants.add(assistant) + + response = self.client.delete( + reverse("course-assistants", args=[str(course.id)]), + data={"assistant": assistant.id}, + follow=True, + ) + + self.assertEqual(response.status_code, 403) + + self.assertTrue(course.assistants.filter(id=assistant.id).exists()) + + def test_add_self_to_course(self): + """ + Able to add self to a course. + """ + course = get_course() + + response = self.client.post( + reverse("course-students", args=[str(course.id)]), + data={"student_id": self.user.id}, + follow=True, + ) + + self.assertEqual(response.status_code, 200) + self.assertTrue(course.students.filter(id=self.user.id).exists()) + + def test_remove_self_from_course(self): + """ + Able to remove self from a course. + """ + course = get_course() + course.students.add(self.user) + + response = self.client.delete( + reverse("course-students", args=[str(course.id)]), + data={"student_id": self.user.id}, + follow=True, + ) + + self.assertEqual(response.status_code, 200) + self.assertFalse(course.students.filter(id=self.user.id).exists()) + + def test_try_add_other_student_to_course(self): + """ + Students should not be able to add other students to a course. + """ + course = get_course() + course.students.add(self.user) + + other_student = get_student() + + response = self.client.post( + reverse("course-students", args=[str(course.id)]), + data={"student_id": other_student.id}, + follow=True, + ) + + self.assertEqual(response.status_code, 403) + + self.assertFalse(course.students.filter(id=other_student.id).exists()) + + def test_try_remove_other_student_from_course(self): + """ + Students should not be able to remove other students from a course. + """ + course = get_course() + course.students.add(self.user) + + other_student = get_student() + + course.students.add(other_student) + + response = self.client.delete( + reverse("course-students", args=[str(course.id)]), + data={"student_id": other_student.id}, + follow=True, + ) + + self.assertEqual(response.status_code, 403) + + self.assertTrue(course.students.filter(id=other_student.id).exists()) + + def test_try_create_course(self): + """ + Students should not be able to create a course. + """ + response = self.client.post( + reverse("course-list"), + data={ + "name": "Introduction to Computer Science", + "academic_startyear": 2022, + "description": "An introductory course on computer science.", + }, + follow=True, + ) + + self.assertEqual(response.status_code, 403) + + self.assertFalse( + Course.objects.filter(name="Introduction to Computer Science").exists() + ) + + def test_try_create_project(self): + """ + Students should not be able to create a project. + """ + course = get_course() + course.students.add(self.user) + + response = self.client.post( + reverse("course-projects", args=[str(course.id)]), + data={ + "name": "become champions", + "description": "win the jpl", + "visible": True, + "archived": False, + "days": 50, + "deadline": timezone.now() + timezone.timedelta(days=50), + "start_date": timezone.now() + }, + follow=True, + ) + + self.assertEqual(response.status_code, 403) + + self.assertFalse(course.projects.filter(name="become champions").exists()) + + def test_try_join_old_year_course(self): + """ + Students should not be able to join a course from a previous year. + """ + course = get_course() + course.academic_startyear = 2020 + course.save() + + response = self.client.post( + reverse("course-students", args=[str(course.id)]), + data={"student_id": self.user.id}, + follow=True, + ) + + self.assertEqual(response.status_code, 400) + + self.assertFalse(course.students.filter(id=self.user.id).exists()) + + def test_try_leave_old_year_course(self): + """ + Students should not be able to leave a course from a previous year. + """ + course = get_course() + course.academic_startyear = 2020 + course.save() + + course.students.add(self.user) + + response = self.client.delete( + reverse("course-students", args=[str(course.id)]), + data={"student_id": self.user.id}, + follow=True, + ) + + self.assertEqual(response.status_code, 400) + + self.assertTrue(course.students.filter(id=self.user.id).exists()) + + def test_try_leave_course_not_part_of(self): + """ + Students should not be able to leave a course they are not part of. + """ + course = get_course() + + response = self.client.delete( + reverse("course-students", args=[str(course.id)]), + data={"student_id": self.user.id}, + follow=True, + ) + + self.assertEqual(response.status_code, 400) + + self.assertFalse(course.students.filter(id=self.user.id).exists()) + + +class CourseModelTestsAsTeacher(APITestCase): + def setUp(self) -> None: + self.user = Teacher.objects.create( + id="teacher", + first_name="Bobke", + last_name="Peeters", + username="bpeeters", + email="Bobke.Peeters@gmail.com" + ) + + self.client.force_authenticate( + self.user + ) + + def test_add_assistant(self): + """ + Able to add an assistant to a course. + """ + course = get_course() + course.teachers.add(self.user) + + assistant = get_assistant() + + response = self.client.post( + reverse("course-assistants", args=[str(course.id)]), + data={"assistant": assistant.id}, + follow=True, + ) + + self.assertEqual(response.status_code, 200) + self.assertTrue(course.assistants.filter(id=assistant.id).exists()) + + def test_remove_assistant(self): + """ + Able to remove an assistant from a course. + """ + course = get_course() + course.teachers.add(self.user) + + assistant = get_assistant() + + course.assistants.add(assistant) + + response = self.client.delete( + reverse("course-assistants", args=[str(course.id)]), + data={"assistant": assistant.id}, + follow=True, + ) + + self.assertEqual(response.status_code, 200) + self.assertFalse(course.assistants.filter(id=assistant.id).exists()) + + def test_add_student(self): + """ + Able to add a student to a course. + """ + course = get_course() + course.teachers.add(self.user) + + student = get_student() + + response = self.client.post( + reverse("course-students", args=[str(course.id)]), + data={"student_id": student.id}, + follow=True, + ) + + self.assertEqual(response.status_code, 200) + self.assertTrue(course.students.filter(id=student.id).exists()) + + def test_remove_student(self): + """ + Able to remove a student from a course. + """ + course = get_course() + course.teachers.add(self.user) + + student = get_student() + + course.students.add(student) + + response = self.client.delete( + reverse("course-students", args=[str(course.id)]), + data={"student_id": student.id}, + follow=True, + ) + + self.assertEqual(response.status_code, 200) + self.assertFalse(course.students.filter(id=student.id).exists()) + + def test_create_course(self): + """ + Able to create a course. + """ + response = self.client.post( + reverse("course-list"), + data={ + "name": "Introduction to Computer Science", + "academic_startyear": 2022, + "description": "An introductory course on computer science.", + }, + follow=True, + ) + + self.assertEqual(response.status_code, 201) + self.assertTrue(Course.objects.filter(name="Introduction to Computer Science").exists()) + + def test_create_project(self): + """ + Able to create a project for a course. + """ + course = get_course() + course.teachers.add(self.user) + + response = self.client.post( + reverse("course-projects", args=[str(course.id)]), + data={ + "name": "become champions", + "description": "win the jpl", + "visible": True, + "archived": False, + "days": 50, + "deadline": timezone.now() + timezone.timedelta(days=50), + "start_date": timezone.now() + }, + follow=True, + ) + + self.assertEqual(response.status_code, 200) + self.assertTrue(course.projects.filter(name="become champions").exists()) + + def test_clone_course(self): + """ + Able to clone a course. + """ + course = get_course() + course.teachers.add(self.user) + + response = self.client.post( + reverse("course-clone", args=[str(course.id)]), + follow=True, + ) + + self.assertEqual(response.status_code, 200) + self.assertTrue(Course.objects.filter(name=course.name, + academic_startyear=course.academic_startyear + 1).exists()) diff --git a/backend/api/tests/test_group.py b/backend/api/tests/test_group.py index 63017054..12ab152b 100644 --- a/backend/api/tests/test_group.py +++ b/backend/api/tests/test_group.py @@ -417,6 +417,51 @@ 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 test_try_leave_locked_group(self): + """Not able to leave a locked 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) + project.locked_groups = True + project.save() + + # Add the student to the course + course.students.add(self.user) + group.students.add(self.user) + + # Try to leave the group + response = self.client.delete( + reverse("group-students", args=[str(group.id)]), + {"student_id": self.user.id}, + follow=True, + ) + + # Make sure that you are not able to leave a locked group + self.assertEqual(response.status_code, 400) + + def test_try_leave_group_not_part_of(self): + """Not able to leave a group you are not part of 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) + + # Try to leave the group + response = self.client.delete( + reverse("group-students", args=[str(group.id)]), + {"student_id": self.user.id}, + follow=True, + ) + + # Make sure that you are not able to leave a group you are not part of + self.assertEqual(response.status_code, 400) + 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) diff --git a/backend/api/tests/test_project.py b/backend/api/tests/test_project.py index df356c94..c08968d7 100644 --- a/backend/api/tests/test_project.py +++ b/backend/api/tests/test_project.py @@ -672,6 +672,71 @@ def test_project_extra_checks(self): settings.TESTING_BASE_LINK + checks.run_script.url, ) + def test_project_groups(self): + """ + Able to retrieve a list of groups of a project 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, + ) + + group1 = create_group(project=project) + group2 = create_group(project=project) + + response = self.client.get( + reverse("project-groups", 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), 2) + + self.assertEqual(int(content_json[0]["id"]), group1.id) + self.assertEqual(int(content_json[1]["id"]), group2.id) + + def test_project_submissions(self): + """ + Able to retrieve a list of submissions of a project 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, + ) + + group1 = create_group(project=project) + group2 = create_group(project=project) + + submission1 = create_submission(submission_number=1, group=group1, structure_checks_passed=True) + submission2 = create_submission(submission_number=2, group=group2, structure_checks_passed=False) + + response = self.client.get( + reverse("project-submissions", 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), 2) + + self.assertEqual(int(content_json[0]["id"]), submission1.id) + self.assertEqual(int(content_json[1]["id"]), submission2.id) + def test_cant_join_locked_groups(self): """Should not be able to add a student to a group if the groups are locked.""" course = create_course(id=3, name="sel2", academic_startyear=2023) @@ -890,6 +955,36 @@ def test_submission_status_groups_submitted_and_passed_checks(self): {"non_empty_groups": 3, "groups_submitted": 2, "submissions_passed": 1}, ) + def test_retrieve_list_submissions(self): + """Able to retrieve a list of submissions 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.teachers.add(self.user) + + group = create_group(project=project) + + create_submission( + submission_number=1, group=group, structure_checks_passed=True + ) + + response = self.client.get( + reverse("project-submissions", 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) + class ProjectModelTestsAsStudent(APITestCase): def setUp(self) -> None: diff --git a/backend/api/views/project_view.py b/backend/api/views/project_view.py index 16d4aa7d..583b812f 100644 --- a/backend/api/views/project_view.py +++ b/backend/api/views/project_view.py @@ -49,7 +49,7 @@ def groups(self, request, **_): def submissions(self, request, **_): """Returns a list of submissions for the given project""" project = self.get_object() - submissions = project.submissions.all() + submissions = Submission.objects.filter(group__project=project) # Serialize the group objects serializer = SubmissionSerializer( diff --git a/backend/api/views/student_view.py b/backend/api/views/student_view.py index 8517a0b8..ef0c0289 100644 --- a/backend/api/views/student_view.py +++ b/backend/api/views/student_view.py @@ -1,4 +1,4 @@ -from rest_framework import viewsets, status +from rest_framework import viewsets from rest_framework.decorators import action from rest_framework.response import Response from rest_framework.permissions import IsAdminUser