Skip to content

Commit

Permalink
Project creation update (#271)
Browse files Browse the repository at this point in the history
* chore: project creation faculty

* fix: faculty added when course created

* chore: start docker upload project creation

* chore: init upload zip structure

* chore: still issues with file-upload

* chore: fix uploaded zip file parsing

* fix: cleanup

* fix: linting

* fix: tests

* fix: hopefully last fix
  • Loading branch information
BramMeir authored Apr 9, 2024
1 parent 954a5c6 commit 82524b0
Show file tree
Hide file tree
Showing 13 changed files with 195 additions and 42 deletions.
22 changes: 22 additions & 0 deletions backend/api/serializers/course_serializer.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from api.serializers.teacher_serializer import TeacherIDSerializer
from api.serializers.faculty_serializer import FacultySerializer
from api.models.course import Course
from authentication.models import Faculty


class CourseSerializer(serializers.ModelSerializer):
Expand Down Expand Up @@ -41,6 +42,27 @@ class Meta:
fields = "__all__"


class CreateCourseSerializer(CourseSerializer):
faculty = serializers.PrimaryKeyRelatedField(
queryset=Faculty.objects.all(),
required=False,
allow_null=True,
)

def create(self, validated_data):
faculty = validated_data.pop('faculty', None)

# Create the course
course = super().create(validated_data)

# Link the faculty, if specified
if faculty is not None:
course.faculty = faculty
course.save()

return course


class CourseIDSerializer(serializers.Serializer):
student_id = serializers.PrimaryKeyRelatedField(
queryset=Course.objects.all()
Expand Down
19 changes: 19 additions & 0 deletions backend/api/serializers/project_serializer.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
from django.core.files.storage import FileSystemStorage
from django.conf import settings
from django.utils.translation import gettext
from rest_framework import serializers
from api.models.project import Project
Expand All @@ -7,6 +9,7 @@
from api.models.checks import FileExtension
from api.serializers.submission_serializer import SubmissionSerializer
from api.serializers.checks_serializer import StructureCheckSerializer
from api.logic.check_folder_structure import parse_zip_file


class ProjectSerializer(serializers.ModelSerializer):
Expand Down Expand Up @@ -59,11 +62,16 @@ def validate(self, data):

class CreateProjectSerializer(ProjectSerializer):
number_groups = serializers.IntegerField(min_value=1, required=False)
zip_structure = serializers.FileField(required=False, read_only=True)

def create(self, validated_data):
# Pop the 'number_groups' field from validated_data
number_groups = validated_data.pop('number_groups', None)

# Get the zip structure file from the request
request = self.context.get('request')
zip_structure = request.FILES.get('zip_structure')

# Create the project object without passing 'number_groups' field
project = super().create(validated_data)

Expand All @@ -81,6 +89,17 @@ def create(self, validated_data):
group = Group.objects.create(project=project)
group.students.add(student)

# If a zip_structure is provided, parse it to create the structure checks
if zip_structure is not None:
# Define tje temporary storage location
temp_storage = FileSystemStorage(location=settings.MEDIA_ROOT)
# Save the file to the temporary location
temp_file_path = temp_storage.save(f"tmp/{zip_structure.name}", zip_structure)
# Pass the full path to the parse_zip_file function
parse_zip_file(project, temp_file_path)
# Delete the temporary file
temp_storage.delete(temp_file_path)

return project


Expand Down
2 changes: 0 additions & 2 deletions backend/api/serializers/submission_serializer.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
from typing import Any

from api.logic.check_folder_structure import check_zip_file # , parse_zip_file
from api.models.submission import (ErrorTemplate, ExtraChecksResult,
Submission, SubmissionFile)
Expand Down
45 changes: 44 additions & 1 deletion backend/api/tests/test_course.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,13 @@
from api.models.course import Course
from api.models.teacher import Teacher
from api.models.student import Student
from api.tests.helpers import create_course, create_assistant, create_student, create_teacher, create_project
from api.tests.helpers import (create_course,
create_assistant,
create_student,
create_teacher,
create_project,
create_faculty)
from django.core.files.uploadedfile import SimpleUploadedFile


def get_course():
Expand Down Expand Up @@ -785,12 +791,15 @@ def test_create_course(self):
"""
Able to create a course.
"""
faculty = create_faculty(name="Engineering")

response = self.client.post(
reverse("course-list"),
data={
"name": "Introduction to Computer Science",
"academic_startyear": 2022,
"description": "An introductory course on computer science.",
"faculty": faculty.id,
},
follow=True,
)
Expand All @@ -802,6 +811,9 @@ def test_create_course(self):
course = Course.objects.get(name="Introduction to Computer Science")
self.assertTrue(course.teachers.filter(id=self.user.id).exists())

# Make sure the course is linked to the faculty
self.assertEqual(course.faculty.id, faculty.id)

def test_create_project(self):
"""
Able to create a project for a course.
Expand Down Expand Up @@ -831,6 +843,37 @@ def test_create_project(self):
project = course.projects.get(name="become champions")
self.assertEqual(project.groups.count(), 0)

def test_create_project_with_zip_file_as_structure(self):
"""
Able to create a project for a course with a zip file as structure.
"""
course = get_course()
course.teachers.add(self.user)

with open("data/testing/structures/zip_struct1.zip", "rb") as f:
response = self.client.post(
reverse("course-projects", args=[str(course.id)]),
data={
"name": "become champions",
"description": "win the jpl",
"visible": True,
"archived": False,
"days": 50,
"deadline": timezone.now() + timezone.timedelta(days=50),
"start_date": timezone.now(),
"group_size": 2,
"zip_structure": SimpleUploadedFile('zip_struct1.zip', f.read(), content_type='application/zip'),
},
follow=True,
)

self.assertEqual(response.status_code, 200)
self.assertTrue(course.projects.filter(name="become champions").exists())

# Make sure there are structure checks added to the project
project = course.projects.get(name="become champions")
self.assertTrue(project.structure_checks.exists())

def test_create_project_with_number_groups(self):
"""
Able to create a project for a course with a number of groups.
Expand Down
10 changes: 4 additions & 6 deletions backend/api/views/course_view.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@
StudentJoinSerializer,
StudentLeaveSerializer,
TeacherJoinSerializer,
TeacherLeaveSerializer)
TeacherLeaveSerializer,
CreateCourseSerializer)
from api.serializers.project_serializer import (CreateProjectSerializer,
ProjectSerializer)
from api.serializers.student_serializer import StudentSerializer
Expand All @@ -35,7 +36,7 @@ class CourseViewSet(viewsets.ModelViewSet):
# TODO: Creating should return the info of the new object and not a message "created" (General TODO)
def create(self, request: Request, *_):
"""Override the create method to add the teacher to the course"""
serializer = CourseSerializer(data=request.data, context={"request": request})
serializer = CreateCourseSerializer(data=request.data, context={"request": request})

