From e8ffd926cc78d58d003de1cc98589ef4945a52ca Mon Sep 17 00:00:00 2001 From: Bram Meir Date: Thu, 2 May 2024 14:57:43 +0200 Subject: [PATCH 01/11] chore: start hash --- backend/api/serializers/course_serializer.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/backend/api/serializers/course_serializer.py b/backend/api/serializers/course_serializer.py index 66de92ce..f7746aaa 100644 --- a/backend/api/serializers/course_serializer.py +++ b/backend/api/serializers/course_serializer.py @@ -1,3 +1,4 @@ +from passlib.hash import pbkdf2_sha256 from nh3 import clean from datetime import timedelta from django.utils import timezone @@ -81,6 +82,9 @@ def create(self, validated_data): course.faculty = faculty course.save() + # Compute the invitation link hash, with a size + course.invitation_link = pbkdf2_sha256.hash(f'{course.id}{course.academic_startyear}', salt_size=16) + return course def update(self, instance, validated_data): From df043c93e4eef53d176c0ba1613626e2001d5c82 Mon Sep 17 00:00:00 2001 From: Bram Meir Date: Sat, 4 May 2024 11:21:51 +0200 Subject: [PATCH 02/11] chore: init refactor --- backend/api/serializers/course_serializer.py | 12 +- backend/api/views/course_view.py | 19 +-- frontend/src/types/filter/Filter.ts | 19 --- .../src/views/courses/CreateCourseView.vue | 1 + .../src/views/courses/SearchCourseView.vue | 151 +++++++++++++----- .../search/ProtectedSearchCourseView.vue | 87 ---------- .../courses/search/PublicSearchCourseView.vue | 129 --------------- 7 files changed, 125 insertions(+), 293 deletions(-) delete mode 100644 frontend/src/views/courses/search/ProtectedSearchCourseView.vue delete mode 100644 frontend/src/views/courses/search/PublicSearchCourseView.vue diff --git a/backend/api/serializers/course_serializer.py b/backend/api/serializers/course_serializer.py index f7746aaa..b1c4c347 100644 --- a/backend/api/serializers/course_serializer.py +++ b/backend/api/serializers/course_serializer.py @@ -1,4 +1,4 @@ -from passlib.hash import pbkdf2_sha256 +import hashlib from nh3 import clean from datetime import timedelta from django.utils import timezone @@ -77,13 +77,17 @@ 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 course.save() - - # Compute the invitation link hash, with a size - course.invitation_link = pbkdf2_sha256.hash(f'{course.id}{course.academic_startyear}', salt_size=16) + else: + course.faculty = "noo" + course.save() return course diff --git a/backend/api/views/course_view.py b/backend/api/views/course_view.py index d3f0c888..c33dccd2 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 diff --git a/frontend/src/types/filter/Filter.ts b/frontend/src/types/filter/Filter.ts index 53282372..59236fd3 100644 --- a/frontend/src/types/filter/Filter.ts +++ b/frontend/src/types/filter/Filter.ts @@ -14,10 +14,6 @@ export type CourseFilter = { years: string[]; } & Filter; -export type PrivateCourseFilter = { - invitationLink: string; -} & Filter; - export type DockerImageFilter = { id: string; name: string; @@ -64,7 +60,6 @@ export function getUserFilters(query: LocationQuery): UserFilter { export function getCourseFilters(query: LocationQuery): CourseFilter { const filters: CourseFilter = { search: query.search?.toString() ?? '', - invitationLink: 'none', faculties: [], years: [], }; @@ -80,20 +75,6 @@ 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 docker image filters from the query. * diff --git a/frontend/src/views/courses/CreateCourseView.vue b/frontend/src/views/courses/CreateCourseView.vue index 5b91b764..eeafbc05 100644 --- a/frontend/src/views/courses/CreateCourseView.vue +++ b/frontend/src/views/courses/CreateCourseView.vue @@ -66,6 +66,7 @@ async function submitCourse(): Promise { form.description, getAcademicYear(), form.private, + null, // No invitation link null, // No parent course form.faculty, ), diff --git a/frontend/src/views/courses/SearchCourseView.vue b/frontend/src/views/courses/SearchCourseView.vue index 31d5a4f3..8692b3ca 100644 --- a/frontend/src/views/courses/SearchCourseView.vue +++ b/frontend/src/views/courses/SearchCourseView.vue @@ -1,49 +1,128 @@ diff --git a/frontend/src/views/courses/search/ProtectedSearchCourseView.vue b/frontend/src/views/courses/search/ProtectedSearchCourseView.vue deleted file mode 100644 index caac1fb9..00000000 --- a/frontend/src/views/courses/search/ProtectedSearchCourseView.vue +++ /dev/null @@ -1,87 +0,0 @@ - - - - - diff --git a/frontend/src/views/courses/search/PublicSearchCourseView.vue b/frontend/src/views/courses/search/PublicSearchCourseView.vue deleted file mode 100644 index 8fa70dc1..00000000 --- a/frontend/src/views/courses/search/PublicSearchCourseView.vue +++ /dev/null @@ -1,129 +0,0 @@ - - - - - From b71b4792bd220a0af7b77675d7916157f25007c1 Mon Sep 17 00:00:00 2001 From: Bram Meir Date: Sat, 4 May 2024 11:42:17 +0200 Subject: [PATCH 03/11] chore: invitation link displayed from backend --- backend/api/serializers/course_serializer.py | 12 ++++ .../components/courses/ShareCourseButton.vue | 52 ++++++-------- .../courses/search/ProtectedSearchStepper.vue | 71 ------------------- .../composables/services/course.service.ts | 7 +- 4 files changed, 35 insertions(+), 107 deletions(-) delete mode 100644 frontend/src/components/courses/search/ProtectedSearchStepper.vue diff --git a/backend/api/serializers/course_serializer.py b/backend/api/serializers/course_serializer.py index b1c4c347..c770fbb7 100644 --- a/backend/api/serializers/course_serializer.py +++ b/backend/api/serializers/course_serializer.py @@ -5,6 +5,7 @@ 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 @@ -46,6 +47,17 @@ 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 + + return data + class Meta: model = Course fields = [ diff --git a/frontend/src/components/courses/ShareCourseButton.vue b/frontend/src/components/courses/ShareCourseButton.vue index 5396fc02..0dc3bda9 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,9 +19,6 @@ 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); @@ -29,16 +26,15 @@ 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); + await activateInvitationLink(props.course.id, linkDuration.value); // Close the dialog displayShareCourse.value = false; @@ -48,28 +44,25 @@ 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){ + navigator.clipboard + .writeText(invitationLink.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. + * 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}/join/${props.course.invitation_link}`; +}); +