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: tree view for project submission structure, cleanups #447

Merged
merged 15 commits into from
May 21, 2024
1 change: 1 addition & 0 deletions backend/.tool-versions
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
python 3.11.4
4 changes: 2 additions & 2 deletions backend/api/logic/parse_zip_files.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@ def parse_zip(project: Project, zip_file: InMemoryUploadedFile) -> bool:

zip_file.seek(0)

with zipfile.ZipFile(zip_file, 'r') as zip:
files = zip.namelist()
with zipfile.ZipFile(zip_file, 'r') as file:
files = file.namelist()
EwoutV marked this conversation as resolved.
Show resolved Hide resolved
directories = [file for file in files if file.endswith('/')]

# Check if all directories start the same
Expand Down
6 changes: 3 additions & 3 deletions backend/api/models/project.py
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,6 @@ def increase_deadline(self, days):
self.save()

if TYPE_CHECKING:
groups: RelatedManager['Group']
structure_checks: RelatedManager['StructureCheck']
extra_checks: RelatedManager['ExtraCheck']
groups: RelatedManager[Group]
structure_checks: RelatedManager[StructureCheck]
extra_checks: RelatedManager[ExtraCheck]
117 changes: 57 additions & 60 deletions backend/api/serializers/checks_serializer.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,87 +7,89 @@


class FileExtensionSerializer(serializers.ModelSerializer):
extension = serializers.CharField(
required=True,
max_length=10
)

class Meta:
model = FileExtension
fields = ["extension"]


class FileExtensionHyperLinkedRelatedField(serializers.HyperlinkedRelatedField):
view_name = "file-extensions-detail"
queryset = FileExtension.objects.all()
fields = ["id", "extension"]

def to_internal_value(self, data):
try:
return self.queryset.get(pk=data)
except FileExtension.DoesNotExist:
return self.fail("no_match")


# TODO: Support partial updates
class StructureCheckSerializer(serializers.ModelSerializer):

