Skip to content

Commit

Permalink
Add an optional submission cap and allow per-student overrides
Browse files Browse the repository at this point in the history
  • Loading branch information
JasonGrace2282 committed Feb 21, 2025
1 parent 8b15637 commit ac16e69
Show file tree
Hide file tree
Showing 9 changed files with 404 additions and 6 deletions.
7 changes: 7 additions & 0 deletions tin/apps/assignments/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
MossResult,
Quiz,
QuizLogMessage,
SubmissionCap,
)


Expand Down Expand Up @@ -140,3 +141,9 @@ def match(self, obj):
elif obj.match_type == "C":
return f"*{obj.match_value}*"
return ""


@admin.register(SubmissionCap)
class SubmissionCapAdmin(admin.ModelAdmin):
list_display = ("assignment", "submission_cap", "student")
save_as = True
47 changes: 45 additions & 2 deletions tin/apps/assignments/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,17 @@
from django.conf import settings

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

logger = getLogger(__name__)


class AssignmentForm(forms.ModelForm):
due = forms.DateTimeInput()

submission_cap = forms.IntegerField(min_value=1)
submission_cap_after_due = forms.IntegerField(min_value=1, required=False)

def __init__(self, course, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields["folder"].queryset = Folder.objects.filter(course=course)
Expand Down Expand Up @@ -136,6 +139,8 @@ class Meta:
"submission_limit_count",
"submission_limit_interval",
"submission_limit_cooldown",
"submission_cap",
"submission_cap_after_due",
),
"collapsed": True,
},
Expand All @@ -150,6 +155,8 @@ class Meta:
'internet access" below. If set, it increases the amount '
"of time it takes to start up the grader (to about 1.5 "
"seconds). This is not recommended unless necessary.",
"submission_cap": "The maximum number of submissions that can be made, or empty for unlimited.",
"submission_cap_after_due": "The maximum number of submissions that can be made after the due date, or empty for unlimited.",
"submission_limit_count": "",
"submission_limit_interval": "Tin sets rate limits on submissions. If a student tries "
"to submit too many submissions in a given interval, "
Expand Down Expand Up @@ -178,7 +185,22 @@ class Meta:
}

def __str__(self) -> str:
return f"AssignmentForm(\"{self['name'].value()}\")"
return f'AssignmentForm("{self["name"].value()}")'

def save(self) -> Assignment:
assignment = super().save()
sub_cap = self.cleaned_data.get("submission_cap")
sub_cap_after_due = self.cleaned_data.get("submission_cap_after_due")
if sub_cap is not None or sub_cap_after_due is not None:
SubmissionCap.objects.update_or_create(
assignment=assignment,
student=None,
defaults={
"submission_cap": sub_cap,
"submission_cap_after_due": sub_cap_after_due,
},
)
return assignment


class GraderScriptUploadForm(forms.Form):
Expand Down Expand Up @@ -241,3 +263,24 @@ class Meta:
"name",
]
help_texts = {"name": "Note: Folders are ordered alphabetically."}


class SubmissionCapForm(forms.ModelForm):
def __init__(self, **kwargs) -> None:
super().__init__(**kwargs)

nonrequired = ("submission_cap", "submission_cap_after_due")
for f in nonrequired:
self.fields[f].required = False

class Meta:
model = SubmissionCap
fields = ["submission_cap", "submission_cap_after_due"]
help_texts = {
"submission_cap_after_due": (
"The submission cap after the due date (or empty for unlimited). "
"By default, this is the same as the submission cap."
),
"student": "The student to apply the cap to.",
}
labels = {"submission_cap_after_due": "Submission cap after due date"}
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# Generated by Django 4.2.16 on 2025-01-10 01:57

from django.conf import settings
import django.core.validators
from django.db import migrations, models
import django.db.models.deletion


class Migration(migrations.Migration):

dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('assignments', '0032_assignment_quiz_description_and_more'),
]

operations = [
migrations.CreateModel(
name='SubmissionCap',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('submission_cap', models.PositiveSmallIntegerField(null=True, validators=[django.core.validators.MinValueValidator(1)])),
('submission_cap_after_due', models.PositiveSmallIntegerField(null=True)),
('assignment', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='submission_caps', to='assignments.assignment')),
('student', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
],
),
migrations.AddConstraint(
model_name='submissioncap',
constraint=models.UniqueConstraint(fields=('student', 'assignment'), name='unique_type'),
),
migrations.AddConstraint(
model_name='submissioncap',
constraint=models.CheckConstraint(check=models.Q(('submission_cap__isnull', False), ('submission_cap_after_due__isnull', False), _connector='OR'), name='has_submission_cap', violation_error_message='Either the submission cap before or after the due date has to be set'),
),
]
98 changes: 98 additions & 0 deletions tin/apps/assignments/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,7 @@ class Assignment(models.Model):

