diff --git a/backend/api/locale/en/LC_MESSAGES/django.po b/backend/api/locale/en/LC_MESSAGES/django.po index ac864a0a..045ca9b8 100644 --- a/backend/api/locale/en/LC_MESSAGES/django.po +++ b/backend/api/locale/en/LC_MESSAGES/django.po @@ -52,13 +52,21 @@ msgid "courses.error.students.already_present" msgstr "The student is already present in the course." #: serializers/course_serializer.py:68 serializers/course_serializer.py:87 -msgid "courses.error.students.past_course" +msgid "courses.error.past_course" msgstr "The course is from a past year, thus cannot be manipulated." #: serializers/course_serializer.py:83 msgid "courses.error.students.not_present" msgstr "The student is not present in the course." +#: serializers/course_serializer.py:97 +msgid "courses.error.teachers.already_present" +msgstr "The teacher is already present in the course." + +#: serializers/course_serializer.py:116 +msgid "courses.error.teachers.not_present" +msgstr "The teacher is not present in the course." + #: serializers/group_serializer.py:47 msgid "group.errors.score_exceeds_max" msgstr "The score exceeds the group's max score." @@ -127,6 +135,14 @@ msgstr "The student was successfully added to the course." msgid "courses.success.students.remove" msgstr "The student was successfully removed from the course." +#: views/course_view.py:172 +msgid "course.success.teachers.add" +msgstr "The teacher was successfully added to the course." + +#: views/course_view.py:195 +msgid "course.success.teachers.remove" +msgstr "The teacher was successfully removed from the course." + #: views/course_view.py:186 msgid "course.success.project.add" msgstr "The project was successfully added to the course." diff --git a/backend/api/locale/nl/LC_MESSAGES/django.po b/backend/api/locale/nl/LC_MESSAGES/django.po index 320b5cb0..0a993617 100644 --- a/backend/api/locale/nl/LC_MESSAGES/django.po +++ b/backend/api/locale/nl/LC_MESSAGES/django.po @@ -52,13 +52,21 @@ msgid "courses.error.students.already_present" msgstr "De student bevindt zich al in de opleiding." #: serializers/course_serializer.py:68 serializers/course_serializer.py:87 -msgid "courses.error.students.past_course" +msgid "courses.error.past_course" msgstr "De opleiding die men probeert te manipuleren is van een vorig jaar." #: serializers/course_serializer.py:83 msgid "courses.error.students.not_present" msgstr "De student bevindt zich niet in de opleiding." +#: serializers/course_serializer.py:97 +msgid "courses.error.teachers.already_present" +msgstr "De lesgever bevindt zich al in de opleiding." + +#: serializers/course_serializer.py:116 +msgid "courses.error.teachers.not_present" +msgstr "De lesgever bevindt zich niet in de opleiding." + #: serializers/group_serializer.py:47 msgid "group.errors.score_exceeds_max" msgstr "De score van de groep is groter dan de maximum score." @@ -128,6 +136,14 @@ msgstr "De student is succesvol toegevoegd aan de opleiding." msgid "courses.success.students.remove" msgstr "De student is succesvol verwijderd uit de opleiding." +#: views/course_view.py:172 +msgid "course.success.teachers.add" +msgstr "De lesgever is succesvol toegevoegd aan de opleiding." + +#: views/course_view.py:195 +msgid "course.success.teachers.remove" +msgstr "De lesgever is succesvol verwijderd uit de opleiding." + #: views/course_view.py:186 msgid "course.success.project.add" msgstr "Het project is succesvol toegevoegd aan de opleiding." diff --git a/backend/api/models/course.py b/backend/api/models/course.py index b8dd4a42..b4fdb972 100644 --- a/backend/api/models/course.py +++ b/backend/api/models/course.py @@ -36,7 +36,7 @@ def is_past(self) -> bool: """Returns whether the course is from a past academic year""" return datetime(self.academic_startyear + 1, 10, 1) < datetime.now() - def clone(self, clone_assistants=True) -> Self: + def clone(self, clone_teachers=True, clone_assistants=True) -> Self: """Clone the course to the next academic start year""" course = Course.objects.create( name=self.name, @@ -46,10 +46,15 @@ def clone(self, clone_assistants=True) -> Self: ) if clone_assistants: - # Add all the assistants of the current course to the follow up course + # Add all the assistants of the current course to the follow-up course for assistant in self.assistants.all(): course.assistants.add(assistant) + if clone_teachers: + # Add all the teachers of the current course to the follow up course + for teacher in self.teachers.all(): + course.teachers.add(teacher) + return course @property diff --git a/backend/api/permissions/course_permissions.py b/backend/api/permissions/course_permissions.py index 4a87fca6..ccb1314e 100644 --- a/backend/api/permissions/course_permissions.py +++ b/backend/api/permissions/course_permissions.py @@ -65,3 +65,16 @@ def has_object_permission(self, request: Request, view: ViewSet, course: Course) # Teachers and assistants can add and remove any student. return super().has_object_permission(request, view, course) + + +class CourseTeacherPermission(CoursePermission): + """Permission class for teacher related endpoints.""" + def has_object_permission(self, request: Request, view: ViewSet, course: Course): + user: User = request.user + + # Logged-in users can fetch course teachers. + if request.method in SAFE_METHODS: + return user.is_authenticated + + # Only teachers can add or remove themselves from a course. + return is_teacher(user) and request.data.get("teacher_id") == user.id diff --git a/backend/api/serializers/course_serializer.py b/backend/api/serializers/course_serializer.py index c0f8ea72..4d7cf067 100644 --- a/backend/api/serializers/course_serializer.py +++ b/backend/api/serializers/course_serializer.py @@ -2,6 +2,7 @@ from rest_framework import serializers from rest_framework.exceptions import ValidationError from api.serializers.student_serializer import StudentIDSerializer +from api.serializers.teacher_serializer import TeacherIDSerializer from api.models.course import Course @@ -42,6 +43,7 @@ class CourseIDSerializer(serializers.Serializer): class CourseCloneSerializer(serializers.Serializer): + clone_teachers = serializers.BooleanField() clone_assistants = serializers.BooleanField() @@ -59,7 +61,7 @@ def validate(self, data): # Check if the course is not from a past academic year. if course.is_past(): - raise ValidationError(gettext("courses.error.students.past_course")) + raise ValidationError(gettext("courses.error.past_course")) return data @@ -72,12 +74,50 @@ def validate(self, data): course: Course = self.context["course"] - # Check if the student isn't already enrolled. + # Make sure the student is enrolled. if not course.students.contains(data["student_id"]): raise ValidationError(gettext("courses.error.students.not_present")) # Check if the course is not from a past academic year. if course.is_past(): - raise ValidationError(gettext("courses.error.students.past_course")) + raise ValidationError(gettext("courses.error.past_course")) + + return data + + +class TeacherJoinSerializer(TeacherIDSerializer): + def validate(self, data): + # The validator needs the course context. + if "course" not in self.context: + raise ValidationError(gettext("courses.error.context")) + + course: Course = self.context["course"] + + # Check if the teacher isn't already enrolled. + if course.teachers.contains(data["teacher_id"]): + raise ValidationError(gettext("courses.error.teachers.already_present")) + + # Check if the course is not from a past academic year. + if course.is_past(): + raise ValidationError(gettext("courses.error.past_course")) + + return data + + +class TeacherLeaveSerializer(TeacherIDSerializer): + def validate(self, data): + # The validator needs the course context. + if "course" not in self.context: + raise ValidationError(gettext("courses.error.context")) + + course: Course = self.context["course"] + + # Make sure the teacher is enrolled. + if not course.teachers.contains(data["teacher_id"]): + raise ValidationError(gettext("courses.error.teachers.not_present")) + + # Check if the course is not from a past academic year. + if course.is_past(): + raise ValidationError(gettext("courses.error.past_course")) return data diff --git a/backend/api/serializers/teacher_serializer.py b/backend/api/serializers/teacher_serializer.py index 984f75a6..75550d65 100644 --- a/backend/api/serializers/teacher_serializer.py +++ b/backend/api/serializers/teacher_serializer.py @@ -15,3 +15,9 @@ class TeacherSerializer(serializers.ModelSerializer): class Meta: model = Teacher fields = "__all__" + + +class TeacherIDSerializer(serializers.Serializer): + teacher_id = serializers.PrimaryKeyRelatedField( + queryset=Teacher.objects.all() + ) diff --git a/backend/api/tests/test_course.py b/backend/api/tests/test_course.py index e8d01c2d..036081cb 100644 --- a/backend/api/tests/test_course.py +++ b/backend/api/tests/test_course.py @@ -98,6 +98,13 @@ def get_assistant(): return create_assistant(id=5, first_name="Simon", last_name="Mignolet", email="Simon.Mignolet@gmail.com") +def get_teacher(): + """ + Return a random teacher to use in tests. + """ + return create_teacher(id=5, first_name="Sinan", last_name="Bolat", email="Sinan.Bolat@gmail.com") + + def get_student(): """ Return a random student to use in tests. @@ -532,6 +539,22 @@ def test_add_self_to_course(self): self.assertEqual(response.status_code, 200) self.assertTrue(course.students.filter(id=self.user.id).exists()) + def test_try_add_self_as_teacher_to_course(self): + """ + Students should not be able to add themselves as teachers to a course. + """ + course = get_course() + + response = self.client.post( + reverse("course-teachers", args=[str(course.id)]), + data={"teacher_id": self.user.id}, + follow=True, + ) + + # Make sure that the student has not been added as a teacher + self.assertEqual(response.status_code, 403) + self.assertFalse(course.teachers.filter(id=self.user.id).exists()) + def test_remove_self_from_course(self): """ Able to remove self from a course. @@ -702,6 +725,54 @@ def setUp(self) -> None: self.user ) + def test_add_self(self): + """ + Teacher should be able to add him/herself to a course. + """ + course = get_course() + + response = self.client.post( + reverse("course-teachers", args=[str(course.id)]), + data={"teacher_id": self.user.id}, + follow=True, + ) + + # Make sure the current logged in teacher was added correctly + self.assertEqual(response.status_code, 200) + self.assertTrue(course.teachers.filter(id=self.user.id).exists()) + + def test_remove_self(self): + """ + Teacher should be able to remove him/herself from a course. + """ + course = get_course() + course.teachers.add(self.user) + + response = self.client.delete( + reverse("course-teachers", args=[str(course.id)]), + data={"teacher_id": self.user.id}, + follow=True, + ) + + # Make sure the current logged in teacher was removed correctly + self.assertEqual(response.status_code, 200) + self.assertFalse(course.teachers.filter(id=self.user.id).exists()) + + def test_try_remove_self_when_not_part_of(self): + """ + Teacher should not be able to remove him/herself from a course he/she is not part of. + """ + course = get_course() + + response = self.client.delete( + reverse("course-teachers", args=[str(course.id)]), + data={"teacher_id": self.user.id}, + follow=True, + ) + + # Make sure the check was successful + self.assertEqual(response.status_code, 400) + def test_add_assistant(self): """ Able to add an assistant to a course. @@ -908,7 +979,7 @@ def test_clone_course(self): response = self.client.post( reverse("course-clone", args=[str(course.id)]), - data={"clone_assistants": False}, + data={"clone_assistants": False, "clone_teachers": False}, follow=True, ) @@ -916,17 +987,32 @@ def test_clone_course(self): self.assertTrue(Course.objects.filter(name=course.name, academic_startyear=course.academic_startyear + 1).exists()) - # Make sure there are no assistants in the cloned course + # Make sure the returned course is the cloned course + retrieved_course = json.loads(response.content.decode("utf-8")) + + self.assertEqual(retrieved_course["name"], course.name) + self.assertEqual(retrieved_course["academic_startyear"], course.academic_startyear + 1) + + # Get the cloned course cloned_course = Course.objects.get(name=course.name, academic_startyear=course.academic_startyear + 1) + + # Make sure there are no assistants in the cloned course self.assertFalse(cloned_course.assistants.exists()) - def test_clone_with_assistants(self): + # Make sure there are no teachers in the cloned course + self.assertFalse(cloned_course.teachers.exists()) + + def test_clone_with_assistants_and_teachers(self): """ - Able to clone a course with assistants. + Able to clone a course with assistants and teachers. """ course = get_course() course.teachers.add(self.user) + # Add another teacher to the course + teacher = get_teacher() + course.teachers.add(teacher) + # Create an assistant and add it to the course assistant = get_assistant() course.assistants.add(assistant) @@ -934,7 +1020,7 @@ def test_clone_with_assistants(self): # Clone the course with the assistants response = self.client.post( reverse("course-clone", args=[str(course.id)]), - data={"clone_assistants": True}, + data={"clone_assistants": True, "clone_teachers": True}, follow=True, ) @@ -942,6 +1028,40 @@ def test_clone_with_assistants(self): self.assertTrue(Course.objects.filter(name=course.name, academic_startyear=course.academic_startyear + 1).exists()) - # Make sure the assistant is also cloned cloned_course = Course.objects.get(name=course.name, academic_startyear=course.academic_startyear + 1) + + # Make sure the assistant is also cloned self.assertTrue(cloned_course.assistants.filter(id=assistant.id).exists()) + self.assertEqual(cloned_course.assistants.count(), 1) + + # Make sure the two teachers are also cloned + self.assertTrue(cloned_course.teachers.filter(id=self.user.id).exists()) + self.assertTrue(cloned_course.teachers.filter(id=teacher.id).exists()) + self.assertEqual(cloned_course.teachers.count(), 2) + + def test_clone_course_that_already_has_child_course(self): + """ + Course that has already a child course should not be cloned, but the child course should be returned. + """ + course = get_course() + course.teachers.add(self.user) + + # Create a child course + child_course = create_course(name="Chemistry 101", academic_startyear=2024, + description="An introductory chemistry course.", parent_course=course) + + # Clone the course + response = self.client.post( + reverse("course-clone", args=[str(course.id)]), + data={"clone_assistants": False, "clone_teachers": False}, + follow=True, + ) + + self.assertEqual(response.status_code, 200) + + # Make sure the returned course is just the child course + retrieved_course = json.loads(response.content.decode("utf-8")) + + self.assertEqual(retrieved_course["id"], child_course.id) + self.assertEqual(retrieved_course["name"], child_course.name) + self.assertEqual(retrieved_course["academic_startyear"], child_course.academic_startyear) diff --git a/backend/api/views/course_view.py b/backend/api/views/course_view.py index 137e4fd1..5f91561d 100644 --- a/backend/api/views/course_view.py +++ b/backend/api/views/course_view.py @@ -6,15 +6,16 @@ from rest_framework.request import Request from drf_yasg.utils import swagger_auto_schema from api.models.course import Course -from api.models.group import Group from api.permissions.course_permissions import ( CoursePermission, CourseAssistantPermission, - CourseStudentPermission + CourseStudentPermission, + CourseTeacherPermission ) from api.permissions.role_permissions import IsTeacher from api.serializers.course_serializer import ( - CourseSerializer, StudentJoinSerializer, StudentLeaveSerializer, CourseCloneSerializer + CourseSerializer, StudentJoinSerializer, StudentLeaveSerializer, CourseCloneSerializer, + TeacherJoinSerializer, TeacherLeaveSerializer ) from api.serializers.teacher_serializer import TeacherSerializer from api.serializers.assistant_serializer import AssistantSerializer, AssistantIDSerializer @@ -124,7 +125,7 @@ def _remove_student(self, request: Request, **_): # Get the course course = self.get_object() - # Add student to course + # Remove the student from the course serializer = StudentLeaveSerializer(data=request.data, context={ "course": course }) @@ -138,7 +139,7 @@ def _remove_student(self, request: Request, **_): "message": gettext("courses.success.students.remove") }) - @action(detail=True, methods=["get"]) + @action(detail=True, methods=["get"], permission_classes=[IsAdminUser | CourseTeacherPermission]) def teachers(self, request, **_): """Returns a list of teachers for the given course""" course = self.get_object() @@ -151,6 +152,49 @@ def teachers(self, request, **_): return Response(serializer.data) + @teachers.mapping.post + @teachers.mapping.put + @swagger_auto_schema(request_body=TeacherJoinSerializer) + def _add_teacher(self, request, **_): + """Add a teacher to the course""" + # Get the course + course = self.get_object() + + # Add teacher to course + serializer = TeacherJoinSerializer(data=request.data, context={ + "course": course + }) + + if serializer.is_valid(raise_exception=True): + course.teachers.add( + serializer.validated_data["teacher_id"] + ) + + return Response({ + "message": gettext("courses.success.teachers.add") + }) + + @teachers.mapping.delete + @swagger_auto_schema(request_body=TeacherLeaveSerializer) + def _remove_teacher(self, request, **_): + """Remove a teacher from the course""" + # Get the course + course = self.get_object() + + # Remove the teacher from the course + serializer = TeacherLeaveSerializer(data=request.data, context={ + "course": course + }) + + if serializer.is_valid(raise_exception=True): + course.teachers.remove( + serializer.validated_data["teacher_id"] + ) + + return Response({ + "message": gettext("courses.success.teachers.remove") + }) + @action(detail=True, methods=["get"]) def projects(self, request, **_): """Returns a list of projects for the given course""" @@ -204,7 +248,8 @@ def clone(self, request: Request, **__): except Course.DoesNotExist: # Else, we clone the course - course.clone( + course = course.clone( + clone_teachers=serializer.validated_data["clone_teachers"], clone_assistants=serializer.validated_data["clone_assistants"] ) diff --git a/backend/authentication/models.py b/backend/authentication/models.py index dff350cb..71eadc53 100644 --- a/backend/authentication/models.py +++ b/backend/authentication/models.py @@ -1,5 +1,7 @@ from datetime import MINYEAR from django.db import models +from django.apps import apps +from django.utils.functional import cached_property from django.db.models import CharField, EmailField, IntegerField, DateTimeField, BooleanField, Model from django.contrib.auth.models import AbstractBaseUser, AbstractUser, PermissionsMixin @@ -38,6 +40,22 @@ def make_admin(self): self.is_staff = True self.save() + @cached_property + def roles(self): + """Return all roles associated with this user""" + return [ + # Use the class' name in lower case... + model.__name__.lower() + # ...for each installed app that could define a role model... + for app_config in apps.get_app_configs() + # ...for each model in the app's models... + for model in app_config.get_models() + # ...that inherit the User class. + if model is not self.__class__ + if issubclass(model, self.__class__) + if model.objects.filter(id=self.id).exists() + ] + @staticmethod def get_dummy_admin(): return User( diff --git a/backend/authentication/serializers.py b/backend/authentication/serializers.py index 86c44cf7..f7d9297b 100644 --- a/backend/authentication/serializers.py +++ b/backend/authentication/serializers.py @@ -7,7 +7,7 @@ from django.contrib.auth.models import update_last_login from rest_framework.serializers import ( CharField, - EmailField, + SerializerMethodField, HyperlinkedRelatedField, ModelSerializer, Serializer, @@ -23,7 +23,6 @@ class CASTokenObtainSerializer(Serializer): This serializer takes the CAS ticket and tries to validate it. Upon successful validation, create a new user if it doesn't exist. """ - ticket = CharField(required=True, min_length=49, max_length=49) def validate(self, data): @@ -64,35 +63,37 @@ def _validate_ticket(self, ticket: str) -> dict: return response.data.get("attributes", dict) def _fetch_user_from_cas(self, attributes: dict) -> Tuple[User, bool]: + # Convert the lastenrolled attribute if attributes.get("lastenrolled"): attributes["lastenrolled"] = int(attributes.get("lastenrolled").split()[0]) - user = UserSerializer( - data={ - "id": attributes.get("ugentID"), - "username": attributes.get("uid"), - "email": attributes.get("mail"), - "first_name": attributes.get("givenname"), - "last_name": attributes.get("surname"), - "last_enrolled": attributes.get("lastenrolled"), - } - ) + # Map the CAS data onto the user data + data = { + "id": attributes.get("ugentID"), + "username": attributes.get("uid"), + "email": attributes.get("mail"), + "first_name": attributes.get("givenname"), + "last_name": attributes.get("surname"), + "last_enrolled": attributes.get("lastenrolled"), + } + + # Get or create the user + user, created = User.objects.get_or_create(id=data["id"], defaults=data) - if not user.is_valid(): - raise ValidationError(user.errors) + # Validate the serializer + serializer = UserSerializer(user, data=data) - return user.get_or_create(user.validated_data) + if not serializer.is_valid(): + raise ValidationError(serializer.errors) + + # Save the user + return serializer.save(), created class UserSerializer(ModelSerializer): """Serializer for the user model This serializer validates the user fields for creation and updating. """ - - id = CharField() - username = CharField() - email = EmailField() - faculties = HyperlinkedRelatedField( many=True, read_only=True, view_name="faculty-detail" ) @@ -102,14 +103,16 @@ class UserSerializer(ModelSerializer): read_only=True, ) + roles = SerializerMethodField() + + def get_roles(self, user: User): + """Get the roles for the user""" + return user.roles + class Meta: model = User fields = "__all__" - def get_or_create(self, validated_data: dict) -> Tuple[User, bool]: - """Create or fetch the user based on the validated data.""" - return User.objects.get_or_create(**validated_data) - class UserIDSerializer(Serializer): user = PrimaryKeyRelatedField( diff --git a/backend/ypovoli/settings.py b/backend/ypovoli/settings.py index c839e268..98fd8b85 100644 --- a/backend/ypovoli/settings.py +++ b/backend/ypovoli/settings.py @@ -10,21 +10,19 @@ https://docs.djangoproject.com/en/5.0/ref/settings/ """ -from django.utils.translation import gettext_lazy as _ import os from datetime import timedelta from os import environ from pathlib import Path +from django.utils.translation import gettext_lazy as _ + # Build paths inside the project like this: BASE_DIR / 'subdir'. BASE_DIR = Path(__file__).resolve().parent.parent MEDIA_ROOT = os.path.normpath(os.path.join(BASE_DIR, "data/production")) -TESTING_BASE_LINK = "http://testserver" - -# Quick-start development settings - unsuitable for production -# See https://docs.djangoproject.com/en/5.0/howto/deployment/checklist/ +TESTING_BASE_LINK = "http://testserver" # SECURITY WARNING: keep the secret key used in production secret! SECRET_KEY = environ.get("DJANGO_SECRET_KEY", "lnZZ2xHc6HjU5D85GDE3Nnu4CJsBnm") @@ -33,10 +31,10 @@ DEBUG = environ.get("DJANGO_DEBUG", "False").lower() in ["true", "1", "t"] DOMAIN_NAME = environ.get("DJANGO_DOMAIN_NAME", "localhost") ALLOWED_HOSTS = [DOMAIN_NAME] +CSRF_TRUSTED_ORIGINS = ["https://" + DOMAIN_NAME] # Application definition - INSTALLED_APPS = [ # Built-ins "django.contrib.auth", @@ -44,12 +42,10 @@ "django.contrib.sessions", "django.contrib.messages", "django.contrib.staticfiles", - # Third party "rest_framework_swagger", # Swagger "rest_framework", # Django rest framework "drf_yasg", # Yet Another Swagger generator "sslserver", # Used for local SSL support (needed by CAS) - # First party`` "authentication", # Ypovoli authentication "api", # Ypovoli logic of the base application "notifications", # Ypovoli notifications @@ -85,12 +81,13 @@ "TOKEN_OBTAIN_SERIALIZER": "authentication.serializers.CASTokenObtainSerializer", } -AUTH_USER_MODEL = "authentication.User" ROOT_URLCONF = "ypovoli.urls" WSGI_APPLICATION = "ypovoli.wsgi.application" -# Application endpoints +# Authentication +AUTH_USER_MODEL = "authentication.User" +# Application endpoints PORT = environ.get("DJANGO_CAS_PORT", "8080") CAS_ENDPOINT = "https://login.ugent.be" CAS_RESPONSE = f"https://{DOMAIN_NAME}:{PORT}/auth/verify" @@ -114,7 +111,6 @@ DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" # Internationalization - LANGUAGE_CODE = "en-us" TIME_ZONE = "UTC" USE_I18N = True diff --git a/data/nginx/nginx.prod.conf b/data/nginx/nginx.prod.conf index 042f9859..8d59a46f 100644 --- a/data/nginx/nginx.prod.conf +++ b/data/nginx/nginx.prod.conf @@ -34,14 +34,6 @@ http { proxy_redirect off; } - location /auth/ { - rewrite ^/auth/(.*)$ /$1 break; - proxy_pass http://backend; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header Host $host; - proxy_redirect off; - } - location / { proxy_pass http://frontend; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; @@ -49,4 +41,4 @@ http { proxy_redirect off; } } -} \ No newline at end of file +} diff --git a/frontend/cypress/screenshots/login.cy.ts/login page -- routes to dashboard when pressing dashboard button (failed).png b/frontend/cypress/screenshots/login.cy.ts/login page -- routes to dashboard when pressing dashboard button (failed).png new file mode 100644 index 00000000..1651f20f Binary files /dev/null and b/frontend/cypress/screenshots/login.cy.ts/login page -- routes to dashboard when pressing dashboard button (failed).png differ diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 0b423e4d..97903416 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,6 +8,7 @@ "name": "vite-vue-typescript-starter", "version": "0.0.0", "dependencies": { + "@vueuse/core": "^10.9.0", "axios": "^1.6.8", "js-cookie": "^3.0.5", "moment": "^2.30.1", @@ -929,17 +930,10 @@ "integrity": "sha512-0vWLNK2D5MT9dg0iOo8GlKguPAU02QjmZitPEsXRuJXU/OGIOt9vT9Fc26wtYuavLxtO45v9PGleoL9Z0k1LHg==", "dev": true }, - "node_modules/@types/statuses": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@types/statuses/-/statuses-2.0.5.tgz", - "integrity": "sha512-jmIUGWrAiwu3dZpxntxieC+1n/5c3mjrImkmOSQ2NC5uP6cYO4aAZDdSmRcI5C1oiTmqlZGHC+/NmJrKogbP5A==", - "dev": true - }, - "node_modules/@types/wrap-ansi": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@types/wrap-ansi/-/wrap-ansi-3.0.0.tgz", - "integrity": "sha512-ltIpx+kM7g/MLRZfkbL7EsCEjfzCcScLpkg37eXEtx5kmrAKBkTJwd1GIAjDSL8wTpM6Hzn5YO4pSb91BEwu1g==", - "dev": true + "node_modules/@types/web-bluetooth": { + "version": "0.0.20", + "resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.20.tgz", + "integrity": "sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow==" }, "node_modules/@types/yauzl": { "version": "2.10.3", @@ -1191,6 +1185,89 @@ "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.4.21.tgz", "integrity": "sha512-PuJe7vDIi6VYSinuEbUIQgMIRZGgM8e4R+G+/dQTk0X1NEdvgvvgv7m+rfmDH1gZzyA1OjjoWskvHlfRNfQf3g==" }, + "node_modules/@vueuse/core": { + "version": "10.9.0", + "resolved": "https://registry.npmjs.org/@vueuse/core/-/core-10.9.0.tgz", + "integrity": "sha512-/1vjTol8SXnx6xewDEKfS0Ra//ncg4Hb0DaZiwKf7drgfMsKFExQ+FnnENcN6efPen+1kIzhLQoGSy0eDUVOMg==", + "dependencies": { + "@types/web-bluetooth": "^0.0.20", + "@vueuse/metadata": "10.9.0", + "@vueuse/shared": "10.9.0", + "vue-demi": ">=0.14.7" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vueuse/core/node_modules/vue-demi": { + "version": "0.14.7", + "resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.7.tgz", + "integrity": "sha512-EOG8KXDQNwkJILkx/gPcoL/7vH+hORoBaKgGe+6W7VFMvCYJfmF2dGbvgDroVnI8LU7/kTu8mbjRZGBU1z9NTA==", + "hasInstallScript": true, + "bin": { + "vue-demi-fix": "bin/vue-demi-fix.js", + "vue-demi-switch": "bin/vue-demi-switch.js" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@vue/composition-api": "^1.0.0-rc.1", + "vue": "^3.0.0-0 || ^2.6.0" + }, + "peerDependenciesMeta": { + "@vue/composition-api": { + "optional": true + } + } + }, + "node_modules/@vueuse/metadata": { + "version": "10.9.0", + "resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-10.9.0.tgz", + "integrity": "sha512-iddNbg3yZM0X7qFY2sAotomgdHK7YJ6sKUvQqbvwnf7TmaVPxS4EJydcNsVejNdS8iWCtDk+fYXr7E32nyTnGA==", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vueuse/shared": { + "version": "10.9.0", + "resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-10.9.0.tgz", + "integrity": "sha512-Uud2IWncmAfJvRaFYzv5OHDli+FbOzxiVEQdLCKQKLyhz94PIyFC3CHcH7EDMwIn8NPtD06+PNbC/PiO0LGLtw==", + "dependencies": { + "vue-demi": ">=0.14.7" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vueuse/shared/node_modules/vue-demi": { + "version": "0.14.7", + "resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.7.tgz", + "integrity": "sha512-EOG8KXDQNwkJILkx/gPcoL/7vH+hORoBaKgGe+6W7VFMvCYJfmF2dGbvgDroVnI8LU7/kTu8mbjRZGBU1z9NTA==", + "hasInstallScript": true, + "bin": { + "vue-demi-fix": "bin/vue-demi-fix.js", + "vue-demi-switch": "bin/vue-demi-switch.js" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@vue/composition-api": "^1.0.0-rc.1", + "vue": "^3.0.0-0 || ^2.6.0" + }, + "peerDependenciesMeta": { + "@vue/composition-api": { + "optional": true + } + } + }, "node_modules/acorn": { "version": "8.11.3", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.11.3.tgz", diff --git a/frontend/package.json b/frontend/package.json index a6a18290..08d9679a 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -13,6 +13,7 @@ "cypress:test": "cypress run" }, "dependencies": { + "@vueuse/core": "^10.9.0", "axios": "^1.6.8", "js-cookie": "^3.0.5", "moment": "^2.30.1", diff --git a/frontend/src/assets/lang/en.json b/frontend/src/assets/lang/en.json index d46828d7..0ddf5c85 100644 --- a/frontend/src/assets/lang/en.json +++ b/frontend/src/assets/lang/en.json @@ -1,13 +1,14 @@ { "layout": { "header": { - "logo": "University of Ghent logo", + "logo": "Ghent University logo", + "login": "login", "navigation": { "dashboard": "Dashboard", "calendar": "Calendar", - "courses": "Courses", - "settings": "Preferences", - "help": "Help" + "courses": "courses", + "settings": "preferences", + "help": "help" }, "language": { "nl": "Dutch", @@ -20,6 +21,16 @@ "courses": "My courses", "projects": "Current projects" }, + "login": { + "title": "Login", + "illustration": "Dare to think", + "subtitle": "Login is done with your UGent account. If you have problems logging in, please contact the administrator.", + "button": "UGent login", + "card": { + "title": "Ypovoli", + "subtitle": "The official submission platform of Ghent University." + } + }, "calendar": { "title": "Calendar" }, @@ -30,30 +41,34 @@ } }, "composables": { - "helpers": { - "errors": { - "notFound": "Not Found", - "notFoundDetail": "Resource 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", - "networkDetail": "Unable to connect to the server.", - "request": "Request Error", - "requestDetail": "An error occurred while making the request." + "helpers": { + "errors": { + "notFound": "Not found", + "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", + "networkDetail": "Unable to reach the server.", + "request": "request error", + "requestDetail": "An error occurred while creating the request." + } } - } }, "components": { "buttons": { "academic_year": "Academic year {0}" }, "card": { - "open": "Details" + "open": "details" + } + }, + "toasts": { + "messages": { + "unknown": "An unknown error has occurred." } }, - "primevue": { "startsWith": "Starts with", "contains": "Contains", diff --git a/frontend/src/assets/lang/nl.json b/frontend/src/assets/lang/nl.json index a19ead69..c183c120 100644 --- a/frontend/src/assets/lang/nl.json +++ b/frontend/src/assets/lang/nl.json @@ -3,6 +3,7 @@ "header": { "logo": "Logo van de Universiteit Gent", "login": "Login", + "view": "Bekijken als {0}", "navigation": { "dashboard": "Dashboard", "calendar": "Kalender", @@ -51,6 +52,7 @@ "serverDetail": "Er vond een fout plaats op de server.", "network": "Netwerk Fout", "networkDetail": "Kan de server niet bereiken.", + "request": "Fout verzoek", "requestDetail": "Een fout vond plaats tijdens het maken van het verzoek." } @@ -64,7 +66,18 @@ "open": "Details" } }, - + "types": { + "roles": { + "student": "Student", + "assistant": "Assistent", + "teacher": "Professor" + } + }, + "toasts": { + "messages": { + "unknown": "Er is een onbekende fout opgetreden." + } + }, "primevue": { "accept": "Ja", "addRule": "Voeg regel toe", diff --git a/frontend/src/components/LanguageSelector.vue b/frontend/src/components/LanguageSelector.vue new file mode 100644 index 00000000..1743db23 --- /dev/null +++ b/frontend/src/components/LanguageSelector.vue @@ -0,0 +1,65 @@ + + + + + \ No newline at end of file diff --git a/frontend/src/components/RoleSelector.vue b/frontend/src/components/RoleSelector.vue new file mode 100644 index 00000000..526f0287 --- /dev/null +++ b/frontend/src/components/RoleSelector.vue @@ -0,0 +1,30 @@ + + + + + \ No newline at end of file diff --git a/frontend/src/components/layout/BaseLayout.vue b/frontend/src/components/layout/BaseLayout.vue index 232f94a0..e5b8398f 100644 --- a/frontend/src/components/layout/BaseLayout.vue +++ b/frontend/src/components/layout/BaseLayout.vue @@ -1,7 +1,7 @@