project = serializers.HyperlinkedRelatedField(
view_name="project-detail",
read_only=True
project = serializers.HyperlinkedIdentityField(
view_name="project-detail"
)

obligated_extensions = FileExtensionSerializer(many=True, required=False, default=[])

blocked_extensions = FileExtensionSerializer(many=True, required=False, default=[])

class Meta:
model = StructureCheck
fields = "__all__"

obligated_extensions = FileExtensionSerializer(
many=True,
required=False,
default=[]
)

# TODO: Simplify
class StructureCheckAddSerializer(StructureCheckSerializer):
blocked_extensions = FileExtensionSerializer(
many=True,
required=False,
default=[]
)

def validate(self, attrs):
"""Validate the structure check"""
project: Project = self.context["project"]
if project.structure_checks.filter(path=attrs["path"]).count():

# The structure check path should not exist already
if project.structure_checks.filter(path=attrs["path"]).exists():
raise ValidationError(_("project.error.structure_checks.already_existing"))

obl_ext = set()
for ext in self.context["obligated"]:
extension, result = FileExtension.objects.get_or_create(
extension=ext
)
obl_ext.add(extension)
attrs["obligated_extensions"] = obl_ext
# The same extension should not be in both blocked and obligated
blocked = set([ext["extension"] for ext in attrs["blocked_extensions"]])
obligated = set([ext["extension"] for ext in attrs["obligated_extensions"]])

block_ext = set()
for ext in self.context["blocked"]:
extension, result = FileExtension.objects.get_or_create(
extension=ext
)
if extension in obl_ext:
raise ValidationError(_("project.error.structure_checks.extension_blocked_and_obligated"))
block_ext.add(extension)
attrs["blocked_extensions"] = block_ext
if blocked.intersection(obligated):
raise ValidationError(_("project.error.structure_checks.blocked_obligated"))

return attrs

def create(self, validated_data: dict) -> StructureCheck:
"""Create a new structure check"""
blocked = validated_data.pop("blocked_extensions")
obligated = validated_data.pop("obligated_extensions")

class DockerImagerHyperLinkedRelatedField(serializers.HyperlinkedRelatedField):
view_name = "docker-image-detail"
queryset = DockerImage.objects.all()
check: StructureCheck = StructureCheck.objects.create(
path=validated_data.pop("path"),
**validated_data
)

def to_internal_value(self, data):
try:
return self.queryset.get(pk=data)
except DockerImage.DoesNotExist:
return self.fail("no_match")
for ext in obligated:
ext, _ = FileExtension.objects.get_or_create(
extension=ext["extension"]
)
check.obligated_extensions.add(ext)

# Add blocked extensions
for ext in blocked:
ext, _ = FileExtension.objects.get_or_create(
extension=ext["extension"]
)
check.blocked_extensions.add(ext)

class ExtraCheckSerializer(serializers.ModelSerializer):
return check

project = serializers.HyperlinkedRelatedField(
class Meta:
model = StructureCheck
fields = "__all__"


class ExtraCheckSerializer(serializers.ModelSerializer):
project = serializers.HyperlinkedIdentityField(
view_name="project-detail",
read_only=True
)

docker_image = DockerImagerHyperLinkedRelatedField()
docker_image = serializers.HyperlinkedIdentityField(
view_name="docker-image-detail"
)
EwoutV marked this conversation as resolved.
Show resolved Hide resolved

class Meta:
model = ExtraCheck
Expand All @@ -96,11 +98,6 @@ class Meta:
def validate(self, attrs):
data = super().validate(attrs)

# Only check if docker image is present when it is not a partial update
if not self.partial:
if "docker_image" not in data:
raise serializers.ValidationError(_("extra_check.error.docker_image"))

if "time_limit" in data and not 10 <= data["time_limit"] <= 1000:
raise serializers.ValidationError(_("extra_check.error.time_limit"))

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 @@ -45,7 +45,11 @@ class CourseSerializer(serializers.ModelSerializer):

def validate(self, attrs: dict) -> dict:
"""Extra custom validation for course serializer"""
attrs = super().validate(attrs)

# Clean the description
attrs['description'] = clean(attrs['description'])

return attrs

def to_representation(self, instance):
Expand Down
Empty file.
34 changes: 34 additions & 0 deletions backend/api/serializers/fields/expandable_hyperlinked_field.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
from typing import Type

from rest_framework import serializers
from rest_framework.request import Request
from rest_framework.serializers import Serializer


class ExpandableHyperlinkedIdentityField(serializers.HyperlinkedIdentityField):
"""A HyperlinkedIdentityField with nested serializer expanding"""

def __init__(self, serializer: Type[Serializer], view_name: str = None, **kwargs):
self.serializer = serializer
super().__init__(view_name=view_name, **kwargs)

def get_url(self, obj: any, view_name: str, request: Request, fm: str):
"""Get the URL of the related object"""
return super().get_url(obj, view_name, request, fm)

def to_representation(self, value):
"""Get the representation of the nested instance"""
request: Request = self.context.get('request')

if request and self.field_name in request.query_params:
try:
instance = getattr(value, self.field_name)
except AttributeError:
instance = value

return self.serializer(instance,
many=self._kwargs.pop('many'),
context=self.context
).data

return super().to_representation(value)
18 changes: 11 additions & 7 deletions backend/api/serializers/project_serializer.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,18 @@
from django.core.files.uploadedfile import InMemoryUploadedFile

from api.logic.parse_zip_files import parse_zip
from api.models.group import Group
from api.models.project import Project
from api.models.submission import Submission, ExtraCheckResult, StructureCheckResult, StateEnum
from api.serializers.course_serializer import CourseSerializer
from django.core.files.uploadedfile import InMemoryUploadedFile
from django.utils import timezone
from django.utils.translation import gettext
from nh3 import clean
from rest_framework import serializers
from rest_framework.exceptions import ValidationError

from api.serializers.fields.expandable_hyperlinked_field import ExpandableHyperlinkedIdentityField


class SubmissionStatusSerializer(serializers.Serializer):
non_empty_groups = serializers.IntegerField(read_only=True)
Expand All @@ -30,7 +33,7 @@ def to_representation(self, instance: Project):

# The total amount of groups with at least one submission should never exceed the total number of non empty groups
# (the seeder does not account for this restriction)
if (groups_submitted > non_empty_groups):
if groups_submitted > non_empty_groups:
non_empty_groups = groups_submitted

passed_structure_checks_submission_ids = StructureCheckResult.objects.filter(
Expand Down Expand Up @@ -61,7 +64,7 @@ def to_representation(self, instance: Project):

# The total number of passed extra checks combined with the number of passed structure checks
# can never exceed the total number of submissions (the seeder does not account for this restriction)
if (structure_checks_passed + extra_checks_passed > groups_submitted):
if structure_checks_passed + extra_checks_passed > groups_submitted:
extra_checks_passed = groups_submitted - structure_checks_passed

return {
Expand Down Expand Up @@ -93,8 +96,7 @@ class ProjectSerializer(serializers.ModelSerializer):
)

structure_checks = serializers.HyperlinkedIdentityField(
view_name="project-structure-checks",
read_only=True
view_name="project-structure-checks"
)

extra_checks = serializers.HyperlinkedIdentityField(
Expand Down Expand Up @@ -146,6 +148,8 @@ def validate(self, attrs):
return attrs

def create(self, validated_data):
"""Create the project object and create groups for the project if specified"""

# Pop the 'number_groups' field from validated_data
number_groups = validated_data.pop('number_groups', None)

Expand All @@ -162,7 +166,6 @@ def create(self, validated_data):
group.students.add(student)

elif number_groups:

for _ in range(number_groups):
Group.objects.create(project=project)

Expand All @@ -172,10 +175,11 @@ def create(self, validated_data):
group_size = project.group_size

for _ in range(0, number_students, group_size):
group = Group.objects.create(project=project)
Group.objects.create(project=project)

# If a zip_structure is provided, parse it to create the structure checks
zip_structure: InMemoryUploadedFile | None = self.context['request'].FILES.get('zip_structure')

if zip_structure:
result = parse_zip(project, zip_structure)

Expand Down
2 changes: 2 additions & 0 deletions backend/api/tests/test_file_structure.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@ def test_parsing(self):

content_json = json.loads(response.content.decode("utf-8"))

print(project, content_json)
EwoutV marked this conversation as resolved.
Show resolved Hide resolved

self.assertEqual(len(content_json), 6)

expected_project_url = settings.TESTING_BASE_LINK + reverse(
Expand Down
Loading
Loading