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 Aug 21, 2024
1 parent b86797f commit bb81025
Show file tree
Hide file tree
Showing 13 changed files with 245 additions and 11 deletions.
4 changes: 4 additions & 0 deletions tin/apps/assignments/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
FileAction,
Folder,
MossResult,
PerStudentData,
Quiz,
QuizLogMessage,
)
Expand Down Expand Up @@ -140,3 +141,6 @@ def match(self, obj):
elif obj.match_type == "C":
return f"*{obj.match_value}*"
return ""


admin.site.register(PerStudentData)
14 changes: 13 additions & 1 deletion tin/apps/assignments/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
from django.conf import settings

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

logger = getLogger(__name__)

Expand Down Expand Up @@ -66,6 +66,7 @@ class Meta:
"grader_timeout",
"grader_has_network_access",
"has_network_access",
"submission_cap",
"submission_limit_count",
"submission_limit_interval",
"submission_limit_cooldown",
Expand Down Expand Up @@ -136,6 +137,7 @@ class Meta:
"submission_limit_count",
"submission_limit_interval",
"submission_limit_cooldown",
"submission_cap",
),
"collapsed": True,
},
Expand Down Expand Up @@ -171,6 +173,7 @@ class Meta:
"instructions that need to be hidden until the student enters the monitored quiz environment.",
"quiz_description_markdown": "This allows adding images, code blocks, or hyperlinks to the quiz "
"description.",
"submission_cap": "The maximum number of submissions that can be made. It can be overridden on a per-student basis.",
}
widgets = {
"description": forms.Textarea(attrs={"cols": 30, "rows": 8}),
Expand Down Expand Up @@ -241,3 +244,12 @@ class Meta:
"name",
]
help_texts = {"name": "Note: Folders are ordered alphabetically."}


class PerStudentDataForm(forms.ModelForm):
class Meta:
model = PerStudentData
fields = [
"submission_cap",
]
help_texts = {"submission_cap": "The maximum number of submissions that can be made."}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# Generated by Django 4.2.14 on 2024-08-16 19:44

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.AddField(
model_name='assignment',
name='submission_cap',
field=models.PositiveSmallIntegerField(null=True, validators=[django.core.validators.MinValueValidator(1)]),
),
migrations.CreateModel(
name='PerStudentData',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('submission_cap', models.PositiveSmallIntegerField(default=None, null=True)),
('assignment', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='student_data', to='assignments.assignment')),
('student', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
],
),
]
43 changes: 43 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 @@ -172,6 +173,11 @@ class Assignment(models.Model):
validators=[MinValueValidator(10)],
)

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

last_action_output = models.CharField(max_length=16 * 1024, default="", null=False, blank=True)

