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 @@ + + + + + + + + {{ t('layout.header.language.' + option) }} + + + + + {{ value }} + + + + + + \ 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 @@ + + + + + + + + {{ t('layout.header.view', [t('types.roles.' + option).toLowerCase()]) }} + + + + + {{ t('types.roles.' + value) }} + + + + + + + \ 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 @@ diff --git a/frontend/src/components/Body.vue b/frontend/src/components/layout/Body.vue similarity index 57% rename from frontend/src/components/Body.vue rename to frontend/src/components/layout/Body.vue index e4697df8..793a8748 100644 --- a/frontend/src/components/Body.vue +++ b/frontend/src/components/layout/Body.vue @@ -2,7 +2,7 @@ - + diff --git a/frontend/src/components/Footer.vue b/frontend/src/components/layout/Footer.vue similarity index 100% rename from frontend/src/components/Footer.vue rename to frontend/src/components/layout/Footer.vue diff --git a/frontend/src/components/Header.vue b/frontend/src/components/layout/Header.vue similarity index 65% rename from frontend/src/components/Header.vue rename to frontend/src/components/layout/Header.vue index 1b2e0a0e..70ad2e4e 100644 --- a/frontend/src/components/Header.vue +++ b/frontend/src/components/layout/Header.vue @@ -1,22 +1,26 @@ diff --git a/frontend/src/composables/i18n.ts b/frontend/src/composables/i18n.ts index 00a319f0..27af1600 100644 --- a/frontend/src/composables/i18n.ts +++ b/frontend/src/composables/i18n.ts @@ -2,9 +2,12 @@ import 'moment/dist/locale/nl'; import en from '@/assets/lang/en.json'; import nl from '@/assets/lang/nl.json'; import {createI18n} from 'vue-i18n'; +import {useLocalStorage} from '@vueuse/core'; + +const localeStorage = useLocalStorage('locale', 'nl'); export const i18n = createI18n({ - locale: 'nl', + locale: localeStorage.value, fallbackLocale: 'en', legacy: false, messages: { en, nl } diff --git a/frontend/src/composables/services/admins.service.ts b/frontend/src/composables/services/admins.service.ts index 7c5191f4..36226d25 100644 --- a/frontend/src/composables/services/admins.service.ts +++ b/frontend/src/composables/services/admins.service.ts @@ -1,36 +1,36 @@ -import {Admin} from '@/types/Admin.ts'; import {ref} from 'vue'; import {endpoints} from '@/config/endpoints.ts'; import { get, getList, create, delete_id } from '@/composables/services/helpers.ts'; +import {User} from '@/types/User.ts'; export function useAdmin() { - const admins = ref(null); - const admin = ref(null); + const admins = ref(null); + const admin = ref(null); async function getAdminByID(id: string) { const endpoint = endpoints.admins.retrieve.replace('{id}', id); - await get(endpoint, admin, Admin.fromJSON); + await get(endpoint, admin, User.fromJSON); } async function getAdmins() { const endpoint = endpoints.admins.index; - await getList(endpoint, admins, Admin.fromJSON); + await getList(endpoint, admins, User.fromJSON); } - async function createAdmin(admin_data: Admin) { + async function createAdmin(admin_data: User) { const endpoint = endpoints.admins.index; - await create(endpoint, + await create(endpoint, { email:admin_data.email, first_name:admin_data.first_name, last_name: admin_data.last_name }, - admin, Admin.fromJSON); + admin, User.fromJSON); } async function deleteAdmin(id: string) { const endpoint = endpoints.admins.retrieve.replace('{id}', id); - await delete_id(endpoint, admin, Admin.fromJSON); + await delete_id(endpoint, admin, User.fromJSON); } return { diff --git a/frontend/src/composables/services/helpers.ts b/frontend/src/composables/services/helpers.ts index 45d0f4a7..de746884 100644 --- a/frontend/src/composables/services/helpers.ts +++ b/frontend/src/composables/services/helpers.ts @@ -1,8 +1,9 @@ import { AxiosError, AxiosResponse } from 'axios'; import { client } from '@/composables/axios.ts' -import {Ref} from 'vue'; -import {useToastStore} from '@/store/toast.store.ts'; +import { Ref } from 'vue'; +import { useMessagesStore } from '@/store/messages.store.ts'; import { i18n } from '../i18n'; + const lifeTime = 3000; export async function get(endpoint: string, ref: Ref, fromJson: (data: any) => T): Promise { @@ -78,7 +79,8 @@ export async function getListMerged(endpoints: string[], ref: Ref, export function processError(error: AxiosError){ const { t } = i18n.global; - const { add } = useToastStore(); + const { add } = useMessagesStore(); + if (error.response) { // The request was made and the server responded with a status code if (error.response.status === 404) { diff --git a/frontend/src/composables/services/structure_check.service.ts b/frontend/src/composables/services/structure_check.service.ts index ddb8b85d..efe3b62e 100644 --- a/frontend/src/composables/services/structure_check.service.ts +++ b/frontend/src/composables/services/structure_check.service.ts @@ -1,43 +1,43 @@ -import {Structure_check} from '@/types/Structure_check.ts'; +import {StructureCheck} from '@/types/StructureCheck.ts'; import {ref} from 'vue'; import {endpoints} from '@/config/endpoints.ts'; import { get, getList, create, delete_id } from '@/composables/services/helpers.ts'; -export function useStructure_check() { - const structure_checks = ref(null); - const structure_check = ref(null); +export function useStructureCheck() { + const structure_checks = ref(null); + const structure_check = ref(null); - async function getStructure_checkByID(id: string) { + async function getStructureCheckByID(id: string) { const endpoint = endpoints.structure_checks.retrieve.replace('{id}', id); - await get(endpoint, structure_check, Structure_check.fromJSON); + await get(endpoint, structure_check, StructureCheck.fromJSON); } - async function getStructure_checkByProject(project_id: string) { + async function getStructureCheckByProject(project_id: string) { const endpoint = endpoints.structure_checks.byProject.replace('{project_id}', project_id); - await getList(endpoint, structure_checks, Structure_check.fromJSON); + await getList(endpoint, structure_checks, StructureCheck.fromJSON); } - async function createStructure_check(structure_check_data: Structure_check, project_id: string) { + async function createStructureCheck(structure_check_data: StructureCheck, project_id: string) { const endpoint = endpoints.structure_checks.byProject.replace('{project_id}', project_id); - await create(endpoint, + await create(endpoint, { name: structure_check_data.name }, - structure_check, Structure_check.fromJSON); + structure_check, StructureCheck.fromJSON); } - async function deleteStructure_check(id: string) { + async function deleteStructureCheck(id: string) { const endpoint = endpoints.structure_checks.retrieve.replace('{id}', id); - await delete_id(endpoint, structure_check, Structure_check.fromJSON); + await delete_id(endpoint, structure_check, StructureCheck.fromJSON); } return { structure_checks, structure_check, - getStructure_checkByID, - getStructure_checkByProject, + getStructureCheckByID, + getStructureCheckByProject, - createStructure_check, - deleteStructure_check + createStructureCheck, + deleteStructureCheck }; } \ No newline at end of file diff --git a/frontend/src/main.ts b/frontend/src/main.ts index 37de12d2..12a3a2ff 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -7,20 +7,15 @@ import {i18n} from '@/composables/i18n'; import {createApp} from 'vue'; import {createPinia} from 'pinia'; +/* Create the application */ const app = createApp(App); /* Bind application plugins */ app.use(createPinia()); - -app.use(ToastService); - app.use(i18n); - -app.use(router) - -app.use(PrimeVue, { - ripple: true -}); +app.use(router); +app.use(PrimeVue, { ripple: true }); +app.use(ToastService); /* Bind app directives */ app.directive('ripple', Ripple); diff --git a/frontend/src/router/guards/authentication.guard.ts b/frontend/src/router/guards/authentication.guard.ts index 4007cf82..748dd66d 100644 --- a/frontend/src/router/guards/authentication.guard.ts +++ b/frontend/src/router/guards/authentication.guard.ts @@ -3,7 +3,9 @@ import {useAuthStore} from '@/store/authentication.store.ts'; import {storeToRefs} from 'pinia'; export async function AuthenticationGuard(to: RouteLocationNormalized) { - const { refresh, pushIntent } = useAuthStore(); + const { refresh } = useAuthStore(); + const { intent } = storeToRefs(useAuthStore()); + const { isAuthenticated} = storeToRefs(useAuthStore()); if (!['login', 'verify'].includes(to.name as string)) { @@ -11,8 +13,7 @@ export async function AuthenticationGuard(to: RouteLocationNormalized) { await refresh(); if (!isAuthenticated.value) { - pushIntent(to.fullPath); - + intent.value = to.fullPath; return { name: 'login' }; } } diff --git a/frontend/src/store/authentication.store.ts b/frontend/src/store/authentication.store.ts index 3fa986be..a119e52c 100644 --- a/frontend/src/store/authentication.store.ts +++ b/frontend/src/store/authentication.store.ts @@ -1,95 +1,93 @@ import axios from 'axios'; import {defineStore} from 'pinia'; -import {User} from '@/types/User.ts'; +import {Role, User} from '@/types/User.ts'; import {endpoints} from '@/config/endpoints.ts'; -import {ref} from 'vue'; -import {useToastStore} from '@/store/toast.store.ts'; +import {useMessagesStore} from '@/store/messages.store.ts'; import {client} from '@/composables/axios.ts'; +import {Teacher} from '@/types/Teacher.ts'; +import {Student} from '@/types/Student.ts'; +import {Assistant} from '@/types/Assistant.ts'; +import {useLocalStorage} from '@vueuse/core'; +import {computed, ref} from 'vue'; -const INTENT_KEY = 'intent'; +export const useAuthStore = defineStore('auth', () => { + /* Stores */ + const user = ref(null); + const teacher = ref(null); + const student = ref(null); + const assistant = ref(null); + const view = useLocalStorage('view', null); + const intent = useLocalStorage('intent', '/'); -export const useAuthStore = defineStore('auth', { - state: () => { - return { - user: ref(null) - }; - }, - actions: { - /** - * Attempt to log in the user using a CAS ticket. - * - * @param ticket - */ - async login(ticket: string) { - // Display toast messages. - const { add } = useToastStore(); + /** + * Attempt to log in the user using a CAS ticket. + * + * @param ticket + */ + async function login(ticket: string) { + // Display toast messages. + const { add } = useMessagesStore(); - // Attempt to log in the user using the ticket. - await axios.post(endpoints.auth.token.obtain, { - ticket - }).then(() => { - add({ - severity: 'success', - summary: 'Success', - detail: 'You have successfully logged in.' - }) - }).catch((error) => { - add({ - severity: 'error', - summary: error.response.statusText, - detail: error.response.data.detail - }); + // Attempt to log in the user using the ticket. + await axios.post(endpoints.auth.token.obtain, { + ticket + }).then(() => { + add({ + severity: 'success', + summary: 'Success', + detail: 'You have successfully logged in.' + }) + }).catch((error) => { + add({ + severity: 'error', + summary: error.response.statusText, + detail: error.response.data.detail }); - }, - /** - * Refresh the user objects in the API endpoint. - */ - async refresh() { - // Display toast messages. - const { add } = useToastStore(); + }); + } + + /** + * Refresh the user objects in the API endpoint. + */ + async function refresh() { + // Display toast messages. + const { add } = useMessagesStore(); + + // Get the user information (using a cookie). + await axios.get(endpoints.auth.whoami).then(response => { + user.value = User.fromJSON(response.data); + + if (view.value === null) { + view.value = user.value.roles[0]; + } + + // TODO: Get the teacher, student, and assistant information. - // Get the user information (using a cookie). - await axios.get(endpoints.auth.whoami).then(response => { - this.user = User.fromJSON(response.data); - }).catch((error) => { - add({ - severity: 'error', - summary: error.response.statusText, - detail: error.response.data.detail - }); + }).catch((error) => { + add({ + severity: 'error', + summary: error.response.statusText, + detail: error.response.data.detail }); - }, - async logout() { - await client.post(endpoints.auth.logout).catch(); - this.user = null; - }, - /** - * Save the intent URL in the local storage. - * - * @param intent - */ - pushIntent(intent: string): void { - localStorage.setItem(INTENT_KEY, intent); - }, - /** - * Get the intent URL from the local storage. - * - * @return string - */ - popIntent(): string { - const intent = localStorage.getItem(INTENT_KEY) || '/'; - localStorage.removeItem(INTENT_KEY); - return intent; - } - }, - getters: { - /** - * Check if the user is authenticated. - * - * @param state - */ - isAuthenticated(state): boolean { - return state.user !== null; - } + }); + } + + /** + * Log out the user. + */ + async function logout() { + await client.post(endpoints.auth.logout).catch(); + user.value = null; + } + + /* Getters */ + const isAuthenticated = computed(() => { + return user.value !== null; + }); + + return { + user, teacher, student, assistant, view, intent, + login, refresh, logout, + isAuthenticated } }); \ No newline at end of file diff --git a/frontend/src/store/messages.store.ts b/frontend/src/store/messages.store.ts new file mode 100644 index 00000000..45fca12d --- /dev/null +++ b/frontend/src/store/messages.store.ts @@ -0,0 +1,22 @@ +import {ref} from 'vue'; +import {ToastMessageOptions} from 'primevue/toast'; +import {defineStore} from 'pinia'; + +export const useMessagesStore = defineStore('messages', () => { + /* State */ + const message = ref(null); + + /** + * Update the toast message. + * + * @param newMessage + */ + function add(newMessage: ToastMessageOptions): void { + message.value = newMessage; + } + + return { + message, + add + } +}); \ No newline at end of file diff --git a/frontend/src/store/toast.store.ts b/frontend/src/store/toast.store.ts deleted file mode 100644 index 0a018fe1..00000000 --- a/frontend/src/store/toast.store.ts +++ /dev/null @@ -1,21 +0,0 @@ -import {ref} from 'vue'; -import {ToastMessageOptions} from 'primevue/toast'; -import {defineStore} from 'pinia'; - -export const useToastStore = defineStore('toast', { - state: () => { - return { - message: ref(null) - } - }, - actions: { - /** - * Update a toast message. - * - * @param message - */ - add(message: ToastMessageOptions): void { - this.message = message; - } - } -}); \ No newline at end of file diff --git a/frontend/src/types/Admin.ts b/frontend/src/types/Admin.ts deleted file mode 100644 index 77ba641a..00000000 --- a/frontend/src/types/Admin.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { Faculty } from "./Faculty"; - -export class Admin { - constructor( - public id: string, - public last_login: Date |null, - public username: string, - public is_staff: boolean, - public email: string, - public first_name: string, - public last_name: string, - public last_enrolled: number, - public create_time: Date, - public faculties: Faculty[] = [] - ) { - } - - /** - * Convert a admin object to a admin instance. - * - * @param admin - */ - static fromJSON(admin: Admin): Admin { - return new Admin( - admin.id, - admin.last_login ? new Date(admin.last_login) : null, - admin.username, - admin.is_staff, - admin.email, - admin.first_name, - admin.last_name, - admin.last_enrolled, - new Date(admin.create_time) - ); - } -} \ No newline at end of file diff --git a/frontend/src/types/Assistant.ts b/frontend/src/types/Assistant.ts index 0a5d3132..786c58ee 100644 --- a/frontend/src/types/Assistant.ts +++ b/frontend/src/types/Assistant.ts @@ -1,18 +1,19 @@ import { Faculty } from "./Faculty"; +import {Role, User} from '@/types/User.ts'; -export class Assistant { +export class Assistant extends User { constructor( public id: string, - public last_login: Date |null, public username: string, - public is_staff: boolean, public email: string, public first_name: string, public last_name: string, public last_enrolled: number, - public create_time: Date, + public is_staff: boolean, + public roles: Role[] = [], public faculties: Faculty[] = [] ) { + super(id, username, email, first_name, last_name, last_enrolled, is_staff, roles); } /** @@ -20,18 +21,17 @@ export class Assistant { * * @param assistant */ - static fromJSON(assistant: Assistant): Assistant { return new Assistant( assistant.id, - assistant.last_login ? new Date(assistant.last_login) : null, assistant.username, - assistant.is_staff, assistant.email, assistant.first_name, assistant.last_name, assistant.last_enrolled, - new Date(assistant.create_time) + assistant.is_staff, + assistant.roles, + assistant.faculties ); } } \ No newline at end of file diff --git a/frontend/src/types/Base.ts b/frontend/src/types/Base.ts deleted file mode 100644 index f2f7b543..00000000 --- a/frontend/src/types/Base.ts +++ /dev/null @@ -1,9 +0,0 @@ -/* -export abstract class JSONConvertible { - constructor() {} - - static fromJSON(json: any): any { - throw new Error("fromJSON method not implemented in subclass."); - } -} -*/ \ No newline at end of file diff --git a/frontend/src/types/Extra_check.ts b/frontend/src/types/ExtraCheck.ts similarity index 54% rename from frontend/src/types/Extra_check.ts rename to frontend/src/types/ExtraCheck.ts index 0446408c..2ea95149 100644 --- a/frontend/src/types/Extra_check.ts +++ b/frontend/src/types/ExtraCheck.ts @@ -1,4 +1,4 @@ -export class Extra_check { +export class ExtraCheck { constructor( ) { } diff --git a/frontend/src/types/File_extensions.ts b/frontend/src/types/FileExtension.ts similarity index 100% rename from frontend/src/types/File_extensions.ts rename to frontend/src/types/FileExtension.ts diff --git a/frontend/src/types/Projects.ts b/frontend/src/types/Projects.ts index bd8f3887..62224c89 100644 --- a/frontend/src/types/Projects.ts +++ b/frontend/src/types/Projects.ts @@ -1,7 +1,7 @@ import { Course } from "./Course"; -import { Extra_check } from "./Extra_check"; +import { ExtraCheck } from "./ExtraCheck.ts"; import { Group } from "./Group"; -import { Structure_check } from "./Structure_check"; +import { StructureCheck } from "./StructureCheck.ts"; import { Submission } from "./Submission"; export class Project { @@ -19,8 +19,8 @@ export class Project { public group_size: number, public course: Course|null = null, - public structure_checks: Structure_check[] = [], - public extra_checks: Extra_check[] = [], + public structure_checks: StructureCheck[] = [], + public extra_checks: ExtraCheck[] = [], public groups: Group[] = [], public submissions: Submission[] = [] ) { diff --git a/frontend/src/types/Structure_check.ts b/frontend/src/types/StructureCheck.ts similarity index 72% rename from frontend/src/types/Structure_check.ts rename to frontend/src/types/StructureCheck.ts index 137a5997..54b724ff 100644 --- a/frontend/src/types/Structure_check.ts +++ b/frontend/src/types/StructureCheck.ts @@ -1,7 +1,7 @@ -import { File_extension } from "./File_extensions"; +import { File_extension } from "./FileExtension.ts"; import { Project } from "./Projects"; -export class Structure_check { +export class StructureCheck { constructor( public id: string, public name: string, @@ -17,8 +17,8 @@ export class Structure_check { * * @param structure_check */ - static fromJSON(structure_check: Structure_check): Structure_check { - return new Structure_check( + static fromJSON(structure_check: StructureCheck): StructureCheck { + return new StructureCheck( structure_check.id, structure_check.name ) diff --git a/frontend/src/types/Student.ts b/frontend/src/types/Student.ts index 6d2d2c50..b884ae5c 100644 --- a/frontend/src/types/Student.ts +++ b/frontend/src/types/Student.ts @@ -1,23 +1,24 @@ import { Course } from "./Course"; import { Faculty } from "./Faculty"; import { Group } from "./Group"; +import {Role, User} from '@/types/User.ts'; -export class Student { +export class Student extends User { constructor( public id: string, - public last_login: Date |null, public username: string, - public is_staff: boolean, public email: string, public first_name: string, public last_name: string, + public is_staff: boolean, public last_enrolled: number, - public create_time: Date, + public roles: Role[] = [], public student_id: string, public courses: Course[] = [], public groups: Group[] = [], public faculties: Faculty[] = [] ) { + super(id, username, email, first_name, last_name, last_enrolled, is_staff, roles); } /** @@ -28,15 +29,17 @@ export class Student { static fromJSON(student: Student): Student { return new Student( student.id, - student.last_login ? new Date(student.last_login) : null, student.username, - student.is_staff, student.email, student.first_name, student.last_name, + student.is_staff, student.last_enrolled, - new Date(student.create_time), - student.student_id + student.roles, + student.student_id, + student.courses, + student.groups, + student.faculties ); } } \ No newline at end of file diff --git a/frontend/src/types/Teacher.ts b/frontend/src/types/Teacher.ts index c3237f20..93bed9df 100644 --- a/frontend/src/types/Teacher.ts +++ b/frontend/src/types/Teacher.ts @@ -1,18 +1,19 @@ import { Faculty } from "./Faculty"; +import {Role, User} from '@/types/User.ts'; -export class Teacher { +export class Teacher extends User { constructor( public id: string, - public last_login: Date | null, public username: string, - public is_staff: boolean, public email: string, public first_name: string, public last_name: string, public last_enrolled: number, - public create_time: Date, + public is_staff: boolean, + public roles: Role[] = [], public faculties: Faculty[] = [] ) { + super(id, username, email, first_name, last_name, last_enrolled, is_staff, roles); } /** @@ -24,14 +25,14 @@ export class Teacher { static fromJSON(teacher: Teacher): Teacher { return new Teacher( teacher.id, - teacher.last_login ? new Date(teacher.last_login) : null, teacher.username, - teacher.is_staff, teacher.email, teacher.first_name, teacher.last_name, teacher.last_enrolled, - new Date(teacher.create_time) + teacher.is_staff, + teacher.roles, + teacher.faculties ); } } \ No newline at end of file diff --git a/frontend/src/types/User.ts b/frontend/src/types/User.ts index 31de3a74..f72c685e 100644 --- a/frontend/src/types/User.ts +++ b/frontend/src/types/User.ts @@ -1,10 +1,15 @@ +export type Role = 'user' | 'student' | 'assistant' | 'teacher'; + export class User { constructor( public id: string, public username: string, + public email: string, public first_name: string, public last_name: string, - public last_enrolled: number + public last_enrolled: number, + public is_staff: boolean, + public roles: Role[] = [] ) { } @@ -17,6 +22,33 @@ export class User { return `${this.first_name} ${this.last_name}`; } + /** + * Check if the user is a student. + * + * @returns boolean + */ + public isStudent(): boolean { + return this.roles.includes('student'); + } + + /** + * Check if the user is an assistant. + * + * @returns boolean + */ + public isAssistant(): boolean { + return this.roles.includes('assistant'); + } + + /** + * Check if the user is a teacher. + * + * @returns boolean + */ + public isTeacher(): boolean { + return this.roles.includes('teacher'); + } + /** * Convert a user object to a user instance. * @@ -26,9 +58,12 @@ export class User { return new User( user.id, user.username, + user.email, user.first_name, user.last_name, - user.last_enrolled + user.last_enrolled, + user.is_staff, + user.roles ); } } \ No newline at end of file diff --git a/frontend/src/views/App.vue b/frontend/src/views/App.vue index 8ac761ea..4c067f5c 100644 --- a/frontend/src/views/App.vue +++ b/frontend/src/views/App.vue @@ -1,28 +1,27 @@ diff --git a/frontend/src/views/authentication/LoginView.vue b/frontend/src/views/authentication/LoginView.vue index 3983e488..42e0a7fe 100644 --- a/frontend/src/views/authentication/LoginView.vue +++ b/frontend/src/views/authentication/LoginView.vue @@ -1,7 +1,7 @@ - + Inloggen Je wordt zo meteen doorverwezen diff --git a/frontend/src/views/calendar/CalendarView.vue b/frontend/src/views/calendar/CalendarView.vue index 80a2392d..8cb9d5c4 100644 --- a/frontend/src/views/calendar/CalendarView.vue +++ b/frontend/src/views/calendar/CalendarView.vue @@ -2,7 +2,7 @@ import moment from 'moment'; import BaseLayout from '@/components/layout/BaseLayout.vue'; import Calendar from 'primevue/calendar'; -import Title from '@/components/Title.vue'; +import Title from '@/components/layout/Title.vue'; import { useProject } from '@/composables/services/project.service'; import { computed, onMounted } from 'vue'; import { useI18n } from 'vue-i18n'; diff --git a/frontend/src/views/courses/CourseView.vue b/frontend/src/views/courses/CourseView.vue index 42bb6469..d9b6ccc9 100644 --- a/frontend/src/views/courses/CourseView.vue +++ b/frontend/src/views/courses/CourseView.vue @@ -1,7 +1,7 @@ @@ -80,23 +52,16 @@ const handleDelete = () => { {{ t('views.dashboard.courses') }} - + - - - create course with vaknaam - - join course with id - delete course with id - - - + @@ -118,16 +83,23 @@ const handleDelete = () => { {{ t('views.dashboard.projects') }} - + - - - - + + + + + + + + {{ t('views.dashboard.no_projects') }} + + diff --git a/frontend/src/views/projects/ProjectView.vue b/frontend/src/views/projects/ProjectView.vue index 99b16038..d3f7157f 100644 --- a/frontend/src/views/projects/ProjectView.vue +++ b/frontend/src/views/projects/ProjectView.vue @@ -1,72 +1,65 @@ - - - - - - {{ project.name }} - - - - - - {{ project.description }} - - + + + + + + {{ project.name }} + + + + + + {{ project.description }} + + + + + + + - - - - - - +
{{ t('views.dashboard.no_projects') }}
- {{ project.description }} -
+ {{ project.description }} +