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

feat: Project "Download grades as a CSV file" button #402

Merged
merged 13 commits into from
May 13, 2024
Merged
21 changes: 15 additions & 6 deletions backend/api/serializers/group_serializer.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
from api.models.group import Group
from api.models.student import Student
from api.permissions.role_permissions import is_student
from api.models.assistant import Assistant
from api.models.teacher import Teacher
from api.permissions.role_permissions import is_student, is_assistant, is_teacher
from api.serializers.project_serializer import ProjectSerializer
from api.serializers.student_serializer import StudentIDSerializer
from django.utils.translation import gettext
Expand Down Expand Up @@ -30,12 +32,19 @@ class Meta:
def to_representation(self, instance):
data = super().to_representation(instance)

# If you are not a student, you can always see the score
if is_student(self.context["request"].user):
# Student can not see the score if they are not part of the group, or it is not visible yet
if not instance.students.filter(id=self.context["request"].user.student.id).exists() or\
not instance.project.score_visible:
user = self.context["request"].user
course_id = instance.project.course.id

# If you are not a student, you can always see the score
# Same with being a student, but not being part of the course affiliated with this group
if is_student(user) and \
not ((is_assistant(user) or is_teacher(user)) and
not user.student.courses.filter(id=course_id).exists()):
student_in_course = user.student.courses.filter(id=course_id).exists()
bsilkyn marked this conversation as resolved.
Show resolved Hide resolved
# Student can not see the score if they are not part of the course associated with group,
# or it is not visible yet
if not student_in_course or \
not instance.project.score_visible and student_in_course:
bsilkyn marked this conversation as resolved.
Show resolved Hide resolved
data.pop("score")

