Skip to content

Commit b330a4f

Browse files
markkuriekkinenmuhammadfaiz12
authored andcommitted
Add late and unofficial submission approval feature
Course staff may approve late and/or unofficial submissions so that any point penalties are removed from those submissions and they become normal, graded submissions (with the "ready" status). Single submissions may be approved or all submissions from one student in one assignment or one whole course module. The whole assignment or module approval may also be set to target only late submissions or only unofficial submissions. The user interface for this feature is visible in the inspect submission view when inspecting a late or unofficial submission. Part of apluslms#892. "The first pull request" in the comment: apluslms#892 (comment) This work started in Muhammad's pull request apluslms#991. Markku has refactored the code, improved the database queries and the details overall. Co-authored-by: Muhammad Wahjoe <faiz00.muhammad@gmail.com>
1 parent 2575dad commit b330a4f

9 files changed

Lines changed: 423 additions & 13 deletions

File tree

exercise/staff_views.py

Lines changed: 103 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,23 @@
1-
import json
21
import logging
3-
import time
42
from typing import Any, Dict
53

64
from django.contrib import messages
75
from django.contrib.auth.models import User
86
from django.core.exceptions import PermissionDenied, ValidationError
97
from django.core.validators import URLValidator
10-
from django.db.models import Count, F, Max, Prefetch, Q
8+
from django.db.models import Count, Max, Prefetch, Q
119
from django.http.request import HttpRequest
1210
from django.http.response import HttpResponse, JsonResponse, Http404
1311
from django.shortcuts import get_object_or_404
1412
from django.urls.base import reverse
1513
from django.utils import timezone
1614
from django.utils.text import format_lazy
17-
from django.utils.translation import gettext_lazy as _
15+
from django.utils.translation import gettext_lazy as _, ngettext
1816

