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/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/project_serializer.py b/backend/api/serializers/project_serializer.py index b4493229..afdfdce4 100644 --- a/backend/api/serializers/project_serializer.py +++ b/backend/api/serializers/project_serializer.py @@ -1,15 +1,18 @@ +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.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,7 +33,7 @@ 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 passed_structure_checks_submission_ids = StructureCheckResult.objects.filter( @@ -61,7 +64,7 @@ def to_representation(self, instance: Project): # 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 +96,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 +147,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 +165,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 +174,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/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/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/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/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 b90ecd77..dbcd356f 100644 --- a/frontend/src/assets/lang/app/en.json +++ b/frontend/src/assets/lang/app/en.json @@ -58,7 +58,7 @@ "joinGroup": "Join group", "leaveGroup": "Leave group", "create": "Create new project", - "edit": "Edit project", + "edit": "Save project", "name": "Project name", "description": "Description", "startDate": "Start project", @@ -71,8 +71,15 @@ "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", @@ -105,6 +112,7 @@ "courses": { "create": "Create course", "edit": "Edit course", + "save": "Save course", "clone": "Clone course", "cloneAssistants": "Clone assistants:", "cloneTeachers": "Clone teachers:", @@ -118,7 +126,9 @@ "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", diff --git a/frontend/src/assets/lang/app/nl.json b/frontend/src/assets/lang/app/nl.json index e9ed6148..be118d94 100644 --- a/frontend/src/assets/lang/app/nl.json +++ b/frontend/src/assets/lang/app/nl.json @@ -37,7 +37,7 @@ }, "calendar": { "title": "Kalender", - "noProjects": "Geen projecten op geselecteerde datum." + "noProjects": "Geen projecten op geselecteerde datum. \uD83E\uDD73" }, "projects": { "all": "Alle projecten", @@ -58,7 +58,8 @@ "joinGroup": "Kies groep", "leaveGroup": "Verlaat groep", "create": "Creëer nieuw project", - "edit": "Bewerk project", + "save": "Project opslaan", + "edit": "Project bewerken", "name": "Projectnaam", "description": "Beschrijving", "startDate": "Start project", @@ -71,9 +72,16 @@ "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", @@ -105,6 +113,7 @@ "courses": { "create": "Creëer vak", "edit": "Bewerk vak", + "save": "Vak opslaan", "clone": "Kloon vak", "cloneAssistants": "Kloon assistenten:", "cloneTeachers": "Kloon lesgevers:", @@ -116,9 +125,11 @@ "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", @@ -126,7 +137,7 @@ "noRole": "Geen", "placeholder": "Zoek een gebruiker op naam", "title": "Zoek gebuikers om aan dit vak toe te voegen", - "results": "1 gebruiker gevonden voor ingestelde filters | {count} gebruikers gevonden voor ingestelde filters" + "results": "\uD83D\uDD0E 1 gebruiker gevonden voor ingestelde filters | \uD83D\uDD0E {count} gebruikers gevonden voor ingestelde filters" } }, "search": { @@ -135,7 +146,7 @@ "year": "Academiejaar", "placeholder": "Zoek een vak op naam", "title": "Zoek een vak", - "results": "1 vak gevonden voor ingestelde filters | {count} vakken gevonden voor ingestelde filters" + "results": "\uD83D\uDD0E 1 vak gevonden voor ingestelde filters | \uD83D\uDD0E {count} vakken gevonden voor ingestelde filters" }, "share": { "title": "Activeer invitatielink", @@ -171,7 +182,7 @@ "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", @@ -181,15 +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. Maak een nieuw project voor een vak met onderstaande knop." + "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." }, - "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" } @@ -283,7 +295,7 @@ "owner": "Eigenaar ID", "public": "Publiek" }, - "noneFound": "Geen overeenkomende data gevonden.", + "noneFound": "Geen overeenkomende data gevonden \uD83D\uDE2D.", "loading": "Aan het laden. Wacht even aub.", "safeGuard": "Bent u het zeker?" }, @@ -333,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/courses/CourseDetailCard.vue b/frontend/src/components/courses/CourseDetailCard.vue index 024c5d8b..049ee0f3 100644 --- a/frontend/src/components/courses/CourseDetailCard.vue +++ b/frontend/src/components/courses/CourseDetailCard.vue @@ -14,27 +14,41 @@ defineProps<{ /* Composable injections */ const { t } = useI18n(); const { getRandomImport } = useGlob(import.meta.glob('@/assets/img/placeholders/*.png', { eager: true })); +const { getImport } = useGlob(import.meta.glob('@/assets/img/faculties/*.png', { eager: true }));