diff --git a/backend/.tool-versions b/backend/.tool-versions new file mode 100644 index 00000000..c10ee4eb --- /dev/null +++ b/backend/.tool-versions @@ -0,0 +1 @@ +python 3.11.4 diff --git a/backend/api/fixtures/realistic/realistic.yaml b/backend/api/fixtures/realistic/realistic.yaml index bf2f4f15..2b101567 100644 --- a/backend/api/fixtures/realistic/realistic.yaml +++ b/backend/api/fixtures/realistic/realistic.yaml @@ -324,6 +324,54 @@ submission_time: 2024-05-11 12:08:21.147551+00:00 is_valid: true zip: fixtures/realistic/projects/0/0/submissions/1/submission_2/submission.zip +- model: api.submission + pk: 5 + fields: + group: 1 + submission_number: 5 + submission_time: 2024-05-12 12:08:21.147551+00:00 + is_valid: true + zip: fixtures/realistic/projects/0/0/submissions/1/submission_2/submission.zip +- model: api.submission + pk: 6 + fields: + group: 1 + submission_number: 6 + submission_time: 2024-05-13 12:08:21.147551+00:00 + is_valid: true + zip: fixtures/realistic/projects/0/0/submissions/1/submission_2/submission.zip +- model: api.submission + pk: 7 + fields: + group: 1 + submission_number: 7 + submission_time: 2024-05-14 12:08:21.147551+00:00 + is_valid: true + zip: fixtures/realistic/projects/0/0/submissions/1/submission_2/submission.zip +- model: api.submission + pk: 8 + fields: + group: 1 + submission_number: 8 + submission_time: 2024-05-15 12:08:21.147551+00:00 + is_valid: true + zip: fixtures/realistic/projects/0/0/submissions/1/submission_2/submission.zip +- model: api.submission + pk: 9 + fields: + group: 1 + submission_number: 9 + submission_time: 2024-05-16 12:08:21.147551+00:00 + is_valid: true + zip: fixtures/realistic/projects/0/0/submissions/1/submission_2/submission.zip +- model: api.submission + pk: 10 + fields: + group: 1 + submission_number: 10 + submission_time: 2024-05-17 12:08:21.147551+00:00 + is_valid: true + zip: fixtures/realistic/projects/0/0/submissions/1/submission_2/submission.zip # MARK: Check Result - model: api.checkresult @@ -434,6 +482,168 @@ submission: 4 result: SUCCESS error_message: null +- model: api.checkresult + pk: 13 + fields: + polymorphic_ctype: + - api + - structurecheckresult + submission: 5 + result: FAILED + error_message: BLOCKED_EXTENSION +- model: api.checkresult + pk: 14 + fields: + polymorphic_ctype: + - api + - extracheckresult + submission: 5 + result: FAILED + error_message: FAILED_STRUCTURE_CHECK +- model: api.checkresult + pk: 15 + fields: + polymorphic_ctype: + - api + - extracheckresult + submission: 5 + result: FAILED + error_message: FAILED_STRUCTURE_CHECK +- model: api.checkresult + pk: 16 + fields: + polymorphic_ctype: + - api + - structurecheckresult + submission: 6 + result: FAILED + error_message: OBLIGATED_EXTENSION_NOT_FOUND +- model: api.checkresult + pk: 17 + fields: + polymorphic_ctype: + - api + - extracheckresult + submission: 6 + result: FAILED + error_message: FAILED_STRUCTURE_CHECK +- model: api.checkresult + pk: 18 + fields: + polymorphic_ctype: + - api + - extracheckresult + submission: 6 + result: FAILED + error_message: FAILED_STRUCTURE_CHECK +- model: api.checkresult + pk: 19 + fields: + polymorphic_ctype: + - api + - structurecheckresult + submission: 7 + result: FAILED + error_message: FILE_DIR_NOT_FOUND +- model: api.checkresult + pk: 20 + fields: + polymorphic_ctype: + - api + - extracheckresult + submission: 7 + result: FAILED + error_message: FAILED_STRUCTURE_CHECK +- model: api.checkresult + pk: 21 + fields: + polymorphic_ctype: + - api + - extracheckresult + submission: 7 + result: FAILED + error_message: FAILED_STRUCTURE_CHECK +- model: api.checkresult + pk: 22 + fields: + polymorphic_ctype: + - api + - structurecheckresult + submission: 8 + result: SUCCESS + error_message: null +- model: api.checkresult + pk: 23 + fields: + polymorphic_ctype: + - api + - extracheckresult + submission: 8 + result: FAILED + error_message: DOCKER_IMAGE_ERROR +- model: api.checkresult + pk: 24 + fields: + polymorphic_ctype: + - api + - extracheckresult + submission: 8 + result: FAILED + error_message: TIME_LIMIT +- model: api.checkresult + pk: 25 + fields: + polymorphic_ctype: + - api + - structurecheckresult + submission: 9 + result: SUCCESS + error_message: null +- model: api.checkresult + pk: 26 + fields: + polymorphic_ctype: + - api + - extracheckresult + submission: 9 + result: FAILED + error_message: MEMORY_LIMIT +- model: api.checkresult + pk: 27 + fields: + polymorphic_ctype: + - api + - extracheckresult + submission: 9 + result: FAILED + error_message: CHECK_ERROR +- model: api.checkresult + pk: 28 + fields: + polymorphic_ctype: + - api + - structurecheckresult + submission: 10 + result: SUCCESS + error_message: null +- model: api.checkresult + pk: 29 + fields: + polymorphic_ctype: + - api + - extracheckresult + submission: 10 + result: FAILED + error_message: RUNTIME_ERROR +- model: api.checkresult + pk: 30 + fields: + polymorphic_ctype: + - api + - extracheckresult + submission: 10 + result: FAILED + error_message: UNKNOWN # MARK: Strucure Check results - model: api.structurecheckresult @@ -452,6 +662,30 @@ pk: 10 fields: structure_check: 0 +- model: api.structurecheckresult + pk: 13 + fields: + structure_check: 0 +- model: api.structurecheckresult + pk: 16 + fields: + structure_check: 0 +- model: api.structurecheckresult + pk: 19 + fields: + structure_check: 0 +- model: api.structurecheckresult + pk: 22 + fields: + structure_check: 0 +- model: api.structurecheckresult + pk: 25 + fields: + structure_check: 0 +- model: api.structurecheckresult + pk: 28 + fields: + structure_check: 0 # MARK: Extra Check Results - model: api.extracheckresult @@ -502,6 +736,78 @@ extra_check: 1 log_file: fixtures/realistic/projects/0/0/submissions/1/submission_2/logs/log_extra_check_1.txt artifact: "" +- model: api.extracheckresult + pk: 14 + 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: 15 + fields: + extra_check: 1 + log_file: fixtures/realistic/projects/0/0/submissions/1/submission_2/logs/log_extra_check_1.txt + artifact: "" +- model: api.extracheckresult + pk: 17 + 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: 18 + fields: + extra_check: 1 + log_file: fixtures/realistic/projects/0/0/submissions/1/submission_2/logs/log_extra_check_1.txt + artifact: "" +- model: api.extracheckresult + pk: 20 + 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: 21 + fields: + extra_check: 1 + log_file: fixtures/realistic/projects/0/0/submissions/1/submission_2/logs/log_extra_check_1.txt + artifact: "" +- model: api.extracheckresult + pk: 23 + 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: 24 + fields: + extra_check: 1 + log_file: fixtures/realistic/projects/0/0/submissions/1/submission_2/logs/log_extra_check_1.txt + artifact: "" +- model: api.extracheckresult + pk: 26 + 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: 27 + fields: + extra_check: 1 + log_file: fixtures/realistic/projects/0/0/submissions/1/submission_2/logs/log_extra_check_1.txt + artifact: "" +- model: api.extracheckresult + pk: 29 + 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: 30 + 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 dbf3b355..51b4c470 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-15 19:49+0200\n" +"POT-Creation-Date: 2024-05-20 12:28+0200\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -152,67 +152,71 @@ msgstr "User is not allowed to assign othher owners than himself to the image." msgid "docker.errors.custom" msgstr "User is not allowed to create public images" -#: serializers/group_serializer.py:56 +#: serializers/group_serializer.py:57 msgid "group.errors.score_exceeds_max" msgstr "The score exceeds the group's max score." -#: serializers/group_serializer.py:66 serializers/group_serializer.py:96 +#: serializers/group_serializer.py:67 serializers/group_serializer.py:97 msgid "group.error.context" msgstr "The group is not supplied in the context." -#: serializers/group_serializer.py:74 serializers/group_serializer.py:108 +#: serializers/group_serializer.py:75 serializers/group_serializer.py:113 msgid "group.errors.locked" msgstr "The group is currently locked." -#: serializers/group_serializer.py:78 +#: serializers/group_serializer.py:79 msgid "group.errors.full" msgstr "The group is already full." -#: serializers/group_serializer.py:82 +#: serializers/group_serializer.py:83 msgid "group.errors.not_in_course" msgstr "The student is not present in the related course." -#: serializers/group_serializer.py:86 +#: serializers/group_serializer.py:87 msgid "group.errors.already_in_group" msgstr "The student is already in the group." -#: serializers/group_serializer.py:104 +#: serializers/group_serializer.py:105 +msgid "group.errors.size_one" +msgstr "Unable to leave a group with size 1." + +#: serializers/group_serializer.py:109 msgid "group.errors.not_present" msgstr "The student is currently not in the group." -#: serializers/project_serializer.py:22 +#: serializers/project_serializer.py:23 msgid "project.errors.invalid_instance" msgstr "Error while parsing the provided zip." -#: serializers/project_serializer.py:81 +#: serializers/project_serializer.py:122 msgid "project.errors.context" msgstr "The project is not supplied in the context." -#: serializers/project_serializer.py:86 +#: serializers/project_serializer.py:127 msgid "project.errors.start_date_in_past" msgstr "The start date of the project lies in the past." -#: serializers/project_serializer.py:100 +#: serializers/project_serializer.py:141 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:142 +#: serializers/project_serializer.py:183 msgid "project.errors.zip_structure" msgstr "Error while parsing the provided zip." -#: serializers/submission_serializer.py:96 tests/test_submission.py:275 +#: serializers/submission_serializer.py:99 tests/test_submission.py:275 msgid "project.error.submissions.past_project" msgstr "The deadline of the project has already passed." -#: serializers/submission_serializer.py:99 tests/test_submission.py:346 +#: serializers/submission_serializer.py:102 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:102 tests/test_submission.py:376 +#: serializers/submission_serializer.py:105 tests/test_submission.py:376 msgid "project.error.submissions.archived_project" msgstr "The project is archived." -#: serializers/submission_serializer.py:105 +#: serializers/submission_serializer.py:108 msgid "project.error.submissions.no_files" msgstr "The submission is empty." @@ -228,39 +232,39 @@ msgstr "The teacher was successfully added." msgid "teachers.success.destroy" msgstr "The teacher was successfully destroyed." -#: views/course_view.py:137 +#: views/course_view.py:136 msgid "courses.success.assistants.add" msgstr "The assistant was successfully added to the course." -#: views/course_view.py:164 +#: views/course_view.py:163 msgid "courses.success.assistants.remove" msgstr "The assistant was successfully removed from the course." -#: views/course_view.py:226 +#: views/course_view.py:225 msgid "courses.success.students.add" msgstr "The student was successfully added to the course." -#: views/course_view.py:247 +#: views/course_view.py:246 msgid "courses.success.students.remove" msgstr "The student was successfully removed from the course." -#: views/course_view.py:292 +#: views/course_view.py:291 msgid "courses.success.teachers.add" msgstr "The teacher was successfully added to the course." -#: views/course_view.py:316 +#: views/course_view.py:315 msgid "courses.success.teachers.remove" msgstr "The teacher was successfully removed from the course." -#: views/group_view.py:74 +#: views/group_view.py:73 msgid "group.success.students.add" msgstr "The student was successfully added to the group." -#: views/group_view.py:94 +#: views/group_view.py:93 msgid "group.success.students.remove" msgstr "The student was successfully removed from the group." -#: views/group_view.py:113 +#: views/group_view.py:112 msgid "group.success.submissions.add" msgstr "The submission was successfully added to the group." @@ -288,6 +292,6 @@ msgstr "No zip file available." msgid "extra_check_result.download.log" msgstr "No log file available." -#: views/submission_view.py:60 +#: views/submission_view.py:59 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 7acb1b3d..4e39a9b6 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-15 19:49+0200\n" +"POT-Creation-Date: 2024-05-20 12:28+0200\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -152,68 +152,72 @@ msgstr "Gebruiker is alleen toegelaten om zichzelf als eigenaar op te geven" msgid "docker.errors.custom" msgstr "Gebruiker is niet toegelaten om publieke afbeeldingen te maken" -#: serializers/group_serializer.py:56 +#: serializers/group_serializer.py:57 msgid "group.errors.score_exceeds_max" msgstr "De score van de groep is groter dan de maximum score." -#: serializers/group_serializer.py:66 serializers/group_serializer.py:96 +#: serializers/group_serializer.py:67 serializers/group_serializer.py:97 msgid "group.error.context" msgstr "De groep is niet meegegeven als context waar dat nodig is." -#: serializers/group_serializer.py:74 serializers/group_serializer.py:108 +#: serializers/group_serializer.py:75 serializers/group_serializer.py:113 msgid "group.errors.locked" msgstr "De groep is momenteel vergrendeld." -#: serializers/group_serializer.py:78 +#: serializers/group_serializer.py:79 msgid "group.errors.full" msgstr "De groep is al vol." -#: serializers/group_serializer.py:82 +#: serializers/group_serializer.py:83 msgid "group.errors.not_in_course" msgstr "" "De student bevindt zich niet in de opleiding waartoe het project hoort." -#: serializers/group_serializer.py:86 +#: serializers/group_serializer.py:87 msgid "group.errors.already_in_group" msgstr "De student bevindt zich al in de groep." -#: serializers/group_serializer.py:104 +#: serializers/group_serializer.py:105 +msgid "group.errors.size_one" +msgstr "Het is niet mogelijk om een group met grootte 1 te verlaten." + +#: serializers/group_serializer.py:109 msgid "group.errors.not_present" msgstr "De student bevindt zich niet in de groep." -#: serializers/project_serializer.py:22 +#: serializers/project_serializer.py:23 msgid "project.errors.invalid_instance" msgstr "Error tijdens de zip te overlopen." -#: serializers/project_serializer.py:81 +#: serializers/project_serializer.py:122 msgid "project.errors.context" msgstr "Het project is niet meegegeven als context waar dat nodig is." -#: serializers/project_serializer.py:86 +#: serializers/project_serializer.py:127 msgid "project.errors.start_date_in_past" msgstr "De startdatum van het project ligt in het verleden." -#: serializers/project_serializer.py:100 +#: serializers/project_serializer.py:141 msgid "project.errors.deadline_before_start_date" msgstr "De uiterste inleverdatum voor het project ligt voor de startdatum." -#: serializers/project_serializer.py:142 +#: serializers/project_serializer.py:183 msgid "project.errors.zip_structure" msgstr "Error tijdens de zip te overlopen." -#: serializers/submission_serializer.py:96 tests/test_submission.py:275 +#: serializers/submission_serializer.py:99 tests/test_submission.py:275 msgid "project.error.submissions.past_project" msgstr "De uiterste inleverdatum voor het project is gepasseerd." -#: serializers/submission_serializer.py:99 tests/test_submission.py:346 +#: serializers/submission_serializer.py:102 tests/test_submission.py:346 msgid "project.error.submissions.non_visible_project" msgstr "Het project is niet zichtbaar." -#: serializers/submission_serializer.py:102 tests/test_submission.py:376 +#: serializers/submission_serializer.py:105 tests/test_submission.py:376 msgid "project.error.submissions.archived_project" msgstr "Het project is gearchiveerd." -#: serializers/submission_serializer.py:105 +#: serializers/submission_serializer.py:108 msgid "project.error.submissions.no_files" msgstr "De indiening is leeg" @@ -229,39 +233,39 @@ msgstr "De lesgever is successvol toegevoegd." msgid "teachers.success.destroy" msgstr "De lesgever is succesvol verwijderd." -#: views/course_view.py:137 +#: views/course_view.py:136 msgid "courses.success.assistants.add" msgstr "De assistent is succesvol toegevoegd aan de opleiding." -#: views/course_view.py:164 +#: views/course_view.py:163 msgid "courses.success.assistants.remove" msgstr "De assistent is succesvol verwijderd uit de opleiding." -#: views/course_view.py:226 +#: views/course_view.py:225 msgid "courses.success.students.add" msgstr "De student is succesvol toegevoegd aan de opleiding." -#: views/course_view.py:247 +#: views/course_view.py:246 msgid "courses.success.students.remove" msgstr "De student is succesvol verwijderd uit de opleiding." -#: views/course_view.py:292 +#: views/course_view.py:291 msgid "courses.success.teachers.add" msgstr "De lesgever is succesvol toegevoegd aan de opleiding." -#: views/course_view.py:316 +#: views/course_view.py:315 msgid "courses.success.teachers.remove" msgstr "De lesgever is succesvol verwijderd uit de opleiding." -#: views/group_view.py:74 +#: views/group_view.py:73 msgid "group.success.students.add" msgstr "De student is succesvol toegevoegd aan de groep." -#: views/group_view.py:94 +#: views/group_view.py:93 msgid "group.success.students.remove" msgstr "De student is succesvol verwijderd uit de groep." -#: views/group_view.py:113 +#: views/group_view.py:112 msgid "group.success.submissions.add" msgstr "De indiening is succesvol toegevoegd aan de groep." @@ -289,7 +293,7 @@ msgstr "Geen zip bestand beschikbaar." msgid "extra_check_result.download.log" msgstr "Geen log bestand beschikbaar." -#: views/submission_view.py:60 +#: views/submission_view.py:59 #, fuzzy #| msgid "extra_check_result.download.log" msgid "extra_check_result.download.artifact" diff --git a/backend/api/logic/parse_zip_files.py b/backend/api/logic/parse_zip_files.py index 7858a585..cc21f531 100644 --- a/backend/api/logic/parse_zip_files.py +++ b/backend/api/logic/parse_zip_files.py @@ -12,12 +12,13 @@ def parse_zip(project: Project, zip_file: InMemoryUploadedFile) -> bool: zip_file.seek(0) - with zipfile.ZipFile(zip_file, 'r') as zip: - files = zip.namelist() + with zipfile.ZipFile(zip_file, 'r') as zip_file: + files = zip_file.namelist() directories = [file for file in files if file.endswith('/')] # Check if all directories start the same common_prefix = os.path.commonprefix(directories) + if '/' in common_prefix: prefixes = common_prefix.split('/') if common_prefix[-1] != '/': @@ -31,6 +32,7 @@ def parse_zip(project: Project, zip_file: InMemoryUploadedFile) -> bool: # Add potential top level files top_level_files = [file for file in files if '/' not in file] + if top_level_files: create_check(project, '', files) diff --git a/backend/api/models/project.py b/backend/api/models/project.py index 2a390b70..ceb786c1 100644 --- a/backend/api/models/project.py +++ b/backend/api/models/project.py @@ -108,6 +108,6 @@ def increase_deadline(self, days): self.save() if TYPE_CHECKING: - groups: RelatedManager['Group'] - structure_checks: RelatedManager['StructureCheck'] - extra_checks: RelatedManager['ExtraCheck'] + groups: RelatedManager[Group] + structure_checks: RelatedManager[StructureCheck] + extra_checks: RelatedManager[ExtraCheck] diff --git a/backend/api/serializers/checks_serializer.py b/backend/api/serializers/checks_serializer.py index a6c06922..54454815 100644 --- a/backend/api/serializers/checks_serializer.py +++ b/backend/api/serializers/checks_serializer.py @@ -3,104 +3,115 @@ from api.models.project import Project from django.utils.translation import gettext as _ from rest_framework import serializers -from rest_framework.exceptions import ValidationError +from rest_framework.exceptions import ValidationError, NotFound class FileExtensionSerializer(serializers.ModelSerializer): + extension = serializers.CharField( + required=True, + max_length=10 + ) + class Meta: model = FileExtension - fields = ["extension"] - - -class FileExtensionHyperLinkedRelatedField(serializers.HyperlinkedRelatedField): - view_name = "file-extensions-detail" - queryset = FileExtension.objects.all() + fields = ["id", "extension"] - def to_internal_value(self, data): - try: - return self.queryset.get(pk=data) - except FileExtension.DoesNotExist: - return self.fail("no_match") - -# TODO: Support partial updates class StructureCheckSerializer(serializers.ModelSerializer): - project = serializers.HyperlinkedRelatedField( - view_name="project-detail", - read_only=True + read_only=True, + view_name="project-detail" ) - obligated_extensions = FileExtensionSerializer(many=True, required=False, default=[]) - - blocked_extensions = FileExtensionSerializer(many=True, required=False, default=[]) - - class Meta: - model = StructureCheck - fields = "__all__" - + obligated_extensions = FileExtensionSerializer( + many=True + ) -# TODO: Simplify -class StructureCheckAddSerializer(StructureCheckSerializer): + blocked_extensions = FileExtensionSerializer( + many=True + ) def validate(self, attrs): + """Validate the structure check""" project: Project = self.context["project"] - if project.structure_checks.filter(path=attrs["path"]).count(): + + # The structure check path should not exist already + if project.structure_checks.filter(path=attrs["path"]).exists(): raise ValidationError(_("project.error.structure_checks.already_existing")) - obl_ext = set() - for ext in self.context["obligated"]: - extension, result = FileExtension.objects.get_or_create( - extension=ext - ) - obl_ext.add(extension) - attrs["obligated_extensions"] = obl_ext + # The same extension should not be in both blocked and obligated + blocked = set([ext["extension"] for ext in attrs["blocked_extensions"]]) + obligated = set([ext["extension"] for ext in attrs["obligated_extensions"]]) - block_ext = set() - for ext in self.context["blocked"]: - extension, result = FileExtension.objects.get_or_create( - extension=ext - ) - if extension in obl_ext: - raise ValidationError(_("project.error.structure_checks.extension_blocked_and_obligated")) - block_ext.add(extension) - attrs["blocked_extensions"] = block_ext + if blocked.intersection(obligated): + raise ValidationError(_("project.error.structure_checks.extension_blocked_and_obligated")) return attrs + def create(self, validated_data: dict) -> StructureCheck: + """Create a new structure check""" + blocked = validated_data.pop("blocked_extensions") + obligated = validated_data.pop("obligated_extensions") -class DockerImagerHyperLinkedRelatedField(serializers.HyperlinkedRelatedField): - view_name = "docker-image-detail" - queryset = DockerImage.objects.all() + check: StructureCheck = StructureCheck.objects.create( + path=validated_data.pop("path"), + **validated_data + ) - def to_internal_value(self, data): - try: - return self.queryset.get(pk=data) - except DockerImage.DoesNotExist: - return self.fail("no_match") + for ext in obligated: + ext, _ = FileExtension.objects.get_or_create( + extension=ext["extension"] + ) + check.obligated_extensions.add(ext) + # Add blocked extensions + for ext in blocked: + ext, _ = FileExtension.objects.get_or_create( + extension=ext["extension"] + ) + check.blocked_extensions.add(ext) -class ExtraCheckSerializer(serializers.ModelSerializer): + return check + + class Meta: + model = StructureCheck + fields = "__all__" + +class ExtraCheckSerializer(serializers.ModelSerializer): project = serializers.HyperlinkedRelatedField( - view_name="project-detail", - read_only=True + read_only=True, + view_name="project-detail" ) - docker_image = DockerImagerHyperLinkedRelatedField() + docker_image = serializers.HyperlinkedRelatedField( + read_only=True, + view_name="docker-image-detail" + ) class Meta: model = ExtraCheck fields = "__all__" - def validate(self, attrs): + def validate(self, attrs: dict) -> dict: + """Validate the extra check""" 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")) + # Check if the docker image is provided + if "docker_image" not in self.initial_data: + raise serializers.ValidationError(_("extra_check.error.docker_image")) + + # Check if the docker image exists + image = DockerImage.objects.get( + id=self.initial_data["docker_image"] + ) + + if image is None: + raise NotFound(_("extra_check.error.docker_image")) + + data["docker_image"] = image + # Check if the time limit and memory limit are in the correct range if "time_limit" in data and not 10 <= data["time_limit"] <= 1000: raise serializers.ValidationError(_("extra_check.error.time_limit")) diff --git a/backend/api/serializers/course_serializer.py b/backend/api/serializers/course_serializer.py index 5d995446..6786c605 100644 --- a/backend/api/serializers/course_serializer.py +++ b/backend/api/serializers/course_serializer.py @@ -45,7 +45,11 @@ class CourseSerializer(serializers.ModelSerializer): def validate(self, attrs: dict) -> dict: """Extra custom validation for course serializer""" + attrs = super().validate(attrs) + + # Clean the description attrs['description'] = clean(attrs['description']) + return attrs def to_representation(self, instance): diff --git a/backend/api/serializers/fields/__init__.py b/backend/api/serializers/fields/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/api/serializers/fields/expandable_hyperlinked_field.py b/backend/api/serializers/fields/expandable_hyperlinked_field.py new file mode 100644 index 00000000..c424e471 --- /dev/null +++ b/backend/api/serializers/fields/expandable_hyperlinked_field.py @@ -0,0 +1,34 @@ +from typing import Type + +from rest_framework import serializers +from rest_framework.request import Request +from rest_framework.serializers import Serializer + + +class ExpandableHyperlinkedIdentityField(serializers.HyperlinkedIdentityField): + """A HyperlinkedIdentityField with nested serializer expanding""" + + def __init__(self, serializer: Type[Serializer], view_name: str = None, **kwargs): + self.serializer = serializer + super().__init__(view_name=view_name, **kwargs) + + def get_url(self, obj: any, view_name: str, request: Request, fm: str): + """Get the URL of the related object""" + return super().get_url(obj, view_name, request, fm) + + def to_representation(self, value): + """Get the representation of the nested instance""" + request: Request = self.context.get('request') + + if request and self.field_name in request.query_params: + try: + instance = getattr(value, self.field_name) + except AttributeError: + instance = value + + return self.serializer(instance, + many=self._kwargs.pop('many'), + context=self.context + ).data + + return super().to_representation(value) diff --git a/backend/api/serializers/group_serializer.py b/backend/api/serializers/group_serializer.py index 3865f7a2..ae2e474f 100644 --- a/backend/api/serializers/group_serializer.py +++ b/backend/api/serializers/group_serializer.py @@ -1,11 +1,13 @@ +from api.models.assistant import Assistant from api.models.group import Group from api.models.student import Student -from api.models.assistant import Assistant from api.models.teacher import Teacher -from api.permissions.role_permissions import is_student, is_assistant, is_teacher +from api.permissions.role_permissions import (is_assistant, is_student, + is_teacher) from api.serializers.project_serializer import ProjectSerializer from api.serializers.student_serializer import StudentIDSerializer from django.utils.translation import gettext +from notifications.signals import NotificationType, notification_create from rest_framework import serializers from rest_framework.exceptions import ValidationError @@ -99,6 +101,10 @@ def validate(self, attrs): group: Group = self.context["group"] student: Student = attrs["student"] + # Make sure the group size is not 1 + if group.project.group_size == 1: + raise ValidationError(gettext("group.errors.size_one")) + # Make sure the student was in the group if not group.students.filter(id=student.id).exists(): raise ValidationError(gettext("group.errors.not_present")) diff --git a/backend/api/serializers/project_serializer.py b/backend/api/serializers/project_serializer.py index b4493229..ad2fdab5 100644 --- a/backend/api/serializers/project_serializer.py +++ b/backend/api/serializers/project_serializer.py @@ -1,15 +1,19 @@ +from django.core.files.uploadedfile import InMemoryUploadedFile + 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, ExtraCheckResult, StructureCheckResult, StateEnum +from api.models.checks import ExtraCheck, StructureCheck from api.serializers.course_serializer import CourseSerializer -from django.core.files.uploadedfile import InMemoryUploadedFile from django.utils import timezone from django.utils.translation import gettext from nh3 import clean from rest_framework import serializers from rest_framework.exceptions import ValidationError +from api.serializers.fields.expandable_hyperlinked_field import ExpandableHyperlinkedIdentityField + class SubmissionStatusSerializer(serializers.Serializer): non_empty_groups = serializers.IntegerField(read_only=True) @@ -30,9 +34,25 @@ def to_representation(self, instance: Project): # 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): + if groups_submitted > non_empty_groups: non_empty_groups = groups_submitted + extra_checks_count = instance.extra_checks.count() + + if extra_checks_count: + 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) + passed_structure_checks_submission_ids = StructureCheckResult.objects.filter( submission__group__project=instance, submission__is_valid=True, @@ -46,22 +66,16 @@ def to_representation(self, instance: Project): 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) + # If there are no extra checks, we can set extra_checks_passed equal to structure_checks_passed + if not extra_checks_count: + extra_checks_passed = structure_checks_passed - unique_groups = set(passed_extra_checks_group_ids) - extra_checks_passed = len(unique_groups) + # If the extra checks succeed, the structure checks also succeed + structure_checks_passed -= extra_checks_passed # 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): + if structure_checks_passed + extra_checks_passed > groups_submitted: extra_checks_passed = groups_submitted - structure_checks_passed return { @@ -93,13 +107,11 @@ class ProjectSerializer(serializers.ModelSerializer): ) structure_checks = serializers.HyperlinkedIdentityField( - view_name="project-structure-checks", - read_only=True + view_name="project-structure-checks" ) extra_checks = serializers.HyperlinkedIdentityField( - view_name="project-extra-checks", - read_only=True + view_name="project-extra-checks" ) groups = serializers.HyperlinkedIdentityField( @@ -146,6 +158,8 @@ def validate(self, attrs): return attrs def create(self, validated_data): + """Create the project object and create groups for the project if specified""" + # Pop the 'number_groups' field from validated_data number_groups = validated_data.pop('number_groups', None) @@ -162,7 +176,6 @@ def create(self, validated_data): group.students.add(student) elif number_groups: - for _ in range(number_groups): Group.objects.create(project=project) @@ -172,10 +185,11 @@ def create(self, validated_data): group_size = project.group_size for _ in range(0, number_students, group_size): - group = Group.objects.create(project=project) + Group.objects.create(project=project) # If a zip_structure is provided, parse it to create the structure checks zip_structure: InMemoryUploadedFile | None = self.context['request'].FILES.get('zip_structure') + if zip_structure: result = parse_zip(project, zip_structure) diff --git a/backend/api/signals.py b/backend/api/signals.py index 82bc905c..ba034ca4 100644 --- a/backend/api/signals.py +++ b/backend/api/signals.py @@ -15,6 +15,7 @@ from authentication.signals import user_created from django.db.models.signals import post_delete, post_save, pre_delete from django.dispatch import Signal, receiver +from notifications.signals import NotificationType, notification_create # MARK: Signals @@ -119,6 +120,13 @@ def hook_submission(sender, instance: Submission, created: bool, **kwargs): run_all_checks.send(sender=Submission, submission=instance) pass + notification_create.send( + sender=Submission, + type=NotificationType.SUBMISSION_RECEIVED, + queryset=list(instance.group.students.all()), + arguments={} + ) + @receiver(post_save, sender=DockerImage) def hook_docker_image(sender, instance: DockerImage, created: bool, **kwargs): diff --git a/backend/api/tasks/docker_image.py b/backend/api/tasks/docker_image.py index 3636ef36..246d5934 100644 --- a/backend/api/tasks/docker_image.py +++ b/backend/api/tasks/docker_image.py @@ -3,6 +3,7 @@ from api.logic.get_file_path import get_docker_image_tag from api.models.docker import DockerImage, StateEnum from celery import shared_task +from notifications.signals import NotificationType, notification_create from ypovoli.settings import MEDIA_ROOT @@ -12,6 +13,8 @@ def task_docker_image_build(docker_image: DockerImage): docker_image.state = StateEnum.BUILDING docker_image.save() + notification_type = NotificationType.DOCKER_IMAGE_BUILD_SUCCESS + # Build the image try: client = docker.from_env() @@ -20,10 +23,18 @@ def task_docker_image_build(docker_image: DockerImage): docker_image.state = StateEnum.READY except (docker.errors.APIError, docker.errors.BuildError, TypeError): docker_image.state = StateEnum.ERROR - # TODO: Sent notification + notification_type = NotificationType.DOCKER_IMAGE_BUILD_ERROR + finally: + # Update the state + docker_image.save() - # Update the state - docker_image.save() + # Send notification + notification_create.send( + sender=DockerImage, + type=notification_type, + queryset=[docker_image.owner], + arguments={"name": docker_image.name}, + ) @shared_task diff --git a/backend/api/tasks/extra_check.py b/backend/api/tasks/extra_check.py index 5ddb1565..1f63e87a 100644 --- a/backend/api/tasks/extra_check.py +++ b/backend/api/tasks/extra_check.py @@ -13,10 +13,10 @@ 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 +from notifications.signals import NotificationType, notification_create from requests.exceptions import ConnectionError @@ -36,12 +36,22 @@ def task_extra_check_start(structure_check_result: bool, extra_check_result: Ext extra_check_result.error_message = ErrorMessageEnum.DOCKER_IMAGE_ERROR extra_check_result.save() + notification_create.send( + sender=ExtraCheckResult, + type=NotificationType.EXTRA_CHECK_FAIL, + queryset=list(extra_check_result.submission.group.students.all()), + arguments={"name": extra_check_result.extra_check.name}, + ) + return structure_check_result # Will probably never happen but doesn't hurt to check while extra_check_result.submission.running_checks: sleep(1) + # Notification type + notification_type = NotificationType.EXTRA_CHECK_SUCCESS + # Lock extra_check_result.submission.running_checks = True @@ -114,41 +124,49 @@ def task_extra_check_start(structure_check_result: bool, extra_check_result: Ext case 1: extra_check_result.result = StateEnum.FAILED extra_check_result.error_message = ErrorMessageEnum.CHECK_ERROR + notification_type = NotificationType.EXTRA_CHECK_FAIL # Time limit case 2: extra_check_result.result = StateEnum.FAILED extra_check_result.error_message = ErrorMessageEnum.TIME_LIMIT + notification_type = NotificationType.EXTRA_CHECK_FAIL # Memory limit case 3: extra_check_result.result = StateEnum.FAILED extra_check_result.error_message = ErrorMessageEnum.MEMORY_LIMIT + notification_type = NotificationType.EXTRA_CHECK_FAIL # Catch all non zero exit codes case _: extra_check_result.result = StateEnum.FAILED extra_check_result.error_message = ErrorMessageEnum.RUNTIME_ERROR + notification_type = NotificationType.EXTRA_CHECK_FAIL # Docker image error except (docker.errors.APIError, docker.errors.ImageNotFound): extra_check_result.result = StateEnum.FAILED extra_check_result.error_message = ErrorMessageEnum.DOCKER_IMAGE_ERROR + notification_type = NotificationType.EXTRA_CHECK_FAIL # Runtime error except docker.errors.ContainerError: extra_check_result.result = StateEnum.FAILED extra_check_result.error_message = ErrorMessageEnum.RUNTIME_ERROR + notification_type = NotificationType.EXTRA_CHECK_FAIL # Timeout error except ConnectionError: extra_check_result.result = StateEnum.FAILED extra_check_result.error_message = ErrorMessageEnum.TIME_LIMIT + notification_type = NotificationType.EXTRA_CHECK_FAIL # Unknown error except Exception: extra_check_result.result = StateEnum.FAILED extra_check_result.error_message = ErrorMessageEnum.UNKNOWN + notification_type = NotificationType.EXTRA_CHECK_FAIL # Cleanup and data saving # Start by saving any logs @@ -165,6 +183,14 @@ def task_extra_check_start(structure_check_result: bool, extra_check_result: Ext extra_check_result.log_file.save(submission_uuid, content=ContentFile(logs), save=False) + # Send notification + notification_create.send( + sender=ExtraCheckResult, + type=notification_type, + queryset=list(extra_check_result.submission.group.students.all()), + arguments={"name": extra_check_result.extra_check.name}, + ) + # Zip and save any possible artifacts memory_zip = io.BytesIO() if os.listdir(artifacts_directory): diff --git a/backend/api/tasks/structure_check.py b/backend/api/tasks/structure_check.py index 8adce52a..d22cd866 100644 --- a/backend/api/tasks/structure_check.py +++ b/backend/api/tasks/structure_check.py @@ -5,6 +5,7 @@ from api.models.submission import (ErrorMessageEnum, StateEnum, StructureCheckResult) from celery import shared_task +from notifications.signals import NotificationType, notification_create @shared_task() @@ -19,6 +20,9 @@ def task_structure_check_start(structure_check_results: list[StructureCheckResul # Lock structure_check_results[0].submission.running_checks = True + # Notification type + notification_type = NotificationType.STRUCTURE_CHECK_SUCCESS + all_checks_passed = True # Boolean to check if all structure checks passed name_ext = _get_all_name_ext(structure_check_results[0].submission.zip.path) # Dict with file name and extension @@ -38,6 +42,7 @@ def task_structure_check_start(structure_check_results: list[StructureCheckResul if len(extensions) == 0: structure_check_result.result = StateEnum.FAILED structure_check_result.error_message = ErrorMessageEnum.FILE_DIR_NOT_FOUND + notification_type = NotificationType.STRUCTURE_CHECK_FAIL # Check if no blocked extension is present if structure_check_result.result == StateEnum.SUCCESS: @@ -45,6 +50,7 @@ def task_structure_check_start(structure_check_results: list[StructureCheckResul if extension.extension in extensions: structure_check_result.result = StateEnum.FAILED structure_check_result.error_message = ErrorMessageEnum.BLOCKED_EXTENSION + notification_type = NotificationType.STRUCTURE_CHECK_FAIL # Check if all obligated extensions are present if structure_check_result.result == StateEnum.SUCCESS: @@ -52,6 +58,7 @@ def task_structure_check_start(structure_check_results: list[StructureCheckResul if extension.extension not in extensions: structure_check_result.result = StateEnum.FAILED structure_check_result.error_message = ErrorMessageEnum.OBLIGATED_EXTENSION_NOT_FOUND + notification_type = NotificationType.STRUCTURE_CHECK_FAIL all_checks_passed = all_checks_passed and structure_check_result.result == StateEnum.SUCCESS structure_check_result.save() @@ -59,6 +66,14 @@ def task_structure_check_start(structure_check_results: list[StructureCheckResul # Release structure_check_results[0].submission.running_checks = False + # Send notification + notification_create.send( + sender=StructureCheckResult, + type=notification_type, + queryset=list(structure_check_results[0].submission.group.students.all()), + arguments={}, + ) + return all_checks_passed diff --git a/backend/api/tests/test_project.py b/backend/api/tests/test_project.py index 0684088e..d4d48c3d 100644 --- a/backend/api/tests/test_project.py +++ b/backend/api/tests/test_project.py @@ -5,6 +5,7 @@ from api.models.project import Project from api.models.student import Student from api.models.teacher import Teacher +from api.serializers.checks_serializer import FileExtensionSerializer from api.tests.helpers import (create_admin, create_course, create_file_extension, create_group, create_project, create_structure_check, @@ -417,22 +418,30 @@ def test_project_structure_checks_post(self): course=course, ) + obligated_extensions = FileExtensionSerializer( + [file_extension1, file_extension4], many=True + ) + + blocked_extensions = FileExtensionSerializer( + [file_extension2, file_extension3], many=True + ) + response = self.client.post( reverse("project-structure-checks", args=[str(project.id)]), - { + json.dumps({ "path": ".", - "obligated_extensions": [file_extension1.extension, file_extension4.extension], - "blocked_extensions": [file_extension2.extension, file_extension3.extension]}, + "obligated_extensions": obligated_extensions.data, + "blocked_extensions": blocked_extensions.data}), follow=True, + content_type="application/json" ) project.refresh_from_db() - self.assertEqual(response.status_code, 200) self.assertEqual(response.accepted_media_type, "application/json") # type: ignore # self.assertEqual(json.loads(response.content), {'message': gettext('project.success.structure_check.add')}) - upd: StructureCheck = project.structure_checks.all()[0] + upd: StructureCheck = project.structure_checks.first() retrieved_obligated_extensions = upd.obligated_extensions.all() retrieved_blocked_file_extensions = upd.blocked_extensions.all() @@ -472,7 +481,6 @@ def test_project_structure_checks_post_already_existing(self): days=7, course=course, ) - create_structure_check( path=".", project=project, @@ -480,13 +488,22 @@ def test_project_structure_checks_post_already_existing(self): blocked_extensions=[file_extension2, file_extension3], ) + obligated_extensions = FileExtensionSerializer( + [file_extension1, file_extension4], many=True + ) + + blocked_extensions = FileExtensionSerializer( + [file_extension2, file_extension3], many=True + ) + response = self.client.post( reverse("project-structure-checks", args=[str(project.id)]), - { + json.dumps({ "path": ".", - "obligated_extensions": [file_extension1.extension, file_extension4.extension], - "blocked_extensions": [file_extension2.extension, file_extension3.extension]}, + "obligated_extensions": obligated_extensions.data, + "blocked_extensions": blocked_extensions.data}), follow=True, + content_type="application/json" ) self.assertEqual(response.status_code, 400) @@ -513,14 +530,22 @@ def test_project_structure_checks_post_blocked_and_obligated(self): course=course, ) + obligated_extensions = FileExtensionSerializer( + [file_extension1, file_extension4], many=True + ) + + blocked_extensions = FileExtensionSerializer( + [file_extension1, file_extension2, file_extension3], many=True + ) + response = self.client.post( reverse("project-structure-checks", args=[str(project.id)]), - { + json.dumps({ "path": ".", - "obligated_extensions": [file_extension1.extension, file_extension4.extension], - "blocked_extensions": [file_extension1.extension, file_extension2.extension, - file_extension3.extension]}, + "obligated_extensions": obligated_extensions.data, + "blocked_extensions": blocked_extensions.data}), follow=True, + content_type="application/json" ) self.assertEqual(response.status_code, 400) diff --git a/backend/api/views/group_view.py b/backend/api/views/group_view.py index 7c118bcd..cf300cfd 100644 --- a/backend/api/views/group_view.py +++ b/backend/api/views/group_view.py @@ -9,6 +9,7 @@ from api.serializers.submission_serializer import SubmissionSerializer from django.utils.translation import gettext from drf_yasg.utils import swagger_auto_schema +from notifications.signals import NotificationType, notification_create from rest_framework.decorators import action from rest_framework.mixins import (CreateModelMixin, DestroyModelMixin, RetrieveModelMixin, UpdateModelMixin) @@ -28,6 +29,22 @@ class GroupViewSet(CreateModelMixin, serializer_class = GroupSerializer permission_classes = [IsAdminUser | GroupPermission] + def update(self, request, *args, **kwargs): + old_group = self.get_object() + response = super().update(request, *args, **kwargs) + if response.status_code == 200: + new_group = self.get_object() + if "score" in request.data and old_group.score != new_group.score: + # Partial updates end up in the update function as well + notification_create.send( + sender=Group, + type=NotificationType.SCORE_UPDATED, + queryset=list(new_group.students.all()), + arguments={"score": str(new_group.score)}, + ) + + return response + @action(detail=True, methods=["get"], permission_classes=[IsAdminUser | GroupStudentPermission]) def students(self, request, **_): """Returns a list of students for the given group""" diff --git a/backend/api/views/project_view.py b/backend/api/views/project_view.py index 5f215c22..180dd5d1 100644 --- a/backend/api/views/project_view.py +++ b/backend/api/views/project_view.py @@ -4,7 +4,6 @@ from api.permissions.project_permissions import (ProjectGroupPermission, ProjectPermission) from api.serializers.checks_serializer import (ExtraCheckSerializer, - StructureCheckAddSerializer, StructureCheckSerializer) from api.serializers.group_serializer import GroupSerializer from api.serializers.project_serializer import (ProjectSerializer, @@ -86,7 +85,7 @@ def _create_groups(self, request, **_): "message": gettext("project.success.groups.created"), }) - @action(detail=True) + @action(detail=True, methods=['get']) def structure_checks(self, request, **_): """Returns the structure checks for the given project""" project = self.get_object() @@ -94,24 +93,26 @@ def structure_checks(self, request, **_): # Serialize the check objects serializer = StructureCheckSerializer( - checks, many=True, context={"request": request} + checks, + many=True, + context={ + "request": request + } ) + return Response(serializer.data) @structure_checks.mapping.post - @swagger_auto_schema(request_body=StructureCheckAddSerializer) + @swagger_auto_schema(request_body=StructureCheckSerializer) def _add_structure_check(self, request: Request, **_): """Add a structure_check to the project""" - project: Project = self.get_object() - serializer = StructureCheckAddSerializer( + serializer = StructureCheckSerializer( data=request.data, context={ "project": project, - "request": request, - "obligated": request.data.getlist('obligated_extensions') if "obligated_extensions" in request.data else [], - "blocked": request.data.getlist('blocked_extensions') if "blocked_extensions" in request.data else [] + "request": request } ) @@ -120,6 +121,30 @@ def _add_structure_check(self, request: Request, **_): return Response(serializer.data) + @structure_checks.mapping.put + @swagger_auto_schema(request_body=StructureCheckSerializer) + def _set_structure_checks(self, request: Request, **_) -> Response: + """Set the structure checks of the given project""" + project: Project = self.get_object() + + # Delete all current structure checks of the project + project.structure_checks.all().delete() + + # Create the new structure checks + serializer = StructureCheckSerializer( + data=request.data, + many=True, + context={ + 'project': project, + 'request': request + } + ) + + if serializer.is_valid(raise_exception=True): + serializer.save(project=project) + + return Response(serializer.validated_data) + @action(detail=True) def extra_checks(self, request, **_): """Returns the extra checks for the given project""" @@ -147,7 +172,6 @@ def _add_extra_check(self, request: Request, **_): } ) - # TODO: Weird error message when invalid docker_image id if serializer.is_valid(raise_exception=True): serializer.save(project=project) @@ -155,8 +179,32 @@ def _add_extra_check(self, request: Request, **_): "message": gettext("project.success.extra_check.add") }) + @extra_checks.mapping.put + @swagger_auto_schema(request_body=ExtraCheckSerializer) + def set_extra_checks(self, request: Request, **_): + """Set the extra checks of the given project""" + project: Project = self.get_object() + + # Delete all current extra checks of the project + project.extra_checks.all().delete() + + # Create the new extra checks + serializer = ExtraCheckSerializer( + data=request.data, + many=True, + context={ + "project": project, + "request": request + } + ) + + if serializer.is_valid(raise_exception=True): + serializer.save(project=project) + + return Response(serializer.validated_data) + @action(detail=True, permission_classes=[IsAdminUser | ProjectGroupPermission]) - def submission_status(self, request, **_): + def submission_status(self, _: Request): """Returns the current submission status for the given project This includes: - The total amount of groups that contain at least one student diff --git a/backend/authentication/views.py b/backend/authentication/views.py index a3964966..85af6b5a 100644 --- a/backend/authentication/views.py +++ b/backend/authentication/views.py @@ -1,3 +1,5 @@ +from django.http import HttpResponseRedirect + from authentication.cas.client import client from authentication.permissions import IsDebug from authentication.serializers import CASTokenObtainSerializer, UserSerializer @@ -21,17 +23,17 @@ class CASViewSet(ViewSet): permission_classes = [IsAuthenticated] @action(detail=False, methods=['GET'], permission_classes=[AllowAny]) - def login(self, request: Request) -> Response: + def login(self, request: Request) -> HttpResponseRedirect: """Attempt to log in. Redirect to our single CAS endpoint.""" should_echo = request.query_params.get('echo', False) - if should_echo == "1" and settings.DEBUG: + if should_echo == "1": client._service_url = settings.CAS_DEBUG_RESPONSE return redirect(client.get_login_url()) @action(detail=False, methods=['POST']) - def logout(self, request: Request) -> Response: + def logout(self, request) -> Response: """Log out the current user.""" logout(request) diff --git a/backend/notifications/fixtures/realistic/realistic.yaml b/backend/notifications/fixtures/realistic/realistic.yaml index e69de29b..0e37a039 100644 --- a/backend/notifications/fixtures/realistic/realistic.yaml +++ b/backend/notifications/fixtures/realistic/realistic.yaml @@ -0,0 +1,45 @@ +- model: notifications.notificationtemplate + pk: 1 + fields: + title_key: "Title: Score added" + description_key: "Description: Score added %(score)s" +- model: notifications.notificationtemplate + pk: 2 + fields: + title_key: "Title: Score updated" + description_key: "Description: Score updated %(score)s" +- model: notifications.notificationtemplate + pk: 3 + fields: + title_key: "Title: Docker image build success" + description_key: "Description: Docker image build success %(name)s" +- model: notifications.notificationtemplate + pk: 4 + fields: + title_key: "Title: Docker image build error" + description_key: "Description: Docker image build error %(name)s" +- model: notifications.notificationtemplate + pk: 5 + fields: + title_key: "Title: Extra check success" + description_key: "Description: Extra check success %(name)s" +- model: notifications.notificationtemplate + pk: 6 + fields: + title_key: "Title: Extra check error" + description_key: "Description: Extra check error %(name)s" +- model: notifications.notificationtemplate + pk: 7 + fields: + title_key: "Title: Structure checks success" + description_key: "Description: Structure checks success" +- model: notifications.notificationtemplate + pk: 8 + fields: + title_key: "Title: Structure checks error" + description_key: "Description: Structure checks" +- model: notifications.notificationtemplate + pk: 9 + fields: + title_key: "Title: Submission received" + description_key: "Description: Submission received" diff --git a/backend/notifications/locale/en/LC_MESSAGES/django.po b/backend/notifications/locale/en/LC_MESSAGES/django.po index 465520e9..d9ee882e 100644 --- a/backend/notifications/locale/en/LC_MESSAGES/django.po +++ b/backend/notifications/locale/en/LC_MESSAGES/django.po @@ -30,3 +30,38 @@ msgid "Title: Score updated" msgstr "New score" msgid "Description: Score updated %(score)s" msgstr "Your score has been updated.\nNew score: %(score)s" +# Docker Image Build Succes +msgid "Title: Docker image build success" +msgstr "Docker image successfully build" +msgid "Description: Docker image build success %(name)s" +msgstr "Your docker image, $(name)s, has successfully been build" +# Docker Image Build Error +msgid "Title: Docker image build error" +msgstr "Docker image failed to build" +msgid "Description: Docker image build error %(name)s" +msgstr "Failed to build your docker image, %(name)s" +# Extra Check Succes +msgid "Title: Extra check success" +msgstr "Passed an extra check" +msgid "Description: Extra check success %(name)s" +msgstr "Your submission passed the extra check, $(name)s" +# Extra Check Error +msgid "Title: Extra check error" +msgstr "Failed an extra check" +msgid "Description: Extra check error %(name)s" +msgstr "Your submission failed to pass the extra check, %(name)s" +# Structure Checks Succes +msgid "Title: Structure checks success" +msgstr "Passed all structure checks" +msgid "Description: Structure checks success" +msgstr "Your submission passed all structure checks" +# Structure Checks Error +msgid "Title: Structure checks error" +msgstr "Failed a structure check" +msgid "Description: Structure checks" +msgstr "Your submission failed one or more structure checks" +# Submission received +msgid "Title: Submission received" +msgstr "Received submission" +msgid "Description: Submission received" +msgstr "We have received your submission" diff --git a/backend/notifications/locale/nl/LC_MESSAGES/django.po b/backend/notifications/locale/nl/LC_MESSAGES/django.po index 5a854108..7d9633d0 100644 --- a/backend/notifications/locale/nl/LC_MESSAGES/django.po +++ b/backend/notifications/locale/nl/LC_MESSAGES/django.po @@ -30,3 +30,38 @@ msgid "Title: Score updated" msgstr "Nieuwe score" msgid "Description: Score updated %(score)s" msgstr "Je score is geupdate.\nNieuwe score: %(score)s" +# Docker Image Build Succes +msgid "Title: Docker image build success" +msgstr "Docker image succesvol gebouwd" +msgid "Description: Docker image build success %(name)s" +msgstr "Jouw docker image, $(name)s, is succesvol gebouwd" +# Docker Image Build Error +msgid "Title: Docker image build error" +msgstr "Docker image is gefaald om te bouwen" +msgid "Description: Docker image build error %(name)s" +msgstr "Gefaald om jouw docker image, %(name)s, te bouwen" +# Extra Check Succes +msgid "Title: Extra check success" +msgstr "Geslaagd voor een extra check" +msgid "Description: Extra check success %(name)s" +msgstr "Jouw indiening is geslaagd voor de extra check: $(name)s" +# Extra Check Error +msgid "Title: Extra check error" +msgstr "Gefaald voor een extra check" +msgid "Description: Extra check error %(name)s" +msgstr "Jouw indiening is gefaald voor de extra check: %(name)s" +# Structure Checks Succes +msgid "Title: Structure checks success" +msgstr "Geslaagd voor de structuur checks" +msgid "Description: Structure checks success" +msgstr "Jouw indiening is geslaagd voor alle structuur checks" +# Structure Checks Error +msgid "Title: Structure checks error" +msgstr "Gefaald voor een structuur check" +msgid "Description: Structure checks" +msgstr "Jouw indiening is gefaald voor een structuur check" +# Submission received +msgid "Title: Submission received" +msgstr "Indiening ontvangen" +msgid "Description: Submission received" +msgstr "We hebben jouw indiening ontvangen" diff --git a/backend/notifications/logic.py b/backend/notifications/logic.py index e2061a87..0f7133a3 100644 --- a/backend/notifications/logic.py +++ b/backend/notifications/logic.py @@ -22,7 +22,7 @@ def get_message_dict(notification: Notification) -> Dict[str, str]: # Call the function after 60 seconds and no more than once in that period def schedule_send_mails(): - if not cache.get("notifications_send_mails"): + if not cache.get("notifications_send_mails", False): cache.set("notifications_send_mails", True) _send_mails.apply_async(countdown=60) @@ -41,7 +41,7 @@ def _send_mail(mail: mail.EmailMessage, result: List[bool]): # TODO: Retry 3 # https://docs.celeryq.dev/en/v5.3.6/getting-started/next-steps.html#next-steps # Send all unsent emails -@shared_task(ignore_result=True) +@shared_task() def _send_mails(): # All notifications that need to be sent notifications = Notification.objects.filter(is_sent=False) diff --git a/backend/notifications/serializers.py b/backend/notifications/serializers.py index d4c488ba..2e3d7c75 100644 --- a/backend/notifications/serializers.py +++ b/backend/notifications/serializers.py @@ -1,5 +1,4 @@ import re -from typing import Dict, List from authentication.models import User from notifications.logic import get_message_dict @@ -23,14 +22,14 @@ class NotificationSerializer(serializers.ModelSerializer): message = serializers.SerializerMethodField() # Check if the required arguments are present - def _get_missing_keys(self, string: str, arguments: Dict[str, str]) -> List[str]: - required_keys: List[str] = re.findall(r"%\((\w+)\)", string) + def _get_missing_keys(self, string: str, arguments: dict[str, str]) -> list[str]: + required_keys: list[str] = re.findall(r"%\((\w+)\)", string) missing_keys = [key for key in required_keys if key not in arguments] return missing_keys - def validate(self, data: Dict[str, str]) -> Dict[str, str]: - data: Dict[str, str] = super().validate(data) + def validate(self, data: dict[str, str | int | dict[str, str]]) -> dict[str, str]: + data: dict[str, str] = super().validate(data) # Validate the arguments if "arguments" not in data: @@ -56,7 +55,7 @@ def validate(self, data: Dict[str, str]) -> Dict[str, str]: return data # Get the message from the template and arguments - def get_message(self, obj: Notification) -> Dict[str, str]: + def get_message(self, obj: Notification) -> dict[str, str]: return get_message_dict(obj) class Meta: diff --git a/backend/notifications/signals.py b/backend/notifications/signals.py index f8203f39..979184bb 100644 --- a/backend/notifications/signals.py +++ b/backend/notifications/signals.py @@ -15,25 +15,27 @@ @receiver(notification_create) def notification_creation( + sender: type, type: NotificationType, - queryset: QuerySet[User], + queryset: list[User], arguments: Dict[str, str], **kwargs, # Required by django ) -> bool: data: List[Dict[str, Union[str, int, Dict[str, str]]]] = [] for user in queryset: - data.append( - { - "template_id": type.value, - "user": reverse("user-detail", kwargs={"pk": user.id}), - "arguments": arguments, - } - ) + if user: + data.append( + { + "template_id": type.value, + "user": reverse("user-detail", kwargs={"pk": user.id}), + "arguments": arguments, + } + ) serializer = NotificationSerializer(data=data, many=True) - if not serializer.is_valid(): + if not serializer.is_valid(raise_exception=False): return False serializer.save() @@ -46,3 +48,10 @@ def notification_creation( class NotificationType(Enum): SCORE_ADDED = 1 # Arguments: {"score": int} SCORE_UPDATED = 2 # Arguments: {"score": int} + DOCKER_IMAGE_BUILD_SUCCESS = 3 # Arguments: {"name": str} + DOCKER_IMAGE_BUILD_ERROR = 4 # Arguments: {"name": str} + EXTRA_CHECK_SUCCESS = 5 # Arguments: {"name": str} + EXTRA_CHECK_FAIL = 6 # Arguments: {"name": str} + STRUCTURE_CHECK_SUCCESS = 7 # Arguments: {} + STRUCTURE_CHECK_FAIL = 8 # Arguments: {} + SUBMISSION_RECEIVED = 9 # Arguments: {} diff --git a/backend/poetry.lock b/backend/poetry.lock index efb7f1f0..de94c864 100644 --- a/backend/poetry.lock +++ b/backend/poetry.lock @@ -1317,13 +1317,13 @@ ocsp = ["cryptography (>=36.0.1)", "pyopenssl (==20.0.1)", "requests (>=2.26.0)" [[package]] name = "requests" -version = "2.31.0" +version = "2.32.0" description = "Python HTTP for Humans." optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" files = [ - {file = "requests-2.31.0-py3-none-any.whl", hash = "sha256:58cd2187c01e70e6e26505bca751777aa9f2ee0b7f4300988b709f44e013003f"}, - {file = "requests-2.31.0.tar.gz", hash = "sha256:942c5a758f98d790eaed1a29cb6eefc7ffb0d1cf7af05c3d2791656dbd6ad1e1"}, + {file = "requests-2.32.0-py3-none-any.whl", hash = "sha256:f2c3881dddb70d056c5bd7600a4fae312b2a300e39be6a118d30b90bd27262b5"}, + {file = "requests-2.32.0.tar.gz", hash = "sha256:fa5490319474c82ef1d2c9bc459d3652e3ae4ef4c4ebdd18a21145a47ca4b6b8"}, ] [package.dependencies] @@ -1626,4 +1626,4 @@ brotli = ["Brotli"] [metadata] lock-version = "2.0" python-versions = "^3.11.4" -content-hash = "eb154813d38b776ea62b72172e5127abd79f4006005d097421c14dfe40c557df" +content-hash = "fe59ecb1d9eb60d2f1cce90067f18cf9cac4748498c357640a8ab39f38a9a9e5" diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 3e5e66a0..d1eba8fc 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -13,7 +13,7 @@ django-sslserver = "^0.22" djangorestframework = "^3.15.1" django-rest-swagger = "^2.2.0" drf-yasg = "^1.21.7" -requests = "^2.31.0" +requests = "^2.32.0" cas-client = "^1.0.0" psycopg2-binary = "^2.9.9" djangorestframework-simplejwt = "^5.3.1" diff --git a/backend/ypovoli/settings.py b/backend/ypovoli/settings.py index 259f96d6..f0e3186e 100644 --- a/backend/ypovoli/settings.py +++ b/backend/ypovoli/settings.py @@ -125,6 +125,34 @@ }, } +LOGGING = { + 'version': 1, + 'disable_existing_loggers': False, + 'handlers': { + 'console': { + 'class': 'logging.StreamHandler', + 'formatter': 'simple', + }, + }, + 'formatters': { + 'simple': { + 'format': '{levelname} {message}', + 'style': '{', + }, + }, + 'root': { + 'handlers': ['console'], + 'level': 'DEBUG', + }, + 'loggers': { + 'ypovoli': { + 'handlers': ['console'], + 'level': 'DEBUG', + 'propagate': False, + } + }, +} + # Default primary key field type # https://docs.djangoproject.com/en/5.0/ref/settings/#default-auto-field DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" diff --git a/development.sh b/development.sh index 70dfe9fe..5a83cf9d 100755 --- a/development.sh +++ b/development.sh @@ -135,7 +135,7 @@ if [ "$data" != "" ]; then echo "Clearing, Migrating & Populating the database" # We have nog fixtures for notification yet. - docker-compose -f development.yml run backend sh -c "python manage.py flush --no-input; python manage.py migrate; python manage.py loaddata authentication/fixtures/$data/*; python manage.py loaddata api/fixtures/$data/*;" + docker-compose -f development.yml run backend sh -c "python manage.py flush --no-input; python manage.py migrate; python manage.py loaddata notifications/fixtures/$data/*; python manage.py loaddata authentication/fixtures/$data/*; python manage.py loaddata api/fixtures/$data/*;" echo "Stopping the services" docker-compose -f development.yml down diff --git a/frontend/package-lock.json b/frontend/package-lock.json index d88cf18d..6189c3b4 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -19,7 +19,7 @@ "pinia": "^2.1.7", "primeflex": "^3.3.1", "primeicons": "^7.0.0", - "primevue": "^3.50.0", + "primevue": "^3.52.0", "quill": "^1.3.7", "vue": "^3.4.18", "vue-i18n": "^9.10.2", diff --git a/frontend/package.json b/frontend/package.json index 833a4f53..38241a94 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -26,7 +26,7 @@ "pinia": "^2.1.7", "primeflex": "^3.3.1", "primeicons": "^7.0.0", - "primevue": "^3.50.0", + "primevue": "^3.52.0", "quill": "^1.3.7", "vue": "^3.4.18", "vue-i18n": "^9.10.2", diff --git a/frontend/src/assets/lang/app/en.json b/frontend/src/assets/lang/app/en.json index 9e86d683..dbcd356f 100644 --- a/frontend/src/assets/lang/app/en.json +++ b/frontend/src/assets/lang/app/en.json @@ -2,16 +2,14 @@ "layout": { "header": { "logo": "Ghent University logo", - "login": "login", + "login": "Login", "view": "View as {0}", "user": "Logged in as {0}", "navigation": { "dashboard": "Dashboard", "calendar": "Calendar", - "courses": "courses", - "projects": "projects", - "settings": "preferences", - "help": "help" + "courses": "Courses", + "projects": "Projects" }, "language": { "nl": "Nederlands", @@ -46,7 +44,7 @@ "coming": "Near deadlines", "deadline": "Deadline", "days": "Today at {hour} | Tomorrow at {hour} | In {count} days", - "ago": "{count} days ago", + "ago": "1 day ago | {count} days ago", "groupName": "Group name", "groupPopulation": "Size", "groupStatus": "Status", @@ -60,21 +58,28 @@ "joinGroup": "Join group", "leaveGroup": "Leave group", "create": "Create new project", - "edit": "Edit project", + "edit": "Save project", "name": "Project name", "description": "Description", - "start_date": "Start project", - "group_size": "Number of students in a group (1 for an individual project)", - "number_of_groups": "Number of groups (optional, otherwise #students / group size)", - "max_score": "Maximum score that can be achieved", + "startDate": "Start project", + "numberStudentsGroup": "Number of students in a group (1 for an individual project)", + "numberOfGroups": "Number of groups (optional, otherwise #students / group size)", + "maxScore": "Maximum score that can be achieved", "visibility": "Make project visible to students", "scoreVisibility": "Make score, when uploaded, automatically visible to students", "submissionStructure": "Structure of how a submission should be made", "noStudents": "No students in this group", "locked": "Closed", "unlocked": "Open", + "structureChecks": { + "title": "Structure checks", + "placeholder": "Give a name to this folder", + "cancelSelection": "Deselect {0}", + "newFolder": "New folder" + }, "extraChecks": { "title": "Automatic checks on a submission", + "empty": "No checks addeed", "add": "New check", "name": "Name", "public": "Public", @@ -82,7 +87,7 @@ "dockerImage": "Docker image", "timeLimit": "Time limit for execution (in seconds)", "memoryLimit": "Memory limit for execution (in MB)", - "showLog": "Making the extra logs of the docker container visible to the students" + "showLog": "Make the extra logs of the docker container visible to the students" } }, "submissions": { @@ -107,10 +112,10 @@ "courses": { "create": "Create course", "edit": "Edit course", + "save": "Save course", "clone": "Clone course", "cloneAssistants": "Clone assistants:", "cloneTeachers": "Clone teachers:", - "cloneCourse": "Clone teachers:", "name": "Course name", "description": "Description", "excerpt": "Short description", @@ -121,18 +126,17 @@ "leave": "Leave", "noProjects": "No projects available for this course", "teachersAndAssistants": { - "title": "People linked to this course", + "title": "Teachers", "enroll": "Add as {0}", "leave": "Remove from course", "edit": "Edit users", "search": { "search": "Search", "faculty": "Faculty", - "role": "Role", - "no_role": "None", + "noRole": "None", "placeholder": "Search a user by name", "title": "Find users to link to this course", - "results": "{0} users found for set filters" + "results": "1 user found for set filters | {count} users found for set filters" } }, "search": { @@ -141,10 +145,7 @@ "year": "Academic year", "placeholder": "Search a course by name", "title": "Search a course", - "results": "{0} courses found for set filters" - }, - "searchByLink": { - "placeholder": "Find a course using the registration link" + "results": "1 course found for set filters | {count} courses found for set filters" }, "share": { "title": "Activate invitation link", @@ -157,14 +158,13 @@ "helpers": { "errors": { "notFound": "Not found", - "notFoundDetail": "Source not found", - "unauthorized": "unauthorized", + "notFoundDetail": "Source not found.", + "unauthorized": "Unauthorized", "unauthorizedDetail": "You are not authorized to access this resource.", - "server": "Server Error", - "serverDetail": "An error occurred on the server.", - "network": "Network Error", + "server": "Server error", + "network": "Network error", "networkDetail": "Unable to reach the server.", - "request": "request error", + "request": "Request error", "requestDetail": "An error occurred while creating the request." } } @@ -176,8 +176,6 @@ "createProject": "Create a new project", "searchCourse": "Search a course", "createCourse": "Create a new course", - "public": "Public", - "protected": "Protected", "csv": "Download grades as a .csv file" }, "card": { @@ -201,7 +199,6 @@ "teacher": "No courses found. Create a new course with the button below.", "search": "No courses found for the given search criteria." }, - "noResults": "No results.", "noIncomingProjects": "No projects with a deadline within 7 days.", "selectCourse": "Select the course for which you want to create a project:", "showPastProjects": "Projects with passed deadline" @@ -250,7 +247,7 @@ "save": { "error": { "title": "Invalid save operation", - "detail": "You are trying to save an item without selecting it" + "detail": "You are trying to save an item without selecting it." } } } @@ -266,25 +263,8 @@ "leaveCourse": "Are you sure you want to leave this course? You will no longer have access to this course.", "shareCourse": "By activating the invitation link, you allow students in possession of this link to enroll in this course. Please copy the generated link, only when you click on \"Activate invitation link\" will this link become active." }, - "protectedCourses": { - "screen1": { - "title": "Obtain invitation link", - "content": "Teachers can choose to make their courses private. This means you have to ask the teacher for an invitation link, to be able to join the course." - }, - "screen2": { - "title": "Search course", - "content": "Use the invitation link to search a course. If you can't find the course, ask the teacher to share a new link." - - }, - "screen3": { - "title": "Enroll", - "content": "Enroll in the course. Now you can see all the current projects, deadlines, ..." - - } - }, "admin": { "title": "Admin", - "keyword": "Keyword", "id": "ID", "list": "List", "add": "Add", @@ -303,31 +283,19 @@ "roles": "Roles" }, "user": "User", - "assistants": { - "title": "Assistants" - }, "assistant": "Assistant", - "students": { - "title": "Students" - }, "student": "Student", - "teachers": { - "title": "Teachers" - }, "teacher": "Teacher", - "catalog": "Catalog", - "docker_images": { + "dockerImages": { "title": "Docker Images", - "name_input": "Name of docker image", + "nameInput": "Name of docker image", "name": "Name", "owner": "Owner ID", - "public": "Public", - "private": "Private" + "public": "Public" }, - "none_found": "No matching data.", + "noneFound": "No matching data.", "loading": "Loading data. Please wait.", - "safeGuard": "Are you sure?", - "no_file": "No file found." + "safeGuard": "Are you sure?" }, "primevue": { "startsWith": "Starts with", diff --git a/frontend/src/assets/lang/app/nl.json b/frontend/src/assets/lang/app/nl.json index 884cb246..be118d94 100644 --- a/frontend/src/assets/lang/app/nl.json +++ b/frontend/src/assets/lang/app/nl.json @@ -9,9 +9,7 @@ "dashboard": "Dashboard", "calendar": "Kalender", "courses": "Vakken", - "projects": "Projecten", - "settings": "Voorkeuren", - "help": "Help" + "projects": "Projecten" }, "language": { "nl": "Nederlands", @@ -25,7 +23,7 @@ "projects": "Lopende projecten" }, "verify": { - "redirect": "Je wordt zo meteen doorverwezen" + "redirect": "Je wordt zo meteen doorverwezen..." }, "login": { "title": "Inloggen", @@ -34,19 +32,19 @@ "button": "UGent login", "card": { "title": "Ypovoli", - "subtitle": "Het officieel indieningsplatform van de Universiteit Gent." + "subtitle": "Het officiële indieningsplatform van de Universiteit Gent." } }, "calendar": { "title": "Kalender", - "noProjects": "Geen projecten op geselecteerde datum." + "noProjects": "Geen projecten op geselecteerde datum. \uD83E\uDD73" }, "projects": { "all": "Alle projecten", "coming": "Aankomende deadlines", "deadline": "Deadline", "days": "Vandaag om {hour} | Morgen om {hour} | Over {count} dagen", - "ago": "{count} dagen geleden", + "ago": "1 dag geleden | {count} dagen geleden", "groupName": "Groepsnaam", "groupPopulation": "Grootte", "groupStatus": "Status", @@ -60,20 +58,30 @@ "joinGroup": "Kies groep", "leaveGroup": "Verlaat groep", "create": "Creëer nieuw project", - "edit": "Bewerk project", + "save": "Project opslaan", + "edit": "Project bewerken", "name": "Projectnaam", "description": "Beschrijving", - "start_date": "Start project", - "group_size": "Aantal studenten per groep (1 voor individueel project)", - "number_of_groups": "Aantal groepen (optioneel, anders #studenten / grootte groep)", - "max_score": "Maximale te behalen score", + "startDate": "Start project", + "numberStudentsGroup": "Aantal studenten per groep (1 voor individueel project)", + "numberOfGroups": "Aantal groepen (optioneel, anders #studenten / grootte groep)", + "maxScore": "Maximale te behalen score", "visibility": "Project zichtbaar maken voor studenten", "scoreVisibility": "Maak score, wanneer ingevuld, automatisch zichtbaar voor studenten", "submissionStructure": "Structuur van hoe de indiening moet gebeuren", "noStudents": "Geen studenten in deze groep", + "locked": "Gesloten", + "unlocked": "Open", + "structureChecks": { + "title": "Indieningsstructuur", + "placeholder": "Geef deze nieuwe map een naam", + "cancelSelection": "Deselecteer {0}", + "newFolder": "Nieuwe map" + }, "extraChecks": { "title": "Automatische checks op een indiening", "add": "Nieuwe check", + "empty": "Nog geen extra checks toegevoegd", "name": "Naam", "public": "Publiek", "bashScript": "Bash script", @@ -84,7 +92,7 @@ } }, "submissions": { - "title": "Inzendingen", + "title": "Indieningen", "submit": "Indienen", "course": "Vak", "chooseFile": "Kies bestand(en)", @@ -105,6 +113,7 @@ "courses": { "create": "Creëer vak", "edit": "Bewerk vak", + "save": "Vak opslaan", "clone": "Kloon vak", "cloneAssistants": "Kloon assistenten:", "cloneTeachers": "Kloon lesgevers:", @@ -112,24 +121,23 @@ "description": "Beschrijving", "excerpt": "Korte beschrijving", "faculty": "Faculteit", - "private": "Gesloten vak (studenten kunnen enkel inschrijven via uitnodigingslink)", + "private": "Gesloten vak (studenten kunnen enkel inschrijven met een uitnodigingslink)", "year": "Academiejaar", "enroll": "Inschrijven", "leave": "Uitschrijven", - "noProjects": "Geen projecten beschikbaar voor dit vak", + "noProjects": "Geen projecten beschikbaar voor dit vak \uD83D\uDE2D", "teachersAndAssistants": { - "title": "Lesgevers gelinkt aan dit vak", + "title": "Lesgevers", "enroll": "Voeg toe als {0}", "leave": "Verwijder uit vak", "edit": "Bewerk gebruikers", "search": { "search": "Zoeken", "faculty": "Faculteit", - "role": "Rol", - "no_role": "Geen", + "noRole": "Geen", "placeholder": "Zoek een gebruiker op naam", "title": "Zoek gebuikers om aan dit vak toe te voegen", - "results": "{0} gebruikers gevonden voor ingestelde filters" + "results": "\uD83D\uDD0E 1 gebruiker gevonden voor ingestelde filters | \uD83D\uDD0E {count} gebruikers gevonden voor ingestelde filters" } }, "search": { @@ -138,28 +146,24 @@ "year": "Academiejaar", "placeholder": "Zoek een vak op naam", "title": "Zoek een vak", - "results": "{0} vakken gevonden voor ingestelde filters" - }, - "searchByLink": { - "placeholder": "Zoek een vak gebruik makende van een uitnodigingslink" + "results": "\uD83D\uDD0E 1 vak gevonden voor ingestelde filters | \uD83D\uDD0E {count} vakken gevonden voor ingestelde filters" }, "share": { "title": "Activeer invitatielink", "duration": "Geldigheidsduur van link (in dagen):", "link": "Invitatielink:" } - } + } }, "composables": { "helpers": { "errors": { - "notFound": "Niet Gevonden", + "notFound": "Niet gevonden", "notFoundDetail": "Bron niet gevonden.", "unauthorized": "Onbevoegd", "unauthorizedDetail": "Je bent niet bevoegd om deze bron te bereiken.", - "server": "Server Fout", - "serverDetail": "Er vond een fout plaats op de server.", - "network": "Netwerk Fout", + "server": "Server fout", + "network": "Netwerk fout", "networkDetail": "Kan de server niet bereiken.", "request": "Fout verzoek", "requestDetail": "Een fout vond plaats tijdens het maken van het verzoek." @@ -173,14 +177,12 @@ "createProject": "Creëer nieuw project", "searchCourse": "Zoek een vak", "createCourse": "Maak een vak", - "public": "Publiek", - "protected": "Besloten", "csv": "Download punten als een .csv bestand" }, "card": { "open": "Details", "newProject": "Nieuw project", - "noSubmissions": "Dit project heeft geen indieningen", + "noSubmissions": "Dit project heeft geen indieningen \uD83D\uDE2D", "submissions": "Indiening | Indieningen", "groups": "Groep | Groepen", "structureTestsSucceed": "Geslaagde structuur testen", @@ -190,16 +192,16 @@ }, "list": { "noProjects": { - "student": "Geen lopende projecten gevonden voor alle ingeschreven vakken. Schrijf in op een openbaar vak met de zoekfunctie, of gebruik een uitnodiginslink van een lesgever.", - "teacher": "Geen lopende projecten gevonden voor de vakken waarvoor je lesgever bent." + "student": "Geen lopende projecten gevonden voor alle ingeschreven vakken \uD83D\uDE2D. Schrijf in op een openbaar vak met de zoekfunctie, of gebruik een uitnodiginslink van een lesgever.", + "teacher": "Geen lopende projecten gevonden voor de vakken waarvoor je lesgever bent . \uD83D\uDE2D. Maak een nieuw project voor een vak met onderstaande knop." }, "noCourses": { - "student": "Geen vakken gevonden. Schrijf in op een openbaar vak met de zoekfunctie, of gebruik een uitnodiginslink van een lesgever.", - "teacher": "Geen vakken gevonden. Maak een vak aan met onderstaande knop.", - "search": "Geen vakken gevonden voor de gegeven zoekcriteria." + "student": "Geen vakken gevonden \uD83D\uDE2D. Schrijf in op een openbaar vak met de zoekfunctie, of gebruik een uitnodiginslink van een lesgever.", + "teacher": "Geen vakken gevonden \uD83D\uDE2D. Maak een vak aan met onderstaande knop.", + "search": "Geen vakken gevonden voor de gegeven zoekcriteria \uD83D\uDE2D." }, - "noResults": "Geen resultaten.", - "noIncomingProjects": "Geen projecten met een deadline binnen de 7 dagen.", + "noResults": "Geen resultaten \uD83D\uDE2D.", + "noIncomingProjects": "Geen projecten met een deadline binnen de 7 dagen \uD83E\uDD73.", "selectCourse": "Selecteer het vak waarvoor je een project wil maken:", "showPastProjects": "Projecten met verstreken deadline" } @@ -246,8 +248,8 @@ "admin": { "save": { "error": { - "title": "Ongeldige opsla operatie", - "detail": "U probeert een item op te slaan zonder dit te selecteren" + "title": "Ongeldige opsla bewerking", + "detail": "U probeert een item op te slaan zonder dit te selecteren." } } } @@ -263,24 +265,8 @@ "leaveCourse": "Ben je zeker dat je dit vak wil verlaten? Je zal geen toegang meer hebben tot dit vak.", "shareCourse": "Door het activeren van de invitatielink staat u studenten in bezit van deze link toe zich in te schrijven voor dit vak. Gelieve de gegenereerde link te kopiëren, pas wanneer u op \"Activeer invitatielink\" klikt zal deze link actief worden." }, - "protectedCourses": { - "screen1": { - "title": "Bemachtigen link", - "content": "Professoren kunnen kiezen om hun vakken niet publiek te maken. Vraag de prof om een invitatielink te delen om te kunnen toetreden tot het vak." - }, - "screen2": { - "title": "Vak zoeken", - "content": "Gebruik de link om het vak te zoeken. Als je het vak niet kan vinden, kan je de prof vragen om een nieuwe link te delen." - - }, - "screen3": { - "title": "Inschrijven", - "content": "Schrijf je in voor het vak. Je kan nu een overzicht raadplegen van alle lopende projecten, deadlines, ..." - } - }, "admin": { "title": "Beheerder", - "keyword": "Trefwoord", "id": "ID", "list": "Lijst", "add": "Voeg toe", @@ -299,28 +285,17 @@ "roles": "Functies" }, "user": "Gebruiker", - "assistants": { - "title": "Assistenten" - }, "assistant": "Assistent", - "students": { - "title": "Studenten" - }, "student": "Student", - "teachers": { - "title": "Proffen" - }, "teacher": "Prof", - "catalog": "Catalogus", - "docker_images": { + "dockerImages": { "title": "Docker Images", - "name_input": "Naam van docker image", + "nameInput": "Naam van docker image", "name": "Naam", "owner": "Eigenaar ID", - "public": "Publiek", - "private": "Privaat" + "public": "Publiek" }, - "none_found": "Geen overeenkomende data gevonden.", + "noneFound": "Geen overeenkomende data gevonden \uD83D\uDE2D.", "loading": "Aan het laden. Wacht even aub.", "safeGuard": "Bent u het zeker?" }, @@ -370,9 +345,9 @@ "vrij", "zat" ], - "emptyFilterMessage": "Geen resultaten gevonden", - "emptyMessage": "Geen resultaten gevonden", - "emptySearchMessage": "Geen resultaten gevonden", + "emptyFilterMessage": "Geen resultaten gevonden \uD83D\uDE2D", + "emptyMessage": "Geen resultaten gevonden \uD83D\uDE2D", + "emptySearchMessage": "Geen resultaten gevonden \uD83D\uDE2D", "emptySelectionMessage": "Geen optie geselecteerd", "emptyFileSelect": "Geen bestand geselecteerd", "endsWith": "Eindigt met", diff --git a/frontend/src/assets/scss/theme/base/components/overlay/_tooltip.scss b/frontend/src/assets/scss/theme/base/components/overlay/_tooltip.scss index 567e0252..a87c9c78 100644 --- a/frontend/src/assets/scss/theme/base/components/overlay/_tooltip.scss +++ b/frontend/src/assets/scss/theme/base/components/overlay/_tooltip.scss @@ -57,7 +57,7 @@ // theme .p-tooltip { .p-tooltip-text { - background: $primaryColor; + background: $secondaryTextColor; color: $tooltipTextColor; padding: $tooltipPadding; box-shadow: $inputOverlayShadow; @@ -66,25 +66,25 @@ &.p-tooltip-right { .p-tooltip-arrow { - border-right-color: $primaryColor; + border-right-color: $secondaryTextColor; } } &.p-tooltip-left { .p-tooltip-arrow { - border-left-color: $primaryColor; + border-left-color: $secondaryTextColor; } } &.p-tooltip-top { .p-tooltip-arrow { - border-top-color: $primaryColor; + border-top-color: $secondaryTextColor; } } &.p-tooltip-bottom { .p-tooltip-arrow { - border-bottom-color: $primaryColor; + border-bottom-color: $secondaryTextColor; } } } diff --git a/frontend/src/components/Loading.vue b/frontend/src/components/Loading.vue new file mode 100644 index 00000000..eac23369 --- /dev/null +++ b/frontend/src/components/Loading.vue @@ -0,0 +1,18 @@ + + + + + diff --git a/frontend/src/components/admin/LazyDataTable.vue b/frontend/src/components/admin/LazyDataTable.vue index dcdfe499..49b91e89 100644 --- a/frontend/src/components/admin/LazyDataTable.vue +++ b/frontend/src/components/admin/LazyDataTable.vue @@ -118,7 +118,7 @@ defineExpose({ fetch });