Skip to content

Commit

Permalink
Roles course (#365)
Browse files Browse the repository at this point in the history
* chore: layout for selecting role

* chore: join logic

* chore: fix filter users

* chore: assistant/teacher role switch

* chore: leave course + fix not last teacher

* fix: backend check on last teacher

* fix: not possible to remove last teacher in frontend

* chore: linting

* fix: comments

* fix: backend test
  • Loading branch information
BramMeir authored Apr 25, 2024
1 parent be953ba commit 7e57b82
Show file tree
Hide file tree
Showing 17 changed files with 363 additions and 276 deletions.
4 changes: 4 additions & 0 deletions backend/api/locale/en/LC_MESSAGES/django.po
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,10 @@ msgstr "The teacher is already present in the course."
msgid "courses.error.teachers.not_present"
msgstr "The teacher is not present in the course."

#: serializers/course_serializer.py:171
msgid "courses.error.teachers.last_teacher"
msgstr "The course must have at least one teacher."

#: serializers/docker_serializer.py:19
msgid "docker.errors.custom"
msgstr "User is not allowed to create public images"
Expand Down
4 changes: 4 additions & 0 deletions backend/api/locale/nl/LC_MESSAGES/django.po
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,10 @@ msgstr "De lesgever bevindt zich al in de opleiding."
msgid "courses.error.teachers.not_present"
msgstr "De lesgever bevindt zich niet in de opleiding."

#: serializers/course_serializer.py:171
msgid "courses.error.teachers.last_teacher"
msgstr "De opleiding moet minstens één lesgever hebben."

#: serializers/docker_serializer.py:19
msgid "docker.errors.custom"
msgstr "Gebruiker is niet toegelaten om publieke afbeeldingen te maken"
Expand Down
4 changes: 4 additions & 0 deletions backend/api/serializers/course_serializer.py
Original file line number Diff line number Diff line change
Expand Up @@ -166,4 +166,8 @@ def validate(self, data):
if course.is_past():
raise ValidationError(gettext("courses.error.past_course"))

# Make sure it is not the last teacher
if course.teachers.count() == 1:
raise ValidationError(gettext("courses.error.teachers.last_teacher"))

return data
1 change: 1 addition & 0 deletions backend/api/tests/test_course.py
Original file line number Diff line number Diff line change
Expand Up @@ -668,6 +668,7 @@ def test_remove_self(self):
Teacher should be able to remove him/herself from a course.
"""
course = get_course()
course.teachers.add(get_teacher())
course.teachers.add(self.user)

response = self.client.delete(
Expand Down
2 changes: 1 addition & 1 deletion backend/api/views/assistant_view.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ def search(self, request: Request) -> Response:

@swagger_auto_schema(request_body=AssistantIDSerializer)
def destroy(self, request: Request, *args, **kwargs) -> Response:
"""Delete the student role from the user"""
"""Delete the assistant role from the user"""
self.get_object().deactivate()

return Response({
Expand Down
41 changes: 39 additions & 2 deletions backend/api/views/course_view.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
from api.models.course import Course
from api.models.assistant import Assistant
from api.models.teacher import Teacher
from api.permissions.course_permissions import (CourseAssistantPermission,
CoursePermission,
CourseStudentPermission,
Expand All @@ -17,6 +19,7 @@
ProjectSerializer)
from api.serializers.student_serializer import StudentSerializer
from api.serializers.teacher_serializer import TeacherSerializer
from authentication.serializers import UserIDSerializer
from django.utils.translation import gettext
from drf_yasg.utils import swagger_auto_schema
from rest_framework import status, viewsets
Expand Down Expand Up @@ -105,6 +108,16 @@ def _add_assistant(self, request: Request, **_):
"""Add an assistant to the course"""
course = self.get_object()

# Get the user
user_serializer = UserIDSerializer(
data={'user': request.data.get('assistant')},
)

# Create an assistant role for the user (if there is already an assistant object, activate it). If the role
# is already active, nothing will happen.
if user_serializer.is_valid(raise_exception=True):
Assistant.create(user_serializer.validated_data.get('user'))

# Add assistant to course
serializer = AssistantIDSerializer(
data=request.data
Expand All @@ -131,10 +144,17 @@ def _remove_assistant(self, request: Request, **_):
)

if serializer.is_valid(raise_exception=True):
assistant = serializer.validated_data["assistant"]

# Remove the assistant from the course
course.assistants.remove(
serializer.validated_data["assistant"]
assistant
)

# If this was the last course of the assistant, deactivate the assistant role
if not assistant.courses.exists():
assistant.deactivate()

return Response({
"message": gettext("courses.success.assistants.remove")
})
Expand Down Expand Up @@ -216,6 +236,16 @@ def _add_teacher(self, request, **_):
# Get the course
course = self.get_object()

# Get the user
user_serializer = UserIDSerializer(
data={'user': request.data.get('teacher')},
)

# Create a teacher role for the user (if there is already an teacher object, activate it). If the role
# is already active, nothing will happen.
if user_serializer.is_valid(raise_exception=True):
Teacher.create(user_serializer.validated_data.get('user'))

# Add teacher to course
serializer = TeacherJoinSerializer(data=request.data, context={
"course": course
Expand Down Expand Up @@ -243,10 +273,17 @@ def _remove_teacher(self, request, **_):
})

if serializer.is_valid(raise_exception=True):
teacher = serializer.validated_data["teacher"]

# Remove the teacher from the course
course.teachers.remove(
serializer.validated_data["teacher"]
teacher
)

# If this was the last course of the teacher, deactivate the teacher role
if not teacher.courses.exists():
teacher.deactivate()

return Response({
"message": gettext("courses.success.teachers.remove")
})
Expand Down
25 changes: 25 additions & 0 deletions backend/api/views/user_view.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
from django.db.models.functions import Concat
from django.db.models import Value
from api.permissions.notification_permissions import NotificationPermission
from api.permissions.role_permissions import IsSameUser
from api.views.pagination.basic_pagination import BasicPagination
Expand Down Expand Up @@ -41,6 +43,29 @@ def search(self, request: Request) -> Response:
email = request.query_params.get("email", "")
roles = request.query_params.getlist("roles[]")

# Search parameters for a simple name + faculty filter
name = request.query_params.get("name", None)
faculties = request.query_params.getlist("faculties[]")

# If name is provided, just filter by the name (and faculties if provided)
if name or faculties:
queryset = self.get_queryset().annotate(
full_name=Concat('first_name', Value(' '), 'last_name')
).filter(
full_name__icontains=name
)

# Filter the queryset based on selected faculties
if faculties:
queryset = queryset.filter(faculties__id__in=faculties)

serializer = self.serializer_class(self.paginate_queryset(queryset), many=True, context={
"request": request
})

return self.get_paginated_response(serializer.data)

# Otherwise, search by the provided search term
queryset1 = self.get_queryset().filter(
id__icontains=search
)
Expand Down
1 change: 1 addition & 0 deletions frontend/src/assets/lang/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,7 @@
"search": "Search",
"faculty": "Faculty",
"role": "Role",
"no_role": "None",
"placeholder": "Search a user by name",
"title": "Find users to link to this course",
"results": "{0} users found for set filters"
Expand Down
1 change: 1 addition & 0 deletions frontend/src/assets/lang/nl.json
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,7 @@
"search": "Zoeken",
"faculty": "Faculteit",
"role": "Rol",
"no_role": "Geen",
"placeholder": "Zoek een gebruiker op naam",
"title": "Zoek gebuikers om aan dit vak toe te voegen",
"results": "{0} gebruikers gevonden voor ingestelde filters"
Expand Down
Original file line number Diff line number Diff line change
@@ -1,40 +1,47 @@
<script setup lang="ts">
import Card from 'primevue/card';
import CourseRoleAddButton from '@/components/teachers_assistants/buttons/CourseRoleAddButton.vue';
import LeaveCourseButton from '@/components/teachers_assistants/buttons/LeaveCourseButton.vue';
import { type User } from '@/types/users/User.ts';
import { type Course } from '@/types/Course';
import { useI18n } from 'vue-i18n';
import TeacherCourseAddButton from '@/components/teachers_assistants/button/TeacherCourseAddButton.vue';
import AssistantCourseAddButton from '@/components/teachers_assistants/button/AssistantCourseAddButton.vue';
import { useAuthStore } from '@/store/authentication.store.ts';
import { storeToRefs } from 'pinia';
/* Component props */
const props = defineProps<{ userValue: User; course: Course }>();
const props = defineProps<{ userValue: User; course: Course; detail?: boolean }>();
/* Composable injections */
const { t } = useI18n();
const { user } = storeToRefs(useAuthStore());
</script>

<template>
<Card class="border-round course-card">
<Card class="course-card">
<template #title>
<h2 class="text-primary m-0 text-xl">{{ props.userValue.getFullName() }}</h2>
<div class="flex justify-content-between">
<h2 class="text-primary mt-2 mb-0 text-xl">{{ props.userValue.getFullName() }}</h2>

<!-- Display the delete button on a detail card, only if the user is not the last teacher of the course -->
<LeaveCourseButton
:user="props.userValue"
:course="course"
v-if="
props.detail &&
!(props.userValue.getRole() == 'types.roles.teacher' && course.teachers?.length == 1)
"
/>
</div>
</template>
<template #subtitle>
<span class="text-sm">{{ t(props.userValue.getRole()) }}</span>
<template #subtitle v-if="props.detail">
<span class="text-sm m-0">{{ t(props.userValue.getRole()) }}</span>
</template>
<template #content>
{{ props.userValue.email }}
</template>
<!-- Display add/remove button on the assistant/teacher card, only when the user is a-->
<template #footer v-if="user?.isTeacher()">
<TeacherCourseAddButton :user="props.userValue" :course="course" v-if="props.userValue.isTeacher()" />
<AssistantCourseAddButton
:user="props.userValue"
:course="course"
v-else-if="props.userValue.isAssistant()"
/>
<!-- Display the role switch button, only if the card is not in detail mode, and the user is not the last teacher of the course -->
<template
#footer
v-if="!props.detail && !(course.teachers?.length == 1 && course.teachers[0].id == props.userValue.id)"
>
<CourseRoleAddButton :user="props.userValue" :course="course" />
</template>
</Card>
</template>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,12 @@ interface Props {
users: User[] | null;
course: Course;
cols?: number;
detail?: boolean;
}
withDefaults(defineProps<Props>(), {
cols: 4,
detail: true,
});
</script>

Expand All @@ -26,7 +28,7 @@ withDefaults(defineProps<Props>(), {
v-for="user in users"
:key="user.id"
>
<TeacherAssistantCard class="h-full" :userValue="user" :course="course" />
<TeacherAssistantCard class="h-full" :userValue="user" :course="course" :detail="detail" />
</div>
</template>
</template>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,62 +8,31 @@ import Checkbox from 'primevue/checkbox';
import Paginator from 'primevue/paginator';
import TeacherAssistantList from './TeacherAssistantList.vue';
import { type Course } from '@/types/Course.ts';
import { onMounted, ref } from 'vue';
import { useTeacher } from '@/composables/services/teacher.service';
import { useAssistant } from '@/composables/services/assistant.service';
import { useAuthStore } from '@/store/authentication.store.ts';
import { onMounted } from 'vue';
import { useUser } from '@/composables/services/users.service';
import { useFaculty } from '@/composables/services/faculty.service.ts';
import { storeToRefs } from 'pinia';
import { useI18n } from 'vue-i18n';
import { useFilter } from '@/composables/filters/filter.ts';
import { usePaginator } from '@/composables/filters/paginator.ts';
import { useRoute } from 'vue-router';
import { getUserFilters } from '@/types/filter/Filter.ts';
import { type User } from '@/types/users/User';
/* Composable injections */
const { t } = useI18n();
const { query } = useRoute();
const { user } = storeToRefs(useAuthStore());
const { faculties, getFaculties } = useFaculty();
const { teacherPagination, searchTeachers } = useTeacher();
const { assistantPagination, searchAssistants } = useAssistant();
const { searchUsers, pagination } = useUser();
const { onPaginate, resetPagination, page, first, pageSize } = usePaginator();
const { filter, onFilter } = useFilter(getUserFilters(query));
/* Props */
const props = defineProps<{ course: Course }>();
/* Teacher and assistant roles */
const roles = [
{ label: 'teacher', value: 'types.roles.teacher' },
{ label: 'assistant', value: 'types.roles.assistant' },
];
/* Ref that contains all the filtered users */
const filteredUsers = ref<User[] | null>(null);
/**
* Fetch the users based on the filter.
*/
async function fetchUsers(): Promise<void> {
if (filter.value.roles.length === 0) {
// No roles selected, so all users should be fetched
await searchTeachers(filter.value, page.value, pageSize.value);
await searchAssistants(filter.value, page.value, pageSize.value);
} else {
// Depending on the roles, fetch the users
if (filter.value.roles.includes('teacher')) {
await searchTeachers(filter.value, page.value, pageSize.value);
}
if (filter.value.roles.includes('assistant')) {
await searchAssistants(filter.value, page.value, pageSize.value);
}
}
// Set the filtered users
filteredUsers.value = [...(teacherPagination.value?.results ?? []), ...(assistantPagination.value?.results ?? [])];
await searchUsers(filter.value, page.value, pageSize.value);
}
/* Fetch the faculties */
Expand All @@ -80,8 +49,7 @@ onMounted(async () => {
/* Reset pagination on filter change */
onFilter(
async () => {
filteredUsers.value = null;
await resetPagination([teacherPagination, assistantPagination]);
await resetPagination([pagination]);
},
0,
false,
Expand All @@ -97,7 +65,7 @@ onMounted(async () => {
<IconField iconPosition="left">
<InputText
:placeholder="t('views.courses.teachers_and_assistants.search.placeholder')"
v-model="filter.search"
v-model="filter.name"
class="w-full"
/>
<InputIcon class="pi pi-search"></InputIcon>
Expand All @@ -114,17 +82,16 @@ onMounted(async () => {
<label :for="faculty.id" class="ml-2 text-sm">{{ faculty.name }}</label>
</div>
</AccordionTab>
<AccordionTab :header="t('views.courses.teachers_and_assistants.search.role')" v-if="user">
<div v-for="role in roles" :key="role.label" class="flex align-items-center mb-2">
<Checkbox v-model="filter.roles" :inputId="role.label" name="roles" :value="role.label" />
<label :for="role.label" class="ml-2 text-sm"> {{ t(role.value) }} </label>
</div>
</AccordionTab>
</Accordion>
</div>
<div class="col-12 xl:col-9">
<TeacherAssistantList :users="filteredUsers" :cols="3" :course="props.course" />
<Paginator :rows="pageSize" :total-records="filteredUsers?.length" :first="first" />
<TeacherAssistantList
:users="pagination?.results ?? null"
:cols="3"
:course="props.course"
:detail="false"
/>
<Paginator :rows="pageSize" :total-records="pagination?.count" v-model:first="first" />
</div>
</div>
</template>
Expand Down
Loading

0 comments on commit 7e57b82

Please sign in to comment.