is_quiz = models.BooleanField(default=False)
Expand All @@ -192,6 +198,23 @@ 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."""
# assignments can have infinite submissions, and so can teachers.
if not student.is_student:
return True
data, created = self.student_data.get_or_create(student=student)
if created:
data.submission_cap = self.submission_cap
data.save()

# note that this doesn't care about killed/incomplete submissions
submission_count = self.submissions.filter(student=student).count()
return submission_count < (data.submission_cap or float("inf"))

def find_student_data(self, student) -> PerStudentData:
return self.student_data.get_or_create(student=student)[0]

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 +394,26 @@ def quiz_issues_for_student(self, student) -> bool:
)


class PerStudentData(models.Model):
"""A collection of per-student data for each :class:`.Assignment`."""

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

student = models.ForeignKey(
get_user_model(),
on_delete=models.CASCADE,
)

submission_cap = models.PositiveSmallIntegerField(null=True, default=None)

def __str__(self):
return f"Override for {self.student} @ Assignment {self.assignment}"


class CooldownPeriod(models.Model):
assignment = models.ForeignKey(
Assignment,
Expand Down
55 changes: 55 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,7 @@ def test_create_assignment(client, course) -> None:
"submission_limit_cooldown": "30",
"is_quiz": False,
"quiz_action": "2",
"submission_cap": "100",
}
response = client.post(
reverse("assignments:add", args=[course.id]),
Expand Down Expand Up @@ -103,3 +104,57 @@ def test_csv_of_missing_assignment_graded(client, assignment, student):
assert float(raw) == max_points
assert float(final) == max_points
assert formatted == "150 / 300 (50.00%)"


@login("student")
@pytest.mark.parametrize("is_quiz", (True, False))
def test_submission_cap(client, assignment, student, is_quiz):
assignment.is_quiz = is_quiz
assignment.submission_cap = 1
assignment.save()
code = "print('hello, world')"
assignment.save_grader_file(code)

def submit():
url = "assignments:quiz" if is_quiz else "assignments:submit"
return client.post(
reverse(url, args=[assignment.id]),
{"text": "print('I hate fun')"},
)

response = submit()
assert response.status_code == 302, f"Expected status code 302, got {response}"

# but now we've passed the cap
response = submit()
assert response.status_code == 403, f"Expected status code 403, got {response}"


@login("student")
@pytest.mark.parametrize("is_quiz", (True, False))
def test_submission_cap_with_override(client, assignment, student, is_quiz):
assignment.is_quiz = is_quiz
assignment.submission_cap = 1
assignment.save()
code = "print('hello, world')"
assignment.save_grader_file(code)

# add student override
assignment.student_data.create(student=student, submission_cap=2)

def submit():
url = "assignments:quiz" if is_quiz else "assignments:submit"
return client.post(
reverse(url, args=[assignment.id]),
{"text": "print('I hate fun')"},
)

# first and second submission should be fine
response = submit()
assert response.status_code == 302, f"Expected status code 302, got {response}"
response = submit()
assert response.status_code == 302, f"Expected status code 302, got {response}"

# but now we've passed the cap for the student
response = submit()
assert response.status_code == 403, f"Expected status code 403, got {response}"
6 changes: 6 additions & 0 deletions tin/apps/assignments/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,12 @@
path("<int:assignment_id>/delete", views.delete_view, name="delete"),
path("<int:assignment_id>/grader", views.manage_grader_view, name="manage_grader"),
path("<int:assignment_id>/grader/download", views.download_grader_view, name="download_grader"),
path("<int:assignment_id>/manage_students", views.manage_students_view, name="manage_students"),
path(
"<int:assignment_id>/<int:student_id>/manage",
views.manage_student,
name="edit_student_data",
),
path("<int:assignment_id>/files", views.manage_files_view, name="manage_files"),
path(
"<int:assignment_id>/files/download/<int:file_id>",
Expand Down
43 changes: 41 additions & 2 deletions tin/apps/assignments/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
FolderForm,
GraderScriptUploadForm,
MossForm,
PerStudentDataForm,
TextSubmissionForm,
)
from .models import Assignment, CooldownPeriod, QuizLogMessage
Expand Down Expand Up @@ -71,6 +72,7 @@ def show_view(request, assignment_id):
"is_student": course.is_student_in_course(request.user),
"is_teacher": request.user in course.teacher.all(),
"quiz_accessible": quiz_accessible,
"within_submission_limit": assignment.within_submission_limit(request.user),
},
)
else:
Expand Down Expand Up @@ -274,6 +276,38 @@ def delete_view(request, assignment_id):
return redirect(reverse("courses:show", args=(course.id,)))


@teacher_or_superuser_required
def manage_students_view(request, assignment_id):
assignment = Assignment.objects.get(id=assignment_id)
course = assignment.course
return render(
request,
"assignments/manage_students.html",
{"students": course.students.all(), "assignment": assignment},
)


@teacher_or_superuser_required
def manage_student(request, assignment_id, student_id):
student = get_object_or_404(
get_user_model().objects.filter(is_student=True),
id=student_id,
)
assignment = get_object_or_404(Assignment, id=assignment_id)
data = assignment.find_student_data(student=student)
form = PerStudentDataForm(instance=data)
if request.method == "POST":
form = PerStudentDataForm(data=request.POST, instance=data)
if form.is_valid():
form.save()
return redirect("assignments:manage_students", assignment_id)
return render(
request,
"assignments/manage_student.html",
{"form": form, "user": student, "assignment": assignment},
)


@teacher_or_superuser_required
def manage_grader_view(request, assignment_id):
"""Uploads a grader for an assignment
Expand Down Expand Up @@ -535,6 +569,8 @@ def submit_view(request, assignment_id):
raise http.Http404