return data
Expand Down
3 changes: 2 additions & 1 deletion frontend/src/assets/lang/app/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,8 @@
"searchCourse": "Search a course",
"createCourse": "Create a new course",
"public": "Public",
"protected": "Protected"
"protected": "Protected",
"csv": "Download grades as a .csv file"
},
"card": {
"open": "Details",
Expand Down
3 changes: 2 additions & 1 deletion frontend/src/assets/lang/app/nl.json
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,8 @@
"searchCourse": "Zoek een vak",
"createCourse": "Maak een vak",
"public": "Publiek",
"protected": "Besloten"
"protected": "Besloten",
"csv": "Download punten als een .csv bestand"
},
"card": {
"open": "Details",
Expand Down
73 changes: 73 additions & 0 deletions frontend/src/components/projects/DownloadCSV.vue
bsilkyn marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
<script setup lang="ts">
import Button from 'primevue/button';
import { useI18n } from 'vue-i18n';
import { useGroup } from '@/composables/services/group.service.ts';
import { useStudents } from '@/composables/services/student.service.ts';
import { type Project } from '@/types/Project.ts';

/* Props */
const props = defineProps<{
project: Project;
}>();

/* Injections */
const { t } = useI18n();
const { groups, getGroupsByProject } = useGroup();
const { students, getStudentsByGroup } = useStudents();

/* Constants */
const header = 'OrgDefinedId,Last Name,First Name,Email,Grade,End-of-Line Indicator\n';

/* Functions */
/**
* generateCSVAndDownload generates a csv combining all the scores for all students in all groups associated
* with the projectId in this component's props.
* After generating a csv, a download link is created and clicked.
*/
const generateCSVAndDownload = async (): Promise<void> => {
// retrieve all the groups associated with a given project
await getGroupsByProject(props.project.id);
// construct for every group's student a csv line according to ufora grade csv standard
// and concatenate them all into one csv
const csvPromises =
groups.value?.map(async (group) => {
await getStudentsByGroup(group.id);
return (
students.value
?.map((student) => {
// single csv line
return `#${student.student_id},${student.last_name},${student.first_name},${student.email},${(group.score * 10) / props.project.max_score},#`;
})
.join('\n') ?? ''
);
}) ?? [];

const csvList = await Promise.all(csvPromises);
const csvContent = header + csvList.join('\n');

// create a blob from the csv content
const blob = new Blob([csvContent], { type: 'text/plain' });

// create a download url for this blob
const url = URL.createObjectURL(blob);

// create an anchor element for downloading the file
const a = document.createElement('a');
a.href = url;
a.download = props.project.name + '.csv';

// click anchor element
a.click();

// clean up URL
URL.revokeObjectURL(url);
};
</script>

<template>
<Button @click="generateCSVAndDownload">
{{ t('components.button.csv') }}
</Button>
</template>

<style scoped lang="scss"></style>
2 changes: 1 addition & 1 deletion frontend/src/composables/services/student.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ export function useStudents(): StudentsState {
endpoint,
{
user: studentData.id,
student_id: studentData.studentId,
student_id: studentData.student_id,
},
student,
Student.fromJSON,
Expand Down
8 changes: 4 additions & 4 deletions frontend/src/test/unit/services/setup/data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,7 @@ export const students = [
last_name: 'Doe',
last_enrolled: 2023,
create_time: new Date('July 21, 2024 01:15:00'),
studentId: null,
student_id: null,
roles: ['student'],
courses: ['1', '2', '3'],
groups: ['0'],
Expand All @@ -174,7 +174,7 @@ export const students = [
last_name: 'Verhaege',
last_enrolled: 2023,
create_time: new Date('July 21, 2024 01:15:00'),
studentId: null,
student_id: null,
roles: ['student'],
courses: [],
groups: [],
Expand All @@ -190,7 +190,7 @@ export const students = [
last_name: 'Verslype',
last_enrolled: 2023,
create_time: new Date('July 21, 2024 01:15:00'),
studentId: '02012470',
student_id: '02012470',
roles: ['student'],
courses: [],
groups: [],
Expand All @@ -206,7 +206,7 @@ export const students = [
last_name: 'somtin',
last_enrolled: 2023,
create_time: new Date('July 21, 2024 01:15:00'),
studentId: null,
student_id: null,
roles: ['student'],
courses: [],
groups: [],
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/test/unit/services/setup/post_handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ export const postHandlers = [
const buffer = await request.arrayBuffer();
const requestBody = new TextDecoder().decode(buffer);
const newStudent = JSON.parse(requestBody);
students.push({ id: newStudent.user, studentId: newStudent.student_id, ...newStudent });
students.push({ id: newStudent.user, student_id: newStudent.student_id, ...newStudent });
return HttpResponse.json(students);
}),
http.post(baseUrl + endpoints.courses.index, async ({ request }) => {
Expand Down
22 changes: 11 additions & 11 deletions frontend/src/test/unit/services/student_service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ describe('students', (): void => {
expect(student.value?.first_name).toBe('John');
expect(student.value?.last_name).toBe('Doe');
expect(student.value?.last_enrolled).toBe(2023);
expect(student.value?.studentId).toBeNull();
expect(student.value?.student_id).toBeNull();
expect(student.value?.last_login).toBeNull();
expect(student.value?.create_time.toISOString()).toEqual('2024-07-20T23:15:00.000Z');
expect(student.value?.courses).toEqual([]);
Expand All @@ -60,7 +60,7 @@ describe('students', (): void => {
expect(students.value?.[0]?.first_name).toBe('John');
expect(students.value?.[0]?.last_name).toBe('Doe');
expect(students.value?.[0]?.last_enrolled).toBe(2023);
expect(students.value?.[0]?.studentId).toBeNull();
expect(students.value?.[0]?.student_id).toBeNull();
expect(students.value?.[0]?.last_login).toBeNull();
expect(students.value?.[0]?.create_time).toEqual(new Date('July 21, 2024 01:15:00'));
expect(students.value?.[0]?.courses).toEqual([]);
Expand All @@ -73,7 +73,7 @@ describe('students', (): void => {
expect(students.value?.[1]?.first_name).toBe('Bartje');
expect(students.value?.[1]?.last_name).toBe('Verhaege');
expect(students.value?.[1]?.last_enrolled).toBe(2023);
expect(students.value?.[1]?.studentId).toBeNull();
expect(students.value?.[1]?.student_id).toBeNull();
expect(students.value?.[1]?.last_login).toBeNull();
expect(students.value?.[1]?.create_time).toEqual(new Date('July 21, 2024 01:15:00'));
expect(students.value?.[1]?.courses).toEqual([]);
Expand All @@ -86,7 +86,7 @@ describe('students', (): void => {
expect(students.value?.[2]?.first_name).toBe('Tybo');
expect(students.value?.[2]?.last_name).toBe('Verslype');
expect(students.value?.[2]?.last_enrolled).toBe(2023);
expect(students.value?.[2]?.studentId).toBe('02012470');
expect(students.value?.[2]?.student_id).toBe('02012470');
expect(students.value?.[2]?.last_login).toEqual(new Date('July 30, 2024 01:15:00'));
expect(students.value?.[2]?.create_time).toEqual(new Date('July 21, 2024 01:15:00'));
expect(students.value?.[2]?.courses).toEqual([]);
Expand All @@ -99,7 +99,7 @@ describe('students', (): void => {
expect(students.value?.[3]?.first_name).toBe('somtin');
expect(students.value?.[3]?.last_name).toBe('somtin');
expect(students.value?.[3]?.last_enrolled).toBe(2023);
expect(students.value?.[3]?.studentId).toBeNull();
expect(students.value?.[3]?.student_id).toBeNull();
expect(students.value?.[3]?.last_login).toBeNull();
expect(students.value?.[3]?.create_time).toEqual(new Date('July 21, 2024 01:15:00'));
expect(students.value?.[3]?.courses).toEqual([]);
Expand All @@ -121,7 +121,7 @@ describe('students', (): void => {
expect(students.value?.[0]?.first_name).toBe('John');
expect(students.value?.[0]?.last_name).toBe('Doe');
expect(students.value?.[0]?.last_enrolled).toBe(2023);
expect(students.value?.[0]?.studentId).toBeNull();
expect(students.value?.[0]?.student_id).toBeNull();
expect(students.value?.[0]?.last_login).toBeNull();
expect(students.value?.[0]?.create_time).toEqual(new Date('July 21, 2024 01:15:00'));
expect(students.value?.[0]?.courses).toEqual([]);
Expand All @@ -134,7 +134,7 @@ describe('students', (): void => {
expect(students.value?.[1]?.first_name).toBe('Bartje');
expect(students.value?.[1]?.last_name).toBe('Verhaege');
expect(students.value?.[1]?.last_enrolled).toBe(2023);
expect(students.value?.[1]?.studentId).toBeNull();
expect(students.value?.[1]?.student_id).toBeNull();
expect(students.value?.[1]?.last_login).toBeNull();
expect(students.value?.[1]?.create_time).toEqual(new Date('July 21, 2024 01:15:00'));
expect(students.value?.[1]?.courses).toEqual([]);
Expand All @@ -147,7 +147,7 @@ describe('students', (): void => {
expect(students.value?.[2]?.first_name).toBe('Tybo');
expect(students.value?.[2]?.last_name).toBe('Verslype');
expect(students.value?.[2]?.last_enrolled).toBe(2023);
expect(students.value?.[2]?.studentId).toBe('02012470');
expect(students.value?.[2]?.student_id).toBe('02012470');
expect(students.value?.[2]?.last_login).toEqual(new Date('July 30, 2024 01:15:00'));
expect(students.value?.[2]?.create_time).toEqual(new Date('July 21, 2024 01:15:00'));
expect(students.value?.[2]?.courses).toEqual([]);
Expand All @@ -160,7 +160,7 @@ describe('students', (): void => {
expect(students.value?.[3]?.first_name).toBe('somtin');
expect(students.value?.[3]?.last_name).toBe('somtin');
expect(students.value?.[3]?.last_enrolled).toBe(2023);
expect(students.value?.[3]?.studentId).toBeNull();
expect(students.value?.[3]?.student_id).toBeNull();
expect(students.value?.[3]?.last_login).toBeNull();
expect(students.value?.[3]?.create_time).toEqual(new Date('July 21, 2024 01:15:00'));
expect(students.value?.[3]?.courses).toEqual([]);
Expand All @@ -181,7 +181,7 @@ describe('students', (): void => {
2024, // last_enrolled
new Date(), // create_time
null, // last_login
'studentId', // studentId
'student_id', // student_id
[],
[],
[],
Expand All @@ -202,6 +202,6 @@ describe('students', (): void => {

// Only check for fields that are sent to the backend
expect(students.value?.[prevLength]?.id).toBe('id');
expect(students.value?.[prevLength]?.studentId).toBe('studentId');
expect(students.value?.[prevLength]?.student_id).toBe('student_id');
});
});
2 changes: 1 addition & 1 deletion frontend/src/test/unit/types/data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ export const studentData = {
courses: [],
create_time: new Date(),
last_login: null,
studentId: '1',
student_id: '1',
groups: [],
};

Expand Down
2 changes: 1 addition & 1 deletion frontend/src/test/unit/types/helper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ export function createStudent(studentData: any): Student {
studentData.last_enrolled,
studentData.create_time,
studentData.last_login,
studentData.studentId,
studentData.student_id,
studentData.roles.slice(),
studentData.courses.slice(),
studentData.groups.slice(),
Expand Down
4 changes: 2 additions & 2 deletions frontend/src/test/unit/types/student.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ describe('student type', () => {
expect(student.last_enrolled).toBe(studentData.last_enrolled);
expect(student.create_time).toStrictEqual(studentData.create_time);
expect(student.last_login).toStrictEqual(studentData.last_login);
expect(student.studentId).toBe(studentData.studentId);
expect(student.student_id).toBe(studentData.student_id);
expect(student.roles).toStrictEqual(studentData.roles);
expect(student.courses).toStrictEqual(studentData.courses);
expect(student.groups).toStrictEqual(studentData.groups);
Expand All @@ -39,7 +39,7 @@ describe('student type', () => {
expect(student.last_enrolled).toBe(studentData.last_enrolled);
expect(student.create_time).toStrictEqual(studentData.create_time);
expect(student.last_login).toStrictEqual(studentData.last_login);
expect(student.studentId).toBe(studentData.studentId);
expect(student.student_id).toBe(studentData.student_id);
expect(student.roles).toStrictEqual(studentData.roles);
expect(student.courses).toStrictEqual(studentData.courses);
expect(student.groups).toStrictEqual(studentData.groups);
Expand Down
4 changes: 2 additions & 2 deletions frontend/src/types/users/Student.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ export class Student extends User {
public last_enrolled: number,
public create_time: Date,
public last_login: Date | null,
public studentId: string,
public student_id: string,
public roles: Role[] = [],
public courses: Course[] = [],
public groups: Group[] = [],
Expand Down Expand Up @@ -51,7 +51,7 @@ export class Student extends User {
student.last_enrolled,
new Date(student.create_time),
student.last_login !== null ? new Date(student.last_login) : null,
student.studentId,
student.student_id,
);
}

Expand Down
2 changes: 1 addition & 1 deletion frontend/src/views/admin/UsersView.vue
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ const saveItem = async (): Promise<void> => {
if (role === 'student') {
const data: Record<string, any> = {
...editItem.value,
studentId: editItem.value.id,
student_id: editItem.value.id,
};
await func(data);
} else {
Expand Down
6 changes: 6 additions & 0 deletions frontend/src/views/projects/roles/TeacherProjectView.vue
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { type Teacher } from '@/types/users/Teacher.ts';
import { type Project } from '@/types/Project.ts';
import ProjectInfo from '@/components/projects/ProjectInfo.vue';
import ProjectMeter from '@/components/projects/ProjectMeter.vue';
import DownloadCSV from '@/components/projects/DownloadCSV.vue';

/* Props */
defineProps<{
Expand Down Expand Up @@ -36,6 +37,11 @@ defineProps<{
<div class="col-12 md:col-4">
<ProjectMeter :project="project" />
</div>
<div class="col-12">
bsilkyn marked this conversation as resolved.
Show resolved Hide resolved
<template v-if="project !== null">
<DownloadCSV :project="project" />
</template>
</div>
</div>
</template>

Expand Down