diff --git a/tin/apps/assignments/admin.py b/tin/apps/assignments/admin.py index 67d9285b..9b71a5a3 100644 --- a/tin/apps/assignments/admin.py +++ b/tin/apps/assignments/admin.py @@ -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, @@ -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",) @@ -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" diff --git a/tin/apps/assignments/forms.py b/tin/apps/assignments/forms.py index bca6c406..779c8690 100644 --- a/tin/apps/assignments/forms.py +++ b/tin/apps/assignments/forms.py @@ -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__) @@ -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"}) @@ -56,7 +65,7 @@ class Meta: "description", "markdown", "folder", - "language", + "language_details", "filename", "venv", "points_possible", @@ -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 = ( { @@ -107,7 +117,7 @@ class Meta: "description": "", "fields": ( "folder", - "language", + "language_details", "filename", "venv", ), diff --git a/tin/apps/assignments/migrations/0033_language.py b/tin/apps/assignments/migrations/0033_language.py new file mode 100644 index 00000000..67f85439 --- /dev/null +++ b/tin/apps/assignments/migrations/0033_language.py @@ -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', + ), + + ] diff --git a/tin/apps/assignments/models.py b/tin/apps/assignments/models.py index 1b1dfcb6..6405a63f 100644 --- a/tin/apps/assignments/models.py +++ b/tin/apps/assignments/models.py @@ -4,6 +4,7 @@ import logging import os import subprocess +from pathlib import Path from typing import Literal from django.conf import settings @@ -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): @@ -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") @@ -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. @@ -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}) >" diff --git a/tin/apps/assignments/tests/test_assignments.py b/tin/apps/assignments/tests/test_assignments.py index 2e456587..db54a0af 100644 --- a/tin/apps/assignments/tests/test_assignments.py +++ b/tin/apps/assignments/tests/test_assignments.py @@ -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", @@ -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]), diff --git a/tin/apps/assignments/tests/test_grader.py b/tin/apps/assignments/tests/test_grader.py index a0db4ac5..73e00aa3 100644 --- a/tin/apps/assignments/tests/test_grader.py +++ b/tin/apps/assignments/tests/test_grader.py @@ -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() diff --git a/tin/apps/assignments/views.py b/tin/apps/assignments/views.py index 72673068..4a729ab5 100644 --- a/tin/apps/assignments/views.py +++ b/tin/apps/assignments/views.py @@ -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(): diff --git a/tin/apps/submissions/models.py b/tin/apps/submissions/models.py index 513a9350..0729a185 100644 --- a/tin/apps/submissions/models.py +++ b/tin/apps/submissions/models.py @@ -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), diff --git a/tin/apps/submissions/tasks.py b/tin/apps/submissions/tasks.py index 50f56156..9536ae61 100644 --- a/tin/apps/submissions/tasks.py +++ b/tin/apps/submissions/tasks.py @@ -32,7 +32,9 @@ def truncate_output(text, field_name): @shared_task def run_submission(submission_id): - submission = Submission.objects.get(id=submission_id) + submission = Submission.objects.select_related( + "assignment", "assignment__language_details" + ).get(id=submission_id) try: grader_path = os.path.join(settings.MEDIA_ROOT, submission.assignment.grader_file.name) @@ -65,7 +67,7 @@ def run_submission(submission_id): python_exe = ( os.path.join(submission.assignment.venv.path, "bin", "python") if submission.assignment.venv_fully_created - else "/usr/bin/python3.10" + else submission.assignment.language_details.executable ) if not settings.DEBUG or shutil.which("bwrap") is not None: @@ -79,7 +81,7 @@ def run_submission(submission_id): "sandboxing", "wrappers", folder_name, - f"{submission.assignment.language}.txt", + f"{submission.assignment.grader_language}.txt", ) ) as wrapper_file: wrapper_text = wrapper_file.read().format( diff --git a/tin/apps/venvs/admin.py b/tin/apps/venvs/admin.py index 306b548b..c8b67f1f 100644 --- a/tin/apps/venvs/admin.py +++ b/tin/apps/venvs/admin.py @@ -1,14 +1,19 @@ from __future__ import annotations +from django import forms from django.contrib import admin +from ..assignments.models import Language from .models import Venv -# Register your models here. + +class VenvAdminForm(forms.ModelForm): + language = forms.ModelChoiceField(queryset=Language.objects.filter(language="P")) @admin.register(Venv) class VenvAdmin(admin.ModelAdmin): + form = VenvAdminForm list_display = ("name", "fully_created", "installing_packages") list_filter = ("fully_created", "installing_packages") save_as = True diff --git a/tin/apps/venvs/forms.py b/tin/apps/venvs/forms.py index 79deb4b6..6689d580 100644 --- a/tin/apps/venvs/forms.py +++ b/tin/apps/venvs/forms.py @@ -2,10 +2,18 @@ from django import forms +from ..assignments.models import Language from .models import Venv class VenvForm(forms.ModelForm): + def __init__(self, *args, **kwargs) -> None: + super().__init__(*args, **kwargs) + self.fields["language"].queryset = Language.objects.filter( + language="P", is_deprecated=False + ) + class Meta: model = Venv - fields = ["name"] + fields = ["name", "language"] + labels = {"language": "Python version"} diff --git a/tin/apps/venvs/migrations/0006_venv_language.py b/tin/apps/venvs/migrations/0006_venv_language.py new file mode 100644 index 00000000..72fed809 --- /dev/null +++ b/tin/apps/venvs/migrations/0006_venv_language.py @@ -0,0 +1,76 @@ +# Generated by Django 4.2.16 on 2024-11-11 02:11 + +from django.db import migrations, models +import django.db.models.deletion + + +def venv_default_to_python310(apps, schema_editor): + """Defaults venvs created previously to use Python 3.10 at /usr/bin/python3.10.""" + + Language = apps.get_model("assignments", "Language") + db_alias = schema_editor.connection.alias + python_310, created = Language.objects.using(db_alias).get_or_create( + name="Python 3.10", + executable="/usr/bin/python3.10", + language="P", + version=310, + ) + + Venv = apps.get_model("venvs", "Venv") + Venv.objects.using(db_alias).filter(language__isnull=True).update(language=python_310) + + # avoid creating a useless row + if created and not python_310.venv_set.exists(): + python_310.delete() + + +def delete_python_310_from_venvs(apps, schema_editor): + Language = apps.get_model("assignments", "Language") + db_alias = schema_editor.connection.alias + python_310 = ( + Language.objects.using(db_alias) + .filter( + name="Python 3.10", + executable="/usr/bin/python3.10", + language="P", + version=310, + is_deprecated=False, + ) + .first() + ) + + if python_310 is not None: + Venv = apps.get_model("venvs", "Venv") + Venv.objects.filter(language=python_310).update(language=None) + + +# fmt: off +class Migration(migrations.Migration): + dependencies = [ + ("assignments", "0033_language"), + ("venvs", "0005_auto_20240328_0033"), + ] + + operations = [ + migrations.AddField( + model_name="venv", + name="language", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="venv_set", + to="assignments.language", + ), + ), + migrations.RunPython(venv_default_to_python310, delete_python_310_from_venvs), + migrations.AlterField( + model_name="venv", + name="language", + field=models.ForeignKey( + null=False, + on_delete=django.db.models.deletion.CASCADE, + related_name="venv_set", + to="assignments.language", + ), + ), + ] diff --git a/tin/apps/venvs/models.py b/tin/apps/venvs/models.py index fa6b6323..9d436b9d 100644 --- a/tin/apps/venvs/models.py +++ b/tin/apps/venvs/models.py @@ -50,6 +50,13 @@ class Venv(models.Model): max_length=OUTPUT_MAX_LENGTH, default="", null=False, blank=True ) + language = models.ForeignKey( + "assignments.Language", + on_delete=models.CASCADE, + related_name="venv_set", + null=False, + ) + objects = VenvQuerySet.as_manager() def __str__(self): diff --git a/tin/apps/venvs/tasks.py b/tin/apps/venvs/tasks.py index ab7798e3..ec1154a2 100644 --- a/tin/apps/venvs/tasks.py +++ b/tin/apps/venvs/tasks.py @@ -2,10 +2,8 @@ import logging import subprocess -import sys from celery import shared_task -from django.conf import settings from .models import Venv, VenvCreationError @@ -18,14 +16,13 @@ def create_venv(venv_id): success = False try: + python = venv.language.executable try: res = subprocess.run( [ - sys.executable, + python, "-m", - "virtualenv", - "-p", - settings.SUBMISSION_PYTHON, + "venv", "--", venv.path, ], diff --git a/tin/apps/venvs/views.py b/tin/apps/venvs/views.py index 7df6e09e..a6a5701c 100644 --- a/tin/apps/venvs/views.py +++ b/tin/apps/venvs/views.py @@ -3,6 +3,7 @@ from django import http from django.shortcuts import get_object_or_404, redirect, render +from ..assignments.models import Language from ..auth.decorators import teacher_or_superuser_required from .forms import VenvForm from .models import Venv @@ -66,7 +67,7 @@ def create_view(request): create_venv.delay(venv.id) return redirect("venvs:show", venv.id) else: - form = VenvForm() + form = VenvForm(initial={"language": Language.objects.filter(language="P").first()}) return render( request, "venvs/edit_create.html", diff --git a/tin/settings/__init__.py b/tin/settings/__init__.py index c213a0cf..dc7d18c5 100644 --- a/tin/settings/__init__.py +++ b/tin/settings/__init__.py @@ -304,11 +304,6 @@ VENV_FILE_SIZE_LIMIT = 1 * 1000 * 1000 * 1000 # 1 GB -# Spaces and special characters may not be handled correctly -# Not importing correctly - specified directly in apps/submissions/tasks.py -# as of 8/3/2022, 2022ldelwich -SUBMISSION_PYTHON = "/usr/bin/python3.10" - SUBMISSION_NAMESERVERS = ["198.38.16.40", "198.38.16.41"] # Users may only have this many submissions running diff --git a/tin/tests/fixtures.py b/tin/tests/fixtures.py index 2cd66aa3..0a5f7cb7 100644 --- a/tin/tests/fixtures.py +++ b/tin/tests/fixtures.py @@ -1,12 +1,15 @@ from __future__ import annotations +import platform import shutil +import sys from pathlib import Path import pytest from django.utils import timezone import tin.tests.create_users as users +from tin.apps.assignments.models import Language from tin.apps.courses.models import Course PASSWORD = "Made with <3 by 2027adeshpan" @@ -88,13 +91,25 @@ def course(teacher, student): @pytest.fixture -def assignment(course): +def python(): + major, minor, _micro = platform.python_version_tuple() + return Language.objects.create( + name=f"Python {platform.python_version()}", + executable=sys.executable, + language="P", + version=int(f"{major}{minor}"), + ) + + +@pytest.fixture +def assignment(course, python): """Creates an :class:`.Assignment` in :func:`~.course`""" data = { "name": "Write a Shader", "description": "See https://learnopengl.com/Getting-started/Shaders", "points_possible": "300", "due": timezone.now(), + "language_details": python, } return course.assignments.create(**data)