Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Course link refactor #403

Merged
merged 12 commits into from
May 5, 2024
30 changes: 19 additions & 11 deletions backend/api/serializers/course_serializer.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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 = [
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -109,26 +127,16 @@ 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:
raise ValidationError(gettext("courses.error.context"))

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()

Expand Down
21 changes: 2 additions & 19 deletions backend/api/views/course_view.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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"""
Expand Down
7 changes: 4 additions & 3 deletions frontend/src/assets/lang/app/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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:"
}
}
Expand Down Expand Up @@ -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": {
Expand Down
7 changes: 4 additions & 3 deletions frontend/src/assets/lang/app/nl.json
Original file line number Diff line number Diff line change
Expand Up @@ -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:"
}
}
Expand Down Expand Up @@ -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": {
Expand Down
55 changes: 14 additions & 41 deletions frontend/src/components/courses/ShareCourseButton.vue
Original file line number Diff line number Diff line change
Expand Up @@ -6,39 +6,28 @@ 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 }>();

/* State for the dialog to share a course */
const displayShareCourse = ref(false);

/* Invitation link for the course */
const link = ref<string>('KVC Westerlo');

/* Number of days the invitation link is valid */
const linkDuration = ref<number>(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<void> {
// 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;
Expand All @@ -48,28 +37,17 @@ async function handleShare(): Promise<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);
});
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}`;
});
</script>

<template>
Expand All @@ -79,7 +57,7 @@ function generateRandomInvitationLink(): string {
icon-pos="right"
class="custom-button"
style="height: 51px; width: 51px"
@click="openShareCourseDialog"
@click="displayShareCourse = true"
v-if="props.course.private_course"
/>
<Dialog
Expand Down Expand Up @@ -109,13 +87,8 @@ function generateRandomInvitationLink(): string {
<div class="grid">
<div class="flex align-items-center col-12 gap-2">
<label for="link">{{ t('views.courses.share.link') }}</label>
<InputText v-model="link" disabled style="width: 25%" />
<InputText v-model="invitationLink" disabled style="width: 50%" />
<Button @click="copyToClipboard()" icon="pi pi-copy" class="p-button-text no-outline" />
<Button
@click="link = generateRandomInvitationLink()"
icon="pi pi-refresh"
class="p-button-text no-outline"
/>
</div>
</div>

Expand Down
71 changes: 0 additions & 71 deletions frontend/src/components/courses/search/ProtectedSearchStepper.vue

This file was deleted.

12 changes: 5 additions & 7 deletions frontend/src/composables/services/course.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ interface CoursesState {
createCourse: (courseData: Course) => Promise<void>;
updateCourse: (courseData: Course) => Promise<void>;
cloneCourse: (courseId: string, cloneAssistants: boolean, cloneTeachers: boolean) => Promise<void>;
saveInvitationLink: (courseId: string, invitationLink: string, linkDuration: number) => Promise<void>;
activateInvitationLink: (courseId: string, linkDuration: number) => Promise<void>;
deleteCourse: (id: string) => Promise<void>;
}

Expand Down Expand Up @@ -112,16 +112,14 @@ export function useCourses(): CoursesState {
await deleteId<Course>(endpoint, course, Course.fromJSON);
}

async function saveInvitationLink(courseId: string, invitationLink: string, linkDuration: number): Promise<void> {
async function activateInvitationLink(courseId: string, linkDuration: number): Promise<void> {
const endpoint = endpoints.courses.invitationLink.replace('{courseId}', courseId);
await create<Course>(
await patch(
endpoint,
{
invitation_link: invitationLink,
link_duration: linkDuration,
},
course,
Course.fromJSON,
response,
);
}

Expand All @@ -141,6 +139,6 @@ export function useCourses(): CoursesState {
updateCourse,
cloneCourse,
deleteCourse,
saveInvitationLink,
activateInvitationLink,
};
}
5 changes: 5 additions & 0 deletions frontend/src/router/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import AdminView from '@/views/admin/AdminView.vue';
import UsersView from '@/views/admin/UsersView.vue';
import ProjectsView from '@/views/projects/ProjectsView.vue';
import DockerImagesView from '@/views/admin/DockerImagesView.vue';
import JoinCourseView from '@/views/courses/JoinCourseView.vue';

const routes: RouteRecordRaw[] = [
// Authentication
Expand Down Expand Up @@ -53,6 +54,10 @@ const routes: RouteRecordRaw[] = [
children: [
{ path: '', component: CourseView, name: 'course' },
{ path: 'edit', component: UpdateCourseView, name: 'course-edit' },
{
path: 'join',
children: [{ path: ':invitationLink', component: JoinCourseView, name: 'course-join' }],
},
// Projects
{
path: 'projects',
Expand Down
1 change: 1 addition & 0 deletions frontend/src/test/unit/types/course.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ describe('course type', () => {
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.invitation_link_expires).toBe(courseData.invitation_link_expires);
expect(course.parent_course).toBe(courseData.parent_course);
expect(course.faculty).toBe(courseData.faculty);
expect(course.teachers).toStrictEqual(courseData.teachers);
Expand Down
Loading