diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9dc948bb..65529f1d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,6 +7,10 @@ defaults: run: shell: bash +env: + # testing on windows + PYTHONUTF=1 + jobs: format: runs-on: ubuntu-latest diff --git a/pyproject.toml b/pyproject.toml index 8717547c..d64c717b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -65,30 +65,12 @@ select = [ "UP", ] ignore = [ - # default arguments for timezone.now() - "B008", - # null=True on CharField/TextField - "DJ001", - # No __str__ method on Model - "DJ008", # Django order of model methods "DJ012", - # ambiguous variable name - "E741", # branching "PLR09", - # avoid magic numbers - "PLR2004", - # loop variables overwritten by assignment - "PLW2901", - # Use ternary operator (x if cond else y) - "RUF005", # mutable class attrs annotated as typing.ClassVar "RUF012", - # implicit Optional - "RUF013", - # use format specifiers instead of percent format - "UP031", ] [tool.ruff.format] diff --git a/tin/apps/assignments/forms.py b/tin/apps/assignments/forms.py index b3cf1209..8c8fb584 100644 --- a/tin/apps/assignments/forms.py +++ b/tin/apps/assignments/forms.py @@ -1,7 +1,7 @@ from __future__ import annotations from logging import getLogger -from typing import Dict, Iterable, Tuple +from typing import Iterable from django import forms from django.conf import settings @@ -33,20 +33,20 @@ def __init__(self, course, *args, **kwargs): # prevent description from getting too big self.fields["description"].widget.attrs.update({"id": "description"}) - def get_sections(self) -> Iterable[Dict[str, str | Tuple[str, ...] | bool]]: + def get_sections(self) -> Iterable[dict[str, str | tuple[str, ...] | bool]]: for section in self.Meta.sections: if section["name"]: # operate on copy so errors on refresh don't happen - section = section.copy() - section["fields"] = tuple(self[field] for field in section["fields"]) - yield section + new_section = section.copy() + new_section["fields"] = tuple(self[field] for field in new_section["fields"]) + yield new_section - def get_main_section(self) -> Dict[str, str | Tuple[str, ...]]: + def get_main_section(self) -> dict[str, str | tuple[str, ...]]: for section in self.Meta.sections: if section["name"] == "": - section = section.copy() - section["fields"] = tuple(self[field] for field in section["fields"]) - return section + new_section = section.copy() + new_section["fields"] = tuple(self[field] for field in new_section["fields"]) + return new_section logger.error(f"Could not find main section for assignment {self}") return {"fields": ()} @@ -127,7 +127,7 @@ class Meta: "filename": "Clarify which file students need to upload (including the file " "extension). For Java assignments, this also sets the name of the " "saved submission file.", - "markdown": "This allows adding images, code blocks, or hyperlinks to the assignment description.", + "markdown": "This allows adding images, code blocks, or hyperlinks to the assignment description.", # noqa: E501 "venv": "If set, Tin will run the student's code in this virtual environment.", "grader_has_network_access": 'If unset, this effectively disables "Give submissions ' 'internet access" below. If set, it increases the amount ' diff --git a/tin/apps/assignments/models.py b/tin/apps/assignments/models.py index b3047da1..a95843b7 100644 --- a/tin/apps/assignments/models.py +++ b/tin/apps/assignments/models.py @@ -54,9 +54,9 @@ def filter_editable(self, user): def upload_grader_file_path(assignment, _): # pylint: disable=unused-argument assert assignment.id is not None if assignment.language == "P": - return "assignment-{}/grader.py".format(assignment.id) + return f"assignment-{assignment.id}/grader.py" else: - return "assignment-{}/Grader.java".format(assignment.id) + return f"assignment-{assignment.id}/Grader.java" class Assignment(models.Model): @@ -169,7 +169,7 @@ def save_grader_file(self, grader_text: str) -> None: stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, encoding="utf-8", - universal_newlines=True, + text=True, check=True, ) except FileNotFoundError as e: @@ -203,7 +203,7 @@ def list_files(self) -> List[Tuple[int, str, str, int, datetime.datetime]]: def save_file(self, file_text: str, file_name: str) -> None: self.make_assignment_dir() - fpath = os.path.join(settings.MEDIA_ROOT, "assignment-{}".format(self.id), file_name) + fpath = os.path.join(settings.MEDIA_ROOT, f"assignment-{self.id}", file_name) os.makedirs(os.path.dirname(fpath), exist_ok=True) @@ -220,7 +220,7 @@ def save_file(self, file_text: str, file_name: str) -> None: stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, encoding="utf-8", - universal_newlines=True, + text=True, check=True, ) except FileNotFoundError as e: @@ -449,7 +449,7 @@ class MossResult(models.Model): user_id = models.CharField(max_length=20) date = models.DateTimeField(auto_now_add=True) - url = models.URLField(max_length=200, null=True, blank=True) + url = models.URLField(max_length=200, blank=True) status = models.CharField(max_length=1024, default="", null=False, blank=True) @property @@ -476,7 +476,7 @@ def run_action(command: List[str]) -> str: stdout=subprocess.PIPE, stderr=subprocess.STDOUT, encoding="utf-8", - universal_newlines=True, + text=True, ) except FileNotFoundError as e: logger.error("File not found: %s", e) @@ -492,8 +492,8 @@ class FileAction(models.Model): courses = models.ManyToManyField(Course, related_name="file_actions") command = models.CharField(max_length=1024) - match_type = models.CharField(max_length=1, choices=MATCH_TYPES, null=True, blank=True) - match_value = models.CharField(max_length=100, null=True, blank=True) + match_type = models.CharField(max_length=1, choices=MATCH_TYPES, blank=True) + match_value = models.CharField(max_length=100, blank=True) case_sensitive_match = models.BooleanField(default=False) is_sandboxed = models.BooleanField(default=True) diff --git a/tin/apps/assignments/views.py b/tin/apps/assignments/views.py index 9b24cb0e..8e15f920 100644 --- a/tin/apps/assignments/views.py +++ b/tin/apps/assignments/views.py @@ -1,6 +1,5 @@ import csv import datetime -import json import logging import os import subprocess @@ -105,13 +104,12 @@ def show_view(request, assignment_id): Period.objects.filter(course=course), id=int(period) ) student_list = active_period.students.all().order_by("last_name") + elif period == "all": + active_period = "all" + student_list = course.students.all().order_by("last_name") else: - if period == "all": - active_period = "all" - student_list = course.students.all().order_by("last_name") - else: - active_period = "none" - student_list = [] + active_period = "none" + student_list = [] for student in student_list: period = student.periods.filter(course=assignment.course) @@ -237,13 +235,12 @@ def edit_view(request, assignment_id): if quiz_type == "-1": if hasattr(assignment, "quiz"): assignment.quiz.delete() + elif hasattr(assignment, "quiz"): + assignment.quiz.action = quiz_type + assignment.save() + assignment.quiz.save() else: - if hasattr(assignment, "quiz"): - assignment.quiz.action = quiz_type - assignment.save() - assignment.quiz.save() - else: - Quiz.objects.create(assignment=assignment, action=quiz_type) + Quiz.objects.create(assignment=assignment, action=quiz_type) return redirect("assignments:show", assignment.id) @@ -533,44 +530,24 @@ def submit_view(request, assignment_id): file_form = FileSubmissionForm(request.POST, request.FILES) file_errors = ( "You have made too many submissions too quickly. You will be able to " - "re-submit in {}.".format(end_delta) + f"re-submit in {end_delta}." ) else: text_form = TextSubmissionForm(request.POST) text_errors = ( "You have made too many submissions too quickly. You will be able to " - "re-submit in {}.".format(end_delta) + f"re-submit in {end_delta}." ) - else: - if request.FILES.get("file"): - if request.FILES["file"].size <= settings.SUBMISSION_SIZE_LIMIT: - file_form = FileSubmissionForm(request.POST, request.FILES) - if file_form.is_valid(): - try: - submission_text = request.FILES["file"].read().decode() - except UnicodeDecodeError: - file_errors = "Please don't upload binary files." - else: - submission = Submission() - submission.assignment = assignment - submission.student = student - submission.save_file(submission_text) - submission.save() - - assignment.check_rate_limit(student) - - submission.create_backup_copy(submission_text) - - run_submission.delay(submission.id) - return redirect("assignments:show", assignment.id) - else: - file_errors = "That file's too large. Are you sure it's a Python program?" - else: - text_form = TextSubmissionForm(request.POST) - if text_form.is_valid(): - submission_text = text_form.cleaned_data["text"] - if len(submission_text) <= settings.SUBMISSION_SIZE_LIMIT: - submission = text_form.save(commit=False) + elif request.FILES.get("file"): + if request.FILES["file"].size <= settings.SUBMISSION_SIZE_LIMIT: + file_form = FileSubmissionForm(request.POST, request.FILES) + if file_form.is_valid(): + try: + submission_text = request.FILES["file"].read().decode() + except UnicodeDecodeError: + file_errors = "Please don't upload binary files." + else: + submission = Submission() submission.assignment = assignment submission.student = student submission.save_file(submission_text) @@ -582,8 +559,27 @@ def submit_view(request, assignment_id): run_submission.delay(submission.id) return redirect("assignments:show", assignment.id) - else: - text_errors = "Submission too large" + else: + file_errors = "That file's too large. Are you sure it's a Python program?" + else: + text_form = TextSubmissionForm(request.POST) + if text_form.is_valid(): + submission_text = text_form.cleaned_data["text"] + if len(submission_text) <= settings.SUBMISSION_SIZE_LIMIT: + submission = text_form.save(commit=False) + submission.assignment = assignment + submission.student = student + submission.save_file(submission_text) + submission.save() + + assignment.check_rate_limit(student) + + submission.create_backup_copy(submission_text) + + run_submission.delay(submission.id) + return redirect("assignments:show", assignment.id) + else: + text_errors = "Submission too large" return render( request, @@ -660,8 +656,7 @@ def quiz_view(request, assignment_id): ): text_form = TextSubmissionForm(request.POST) text_errors = ( - "You may only have a maximum of {} submission{} running at the same " - "time".format( + "You may only have a maximum of {} submission{} running at the same " "time".format( settings.CONCURRENT_USER_SUBMISSION_LIMIT, "" if settings.CONCURRENT_USER_SUBMISSION_LIMIT == 1 else "s", ) @@ -676,7 +671,7 @@ def quiz_view(request, assignment_id): text_form = TextSubmissionForm(request.POST) text_errors = ( "You have made too many submissions too quickly. You will be able to re-submit" - "in {}.".format(end_delta) + f"in {end_delta}." ) else: text_form = TextSubmissionForm(request.POST) @@ -776,7 +771,7 @@ def scores_csv_view(request, assignment_id): if period == "all": students = course.students.all() - name = "assignment_{}_all_scores.csv".format(assignment.id) + name = f"assignment_{assignment.id}_all_scores.csv" elif course.period_set.exists(): period_obj = get_object_or_404(Period.objects.filter(course=course), id=int(period)) students = period_obj.students.all() @@ -787,7 +782,7 @@ def scores_csv_view(request, assignment_id): raise http.Http404 response = http.HttpResponse(content_type="text/csv") - response["Content-Disposition"] = "attachment; filename={}".format(name) + response["Content-Disposition"] = f"attachment; filename={name}" writer = csv.writer(response) writer.writerow(["Name", "Username", "Period", "Raw Score", "Final Score", "Formatted Grade"]) @@ -829,7 +824,7 @@ def download_submissions_view(request, assignment_id): if period == "all": students = course.students.all() - name = "assignment_{}_all_submissions.zip".format(assignment.id) + name = f"assignment_{assignment.id}_all_submissions.zip" elif course.period_set.exists(): period_obj = get_object_or_404(Period.objects.filter(course=course), id=int(period)) students = period_obj.students.all() @@ -853,7 +848,7 @@ def download_submissions_view(request, assignment_id): file_with_header = published_submission.file_text_with_header zf.writestr(f"{student.username}.{extension}", file_with_header) resp = http.HttpResponse(s.getvalue(), content_type="application/x-zip-compressed") - resp["Content-Disposition"] = "attachment; filename={}".format(name) + resp["Content-Disposition"] = f"attachment; filename={name}" return resp @@ -930,10 +925,8 @@ def download_log_view(request, assignment_id): try: res = subprocess.run( args, - stdin=subprocess.DEVNULL, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - universal_newlines=True, + capture_output=True, + text=True, check=True, ) except FileNotFoundError as e: @@ -943,8 +936,8 @@ def download_log_view(request, assignment_id): data = res.stdout response = http.HttpResponse(data, content_type="text/plain") - response["Content-Disposition"] = 'attachment; filename="{}-grader.log"'.format( - slugify(assignment.name) + response["Content-Disposition"] = ( + f'attachment; filename="{slugify(assignment.name)}-grader.log"' ) return response diff --git a/tin/apps/auth/oauth.py b/tin/apps/auth/oauth.py index 43b31644..08234a90 100644 --- a/tin/apps/auth/oauth.py +++ b/tin/apps/auth/oauth.py @@ -3,7 +3,7 @@ def get_username(strategy, details, *args, user=None, **kwargs): - result = social_get_username(strategy, details, user=user, *args, **kwargs) + result = social_get_username(strategy, details, *args, user=user, **kwargs) # if not hasattr(user, 'social_user'): # user.social_user return result diff --git a/tin/apps/courses/models.py b/tin/apps/courses/models.py index c45d4bca..9d10f0f1 100644 --- a/tin/apps/courses/models.py +++ b/tin/apps/courses/models.py @@ -45,7 +45,7 @@ def get_absolute_url(self): return reverse("courses:show", args=[self.id]) def get_teacher_str(self): - return ", ".join((t.last_name for t in self.teacher.all())) + return ", ".join(t.last_name for t in self.teacher.all()) def is_student_in_course(self, user): return user in self.students.all() diff --git a/tin/apps/courses/views.py b/tin/apps/courses/views.py index 452bfedd..a686c536 100644 --- a/tin/apps/courses/views.py +++ b/tin/apps/courses/views.py @@ -32,9 +32,9 @@ def index_view(request): unsubmitted_assignments = assignments.exclude(submissions__student=request.user) context["unsubmitted_assignments"] = unsubmitted_assignments - context["courses_with_unsubmitted_assignments"] = set( + context["courses_with_unsubmitted_assignments"] = { assignment.course for assignment in unsubmitted_assignments - ) + } now = timezone.now() due_soon_assignments = assignments.filter(due__gte=now, due__lte=now + timedelta(weeks=1)) @@ -187,12 +187,12 @@ def import_from_selected_course(request, course_id, other_course_id): old_assignment = Assignment.objects.get(id=old_id) if form.cleaned_data["copy_graders"] and old_assignment.grader_file: - with open(old_assignment.grader_file.path, "r") as f: + with open(old_assignment.grader_file.path) as f: assignment.save_grader_file(f.read()) # Save to new directory if form.cleaned_data["copy_files"]: for _, filename, path, _, _ in old_assignment.list_files(): - with open(path, "r") as f: + with open(path) as f: assignment.save_file(f.read(), filename) return redirect("courses:show", course.id) diff --git a/tin/apps/errors/views.py b/tin/apps/errors/views.py index d63e5bab..f0e5e930 100644 --- a/tin/apps/errors/views.py +++ b/tin/apps/errors/views.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- from django.shortcuts import render diff --git a/tin/apps/submissions/models.py b/tin/apps/submissions/models.py index 762993cc..ea055ba4 100644 --- a/tin/apps/submissions/models.py +++ b/tin/apps/submissions/models.py @@ -128,7 +128,7 @@ def points(self): def grade_percent(self): if self.points_received is None: return None - return "{:.2%}".format(self.points / self.points_possible) + return f"{self.points / self.points_possible:.2%}" @property def grade_percent_num(self): @@ -139,7 +139,10 @@ def grade_percent_num(self): @property def formatted_grade(self): if self.has_been_graded: - return f"{decimal_repr(self.points)} / {decimal_repr(self.points_possible)} ({self.grade_percent})" + return ( + f"{decimal_repr(self.points)} / {decimal_repr(self.points_possible)} " + f"({self.grade_percent})" + ) return "Not graded" @property @@ -179,7 +182,7 @@ def file_text(self): return None try: - with open(self.backup_file_path, "r") as f: + with open(self.backup_file_path) as f: file_text = f.read() except OSError: file_text = "[Error accessing submission file]" @@ -249,7 +252,7 @@ def save_file(self, submission_text: str) -> None: stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, encoding="utf-8", - universal_newlines=True, + text=True, check=True, ) except FileNotFoundError as e: @@ -289,7 +292,7 @@ def rerun_color(self): @property def channel_group_name(self) -> str: - return "submission-{}".format(self.id) + return f"submission-{self.id}" @property def is_latest(self): @@ -352,6 +355,9 @@ def get_published(cls, student, assignment): class Meta: get_latest_by = "submission__date_submitted" + def __str__(self) -> str: + return f"{type(self).__name__}({self.assignment!s})" + class Comment(models.Model): submission = models.ForeignKey(Submission, on_delete=models.CASCADE, related_name="comments") @@ -364,3 +370,6 @@ class Comment(models.Model): date = models.DateTimeField(auto_now_add=True) text = models.CharField(max_length=1024) point_override = models.DecimalField(max_digits=6, decimal_places=3, null=True, blank=True) + + def __str__(self) -> str: + return f"{type(self).__name__}({self.submission!s})" diff --git a/tin/apps/submissions/tasks.py b/tin/apps/submissions/tasks.py index b24a5795..c90a5222 100644 --- a/tin/apps/submissions/tasks.py +++ b/tin/apps/submissions/tasks.py @@ -26,7 +26,7 @@ def truncate_output(text, field_name): max_len = Submission._meta.get_field(field_name).max_length - return ("..." + text[-max_len + 5:]) if len(text) > max_len else text + return ("..." + text[-max_len + 5 :]) if len(text) > max_len else text @shared_task @@ -54,7 +54,7 @@ def run_submission(submission_id): stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, - universal_newlines=True, + text=True, check=True, ) except FileNotFoundError as e: @@ -96,7 +96,7 @@ def run_submission(submission_id): f_obj.write(wrapper_text) os.chmod(submission_wrapper_path, 0o700) - except IOError: + except OSError: submission.grader_output = ( "An internal error occurred. Please try again.\n" "If the problem persists, contact your teacher." @@ -155,7 +155,7 @@ def run_submission(submission_id): stdin=subprocess.DEVNULL, bufsize=0, cwd=os.path.dirname(grader_path), - preexec_fn=os.setpgrp, + preexec_fn=os.setpgrp, # noqa: PLW1509 env=env, ) as proc: start_time = time.time() @@ -239,7 +239,7 @@ def run_submission(submission_id): if errors and not errors.endswith("\n"): errors += "\n" - errors += "[Grader exited with status {}]".format(retcode) + errors += f"[Grader exited with status {retcode}]" submission.grader_output = truncate_output(output.replace("\0", ""), "grader_output") submission.grader_errors = truncate_output(errors.replace("\0", ""), "grader_errors") @@ -260,7 +260,9 @@ def run_submission(submission_id): score = submission.assignment.points_possible * Decimal(score[:-1]) / 100 else: score = Decimal(score) - if abs(score) < 1000: + + HAS_BEEN_GRADED_MAX = 1000 + if abs(score) < HAS_BEEN_GRADED_MAX: submission.points_received = score submission.has_been_graded = True finally: diff --git a/tin/apps/users/forms.py b/tin/apps/users/forms.py index f6aee0e2..d1c1c085 100644 --- a/tin/apps/users/forms.py +++ b/tin/apps/users/forms.py @@ -3,4 +3,4 @@ class UserMultipleChoiceField(forms.ModelMultipleChoiceField): def label_from_instance(self, user): # pylint: disable=arguments-differ - return "{} ({})".format(user.full_name, user.username) + return f"{user.full_name} ({user.username})" diff --git a/tin/apps/users/models.py b/tin/apps/users/models.py index 5b70566b..f56c564f 100644 --- a/tin/apps/users/models.py +++ b/tin/apps/users/models.py @@ -44,8 +44,9 @@ def api_request(self, url, params=None, refresh=True): social_auth = self.get_social_auth() params.update({"format": "json"}) params.update({"access_token": social_auth.access_token}) - res = requests.get("https://ion.tjhsst.edu/api/{}".format(url), params=params) - if res.status_code == 401: + res = requests.get(f"https://ion.tjhsst.edu/api/{url}", params=params) + INVALID_AUTH_HTTP_CODE = 401 + if res.status_code == INVALID_AUTH_HTTP_CODE: if refresh: try: self.get_social_auth().refresh_token(load_strategy()) diff --git a/tin/apps/venvs/models.py b/tin/apps/venvs/models.py index 49190aea..ac878414 100644 --- a/tin/apps/venvs/models.py +++ b/tin/apps/venvs/models.py @@ -1,10 +1,9 @@ import logging import os import subprocess -import sys from django.conf import settings -from django.db import IntegrityError, models +from django.db import models from django.urls import reverse from ... import sandboxing @@ -83,10 +82,8 @@ def list_packages(self): args, check=False, env=env, - stdin=subprocess.DEVNULL, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - universal_newlines=True, + capture_output=True, + text=True, ) except FileNotFoundError as e: logger.error("Cannot run processes: %s", e) @@ -130,9 +127,9 @@ def install_packages(self, pkgs): raise FileNotFoundError from e try: - self.package_installation_output = res.stdout.decode()[-self.OUTPUT_MAX_LENGTH:] + self.package_installation_output = res.stdout.decode()[-self.OUTPUT_MAX_LENGTH :] except UnicodeDecodeError: - self.package_installation_output = str(res.stdout)[-self.OUTPUT_MAX_LENGTH:] + self.package_installation_output = str(res.stdout)[-self.OUTPUT_MAX_LENGTH :] finally: self.installing_packages = False self.save() diff --git a/tin/apps/venvs/tasks.py b/tin/apps/venvs/tasks.py index 39ac833c..f4090166 100644 --- a/tin/apps/venvs/tasks.py +++ b/tin/apps/venvs/tasks.py @@ -6,7 +6,7 @@ from django.conf import settings -from .models import Venv, VenvCreationError, VenvExistsError +from .models import Venv, VenvCreationError logger = logging.getLogger(__name__) @@ -39,9 +39,7 @@ def create_venv(venv_id): if res.returncode != 0: raise VenvCreationError( - "Error creating virtual environment (return code {}): {}".format( - res.returncode, res.stdout - ) + f"Error creating virtual environment (return code {res.returncode}): {res.stdout}" ) venv.fully_created = True diff --git a/tin/apps/venvs/views.py b/tin/apps/venvs/views.py index 5bfdaf8a..d6f95058 100644 --- a/tin/apps/venvs/views.py +++ b/tin/apps/venvs/views.py @@ -1,7 +1,6 @@ from django import http from django.shortcuts import get_object_or_404, redirect, render -from ..assignments.models import Assignment from ..auth.decorators import teacher_or_superuser_required from .forms import VenvForm from .models import Venv diff --git a/tin/asgi.py b/tin/asgi.py index cb4ac322..06f0d9cd 100644 --- a/tin/asgi.py +++ b/tin/asgi.py @@ -23,7 +23,7 @@ django_asgi_app = get_asgi_application() -from .apps.submissions.consumers import SubmissionJsonConsumer # fmt: off +from .apps.submissions.consumers import SubmissionJsonConsumer # noqa: E402 class WebsocketCloseConsumer(WebsocketConsumer):