From 9c7f97f4dd0ab7f6ef13a86f14fd19cf9a852987 Mon Sep 17 00:00:00 2001 From: Krishnan Shankar Date: Mon, 27 May 2024 16:03:32 -0400 Subject: [PATCH] Migrate fields/functionality from Quiz to Assignment (#32) Also rename LogMessage to QuizLogMessage --- tin/apps/assignments/admin.py | 26 ++++---- tin/apps/assignments/forms.py | 22 +++++-- ...zlogmessage_assignment_is_quiz_and_more.py | 65 +++++++++++++++++++ tin/apps/assignments/models.py | 47 ++++++++++---- tin/apps/assignments/tests.py | 12 +--- tin/apps/assignments/views.py | 62 ++++++------------ tin/templates/assignments/show.html | 3 - 7 files changed, 149 insertions(+), 88 deletions(-) create mode 100644 tin/apps/assignments/migrations/0030_quizlogmessage_assignment_is_quiz_and_more.py diff --git a/tin/apps/assignments/admin.py b/tin/apps/assignments/admin.py index ab516968..67d9285b 100644 --- a/tin/apps/assignments/admin.py +++ b/tin/apps/assignments/admin.py @@ -4,7 +4,15 @@ from django.contrib import admin -from .models import Assignment, CooldownPeriod, FileAction, Folder, LogMessage, MossResult, Quiz +from .models import ( + Assignment, + CooldownPeriod, + FileAction, + Folder, + MossResult, + Quiz, + QuizLogMessage, +) @admin.register(Folder) @@ -86,19 +94,15 @@ def visible(self, obj): return not obj.assignment.hidden -@admin.register(LogMessage) -class LogMessageAdmin(admin.ModelAdmin): +@admin.register(QuizLogMessage) +class QuizLogMessageAdmin(admin.ModelAdmin): date_hierarchy = "date" list_display = ("content", "assignment", "student", "date", "severity") list_filter = ("student", "severity") ordering = ("-date",) save_as = True - search_fields = ("quiz__assignment__name", "student__username", "content") - autocomplete_fields = ("quiz", "student") - - @admin.display(description="Assignment") - def assignment(self, obj): - return obj.quiz.assignment.name + search_fields = ("assignment__name", "student__username", "content") + autocomplete_fields = ("assignment", "student") @admin.register(MossResult) @@ -111,10 +115,6 @@ class MossResultAdmin(admin.ModelAdmin): search_fields = ("assignment__name", "url") autocomplete_fields = ("assignment",) - @admin.display(description="Assignment") - def assignment(self, obj): - return obj.quiz.assignment.name - @admin.display(description="Course") def course_name(self, obj): return obj.assignment.course.name diff --git a/tin/apps/assignments/forms.py b/tin/apps/assignments/forms.py index ef5e5041..66a7ec62 100644 --- a/tin/apps/assignments/forms.py +++ b/tin/apps/assignments/forms.py @@ -13,10 +13,7 @@ class AssignmentForm(forms.ModelForm): - QUIZ_ACTIONS = (("-1", "No"), ("0", "Log only"), ("1", "Color Change"), ("2", "Lock")) - due = forms.DateTimeInput() - is_quiz = forms.ChoiceField(choices=QUIZ_ACTIONS) def __init__(self, course, *args, **kwargs): super().__init__(*args, **kwargs) @@ -70,6 +67,8 @@ class Meta: "submission_limit_count", "submission_limit_interval", "submission_limit_cooldown", + "is_quiz", + "quiz_action", ] labels = { "markdown": "Use markdown?", @@ -93,7 +92,6 @@ class Meta: "markdown", "due", "points_possible", - "is_quiz", "hidden", ), }, @@ -109,7 +107,16 @@ class Meta: "collapsed": False, }, { - "name": "Submissions", + "name": "Quiz Options", + "description": "", + "fields": ( + "is_quiz", + "quiz_action", + ), + "collapsed": False, + }, + { + "name": "Other Settings", "description": "", "fields": ( "enable_grader_timeout", @@ -142,8 +149,9 @@ class Meta: "submission_limit_cooldown": 'This sets the length of the "cooldown" period after a ' "student exceeds the rate limit for submissions.", "folder": "If blank, assignment will show on the main classroom page.", - "is_quiz": "If set, Tin will take the selected action if a student clicks off of the " - "submission page.", + "is_quiz": "This forces students to submit through a page that monitors their actions.", + "quiz_action": "Tin will take the selected action if a student clicks off of the " + "quiz page.", } widgets = {"description": forms.Textarea(attrs={"cols": 30, "rows": 4})} diff --git a/tin/apps/assignments/migrations/0030_quizlogmessage_assignment_is_quiz_and_more.py b/tin/apps/assignments/migrations/0030_quizlogmessage_assignment_is_quiz_and_more.py new file mode 100644 index 00000000..296fc7aa --- /dev/null +++ b/tin/apps/assignments/migrations/0030_quizlogmessage_assignment_is_quiz_and_more.py @@ -0,0 +1,65 @@ +# Generated by Django 4.2.13 on 2024-05-27 04:12 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("assignments", "0029_assignment_markdown"), + ] + + operations = [ + migrations.CreateModel( + name="QuizLogMessage", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, primary_key=True, serialize=False, verbose_name="ID" + ), + ), + ("date", models.DateTimeField(auto_now_add=True)), + ("content", models.CharField(max_length=100)), + ("severity", models.IntegerField()), + ], + ), + migrations.AddField( + model_name="assignment", + name="is_quiz", + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name="assignment", + name="quiz_action", + field=models.CharField( + choices=[("0", "Log only"), ("1", "Color Change"), ("2", "Lock")], + default="2", + max_length=1, + ), + ), + migrations.DeleteModel( + name="LogMessage", + ), + migrations.AddField( + model_name="quizlogmessage", + name="assignment", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="log_messages", + to="assignments.assignment", + ), + ), + migrations.AddField( + model_name="quizlogmessage", + name="student", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="log_messages", + to=settings.AUTH_USER_MODEL, + ), + ), + ] diff --git a/tin/apps/assignments/models.py b/tin/apps/assignments/models.py index 8cfb0488..b7bacdb8 100644 --- a/tin/apps/assignments/models.py +++ b/tin/apps/assignments/models.py @@ -125,6 +125,10 @@ class Assignment(models.Model): last_action_output = models.CharField(max_length=16 * 1024, default="", null=False, blank=True) + is_quiz = models.BooleanField(default=False) + QUIZ_ACTIONS = (("0", "Log only"), ("1", "Color Change"), ("2", "Lock")) + quiz_action = models.CharField(max_length=1, choices=QUIZ_ACTIONS, default="2") + objects = AssignmentQuerySet.as_manager() def __str__(self): @@ -279,11 +283,23 @@ def grader_log_filename(self): else None ) - @property - def is_quiz(self): - if hasattr(self, "quiz"): - return self.quiz - return False + def quiz_open_for_student(self, student): + is_teacher = self.course.teacher.filter(id=student.id).exists() + if is_teacher or student.is_superuser: + return True + return not (self.quiz_ended_for_student(student) or self.quiz_locked_for_student(student)) + + def quiz_ended_for_student(self, student): + return self.log_messages.filter(student=student, content="Ended quiz").exists() + + def quiz_locked_for_student(self, student): + return self.quiz_issues_for_student(student) and self.quiz_action == "2" + + def quiz_issues_for_student(self, student): + return ( + sum(lm.severity for lm in self.log_messages.filter(student=student)) + >= settings.QUIZ_ISSUE_THRESHOLD + ) class CooldownPeriod(models.Model): @@ -335,6 +351,9 @@ def get_time_to_end(self) -> datetime.timedelta: ) +# WARNING: This model is deprecated and will be removed in the future. +# It is kept for backwards compatibility with existing data. +# All fields and methods have been migrated to the Assignment model. class Quiz(models.Model): QUIZ_ACTIONS = (("0", "Log only"), ("1", "Color Change"), ("2", "Lock")) @@ -358,7 +377,7 @@ def __repr__(self): def issues_for_student(self, student): return ( - sum(lm.severity for lm in self.log_messages.filter(student=student)) + sum(lm.severity for lm in self.assignment.log_messages.filter(student=student)) >= settings.QUIZ_ISSUE_THRESHOLD ) @@ -372,11 +391,13 @@ def locked_for_student(self, student): return self.issues_for_student(student) and self.action == "2" def ended_for_student(self, student): - return self.log_messages.filter(student=student, content="Ended quiz").exists() + return self.assignment.log_messages.filter(student=student, content="Ended quiz").exists() -class LogMessage(models.Model): - quiz = models.ForeignKey(Quiz, on_delete=models.CASCADE, related_name="log_messages") +class QuizLogMessage(models.Model): + assignment = models.ForeignKey( + Assignment, on_delete=models.CASCADE, related_name="log_messages" + ) student = models.ForeignKey( get_user_model(), on_delete=models.CASCADE, related_name="log_messages" ) @@ -386,15 +407,13 @@ class LogMessage(models.Model): severity = models.IntegerField() def __str__(self): - return f"{self.content} for {self.quiz}" + return f"{self.content} for {self.assignment} by {self.student}" def get_absolute_url(self): - return reverse( - "assignments:student_submission", args=(self.quiz.assignment.id, self.student.id) - ) + return reverse("assignments:student_submission", args=(self.assignment.id, self.student.id)) def __repr__(self): - return f"{self.content} for {self.quiz}" + return f"{self.content} for {self.assignment} by {self.student}" def moss_base_file_path(obj, _): # pylint: disable=unused-argument diff --git a/tin/apps/assignments/tests.py b/tin/apps/assignments/tests.py index 1c151e18..c8b04a43 100644 --- a/tin/apps/assignments/tests.py +++ b/tin/apps/assignments/tests.py @@ -1,6 +1,5 @@ from __future__ import annotations -import pytest from django.urls import reverse from tin.tests import is_redirect, teacher @@ -16,13 +15,11 @@ def test_create_folder(client, course) -> None: @teacher -@pytest.mark.parametrize("is_quiz", (-1, 0, 1, 2)) -def test_create_assignment(client, course, is_quiz) -> None: +def test_create_assignment(client, course) -> None: data = { "name": "Write a Vertex Shader", "description": "See https://learnopengl.com/Getting-started/Shaders", "language": "P", - "is_quiz": is_quiz, "filename": "vertex.glsl", "points_possible": "300", "due": "04/16/2025", @@ -30,6 +27,8 @@ def test_create_assignment(client, course, is_quiz) -> None: "submission_limit_count": "90", "submission_limit_interval": "30", "submission_limit_cooldown": "30", + "is_quiz": False, + "quiz_action": "2", } response = client.post( reverse("assignments:add", args=[course.id]), @@ -38,8 +37,3 @@ def test_create_assignment(client, course, is_quiz) -> None: assert is_redirect(response) assignment_set = course.assignments.filter(name__exact=data["name"]) assert assignment_set.count() == 1 - assignment = assignment_set.get() - if is_quiz != -1: - assert assignment.quiz.action == str(is_quiz) - else: - assert not hasattr(assignment, "quiz") diff --git a/tin/apps/assignments/views.py b/tin/apps/assignments/views.py index 6a23341d..b2d2ffe4 100644 --- a/tin/apps/assignments/views.py +++ b/tin/apps/assignments/views.py @@ -32,7 +32,7 @@ MossForm, TextSubmissionForm, ) -from .models import Assignment, CooldownPeriod, LogMessage, Quiz +from .models import Assignment, CooldownPeriod, QuizLogMessage from .tasks import run_moss logger = logging.getLogger(__name__) @@ -45,11 +45,11 @@ def show_view(request, assignment_id): :param request: The request :param assignment_id: The assignment id """ - assignment = get_object_or_404( + assignment: Assignment = get_object_or_404( Assignment.objects.filter_visible(request.user), id=assignment_id ) course = assignment.course - quiz_accessible = assignment.is_quiz and assignment.quiz.open_for_student(request.user) + quiz_accessible = assignment.is_quiz and assignment.quiz_open_for_student(request.user) if course.is_only_student_in_course(request.user): submissions = Submission.objects.filter(student=request.user, assignment=assignment) @@ -147,8 +147,8 @@ def show_view(request, assignment_id): period, latest_submission, graded_submission, - assignment.quiz.ended_for_student(student), - assignment.quiz.locked_for_student(student), + assignment.quiz_ended_for_student(student), + assignment.quiz_locked_for_student(student), ) ) @@ -200,10 +200,6 @@ def create_view(request, course_id): assignment.make_assignment_dir() - quiz_type = assignment_form.cleaned_data["is_quiz"] - if quiz_type != "-1": - Quiz.objects.create(assignment=assignment, action=quiz_type) - return redirect("assignments:show", assignment.id) else: assignment_form = AssignmentForm(course) @@ -222,35 +218,17 @@ def create_view(request, course_id): @teacher_or_superuser_required def edit_view(request, assignment_id): """Edits an assignment""" - assignment = get_object_or_404( + assignment: Assignment = get_object_or_404( Assignment.objects.filter_editable(request.user), id=assignment_id ) course = assignment.course - initial_is_quiz = -1 - if hasattr(assignment, "quiz"): - initial_is_quiz = assignment.quiz.action - - assignment_form = AssignmentForm( - course, instance=assignment, initial={"is_quiz": initial_is_quiz} - ) + assignment_form = AssignmentForm(course, instance=assignment) if request.method == "POST": assignment_form = AssignmentForm(course, data=request.POST, instance=assignment) if assignment_form.is_valid(): assignment_form.save() - - quiz_type = assignment_form.cleaned_data["is_quiz"] - 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: - Quiz.objects.create(assignment=assignment, action=quiz_type) - return redirect("assignments:show", assignment.id) return render( @@ -450,7 +428,7 @@ def file_action_view(request, assignment_id, action_id): @teacher_or_superuser_required def student_submissions_view(request, assignment_id, student_id): - assignment = get_object_or_404( + assignment: Assignment = get_object_or_404( Assignment.objects.filter_editable(request.user), id=assignment_id ) student = get_object_or_404(User, id=student_id) @@ -461,7 +439,7 @@ def student_submissions_view(request, assignment_id, student_id): published_submission = publishes.latest().submission if publishes else latest_submission log_messages = ( - assignment.quiz.log_messages.filter(student=student).order_by("date") + assignment.log_messages.filter(student=student).order_by("date") if assignment.is_quiz else None ) @@ -640,11 +618,11 @@ def rerun_view(request, assignment_id): @login_required def quiz_view(request, assignment_id): - assignment = get_object_or_404( + assignment: Assignment = get_object_or_404( Assignment.objects.filter_visible(request.user), id=assignment_id ) - if not assignment.is_quiz or not assignment.quiz.open_for_student(request.user): + if not assignment.is_quiz or not assignment.quiz_open_for_student(request.user): raise http.Http404 student = request.user @@ -703,7 +681,7 @@ def quiz_view(request, assignment_id): else: text_errors = "Submission too large" - quiz_color = assignment.quiz.issues_for_student(request.user) and assignment.quiz.action == "1" + quiz_color = assignment.quiz_issues_for_student(request.user) and assignment.quiz_action == "1" return render( request, @@ -732,15 +710,15 @@ def quiz_report_view(request, assignment_id): action = "no action" - if not assignment.quiz.ended_for_student(request.user): - LogMessage.objects.create( - quiz=assignment.quiz, student=request.user, content=content, severity=severity + if not assignment.quiz_ended_for_student(request.user): + QuizLogMessage.objects.create( + assignment=assignment, student=request.user, content=content, severity=severity ) if severity >= settings.QUIZ_ISSUE_THRESHOLD: - if assignment.quiz.action == "1": + if assignment.quiz_action == "1": action = "color" - elif assignment.quiz.action == "2": + elif assignment.quiz_action == "2": action = "lock" return http.JsonResponse({"action": action}) @@ -752,8 +730,8 @@ def quiz_end_view(request, assignment_id): Assignment.objects.filter_visible(request.user), id=assignment_id ) - LogMessage.objects.create( - quiz=assignment.quiz, student=request.user, content="Ended quiz", severity=0 + QuizLogMessage.objects.create( + assignment=assignment, student=request.user, content="Ended quiz", severity=0 ) return redirect("assignments:show", assignment.id) @@ -766,7 +744,7 @@ def quiz_clear_view(request, assignment_id, user_id): ) user = get_object_or_404(get_user_model(), id=user_id) - assignment.quiz.log_messages.filter(student=user).delete() + assignment.log_messages.filter(student=user).delete() return redirect("assignments:student_submission", assignment.id, user.id) diff --git a/tin/templates/assignments/show.html b/tin/templates/assignments/show.html index 2dddbf95..e787224c 100644 --- a/tin/templates/assignments/show.html +++ b/tin/templates/assignments/show.html @@ -28,9 +28,6 @@

{% if assignment.is_quiz %}[QUIZ] {% endif %}{{ assignment.name {% if is_teacher or request.user.is_superuser %} {% if request.user.is_superuser %} Admin - {% if assignment.is_quiz %} - Admin (Quiz) - {% endif %} {% endif %} Edit {% if assignment.grader_file %}