diff --git a/backend/api/fixtures/realistic/realistic.yaml b/backend/api/fixtures/realistic/realistic.yaml index 84cf1c2a..bf2f4f15 100644 --- a/backend/api/fixtures/realistic/realistic.yaml +++ b/backend/api/fixtures/realistic/realistic.yaml @@ -172,6 +172,7 @@ time_limit: 10 memory_limit: 50 show_log: false + show_artifact: false - model: api.extracheck pk: 1 fields: @@ -182,6 +183,7 @@ time_limit: 30 memory_limit: 128 show_log: true + show_artifact: true # MARK: Students - model: api.student @@ -457,41 +459,49 @@ fields: extra_check: 0 log_file: fixtures/realistic/projects/0/0/submissions/0/submission_1/logs/log_extra_check_0.txt + artifact: "" - model: api.extracheckresult pk: 3 fields: extra_check: 1 log_file: fixtures/realistic/projects/0/0/submissions/0/submission_1/logs/log_extra_check_1.txt + artifact: fixtures/realistic/projects/0/0/submissions/0/submission_1/artifacts/artifact_extra_check_1.zip - model: api.extracheckresult pk: 5 fields: extra_check: 0 log_file: fixtures/realistic/projects/0/0/submissions/0/submission_2/logs/log_extra_check_0.txt + artifact: "" - model: api.extracheckresult pk: 6 fields: extra_check: 1 log_file: fixtures/realistic/projects/0/0/submissions/0/submission_2/logs/log_extra_check_1.txt + artifact: fixtures/realistic/projects/0/0/submissions/0/submission_2/artifacts/artifact_extra_check_1.zip - model: api.extracheckresult pk: 8 fields: extra_check: 0 log_file: "" + artifact: "" - model: api.extracheckresult pk: 9 fields: extra_check: 1 log_file: "" + artifact: "" - model: api.extracheckresult pk: 11 fields: extra_check: 0 log_file: fixtures/realistic/projects/0/0/submissions/1/submission_2/logs/log_extra_check_0.txt + artifact: "" - model: api.extracheckresult pk: 12 fields: extra_check: 1 log_file: fixtures/realistic/projects/0/0/submissions/1/submission_2/logs/log_extra_check_1.txt + artifact: "" # MARK: Teachers - model: api.teacher diff --git a/backend/api/locale/en/LC_MESSAGES/django.po b/backend/api/locale/en/LC_MESSAGES/django.po index dbe86326..dbf3b355 100755 --- 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-05-11 18:26+0200\n" +"POT-Creation-Date: 2024-05-15 19:49+0200\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -34,69 +34,69 @@ msgstr "Docker image is ready" msgid "dockerimage.state.error" msgstr "Docker image failed to build" -#: models/submission.py:61 +#: models/submission.py:62 msgid "submission.state.queued" msgstr "Queued" -#: models/submission.py:62 +#: models/submission.py:63 msgid "submission.state.running" msgstr "Running" -#: models/submission.py:63 +#: models/submission.py:64 msgid "submission.state.success" msgstr "Success" -#: models/submission.py:64 +#: models/submission.py:65 msgid "submission.state.failed" msgstr "Failed" -#: models/submission.py:69 +#: models/submission.py:70 msgid "submission.error.blockedextension" msgstr "The zip file contains a file with a non-allowed extension." -#: models/submission.py:70 +#: models/submission.py:71 msgid "submission.error.obligatedextensionnotfound" msgstr "" "The submitted zip file doesn't have any file with an obligated file " "extension." -#: models/submission.py:71 +#: models/submission.py:72 msgid "submission.error.filedirnotfound" msgstr "The submitted zip file lacks an obligated directory." -#: models/submission.py:74 +#: models/submission.py:75 msgid "submission.error.dockerimageerror" msgstr "try again later." -#: models/submission.py:75 +#: models/submission.py:76 msgid "submission.error.timelimit" msgstr "Timelimit exceeded." -#: models/submission.py:76 +#: models/submission.py:77 msgid "submission.error.memorylimit" msgstr "Memorylimit exceeded." -#: models/submission.py:77 +#: models/submission.py:78 msgid "submission.error.checkerror" msgstr "A check failed." -#: models/submission.py:78 +#: models/submission.py:79 msgid "submission.error.runtimeerror" msgstr "Crashed." -#: models/submission.py:79 +#: models/submission.py:80 msgid "submission.error.unknown" msgstr "Unkown error." -#: models/submission.py:80 +#: models/submission.py:81 msgid "submission.error.failedstructurecheck" msgstr "The zip file doesn't have the right structure." -#: serializers/checks_serializer.py:49 tests/test_project.py:494 +#: serializers/checks_serializer.py:49 tests/test_project.py:495 msgid "project.error.structure_checks.already_existing" msgstr "The structure check is already present in the project." -#: serializers/checks_serializer.py:65 tests/test_project.py:528 +#: serializers/checks_serializer.py:65 tests/test_project.py:529 msgid "project.error.structure_checks.extension_blocked_and_obligated" msgstr "An extension can't be blocked and obligated at the same time." @@ -112,71 +112,71 @@ msgstr "The field 'time_limit' has to be between 10 and 1000." msgid "extra_check.error.memory_limit" msgstr "The field 'memory_limit' has to be between 100 and 1024." -#: serializers/course_serializer.py:136 -msgid "courses.error.invitation_link" -msgstr "The invitation link is not unique, please try again." - -#: serializers/course_serializer.py:143 serializers/course_serializer.py:158 -#: serializers/course_serializer.py:177 serializers/course_serializer.py:196 -#: serializers/course_serializer.py:215 +#: serializers/course_serializer.py:136 serializers/course_serializer.py:151 +#: serializers/course_serializer.py:170 serializers/course_serializer.py:189 +#: serializers/course_serializer.py:208 msgid "courses.error.context" msgstr "The course is not supplied in the context." -#: serializers/course_serializer.py:164 tests/test_locale.py:28 +#: serializers/course_serializer.py:157 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:168 serializers/course_serializer.py:187 -#: serializers/course_serializer.py:206 serializers/course_serializer.py:225 +#: serializers/course_serializer.py:161 serializers/course_serializer.py:180 +#: serializers/course_serializer.py:199 serializers/course_serializer.py:218 msgid "courses.error.past_course" msgstr "The course is from a past year, thus cannot be manipulated." -#: serializers/course_serializer.py:183 +#: serializers/course_serializer.py:176 msgid "courses.error.students.not_present" msgstr "The student is not present in the course." -#: serializers/course_serializer.py:202 +#: serializers/course_serializer.py:195 msgid "courses.error.teachers.already_present" msgstr "The teacher is already present in the course." -#: serializers/course_serializer.py:221 +#: serializers/course_serializer.py:214 msgid "courses.error.teachers.not_present" msgstr "The teacher is not present in the course." -#: serializers/course_serializer.py:229 +#: serializers/course_serializer.py:222 msgid "courses.error.teachers.last_teacher" msgstr "The course must have at least one teacher." -#: serializers/docker_serializer.py:19 +#: serializers/docker_serializer.py:18 +msgid "docker.errors.no_staff" +msgstr "User is not allowed to assign othher owners than himself to the image." + +#: serializers/docker_serializer.py:31 msgid "docker.errors.custom" msgstr "User is not allowed to create public images" -#: serializers/group_serializer.py:49 +#: serializers/group_serializer.py:56 msgid "group.errors.score_exceeds_max" msgstr "The score exceeds the group's max score." -#: serializers/group_serializer.py:59 serializers/group_serializer.py:89 +#: serializers/group_serializer.py:66 serializers/group_serializer.py:96 msgid "group.error.context" msgstr "The group is not supplied in the context." -#: serializers/group_serializer.py:67 serializers/group_serializer.py:101 +#: serializers/group_serializer.py:74 serializers/group_serializer.py:108 msgid "group.errors.locked" msgstr "The group is currently locked." -#: serializers/group_serializer.py:71 +#: serializers/group_serializer.py:78 msgid "group.errors.full" msgstr "The group is already full." -#: serializers/group_serializer.py:75 +#: serializers/group_serializer.py:82 msgid "group.errors.not_in_course" msgstr "The student is not present in the related course." -#: serializers/group_serializer.py:79 +#: serializers/group_serializer.py:86 msgid "group.errors.already_in_group" msgstr "The student is already in the group." -#: serializers/group_serializer.py:97 +#: serializers/group_serializer.py:104 msgid "group.errors.not_present" msgstr "The student is currently not in the group." @@ -188,31 +188,31 @@ msgstr "Error while parsing the provided zip." msgid "project.errors.context" msgstr "The project is not supplied in the context." -#: serializers/project_serializer.py:85 +#: serializers/project_serializer.py:86 msgid "project.errors.start_date_in_past" msgstr "The start date of the project lies in the past." -#: serializers/project_serializer.py:99 +#: serializers/project_serializer.py:100 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:132 +#: serializers/project_serializer.py:142 msgid "project.errors.zip_structure" msgstr "Error while parsing the provided zip." -#: serializers/submission_serializer.py:67 tests/test_submission.py:330 +#: serializers/submission_serializer.py:96 tests/test_submission.py:275 msgid "project.error.submissions.past_project" msgstr "The deadline of the project has already passed." -#: serializers/submission_serializer.py:70 tests/test_submission.py:401 +#: serializers/submission_serializer.py:99 tests/test_submission.py:346 msgid "project.error.submissions.non_visible_project" msgstr "The project is currently in a non-visible state." -#: serializers/submission_serializer.py:73 tests/test_submission.py:431 +#: serializers/submission_serializer.py:102 tests/test_submission.py:376 msgid "project.error.submissions.archived_project" msgstr "The project is archived." -#: serializers/submission_serializer.py:76 +#: serializers/submission_serializer.py:105 msgid "project.error.submissions.no_files" msgstr "The submission is empty." @@ -280,10 +280,14 @@ msgstr "The student was successfully added." msgid "students.success.destroy" msgstr "The student was successfully destroyed." -#: views/submission_view.py:28 +#: views/submission_view.py:29 msgid "submission.download.zip" msgstr "No zip file available." -#: views/submission_view.py:49 +#: views/submission_view.py:50 msgid "extra_check_result.download.log" msgstr "No log file available." + +#: views/submission_view.py:60 +msgid "extra_check_result.download.artifact" +msgstr "No artifact available." diff --git a/backend/api/locale/nl/LC_MESSAGES/django.po b/backend/api/locale/nl/LC_MESSAGES/django.po index b89c1041..7acb1b3d 100755 --- 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-05-11 18:26+0200\n" +"POT-Creation-Date: 2024-05-15 19:49+0200\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -34,69 +34,69 @@ msgstr "Docker image is klaar." msgid "dockerimage.state.error" msgstr "Docker image is gefaald om te bouwen." -#: models/submission.py:61 +#: models/submission.py:62 msgid "submission.state.queued" msgstr "wachten" -#: models/submission.py:62 +#: models/submission.py:63 msgid "submission.state.running" msgstr "lopen" -#: models/submission.py:63 +#: models/submission.py:64 msgid "submission.state.success" msgstr "succes" -#: models/submission.py:64 +#: models/submission.py:65 msgid "submission.state.failed" msgstr "gefaald" -#: models/submission.py:69 +#: models/submission.py:70 msgid "submission.error.blockedextension" msgstr "De zip file bevat een niet toegelaten bestandstype." -#: models/submission.py:70 +#: models/submission.py:71 msgid "submission.error.obligatedextensionnotfound" msgstr "" "Er is geen enkel bestand met een bepaalde bestandstype die verplicht is in " "het ingediende zip-bestand." -#: models/submission.py:71 +#: models/submission.py:72 msgid "submission.error.filedirnotfound" msgstr "De ingediende zip file mankeerd een verplichtte map." -#: models/submission.py:74 +#: models/submission.py:75 msgid "submission.error.dockerimageerror" msgstr "Probeer later opnieuw." -#: models/submission.py:75 +#: models/submission.py:76 msgid "submission.error.timelimit" msgstr "Tijdslimit bereikt." -#: models/submission.py:76 +#: models/submission.py:77 msgid "submission.error.memorylimit" msgstr "Geheugenlimiet bereikt." -#: models/submission.py:77 +#: models/submission.py:78 msgid "submission.error.checkerror" msgstr "Een check faalde." -#: models/submission.py:78 +#: models/submission.py:79 msgid "submission.error.runtimeerror" msgstr "Crashed." -#: models/submission.py:79 +#: models/submission.py:80 msgid "submission.error.unknown" msgstr "Onbekende fout." -#: models/submission.py:80 +#: models/submission.py:81 msgid "submission.error.failedstructurecheck" msgstr "De ingediende zip file heeft niet de juiste structuur." -#: serializers/checks_serializer.py:49 tests/test_project.py:494 +#: serializers/checks_serializer.py:49 tests/test_project.py:495 msgid "project.error.structure_checks.already_existing" msgstr "De structuur check was al aanwezig." -#: serializers/checks_serializer.py:65 tests/test_project.py:528 +#: serializers/checks_serializer.py:65 tests/test_project.py:529 msgid "project.error.structure_checks.extension_blocked_and_obligated" msgstr "Een extensie kan niet geblokkeerd en vereist zijn tegelijkertijd." @@ -112,72 +112,72 @@ msgstr "Het veld 'time_limit' moet tussen 10 en 1000 liggen." msgid "extra_check.error.memory_limit" msgstr "Het veld 'memory_limit' moet tussen 100 en 1024 liggen." -#: serializers/course_serializer.py:136 -msgid "courses.error.invitation_link" -msgstr "De uitnodigingslink is niet uniek, probeer het opnieuw." - -#: serializers/course_serializer.py:143 serializers/course_serializer.py:158 -#: serializers/course_serializer.py:177 serializers/course_serializer.py:196 -#: serializers/course_serializer.py:215 +#: serializers/course_serializer.py:136 serializers/course_serializer.py:151 +#: serializers/course_serializer.py:170 serializers/course_serializer.py:189 +#: serializers/course_serializer.py:208 msgid "courses.error.context" msgstr "De opleiding is niet meegeleverd als context." -#: serializers/course_serializer.py:164 tests/test_locale.py:28 +#: serializers/course_serializer.py:157 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:168 serializers/course_serializer.py:187 -#: serializers/course_serializer.py:206 serializers/course_serializer.py:225 +#: serializers/course_serializer.py:161 serializers/course_serializer.py:180 +#: serializers/course_serializer.py:199 serializers/course_serializer.py:218 msgid "courses.error.past_course" msgstr "De opleiding die men probeert te manipuleren is van een vorig jaar." -#: serializers/course_serializer.py:183 +#: serializers/course_serializer.py:176 msgid "courses.error.students.not_present" msgstr "De student bevindt zich niet in de opleiding." -#: serializers/course_serializer.py:202 +#: serializers/course_serializer.py:195 msgid "courses.error.teachers.already_present" msgstr "De lesgever bevindt zich al in de opleiding." -#: serializers/course_serializer.py:221 +#: serializers/course_serializer.py:214 msgid "courses.error.teachers.not_present" msgstr "De lesgever bevindt zich niet in de opleiding." -#: serializers/course_serializer.py:229 +#: serializers/course_serializer.py:222 msgid "courses.error.teachers.last_teacher" msgstr "De opleiding moet minstens één lesgever hebben." -#: serializers/docker_serializer.py:19 +#: serializers/docker_serializer.py:18 +msgid "docker.errors.no_staff" +msgstr "Gebruiker is alleen toegelaten om zichzelf als eigenaar op te geven" + +#: serializers/docker_serializer.py:31 msgid "docker.errors.custom" msgstr "Gebruiker is niet toegelaten om publieke afbeeldingen te maken" -#: serializers/group_serializer.py:49 +#: serializers/group_serializer.py:56 msgid "group.errors.score_exceeds_max" msgstr "De score van de groep is groter dan de maximum score." -#: serializers/group_serializer.py:59 serializers/group_serializer.py:89 +#: serializers/group_serializer.py:66 serializers/group_serializer.py:96 msgid "group.error.context" msgstr "De groep is niet meegegeven als context waar dat nodig is." -#: serializers/group_serializer.py:67 serializers/group_serializer.py:101 +#: serializers/group_serializer.py:74 serializers/group_serializer.py:108 msgid "group.errors.locked" msgstr "De groep is momenteel vergrendeld." -#: serializers/group_serializer.py:71 +#: serializers/group_serializer.py:78 msgid "group.errors.full" msgstr "De groep is al vol." -#: serializers/group_serializer.py:75 +#: serializers/group_serializer.py:82 msgid "group.errors.not_in_course" msgstr "" "De student bevindt zich niet in de opleiding waartoe het project hoort." -#: serializers/group_serializer.py:79 +#: serializers/group_serializer.py:86 msgid "group.errors.already_in_group" msgstr "De student bevindt zich al in de groep." -#: serializers/group_serializer.py:97 +#: serializers/group_serializer.py:104 msgid "group.errors.not_present" msgstr "De student bevindt zich niet in de groep." @@ -189,31 +189,31 @@ msgstr "Error tijdens de zip te overlopen." msgid "project.errors.context" msgstr "Het project is niet meegegeven als context waar dat nodig is." -#: serializers/project_serializer.py:85 +#: serializers/project_serializer.py:86 msgid "project.errors.start_date_in_past" msgstr "De startdatum van het project ligt in het verleden." -#: serializers/project_serializer.py:99 +#: serializers/project_serializer.py:100 msgid "project.errors.deadline_before_start_date" msgstr "De uiterste inleverdatum voor het project ligt voor de startdatum." -#: serializers/project_serializer.py:132 +#: serializers/project_serializer.py:142 msgid "project.errors.zip_structure" msgstr "Error tijdens de zip te overlopen." -#: serializers/submission_serializer.py:67 tests/test_submission.py:330 +#: serializers/submission_serializer.py:96 tests/test_submission.py:275 msgid "project.error.submissions.past_project" msgstr "De uiterste inleverdatum voor het project is gepasseerd." -#: serializers/submission_serializer.py:70 tests/test_submission.py:401 +#: serializers/submission_serializer.py:99 tests/test_submission.py:346 msgid "project.error.submissions.non_visible_project" msgstr "Het project is niet zichtbaar." -#: serializers/submission_serializer.py:73 tests/test_submission.py:431 +#: serializers/submission_serializer.py:102 tests/test_submission.py:376 msgid "project.error.submissions.archived_project" msgstr "Het project is gearchiveerd." -#: serializers/submission_serializer.py:76 +#: serializers/submission_serializer.py:105 msgid "project.error.submissions.no_files" msgstr "De indiening is leeg" @@ -281,10 +281,16 @@ msgstr "De student is successvol toegevoegd." msgid "students.success.destroy" msgstr "De student is successvol verwijderd." -#: views/submission_view.py:28 +#: views/submission_view.py:29 msgid "submission.download.zip" msgstr "Geen zip bestand beschikbaar." -#: views/submission_view.py:49 +#: views/submission_view.py:50 msgid "extra_check_result.download.log" msgstr "Geen log bestand beschikbaar." + +#: views/submission_view.py:60 +#, fuzzy +#| msgid "extra_check_result.download.log" +msgid "extra_check_result.download.artifact" +msgstr "Geen artifact beschikbaar." diff --git a/backend/api/logic/get_file_path.py b/backend/api/logic/get_file_path.py index f0b735d0..c75adb0f 100644 --- a/backend/api/logic/get_file_path.py +++ b/backend/api/logic/get_file_path.py @@ -56,3 +56,8 @@ def get_docker_image_file_path(instance: DockerImage, _: str) -> str: def get_docker_image_tag(instance: DockerImage) -> str: return f"{DOCKER_BUILD_ROOT_NAME}_{instance.id}" + + +def get_extra_check_artifact_file_path(instance: ExtraCheckResult, uuid: str) -> str: + return (f"{_get_project_dir_path(instance.submission.group.project)}" + f"submissions/{instance.submission.group.id}/{uuid}/artifacts/{_get_uuid()}.zip") diff --git a/backend/api/migrations/0025_extracheckresult_artifact.py b/backend/api/migrations/0025_extracheckresult_artifact.py new file mode 100644 index 00000000..7e3984aa --- /dev/null +++ b/backend/api/migrations/0025_extracheckresult_artifact.py @@ -0,0 +1,24 @@ +# Generated by Django 5.0.4 on 2024-05-13 21:33 + +from api.logic.get_file_path import get_extra_check_artifact_file_path +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0024_alter_dockerimage_state'), + ] + + operations = [ + migrations.AddField( + model_name='extracheckresult', + name='artifact', + field=models.FileField(max_length=256, null=True, upload_to=get_extra_check_artifact_file_path), + ), + migrations.AddField( + model_name='extracheck', + name='show_artifact', + field=models.BooleanField(default=True), + ), + ] diff --git a/backend/api/models/checks.py b/backend/api/models/checks.py index efc7b42d..dfb7e2f0 100644 --- a/backend/api/models/checks.py +++ b/backend/api/models/checks.py @@ -14,8 +14,6 @@ class FileExtension(models.Model): unique=True ) -# TODO: Remove zip.* translations - class StructureCheck(models.Model): """Model that represents a structure check for a project. @@ -100,7 +98,6 @@ class ExtraCheck(models.Model): ) # Maximum memory the container uses in MB - # TODO: Set max and min memory_limit = models.PositiveSmallIntegerField( default=128, blank=False, @@ -113,3 +110,10 @@ class ExtraCheck(models.Model): blank=False, null=False ) + + # Whether the artifacts should made available to the student + show_artifact = models.BooleanField( + default=True, + blank=False, + null=False + ) diff --git a/backend/api/models/submission.py b/backend/api/models/submission.py index 20294604..ee3afdb1 100644 --- a/backend/api/models/submission.py +++ b/backend/api/models/submission.py @@ -1,6 +1,7 @@ from typing import TYPE_CHECKING -from api.logic.get_file_path import (get_extra_check_log_file_path, +from api.logic.get_file_path import (get_extra_check_artifact_file_path, + get_extra_check_log_file_path, get_submission_file_path) from api.models.checks import ExtraCheck, StructureCheck from api.models.group import Group @@ -137,3 +138,11 @@ class ExtraCheckResult(CheckResult): blank=False, null=True ) + + # File path for the artifact of the extra checks + artifact = models.FileField( + upload_to=get_extra_check_artifact_file_path, + max_length=256, + blank=False, + null=True + ) diff --git a/backend/api/permissions/group_permissions.py b/backend/api/permissions/group_permissions.py index e75da21d..00f15f6a 100644 --- a/backend/api/permissions/group_permissions.py +++ b/backend/api/permissions/group_permissions.py @@ -1,8 +1,10 @@ +from api.models.group import Group 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.views import APIView from rest_framework.viewsets import ViewSet @@ -59,6 +61,23 @@ def has_object_permission(self, request: Request, view: ViewSet, group) -> bool: class GroupSubmissionPermission(BasePermission): """Permission class for submission related group endpoints""" + def has_permission(self, request: Request, view: APIView) -> bool: + user: User = request.user + group_id = view.kwargs.get('pk') + group: Group | None = Group.objects.get(id=group_id) if group_id else None + + if group is None: + return True + + # Teachers and assistants of that course can view all submissions + if is_teacher(user): + return group.project.course.teachers.filter(id=user.teacher.id).exists() + + if is_assistant(user): + return group.project.course.assistants.filter(id=user.assistant.id).exists() + + return is_student(user) and group.students.filter(id=user.student.id).exists() + def had_object_permission(self, request: Request, view: ViewSet, group) -> bool: user: User = request.user course = group.project.course diff --git a/backend/api/permissions/project_permissions.py b/backend/api/permissions/project_permissions.py index 55ff054a..a13b4946 100644 --- a/backend/api/permissions/project_permissions.py +++ b/backend/api/permissions/project_permissions.py @@ -13,11 +13,6 @@ 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 - # We only allow teachers and assistants to create new projects. return is_teacher(user) or is_assistant(user) diff --git a/backend/api/permissions/submission_permissions.py b/backend/api/permissions/submission_permissions.py index 7bc7f642..7180a0fa 100644 --- a/backend/api/permissions/submission_permissions.py +++ b/backend/api/permissions/submission_permissions.py @@ -57,3 +57,18 @@ def has_object_permission(self, request: Request, view: APIView, obj: ExtraCheck return obj.extra_check.show_log return True + + +class ExtraCheckResultArtifactPermission(ExtraCheckResultPermission): + def has_object_permission(self, request: Request, view: APIView, obj: ExtraCheckResult) -> bool: + result = super().has_object_permission(request, view, obj) + + if not result: + return False + + user: User = cast(User, request.user) + + if is_student(user): + return obj.extra_check.show_artifact + + return True diff --git a/backend/api/serializers/docker_serializer.py b/backend/api/serializers/docker_serializer.py index eb009eb3..6eafef00 100644 --- a/backend/api/serializers/docker_serializer.py +++ b/backend/api/serializers/docker_serializer.py @@ -13,9 +13,21 @@ class Meta: def validate(self, attrs): data = super().validate(attrs=attrs) - data["owner"] = self.context["request"].user + if "owner" in data and data["owner"] != self.context["request"].user and not self.context["request"].user.is_staff: + # Only allow staff to set the owner of an image to someone else + raise ValidationError(_("docker.errors.no_staff")) + + if "owner" not in data: + # Add the owner data if not present + if not self.partial: + # If it's created assign the user who made the request + data["owner"] = self.context["request"].user + else: + # Else use the pre exisiting owner + data["owner"] = self.instance.owner if "public" in data and data["public"] and not data["owner"].is_staff: + # Only allow staff to have public images raise ValidationError(_("docker.errors.custom")) return data diff --git a/backend/api/serializers/project_serializer.py b/backend/api/serializers/project_serializer.py index 274d4e55..b4493229 100644 --- a/backend/api/serializers/project_serializer.py +++ b/backend/api/serializers/project_serializer.py @@ -1,7 +1,7 @@ from api.logic.parse_zip_files import parse_zip from api.models.group import Group from api.models.project import Project -from api.models.submission import Submission +from api.models.submission import Submission, ExtraCheckResult, StructureCheckResult, StateEnum from api.serializers.course_serializer import CourseSerializer from django.core.files.uploadedfile import InMemoryUploadedFile from django.utils import timezone @@ -14,28 +14,69 @@ class SubmissionStatusSerializer(serializers.Serializer): non_empty_groups = serializers.IntegerField(read_only=True) groups_submitted = serializers.IntegerField(read_only=True) - submissions_passed = serializers.IntegerField(read_only=True) + structure_checks_passed = serializers.IntegerField(read_only=True) + extra_checks_passed = serializers.IntegerField(read_only=True) def to_representation(self, instance: Project): """Return the submission status of the project""" if not isinstance(instance, Project): raise ValidationError(gettext("project.errors.invalid_instance")) - non_empty_groups = instance.groups.filter(students__isnull=False).count() - groups_submitted = Submission.objects.filter(group__project=instance).count() - submissions_passed = Submission.objects.filter(group__project=instance, is_valid=True).count() + non_empty_groups = Group.objects.filter(project=instance, students__isnull=False).distinct().count() + + groups_submitted_ids = Submission.objects.filter(group__project=instance).values_list('group__id', flat=True) + unique_groups = set(groups_submitted_ids) + groups_submitted = len(unique_groups) + + # The total amount of groups with at least one submission should never exceed the total number of non empty groups + # (the seeder does not account for this restriction) + if (groups_submitted > non_empty_groups): + non_empty_groups = groups_submitted + + passed_structure_checks_submission_ids = StructureCheckResult.objects.filter( + submission__group__project=instance, + submission__is_valid=True, + result=StateEnum.SUCCESS + ).values_list('submission__id', flat=True) + + passed_structure_checks_group_ids = Submission.objects.filter( + id__in=passed_structure_checks_submission_ids + ).values_list('group_id', flat=True) + + unique_groups = set(passed_structure_checks_group_ids) + structure_checks_passed = len(unique_groups) + + passed_extra_checks_submission_ids = ExtraCheckResult.objects.filter( + submission__group__project=instance, + submission__is_valid=True, + result=StateEnum.SUCCESS + ).values_list('submission__id', flat=True) + + passed_extra_checks_group_ids = Submission.objects.filter( + id__in=passed_extra_checks_submission_ids + ).values_list('group_id', flat=True) + + unique_groups = set(passed_extra_checks_group_ids) + extra_checks_passed = len(unique_groups) + + # The total number of passed extra checks combined with the number of passed structure checks + # can never exceed the total number of submissions (the seeder does not account for this restriction) + if (structure_checks_passed + extra_checks_passed > groups_submitted): + extra_checks_passed = groups_submitted - structure_checks_passed return { "non_empty_groups": non_empty_groups, "groups_submitted": groups_submitted, - "submissions_passed": submissions_passed, + "structure_checks_passed": structure_checks_passed, + "extra_checks_passed": extra_checks_passed } class Meta: fields = [ "non_empty_groups", "groups_submitted", - "submissions_passed", + "structure_checks_passed", + "extra_checks_passed" ] diff --git a/backend/api/serializers/submission_serializer.py b/backend/api/serializers/submission_serializer.py index db2d505c..acdf26f8 100644 --- a/backend/api/serializers/submission_serializer.py +++ b/backend/api/serializers/submission_serializer.py @@ -39,6 +39,9 @@ def to_representation(self, instance: ExtraCheckResult) -> dict | None: representation["log_file"] = request.build_absolute_uri( reverse("extra-check-result-detail", args=[str(instance.id)]) + "log/" ) + representation["artifact"] = request.build_absolute_uri( + reverse("extra-check-result-detail", args=[str(instance.id)]) + "artifact/" + ) return representation return None diff --git a/backend/api/signals.py b/backend/api/signals.py index b705255a..82bc905c 100644 --- a/backend/api/signals.py +++ b/backend/api/signals.py @@ -7,24 +7,26 @@ from api.models.student import Student from api.models.submission import (ExtraCheckResult, StateEnum, StructureCheckResult, Submission) -from api.tasks.docker_image import task_docker_image_build +from api.tasks.docker_image import (task_docker_image_build, + task_docker_image_remove) from api.tasks.extra_check import task_extra_check_start from api.tasks.structure_check import task_structure_check_start from authentication.models import User from authentication.signals import user_created -from django.db.models.signals import post_delete, post_save +from django.db.models.signals import post_delete, post_save, pre_delete from django.dispatch import Signal, receiver -# Signals +# MARK: Signals run_docker_image_build = Signal() +run_docker_image_remove = Signal() run_all_checks = Signal() run_structure_checks = Signal() run_extra_checks = Signal() -# Receivers +# MARK: Receivers @receiver(user_created) @@ -44,6 +46,11 @@ def _run_docker_image_build(docker_image: DockerImage, **_): task_docker_image_build.apply_async((docker_image,)) +@receiver(run_docker_image_remove) +def _run_docker_image_remove(docker_image: DockerImage, **_): + task_docker_image_remove.apply_async((docker_image,)) + + @receiver(run_all_checks) def _run_all_checks(submission: Submission, **_): # Get all checks @@ -75,7 +82,7 @@ def _run_extra_checks(submission: Submission, **_): task_extra_check_start.apply_async((True, extra_check_result,)) -# Hooks +# MARK: Hooks @receiver(post_save, sender=StructureCheck) @@ -108,7 +115,6 @@ def hook_extra_check(sender, instance: ExtraCheck, **kwargs): @receiver(post_save, sender=Submission) def hook_submission(sender, instance: Submission, created: bool, **kwargs): - # TODO: Maybe remove the raw check if created and not kwargs.get('raw', False): run_all_checks.send(sender=Submission, submission=instance) pass @@ -116,11 +122,16 @@ def hook_submission(sender, instance: Submission, created: bool, **kwargs): @receiver(post_save, sender=DockerImage) def hook_docker_image(sender, instance: DockerImage, created: bool, **kwargs): - # Run when it's created if created: run_docker_image_build.send(sender=DockerImage, docker_image=instance) -# Helpers + +@receiver(pre_delete, sender=DockerImage) +def hook_docker_image_delete(sender, instance: DockerImage, **kwargs): + run_docker_image_remove.send(sender=DockerImage, docker_image=instance) + + +# MARK: Helpers # Get all structure checks and create a result for each one diff --git a/backend/api/tasks/docker_image.py b/backend/api/tasks/docker_image.py index cf614a0d..3636ef36 100644 --- a/backend/api/tasks/docker_image.py +++ b/backend/api/tasks/docker_image.py @@ -6,7 +6,6 @@ from ypovoli.settings import MEDIA_ROOT -# TODO: Remove built image when it's deleted from the database @shared_task def task_docker_image_build(docker_image: DockerImage): # Set state @@ -19,9 +18,18 @@ def task_docker_image_build(docker_image: DockerImage): client.images.build(path=MEDIA_ROOT, dockerfile=docker_image.file.path, tag=get_docker_image_tag(docker_image), rm=True, quiet=True, forcerm=True) docker_image.state = StateEnum.READY - except (docker.errors.APIError, docker.errors.BuildError, docker.errors.APIError, TypeError): + except (docker.errors.APIError, docker.errors.BuildError, TypeError): docker_image.state = StateEnum.ERROR # TODO: Sent notification # Update the state docker_image.save() + + +@shared_task +def task_docker_image_remove(docker_image: DockerImage): + try: + client = docker.from_env() + client.images.remove(get_docker_image_tag(docker_image)) + except docker.errors.APIError: + pass diff --git a/backend/api/tasks/extra_check.py b/backend/api/tasks/extra_check.py index 7ad94afc..5ddb1565 100644 --- a/backend/api/tasks/extra_check.py +++ b/backend/api/tasks/extra_check.py @@ -1,3 +1,5 @@ +import io +import os import shutil import zipfile from time import sleep @@ -11,6 +13,7 @@ from api.models.docker import StateEnum as DockerStateEnum from api.models.submission import ErrorMessageEnum, ExtraCheckResult, StateEnum from celery import shared_task +from django.core.files import File from django.core.files.base import ContentFile from docker.models.containers import Container from docker.types import LogConfig @@ -48,6 +51,7 @@ def task_extra_check_start(structure_check_result: bool, extra_check_result: Ext submission_directory = "/".join(extra_check_result.submission.zip.path.split("/") [:-1]) + "/submission/" # Directory where the files will be extracted + artifacts_directory = f"{submission_directory}/artifacts" # Directory where the artifacts will be stored extra_check_name = extra_check_result.extra_check.file.name.split("/")[-1] # Name of the extra check file submission_uuid = extra_check_result.submission.zip.path.split("/")[-2] # Uuid of the submission @@ -55,6 +59,9 @@ def task_extra_check_start(structure_check_result: bool, extra_check_result: Ext with zipfile.ZipFile(extra_check_result.submission.zip.path, 'r') as zip: zip.extractall(submission_directory) + # Create artifacts directory + os.makedirs(artifacts_directory, exist_ok=True) + container: Container | None = None try: @@ -76,6 +83,9 @@ def task_extra_check_start(structure_check_result: bool, extra_check_result: Ext f"{get_submission_full_dir_path(extra_check_result.submission)}/submission": { "bind": "/submission", "mode": "rw" }, + f"{get_submission_full_dir_path(extra_check_result.submission)}/submission/artifacts": { + "bind": "/submission/artifacts", "mode": "rw" + }, get_extra_check_file_full_path(extra_check_result.extra_check): { "bind": f"/submission/{extra_check_name}", "mode": "ro" } @@ -141,6 +151,7 @@ def task_extra_check_start(structure_check_result: bool, extra_check_result: Ext extra_check_result.error_message = ErrorMessageEnum.UNKNOWN # Cleanup and data saving + # Start by saving any logs finally: logs: str if container: @@ -153,7 +164,19 @@ def task_extra_check_start(structure_check_result: bool, extra_check_result: Ext logs = "Container error" extra_check_result.log_file.save(submission_uuid, content=ContentFile(logs), save=False) - extra_check_result.save() + + # Zip and save any possible artifacts + memory_zip = io.BytesIO() + if os.listdir(artifacts_directory): + with zipfile.ZipFile(memory_zip, 'w') as zip: + for root, _, files in os.walk(artifacts_directory): + for file in files: + zip.write(os.path.join(root, file), os.path.relpath(os.path.join(root, file), artifacts_directory)) + + memory_zip.seek(0) + extra_check_result.artifact.save(submission_uuid, ContentFile(memory_zip.read()), False) + + extra_check_result.save() # Remove directory try: diff --git a/backend/api/tests/test_project.py b/backend/api/tests/test_project.py index 98047703..0684088e 100644 --- a/backend/api/tests/test_project.py +++ b/backend/api/tests/test_project.py @@ -802,10 +802,10 @@ def test_submission_status_non_empty_groups(self): # Only two of the three created groups contain at least one student self.assertEqual( content_json["status"], - {"non_empty_groups": 2, "groups_submitted": 0, "submissions_passed": 0}, + {"non_empty_groups": 2, "groups_submitted": 0, "extra_checks_passed": 0, "structure_checks_passed": 0}, ) - def test_submission_status_groups_submitted_and_passed_checks(self): + def test_submission_status_groups_submitted_and_not_passed_checks(self): """Retrieve the submission status for a project.""" course = create_course(name="test course", academic_startyear=2024) project = create_project( @@ -866,7 +866,7 @@ def test_submission_status_groups_submitted_and_passed_checks(self): self.assertEqual( content_json["status"], - {"non_empty_groups": 3, "groups_submitted": 2, "submissions_passed": 2}, + {"non_empty_groups": 3, "groups_submitted": 2, "extra_checks_passed": 0, "structure_checks_passed": 0}, ) def test_retrieve_list_submissions(self): diff --git a/backend/api/views/course_view.py b/backend/api/views/course_view.py index c3f42786..3adc2e6c 100644 --- a/backend/api/views/course_view.py +++ b/backend/api/views/course_view.py @@ -10,9 +10,9 @@ from api.serializers.assistant_serializer import (AssistantIDSerializer, AssistantSerializer) from api.serializers.course_serializer import (CourseCloneSerializer, - SaveInvitationLinkSerializer, CourseSerializer, CreateCourseSerializer, + SaveInvitationLinkSerializer, StudentJoinSerializer, StudentLeaveSerializer, TeacherJoinSerializer, @@ -39,7 +39,6 @@ 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 = CreateCourseSerializer(data=request.data, context={"request": request}) diff --git a/backend/api/views/docker_view.py b/backend/api/views/docker_view.py index 525ced88..96173e7b 100644 --- a/backend/api/views/docker_view.py +++ b/backend/api/views/docker_view.py @@ -1,22 +1,19 @@ from api.models.docker import DockerImage from api.permissions.docker_permissions import DockerPermission from api.serializers.docker_serializer import DockerImageSerializer -from rest_framework.permissions import IsAdminUser +from api.views.pagination.basic_pagination import BasicPagination 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 -from api.views.pagination.basic_pagination import BasicPagination - - -# TODO: Remove update abilities, maybe? -class DockerImageViewSet(RetrieveModelMixin, CreateModelMixin, UpdateModelMixin, DestroyModelMixin, GenericViewSet): +class DockerImageViewSet(RetrieveModelMixin, UpdateModelMixin, CreateModelMixin, DestroyModelMixin, GenericViewSet): queryset = DockerImage.objects.all() serializer_class = DockerImageSerializer @@ -70,8 +67,6 @@ def delete(self, request: Request, **_) -> Response: return Response(response) - # 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: diff --git a/backend/api/views/group_view.py b/backend/api/views/group_view.py index c3595f4d..7c118bcd 100644 --- a/backend/api/views/group_view.py +++ b/backend/api/views/group_view.py @@ -40,7 +40,6 @@ def students(self, request, **_): ) return Response(serializer.data) - # TODO: I can access this endpoint unauthorized @action(detail=True, permission_classes=[IsAdminUser | GroupSubmissionPermission]) def submissions(self, request, **_): """Returns a list of submissions for the given group""" diff --git a/backend/api/views/submission_view.py b/backend/api/views/submission_view.py index 9010b64e..37911477 100644 --- a/backend/api/views/submission_view.py +++ b/backend/api/views/submission_view.py @@ -1,8 +1,9 @@ from api.models.submission import (ExtraCheckResult, StructureCheckResult, Submission) from api.permissions.submission_permissions import ( - ExtraCheckResultLogPermission, ExtraCheckResultPermission, - StructureCheckResultPermission, SubmissionPermission) + ExtraCheckResultArtifactPermission, ExtraCheckResultLogPermission, + ExtraCheckResultPermission, StructureCheckResultPermission, + SubmissionPermission) from api.serializers.submission_serializer import ( ExtraCheckResultSerializer, StructureCheckResultSerializer, SubmissionSerializer) @@ -15,7 +16,6 @@ from rest_framework.viewsets import GenericViewSet -# TODO: Permission to ask for logs class SubmissionViewSet(RetrieveModelMixin, GenericViewSet): queryset = Submission.objects.all() serializer_class = SubmissionSerializer @@ -42,11 +42,20 @@ class ExtraCheckResultViewSet(RetrieveModelMixin, GenericViewSet): serializer_class = ExtraCheckResultSerializer permission_classes = [ExtraCheckResultPermission] - @action(detail=True, permission_classes=[IsAdminUser | ExtraCheckResultLogPermission]) + @action(detail=True, permission_classes=[IsAdminUser | ExtraCheckResultArtifactPermission]) def log(self, request, **__): extra_check_result: ExtraCheckResult = self.get_object() if not extra_check_result.log_file: return Response({"message": _("extra_check_result.download.log")}, status=404) - return FileResponse(open(extra_check_result.log_file.path, "rb"), as_attachment=True) + return FileResponse(open(extra_check_result.log_file.path, "rb"), as_attachment=True, filename="log.txt") + + @action(detail=True, permission_classes=[IsAdminUser | ExtraCheckResultLogPermission]) + def artifact(self, request, **__): + extra_check_result: ExtraCheckResult = self.get_object() + + if not extra_check_result.artifact: + return Response({"message": _("extra_check_result.download.artifact")}, status=404) + + return FileResponse(open(extra_check_result.artifact.path, "rb"), as_attachment=True, filename="artifact.zip") diff --git a/backend/data/fixtures/realistic/projects/0/0/checks/generate_gibberish.sh b/backend/data/fixtures/realistic/projects/0/0/checks/generate_gibberish.sh index 182840c5..9690ec1e 100755 --- a/backend/data/fixtures/realistic/projects/0/0/checks/generate_gibberish.sh +++ b/backend/data/fixtures/realistic/projects/0/0/checks/generate_gibberish.sh @@ -1,5 +1,7 @@ #!/bin/bash +# generate gibberish logs + # Function to generate a random sequence of characters generate_sequence() { cat /dev/urandom | tr -dc 'a-zA-Z0-9' | head -c 50 @@ -14,3 +16,7 @@ while [ $count -le 50 ]; do generate_sequence count=$((count+1)) done + +# Generate an artifact + +wget https://golang.org/doc/gopher/modelsheet.jpg -P artifacts diff --git a/backend/data/fixtures/realistic/projects/0/0/submissions/0/submission_1/artifacts/artifact_extra_check_0.zip b/backend/data/fixtures/realistic/projects/0/0/submissions/0/submission_1/artifacts/artifact_extra_check_0.zip new file mode 100644 index 00000000..e69de29b diff --git a/backend/data/fixtures/realistic/projects/0/0/submissions/0/submission_1/artifacts/artifact_extra_check_1.zip b/backend/data/fixtures/realistic/projects/0/0/submissions/0/submission_1/artifacts/artifact_extra_check_1.zip new file mode 100644 index 00000000..912548a7 Binary files /dev/null and b/backend/data/fixtures/realistic/projects/0/0/submissions/0/submission_1/artifacts/artifact_extra_check_1.zip differ diff --git a/backend/data/fixtures/realistic/projects/0/0/submissions/0/submission_2/artifacts/artifact_extra_check_0.zip b/backend/data/fixtures/realistic/projects/0/0/submissions/0/submission_2/artifacts/artifact_extra_check_0.zip new file mode 100644 index 00000000..e69de29b diff --git a/backend/data/fixtures/realistic/projects/0/0/submissions/0/submission_2/artifacts/artifact_extra_check_1.zip b/backend/data/fixtures/realistic/projects/0/0/submissions/0/submission_2/artifacts/artifact_extra_check_1.zip new file mode 100644 index 00000000..912548a7 Binary files /dev/null and b/backend/data/fixtures/realistic/projects/0/0/submissions/0/submission_2/artifacts/artifact_extra_check_1.zip differ diff --git a/frontend/src/assets/lang/app/en.json b/frontend/src/assets/lang/app/en.json index 330b5b33..f3fc6ed8 100644 --- a/frontend/src/assets/lang/app/en.json +++ b/frontend/src/assets/lang/app/en.json @@ -186,7 +186,8 @@ "noSubmissions": "This project does not have any submissions", "submissions": "Submission | Submissions", "groups": "Group | Groups", - "testsSucceed": "Succeeded tests", + "structureTestsSucceed": "Succeeded structure tests", + "extraTestsSucceed": "Succeeded extra tests", "testsFail": "Failed tests", "submit": "Submit" }, diff --git a/frontend/src/assets/lang/app/nl.json b/frontend/src/assets/lang/app/nl.json index 066c1331..b615a703 100644 --- a/frontend/src/assets/lang/app/nl.json +++ b/frontend/src/assets/lang/app/nl.json @@ -183,7 +183,8 @@ "noSubmissions": "Dit project heeft geen indieningen", "submissions": "Indiening | Indieningen", "groups": "Groep | Groepen", - "testsSucceed": "Geslaagde testen", + "structureTestsSucceed": "Geslaagde structuur testen", + "extraTestsSucceed": "Geslaagde extra testen", "testsFail": "Gefaalde testen", "submit": "Indienen" }, diff --git a/frontend/src/components/projects/ProjectMeter.vue b/frontend/src/components/projects/ProjectMeter.vue index 823dd351..c02fc00c 100644 --- a/frontend/src/components/projects/ProjectMeter.vue +++ b/frontend/src/components/projects/ProjectMeter.vue @@ -17,15 +17,23 @@ const { t } = useI18n(); const meterItems = computed(() => { const groups = props.project !== null ? props.project.status.non_empty_groups : 0; const groupsSubmitted = props.project !== null ? props.project.status.groups_submitted : 0; - const submissionsPassed = props.project !== null ? props.project.status.submissions_passed : 0; - const submissionsFailed = groupsSubmitted - submissionsPassed; + const structureChecksPassed = props.project !== null ? props.project.status.structure_checks_passed : 0; + const extraChecksPassed = props.project !== null ? props.project.status.extra_checks_passed : 0; + const submissionsFailed = groupsSubmitted - structureChecksPassed; + return [ { - value: (submissionsPassed / groups) * 100, + value: (extraChecksPassed / groups) * 100, color: '#749b68', - label: t('components.card.testsSucceed'), + label: t('components.card.extraTestsSucceed'), icon: 'pi pi-check', }, + { + value: (structureChecksPassed / groups) * 100, + color: '#fa9746', + label: t('components.card.structureTestsSucceed'), + icon: 'pi pi-exclamation-circle', + }, { value: (submissionsFailed / groups) * 100, color: '#FF5445', diff --git a/frontend/src/test/unit/services/setup/delete_handlers.ts b/frontend/src/test/unit/services/setup/delete_handlers.ts index c43ac547..593db386 100644 --- a/frontend/src/test/unit/services/setup/delete_handlers.ts +++ b/frontend/src/test/unit/services/setup/delete_handlers.ts @@ -29,4 +29,9 @@ export const deleteHandlers = [ assistants.splice(index, 1); return HttpResponse.json(assistants); }), + http.delete(baseUrl + endpoints.structureChecks.retrieve.replace('{id}', ':id'), async ({ params }) => { + const index = structureChecks.findIndex((x) => x.id === params.id); + structureChecks.splice(index, 1); + return HttpResponse.json(structureChecks); + }), ]; diff --git a/frontend/src/test/unit/services/setup/get_handlers.ts b/frontend/src/test/unit/services/setup/get_handlers.ts index d212b3f6..51a805ba 100644 --- a/frontend/src/test/unit/services/setup/get_handlers.ts +++ b/frontend/src/test/unit/services/setup/get_handlers.ts @@ -86,27 +86,10 @@ export const getHandlers = [ const project = projects.find((x) => x.id === params.id); const groupIds = project !== null && project !== undefined ? project.groups : []; const submissionIds = project !== null && project !== undefined ? project.submissions : []; - const subGroupIds = Array.from( - new Set(submissions.filter((x) => submissionIds.includes(x.id)).map((x) => x.group)), - ); - // Filter submissions for each subgroup and get the submission with the highest number - const subgroupSubmissions = subGroupIds.map((groupId) => { - const submissionsForGroup = submissions.filter((submission) => submission.group === groupId); - if (submissionsForGroup.length > 0) { - return submissionsForGroup.reduce((maxSubmission, currentSubmission) => { - return currentSubmission.submission_number > maxSubmission.submission_number - ? currentSubmission - : maxSubmission; - }); - } else { - return null; - } - }); return HttpResponse.json({ groups_submitted: new Set(submissions.filter((x) => submissionIds.includes(x.id)).map((x) => x.group)).size, non_empty_groups: groups.filter((x) => groupIds.includes(x.id) && x.students.length > 0).length, - submissions_passed: subgroupSubmissions.filter((x) => x?.structureChecks_passed).length, }); }), http.get(baseUrl + endpoints.structureChecks.byProject.replace('{projectId}', ':id'), ({ params }) => { diff --git a/frontend/src/test/unit/services/setup/post_handlers.ts b/frontend/src/test/unit/services/setup/post_handlers.ts index 5409c5df..e935501c 100644 --- a/frontend/src/test/unit/services/setup/post_handlers.ts +++ b/frontend/src/test/unit/services/setup/post_handlers.ts @@ -54,18 +54,29 @@ export const postHandlers = [ faculties.push(newFaculty); return HttpResponse.json(faculties); }), - http.post(baseUrl + endpoints.groups.byProject.replace('{projectId}', ':id'), async ({ request, params }) => { + http.post(baseUrl + endpoints.groups.byProject.replace('{projectId}', ':id'), async ({ request }) => { const buffer = await request.arrayBuffer(); const requestBody = new TextDecoder().decode(buffer); const newGroup = JSON.parse(requestBody); groups.push(newGroup); return HttpResponse.json(groups); }), - http.post(baseUrl + endpoints.projects.byCourse.replace('{courseId}', ':id'), async ({ request, params }) => { + http.post(baseUrl + endpoints.projects.byCourse.replace('{courseId}', ':id'), async ({ request }) => { const buffer = await request.arrayBuffer(); const requestBody = new TextDecoder().decode(buffer); const newProject = JSON.parse(requestBody); projects.push(newProject); return HttpResponse.json(projects); }), + http.post( + baseUrl + endpoints.structureChecks.byProject.replace('{projectId}', ':id'), + async ({ request, params }) => { + const buffer = await request.arrayBuffer(); + const requestBody = new TextDecoder().decode(buffer); + const newStructureCheck = JSON.parse(requestBody); + newStructureCheck.project = params.id; + structureChecks.push(newStructureCheck); + return HttpResponse.json(structureChecks); + }, + ), ]; diff --git a/frontend/src/test/unit/services/structure_check.test.ts b/frontend/src/test/unit/services/structure_check.test.ts index 91a596c0..5e8a6c6f 100644 --- a/frontend/src/test/unit/services/structure_check.test.ts +++ b/frontend/src/test/unit/services/structure_check.test.ts @@ -1,6 +1,7 @@ /* eslint-disable @typescript-eslint/no-unused-vars */ import { describe, it, expect } from 'vitest'; import { useStructureCheck } from '@/composables/services/structure_check.service.ts'; +import { StructureCheck } from '@/types/StructureCheck'; const { structureChecks, @@ -59,3 +60,52 @@ describe('structureCheck', (): void => { expect(structureChecks.value?.[3]?.blocked_extensions).toBeNull(); }); }); + +it('create structureCheck', async () => { + resetService(); + + const exampleStructureCheck = new StructureCheck( + '', // id + 'structure_check_name', // name + [], // blocked extensions + [], // obligated extensions + null, // project + ); + + await getStructureCheckByProject('123456'); + expect(structureChecks).not.toBeNull(); + expect(Array.isArray(structureChecks.value)).toBe(true); + const prevLength = structureChecks.value?.length ?? 0; + + await createStructureCheck(exampleStructureCheck, '123456'); + await getStructureCheckByProject('123456'); + + expect(structureChecks).not.toBeNull(); + expect(Array.isArray(structureChecks.value)).toBe(true); + expect(structureChecks.value?.length).toBe(prevLength + 1); + + // Only check for fields that are sent to the backend + expect(structureChecks.value?.[prevLength]?.name).toBe('structure_check_name'); +}); + +it('delete structureCheck', async () => { + resetService(); + + await getStructureCheckByProject('123456'); + expect(structureChecks.value).not.toBeNull(); + expect(Array.isArray(structureChecks.value)).toBe(true); + const prevLength = structureChecks.value?.length ?? 0; + + let structureCheckId = ''; + if (structureChecks.value?.[2]?.id !== undefined && structureChecks.value?.[2].id !== null) { + structureCheckId = structureChecks.value?.[2]?.id; + } + + await deleteStructureCheck(structureCheckId); + await getStructureCheckByProject('123456'); + + expect(structureChecks).not.toBeNull(); + expect(Array.isArray(structureChecks.value)).toBe(true); + expect(structureChecks.value?.length).toBe(prevLength - 1); + expect(structureChecks.value?.[2]?.id).not.toBe(structureCheckId); +}); diff --git a/frontend/src/test/unit/services/submission_status_service.test.ts b/frontend/src/test/unit/services/submission_status_service.test.ts index 896a4df6..d7a90797 100644 --- a/frontend/src/test/unit/services/submission_status_service.test.ts +++ b/frontend/src/test/unit/services/submission_status_service.test.ts @@ -16,6 +16,6 @@ describe('submision_status', (): void => { expect(submissionStatus.value).not.toBeNull(); expect(submissionStatus.value?.groups_submitted).toBe(1); expect(submissionStatus.value?.non_empty_groups).toBe(2); - expect(submissionStatus.value?.submissions_passed).toBe(1); + // No need to check for structure_check_passed and extra_checks_passed since those queries are not implemented in the frontend }); }); diff --git a/frontend/src/test/unit/types/data.ts b/frontend/src/test/unit/types/data.ts index 9c004c27..255ddc3c 100644 --- a/frontend/src/test/unit/types/data.ts +++ b/frontend/src/test/unit/types/data.ts @@ -123,8 +123,9 @@ export const structureCheckData = { export const submissionStatusData = { non_empty_groups: 5, - groups_submitted: 2, - submissions_passed: 1, + groups_submitted: 4, + structure_checks_passed: 3, + extra_checks_passed: 1, }; export const submissionData = { diff --git a/frontend/src/test/unit/types/helper.ts b/frontend/src/test/unit/types/helper.ts index 60c32d6b..bab3fede 100644 --- a/frontend/src/test/unit/types/helper.ts +++ b/frontend/src/test/unit/types/helper.ts @@ -156,7 +156,8 @@ export function createSubmissionStatus(submissionStatusData: any): SubmissionSta return new SubmissionStatus( submissionStatusData.non_empty_groups, submissionStatusData.groups_submitted, - submissionStatusData.submissions_passed, + submissionStatusData.structure_checks_passed, + submissionStatusData.extra_checks_passed, ); } diff --git a/frontend/src/test/unit/types/submissionStatus.test.ts b/frontend/src/test/unit/types/submissionStatus.test.ts index b1f7d5b8..7629ad60 100644 --- a/frontend/src/test/unit/types/submissionStatus.test.ts +++ b/frontend/src/test/unit/types/submissionStatus.test.ts @@ -11,7 +11,8 @@ describe('submissionStatus type', () => { expect(submissionStatus).toBeInstanceOf(SubmissionStatus); expect(submissionStatus.non_empty_groups).toBe(submissionStatusData.non_empty_groups); expect(submissionStatus.groups_submitted).toBe(submissionStatusData.groups_submitted); - expect(submissionStatus.submissions_passed).toBe(submissionStatusData.submissions_passed); + expect(submissionStatus.structure_checks_passed).toBe(submissionStatusData.structure_checks_passed); + expect(submissionStatus.extra_checks_passed).toBe(submissionStatusData.extra_checks_passed); }); it('create a submissionStatus instance from JSON data', () => { @@ -21,6 +22,7 @@ describe('submissionStatus type', () => { expect(submissionStatus).toBeInstanceOf(SubmissionStatus); expect(submissionStatus.non_empty_groups).toBe(submissionStatusData.non_empty_groups); expect(submissionStatus.groups_submitted).toBe(submissionStatusData.groups_submitted); - expect(submissionStatus.submissions_passed).toBe(submissionStatusData.submissions_passed); + expect(submissionStatus.structure_checks_passed).toBe(submissionStatusData.structure_checks_passed); + expect(submissionStatus.extra_checks_passed).toBe(submissionStatusData.extra_checks_passed); }); }); diff --git a/frontend/src/types/SubmisionStatus.ts b/frontend/src/types/SubmisionStatus.ts index 159b1129..597bbece 100644 --- a/frontend/src/types/SubmisionStatus.ts +++ b/frontend/src/types/SubmisionStatus.ts @@ -2,7 +2,8 @@ export class SubmissionStatus { constructor( public non_empty_groups: number, public groups_submitted: number, - public submissions_passed: number, + public structure_checks_passed: number, + public extra_checks_passed: number, ) {} /** @@ -14,7 +15,8 @@ export class SubmissionStatus { return new SubmissionStatus( submissionStatus.non_empty_groups, submissionStatus.groups_submitted, - submissionStatus.submissions_passed, + submissionStatus.structure_checks_passed, + submissionStatus.extra_checks_passed, ); } }