1917
from authorization.permissions import ACCESS
2018
from course.viewbase import CourseInstanceBaseView, CourseInstanceMixin
2119
from course.models import (
20+
CourseModule,
2221
Enrollment,
2322
USERTAG_EXTERNAL,
2423
USERTAG_INTERNAL,
@@ -29,23 +28,20 @@
2928
from lib.viewbase import BaseRedirectView, BaseFormView, BaseView
3029
from notification.models import Notification
3130
from userprofile.models import UserProfile
32-
from .models import LearningObject
31+
from .models import BaseExercise, ExerciseTask, LearningObject, Submission
3332
from .forms import (
3433
SubmissionReviewForm,
3534
SubmissionCreateAndReviewForm,
3635
EditSubmittersForm,
3736
)
3837
from .tasks import regrade_exercises
39-
from .submission_models import Submission
40-
from .exercise_models import ExerciseTask
4138
from .viewbase import (
4239
ExerciseBaseView,
4340
SubmissionBaseView,
4441
SubmissionMixin,
4542
ExerciseMixin,
4643
ExerciseListBaseView,
4744
)
48-
from .exercise_models import BaseExercise
4945
from lib.logging import SecurityLog
5046

5147

@@ -526,3 +522,102 @@ def form_valid(self, form):
526522
def form_invalid(self, form):
527523
messages.error(self.request, _('FAILURE_SAVING_CHANGES'))
528524
return super().form_invalid(form)
525+
526+
527+
class SubmissionApprovalView(SubmissionMixin, BaseRedirectView):
528+
"""
529+
A POST-only view that approves a student's late or unofficial submission
530+
as a normal, graded submission. The late penalty of the submission is
531+
removed and the submission status is changed to ready.
532+
"""
533+
access_mode = ACCESS.GRADING
534+
535+
def post(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse:
536+
self.submission.approve_penalized_submission()
537+
self.submission.save()
538+
messages.success(self.request, format_lazy(
539+
_('SUBMISSION_APPROVAL_SUCCESS -- {points}, {max_points}'),
540+
points=self.submission.grade,
541+
max_points=self.submission.exercise.max_points,
542+
))
543+
return self.redirect(self.submission.get_inspect_url())
544+
545+
546+
class SubmissionApprovalByModuleView(CourseInstanceMixin, BaseRedirectView):
547+
"""
548+
A POST-only view that approves a student's late or unofficial submissions
549+
as normal, graded submissions in a whole module or exercise.
550+
"""
551+
user_kw = 'user_id'
552+
submission_kw = 'submission_id'
553+
access_mode = ACCESS.ASSISTANT
554+
555+
def get_resource_objects(self):
556+
super().get_resource_objects()
557+
558+
self.student = get_object_or_404(
559+
User,
560+
id=self.request.POST.get(self.user_kw),
561+
)
562+
self.submission = get_object_or_404(
563+
Submission,
564+
id=self.request.POST.get(self.submission_kw),
565+
)
566+
self.exercise = self.submission.exercise
567+
self.module = self.exercise.course_module
568+
569+
def post(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse:
570+
approve_scope = self.request.POST.get('approve-scope')
571+
approve_type = self.request.POST.get('approve-type')
572+
573+
if approve_scope == 'single-exercise':
574+
exercise_filter = {'exercise': self.exercise}
575+
disallow_assistant_grading = not self.exercise.allow_assistant_grading
576+
else:
577+
exercise_filter = {'exercise__course_module': self.module}
578+
disallow_assistant_grading = (BaseExercise.objects
579+
.filter(
580+
course_module=self.module,
581+
allow_assistant_grading=False,
582+
)
583+
.exists())
584+
585+
if self.is_assistant and disallow_assistant_grading:
586+
return self.permission_denied(
587+
message=_('SUBMISSION_APPROVAL_ASSISTANT_PERMISSION_DENIED_MSG'),
588+
)
589+
590+
submissions = (self.student.userprofile.submissions
591+
.exclude_errors()
592+
.defer_text_fields()
593+
.filter(**exercise_filter)
594+
)
595+
if approve_type == 'only-late':
596+
submissions = submissions.filter(
597+
late_penalty_applied__isnull=False,
598+
).exclude(
599+
status=Submission.STATUS.UNOFFICIAL,
600+
)
601+
elif approve_type == 'only-unofficial':
602+
submissions = submissions.filter(status=Submission.STATUS.UNOFFICIAL)
603+
else:
604+
# Both late and unofficial submissions.
605+
# Exclude normal submissions since there are usually many of those.
606+
submissions = submissions.filter(
607+
Q(late_penalty_applied__isnull=False)
608+
| Q(status=Submission.STATUS.UNOFFICIAL),
609+
)
610+
611+
count = 0
612+
for submission in submissions:
613+
submission.approve_penalized_submission()
614+
submission.save()
615+
count += 1
616+
617+
messages.success(self.request, ngettext(
618+
'SUBMISSION_APPROVAL_MULTIPLE_SUCCESS -- {count}',
619+
'SUBMISSION_APPROVAL_MULTIPLE_SUCCESS_PLURAL -- {count}',
620+
count,
621+
).format(count=count),
622+
)
623+
return self.redirect(self.submission.get_inspect_url())

exercise/submission_models.py

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,15 @@ def annotate_submitter_points(
124124
})
125125
)
126126

127+
def defer_text_fields(self):
128+
return self.defer(
129+
'feedback',
130+
'assistant_feedback',
131+
'grading_data',
132+
'submission_data',
133+
'meta_data',
134+
)
135+
127136

128137
class SubmissionManager(JWTAccessible["Submission"], models.Manager):
129138
_queryset_class = SubmissionQuerySet
@@ -411,6 +420,17 @@ def clean_post_parameters(self):
411420
del self._files
412421
del self._data
413422

423+
def approve_penalized_submission(self):
424+
"""
425+
Remove the late penalty and set the status to ready for this submission.
426+
427+
The points of this submission are reset based on the original service points.
428+
This method is used to approve a late or unofficial submission as
429+
a normal, graded submission.
430+
"""
431+
self.set_points(self.service_points, self.service_max_points, no_penalties=True)
432+
self.set_ready(approve_unofficial=True)
433+
414434
def set_points(self, points, max_points, no_penalties=False):
415435
"""
416436
Sets the points and maximum points for this submissions. If the given
@@ -472,9 +492,9 @@ def scale_grade_to(self, percentage):
472492
def set_waiting(self):
473493
self.status = self.STATUS.WAITING
474494

475-
def set_ready(self):
495+
def set_ready(self, approve_unofficial=False):
476496
self.grading_time = timezone.now()
477-
if self.status != self.STATUS.UNOFFICIAL or self.force_exercise_points:
497+
if self.status != self.STATUS.UNOFFICIAL or self.force_exercise_points or approve_unofficial:
478498
self.status = self.STATUS.READY
479499

480500
# Fire set hooks.
@@ -505,6 +525,12 @@ def lang(self):
505525
# Handle cases where database includes null or non dictionary json
506526
return None
507527

528+
@property
529+
def is_approvable(self):
530+
"""Is this submission late or unofficial so that it could be approved?"""
531+
return (self.late_penalty_applied is not None
532+
or self.status == self.STATUS.UNOFFICIAL)
533+
508534
ABSOLUTE_URL_NAME = "submission"
509535

510536
def get_url_kwargs(self):

exercise/templates/exercise/staff/_assessment_panel.html

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,18 @@
1919
{% endif %}
2020
</div>
2121
<div>
22+
{% if is_teacher or exercise.allow_assistant_grading %}
23+
{% if submission.is_approvable %}
24+
<button
25+
data-toggle="modal"
26+
data-target="#submission-approval-modal"
27+
class="aplus-button--secondary aplus-button--sm"
28+
type="button"
29+
>
30+
{% translate 'APPROVE_SUBMISSION' %}
31+
</button>
32+
{% endif %}
33+
{% endif %}
2234
<button
2335
data-toggle="modal"
2436
data-target="#details-modal"
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
{% load i18n %}
2+
{% load course %}
3+
4+
<div class="modal" id="submission-approval-modal" tabindex="-1" role="dialog"
5+
aria-labelledby="submission-approval-modal-label"
6+
>
7+
<div class="modal-dialog" role="document">
8+
<div class="modal-content">
9+
<div class="modal-header">
10+
<button type="button" class="close" data-dismiss="modal" aria-label="{% translate 'CLOSE' %}">
11+
<span aria-hidden="true">&times;</span>
12+
</button>
13+
<h4 class="modal-title" id="submission-approval-modal-label">
14+
{% translate 'SUBMISSION_APPROVAL_TITLE' %}
15+
</h4>
16+
</div>
17+
18+
<div class="modal-body">
19+
<p>{% translate 'SUBMISSION_APPROVAL_DESCRIPTION' %}</p>
20+
<p>{% translate 'SUBMISSION_APPROVAL_CONFIRMATION_TEXT' %}</p>
21+
<p>{{ submitter.name_with_student_id }}</p>
22+
<button
23+
id="submission-approval-multiple-toggle"
24+
class="aplus-button--secondary aplus-button--xs"
25+
>
26+
{% translate 'SUBMISSION_APPROVAL_APPROVE_MULTIPLE_BUTTON' %}
27+
</button>
28+
<br>
29+
<br>
30+
31+
<form
32+
method="post"
33+
id="approve-module-form"
34+
action="{% url 'submission-approve-module' course_slug=course.url instance_slug=instance.url %}"
35+
style="display: none"
36+
>
37+
{% csrf_token %}
38+
<input type="hidden" name="user_id" value="{{ submitter.id }}">
39+
<input type="hidden" name="submission_id" value="{{ submission.id }}">
40+
<p>{% translate 'SUBMISSION_APPROVAL_APPROVE_MULTIPLE_SELECT_SCOPE' %}</p>
41+
<label>
42+
<input type="radio" name="approve-scope" value="single-exercise" checked>
43+
{% blocktranslate trimmed with exercise=exercise|parse_localization %}
44+
SUBMISSION_APPROVAL_APPROVE_MULTIPLE_SCOPE_EXERCISE -- {{ exercise }}
45+
{% endblocktranslate %}
46+
</label>
47+
<br>
48+
<label>
49+
<input type="radio" name="approve-scope" value="whole-module">
50+
{% blocktranslate trimmed with module=module|parse_localization %}
51+
SUBMISSION_APPROVAL_APPROVE_MULTIPLE_SCOPE_MODULE -- {{ module }}
52+
{% endblocktranslate %}
53+
</label>
54+
<br>
55+
56+
<p>{% translate 'SUBMISSION_APPROVAL_APPROVE_MULTIPLE_SELECT_TYPE' %}</p>
57+
<label>
58+
<input type="radio" name="approve-type" value="only-late">
59+
{% translate 'SUBMISSION_APPROVAL_APPROVE_MULTIPLE_TYPE_LATE' %}
60+
</label>
61+
<br>
62+
<label>
63+
<input type="radio" name="approve-type" value="only-unofficial">
64+
{% translate 'SUBMISSION_APPROVAL_APPROVE_MULTIPLE_TYPE_UNOFFICIAL' %}
65+
</label>
66+
<br>
67+
<label>
68+
<input type="radio" name="approve-type" value="all" checked>
69+
{% translate 'SUBMISSION_APPROVAL_APPROVE_MULTIPLE_TYPE_ALL' %}
70+
</label>
71+
<br>
72+
<button class="aplus-button--default aplus-button--sm" type="submit">
73+
{% translate 'APPROVE_MULTIPLE_SUBMISSIONS' %}
74+
</button>
75+
</form>
76+
<form
77+
method="post"
78+
id="approve-singular-form"
79+
action="{{ submission|url:'submission-approve' }}"
80+
style="display: inline-block"
81+
>
82+
{% csrf_token %}
83+
<button class="aplus-button--default aplus-button--sm" type="submit">
84+
{% translate 'APPROVE_THIS_SUBMISSION' %}
85+
</button>
86+
</form>
87+
</div>
88+
</div>
89+
</div>
90+
</div>
91+
<script>
92+
$('#submission-approval-multiple-toggle').click(function() {
93+
/* One form is visible at a time and the button switches between them. */
94+
$('#approve-module-form').toggle();
95+
$('#approve-singular-form').toggle();
96+
});
97+
</script>

exercise/templates/exercise/staff/inspect_submission.html

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,7 @@
9797
</div>
9898
{% include "exercise/staff/_submission_data_modal.html" %}
9999
{% include "exercise/staff/_resubmit_modal.html" %}
100+
{% include "exercise/staff/_late_submission_approval_modal.html" %}
100101
{% endblock %}
101102

102103
{% block scripts %}

exercise/tests.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -572,6 +572,13 @@ def test_submission_late_penalty_applied(self):
572572
self.late_late_submission_when_late_allowed.set_points(5, 10)
573573
self.assertAlmostEqual(self.late_late_submission_when_late_allowed.late_penalty_applied, 0.2)
574574

575+
def test_submission_late_conversion(self):
576+
convert_submission_url = self.late_submission.get_url('submission-approve')
577+
response = self.client.get(convert_submission_url)
578+
self.assertEqual(response.status_code, 302)
579+
self.assertTrue(self.late_submission.late_penalty_applied is None)
580+
581+
575582
def test_early_submission(self):
576583
self.course_module_with_late_submissions_allowed.opening_time = self.tomorrow
577584
submission = Submission.objects.create(

exercise/urls.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,9 @@
5555
url(EXERCISE_URL_PREFIX + r'submitters/next-unassessed/$',
5656
staff_views.NextUnassessedSubmitterView.as_view(),
5757
name="submission-next-unassessed"),
58+
url(SUBMISSION_URL_PREFIX + r'approve/$',
59+
staff_views.SubmissionApprovalView.as_view(),
60+
name="submission-approve"),
5861
url(SUBMISSION_URL_PREFIX + r'inspect/$',
5962
staff_views.InspectSubmissionView.as_view(),
6063
name="submission-inspect"),
@@ -79,7 +82,9 @@
7982
url(EDIT_URL_PREFIX + r'fetch-metadata/$',
8083
staff_views.FetchMetadataView.as_view(),
8184
name="exercise-metadata"),
82-
85+
url(EDIT_URL_PREFIX + r'approve/module/$',
86+
staff_views.SubmissionApprovalByModuleView.as_view(),
87+
name="submission-approve-module"),
8388
url(EXERCISE_URL_PREFIX + r'plain/$',
8489
views.ExercisePlainView.as_view(),
8590
name="exercise-plain"),

0 commit comments

Comments
 (0)