has_network_access = models.BooleanField(default=False)

# WARNING: this is the rate limit
submission_limit_count = models.PositiveIntegerField(
default=90,
validators=[MinValueValidator(10)],
Expand All @@ -183,6 +184,8 @@ class Assignment(models.Model):

objects = AssignmentQuerySet.as_manager()

submission_caps: models.QuerySet[SubmissionCap]

def __str__(self):
return self.name

Expand All @@ -192,6 +195,59 @@ def get_absolute_url(self):
def __repr__(self):
return self.name

def within_submission_limit(self, student) -> bool:
"""Check if a student is within the submission limit for an assignment."""
# teachers should have infinite submissions
if not student.is_student or self.is_quiz or not self.submission_caps.exists():
return True

# note that this doesn't care about killed/incomplete submissions
submission_count = self.submissions.filter(student=student).count()

cap = self.find_submission_cap(student)
return submission_count < cap

def find_submission_cap(self, student) -> float:
"""Given a student, find the submission cap.
This takes into account student overrides, and due dates.
"""
if student.is_superuser or student.is_teacher:
return float("inf")
cap = self.find_student_override(student)
if cap is not None:
return cap.calculate_submission_cap()
elif timezone.localtime() > self.due:
return self.submission_cap_after_due()
return self.before_submission_cap()

def find_student_override(self, student) -> SubmissionCap | None:
"""Find an :class:`.SubmissionCap` for a student.
Returns ``None`` if no override exists.
"""
return self.submission_caps.filter(student=student).first()

def before_submission_cap(self) -> float:
"""Get the submission cap for an assignment before the due date.
Returns ``float("inf")`` if no cap is found.
"""
cap = self.submission_caps.filter(student__isnull=True).first()
if cap is not None and cap.submission_cap is not None:
return cap.submission_cap
return float("inf")

def submission_cap_after_due(self) -> float:
"""Get the submission cap after the due date.
Returns ``float("inf")`` if no cap is found.
"""
cap = self.submission_caps.filter(student__isnull=True).first()
if cap is not None and cap.submission_cap_after_due is not None:
return cap.submission_cap_after_due
return float("inf")

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}")
Expand Down Expand Up @@ -371,6 +427,48 @@ def quiz_issues_for_student(self, student) -> bool:
)


class SubmissionCap(models.Model):
"""Submission cap information"""

student = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
null=True,
)

assignment = models.ForeignKey(
Assignment,
on_delete=models.CASCADE,
related_name="submission_caps",
)

submission_cap = models.PositiveSmallIntegerField(null=True, validators=[MinValueValidator(1)])
submission_cap_after_due = models.PositiveSmallIntegerField(null=True)

class Meta:
constraints = [
# TODO: In django 5.0+ add nulls_distinct=False
models.UniqueConstraint(fields=["student", "assignment"], name="unique_type"),
models.CheckConstraint(
check=Q(submission_cap__isnull=False) | Q(submission_cap_after_due__isnull=False),
violation_error_message="Either the submission cap before or after the due date has to be set",
name="has_submission_cap",
),
]

def __str__(self) -> str:
return f"{type(self).__name__}(submission_cap={self.submission_cap})"

def calculate_submission_cap(self) -> float:
"""Get the submission cap for a given due date"""
if timezone.localtime() > self.assignment.due and self.submission_cap_after_due is not None:
return self.submission_cap_after_due
# This is the case where only submission_cap_after_due is set
if self.submission_cap is not None:
return self.submission_cap
return float("inf")


class CooldownPeriod(models.Model):
assignment = models.ForeignKey(
Assignment,
Expand Down
2 changes: 2 additions & 0 deletions tin/apps/assignments/tests/test_assignments.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ def test_create_assignment(client, course) -> None:
"submission_limit_cooldown": "30",
"is_quiz": False,
"quiz_action": "2",
"submission_cap": "100",
"submission_cap_after_due": "100",
}
response = client.post(
reverse("assignments:add", args=[course.id]),
Expand Down
Loading

0 comments on commit ac16e69

Please sign in to comment.