From 4f6127293833d940e0c9da52b052e37ec2b3d833 Mon Sep 17 00:00:00 2001
From: Bram Meir
Date: Sat, 27 Apr 2024 17:16:53 +0200
Subject: [PATCH 01/17] chore: init public/protected selection button
---
.../src/views/courses/SearchCourseView.vue | 170 +++++-------------
.../search/ProtectedSearchCourseView.vue | 125 +++++++++++++
.../courses/search/PublicSearchCourseView.vue | 125 +++++++++++++
3 files changed, 299 insertions(+), 121 deletions(-)
create mode 100644 frontend/src/views/courses/search/ProtectedSearchCourseView.vue
create mode 100644 frontend/src/views/courses/search/PublicSearchCourseView.vue
diff --git a/frontend/src/views/courses/SearchCourseView.vue b/frontend/src/views/courses/SearchCourseView.vue
index 6df83e4b..fc6cf78d 100644
--- a/frontend/src/views/courses/SearchCourseView.vue
+++ b/frontend/src/views/courses/SearchCourseView.vue
@@ -1,121 +1,49 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {{ faculty.name }}
-
-
-
-
-
- {{ year }} - {{ year + 1 }}
-
-
-
-
-
-
- {{ t('views.courses.search.title') }}
-
-
- {{ t('views.courses.search.results', [pagination.count]) }}
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/views/courses/search/ProtectedSearchCourseView.vue b/frontend/src/views/courses/search/ProtectedSearchCourseView.vue
new file mode 100644
index 00000000..14a09075
--- /dev/null
+++ b/frontend/src/views/courses/search/ProtectedSearchCourseView.vue
@@ -0,0 +1,125 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ faculty.name }}
+
+
+
+
+
+ {{ year }} - {{ year + 1 }}
+
+
+
+
+
+
+
+ {{ t('views.courses.search.title') }}
+
+
+
+
+
+ {{ t('views.courses.search.results', [pagination.count]) }}
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/views/courses/search/PublicSearchCourseView.vue b/frontend/src/views/courses/search/PublicSearchCourseView.vue
new file mode 100644
index 00000000..14a09075
--- /dev/null
+++ b/frontend/src/views/courses/search/PublicSearchCourseView.vue
@@ -0,0 +1,125 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ faculty.name }}
+
+
+
+
+
+ {{ year }} - {{ year + 1 }}
+
+
+
+
+
+
+
+ {{ t('views.courses.search.title') }}
+
+
+
+
+
+ {{ t('views.courses.search.results', [pagination.count]) }}
+
+
+
+
+
+
+
+
+
+
+
From e6d4082a49b6826c801eb2dd517ec88ef6232461 Mon Sep 17 00:00:00 2001
From: Bram Meir
Date: Sat, 27 Apr 2024 18:38:01 +0200
Subject: [PATCH 02/17] chore: init layout protected courses
---
frontend/src/assets/lang/app/en.json | 1 +
frontend/src/assets/lang/app/nl.json | 1 +
.../courses/search/ProtectedSearchStepper.vue | 56 ++++++++++
.../search/ProtectedSearchCourseView.vue | 105 +++---------------
4 files changed, 73 insertions(+), 90 deletions(-)
create mode 100644 frontend/src/components/courses/search/ProtectedSearchStepper.vue
diff --git a/frontend/src/assets/lang/app/en.json b/frontend/src/assets/lang/app/en.json
index c68e83c5..297835d5 100644
--- a/frontend/src/assets/lang/app/en.json
+++ b/frontend/src/assets/lang/app/en.json
@@ -125,6 +125,7 @@
"faculty": "Faculty",
"year": "Academic year",
"placeholder": "Search a course by name",
+ "placeholderByLink": "Find a course using the registration link",
"title": "Search a course",
"results": "{0} courses found for set filters"
}
diff --git a/frontend/src/assets/lang/app/nl.json b/frontend/src/assets/lang/app/nl.json
index a7ff3470..e5c73778 100644
--- a/frontend/src/assets/lang/app/nl.json
+++ b/frontend/src/assets/lang/app/nl.json
@@ -125,6 +125,7 @@
"faculty": "Faculteit",
"year": "Academiejaar",
"placeholder": "Zoek een vak op naam",
+ "placeholderByLink": "Zoek een vak gebruik makende van een uitnodigingslink",
"title": "Zoek een vak",
"results": "{0} vakken gevonden voor ingestelde filters"
}
diff --git a/frontend/src/components/courses/search/ProtectedSearchStepper.vue b/frontend/src/components/courses/search/ProtectedSearchStepper.vue
new file mode 100644
index 00000000..c8613b5c
--- /dev/null
+++ b/frontend/src/components/courses/search/ProtectedSearchStepper.vue
@@ -0,0 +1,56 @@
+
+
+
+
+
+
+
+
+ Professoren kunnen kiezen om hun vakken niet publiek te maken. Vraag de prof om de link van het vak te delen, om te kunnen toetreden tot het vak.
+
+
+
+
+
+
+
+
+
+
+
+ Gebruik de link om het vak te zoeken. Als je het vak niet kan vinden, kan je de prof vragen om de link opnieuw te delen.
+
+
+
+
+
+
+
+
+
+
+
+
+ Schrijf je in voor het vak. Je kan nu de lopende projecten raadplegen.
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/frontend/src/views/courses/search/ProtectedSearchCourseView.vue b/frontend/src/views/courses/search/ProtectedSearchCourseView.vue
index 14a09075..d76b2af6 100644
--- a/frontend/src/views/courses/search/ProtectedSearchCourseView.vue
+++ b/frontend/src/views/courses/search/ProtectedSearchCourseView.vue
@@ -1,106 +1,33 @@
-
-
-
-
-
-
-
-
-
-
- {{ faculty.name }}
-
-
-
-
-
- {{ year }} - {{ year + 1 }}
-
-
-
+
@@ -110,16 +37,14 @@ onMounted(async () => {
-
- {{ t('views.courses.search.results', [pagination.count]) }}
-
-
-
-
-
+
+
+
+
-
+
\ No newline at end of file
From ebc08497aa340e0984afbcde6e3ad5029afcb506 Mon Sep 17 00:00:00 2001
From: Bram Meir
Date: Sat, 27 Apr 2024 18:56:07 +0200
Subject: [PATCH 03/17] fix: little layout
---
frontend/src/assets/lang/app/en.json | 4 +++-
frontend/src/assets/lang/app/nl.json | 4 +++-
frontend/src/components/courses/CourseGeneralList.vue | 1 +
.../views/courses/search/ProtectedSearchCourseView.vue | 9 +++++----
4 files changed, 12 insertions(+), 6 deletions(-)
diff --git a/frontend/src/assets/lang/app/en.json b/frontend/src/assets/lang/app/en.json
index 297835d5..aa95d6ec 100644
--- a/frontend/src/assets/lang/app/en.json
+++ b/frontend/src/assets/lang/app/en.json
@@ -125,9 +125,11 @@
"faculty": "Faculty",
"year": "Academic year",
"placeholder": "Search a course by name",
- "placeholderByLink": "Find a course using the registration link",
"title": "Search a course",
"results": "{0} courses found for set filters"
+ },
+ "searchByLink": {
+ "placeholder": "Find a course using the registration link"
}
}
},
diff --git a/frontend/src/assets/lang/app/nl.json b/frontend/src/assets/lang/app/nl.json
index e5c73778..527b99fb 100644
--- a/frontend/src/assets/lang/app/nl.json
+++ b/frontend/src/assets/lang/app/nl.json
@@ -125,9 +125,11 @@
"faculty": "Faculteit",
"year": "Academiejaar",
"placeholder": "Zoek een vak op naam",
- "placeholderByLink": "Zoek een vak gebruik makende van een uitnodigingslink",
"title": "Zoek een vak",
"results": "{0} vakken gevonden voor ingestelde filters"
+ },
+ "searchByLink": {
+ "placeholder": "Zoek een vak gebruik makende van een uitnodigingslink"
}
}
},
diff --git a/frontend/src/components/courses/CourseGeneralList.vue b/frontend/src/components/courses/CourseGeneralList.vue
index 4ed8cbe7..805041a8 100644
--- a/frontend/src/components/courses/CourseGeneralList.vue
+++ b/frontend/src/components/courses/CourseGeneralList.vue
@@ -1,6 +1,7 @@
+
+
+
+
+
+
+
+ {{ t('views.courses.share') }}
+
+
+
+
+ {{ t('confirmations.shareCourse') }}
+
+
+
+ {{ t('views.courses.linkDuration') }}
+
+
+
+
+ {{ t('primevue.cancel') }}
+ {{ t('views.courses.share') }}
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/frontend/src/composables/services/course.service.ts b/frontend/src/composables/services/course.service.ts
index 1abf989c..a93a05d7 100644
--- a/frontend/src/composables/services/course.service.ts
+++ b/frontend/src/composables/services/course.service.ts
@@ -68,7 +68,7 @@ export function useCourses(): CoursesState {
description: courseData.description,
excerpt: courseData.excerpt,
academic_startyear: courseData.academic_startyear,
- private: courseData.private_course,
+ private_course: courseData.private_course,
faculty: courseData.faculty?.id,
},
course,
@@ -87,6 +87,7 @@ export function useCourses(): CoursesState {
name: courseData.name,
description: courseData.description,
faculty: courseData.faculty?.id,
+ private_course: courseData.private_course,
},
response,
);
diff --git a/frontend/src/views/courses/UpdateCourseView.vue b/frontend/src/views/courses/UpdateCourseView.vue
index 027f559e..8d9de5da 100644
--- a/frontend/src/views/courses/UpdateCourseView.vue
+++ b/frontend/src/views/courses/UpdateCourseView.vue
@@ -6,6 +6,7 @@ import Textarea from 'primevue/textarea';
import Editor from '@/components/forms/Editor.vue';
import Button from 'primevue/button';
import Dropdown from 'primevue/dropdown';
+import InputSwitch from 'primevue/inputswitch';
import { reactive, computed, onMounted } from 'vue';
import { useRouter, useRoute } from 'vue-router';
import { useI18n } from 'vue-i18n';
@@ -33,7 +34,9 @@ onMounted(async () => {
if (course.value !== null) {
form.name = course.value.name;
form.description = course.value.description ?? '';
+ form.excerpt = course.value.excerpt ?? '';
form.faculty = course.value.faculty ?? new Faculty('', '');
+ form.private = course.value.private_course;
form.year = `${course.value.academic_startyear} - ${course.value.academic_startyear + 1}`;
}
});
@@ -44,6 +47,7 @@ const form = reactive({
description: '',
excerpt: '',
faculty: new Faculty('', ''), // Default value for the dropdown
+ private: false,
year: '',
});
@@ -73,6 +77,7 @@ const submitCourse = async (): Promise => {
form.description,
form.excerpt,
course.value.academic_startyear,
+ form.private,
course.value.parent_course,
form.faculty,
),
@@ -132,6 +137,14 @@ const submitCourse = async (): Promise => {
+
+
+
+
+ {{ t('views.courses.private') }}
+
+
+
{{ props.course.name }}
-
+
@@ -79,18 +81,19 @@ watch(
:icon="PrimeIcons.PENCIL"
icon-pos="right"
class="custom-button"
- style="height: 51px; width: 51px; margin-right: 10px"
+ style="height: 51px; width: 51px"
/>
-
+
{{ message.header }}
{{ message.message }}
+
{{ t('views.courses.cloneTeachers') }}
@@ -116,6 +119,9 @@ watch(
@click="handleClone()"
/>
+
+
+
From 310eacb18ae1f9d1bb908a4f85d32276f059a25e Mon Sep 17 00:00:00 2001
From: Bram Meir
Date: Mon, 29 Apr 2024 11:07:34 +0200
Subject: [PATCH 06/17] chore: backend creation of invitation link
---
backend/api/locale/en/LC_MESSAGES/django.po | 4 ++
backend/api/locale/nl/LC_MESSAGES/django.po | 4 ++
backend/api/models/course.py | 3 ++
backend/api/serializers/course_serializer.py | 24 ++++++++++
backend/api/views/course_view.py | 31 ++++++++++++
frontend/src/assets/lang/app/en.json | 9 +++-
frontend/src/assets/lang/app/nl.json | 8 +++-
.../components/courses/ShareCourseButton.vue | 47 ++++++++++++++++---
8 files changed, 119 insertions(+), 11 deletions(-)
diff --git a/backend/api/locale/en/LC_MESSAGES/django.po b/backend/api/locale/en/LC_MESSAGES/django.po
index e113318d..ef7adf3b 100644
--- a/backend/api/locale/en/LC_MESSAGES/django.po
+++ b/backend/api/locale/en/LC_MESSAGES/django.po
@@ -137,6 +137,10 @@ msgstr "The teacher is not present in the course."
msgid "courses.error.teachers.last_teacher"
msgstr "The course must have at least one teacher."
+#: serializers/course_serializer.py:116
+msgid "courses.error.invitation_link"
+msgstr "The invitation link is not unique, please try again."
+
#: serializers/docker_serializer.py:19
msgid "docker.errors.custom"
msgstr "User is not allowed to create public images"
diff --git a/backend/api/locale/nl/LC_MESSAGES/django.po b/backend/api/locale/nl/LC_MESSAGES/django.po
index 744df8d0..accf935e 100644
--- a/backend/api/locale/nl/LC_MESSAGES/django.po
+++ b/backend/api/locale/nl/LC_MESSAGES/django.po
@@ -139,6 +139,10 @@ msgstr "De lesgever bevindt zich niet in de opleiding."
msgid "courses.error.teachers.last_teacher"
msgstr "De opleiding moet minstens één lesgever hebben."
+#: serializers/course_serializer.py:116
+msgid "courses.error.invitation_link"
+msgstr "De uitnodigingslink is niet uniek, probeer het opnieuw."
+
#: serializers/docker_serializer.py:19
msgid "docker.errors.custom"
msgstr "Gebruiker is niet toegelaten om publieke afbeeldingen te maken"
diff --git a/backend/api/models/course.py b/backend/api/models/course.py
index e31941e4..40e96dfa 100644
--- a/backend/api/models/course.py
+++ b/backend/api/models/course.py
@@ -47,6 +47,9 @@ class Course(models.Model):
# Field that contains the invite link for the course
invite_link = models.CharField(max_length=100, blank=True, null=True)
+ # Date when the invite link expires
+ invite_link_expires = models.DateField(blank=True, null=True)
+
def __str__(self) -> str:
"""The string representation of the course."""
return str(self.name)
diff --git a/backend/api/serializers/course_serializer.py b/backend/api/serializers/course_serializer.py
index 3623101f..667218f2 100644
--- a/backend/api/serializers/course_serializer.py
+++ b/backend/api/serializers/course_serializer.py
@@ -106,6 +106,30 @@ class CourseCloneSerializer(serializers.Serializer):
clone_assistants = serializers.BooleanField()
+class SaveInvitationLinkSerializer(serializers.Serializer):
+ invitation_link = serializers.CharField(required=True)
+ invite_link_expires = serializers.DateField(required=True)
+
+ def validate(self, data):
+ # Check if there is no course with the same invitation link.
+ if Course.objects.filter(invitation_link=data["invitation_link"]).exists():
+ raise ValidationError(gettext("courses.error.invitation_link"))
+
+ return data
+
+ def create(self, validated_data):
+ # Save the invitation link and the expiration date.
+ if "course" not in self.context:
+ raise ValidationError(gettext("courses.error.context"))
+
+ course: Course = self.context["course"]
+
+ course.invitation_link = validated_data["invitation_link"]
+ course.invite_link_expires = validated_data["invite_link_expires"]
+ course.save()
+ return course
+
+
class StudentJoinSerializer(StudentIDSerializer):
def validate(self, data):
# The validator needs the course context.
diff --git a/backend/api/views/course_view.py b/backend/api/views/course_view.py
index 969199c8..257c4131 100644
--- a/backend/api/views/course_view.py
+++ b/backend/api/views/course_view.py
@@ -1,3 +1,4 @@
+import string
from api.models.course import Course
from api.models.assistant import Assistant
from api.models.teacher import Teacher
@@ -10,6 +11,7 @@
from api.serializers.assistant_serializer import (AssistantIDSerializer,
AssistantSerializer)
from api.serializers.course_serializer import (CourseCloneSerializer,
+ SaveInvitationLinkSerializer,
CourseSerializer,
StudentJoinSerializer,
StudentLeaveSerializer,
@@ -23,6 +25,7 @@
from authentication.serializers import UserIDSerializer
from django.utils.translation import gettext
from django.utils import timezone
+from django.utils.crypto import get_random_string
from drf_yasg.utils import swagger_auto_schema
from rest_framework import status, viewsets
from rest_framework.decorators import action
@@ -376,3 +379,31 @@ def clone(self, request: Request, **__):
course_serializer = CourseSerializer(course, context={"request": request})
return Response(course_serializer.data)
+
+ @action(detail=True, methods=["get"])
+ def invitation_link(self, request, **__):
+ """Return a unique link that can be used to join the course"""
+ # Generate a unique link for the course
+ unique_link = None
+
+ while not unique_link:
+ unique_link = get_random_string(20, allowed_chars=string.ascii_letters + string.digits)
+
+ # Make sure there is no course with the same link
+ if Course.objects.filter(invitation_link=unique_link).exists():
+ unique_link = None
+
+ return Response({"invitation_link": unique_link})
+
+ @invitation_link.mapping.post
+ @invitation_link.mapping.put
+ @swagger_auto_schema(request_body=SaveInvitationLinkSerializer)
+ def _save_invitation_link(self, request, **_):
+ """Save the invitation link to the course"""
+ course = self.get_object()
+ course.invitation_link = request.data["invitation_link"]
+ course.save()
+
+ return Response({
+ "message": gettext("courses.success.invitation_link.save")
+ })
diff --git a/frontend/src/assets/lang/app/en.json b/frontend/src/assets/lang/app/en.json
index e256e248..01bfd509 100644
--- a/frontend/src/assets/lang/app/en.json
+++ b/frontend/src/assets/lang/app/en.json
@@ -95,7 +95,6 @@
"create": "Create course",
"edit": "Edit course",
"clone": "Clone course",
- "share": "Create invitation link",
"cloneAssistants": "Clone assistants:",
"cloneTeachers": "Clone teachers:",
"name": "Course name",
@@ -132,7 +131,13 @@
},
"searchByLink": {
"placeholder": "Find a course using the registration link"
+ },
+ "share": {
+ "title": "Create invitation link",
+ "duration": "Validity period link (in days):",
+ "link": "Invitation link:"
}
+
}
},
"composables": {
@@ -232,7 +237,7 @@
"confirmations": {
"cloneCourse": "Are you sure you want to clone this coure? This will create the same course for the next academic year.",
"leaveCourse": "Are you sure you want to leave this course? You will no longer have access to this course.",
- "shareCourse": "By creating an invitation link, you allow students in possession of this link to enroll in this course."
+ "shareCourse": "By creating an invitation link, you allow students in possession of this link to enroll in this course. Please copy the generated link, only when you click on \"Create invitation link\" will this link become valid."
},
"admin": {
"title": "Admin",
diff --git a/frontend/src/assets/lang/app/nl.json b/frontend/src/assets/lang/app/nl.json
index e7653994..7bcb6ab7 100644
--- a/frontend/src/assets/lang/app/nl.json
+++ b/frontend/src/assets/lang/app/nl.json
@@ -95,7 +95,6 @@
"create": "Creëer vak",
"edit": "Bewerk vak",
"clone": "Kloon vak",
- "share": "Creëer invitatielink",
"cloneAssistants": "Kloon assistenten:",
"cloneTeachers": "Kloon lesgevers:",
"name": "Vaknaam",
@@ -132,6 +131,11 @@
},
"searchByLink": {
"placeholder": "Zoek een vak gebruik makende van een uitnodigingslink"
+ },
+ "share": {
+ "title": "Creëer invitatielink",
+ "duration": "Geldigheidsduur link (in dagen):",
+ "link": "Invitatielink:"
}
}
},
@@ -232,7 +236,7 @@
"confirmations": {
"cloneCourse": "Ben je zeker dat je dit vak wil klonen? Dit zal hetzelfde vak aanmaken voor het volgende academiejaar.",
"leaveCourse": "Ben je zeker dat je dit vak wil verlaten? Je zal geen toegang meer hebben tot dit vak.",
- "shareCourse": "Door het creëren van een invitatielink staat u studenten in bezit van deze link toe zich in te schrijven voor dit vak."
+ "shareCourse": "Door het creëren van een invitatielink staat u studenten in bezit van deze link toe zich in te schrijven voor dit vak. Gelieve de gegenereerde link te kopiëren, pas wanneer u op \"Creëer invitatielink\" klikt zal deze link geldig worden."
},
"admin": {
"title": "Beheerder",
diff --git a/frontend/src/components/courses/ShareCourseButton.vue b/frontend/src/components/courses/ShareCourseButton.vue
index 260c7331..55e897f3 100644
--- a/frontend/src/components/courses/ShareCourseButton.vue
+++ b/frontend/src/components/courses/ShareCourseButton.vue
@@ -2,6 +2,7 @@
import Dialog from 'primevue/dialog';
import Button from 'primevue/button';
import InputNumber from 'primevue/inputnumber';
+import InputText from 'primevue/inputtext';
import { useI18n } from 'vue-i18n';
import { type Course } from '@/types/Course.ts';
import { PrimeIcons } from 'primevue/api';
@@ -16,6 +17,9 @@ const props = defineProps<{ course: Course }>();
/* State for the dialog to share a course */
const displayShareCourse = ref(false);
+/* Invitation link for the course */
+const link = ref('KVC Westerlo');
+
/* Number of days the invitation link is valid */
const linkDuration = ref(7);
@@ -25,12 +29,25 @@ const linkDuration = ref(7);
async function handleShare(): Promise {
// Show a confirmation dialog
console.log('Share course');
+ displayShareCourse.value = false;
+}
+
+/**
+ * Copies the invitation link to the clipboard.
+ */
+function copyToClipboard(): void {
+ navigator.clipboard.writeText(link.value).then(() => {
+ console.log('Link copied to clipboard');
+ }).catch(err => {
+ console.error('Failed to copy text: ', err);
+ });
}
+
-
+
(7);
>
- {{ t('views.courses.share') }}
+ {{ t('views.courses.share.title') }}
@@ -56,18 +73,34 @@ const linkDuration = ref(7);
{{ t('confirmations.shareCourse') }}
-
-
{{ t('views.courses.linkDuration') }}
-
+
+
+ {{ t('views.courses.share.duration') }}
+
+
+
+
+
+
+ {{ t('views.courses.share.link') }}
+
+
+
{{ t('primevue.cancel') }}
- {{ t('views.courses.share') }}
+ {{ t('views.courses.share.title') }}
-
\ No newline at end of file
+
\ No newline at end of file
From 071aebe2091851666d337a2a3822941df4d9f70f Mon Sep 17 00:00:00 2001
From: Bram Meir
Date: Mon, 29 Apr 2024 22:55:47 +0200
Subject: [PATCH 07/17] chore: refactor link generation logic + save service
link
---
...> 0018_course_invitation_link_and_more.py} | 9 ++++-
backend/api/models/course.py | 4 +-
backend/api/serializers/course_serializer.py | 9 ++++-
backend/api/views/course_view.py | 39 +++++++------------
.../components/courses/ShareCourseButton.vue | 36 ++++++++++++++---
.../composables/services/course.service.ts | 14 +++++++
frontend/src/config/endpoints.ts | 1 +
frontend/src/types/Course.ts | 2 +
8 files changed, 78 insertions(+), 36 deletions(-)
rename backend/api/migrations/{0018_course_invite_link_course_private_course.py => 0018_course_invitation_link_and_more.py} (65%)
diff --git a/backend/api/migrations/0018_course_invite_link_course_private_course.py b/backend/api/migrations/0018_course_invitation_link_and_more.py
similarity index 65%
rename from backend/api/migrations/0018_course_invite_link_course_private_course.py
rename to backend/api/migrations/0018_course_invitation_link_and_more.py
index 3488e5b0..379dbbec 100644
--- a/backend/api/migrations/0018_course_invite_link_course_private_course.py
+++ b/backend/api/migrations/0018_course_invitation_link_and_more.py
@@ -1,4 +1,4 @@
-# Generated by Django 5.0.4 on 2024-04-27 17:30
+# Generated by Django 5.0.4 on 2024-04-29 20:35
from django.db import migrations, models
@@ -12,9 +12,14 @@ class Migration(migrations.Migration):
operations = [
migrations.AddField(
model_name='course',
- name='invite_link',
+ name='invitation_link',
field=models.CharField(blank=True, max_length=100, null=True),
),
+ migrations.AddField(
+ model_name='course',
+ name='invitation_link_expires',
+ field=models.DateField(blank=True, null=True),
+ ),
migrations.AddField(
model_name='course',
name='private_course',
diff --git a/backend/api/models/course.py b/backend/api/models/course.py
index 40e96dfa..190fd685 100644
--- a/backend/api/models/course.py
+++ b/backend/api/models/course.py
@@ -45,10 +45,10 @@ class Course(models.Model):
private_course = models.BooleanField(default=False)
# Field that contains the invite link for the course
- invite_link = models.CharField(max_length=100, blank=True, null=True)
+ invitation_link = models.CharField(max_length=100, blank=True, null=True)
# Date when the invite link expires
- invite_link_expires = models.DateField(blank=True, null=True)
+ invitation_link_expires = models.DateField(blank=True, null=True)
def __str__(self) -> str:
"""The string representation of the course."""
diff --git a/backend/api/serializers/course_serializer.py b/backend/api/serializers/course_serializer.py
index 667218f2..66de92ce 100644
--- a/backend/api/serializers/course_serializer.py
+++ b/backend/api/serializers/course_serializer.py
@@ -1,4 +1,6 @@
from nh3 import clean
+from datetime import timedelta
+from django.utils import timezone
from django.utils.translation import gettext
from rest_framework import serializers
from rest_framework.exceptions import ValidationError
@@ -108,7 +110,7 @@ class CourseCloneSerializer(serializers.Serializer):
class SaveInvitationLinkSerializer(serializers.Serializer):
invitation_link = serializers.CharField(required=True)
- invite_link_expires = serializers.DateField(required=True)
+ link_duration = serializers.IntegerField(required=True)
def validate(self, data):
# Check if there is no course with the same invitation link.
@@ -125,8 +127,11 @@ def create(self, validated_data):
course: Course = self.context["course"]
course.invitation_link = validated_data["invitation_link"]
- course.invite_link_expires = validated_data["invite_link_expires"]
+
+ # Save the expiration date as the current date + the invite link expires parameter in days.
+ course.invitation_link_expires = timezone.now() + timedelta(days=validated_data["link_duration"])
course.save()
+
return course
diff --git a/backend/api/views/course_view.py b/backend/api/views/course_view.py
index 257c4131..b8d225c5 100644
--- a/backend/api/views/course_view.py
+++ b/backend/api/views/course_view.py
@@ -1,4 +1,3 @@
-import string
from api.models.course import Course
from api.models.assistant import Assistant
from api.models.teacher import Teacher
@@ -25,7 +24,6 @@
from authentication.serializers import UserIDSerializer
from django.utils.translation import gettext
from django.utils import timezone
-from django.utils.crypto import get_random_string
from drf_yasg.utils import swagger_auto_schema
from rest_framework import status, viewsets
from rest_framework.decorators import action
@@ -380,30 +378,21 @@ def clone(self, request: Request, **__):
return Response(course_serializer.data)
- @action(detail=True, methods=["get"])
- def invitation_link(self, request, **__):
- """Return a unique link that can be used to join the course"""
- # Generate a unique link for the course
- unique_link = None
-
- while not unique_link:
- unique_link = get_random_string(20, allowed_chars=string.ascii_letters + string.digits)
-
- # Make sure there is no course with the same link
- if Course.objects.filter(invitation_link=unique_link).exists():
- unique_link = None
-
- return Response({"invitation_link": unique_link})
-
- @invitation_link.mapping.post
- @invitation_link.mapping.put
+ @action(detail=True, methods=["post"])
@swagger_auto_schema(request_body=SaveInvitationLinkSerializer)
- def _save_invitation_link(self, request, **_):
+ def invitation_link(self, request, **_):
"""Save the invitation link to the course"""
course = self.get_object()
- course.invitation_link = request.data["invitation_link"]
- course.save()
- return Response({
- "message": gettext("courses.success.invitation_link.save")
- })
+ serializer = SaveInvitationLinkSerializer(
+ data=request.data,
+ context={"course": course}
+ )
+
+ if serializer.is_valid(raise_exception=True):
+ serializer.save()
+
+ # Return serialized cloned course
+ course_serializer = CourseSerializer(course, context={"request": request})
+
+ return Response(course_serializer.data)
diff --git a/frontend/src/components/courses/ShareCourseButton.vue b/frontend/src/components/courses/ShareCourseButton.vue
index 55e897f3..f1129c42 100644
--- a/frontend/src/components/courses/ShareCourseButton.vue
+++ b/frontend/src/components/courses/ShareCourseButton.vue
@@ -6,10 +6,12 @@ import InputText from 'primevue/inputtext';
import { useI18n } from 'vue-i18n';
import { type Course } from '@/types/Course.ts';
import { PrimeIcons } from 'primevue/api';
-import { ref } from 'vue';
+import { onMounted, ref } from 'vue';
+import { useCourses } from '@/composables/services/course.service';
/* Composable injections */
const { t } = useI18n();
+const { saveInvitationLink } = useCourses();
/* Props */
const props = defineProps<{ course: Course }>();
@@ -23,12 +25,22 @@ const link = ref('KVC Westerlo');
/* Number of days the invitation link is valid */
const linkDuration = ref(7);
+/**
+ * Opens the dialog to share the course, and generates a random invitation link.
+ */
+function openShareCourseDialog(): void {
+ link.value = generateRandomInvitationLink();
+ displayShareCourse.value = true;
+}
+
/**
* Creates an invitation link for the course.
*/
async function handleShare(): Promise {
- // Show a confirmation dialog
- console.log('Share course');
+ // Save the invitation link for the course
+ await saveInvitationLink(props.course.id, link.value, linkDuration.value);
+
+ // Close the dialog
displayShareCourse.value = false;
}
@@ -43,6 +55,19 @@ function copyToClipboard(): void {
});
}
+/**
+ * Generates a random invitation link for the course.
+ */
+function generateRandomInvitationLink() {
+ let result = '';
+ const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
+ const charactersLength = characters.length;
+ for (let i = 0; i < 20; i++) {
+ result += characters.charAt(Math.floor(Math.random() * charactersLength));
+ }
+ return result;
+}
+
@@ -53,7 +78,7 @@ function copyToClipboard(): void {
icon-pos="right"
class="custom-button"
style="height: 51px; width: 51px"
- @click="displayShareCourse = true"
+ @click="openShareCourseDialog"
v-if="props.course.private_course"
/>
{{ t('views.courses.share.link') }}
-
+
+
diff --git a/frontend/src/composables/services/course.service.ts b/frontend/src/composables/services/course.service.ts
index a93a05d7..aff17373 100644
--- a/frontend/src/composables/services/course.service.ts
+++ b/frontend/src/composables/services/course.service.ts
@@ -19,6 +19,7 @@ interface CoursesState {
createCourse: (courseData: Course) => Promise
;
updateCourse: (courseData: Course) => Promise;
cloneCourse: (courseId: string, cloneAssistants: boolean, cloneTeachers: boolean) => Promise;
+ saveInvitationLink: (courseId: string, invitationLink: string, linkDuration: number) => Promise;
deleteCourse: (id: string) => Promise;
}
@@ -111,6 +112,18 @@ export function useCourses(): CoursesState {
await deleteId(endpoint, course, Course.fromJSON);
}
+ async function saveInvitationLink(courseId: string, invitationLink: string, linkDuration: number): Promise {
+ const endpoint = endpoints.courses.invitationLink.replace('{courseId}', courseId);
+ await create(endpoint,
+ {
+ invitation_link: invitationLink,
+ link_duration: linkDuration,
+ },
+ course,
+ Course.fromJSON
+ );
+ }
+
return {
pagination,
courses,
@@ -127,5 +140,6 @@ export function useCourses(): CoursesState {
updateCourse,
cloneCourse,
deleteCourse,
+ saveInvitationLink,
};
}
diff --git a/frontend/src/config/endpoints.ts b/frontend/src/config/endpoints.ts
index ad8e8435..410b2081 100644
--- a/frontend/src/config/endpoints.ts
+++ b/frontend/src/config/endpoints.ts
@@ -14,6 +14,7 @@ export const endpoints = {
search: '/api/courses/search/',
retrieve: '/api/courses/{id}/',
clone: '/api/courses/{courseId}/clone/',
+ invitationLink: '/api/courses/{courseId}/invitation_link/',
byStudent: '/api/students/{studentId}/courses/',
byTeacher: '/api/teachers/{teacherId}/courses/',
byAssistant: '/api/assistants/{assistantId}/courses/',
diff --git a/frontend/src/types/Course.ts b/frontend/src/types/Course.ts
index d0bcbea4..cbf4c4f4 100644
--- a/frontend/src/types/Course.ts
+++ b/frontend/src/types/Course.ts
@@ -12,6 +12,7 @@ export class Course {
public description: string | null,
public academic_startyear: number,
public private_course: boolean = false,
+ public invitation_link: string | null = null,
public parent_course: Course | null = null,
public faculty: Faculty | null = null,
public teachers: Teacher[] | null = null,
@@ -53,6 +54,7 @@ export class Course {
course.description,
course.academic_startyear,
course.private_course,
+ course.invitation_link,
course.parent_course,
faculty,
);
From 703fa4868c687bf2352d7365631455fe735775c0 Mon Sep 17 00:00:00 2001
From: Bram Meir
Date: Tue, 30 Apr 2024 10:19:10 +0200
Subject: [PATCH 08/17] chore: search based on invitation link
---
backend/api/views/course_view.py | 16 +++++++-
frontend/src/types/filter/Filter.ts | 19 +++++++++
.../search/ProtectedSearchCourseView.vue | 39 ++++++++++++++++---
3 files changed, 68 insertions(+), 6 deletions(-)
diff --git a/backend/api/views/course_view.py b/backend/api/views/course_view.py
index b8d225c5..9ca0424c 100644
--- a/backend/api/views/course_view.py
+++ b/backend/api/views/course_view.py
@@ -68,9 +68,23 @@ def update(self, request: Request, *_, **__):
def search(self, request: Request) -> Response:
# Extract filter params
search = request.query_params.get("search", "")
+ invitation_link = request.query_params.get("invitationLink", "")
years = request.query_params.getlist("years[]")
faculties = request.query_params.getlist("faculties[]")
+ # If the invitation link was passed, then only the private course with the invitation link should be returned
+ if invitation_link != "none":
+ queryset = self.get_queryset().filter(
+ invitation_link=invitation_link
+ )
+
+ # Serialize the resulting queryset
+ serializer = self.serializer_class(self.paginate_queryset(queryset), many=True, context={
+ "request": request
+ })
+
+ return self.get_paginated_response(serializer.data)
+
# Filter the queryset based on the search term
queryset = self.get_queryset().filter(
name__icontains=search
@@ -84,7 +98,7 @@ def search(self, request: Request) -> Response:
if faculties:
queryset = queryset.filter(faculty__in=faculties)
- # Filter the queryset so that only public courses are shown
+ # No invitation link was passed, so filter out private courses
queryset = queryset.filter(private_course=False)
# Serialize the resulting queryset
diff --git a/frontend/src/types/filter/Filter.ts b/frontend/src/types/filter/Filter.ts
index 10358711..9a39bfd5 100644
--- a/frontend/src/types/filter/Filter.ts
+++ b/frontend/src/types/filter/Filter.ts
@@ -14,6 +14,10 @@ export type CourseFilter = {
years: string[];
} & Filter;
+export type PrivateCourseFilter = {
+ invitationLink: string;
+} & Filter;
+
export interface Filter {
search: string;
[key: string]: string | string[];
@@ -54,6 +58,7 @@ export function getUserFilters(query: LocationQuery): UserFilter {
export function getCourseFilters(query: LocationQuery): CourseFilter {
const filters: CourseFilter = {
search: query.search?.toString() ?? '',
+ invitationLink: 'none',
faculties: [],
years: [],
};
@@ -69,6 +74,20 @@ export function getCourseFilters(query: LocationQuery): CourseFilter {
return filters;
}
+/**
+ * Get the private course filters from the query.
+ *
+ * @param query
+ */
+export function getPrivateCourseFilters(query: LocationQuery): PrivateCourseFilter {
+ const filters: PrivateCourseFilter = {
+ search: query.search?.toString() ?? '',
+ invitationLink: query.invitationLink?.toString() ?? '',
+ };
+
+ return filters;
+}
+
/**
* Get the query list.
*
diff --git a/frontend/src/views/courses/search/ProtectedSearchCourseView.vue b/frontend/src/views/courses/search/ProtectedSearchCourseView.vue
index 9568b027..5ee3a82e 100644
--- a/frontend/src/views/courses/search/ProtectedSearchCourseView.vue
+++ b/frontend/src/views/courses/search/ProtectedSearchCourseView.vue
@@ -5,22 +5,49 @@ import ProtectedSearchStepper from '@/components/courses/search/ProtectedSearchS
import Title from '@/components/layout/Title.vue';
import BaseLayout from '@/components/layout/base/BaseLayout.vue';
import { useI18n } from 'vue-i18n';
-import { ref } from 'vue';
+import { ref, onMounted } from 'vue';
+import CourseGeneralList from '@/components/courses/CourseGeneralList.vue';
+import { useCourses } from '@/composables/services/course.service.ts';
+import { usePaginator } from '@/composables/filters/paginator.ts';
+import { useFilter } from '@/composables/filters/filter.ts';
+import { getPrivateCourseFilters } from '@/types/filter/Filter.ts';
+import { useRoute } from 'vue-router';
+
/* Composable injections */
const { t } = useI18n();
+const { query } = useRoute();
/* State */
const searchQuery = ref('');
+const { pagination, searchCourses } = useCourses();
+const { onPaginate, resetPagination, page, pageSize } = usePaginator();
+const { filter, onFilter } = useFilter(getPrivateCourseFilters(query));
/**
* Fetch the courses based on the filter.
*/
-async function searchCourses(): Promise {
- console.log('Searching courses...');
+async function fetchCourses(): Promise {
+ filter.value['invitationLink'] = searchQuery.value;
+ await searchCourses(filter.value, page.value, pageSize.value);
}
+onMounted(async () => {
+ /* Search courses on page change */
+ onPaginate(fetchCourses);
+
+ /* Search courses on filter change */
+ onFilter(fetchCourses);
+ /* Reset pagination on filter change */
+ onFilter(
+ async () => {
+ await resetPagination([pagination]);
+ },
+ 0,
+ false,
+ );
+});
@@ -39,9 +66,11 @@ async function searchCourses(): Promise {
-
+
-
+
+
+
From a288eb4569e37a7b7450b226ad866f7d33db8f82 Mon Sep 17 00:00:00 2001
From: Bram Meir
Date: Tue, 30 Apr 2024 10:34:30 +0200
Subject: [PATCH 09/17] fix: link expire date
---
backend/api/views/course_view.py | 5 ++++-
1 file changed, 4 insertions(+), 1 deletion(-)
diff --git a/backend/api/views/course_view.py b/backend/api/views/course_view.py
index 9ca0424c..d3f0c888 100644
--- a/backend/api/views/course_view.py
+++ b/backend/api/views/course_view.py
@@ -74,8 +74,11 @@ def search(self, request: Request) -> Response:
# If the invitation link was passed, then only the private course with the invitation link should be returned
if invitation_link != "none":
+
+ # Filter based on invitation link, and that the invitation link is not expired
queryset = self.get_queryset().filter(
- invitation_link=invitation_link
+ invitation_link=invitation_link,
+ invitation_link_expires__gte=timezone.now()
)
# Serialize the resulting queryset
From 22a0dc0a67862f83f38fa9e97996e9e6ef2dbefe Mon Sep 17 00:00:00 2001
From: Bram Meir
Date: Tue, 30 Apr 2024 10:47:05 +0200
Subject: [PATCH 10/17] chore: start translations
---
frontend/src/assets/lang/app/en.json | 15 ++++++++++++++-
frontend/src/assets/lang/app/nl.json | 15 ++++++++++++++-
.../courses/search/ProtectedSearchStepper.vue | 6 +++++-
frontend/src/views/courses/SearchCourseView.vue | 14 +++++++++-----
4 files changed, 42 insertions(+), 8 deletions(-)
diff --git a/frontend/src/assets/lang/app/en.json b/frontend/src/assets/lang/app/en.json
index 01bfd509..dda520c4 100644
--- a/frontend/src/assets/lang/app/en.json
+++ b/frontend/src/assets/lang/app/en.json
@@ -161,7 +161,9 @@
"academicYear": "Academic year {0}",
"createProject": "Create a new project",
"searchCourse": "Search a course",
- "createCourse": "Create a new course"
+ "createCourse": "Create a new course",
+ "public": "Public",
+ "protected": "Protected"
},
"card": {
"open": "Details",
@@ -239,6 +241,17 @@
"leaveCourse": "Are you sure you want to leave this course? You will no longer have access to this course.",
"shareCourse": "By creating an invitation link, you allow students in possession of this link to enroll in this course. Please copy the generated link, only when you click on \"Create invitation link\" will this link become valid."
},
+ "protectedCourses": {
+ "screen1": {
+ "title": "Obtain invitation link"
+ },
+ "screen2": {
+
+ },
+ "screen3": {
+
+ }
+ },
"admin": {
"title": "Admin",
"keyword": "Keyword",
diff --git a/frontend/src/assets/lang/app/nl.json b/frontend/src/assets/lang/app/nl.json
index 7bcb6ab7..79be880c 100644
--- a/frontend/src/assets/lang/app/nl.json
+++ b/frontend/src/assets/lang/app/nl.json
@@ -161,7 +161,9 @@
"academicYear": "Academiejaar {0}",
"createProject": "Creëer nieuw project",
"searchCourse": "Zoek een vak",
- "createCourse": "Maak een vak"
+ "createCourse": "Maak een vak",
+ "public": "Publiek",
+ "protected": "Besloten"
},
"card": {
"open": "Details",
@@ -238,6 +240,17 @@
"leaveCourse": "Ben je zeker dat je dit vak wil verlaten? Je zal geen toegang meer hebben tot dit vak.",
"shareCourse": "Door het creëren van een invitatielink staat u studenten in bezit van deze link toe zich in te schrijven voor dit vak. Gelieve de gegenereerde link te kopiëren, pas wanneer u op \"Creëer invitatielink\" klikt zal deze link geldig worden."
},
+ "protectedCourses": {
+ "screen1": {
+ "title": "Bemachtigen link"
+ },
+ "screen2": {
+
+ },
+ "screen3": {
+
+ }
+ },
"admin": {
"title": "Beheerder",
"keyword": "Trefwoord",
diff --git a/frontend/src/components/courses/search/ProtectedSearchStepper.vue b/frontend/src/components/courses/search/ProtectedSearchStepper.vue
index c8613b5c..3150f924 100644
--- a/frontend/src/components/courses/search/ProtectedSearchStepper.vue
+++ b/frontend/src/components/courses/search/ProtectedSearchStepper.vue
@@ -2,11 +2,15 @@
import Stepper from 'primevue/stepper';
import StepperPanel from 'primevue/stepperpanel';
import Button from 'primevue/button';
+import { useI18n } from 'vue-i18n';
+
+/* Composable injections */
+const { t } = useI18n();
-
+
diff --git a/frontend/src/views/courses/SearchCourseView.vue b/frontend/src/views/courses/SearchCourseView.vue
index fc6cf78d..9aeeabbd 100644
--- a/frontend/src/views/courses/SearchCourseView.vue
+++ b/frontend/src/views/courses/SearchCourseView.vue
@@ -1,18 +1,22 @@
From 376d37f118e3f4fb0d8cb2764d1cbb70b8abec83 Mon Sep 17 00:00:00 2001
From: Bram Meir
Date: Tue, 30 Apr 2024 11:22:14 +0200
Subject: [PATCH 11/17] fix: stepper content translated
---
frontend/src/assets/lang/app/en.json | 7 ++++++-
frontend/src/assets/lang/app/nl.json | 8 ++++++--
.../courses/search/ProtectedSearchStepper.vue | 10 +++++-----
3 files changed, 17 insertions(+), 8 deletions(-)
diff --git a/frontend/src/assets/lang/app/en.json b/frontend/src/assets/lang/app/en.json
index dda520c4..4759fd53 100644
--- a/frontend/src/assets/lang/app/en.json
+++ b/frontend/src/assets/lang/app/en.json
@@ -243,12 +243,17 @@
},
"protectedCourses": {
"screen1": {
- "title": "Obtain invitation link"
+ "title": "Obtain invitation link",
+ "content": "Teachers can choose to make their courses private. This means you have to ask the teacher for an invitation link, to be able to join the course."
},
"screen2": {
+ "title": "Search course",
+ "content": "Use the invitation link to search a course. If you can't find the course, ask the teacher to reshare the link."
},
"screen3": {
+ "title": "Enroll",
+ "content": "Enroll in the course. Now you can see all the current projects, deadlines, ..."
}
},
diff --git a/frontend/src/assets/lang/app/nl.json b/frontend/src/assets/lang/app/nl.json
index 79be880c..fb9a51c9 100644
--- a/frontend/src/assets/lang/app/nl.json
+++ b/frontend/src/assets/lang/app/nl.json
@@ -242,13 +242,17 @@
},
"protectedCourses": {
"screen1": {
- "title": "Bemachtigen link"
+ "title": "Bemachtigen link",
+ "content": "Professoren kunnen kiezen om hun vakken niet publiek te maken. Vraag de prof om de link van het vak te delen, om te kunnen toetreden tot het vak."
},
"screen2": {
+ "title": "Vak zoeken",
+ "content": "Gebruik de link om het vak te zoeken. Als je het vak niet kan vinden, kan je de prof vragen om de link opnieuw te delen."
},
"screen3": {
-
+ "title": "Inschrijven",
+ "content": "Schrijf je in voor het vak. Je kan nu alle lopende projecten raadplegen, deadlines, ..."
}
},
"admin": {
diff --git a/frontend/src/components/courses/search/ProtectedSearchStepper.vue b/frontend/src/components/courses/search/ProtectedSearchStepper.vue
index 3150f924..9f741ab1 100644
--- a/frontend/src/components/courses/search/ProtectedSearchStepper.vue
+++ b/frontend/src/components/courses/search/ProtectedSearchStepper.vue
@@ -14,7 +14,7 @@ const { t } = useI18n();
- Professoren kunnen kiezen om hun vakken niet publiek te maken. Vraag de prof om de link van het vak te delen, om te kunnen toetreden tot het vak.
+ {{ t('protectedCourses.screen1.content') }}
@@ -22,11 +22,11 @@ const { t } = useI18n();
-
+
- Gebruik de link om het vak te zoeken. Als je het vak niet kan vinden, kan je de prof vragen om de link opnieuw te delen.
+ {{ t('protectedCourses.screen2.content') }}
@@ -35,11 +35,11 @@ const { t } = useI18n();
-
+
- Schrijf je in voor het vak. Je kan nu de lopende projecten raadplegen.
+ {{ t('protectedCourses.screen3.content') }}
From bad509f49a3c729022f14fe3d7af8d782f469ede Mon Sep 17 00:00:00 2001
From: Bram Meir
Date: Tue, 30 Apr 2024 11:35:17 +0200
Subject: [PATCH 12/17] fix: cleanup + linting
---
frontend/src/assets/lang/app/en.json | 6 +--
frontend/src/assets/lang/app/nl.json | 4 +-
.../components/courses/ShareCourseButton.vue | 38 ++++++++++---------
.../courses/search/ProtectedSearchStepper.vue | 37 +++++++++++-------
.../composables/services/course.service.ts | 5 ++-
.../src/views/courses/SearchCourseView.vue | 10 ++---
.../views/courses/roles/TeacherCourseView.vue | 1 -
.../search/ProtectedSearchCourseView.vue | 15 +++++---
8 files changed, 65 insertions(+), 51 deletions(-)
diff --git a/frontend/src/assets/lang/app/en.json b/frontend/src/assets/lang/app/en.json
index 4759fd53..80dfb113 100644
--- a/frontend/src/assets/lang/app/en.json
+++ b/frontend/src/assets/lang/app/en.json
@@ -157,6 +157,7 @@
}
},
"components": {
+ "submission" : "Submit",
"button": {
"academicYear": "Academic year {0}",
"createProject": "Create a new project",
@@ -188,8 +189,7 @@
"noIncomingProjects": "No projects with a deadline within 7 days.",
"selectCourse": "Select the course for which you want to create a project:",
"showPastProjects": "Projects with passed deadline"
- },
- "submission": "Submit"
+ }
},
"types": {
"roles": {
@@ -248,7 +248,7 @@
},
"screen2": {
"title": "Search course",
- "content": "Use the invitation link to search a course. If you can't find the course, ask the teacher to reshare the link."
+ "content": "Use the invitation link to search a course. If you can't find the course, ask the teacher to share a new link."
},
"screen3": {
diff --git a/frontend/src/assets/lang/app/nl.json b/frontend/src/assets/lang/app/nl.json
index fb9a51c9..b0a5be51 100644
--- a/frontend/src/assets/lang/app/nl.json
+++ b/frontend/src/assets/lang/app/nl.json
@@ -243,11 +243,11 @@
"protectedCourses": {
"screen1": {
"title": "Bemachtigen link",
- "content": "Professoren kunnen kiezen om hun vakken niet publiek te maken. Vraag de prof om de link van het vak te delen, om te kunnen toetreden tot het vak."
+ "content": "Professoren kunnen kiezen om hun vakken niet publiek te maken. Vraag de prof om een invitatielink te delen om te kunnen toetreden tot het vak."
},
"screen2": {
"title": "Vak zoeken",
- "content": "Gebruik de link om het vak te zoeken. Als je het vak niet kan vinden, kan je de prof vragen om de link opnieuw te delen."
+ "content": "Gebruik de link om het vak te zoeken. Als je het vak niet kan vinden, kan je de prof vragen om een nieuwe link te delen."
},
"screen3": {
diff --git a/frontend/src/components/courses/ShareCourseButton.vue b/frontend/src/components/courses/ShareCourseButton.vue
index f1129c42..5396fc02 100644
--- a/frontend/src/components/courses/ShareCourseButton.vue
+++ b/frontend/src/components/courses/ShareCourseButton.vue
@@ -6,7 +6,7 @@ import InputText from 'primevue/inputtext';
import { useI18n } from 'vue-i18n';
import { type Course } from '@/types/Course.ts';
import { PrimeIcons } from 'primevue/api';
-import { onMounted, ref } from 'vue';
+import { ref } from 'vue';
import { useCourses } from '@/composables/services/course.service';
/* Composable injections */
@@ -36,7 +36,7 @@ function openShareCourseDialog(): void {
/**
* Creates an invitation link for the course.
*/
- async function handleShare(): Promise {
+async function handleShare(): Promise {
// Save the invitation link for the course
await saveInvitationLink(props.course.id, link.value, linkDuration.value);
@@ -48,17 +48,20 @@ function openShareCourseDialog(): void {
* Copies the invitation link to the clipboard.
*/
function copyToClipboard(): void {
- navigator.clipboard.writeText(link.value).then(() => {
- console.log('Link copied to clipboard');
- }).catch(err => {
- console.error('Failed to copy text: ', err);
- });
+ navigator.clipboard
+ .writeText(link.value)
+ .then(() => {
+ console.log('Link copied to clipboard');
+ })
+ .catch((err) => {
+ console.error('Failed to copy text: ', err);
+ });
}
/**
* Generates a random invitation link for the course.
*/
-function generateRandomInvitationLink() {
+function generateRandomInvitationLink(): string {
let result = '';
const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
const charactersLength = characters.length;
@@ -67,8 +70,6 @@ function generateRandomInvitationLink() {
}
return result;
}
-
-
@@ -80,14 +81,14 @@ function generateRandomInvitationLink() {
style="height: 51px; width: 51px"
@click="openShareCourseDialog"
v-if="props.course.private_course"
- />
-
+
+ >
{{ t('views.courses.share.title') }}
@@ -110,7 +111,11 @@ function generateRandomInvitationLink() {
{{ t('views.courses.share.link') }}
-
+
@@ -119,7 +124,7 @@ function generateRandomInvitationLink() {
{{ t('views.courses.share.title') }}
-
+
@@ -127,6 +132,5 @@ function generateRandomInvitationLink() {
.no-outline:focus,
.no-outline:active {
box-shadow: none !important;
-
}
-
\ No newline at end of file
+
diff --git a/frontend/src/components/courses/search/ProtectedSearchStepper.vue b/frontend/src/components/courses/search/ProtectedSearchStepper.vue
index 9f741ab1..c1ba3880 100644
--- a/frontend/src/components/courses/search/ProtectedSearchStepper.vue
+++ b/frontend/src/components/courses/search/ProtectedSearchStepper.vue
@@ -13,32 +13,50 @@ const { t } = useI18n();
-
+
{{ t('protectedCourses.screen1.content') }}
-
+
-
+
{{ t('protectedCourses.screen2.content') }}
-
+
-
+
{{ t('protectedCourses.screen3.content') }}
@@ -50,11 +68,4 @@ const { t } = useI18n();
-
\ No newline at end of file
+
diff --git a/frontend/src/composables/services/course.service.ts b/frontend/src/composables/services/course.service.ts
index aff17373..3b12c301 100644
--- a/frontend/src/composables/services/course.service.ts
+++ b/frontend/src/composables/services/course.service.ts
@@ -114,13 +114,14 @@ export function useCourses(): CoursesState {
async function saveInvitationLink(courseId: string, invitationLink: string, linkDuration: number): Promise
{
const endpoint = endpoints.courses.invitationLink.replace('{courseId}', courseId);
- await create(endpoint,
+ await create(
+ endpoint,
{
invitation_link: invitationLink,
link_duration: linkDuration,
},
course,
- Course.fromJSON
+ Course.fromJSON,
);
}
diff --git a/frontend/src/views/courses/SearchCourseView.vue b/frontend/src/views/courses/SearchCourseView.vue
index 9aeeabbd..3b229f8a 100644
--- a/frontend/src/views/courses/SearchCourseView.vue
+++ b/frontend/src/views/courses/SearchCourseView.vue
@@ -5,7 +5,6 @@ import { useI18n } from 'vue-i18n';
import PublicSearchCourseView from './search/PublicSearchCourseView.vue';
import ProtectedSearchCourseView from './search/ProtectedSearchCourseView.vue';
-
/* Composable injections */
const { t } = useI18n();
@@ -17,7 +16,6 @@ const options = computed(() => [
{ label: t('components.button.public'), value: true },
{ label: t('components.button.protected'), value: false },
]);
-
@@ -30,7 +28,7 @@ const options = computed(() => [
option-value="value"
option-label="label"
:allow-empty="false"
- />
+ />
@@ -43,11 +41,9 @@ const options = computed(() => [
option-value="value"
option-label="label"
:allow-empty="false"
- />
+ />
-
+
diff --git a/frontend/src/views/courses/roles/TeacherCourseView.vue b/frontend/src/views/courses/roles/TeacherCourseView.vue
index 8d1eae44..7c9abd41 100644
--- a/frontend/src/views/courses/roles/TeacherCourseView.vue
+++ b/frontend/src/views/courses/roles/TeacherCourseView.vue
@@ -8,7 +8,6 @@ import Button from 'primevue/button';
import ButtonGroup from 'primevue/buttongroup';
import InputSwitch from 'primevue/inputswitch';
import ConfirmDialog from 'primevue/confirmdialog';
-import Dialog from 'primevue/dialog';
import { useConfirm } from 'primevue/useconfirm';
import { type Course } from '@/types/Course.ts';
import { useI18n } from 'vue-i18n';
diff --git a/frontend/src/views/courses/search/ProtectedSearchCourseView.vue b/frontend/src/views/courses/search/ProtectedSearchCourseView.vue
index 5ee3a82e..e08778fe 100644
--- a/frontend/src/views/courses/search/ProtectedSearchCourseView.vue
+++ b/frontend/src/views/courses/search/ProtectedSearchCourseView.vue
@@ -13,7 +13,6 @@ import { useFilter } from '@/composables/filters/filter.ts';
import { getPrivateCourseFilters } from '@/types/filter/Filter.ts';
import { useRoute } from 'vue-router';
-
/* Composable injections */
const { t } = useI18n();
const { query } = useRoute();
@@ -28,7 +27,7 @@ const { filter, onFilter } = useFilter(getPrivateCourseFilters(query));
* Fetch the courses based on the filter.
*/
async function fetchCourses(): Promise {
- filter.value['invitationLink'] = searchQuery.value;
+ filter.value.invitationLink = searchQuery.value;
await searchCourses(filter.value, page.value, pageSize.value);
}
@@ -65,9 +64,14 @@ onMounted(async () => {
-
+
-
+
@@ -76,5 +80,4 @@ onMounted(async () => {
-
\ No newline at end of file
+
From eaeb6695f6675a48a15e1a53536dc2824707a8b2 Mon Sep 17 00:00:00 2001
From: Bram Meir
Date: Tue, 30 Apr 2024 14:52:20 +0200
Subject: [PATCH 13/17] fix: service tests
---
frontend/src/test/unit/types/course.test.ts | 2 ++
frontend/src/test/unit/types/data.ts | 2 ++
frontend/src/test/unit/types/helper.ts | 2 ++
3 files changed, 6 insertions(+)
diff --git a/frontend/src/test/unit/types/course.test.ts b/frontend/src/test/unit/types/course.test.ts
index baf5ff09..8ea426bd 100644
--- a/frontend/src/test/unit/types/course.test.ts
+++ b/frontend/src/test/unit/types/course.test.ts
@@ -14,6 +14,8 @@ describe('course type', () => {
expect(course.excerpt).toBe(courseData.excerpt);
expect(course.description).toBe(courseData.description);
expect(course.academic_startyear).toBe(courseData.academic_startyear);
+ expect(course.private_course).toBe(courseData.private_course);
+ expect(course.invitation_link).toBe(courseData.invitation_link);
expect(course.parent_course).toBe(courseData.parent_course);
expect(course.faculty).toBe(courseData.faculty);
expect(course.teachers).toStrictEqual(courseData.teachers);
diff --git a/frontend/src/test/unit/types/data.ts b/frontend/src/test/unit/types/data.ts
index e504545b..9dab032b 100644
--- a/frontend/src/test/unit/types/data.ts
+++ b/frontend/src/test/unit/types/data.ts
@@ -65,6 +65,8 @@ export const courseData = {
excerpt: 'course1_excerpt',
description: 'course1_description',
academic_startyear: 2024,
+ private_course: false,
+ invitation_link: null,
parent_course: null,
faculty: null,
teachers: [],
diff --git a/frontend/src/test/unit/types/helper.ts b/frontend/src/test/unit/types/helper.ts
index d6999ddf..450eb419 100644
--- a/frontend/src/test/unit/types/helper.ts
+++ b/frontend/src/test/unit/types/helper.ts
@@ -90,6 +90,8 @@ export function createCourse(courseData: any): Course {
courseData.excerpt,
courseData.description,
courseData.academic_startyear,
+ courseData.private_course,
+ courseData.invitation_link,
courseData.parent_course,
courseData.faculty,
courseData.teachers.slice(),
From 31e3ec044ce3f17fd8e8cd6eb15beb0e294272fb Mon Sep 17 00:00:00 2001
From: Bram Meir
Date: Wed, 1 May 2024 10:23:15 +0200
Subject: [PATCH 14/17] fix: translation
---
frontend/src/assets/lang/app/nl.json | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/frontend/src/assets/lang/app/nl.json b/frontend/src/assets/lang/app/nl.json
index b0a5be51..4038fd3f 100644
--- a/frontend/src/assets/lang/app/nl.json
+++ b/frontend/src/assets/lang/app/nl.json
@@ -252,7 +252,7 @@
},
"screen3": {
"title": "Inschrijven",
- "content": "Schrijf je in voor het vak. Je kan nu alle lopende projecten raadplegen, deadlines, ..."
+ "content": "Schrijf je in voor het vak. Je kan nu een overzicht raadplegen van alle lopende projecten, deadlines, ..."
}
},
"admin": {
From 4df45e02af3d7db03c6a2ac135ac53dcd13d585f Mon Sep 17 00:00:00 2001
From: Bram Meir
Date: Wed, 1 May 2024 20:30:37 +0200
Subject: [PATCH 15/17] fix: arrows
---
.../src/components/courses/search/ProtectedSearchStepper.vue | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/frontend/src/components/courses/search/ProtectedSearchStepper.vue b/frontend/src/components/courses/search/ProtectedSearchStepper.vue
index c1ba3880..688279db 100644
--- a/frontend/src/components/courses/search/ProtectedSearchStepper.vue
+++ b/frontend/src/components/courses/search/ProtectedSearchStepper.vue
@@ -39,7 +39,7 @@ const { t } = useI18n();
{{ t('protectedCourses.screen2.content') }}
-
+
Date: Wed, 1 May 2024 22:09:17 +0200
Subject: [PATCH 16/17] fix: empty slot value for search results
---
frontend/src/assets/lang/app/en.json | 3 ++-
frontend/src/assets/lang/app/nl.json | 3 ++-
.../src/views/courses/search/ProtectedSearchCourseView.vue | 6 +++++-
.../src/views/courses/search/PublicSearchCourseView.vue | 6 +++++-
4 files changed, 14 insertions(+), 4 deletions(-)
diff --git a/frontend/src/assets/lang/app/en.json b/frontend/src/assets/lang/app/en.json
index 4f0f7818..6f8a1ffc 100644
--- a/frontend/src/assets/lang/app/en.json
+++ b/frontend/src/assets/lang/app/en.json
@@ -186,7 +186,8 @@
},
"noCourses": {
"student": "No courses found. Register for a public course using the search function, or use an invitation link from a teacher.",
- "teacher": "No courses found. Create a new course with the button below."
+ "teacher": "No courses found. Create a new course with the button below.",
+ "search": "No courses found for the given search criteria."
},
"noResults": "No results.",
"noIncomingProjects": "No projects with a deadline within 7 days.",
diff --git a/frontend/src/assets/lang/app/nl.json b/frontend/src/assets/lang/app/nl.json
index 8e8338e3..eeddc296 100644
--- a/frontend/src/assets/lang/app/nl.json
+++ b/frontend/src/assets/lang/app/nl.json
@@ -182,7 +182,8 @@
},
"noCourses": {
"student": "Geen vakken gevonden. Schrijf in op een openbaar vak met de zoekfunctie, of gebruik een uitnodiginslink van een lesgever.",
- "teacher": "Geen vakken gevonden. Maak een vak aan met onderstaande knop."
+ "teacher": "Geen vakken gevonden. Maak een vak aan met onderstaande knop.",
+ "search": "Geen vakken gevonden voor de gegeven zoekcriteria."
},
"noResults": "Geen resultaten.",
"noIncomingProjects": "Geen projecten met een deadline binnen de 7 dagen.",
diff --git a/frontend/src/views/courses/search/ProtectedSearchCourseView.vue b/frontend/src/views/courses/search/ProtectedSearchCourseView.vue
index e08778fe..caac1fb9 100644
--- a/frontend/src/views/courses/search/ProtectedSearchCourseView.vue
+++ b/frontend/src/views/courses/search/ProtectedSearchCourseView.vue
@@ -74,7 +74,11 @@ onMounted(async () => {
-
+
+
+ {{ t('components.list.noCourses.search') }}
+
+
diff --git a/frontend/src/views/courses/search/PublicSearchCourseView.vue b/frontend/src/views/courses/search/PublicSearchCourseView.vue
index 14a09075..8fa70dc1 100644
--- a/frontend/src/views/courses/search/PublicSearchCourseView.vue
+++ b/frontend/src/views/courses/search/PublicSearchCourseView.vue
@@ -114,7 +114,11 @@ onMounted(async () => {
{{ t('views.courses.search.results', [pagination.count]) }}
-
+
+
+ {{ t('components.list.noCourses.search') }}
+
+
From 14f84a4550ad6146ace5799ad3d976a67421bbec Mon Sep 17 00:00:00 2001
From: Bram Meir
Date: Wed, 1 May 2024 22:13:41 +0200
Subject: [PATCH 17/17] fix: space
---
frontend/src/assets/lang/app/en.json | 1 -
1 file changed, 1 deletion(-)
diff --git a/frontend/src/assets/lang/app/en.json b/frontend/src/assets/lang/app/en.json
index 6f8a1ffc..e91c045e 100644
--- a/frontend/src/assets/lang/app/en.json
+++ b/frontend/src/assets/lang/app/en.json
@@ -140,7 +140,6 @@
"duration": "Validity period link (in days):",
"link": "Invitation link:"
}
-
}
},
"composables": {