if serializer.is_valid(raise_exception=True):
course = serializer.save()
Expand All @@ -44,10 +45,7 @@ def create(self, request: Request, *_):
if is_teacher(request.user):
course.teachers.add(request.user.id)

return Response(
{"message": gettext("courses.success.create")},
status=status.HTTP_201_CREATED
)
return Response(serializer.data, status=status.HTTP_201_CREATED)

@action(detail=False)
def search(self, request: Request) -> Response:
Expand Down
6 changes: 4 additions & 2 deletions frontend/src/assets/lang/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,9 @@
"group_size": "Number of students in a group (1 for an individual project)",
"max_score": "Maximum score that can be achieved",
"visibility": "Make project visible to students",
"score_visibility": "Make score, when uploaded, automatically visible to students"
"score_visibility": "Make score, when uploaded, automatically visible to students",
"docker_upload": "Upload a Dockerfile",
"submission_structure": "Structure of how a submission should be made"
},
"submissions": {
"title": "Submissions",
Expand All @@ -66,7 +68,7 @@
"create": "Create course",
"name": "Course name",
"description": "Description",
"year": "Academic year"
"faculty": "Faculty"
}
},
"composables": {
Expand Down
6 changes: 4 additions & 2 deletions frontend/src/assets/lang/nl.json
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,9 @@
"group_size": "Aantal studenten per groep (1 voor individueel project)",
"max_score": "Maximale te behalen score",
"visibility": "Project zichtbaar maken voor studenten",
"score_visibility": "Maak score, wanneer ingevuld, automatisch zichtbaar voor studenten"
"score_visibility": "Maak score, wanneer ingevuld, automatisch zichtbaar voor studenten",
"docker_upload": "Upload een Dockerfile",
"submission_structure": "Structuur van hoe de indiening moet gebeuren"
},
"submissions": {
"title": "Inzendingen",
Expand All @@ -68,7 +70,7 @@
"create": "Creëer vak",
"name": "Vaknaam",
"description": "Beschrijving",
"year": "Academiejaar",
"faculty": "Faculteit",
"search": {
"search": "Zoeken",
"faculty": "Faculteit",
Expand Down
1 change: 1 addition & 0 deletions frontend/src/composables/services/courses.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ export function useCourses(): CoursesState {
name: courseData.name,
description: courseData.description,
academic_startyear: courseData.academic_startyear,
faculty: courseData.faculty?.id,
},
course,
Course.fromJSON,
Expand Down
7 changes: 6 additions & 1 deletion frontend/src/composables/services/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,9 +37,14 @@ export async function create<T>(
data: any,
ref: Ref<T | null>,
fromJson: (data: any) => T,
contentType: string = 'application/json',
): Promise<void> {
try {
const response = await client.post(endpoint, data);
const response = await client.post(endpoint, data, {
headers: {
'Content-Type': contentType,
},
});
ref.value = fromJson(response.data);
} catch (error: any) {
processError(error);
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/composables/services/project.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,9 +89,11 @@ export function useProject(): ProjectState {
max_score: projectData.max_score,
score_visible: projectData.score_visible,
group_size: projectData.group_size,
zip_structure: projectData.structure_file,
},
project,
Project.fromJSON,
'multipart/form-data',
);
}

Expand Down
1 change: 1 addition & 0 deletions frontend/src/types/Projects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export class Project {
public score_visible: boolean,
public group_size: number,

public structure_file: File | null = null,
public course: Course | null = null,
public structureChecks: StructureCheck[] = [],
public extra_checks: ExtraCheck[] = [],
Expand Down
54 changes: 34 additions & 20 deletions frontend/src/views/courses/CreateCourseView.vue
Original file line number Diff line number Diff line change
@@ -1,42 +1,45 @@
<script setup lang="ts">
import Calendar from 'primevue/calendar';
import Dropdown from 'primevue/dropdown';
import BaseLayout from '@/components/layout/BaseLayout.vue';
import Title from '@/components/layout/Title.vue';
import { reactive, computed } from 'vue';
import { reactive, computed, onMounted } from 'vue';
import { useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { useAuthStore } from '@/store/authentication.store.ts';
import { storeToRefs } from 'pinia';
import InputText from 'primevue/inputtext';
import Textarea from 'primevue/textarea';
import Button from 'primevue/button';
import { Course } from '@/types/Course';
import { useCourses } from '@/composables/services/courses.service';
import { useFaculty } from '@/composables/services/faculties.service.ts';
import { required, helpers } from '@vuelidate/validators';
import { useVuelidate } from '@vuelidate/core';
import ErrorMessage from '@/components/forms/ErrorMessage.vue';
import { User } from '@/types/users/User.ts';
/* Composable injections */
const { t } = useI18n();
const { push } = useRouter();
const { user } = storeToRefs(useAuthStore());
const { faculties, getFaculties } = useFaculty();
/* Service injection */
const { createCourse } = useCourses();
/* Fetch the faculties */
onMounted(async () => {
await getFaculties();
});
/* Form content */
const form = reactive({
name: '',
description: '',
year: user.value !== null ? new Date(User.getAcademicYear(new Date()), 0, 1) : new Date(),
faculty: null,
});
// Define validation rules for each form field
const rules = computed(() => {
return {
name: { required: helpers.withMessage(t('validations.required'), required) },
year: { required: helpers.withMessage(t('validations.required'), required) },
faculty: { required: helpers.withMessage(t('validations.required'), required) },
};
});
Expand All @@ -52,17 +55,28 @@ const submitCourse = async (): Promise<void> => {
// Pass the course data to the service
await createCourse(
new Course(
'', // ID not needed for creation, will be generated by the backend
'',
form.name,
form.description,
form.year.getFullYear(),
currentAcademicYear(),
null, // No parent course
form.faculty,
),
);
// Redirect to the dashboard overview
push({ name: 'dashboard' });
}
};
/* Get the current academic year */
const currentAcademicYear = (): number => {
if (new Date().getMonth() < 9) {
return new Date().getFullYear() - 1;
} else {
return new Date().getFullYear();
}
};
</script>

<template>
Expand All @@ -87,17 +101,17 @@ const submitCourse = async (): Promise<void> => {
<Textarea id="courseDescription" v-model="form.description" autoResize rows="5" cols="30" />
</div>

<!-- Course academic year -->
<!-- Course faculty -->
<div class="mb-4">
<label for="courseYear">{{ t('views.courses.year') }}</label>
<Calendar id="courseYear" v-model="form.year" view="year" dateFormat="yy" showIcon>
<template #footer>
<div style="text-align: center">
{{ form.year.getFullYear() }} - {{ form.year.getFullYear() + 1 }}
</div>
</template>
</Calendar>
<ErrorMessage :field="v$.year" />
<label for="courseFaculty">{{ t('views.courses.faculty') }}</label>
<Dropdown
id="courseFaculty"
v-model="form.faculty"
:options="faculties"
optionLabel="name"
v-if="faculties"
/>
<ErrorMessage :field="v$.faculty" />
</div>

<!-- Submit button -->
Expand Down
Loading

0 comments on commit 82524b0

Please sign in to comment.