diff --git a/backend/api/serializers/course_serializer.py b/backend/api/serializers/course_serializer.py index 66de92ce..ae3b91df 100644 --- a/backend/api/serializers/course_serializer.py +++ b/backend/api/serializers/course_serializer.py @@ -1,9 +1,11 @@ +import hashlib 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 +from api.permissions.role_permissions import is_teacher from api.serializers.student_serializer import StudentIDSerializer from api.serializers.teacher_serializer import TeacherIDSerializer from api.serializers.faculty_serializer import FacultySerializer @@ -45,6 +47,18 @@ def validate(self, attrs: dict) -> dict: attrs['description'] = clean(attrs['description']) return attrs + def to_representation(self, instance): + data = super().to_representation(instance) + + # If you are a teacher, you can see the invitation link of the course + if is_teacher(self.context["request"].user): + # Teacher can only see the invitation link if they are part of the course + if instance.teachers.filter(id=self.context["request"].user.id).exists(): + data["invitation_link"] = instance.invitation_link + data["invitation_link_expires"] = instance.invitation_link_expires + + return data + class Meta: model = Course fields = [ @@ -76,6 +90,10 @@ def create(self, validated_data): # Create the course course = super().create(validated_data) + # Compute the invitation link hash + course.invitation_link = hashlib.sha256(f'{course.id}{course.academic_startyear}'.encode()).hexdigest() + course.invitation_link_expires = timezone.now() + # Link the faculty, if specified if faculty is not None: course.faculty = faculty @@ -109,16 +127,8 @@ class CourseCloneSerializer(serializers.Serializer): class SaveInvitationLinkSerializer(serializers.Serializer): - invitation_link = serializers.CharField(required=True) link_duration = serializers.IntegerField(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: @@ -126,9 +136,7 @@ def create(self, validated_data): course: Course = self.context["course"] - course.invitation_link = validated_data["invitation_link"] - - # Save the expiration date as the current date + the invite link expires parameter in days. + # Save the expiration date as the current date + the invite link duration parameter in days. course.invitation_link_expires = timezone.now() + timedelta(days=validated_data["link_duration"]) course.save() diff --git a/backend/api/views/course_view.py b/backend/api/views/course_view.py index d3f0c888..3f94723b 100644 --- a/backend/api/views/course_view.py +++ b/backend/api/views/course_view.py @@ -68,26 +68,9 @@ 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": - - # Filter based on invitation link, and that the invitation link is not expired - queryset = self.get_queryset().filter( - invitation_link=invitation_link, - invitation_link_expires__gte=timezone.now() - ) - - # 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 @@ -101,7 +84,7 @@ def search(self, request: Request) -> Response: if faculties: queryset = queryset.filter(faculty__in=faculties) - # No invitation link was passed, so filter out private courses + # Public courses search so filter out private courses queryset = queryset.filter(private_course=False) # Serialize the resulting queryset @@ -395,7 +378,7 @@ def clone(self, request: Request, **__): return Response(course_serializer.data) - @action(detail=True, methods=["post"]) + @action(detail=True, methods=["patch"]) @swagger_auto_schema(request_body=SaveInvitationLinkSerializer) def invitation_link(self, request, **_): """Save the invitation link to the course""" diff --git a/frontend/src/assets/lang/app/en.json b/frontend/src/assets/lang/app/en.json index 11636b0f..d2f951a7 100644 --- a/frontend/src/assets/lang/app/en.json +++ b/frontend/src/assets/lang/app/en.json @@ -137,8 +137,8 @@ "placeholder": "Find a course using the registration link" }, "share": { - "title": "Create invitation link", - "duration": "Validity period link (in days):", + "title": "Activate invitation link", + "duration": "Validity period of link (in days):", "link": "Invitation link:" } } @@ -242,8 +242,9 @@ }, "confirmations": { "cloneCourse": "Are you sure you want to clone this coure? This will create the same course for the next academic year.", + "joinCourse": "Are you sure you want to enroll in this course? This will give you access to all projects, assignments, ...", "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." + "shareCourse": "By activating the 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 \"Activate invitation link\" will this link become active." }, "protectedCourses": { "screen1": { diff --git a/frontend/src/assets/lang/app/nl.json b/frontend/src/assets/lang/app/nl.json index 9f11273c..92456aa0 100644 --- a/frontend/src/assets/lang/app/nl.json +++ b/frontend/src/assets/lang/app/nl.json @@ -134,8 +134,8 @@ "placeholder": "Zoek een vak gebruik makende van een uitnodigingslink" }, "share": { - "title": "Creëer invitatielink", - "duration": "Geldigheidsduur link (in dagen):", + "title": "Activeer invitatielink", + "duration": "Geldigheidsduur van link (in dagen):", "link": "Invitatielink:" } } @@ -239,8 +239,9 @@ }, "confirmations": { "cloneCourse": "Ben je zeker dat je dit vak wil klonen? Dit zal hetzelfde vak aanmaken voor het volgende academiejaar.", + "joinCourse": "Ben je zeker dat je jezelf wil inschrijven voor dit vak? Dit zal je toegang geven tot alle projecten, opdrachten, ...", "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." + "shareCourse": "Door het activeren van de 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 \"Activeer invitatielink\" klikt zal deze link actief worden." }, "protectedCourses": { "screen1": { diff --git a/frontend/src/components/courses/ShareCourseButton.vue b/frontend/src/components/courses/ShareCourseButton.vue index 5396fc02..288ba277 100644 --- a/frontend/src/components/courses/ShareCourseButton.vue +++ b/frontend/src/components/courses/ShareCourseButton.vue @@ -6,12 +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 { ref, computed } from 'vue'; import { useCourses } from '@/composables/services/course.service'; /* Composable injections */ const { t } = useI18n(); -const { saveInvitationLink } = useCourses(); +const { activateInvitationLink } = useCourses(); /* Props */ const props = defineProps<{ course: Course }>(); @@ -19,26 +19,15 @@ 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); /** - * 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. + * Activates the invitation link for the course, with the specified duration. */ async function handleShare(): Promise { - // Save the invitation link for the course - await saveInvitationLink(props.course.id, link.value, linkDuration.value); + // Activates the invitation link by setting an expiration date + await activateInvitationLink(props.course.id, linkDuration.value); // Close the dialog displayShareCourse.value = false; @@ -48,28 +37,17 @@ async function handleShare(): Promise { * 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); - }); + if (props.course.invitation_link !== null) { + navigator.clipboard.writeText(invitationLink.value); + } } /** - * Generates a random invitation link for the course. + * Returns the course's invitation link, formatted as the full URL. */ -function generateRandomInvitationLink(): string { - 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; -} +const invitationLink = computed(() => { + return `${window.location.toString()}/join/${props.course.invitation_link}`; +});