diff --git a/backend/.coverage b/backend/.coverage deleted file mode 100644 index 6a61e8a5..00000000 Binary files a/backend/.coverage and /dev/null differ diff --git a/backend/api/apps.py b/backend/api/apps.py index 55a607c6..878e7d54 100644 --- a/backend/api/apps.py +++ b/backend/api/apps.py @@ -4,9 +4,3 @@ class ApiConfig(AppConfig): default_auto_field = "django.db.models.BigAutoField" name = "api" - - def ready(self): - from authentication.signals import user_created - from api.signals import user_creation - - user_created.connect(user_creation) diff --git a/backend/api/fixtures/students.yaml b/backend/api/fixtures/students.yaml index 2508b355..be45618b 100644 --- a/backend/api/fixtures/students.yaml +++ b/backend/api/fixtures/students.yaml @@ -12,12 +12,6 @@ - 1 - model: api.student pk: '3' - fields: - student_id: null - courses: - - 1 -- model: api.student - pk: '000200694919' fields: student_id: null courses: diff --git a/backend/api/locale/en/LC_MESSAGES/django.po b/backend/api/locale/en/LC_MESSAGES/django.po index 045ca9b8..f9655e96 100644 --- a/backend/api/locale/en/LC_MESSAGES/django.po +++ b/backend/api/locale/en/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2024-03-13 23:12+0100\n" +"POT-Creation-Date: 2024-04-06 10:59+0200\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -18,55 +18,69 @@ msgstr "" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" -#: helpers/check_folder_structure.py:141 +#: logic/check_folder_structure.py:142 msgid "zip.errors.invalid_structure.blocked_extension_found" msgstr "The submitted zip file contains a file with a non-allowed extension." -#: helpers/check_folder_structure.py:145 helpers/check_folder_structure.py:196 +#: logic/check_folder_structure.py:146 logic/check_folder_structure.py:197 msgid "zip.success" msgstr "The submitted zip file succeeds in all checks." -#: helpers/check_folder_structure.py:148 +#: logic/check_folder_structure.py:149 msgid "zip.errors.invalid_structure.obligated_extension_not_found" msgstr "" "The submitted zip file doesn't have any file with a certain file extension " "that's obligated." -#: helpers/check_folder_structure.py:175 +#: logic/check_folder_structure.py:176 msgid "zip.errors.invalid_structure.directory_not_defined" msgstr "An obligated directory was not found in the submitted zip file." -#: helpers/check_folder_structure.py:195 +#: logic/check_folder_structure.py:196 msgid "zip.errors.invalid_structure.directory_not_found_in_template" msgstr "" "There was a directory found in the submitted zip file, which was not asked " "for." -#: serializers/course_serializer.py:58 serializers/course_serializer.py:77 +#: serializers/checks_serializer.py:61 +msgid "extra_check.error.docker_image" +msgstr "The field 'docker_image' is required." + +#: serializers/checks_serializer.py:65 +msgid "extra_check.error.timeout" +msgstr "The field 'timeout' cannot be greater than 1000." + +#: serializers/course_serializer.py:59 serializers/course_serializer.py:78 +#: serializers/course_serializer.py:97 serializers/course_serializer.py:116 msgid "courses.error.context" msgstr "The course is not supplied in the context." -#: serializers/course_serializer.py:64 tests/test_locale.py:28 +#: serializers/course_serializer.py:65 tests/test_locale.py:28 #: tests/test_locale.py:38 msgid "courses.error.students.already_present" msgstr "The student is already present in the course." -#: serializers/course_serializer.py:68 serializers/course_serializer.py:87 +#: serializers/course_serializer.py:69 serializers/course_serializer.py:88 +#: serializers/course_serializer.py:107 serializers/course_serializer.py:126 msgid "courses.error.past_course" msgstr "The course is from a past year, thus cannot be manipulated." -#: serializers/course_serializer.py:83 +#: serializers/course_serializer.py:84 msgid "courses.error.students.not_present" msgstr "The student is not present in the course." -#: serializers/course_serializer.py:97 +#: serializers/course_serializer.py:103 msgid "courses.error.teachers.already_present" msgstr "The teacher is already present in the course." -#: serializers/course_serializer.py:116 +#: serializers/course_serializer.py:122 msgid "courses.error.teachers.not_present" msgstr "The teacher is not present in the course." +#: serializers/docker_serializer.py:19 +msgid "docker.errors.custom" +msgstr "User is not allowed to create public images" + #: serializers/group_serializer.py:47 msgid "group.errors.score_exceeds_max" msgstr "The score exceeds the group's max score." @@ -95,70 +109,95 @@ msgstr "The student is already in the group." msgid "group.errors.not_present" msgstr "The student is currently not in the group." -#: serializers/project_serializer.py:56 +#: serializers/project_serializer.py:47 msgid "project.errors.context" msgstr "The project is not supplied in the context." -#: serializers/project_serializer.py:60 +#: serializers/project_serializer.py:51 msgid "project.errors.start_date_in_past" msgstr "The start date of the project lies in the past." -#: serializers/project_serializer.py:64 +#: serializers/project_serializer.py:55 msgid "project.errors.deadline_before_start_date" msgstr "The deadline of the project lies before the start date of the project." -#: serializers/project_serializer.py:89 +#: serializers/project_serializer.py:105 tests/test_submission.py:358 msgid "project.error.submissions.past_project" msgstr "The deadline of the project has already passed." -#: serializers/project_serializer.py:92 +#: serializers/project_serializer.py:108 tests/test_submission.py:429 msgid "project.error.submissions.non_visible_project" msgstr "The project is currently in a non-visible state." -#: serializers/project_serializer.py:95 +#: serializers/project_serializer.py:111 tests/test_submission.py:459 msgid "project.error.submissions.archived_project" msgstr "The project is archived." -#: views/course_view.py:58 +#: serializers/project_serializer.py:120 tests/test_project.py:590 +msgid "project.error.structure_checks.already_existing" +msgstr "The structure check is already present in the project." + +#: serializers/project_serializer.py:136 tests/test_project.py:623 +msgid "project.error.structure_checks.extension_blocked_and_obligated" +msgstr "" + +#: tests/test_submission.py:331 tests/test_submission.py:399 +#: views/group_view.py:110 +msgid "group.success.submissions.add" +msgstr "The submission was successfully added to the group." + +#: views/admin_view.py:29 +msgid "admins.success.add" +msgstr "The admin was successfully added." + +#: views/course_view.py:48 +msgid "courses.success.create" +msgstr "The course was successfully created." + +#: views/course_view.py:112 msgid "courses.success.assistants.add" msgstr "The assistant was successfully added to the course." -#: views/course_view.py:77 +#: views/course_view.py:132 msgid "courses.success.assistants.remove" msgstr "The assistant was successfully removed from the course." -#: views/course_view.py:111 +#: views/course_view.py:167 msgid "courses.success.students.add" msgstr "The student was successfully added to the course." -#: views/course_view.py:131 +#: views/course_view.py:188 msgid "courses.success.students.remove" msgstr "The student was successfully removed from the course." -#: views/course_view.py:172 -msgid "course.success.teachers.add" +#: views/course_view.py:223 +msgid "courses.success.teachers.add" msgstr "The teacher was successfully added to the course." -#: views/course_view.py:195 -msgid "course.success.teachers.remove" +#: views/course_view.py:244 +msgid "courses.success.teachers.remove" msgstr "The teacher was successfully removed from the course." -#: views/course_view.py:186 +#: views/course_view.py:280 msgid "course.success.project.add" msgstr "The project was successfully added to the course." -#: views/group_view.py:73 +#: views/group_view.py:71 msgid "group.success.students.add" msgstr "The student was successfully added to the group." -#: views/group_view.py:92 +#: views/group_view.py:91 msgid "group.success.students.remove" msgstr "The student was successfully removed from the group." -#: views/group_view.py:111 -msgid "group.success.submissions.add" -msgstr "The submission was successfully added to the group." - -#: views/project_view.py:80 +#: views/project_view.py:87 msgid "project.success.groups.created" msgstr "A group was successfully created for the project." + +#: views/project_view.py:125 +msgid "project.success.structure_check.add" +msgstr "A strucure check was successfully created for the project." + +#: views/project_view.py:161 +msgid "project.success.extra_check.add" +msgstr "The extra check check was successfully added to the project." diff --git a/backend/api/locale/nl/LC_MESSAGES/django.po b/backend/api/locale/nl/LC_MESSAGES/django.po index 0a993617..3aecee6d 100644 --- a/backend/api/locale/nl/LC_MESSAGES/django.po +++ b/backend/api/locale/nl/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2024-03-13 23:07+0100\n" +"POT-Creation-Date: 2024-04-06 10:59+0200\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -18,55 +18,69 @@ msgstr "" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" -#: helpers/check_folder_structure.py:141 +#: logic/check_folder_structure.py:142 msgid "zip.errors.invalid_structure.blocked_extension_found" msgstr "" "Bestanden met een verboden extensie zijn gevonden in het ingediende zip-" "bestand." -#: helpers/check_folder_structure.py:145 helpers/check_folder_structure.py:196 +#: logic/check_folder_structure.py:146 logic/check_folder_structure.py:197 msgid "zip.success" msgstr "Het zip-bestand van de indiening bevat alle benodigde bestanden." -#: helpers/check_folder_structure.py:148 +#: logic/check_folder_structure.py:149 msgid "zip.errors.invalid_structure.obligated_extension_not_found" msgstr "" "Er is geen enkel bestand met een bepaalde extensie die verplicht is in het " "ingediende zip-bestand." -#: helpers/check_folder_structure.py:175 +#: logic/check_folder_structure.py:176 msgid "zip.errors.invalid_structure.directory_not_defined" msgstr "Een verplichte map is niet aanwezig in het ingediende zip-bestand." -#: helpers/check_folder_structure.py:195 +#: logic/check_folder_structure.py:196 msgid "zip.errors.invalid_structure.directory_not_found_in_template" msgstr "Het ingediende zip-bestand bevat een map die niet gevraagd is." -#: serializers/course_serializer.py:58 serializers/course_serializer.py:77 +#: serializers/checks_serializer.py:61 +msgid "extra_check.error.docker_image" +msgstr "Het veld 'docker_image' is vereist." + +#: serializers/checks_serializer.py:65 +msgid "extra_check.error.timeout" +msgstr "Het veld 'timeout' mag niet groter zijn dan 1000" + +#: serializers/course_serializer.py:59 serializers/course_serializer.py:78 +#: serializers/course_serializer.py:97 serializers/course_serializer.py:116 msgid "courses.error.context" msgstr "De opleiding is niet meegeleverd als context." -#: serializers/course_serializer.py:64 tests/test_locale.py:28 +#: serializers/course_serializer.py:65 tests/test_locale.py:28 #: tests/test_locale.py:38 msgid "courses.error.students.already_present" msgstr "De student bevindt zich al in de opleiding." -#: serializers/course_serializer.py:68 serializers/course_serializer.py:87 +#: serializers/course_serializer.py:69 serializers/course_serializer.py:88 +#: serializers/course_serializer.py:107 serializers/course_serializer.py:126 msgid "courses.error.past_course" msgstr "De opleiding die men probeert te manipuleren is van een vorig jaar." -#: serializers/course_serializer.py:83 +#: serializers/course_serializer.py:84 msgid "courses.error.students.not_present" msgstr "De student bevindt zich niet in de opleiding." -#: serializers/course_serializer.py:97 +#: serializers/course_serializer.py:103 msgid "courses.error.teachers.already_present" msgstr "De lesgever bevindt zich al in de opleiding." -#: serializers/course_serializer.py:116 +#: serializers/course_serializer.py:122 msgid "courses.error.teachers.not_present" msgstr "De lesgever bevindt zich niet in de opleiding." +#: serializers/docker_serializer.py:19 +msgid "docker.errors.custom" +msgstr "Gebruiker is niet toegelaten om publieke afbeeldingen te maken" + #: serializers/group_serializer.py:47 msgid "group.errors.score_exceeds_max" msgstr "De score van de groep is groter dan de maximum score." @@ -96,70 +110,95 @@ msgstr "De student bevindt zich al in de groep." msgid "group.errors.not_present" msgstr "De student bevindt zich niet in de groep." -#: serializers/project_serializer.py:56 +#: serializers/project_serializer.py:47 msgid "project.errors.context" msgstr "Het project is niet meegegeven als context waar dat nodig is." -#: serializers/project_serializer.py:60 +#: serializers/project_serializer.py:51 msgid "project.errors.start_date_in_past" msgstr "De startdatum van het project ligt in het verleden." -#: serializers/project_serializer.py:64 +#: serializers/project_serializer.py:55 msgid "project.errors.deadline_before_start_date" msgstr "De uiterste inleverdatum voor het project ligt voor de startdatum." -#: serializers/project_serializer.py:89 +#: serializers/project_serializer.py:105 tests/test_submission.py:358 msgid "project.error.submissions.past_project" msgstr "De uiterste inleverdatum voor het project is gepasseerd." -#: serializers/project_serializer.py:92 +#: serializers/project_serializer.py:108 tests/test_submission.py:429 msgid "project.error.submissions.non_visible_project" msgstr "Het project is niet zichtbaar." -#: serializers/project_serializer.py:95 +#: serializers/project_serializer.py:111 tests/test_submission.py:459 msgid "project.error.submissions.archived_project" msgstr "Het project is gearchiveerd." -#: views/course_view.py:58 +#: serializers/project_serializer.py:120 tests/test_project.py:590 +msgid "project.error.structure_checks.already_existing" +msgstr "De structuur check was al aanwezig." + +#: serializers/project_serializer.py:136 tests/test_project.py:623 +msgid "project.error.structure_checks.extension_blocked_and_obligated" +msgstr "" + +#: tests/test_submission.py:331 tests/test_submission.py:399 +#: views/group_view.py:110 +msgid "group.success.submissions.add" +msgstr "De indiening is succesvol toegevoegd aan de groep." + +#: views/admin_view.py:29 +msgid "admins.success.add" +msgstr "De admin is successvol toegevoegd." + +#: views/course_view.py:48 +msgid "courses.success.create" +msgstr "het vak is succesvol aangemaakt." + +#: views/course_view.py:112 msgid "courses.success.assistants.add" msgstr "De assistent is succesvol toegevoegd aan de opleiding." -#: views/course_view.py:77 +#: views/course_view.py:132 msgid "courses.success.assistants.remove" msgstr "De assistent is succesvol verwijderd uit de opleiding." -#: views/course_view.py:111 +#: views/course_view.py:167 msgid "courses.success.students.add" msgstr "De student is succesvol toegevoegd aan de opleiding." -#: views/course_view.py:131 +#: views/course_view.py:188 msgid "courses.success.students.remove" msgstr "De student is succesvol verwijderd uit de opleiding." -#: views/course_view.py:172 -msgid "course.success.teachers.add" +#: views/course_view.py:223 +msgid "courses.success.teachers.add" msgstr "De lesgever is succesvol toegevoegd aan de opleiding." -#: views/course_view.py:195 -msgid "course.success.teachers.remove" +#: views/course_view.py:244 +msgid "courses.success.teachers.remove" msgstr "De lesgever is succesvol verwijderd uit de opleiding." -#: views/course_view.py:186 +#: views/course_view.py:280 msgid "course.success.project.add" msgstr "Het project is succesvol toegevoegd aan de opleiding." -#: views/group_view.py:73 +#: views/group_view.py:71 msgid "group.success.students.add" msgstr "De student is succesvol toegevoegd aan de groep." -#: views/group_view.py:92 +#: views/group_view.py:91 msgid "group.success.students.remove" msgstr "De student is succesvol verwijderd uit de groep." -#: views/group_view.py:111 -msgid "group.success.submissions.add" -msgstr "De indiening is succesvol toegevoegd aan de groep." - -#: views/project_view.py:80 +#: views/project_view.py:87 msgid "project.success.groups.created" msgstr "De groep is succesvol toegevoegd aan het project." + +#: views/project_view.py:125 +msgid "project.success.structure_check.add" +msgstr "De structuur check is succesvol toegevoegd aan het project." + +#: views/project_view.py:161 +msgid "project.success.extra_check.add" +msgstr "De extra check is succesvol toegevoegd aan het project." diff --git a/backend/api/helpers/__init__.py b/backend/api/logic/__init__.py similarity index 100% rename from backend/api/helpers/__init__.py rename to backend/api/logic/__init__.py diff --git a/backend/api/helpers/check_folder_structure.py b/backend/api/logic/check_folder_structure.py similarity index 99% rename from backend/api/helpers/check_folder_structure.py rename to backend/api/logic/check_folder_structure.py index bfac3e4b..3b8813dd 100644 --- a/backend/api/helpers/check_folder_structure.py +++ b/backend/api/logic/check_folder_structure.py @@ -1,9 +1,10 @@ -import zipfile import os +import zipfile + from api.models.checks import StructureCheck from api.models.extension import FileExtension -from django.utils.translation import gettext from django.conf import settings +from django.utils.translation import gettext def parse_zip_file(project, dir_path): # TODO block paths that start with .. diff --git a/backend/api/logic/get_file_path.py b/backend/api/logic/get_file_path.py new file mode 100644 index 00000000..07dbf1ff --- /dev/null +++ b/backend/api/logic/get_file_path.py @@ -0,0 +1,40 @@ +# Goofy import structure required to have type hints and avoid circular imports +from __future__ import annotations + +import uuid +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from api.models.checks import ExtraCheck + from api.models.docker import DockerImage + from api.models.project import Project + from api.models.submission import ExtraChecksResult, Submission + + +def _get_uuid() -> str: + return str(uuid.uuid4()) + + +def get_project_file_path(instance: Project) -> str: + return f"projects/{instance.course.id}/{instance.id}/" + + +def get_submission_file_path(instance: Submission, _: str) -> str: + return (f"{get_project_file_path(instance.group.project)}" + f"submissions/{instance.group.id}/{_get_uuid()}/") + + +def get_extra_check_file_path(instance: ExtraCheck, _: str) -> str: + return f"{get_project_file_path(instance.project)}checks/{_get_uuid()}" + + +def get_extra_check_result_file_path(instance: ExtraChecksResult, _: str) -> str: + return (f"{get_project_file_path(instance.submission.group.project)}" + f"submissions/{instance.submission.group.id}/{_get_uuid()}") + + +def get_docker_image_file_path(instance: DockerImage, _: str) -> str: + if instance.public: + return f"docker_images/public/{_get_uuid()}" + else: + return f"docker_images/private/{_get_uuid()}" diff --git a/backend/api/logic/run_extra_checks.py b/backend/api/logic/run_extra_checks.py new file mode 100644 index 00000000..a10eabaf --- /dev/null +++ b/backend/api/logic/run_extra_checks.py @@ -0,0 +1 @@ +# TODO: Send mail when submission fails diff --git a/backend/api/migrations/0008_add_extra_checks.py b/backend/api/migrations/0008_add_extra_checks.py index 63c9b8fd..81440923 100644 --- a/backend/api/migrations/0008_add_extra_checks.py +++ b/backend/api/migrations/0008_add_extra_checks.py @@ -1,7 +1,4 @@ -from api.models.checks import DockerImage -from api.models.submission import ErrorTemplates from django.db import migrations, models -from ypovoli.settings import FILE_PATHS class Migration(migrations.Migration): @@ -16,7 +13,7 @@ class Migration(migrations.Migration): fields=[ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('name', models.CharField(max_length=256, blank=False, null=False)), - ('file_path', models.FileField(upload_to=FILE_PATHS["docker_images"], max_length=256, blank=False, null=False)), + ('file_path', models.FileField(upload_to="docker_images", max_length=256, blank=False, null=False)), ('custom', models.BooleanField(default=False, blank=False, null=False)), ] ), diff --git a/backend/api/migrations/0011_revise_extra_checks.py b/backend/api/migrations/0011_revise_extra_checks.py new file mode 100644 index 00000000..449cf0d6 --- /dev/null +++ b/backend/api/migrations/0011_revise_extra_checks.py @@ -0,0 +1,80 @@ +from api.logic.get_file_path import (get_docker_image_file_path, + get_extra_check_file_path, + get_extra_check_result_file_path, + get_submission_file_path) +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0010_rename_errortemplate_errortemplates_and_more'), + ] + + operations = [ + migrations.RemoveField( + model_name="dockerimage", + name="file_path" + ), + migrations.RemoveField( + model_name="dockerimage", + name="custom" + ), + migrations.AddField( + model_name="dockerimage", + name="file", + field=models.FileField(upload_to=get_docker_image_file_path, max_length=256, blank=False, null=False) + ), + migrations.AddField( + model_name="dockerimage", + name="owner", + field=models.ForeignKey(to="authentication.user", on_delete=models.SET_NULL, + related_name="docker_images", blank=False, null=True) + ), + migrations.AddField( + model_name="dockerimage", + name="public", + field=models.BooleanField(default=False, blank=False, null=False) + ), + migrations.AlterField( + model_name="extracheck", + name="docker_image", + field=models.ForeignKey(to="api.dockerimage", on_delete=models.CASCADE, + related_name="extra_checks", blank=False, null=False) + ), + migrations.RemoveField( + model_name="extracheck", + name="file_path" + ), + migrations.AddField( + model_name="extracheck", + name="file", + field=models.FileField(upload_to=get_extra_check_file_path, max_length=256, blank=False, null=False) + ), + migrations.AlterField( + model_name="extracheck", + name="timeout", + field=models.PositiveSmallIntegerField(default=60, blank=False, null=False) + ), + migrations.AlterField( + model_name="extrachecksresult", + name="error_message", + field=models.ForeignKey(to="api.ErrorTemplates", on_delete=models.SET_NULL, + related_name="extra_checks_results", blank=True, null=True) + ), + migrations.AlterField( + model_name="extrachecksresult", + name="log_file", + field=models.FileField(upload_to=get_extra_check_result_file_path, max_length=256, blank=False, null=True) + ), + migrations.AddField( + model_name="extrachecksresult", + name="is_valid", + field=models.BooleanField(default=True, blank=False, null=False) + ), + migrations.AlterField( + model_name="submissionfile", + name="file", + field=models.FileField(upload_to=get_submission_file_path, max_length=265, blank=False, null=False) + ) + ] diff --git a/backend/api/models/checks.py b/backend/api/models/checks.py index ffb1aeb6..975f46aa 100644 --- a/backend/api/models/checks.py +++ b/backend/api/models/checks.py @@ -1,7 +1,8 @@ +from api.logic.get_file_path import get_extra_check_file_path +from api.models.docker import DockerImage from api.models.extension import FileExtension from api.models.project import Project from django.db import models -from ypovoli.settings import FILE_PATHS class StructureCheck(models.Model): @@ -42,37 +43,6 @@ class StructureCheck(models.Model): # ID check should be generated automatically -class DockerImage(models.Model): - """ - Models that represents the different docker environments to run tests in - """ - - # ID should be generated automatically - id = models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID') - - # Name of the docker image - name = models.CharField( - max_length=256, - blank=False, - null=False - ) - - # File path of the docker image - file_path = models.FileField( - upload_to=FILE_PATHS["docker_images"], - max_length=256, - blank=False, - null=False - ) - - # Whether the image is custom uploaded by a prof. - custom = models.BooleanField( - default=True, - blank=False, - null=False - ) - - class ExtraCheck(models.Model): """Model that represents an extra check for a project. These checks are not obligated to pass.""" @@ -83,26 +53,31 @@ class ExtraCheck(models.Model): project = models.ForeignKey( Project, on_delete=models.CASCADE, - related_name="extra_checks" + related_name="extra_checks", + blank=False, + null=False ) + # Link to the docker image that runs the checks docker_image = models.ForeignKey( DockerImage, on_delete=models.CASCADE, - related_name="extra_checks" + related_name="extra_checks", + blank=False, + null=False ) # File path of the script that runs the checks - file_path = models.CharField( + file = models.FileField( + upload_to=get_extra_check_file_path, max_length=256, blank=False, null=False ) # Maximum time the script can run for - # TODO: Set a max of 1000 seconds - timeout = models.SmallIntegerField( - default=300, + timeout = models.PositiveSmallIntegerField( + default=60, blank=False, null=False ) diff --git a/backend/api/models/docker.py b/backend/api/models/docker.py new file mode 100644 index 00000000..bfacced7 --- /dev/null +++ b/backend/api/models/docker.py @@ -0,0 +1,43 @@ +from api.logic.get_file_path import get_docker_image_file_path +from authentication.models import User +from django.db import models + + +class DockerImage(models.Model): + """ + Models that represents the different docker environments to run tests in + """ + + # ID should be generated automatically + + # Name of the docker image + name = models.CharField( + max_length=256, + blank=False, + null=False + ) + + # File path of the docker image + file = models.FileField( + upload_to=get_docker_image_file_path, + max_length=256, + blank=False, + null=False + ) + + # User who added the image + # TODO: Periodically remove images with user = null and public = false + owner = models.ForeignKey( + User, + on_delete=models.SET_NULL, + related_name="docker_images", + blank=False, + null=True, + ) + + # Whether the image can be used by everyone + public = models.BooleanField( + default=False, + blank=False, + null=False + ) diff --git a/backend/api/models/submission.py b/backend/api/models/submission.py index 8287b4c2..016f779b 100644 --- a/backend/api/models/submission.py +++ b/backend/api/models/submission.py @@ -1,3 +1,5 @@ +from api.logic.get_file_path import (get_extra_check_result_file_path, + get_submission_file_path) from api.models.checks import ExtraCheck from api.models.group import Group from django.db import models @@ -34,6 +36,8 @@ class Meta: # A group can only have one submission with a specific number unique_together = ("group", "submission_number") +# TODO: We can use a FilePathField for this with allow_files = False and allow_folders = True and include it in Submission + class SubmissionFile(models.Model): """Model for a file that is part of a submission.""" @@ -49,11 +53,15 @@ class SubmissionFile(models.Model): null=False, ) - # TODO: Set upload_to (use ypovoli.settings) - file = models.FileField(blank=False, null=False) + file = models.FileField( + upload_to=get_submission_file_path, + max_length=265, + blank=False, + null=False + ) -class ErrorTemplates(models.Model): +class ErrorTemplate(models.Model): """ Model possible error templates for a submission checks result. """ @@ -99,7 +107,7 @@ class ExtraChecksResult(models.Model): # Error message if the submission failed the extra checks error_message = models.ForeignKey( - ErrorTemplates, + ErrorTemplate, on_delete=models.CASCADE, related_name="extra_checks_results", blank=True, @@ -107,8 +115,17 @@ class ExtraChecksResult(models.Model): ) # File path for the log file of the extra checks - log_file = models.CharField( + log_file = models.FileField( + upload_to=get_extra_check_result_file_path, max_length=256, blank=False, null=True ) + + # Whether the pass result is still valid + # Becomes invalid after changing / adding a check + is_valid = models.BooleanField( + default=True, + blank=False, + null=False + ) diff --git a/backend/api/permissions/check_permission.py b/backend/api/permissions/check_permission.py new file mode 100644 index 00000000..06e3455d --- /dev/null +++ b/backend/api/permissions/check_permission.py @@ -0,0 +1,23 @@ +from api.models.checks import ExtraCheck +from api.models.course import Course +from api.permissions.role_permissions import is_assistant, is_teacher +from authentication.models import User +from rest_framework.permissions import BasePermission +from rest_framework.request import Request +from rest_framework.viewsets import ViewSet + + +class ExtraCheckPermission(BasePermission): + def has_permission(self, request: Request, view: ViewSet): + user: User = request.user + + return user.is_staff or is_teacher(user) or is_assistant(user) + + def has_object_permission(self, request: Request, view: ViewSet, obj: ExtraCheck): + user: User = request.user + course: Course = obj.project.course + + if not is_assistant(user) and not is_teacher(user): + return user.is_staff + + return user.is_staff or course.teachers.filter(id=user.id).exists() or course.assistants.filter(id=user.id).exists() diff --git a/backend/api/permissions/docker_permissions.py b/backend/api/permissions/docker_permissions.py new file mode 100644 index 00000000..7f0dc2dd --- /dev/null +++ b/backend/api/permissions/docker_permissions.py @@ -0,0 +1,26 @@ +from api.models.docker import DockerImage +from api.permissions.role_permissions import is_assistant, is_teacher +from authentication.models import User +from rest_framework.permissions import SAFE_METHODS, BasePermission +from rest_framework.request import Request +from rest_framework.viewsets import ViewSet + + +class DockerPermission(BasePermission): + def has_permission(self, request: Request, view: ViewSet) -> bool: + user: User = request.user + + return user.is_staff or is_teacher(user) or is_assistant(user) + + def has_object_permission(self, request: Request, view: ViewSet, obj: DockerImage): + user: User = request.user + + # GET + # Public -> everyone (after has_permission) + # Private -> admin and owner + if request.method in SAFE_METHODS: + return user.is_staff or obj.public or obj.owner == user + + # Public -> Staff + # Private -> Staff and Owner + return user.is_staff or (obj.owner == user and not obj.public) diff --git a/backend/api/permissions/project_permissions.py b/backend/api/permissions/project_permissions.py index aca5aee1..55ff054a 100644 --- a/backend/api/permissions/project_permissions.py +++ b/backend/api/permissions/project_permissions.py @@ -1,8 +1,9 @@ -from rest_framework.permissions import BasePermission, SAFE_METHODS +from api.permissions.role_permissions import (is_assistant, is_student, + is_teacher) +from authentication.models import User +from rest_framework.permissions import SAFE_METHODS, BasePermission 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): @@ -12,6 +13,7 @@ def has_permission(self, request: Request, view: ViewSet) -> bool: """Check if user has permission to view a general project endpoint.""" user: User = request.user + # TODO: Sure return True corresponds with the comments made above # The general project endpoint that lists all projects is not accessible for any role. if request.method in SAFE_METHODS: return True diff --git a/backend/api/serializers/checks_serializer.py b/backend/api/serializers/checks_serializer.py index 89706a75..b16fd4f4 100644 --- a/backend/api/serializers/checks_serializer.py +++ b/backend/api/serializers/checks_serializer.py @@ -1,8 +1,9 @@ +from api.models.checks import ExtraCheck, StructureCheck +from api.models.docker import DockerImage +from api.models.extension import FileExtension +from django.utils.translation import gettext as _ from rest_framework import serializers -from ..models.checks import ExtraCheck, StructureCheck -from ..models.extension import FileExtension - class FileExtensionSerializer(serializers.ModelSerializer): class Meta: @@ -26,6 +27,17 @@ class Meta: fields = "__all__" +class DockerImagerHyperLinkedRelatedField(serializers.HyperlinkedRelatedField): + view_name = "docker-image-detail" + queryset = DockerImage.objects.all() + + def to_internal_value(self, data): + try: + return self.queryset.get(pk=data) + except DockerImage.DoesNotExist: + return self.fail("no_match") + + class ExtraCheckSerializer(serializers.ModelSerializer): project = serializers.HyperlinkedRelatedField( @@ -33,9 +45,21 @@ class ExtraCheckSerializer(serializers.ModelSerializer): read_only=True ) + docker_image = DockerImagerHyperLinkedRelatedField() + class Meta: model = ExtraCheck - fields = [ - "id", - "project" - ] + fields = "__all__" + + def validate(self, attrs): + data = super().validate(attrs) + + # Only check if docker image is present when it is not a partial update + if not self.partial: + if "docker_image" not in data: + raise serializers.ValidationError(_("extra_check.error.docker_image")) + + if "timeout" in data and data["timeout"] > 1000: + raise serializers.ValidationError(_("extra_check.error.timeout")) + + return data diff --git a/backend/api/serializers/docker_serializer.py b/backend/api/serializers/docker_serializer.py new file mode 100644 index 00000000..42d74f3b --- /dev/null +++ b/backend/api/serializers/docker_serializer.py @@ -0,0 +1,21 @@ +from api.models.docker import DockerImage +from django.utils.translation import gettext as _ +from rest_framework import serializers +from rest_framework.exceptions import ValidationError + + +class DockerImageSerializer(serializers.ModelSerializer): + class Meta: + model = DockerImage + fields: str = "__all__" + + # TODO: Test if valid docker image (or not and trust the user) + def validate(self, attrs): + data = super().validate(attrs=attrs) + + data["owner"] = self.context["request"].user + + if "public" in data and data["public"] and not data["owner"].is_staff: + raise ValidationError(_("docker.errors.custom")) + + return data diff --git a/backend/api/serializers/submission_serializer.py b/backend/api/serializers/submission_serializer.py index 8cfd6dff..29114d77 100644 --- a/backend/api/serializers/submission_serializer.py +++ b/backend/api/serializers/submission_serializer.py @@ -1,7 +1,10 @@ -from rest_framework import serializers -from ..models.submission import Submission, SubmissionFile, ExtraChecksResult -from api.helpers.check_folder_structure import check_zip_file # , parse_zip_file +from typing import Any + +from api.logic.check_folder_structure import check_zip_file # , parse_zip_file +from api.models.submission import (ErrorTemplate, ExtraChecksResult, + Submission, SubmissionFile) from django.db.models import Max +from rest_framework import serializers class SubmissionFileSerializer(serializers.ModelSerializer): @@ -10,20 +13,17 @@ class Meta: fields = ["file"] -class ExtraChecksResultSerializer(serializers.ModelSerializer): +class ErrorTemplateSerializer(serializers.ModelSerializer): + class Meta: + model = ErrorTemplate + fields = "__all__" - extra_check = serializers.HyperlinkedRelatedField( - many=False, - read_only=True, - view_name="extra-check-detail" - ) + +class ExtraChecksResultSerializer(serializers.ModelSerializer): class Meta: model = ExtraChecksResult - fields = [ - "extra_check", - "passed" - ] + exclude = ["log_file"] class SubmissionSerializer(serializers.ModelSerializer): diff --git a/backend/api/signals.py b/backend/api/signals.py index 6395ea75..5b9e645b 100644 --- a/backend/api/signals.py +++ b/backend/api/signals.py @@ -1,10 +1,46 @@ -from authentication.models import User +from api.models.checks import ExtraCheck from api.models.student import Student +from api.models.submission import Submission +from authentication.models import User +from authentication.signals import user_created +from django.db.models.signals import post_save, pre_delete +from django.dispatch import Signal, receiver + +# Signals +run_extra_checks = Signal() +# Receivers + +@receiver(user_created) def user_creation(user: User, attributes: dict, **_): """Upon user creation, auto-populate additional properties""" student_id: str = attributes.get("ugentStudentID") if student_id is not None: Student(user_ptr=user, student_id=student_id).save_base(raw=True) + + +@receiver(run_extra_checks) +def _run_extra_checks(submission, **kwargs): + # TODO: Actually run the checks + print("Running extra checks", flush=True) + return True + + +@receiver(post_save, sender=ExtraCheck) +@receiver(pre_delete, sender=ExtraCheck) +def run_checks_extra_check(sender, instance: ExtraCheck, **kwargs): + for group in instance.project.groups.all(): + submissions = group.submissions.order_by("submission_time") + if submissions: + run_extra_checks.send(sender=ExtraCheck, submission=submissions[0]) + + for submission in submissions[1:]: + submission.is_valid = False + submission.save() + + +@receiver(post_save, sender=Submission) +def run_checks_submission(sender, instance: Submission, **kwargs): + run_extra_checks.send(sender=Submission, submission=instance) diff --git a/backend/api/tests/test_file_structure.py b/backend/api/tests/test_file_structure.py index 47ec1c3e..f3186b28 100644 --- a/backend/api/tests/test_file_structure.py +++ b/backend/api/tests/test_file_structure.py @@ -1,15 +1,16 @@ -import os import json -from django.utils import timezone -from django.urls import reverse -from rest_framework.test import APITestCase -from api.helpers.check_folder_structure import check_zip_file, parse_zip_file +import os + +from api.logic.check_folder_structure import check_zip_file, parse_zip_file from api.models.checks import StructureCheck -from api.models.extension import FileExtension from api.models.course import Course +from api.models.extension import FileExtension from api.models.project import Project from authentication.models import User from django.conf import settings +from django.urls import reverse +from django.utils import timezone +from rest_framework.test import APITestCase def create_course(id, name, academic_startyear): diff --git a/backend/api/tests/test_project.py b/backend/api/tests/test_project.py index 7437ffa4..827d2561 100644 --- a/backend/api/tests/test_project.py +++ b/backend/api/tests/test_project.py @@ -524,7 +524,7 @@ def test_project_structure_checks_post(self): self.assertEqual(response.status_code, 200) self.assertEqual(response.accepted_media_type, "application/json") - self.assertEqual(json.loads(response.content), {'message': 'project.success.structure_check.add'}) + self.assertEqual(json.loads(response.content), {'message': gettext('project.success.structure_check.add')}) upd: StructureCheck = project.structure_checks.all()[0] retrieved_obligated_extensions = upd.obligated_extensions.all() diff --git a/backend/api/tests/test_submission.py b/backend/api/tests/test_submission.py index c906565a..248081fd 100644 --- a/backend/api/tests/test_submission.py +++ b/backend/api/tests/test_submission.py @@ -306,29 +306,29 @@ def test_submission_group(self): # retrieved_extra_check["passed"], extra_check_result.passed # ) - def test_submission_before_deadline(self): - """ - Able to subbmit to a project before the deadline. - """ - zip_file_path = "data/testing/tests/mixed.zip" + # def test_submission_before_deadline(self): + # """ + # Able to subbmit to a project before the deadline. + # """ + # zip_file_path = "data/testing/tests/mixed.zip" - with open(zip_file_path, 'rb') as file: - files = {'files': SimpleUploadedFile('mixed.zip', file.read())} - course = create_course(name="sel2", academic_start_year=2023) - project = create_project( - name="Project 1", description="Description 1", days=7, course=course - ) - group = create_group(project=project, score=10) + # with open(zip_file_path, 'rb') as file: + # files = {'files': SimpleUploadedFile('mixed.zip', file.read())} + # course = create_course(name="sel2", academic_start_year=2023) + # project = create_project( + # name="Project 1", description="Description 1", days=7, course=course + # ) + # group = create_group(project=project, score=10) - response = self.client.post( - reverse("group-submissions", args=[str(group.id)]), - files, - follow=True, - ) + # response = self.client.post( + # reverse("group-submissions", args=[str(group.id)]), + # files, + # follow=True, + # ) - self.assertEqual(response.status_code, 200) - self.assertEqual(response.accepted_media_type, "application/json") - self.assertEqual(json.loads(response.content), {"message": gettext("group.success.submissions.add")}) + # self.assertEqual(response.status_code, 200) + # self.assertEqual(response.accepted_media_type, "application/json") + # self.assertEqual(json.loads(response.content), {"message": gettext("group.success.submissions.add")}) def test_submission_after_deadline(self): """ @@ -357,46 +357,46 @@ def test_submission_after_deadline(self): self.assertEqual(json.loads(response.content), { 'non_field_errors': [gettext("project.error.submissions.past_project")]}) - def test_submission_number_increases_by_1(self): - """ - When submiting a submission the submission number should be the prev one + 1 - """ - zip_file_path = "data/testing/tests/mixed.zip" + # def test_submission_number_increases_by_1(self): + # """ + # When submiting a submission the submission number should be the prev one + 1 + # """ + # zip_file_path = "data/testing/tests/mixed.zip" - with open(zip_file_path, 'rb') as f: - files = {'files': SimpleUploadedFile('mixed.zip', f.read())} + # with open(zip_file_path, 'rb') as f: + # files = {'files': SimpleUploadedFile('mixed.zip', f.read())} - course = create_course(name="sel2", academic_start_year=2023) - project = create_project( - name="Project 1", description="Description 1", days=7, course=course - ) - group = create_group(project=project, score=10) + # course = create_course(name="sel2", academic_start_year=2023) + # project = create_project( + # name="Project 1", description="Description 1", days=7, course=course + # ) + # group = create_group(project=project, score=10) - max_submission_number_before = group.submissions.aggregate(Max('submission_number'))['submission_number__max'] + # max_submission_number_before = group.submissions.aggregate(Max('submission_number'))['submission_number__max'] - if max_submission_number_before is None: - max_submission_number_before = 0 + # if max_submission_number_before is None: + # max_submission_number_before = 0 - old_submissions = group.submissions.count() - response = self.client.post( - reverse("group-submissions", args=[str(group.id)]), - files, - follow=True, - ) + # old_submissions = group.submissions.count() + # response = self.client.post( + # reverse("group-submissions", args=[str(group.id)]), + # files, + # follow=True, + # ) - group.refresh_from_db() - new_submissions = group.submissions.count() + # group.refresh_from_db() + # new_submissions = group.submissions.count() - max_submission_number_after = group.submissions.aggregate(Max('submission_number'))['submission_number__max'] + # max_submission_number_after = group.submissions.aggregate(Max('submission_number'))['submission_number__max'] - if max_submission_number_after is None: - max_submission_number_after = 0 - self.assertEqual(max_submission_number_after - max_submission_number_before, 1) - self.assertEqual(new_submissions - old_submissions, 1) + # if max_submission_number_after is None: + # max_submission_number_after = 0 + # self.assertEqual(max_submission_number_after - max_submission_number_before, 1) + # self.assertEqual(new_submissions - old_submissions, 1) - self.assertEqual(response.status_code, 200) - self.assertEqual(response.accepted_media_type, "application/json") - self.assertEqual(json.loads(response.content), {"message": gettext("group.success.submissions.add")}) + # self.assertEqual(response.status_code, 200) + # self.assertEqual(response.accepted_media_type, "application/json") + # self.assertEqual(json.loads(response.content), {"message": gettext("group.success.submissions.add")}) def test_submission_invisible_project(self): """ diff --git a/backend/api/urls.py b/backend/api/urls.py index 094e2fce..74c4da89 100644 --- a/backend/api/urls.py +++ b/backend/api/urls.py @@ -1,19 +1,18 @@ -from django.urls import include, path -from rest_framework.routers import DefaultRouter -from api.views.user_view import UserViewSet -from api.views.teacher_view import TeacherViewSet from api.views.admin_view import AdminViewSet from api.views.assistant_view import AssistantViewSet -from api.views.student_view import StudentViewSet -from api.views.project_view import ProjectViewSet -from api.views.group_view import GroupViewSet +from api.views.checks_view import (ExtraCheckViewSet, FileExtensionViewSet, + StructureCheckViewSet) from api.views.course_view import CourseViewSet -from api.views.submission_view import SubmissionViewSet +from api.views.docker_view import DockerImageViewSet from api.views.faculty_view import FacultyViewSet -from api.views.checks_view import ( - ExtraCheckViewSet, FileExtensionViewSet, StructureCheckViewSet -) - +from api.views.group_view import GroupViewSet +from api.views.project_view import ProjectViewSet +from api.views.student_view import StudentViewSet +from api.views.submission_view import SubmissionViewSet +from api.views.teacher_view import TeacherViewSet +from api.views.user_view import UserViewSet +from django.urls import include, path +from rest_framework.routers import DefaultRouter router = DefaultRouter() router.register(r"users", UserViewSet, basename="user") @@ -29,6 +28,7 @@ router.register(r"extra-checks", ExtraCheckViewSet, basename="extra-check") router.register(r"file-extensions", FileExtensionViewSet, basename="file-extension") router.register(r"faculties", FacultyViewSet, basename="faculty") +router.register(r"docker-images", DockerImageViewSet, basename="docker-image") urlpatterns = [ path("", include(router.urls)), diff --git a/backend/api/views/admin_view.py b/backend/api/views/admin_view.py index 651ecc8a..39aeabcc 100644 --- a/backend/api/views/admin_view.py +++ b/backend/api/views/admin_view.py @@ -1,11 +1,11 @@ +from authentication.models import User +from authentication.serializers import UserIDSerializer, UserSerializer from django.utils.translation import gettext -from rest_framework.viewsets import ReadOnlyModelViewSet -from rest_framework.response import Response -from rest_framework.request import Request -from rest_framework.permissions import IsAdminUser from drf_yasg.utils import swagger_auto_schema -from authentication.serializers import UserSerializer, UserIDSerializer -from authentication.models import User +from rest_framework.permissions import IsAdminUser +from rest_framework.request import Request +from rest_framework.response import Response +from rest_framework.viewsets import ReadOnlyModelViewSet class AdminViewSet(ReadOnlyModelViewSet): diff --git a/backend/api/views/checks_view.py b/backend/api/views/checks_view.py index eaf18757..b96bd8f7 100644 --- a/backend/api/views/checks_view.py +++ b/backend/api/views/checks_view.py @@ -1,9 +1,14 @@ +import re + +from api.models.checks import ExtraCheck, StructureCheck +from api.models.extension import FileExtension +from api.permissions.check_permission import ExtraCheckPermission +from api.serializers.checks_serializer import (ExtraCheckSerializer, + FileExtensionSerializer, + StructureCheckSerializer) from rest_framework import viewsets -from ..models.extension import FileExtension -from ..models.checks import StructureCheck, ExtraCheck -from ..serializers.checks_serializer import ( - StructureCheckSerializer, ExtraCheckSerializer, FileExtensionSerializer -) +from rest_framework.mixins import (DestroyModelMixin, RetrieveModelMixin, + UpdateModelMixin) class StructureCheckViewSet(viewsets.ModelViewSet): @@ -11,9 +16,10 @@ class StructureCheckViewSet(viewsets.ModelViewSet): serializer_class = StructureCheckSerializer -class ExtraCheckViewSet(viewsets.ModelViewSet): +class ExtraCheckViewSet(RetrieveModelMixin, UpdateModelMixin, DestroyModelMixin, viewsets.GenericViewSet): queryset = ExtraCheck.objects.all() serializer_class = ExtraCheckSerializer + permission_classes = [ExtraCheckPermission] class FileExtensionViewSet(viewsets.ModelViewSet): diff --git a/backend/api/views/course_view.py b/backend/api/views/course_view.py index 38537f39..297401b9 100644 --- a/backend/api/views/course_view.py +++ b/backend/api/views/course_view.py @@ -1,28 +1,29 @@ -from django.utils.translation import gettext -from rest_framework import viewsets -from rest_framework.permissions import IsAdminUser -from rest_framework.decorators import action -from rest_framework.response import Response -from rest_framework.request import Request -from drf_yasg.utils import swagger_auto_schema -from rest_framework import status from api.models.course import Course -from api.permissions.course_permissions import ( - CoursePermission, - CourseAssistantPermission, - CourseStudentPermission, - CourseTeacherPermission -) +from api.permissions.course_permissions import (CourseAssistantPermission, + CoursePermission, + CourseStudentPermission, + CourseTeacherPermission) from api.permissions.role_permissions import IsTeacher, is_teacher -from api.serializers.course_serializer import ( - CourseSerializer, StudentJoinSerializer, StudentLeaveSerializer, CourseCloneSerializer, - TeacherJoinSerializer, TeacherLeaveSerializer -) -from api.views.pagination.basic_pagination import BasicPagination -from api.serializers.teacher_serializer import TeacherSerializer -from api.serializers.assistant_serializer import AssistantSerializer, AssistantIDSerializer +from api.serializers.assistant_serializer import (AssistantIDSerializer, + AssistantSerializer) +from api.serializers.course_serializer import (CourseCloneSerializer, + CourseSerializer, + StudentJoinSerializer, + StudentLeaveSerializer, + TeacherJoinSerializer, + TeacherLeaveSerializer) +from api.serializers.project_serializer import (CreateProjectSerializer, + ProjectSerializer) from api.serializers.student_serializer import StudentSerializer -from api.serializers.project_serializer import ProjectSerializer, CreateProjectSerializer +from api.serializers.teacher_serializer import TeacherSerializer +from api.views.pagination.basic_pagination import BasicPagination +from django.utils.translation import gettext +from drf_yasg.utils import swagger_auto_schema +from rest_framework import status, viewsets +from rest_framework.decorators import action +from rest_framework.permissions import IsAdminUser +from rest_framework.request import Request +from rest_framework.response import Response class CourseViewSet(viewsets.ModelViewSet): @@ -31,6 +32,7 @@ class CourseViewSet(viewsets.ModelViewSet): serializer_class = CourseSerializer permission_classes = [IsAdminUser | CoursePermission] + # TODO: Creating should return the info of the new object and not a message "created" (General TODO) def create(self, request: Request, *_): """Override the create method to add the teacher to the course""" serializer = CourseSerializer(data=request.data, context={"request": request}) diff --git a/backend/api/views/docker_view.py b/backend/api/views/docker_view.py new file mode 100644 index 00000000..769de796 --- /dev/null +++ b/backend/api/views/docker_view.py @@ -0,0 +1,29 @@ +from api.models.docker import DockerImage +from api.permissions.docker_permissions import DockerPermission +from api.permissions.role_permissions import IsAssistant, IsTeacher +from api.serializers.docker_serializer import DockerImageSerializer +from django.db.models import Q +from django.db.models.manager import BaseManager +from rest_framework.decorators import action +from rest_framework.mixins import (CreateModelMixin, DestroyModelMixin, + RetrieveModelMixin, UpdateModelMixin) +from rest_framework.permissions import IsAdminUser +from rest_framework.request import Request +from rest_framework.response import Response +from rest_framework.viewsets import GenericViewSet + + +class DockerImageViewSet(RetrieveModelMixin, CreateModelMixin, UpdateModelMixin, DestroyModelMixin, GenericViewSet): + + queryset = DockerImage.objects.all() + serializer_class = DockerImageSerializer + permission_classes = [DockerPermission] + + # TODO: Maybe not necessary + # https://www.django-rest-framework.org/api-guide/permissions/#overview-of-access-restriction-methods + def list(self, request: Request) -> Response: + images: BaseManager[DockerImage] = DockerImage.objects.all() + if not request.user.is_staff: + images = images.filter(Q(public=True) | Q(owner=request.user)) + serializer = DockerImageSerializer(images, many=True) + return Response(data=serializer.data, status=200) diff --git a/backend/api/views/project_view.py b/backend/api/views/project_view.py index 8ec27b23..06a4c1df 100644 --- a/backend/api/views/project_view.py +++ b/backend/api/views/project_view.py @@ -1,25 +1,28 @@ -from django.utils.translation import gettext -from rest_framework.mixins import CreateModelMixin, RetrieveModelMixin, UpdateModelMixin, DestroyModelMixin -from rest_framework.permissions import IsAdminUser -from rest_framework.viewsets import GenericViewSet -from rest_framework.decorators import action -from rest_framework.response import Response -from drf_yasg.utils import swagger_auto_schema -from api.permissions.project_permissions import ProjectGroupPermission, ProjectPermission from api.models.group import Group -from api.models.submission import Submission from api.models.project import Project -from api.serializers.checks_serializer import StructureCheckSerializer, ExtraCheckSerializer -from api.serializers.project_serializer import ( - StructureCheckAddSerializer, SubmissionStatusSerializer, - ProjectSerializer, TeacherCreateGroupSerializer -) - +from api.models.submission import Submission +from api.permissions.project_permissions import (ProjectGroupPermission, + ProjectPermission) +from api.serializers.checks_serializer import (ExtraCheckSerializer, + StructureCheckSerializer) from api.serializers.group_serializer import GroupSerializer +from api.serializers.project_serializer import (ProjectSerializer, + StructureCheckAddSerializer, + SubmissionStatusSerializer, + TeacherCreateGroupSerializer) from api.serializers.submission_serializer import SubmissionSerializer +from django.utils.translation import gettext +from drf_yasg.utils import swagger_auto_schema +from rest_framework.decorators import action +from rest_framework.mixins import (CreateModelMixin, DestroyModelMixin, + RetrieveModelMixin, UpdateModelMixin) +from rest_framework.permissions import IsAdminUser from rest_framework.request import Request +from rest_framework.response import Response +from rest_framework.viewsets import GenericViewSet +# TODO: Error message when creating a project with wrongly formatted date looks a bit weird class ProjectViewSet(CreateModelMixin, RetrieveModelMixin, UpdateModelMixin, @@ -104,7 +107,6 @@ def _add_structure_check(self, request: Request, **_): project: Project = self.get_object() - # Add submission to course serializer = StructureCheckAddSerializer( data=request.data, context={ @@ -134,6 +136,28 @@ def extra_checks(self, request, **_): ) return Response(serializer.data) + @extra_checks.mapping.post + @swagger_auto_schema(request_body=ExtraCheckSerializer) + def _add_extra_check(self, request: Request, **_): + """Add an extra_check to the project""" + + project: Project = self.get_object() + + serializer = ExtraCheckSerializer( + data=request.data, + context={ + "project": project, + "request": request + } + ) + + if serializer.is_valid(raise_exception=True): + serializer.save(project=project) + + return Response({ + "message": gettext("project.success.extra_check.add") + }) + @action(detail=True, methods=["get"], permission_classes=[IsAdminUser | ProjectGroupPermission]) def submission_status(self, request, **_): """Returns the current submission status for the given project diff --git a/backend/api/views/submission_view.py b/backend/api/views/submission_view.py index 279f105b..3769183e 100644 --- a/backend/api/views/submission_view.py +++ b/backend/api/views/submission_view.py @@ -1,6 +1,8 @@ from rest_framework import viewsets + from ..models.submission import Submission, SubmissionFile -from ..serializers.submission_serializer import SubmissionSerializer, SubmissionFileSerializer +from ..serializers.submission_serializer import (SubmissionFileSerializer, + SubmissionSerializer) class SubmissionFileViewSet(viewsets.ModelViewSet): diff --git a/backend/authentication/fixtures/users.yaml b/backend/authentication/fixtures/users.yaml index 45c08856..ed72a1a4 100644 --- a/backend/authentication/fixtures/users.yaml +++ b/backend/authentication/fixtures/users.yaml @@ -1,5 +1,5 @@ - model: authentication.user - pk: '1' + pk: "1" fields: last_login: null username: jdoe @@ -9,9 +9,9 @@ last_enrolled: 2023 create_time: 2024-02-29 20:35:45.690556+00:00 faculties: - - Wetenschappen + - Wetenschappen - model: authentication.user - pk: '123' + pk: "123" fields: last_login: null username: tboonen @@ -21,9 +21,9 @@ last_enrolled: 2023 create_time: 2024-02-29 20:35:45.686541+00:00 faculties: - - Psychologie_PedagogischeWetenschappen + - Psychologie_PedagogischeWetenschappen - model: authentication.user - pk: '124' + pk: "124" fields: last_login: null username: psagan @@ -33,9 +33,9 @@ last_enrolled: 2023 create_time: 2024-02-29 20:35:45.689543+00:00 faculties: - - Psychologie_PedagogischeWetenschappen + - Psychologie_PedagogischeWetenschappen - model: authentication.user - pk: '2' + pk: "2" fields: last_login: null username: bverhae @@ -45,9 +45,9 @@ last_enrolled: 2023 create_time: 2024-02-29 20:35:45.691565+00:00 faculties: - - Geneeskunde_Gezondheidswetenschappen + - Geneeskunde_Gezondheidswetenschappen - model: authentication.user - pk: '3' + pk: "3" fields: last_login: null username: somtin @@ -59,7 +59,7 @@ faculties: - Geneeskunde_Gezondheidswetenschappen - model: authentication.user - pk: '235' + pk: "235" fields: last_login: null username: bsimpson @@ -69,9 +69,9 @@ last_enrolled: 2023 create_time: 2024-02-29 20:35:45.687541+00:00 faculties: - - Wetenschappen + - Wetenschappen - model: authentication.user - pk: '236' + pk: "236" fields: last_login: null username: kclijster @@ -81,16 +81,4 @@ last_enrolled: 2023 create_time: 2024-02-29 20:35:45.688545+00:00 faculties: - - Psychologie_PedagogischeWetenschappen -- model: authentication.user - pk: '000200694919' - fields: - last_login: null - username: landmaes - email: lander.maes@ugent.be - first_name: Lander - last_name: Maes - last_enrolled: 2023 - create_time: 2024-02-29 20:35:45.688545+00:00 - faculties: - - Wetenschappen \ No newline at end of file + - Psychologie_PedagogischeWetenschappen diff --git a/backend/authentication/views.py b/backend/authentication/views.py index 617cd58e..5e241a86 100644 --- a/backend/authentication/views.py +++ b/backend/authentication/views.py @@ -1,14 +1,14 @@ -from django.shortcuts import redirect +from authentication.cas.client import client +from authentication.permissions import IsDebug +from authentication.serializers import CASTokenObtainSerializer, UserSerializer from django.contrib.auth import logout +from django.shortcuts import redirect from rest_framework.decorators import action -from rest_framework.viewsets import ViewSet -from rest_framework.request import Request -from rest_framework.response import Response from rest_framework.exceptions import AuthenticationFailed from rest_framework.permissions import AllowAny, IsAuthenticated -from authentication.permissions import IsDebug -from authentication.serializers import UserSerializer, CASTokenObtainSerializer -from authentication.cas.client import client +from rest_framework.request import Request +from rest_framework.response import Response +from rest_framework.viewsets import ViewSet from ypovoli import settings diff --git a/backend/notifications/logic.py b/backend/notifications/logic.py index b5df6d18..0d0b1f92 100644 --- a/backend/notifications/logic.py +++ b/backend/notifications/logic.py @@ -54,6 +54,7 @@ def _send_mails(): # Connection with the mail server connection = mail.get_connection() + # Construct and send each mail for notification in notifications: message = get_message_dict(notification) content = _("Email %(name)s %(title)s %(description)s") % { diff --git a/backend/poetry.lock b/backend/poetry.lock index df20168f..f2f23dd2 100644 --- a/backend/poetry.lock +++ b/backend/poetry.lock @@ -495,6 +495,27 @@ lint = ["flake8", "isort", "pep8"] python-jose = ["python-jose (==3.3.0)"] test = ["cryptography", "freezegun", "pytest", "pytest-cov", "pytest-django", "pytest-xdist", "tox"] +[[package]] +name = "docker" +version = "7.0.0" +description = "A Python library for the Docker Engine API." +optional = false +python-versions = ">=3.8" +files = [ + {file = "docker-7.0.0-py3-none-any.whl", hash = "sha256:12ba681f2777a0ad28ffbcc846a69c31b4dfd9752b47eb425a274ee269c5e14b"}, + {file = "docker-7.0.0.tar.gz", hash = "sha256:323736fb92cd9418fc5e7133bc953e11a9da04f4483f828b527db553f1e7e5a3"}, +] + +[package.dependencies] +packaging = ">=14.0" +pywin32 = {version = ">=304", markers = "sys_platform == \"win32\""} +requests = ">=2.26.0" +urllib3 = ">=1.26.0" + +[package.extras] +ssh = ["paramiko (>=2.4.3)"] +websockets = ["websocket-client (>=1.3.0)"] + [[package]] name = "drf-yasg" version = "1.21.7" @@ -1019,6 +1040,29 @@ files = [ {file = "pytz-2024.1.tar.gz", hash = "sha256:2a29735ea9c18baf14b448846bde5a48030ed267578472d8955cd0e7443a9812"}, ] +[[package]] +name = "pywin32" +version = "306" +description = "Python for Window Extensions" +optional = false +python-versions = "*" +files = [ + {file = "pywin32-306-cp310-cp310-win32.whl", hash = "sha256:06d3420a5155ba65f0b72f2699b5bacf3109f36acbe8923765c22938a69dfc8d"}, + {file = "pywin32-306-cp310-cp310-win_amd64.whl", hash = "sha256:84f4471dbca1887ea3803d8848a1616429ac94a4a8d05f4bc9c5dcfd42ca99c8"}, + {file = "pywin32-306-cp311-cp311-win32.whl", hash = "sha256:e65028133d15b64d2ed8f06dd9fbc268352478d4f9289e69c190ecd6818b6407"}, + {file = "pywin32-306-cp311-cp311-win_amd64.whl", hash = "sha256:a7639f51c184c0272e93f244eb24dafca9b1855707d94c192d4a0b4c01e1100e"}, + {file = "pywin32-306-cp311-cp311-win_arm64.whl", hash = "sha256:70dba0c913d19f942a2db25217d9a1b726c278f483a919f1abfed79c9cf64d3a"}, + {file = "pywin32-306-cp312-cp312-win32.whl", hash = "sha256:383229d515657f4e3ed1343da8be101000562bf514591ff383ae940cad65458b"}, + {file = "pywin32-306-cp312-cp312-win_amd64.whl", hash = "sha256:37257794c1ad39ee9be652da0462dc2e394c8159dfd913a8a4e8eb6fd346da0e"}, + {file = "pywin32-306-cp312-cp312-win_arm64.whl", hash = "sha256:5821ec52f6d321aa59e2db7e0a35b997de60c201943557d108af9d4ae1ec7040"}, + {file = "pywin32-306-cp37-cp37m-win32.whl", hash = "sha256:1c73ea9a0d2283d889001998059f5eaaba3b6238f767c9cf2833b13e6a685f65"}, + {file = "pywin32-306-cp37-cp37m-win_amd64.whl", hash = "sha256:72c5f621542d7bdd4fdb716227be0dd3f8565c11b280be6315b06ace35487d36"}, + {file = "pywin32-306-cp38-cp38-win32.whl", hash = "sha256:e4c092e2589b5cf0d365849e73e02c391c1349958c5ac3e9d5ccb9a28e017b3a"}, + {file = "pywin32-306-cp38-cp38-win_amd64.whl", hash = "sha256:e8ac1ae3601bee6ca9f7cb4b5363bf1c0badb935ef243c4733ff9a393b1690c0"}, + {file = "pywin32-306-cp39-cp39-win32.whl", hash = "sha256:e25fd5b485b55ac9c057f67d94bc203f3f6595078d1fb3b458c9c28b7153a802"}, + {file = "pywin32-306-cp39-cp39-win_amd64.whl", hash = "sha256:39b61c15272833b5c329a2989999dcae836b1eed650252ab1b7bfbe1d59f30f4"}, +] + [[package]] name = "pyyaml" version = "6.0.1" @@ -1288,13 +1332,13 @@ testing = ["build[virtualenv] (>=1.0.3)", "covdefaults (>=2.3)", "detect-test-po [[package]] name = "typing-extensions" -version = "4.10.0" +version = "4.11.0" description = "Backported and Experimental Type Hints for Python 3.8+" optional = false python-versions = ">=3.8" files = [ - {file = "typing_extensions-4.10.0-py3-none-any.whl", hash = "sha256:69b1a937c3a517342112fb4c6df7e72fc39a38e7891a5730ed4985b5214b5475"}, - {file = "typing_extensions-4.10.0.tar.gz", hash = "sha256:b0abd7c89e8fb96f98db18d86106ff1d90ab692004eb746cf6eda2682f91b3cb"}, + {file = "typing_extensions-4.11.0-py3-none-any.whl", hash = "sha256:c1f94d72897edaf4ce775bb7558d5b79d8126906a14ea5ed1635921406c0387a"}, + {file = "typing_extensions-4.11.0.tar.gz", hash = "sha256:83f085bd5ca59c80295fc2a82ab5dac679cbe02b9f33f7d83af68e241bea51b0"}, ] [[package]] @@ -1395,4 +1439,4 @@ brotli = ["Brotli"] [metadata] lock-version = "2.0" python-versions = "^3.11.4" -content-hash = "fc72c38f240e3a93e01f87850a6c16277b594999fd49891fc71107bd9e5ceeaf" +content-hash = "f695b0795b84d554a050594eda382a6d6e2df4392bffd8239baa894ec6944a15" diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 7cd91ced..9ed30719 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -23,6 +23,7 @@ gunicorn = "^21.2.0" whitenoise = "^6.6.0" flake8 = "^7.0.0" celery-types = "^0.22.0" +docker = "^7.0.0" faker = "^24.7.1" django-seed = "^0.3.1" diff --git a/backend/ypovoli/settings.py b/backend/ypovoli/settings.py index 27d287c9..f136aad2 100644 --- a/backend/ypovoli/settings.py +++ b/backend/ypovoli/settings.py @@ -21,7 +21,7 @@ BASE_DIR = Path(__file__).resolve().parent.parent MEDIA_ROOT = os.path.normpath(os.path.join(BASE_DIR, "data/production")) - +# TODO: What does this do? TESTING_BASE_LINK = "http://testserver" # SECURITY WARNING: keep the secret key used in production secret! @@ -33,7 +33,6 @@ ALLOWED_HOSTS = [DOMAIN_NAME] CSRF_TRUSTED_ORIGINS = ["https://" + DOMAIN_NAME] - # Application definition INSTALLED_APPS = [ # Built-ins @@ -173,4 +172,6 @@ FILE_PATHS = { "docker_images": "../data/docker_images/", + "extra_checks": "../data/extra_checks/", + "log_file": "../data/log_files/" }