student = request.user
if not assignment.within_submission_limit(student):
return http.HttpResponseForbidden("Submission limit exceeded")

submissions = Submission.objects.filter(student=student, assignment=assignment)
latest_submission = submissions.latest() if submissions else None
Expand Down Expand Up @@ -704,6 +740,8 @@ def quiz_view(request, assignment_id):
raise http.Http404

student = request.user
if not assignment.within_submission_limit(student):
return http.HttpResponseForbidden("Submission limit exceeded")

submissions = Submission.objects.filter(student=student, assignment=assignment)
latest_submission = submissions.latest() if submissions else None
Expand Down Expand Up @@ -756,8 +794,9 @@ def quiz_view(request, assignment_id):

run_submission.delay(submission.id)
return redirect("assignments:quiz", assignment.id)
else:
text_errors = "Submission too large"

else:
text_errors = "Submission too large"

quiz_color = assignment.quiz_issues_for_student(request.user) and assignment.quiz_action == "1"

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 @@ -199,7 +199,7 @@ def file_text(self):
return None

try:
with open(self.backup_file_path) as f:
with open(self.backup_file_path, encoding="utf-8") as f:
file_text = f.read()
except OSError:
file_text = "[Error accessing submission file]"
Expand Down
5 changes: 3 additions & 2 deletions tin/apps/submissions/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ def truncate_output(text, field_name):


@shared_task
def run_submission(submission_id):
def run_submission(submission_id: int) -> None:
submission = Submission.objects.get(id=submission_id)

try:
Expand Down Expand Up @@ -80,7 +80,8 @@ def run_submission(submission_id):
"wrappers",
folder_name,
f"{submission.assignment.language}.txt",
)
),
encoding="utf-8",
) as wrapper_file:
wrapper_text = wrapper_file.read().format(
has_network_access=bool(submission.assignment.has_network_access),
Expand Down
12 changes: 8 additions & 4 deletions tin/static/css/base.css
Original file line number Diff line number Diff line change
Expand Up @@ -150,23 +150,26 @@ ul.no-list {
ul#course-list,
ul#assignment-list,
ul#venv-list,
ul#archive-list {
ul#archive-list,
ul.linebreak-list {
padding-left: 0px;
margin-left: 0px;
}

ul#course-list > li,
ul#assignment-list > li,
ul#venv-list > li,
ul#archive-list > li {
ul#archive-list > li,
ul.linebreak-list > li {
display: flex;
padding: 10px 0px;
border-top: 1px solid lightgray;
}

ul#course-list > li:last-child,
ul#assignment-list > li:last-child,
ul#venv-list > li:last-child {
ul#venv-list > li:last-child,
ul.linebreak-last > li:last-child {
border-bottom: 1px solid lightgray;
}

Expand Down Expand Up @@ -204,7 +207,8 @@ ul#venv-list > li .left {
ul#course-list > li .right,
ul#assignment-list > li .right,
ul#venv-list > li .right,
ul#archive-list > li .right {
ul#archive-list > li .right,
ul.linebreak-list > li .right {
margin-left: auto;
flex: 0 0 auto;
text-align: right;
Expand Down
Loading

0 comments on commit bb81025

Please sign in to comment.