Skip to content

Commit

Permalink
Allow choosing python versions for graders.
Browse files Browse the repository at this point in the history
Custom migrations were written to safely transfer data,
and handle deprecated languages.
  • Loading branch information
JasonGrace2282 committed Nov 16, 2024
1 parent 6a43c84 commit e55d1e6
Show file tree
Hide file tree
Showing 17 changed files with 374 additions and 38 deletions.
46 changes: 43 additions & 3 deletions tin/apps/assignments/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,15 @@

import datetime

from django.contrib import admin
from django.contrib import admin, messages
from django.utils.translation import ngettext

from .models import (
Assignment,
CooldownPeriod,
FileAction,
Folder,
Language,
MossResult,
Quiz,
QuizLogMessage,
Expand All @@ -32,8 +34,16 @@ def assignments(self, obj):
@admin.register(Assignment)
class AssignmentAdmin(admin.ModelAdmin):
date_hierarchy = "due"
list_display = ("name", "course_name", "folder", "due", "visible", "quiz_icon")
list_filter = ("language", "course", "due")
list_display = (
"name",
"course_name",
"folder",
"due",
"visible",
"quiz_icon",
"language_details",
)
list_filter = ("language_details", "course", "due")
ordering = ("-due",)
save_as = True
search_fields = ("name",)
Expand All @@ -52,6 +62,36 @@ def quiz_icon(self, obj):
return bool(obj.is_quiz)


@admin.register(Language)
class LanguageAdmin(admin.ModelAdmin):
list_display = ("name", "language", "executable", "is_deprecated")
search_fields = ("name", "executable")
ordering = ("-language", "is_deprecated", "name")
save_as = True
list_filter = ("language",)
actions = ["make_deprecated"]

@admin.action(description="Mark languages as deprecated")
def make_deprecated(self, request, queryset) -> None:
changed = 0
for language in queryset:
language.is_deprecated = True
language.name = f"{language.name} (Deprecated)"
language.save()
changed += 1

self.message_user(
request,
ngettext(
"Successfully marked %d language as deprecated.",
"Successfully marked %d languages as deprecated.",
changed,
)
% changed,
messages.SUCCESS,
)


@admin.register(CooldownPeriod)
class CooldownPeriodAdmin(admin.ModelAdmin):
date_hierarchy = "start_time"
Expand Down
24 changes: 17 additions & 7 deletions tin/apps/assignments/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,10 @@

from django import forms
from django.conf import settings
from django.db.models import Q

from ..submissions.models import Submission
from .models import Assignment, Folder, MossResult
from .models import Assignment, Folder, Language, MossResult

logger = getLogger(__name__)

Expand All @@ -19,14 +20,22 @@ def __init__(self, course, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields["folder"].queryset = Folder.objects.filter(course=course)

# Prevent changing the language of an assignment after it has been created
instance = getattr(self, "instance", None)
if instance and instance.pk:
self.fields["language"].help_text = (
"Changing this after uploading a grader script is not recommended and will cause "
"issues."
# allow them to change from a deprecated language to a non-deprecated one
# but it must be the same (e.g. python -> python, or java -> java)
self.fields["language_details"].queryset = Language.objects.filter(
Q(is_deprecated=False) & Q(language=instance.language_details.language)
# just nicer UI to show the deprecated language choice
| Q(id=instance.language_details.id)
)

self.fields["language_details"].queryset = Language.objects.filter(is_deprecated=False)
self.fields["language_details"].help_text = (
"Keep in mind you cannot swap between languages after "
"the assignment has been created."
)

# prevent description from getting too big
self.fields["description"].widget.attrs.update({"id": "description"})

Expand Down Expand Up @@ -56,7 +65,7 @@ class Meta:
"description",
"markdown",
"folder",
"language",
"language_details",
"filename",
"venv",
"points_possible",
Expand Down Expand Up @@ -89,6 +98,7 @@ class Meta:
"is_quiz": "Is this a quiz?",
"quiz_autocomplete_enabled": "Enable code autocompletion?",
"quiz_description_markdown": "Use markdown?",
"language_details": "Grader language",
}
sections = (
{
Expand All @@ -107,7 +117,7 @@ class Meta:
"description": "",
"fields": (
"folder",
"language",
"language_details",
"filename",
"venv",
),
Expand Down
117 changes: 117 additions & 0 deletions tin/apps/assignments/migrations/0033_language.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
# Generated by Django 4.2.16 on 2024-11-11 01:40

from django.db import migrations, models


def replace_language_with_default_language(apps, schema_editor):
"""Creates a default :class:`.Language` model for each assignment.
This converts the old language field to a foreign key to the new language model.
"""

Language = apps.get_model("assignments", "Language")
db_alias = schema_editor.connection.alias
python_310, py_created = Language.objects.using(db_alias).get_or_create(
name="Python 3.10",
executable="/usr/bin/python3.10",
language="P",
version=310,
)

# Keep backwards compatibility with Java assignments by making them
# use python.
java, java_created = Language.objects.using(db_alias).get_or_create(
name="Python 3.10 (Deprecated)",
executable="/usr/bin/python3.10",
language="P",
version=310,
is_deprecated=True,
use_java_folder=True,
)

Assignment = apps.get_model("assignments", "Assignment")
for assignment in Assignment.objects.using(db_alias).all():
if assignment.language == "P":
assignment.language_details = python_310
assignment.save()
elif assignment.language == "J":
assignment.language_details = java
assignment.save()

# avoid creating empty models
if py_created and not python_310.assignment_set.exists():
python_310.delete()
if java_created and not java.assignment_set.exists():
java.delete()


def revert_default_language(apps, schema_editor):
Assignment = apps.get_model("assignments", "Assignment")
Language = apps.get_model("assignments", "Language")
db_alias = schema_editor.connection.alias
java = (
Language.objects.using(db_alias)
.filter(
language="P",
is_deprecated=True,
use_java_folder=True,
name="Python 3.10 (Deprecated)",
)
.first()
)
for assignment in Assignment.objects.using(db_alias).all():
if java and assignment.language_details == java:
assignment.language = "J"
else:
assignment.language = "P"
assignment.save()


# fmt: off
class Migration(migrations.Migration):

dependencies = [
('assignments', '0032_assignment_quiz_description_and_more'),
]

operations = [
migrations.CreateModel(
name='Language',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('is_deprecated', models.BooleanField(default=False)),
('language', models.CharField(choices=[('P', 'Python 3'), ('J', 'Java')], max_length=1)),
('name', models.CharField(help_text='The name of the language', max_length=50)),
('executable', models.CharField(help_text='The path to the language executable', max_length=100)),
('use_java_folder', models.BooleanField(default=False, help_text="Store the assignment grader in the Java folder, regardless of language")),
('version', models.PositiveSmallIntegerField(help_text="The version of the executable. 0 if executable is /dev/null.")),
],
options={'ordering': ['-language', '-version']},
),
migrations.AddField(
model_name="assignment",
name="language_details",
field=models.ForeignKey(
null=True,
on_delete=models.deletion.CASCADE,
related_name="assignment_set",
to="assignments.language",
),
),
migrations.RunPython(replace_language_with_default_language, revert_default_language),
migrations.AlterField(
model_name="assignment",
name="language_details",
field=models.ForeignKey(
null=False,
on_delete=models.deletion.CASCADE,
related_name="assignment_set",
to="assignments.language",
),
),
migrations.RemoveField(
model_name='assignment',
name='language',
),

]
68 changes: 61 additions & 7 deletions tin/apps/assignments/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import logging
import os
import subprocess
from pathlib import Path
from typing import Literal

from django.conf import settings
Expand Down Expand Up @@ -95,10 +96,10 @@ def filter_editable(self, user):
def upload_grader_file_path(assignment, _): # pylint: disable=unused-argument
"""Get the location of the grader file for an assignment"""
assert assignment.id is not None
if assignment.language == "P":
return f"assignment-{assignment.id}/grader.py"
else:
if assignment.grader_language == "J" or assignment.language_details.use_java_folder:
return f"assignment-{assignment.id}/Grader.java"
else:
return f"assignment-{assignment.id}/grader.py"


class Assignment(models.Model):
Expand All @@ -122,11 +123,12 @@ class Assignment(models.Model):
description = models.CharField(max_length=4096)
markdown = models.BooleanField(default=False)

LANGUAGES = (
("P", "Python 3"),
("J", "Java"),
language_details = models.ForeignKey(
"Language",
on_delete=models.CASCADE,
related_name="assignment_set",
null=False,
)
language = models.CharField(max_length=1, choices=LANGUAGES, default="P")

filename = models.CharField(max_length=50, default="main.py")

Expand Down Expand Up @@ -192,11 +194,24 @@ def get_absolute_url(self):
def __repr__(self):
return self.name

@property
def grader_language(self) -> Literal["P", "J"]:
"""The language of the assignment (Python or Java)"""
return self.language_details.language

def make_assignment_dir(self) -> None:
"""Creates the directory where the assignment grader scripts go."""
assignment_path = os.path.join(settings.MEDIA_ROOT, f"assignment-{self.id}")
os.makedirs(assignment_path, exist_ok=True)

def grader_exists(self) -> bool:
"""Check if a grader file exists."""
if self.grader_file is None or self.grader_file.name is None:
return False

fpath = Path(settings.MEDIA_ROOT) / self.grader_file.name
return fpath.exists()

def save_grader_file(self, grader_text: str) -> None:
"""Save the grader file to the correct location.
Expand Down Expand Up @@ -667,3 +682,42 @@ def run(self, assignment: Assignment):

assignment.last_action_output = output
assignment.save()


class Language(models.Model):
"""Which version of a language is used for an assignment.
.. tip::
Use :attr:`executable` over :attr:`path`.
"""

LANGUAGES = (
("P", "Python 3"),
("J", "Java"),
)

name = models.CharField(max_length=50, help_text="The name of the language")
executable = models.CharField(max_length=100, help_text="The path to the language executable")
language = models.CharField(max_length=1, choices=LANGUAGES)
is_deprecated = models.BooleanField(default=False)

# for decimals like 3.10, use 310
version = models.PositiveSmallIntegerField(
help_text="The version of the executable. 0 if executable is /dev/null."
)

# This is deprecated, new models should NOT use this attribute.
use_java_folder = models.BooleanField(
default=False,
help_text="Store the assignment grader in the Java folder, regardless of language",
)

class Meta:
ordering = ["-language", "-version"]

def __str__(self) -> str:
return self.name

def __repr__(self) -> str:
return f"<{type(self).__name__}: {self.name} ({self.language}) >"
3 changes: 2 additions & 1 deletion tin/apps/assignments/tests/test_assignments.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@


@login("teacher")
def test_create_assignment(client, course) -> None:
def test_create_assignment(client, course, python) -> None:
data = {
"name": "Write a Vertex Shader",
"description": "See https://learnopengl.com/Getting-started/Shaders",
Expand All @@ -24,6 +24,7 @@ def test_create_assignment(client, course) -> None:
"submission_limit_cooldown": "30",
"is_quiz": False,
"quiz_action": "2",
"language_details": python.id,
}
response = client.post(
reverse("assignments:add", args=[course.id]),
Expand Down
5 changes: 5 additions & 0 deletions tin/apps/assignments/tests/test_grader.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,3 +48,8 @@ def test_download_grader(client, assignment):

assert response.status_code == 200
assert response.content.decode("utf-8") == code


def test_grader_save_file(assignment):
assignment.save_grader_file("print('hello, world')")
assert assignment.grader_exists()
3 changes: 2 additions & 1 deletion tin/apps/assignments/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -232,8 +232,9 @@ def edit_view(request, assignment_id):
)

course = assignment.course
initial = {"language_details": assignment.language_details}

assignment_form = AssignmentForm(course, instance=assignment)
assignment_form = AssignmentForm(course, instance=assignment, initial=initial)
if request.method == "POST":
assignment_form = AssignmentForm(course, data=request.POST, instance=assignment)
if assignment_form.is_valid():
Expand Down
Loading

0 comments on commit e55d1e6

Please sign in to comment.