diff --git a/backend/api/models/course.py b/backend/api/models/course.py index b8dd4a42..403cae8a 100644 --- a/backend/api/models/course.py +++ b/backend/api/models/course.py @@ -46,7 +46,7 @@ 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) 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..44171472 100644 --- a/backend/ypovoli/settings.py +++ b/backend/ypovoli/settings.py @@ -20,11 +20,8 @@ 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") @@ -36,7 +33,6 @@ # Application definition - INSTALLED_APPS = [ # Built-ins "django.contrib.auth", @@ -44,12 +40,12 @@ "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/frontend/package-lock.json b/frontend/package-lock.json index b1756bb5..1f9a4633 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", @@ -748,6 +749,11 @@ "integrity": "sha512-0vWLNK2D5MT9dg0iOo8GlKguPAU02QjmZitPEsXRuJXU/OGIOt9vT9Fc26wtYuavLxtO45v9PGleoL9Z0k1LHg==", "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", "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz", @@ -998,6 +1004,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 1d1e5699..12635ca3 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..2a067930 --- /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 @@