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

Allow choosing custom versions of python for graders #92

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 34 additions & 2 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 @@ -33,7 +35,7 @@ def assignments(self, obj):
class AssignmentAdmin(admin.ModelAdmin):
date_hierarchy = "due"
list_display = ("name", "course_name", "folder", "due", "visible", "quiz_icon")
list_filter = ("language", "course", "due")
list_filter = ("language_details", "course", "due")
ordering = ("-due",)
save_as = True
search_fields = ("name",)
Expand All @@ -52,6 +54,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,13 +20,21 @@ 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)
)
else:
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
114 changes: 114 additions & 0 deletions tin/apps/assignments/migrations/0033_language.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
# Generated by Django 4.2.16 on 2024-11-11 01:40

from django.db import migrations, models


def migrate_to_foreignkey(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. Note that we cannot deprecate it yet until we have
# Java support, and figure out how to migrate the media.
java, java_created = Language.objects.using(db_alias).get_or_create(
name="Java/Python 3.10",
executable="/usr/bin/python3.10",
language="J",
version=310,
)

Assignment = apps.get_model("assignments", "Assignment")
for assignment in Assignment.objects.using(db_alias).all():
if assignment.language == "P":
assignment.language_details = python_310
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):
"""Converts the foreign key :class:`.Language` back to the old ``language`` field."""

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="J",
name="Java/Python 3.10",
)
.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)),
('version', models.PositiveSmallIntegerField(help_text="The version of the executable.")),
],
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(migrate_to_foreignkey, 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',
),

]
55 changes: 48 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":
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,29 @@ 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."""

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.")

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 @@ -251,8 +251,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
2 changes: 1 addition & 1 deletion tin/apps/submissions/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ def filter_editable(self, user):
def upload_submission_file_path(submission, _) -> str: # pylint: disable=unused-argument
"""Get the path to a submission"""
assert submission.assignment.id is not None
if submission.assignment.language == "P":
if submission.assignment.grader_language == "P":
return "assignment-{}/{}/submission_{}.py".format(
submission.assignment.id,
slugify(submission.student.username),
Expand Down
Loading
Loading