Skip to content

Commit 84b9f97

Browse files
committed
Add submission invalidation
1 parent 71f3407 commit 84b9f97

10 files changed

Lines changed: 182 additions & 1 deletion

File tree

exercise/api/csv/views.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -282,6 +282,7 @@ def serialize_profiles(self, request: Request, profiles: QuerySet[UserProfile])
282282
.filter(exercise__in=ids, submitters__in=profiles)
283283
.exclude(status__in=(
284284
Submission.STATUS.UNOFFICIAL, Submission.STATUS.ERROR, Submission.STATUS.REJECTED,
285+
Submission.STATUS.INVALIDATED,
285286
))
286287
.values('submitters__user_id', 'exercise_id')
287288
.annotate(count=Count('id'))
@@ -454,7 +455,11 @@ def serialize_profiles(self, request: Request, profiles: QuerySet[UserProfile])
454455
ids = [e.id for e in exercises]
455456
points = CachedPoints(self.instance, request.user, self.is_course_staff)
456457
revealed_ids = get_revealed_exercise_ids(search_args, points)
457-
exclude_list = [Submission.STATUS.ERROR, Submission.STATUS.REJECTED]
458+
exclude_list = [
459+
Submission.STATUS.ERROR,
460+
Submission.STATUS.REJECTED,
461+
Submission.STATUS.INVALIDATED,
462+
]
458463
show_unofficial = request.GET.get('show_unofficial') == 'true'
459464
if not show_unofficial:
460465
exclude_list.append(Submission.STATUS.UNOFFICIAL)

exercise/management/commands/export_submissions.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -222,6 +222,7 @@ def handle(self, *args, **options): # noqa: MC0001
222222
Submission.STATUS.UNOFFICIAL,
223223
Submission.STATUS.ERROR,
224224
Submission.STATUS.REJECTED,
225+
Submission.STATUS.INVALIDATED,
225226
)).values(
226227
'submitters__user_id',
227228
'exercise_id',
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
# Generated by Django 4.2.29 on 2026-07-02 09:07
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
dependencies = [
9+
('exercise', '0051_revealrule_show_zero_points_immediately'),
10+
]
11+
12+
operations = [
13+
migrations.AlterField(
14+
model_name='submission',
15+
name='status',
16+
field=models.CharField(choices=[('error', 'STATUS_ERROR'), ('initialized', 'STATUS_INITIALIZED'), ('invalidated', 'STATUS_INVALIDATED'), ('ready', 'STATUS_READY'), ('rejected', 'STATUS_REJECTED'), ('unofficial', 'STATUS_UNOFFICIAL'), ('waiting', 'STATUS_WAITING')], default='initialized', max_length=32, verbose_name='LABEL_STATUS'),
17+
),
18+
]

exercise/staff_views.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -654,6 +654,36 @@ def post(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse:
654654
return self.redirect(self.submission.get_inspect_url())
655655

656656

657+
class SubmissionInvalidateView(SubmissionMixin, BaseRedirectView):
658+
"""A POST-only view that marks a submission as invalidated."""
659+
access_mode = ACCESS.TEACHER
660+
661+
def post(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse:
662+
if self.submission.status == Submission.STATUS.INVALIDATED:
663+
messages.info(self.request, _('Submission is already invalidated.'))
664+
elif self.submission.status != Submission.STATUS.READY:
665+
messages.error(self.request, _('Only ready submissions can be invalidated.'))
666+
else:
667+
self.submission.set_invalidated()
668+
self.submission.save()
669+
messages.success(self.request, _('Submission was invalidated.'))
670+
return self.redirect(self.submission.get_inspect_url())
671+
672+
673+
class SubmissionRevalidateView(SubmissionMixin, BaseRedirectView):
674+
"""A POST-only view that restores an invalidated submission to ready."""
675+
access_mode = ACCESS.TEACHER
676+
677+
def post(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse:
678+
if self.submission.status != Submission.STATUS.INVALIDATED:
679+
messages.info(self.request, _('Submission is not invalidated.'))
680+
else:
681+
self.submission.set_revalidated()
682+
self.submission.save()
683+
messages.success(self.request, _('Submission was re-validated.'))
684+
return self.redirect(self.submission.get_inspect_url())
685+
686+
657687
class SubmissionApprovalByModuleView(CourseInstanceMixin, BaseRedirectView):
658688
"""
659689
A POST-only view that approves a student's late or unofficial submissions

exercise/submission_models.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ def exclude_errors(self):
4646
return self.exclude(status__in=(
4747
Submission.STATUS.ERROR,
4848
Submission.STATUS.REJECTED,
49+
Submission.STATUS.INVALIDATED,
4950
))
5051

5152
def exclude_unofficial(self):
@@ -285,6 +286,7 @@ def exclude_errors(self):
285286
return self.exclude(status__in=(
286287
Submission.STATUS.ERROR,
287288
Submission.STATUS.REJECTED,
289+
Submission.STATUS.INVALIDATED,
288290
))
289291

290292
def exclude_unofficial(self):
@@ -361,6 +363,7 @@ class Submission(SubmissionProto, models.Model):
361363
('READY', 'ready', _('STATUS_READY')), # graded normally
362364
('ERROR', 'error', _('STATUS_ERROR')),
363365
('REJECTED', 'rejected', _('STATUS_REJECTED')), # missing fields etc
366+
('INVALIDATED', 'invalidated', _('STATUS_INVALIDATED')),
364367
('UNOFFICIAL', 'unofficial', _('STATUS_UNOFFICIAL')),
365368
# unofficial: graded after the deadline or after exceeding the submission limit
366369
])
@@ -666,6 +669,18 @@ def set_error(self):
666669
self.status = self.STATUS.ERROR
667670
self.clear_pending()
668671

672+
def set_invalidated(self):
673+
if self.status != self.STATUS.READY:
674+
raise ValueError("Only ready submissions can be invalidated")
675+
self.status = self.STATUS.INVALIDATED
676+
self.clear_pending()
677+
678+
def set_revalidated(self):
679+
if self.status != self.STATUS.INVALIDATED:
680+
raise ValueError("Only invalidated submissions can be re-validated")
681+
self.status = self.STATUS.READY
682+
self.clear_pending()
683+
669684
@property
670685
def is_assessed(self) -> bool:
671686
"""Return whether the submission has been manually assessed"""

exercise/templates/exercise/staff/_assessment_panel.html

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,30 @@
6363
</button>
6464
{% endif %}
6565
{% endif %}
66+
{% if is_teacher %}
67+
{% if submission.status == submission.STATUS.INVALIDATED %}
68+
<form class="d-flex flex-nowrap" method="post" action="{{ submission|url:'submission-revalidate' }}">
69+
{% csrf_token %}
70+
<button
71+
class="aplus-button--secondary aplus-button--sm"
72+
type="submit"
73+
>
74+
{% translate 'REVALIDATE_SUBMISSION' %}
75+
</button>
76+
</form>
77+
{% elif submission.status == submission.STATUS.READY %}
78+
<form class="d-flex flex-nowrap" method="post" action="{{ submission|url:'submission-invalidate' }}">
79+
{% csrf_token %}
80+
<button
81+
class="aplus-button--danger aplus-button--sm"
82+
type="submit"
83+
onclick="return confirm('{% translate 'INVALIDATE_SUBMISSION_CONFIRM' %}');"
84+
>
85+
{% translate 'INVALIDATE_SUBMISSION' %}
86+
</button>
87+
</form>
88+
{% endif %}
89+
{% endif %}
6690
<button
6791
data-bs-toggle="modal"
6892
data-bs-target="#details-modal"

exercise/tests.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -798,6 +798,56 @@ def test_exercise_staff_views(self) -> None:
798798
response = self.client.get(list_submissions_url)
799799
self.assertEqual(response.status_code, 403)
800800

801+
def test_submission_invalidate_view(self):
802+
invalidate_submission_url = self.submission.get_url('submission-invalidate')
803+
revalidate_submission_url = self.submission.get_url('submission-revalidate')
804+
self.submission.feedback = "grader feedback"
805+
self.submission.assistant_feedback = "assistant feedback"
806+
self.submission.grading_data = {"source": "grader"}
807+
self.submission.set_points(7, 10, no_penalties=True)
808+
self.submission.set_ready()
809+
self.submission.save()
810+
811+
self.client.login(username="testUser", password="testPassword")
812+
response = self.client.post(invalidate_submission_url)
813+
self.assertEqual(response.status_code, 403)
814+
815+
self.client.login(username="grader", password="graderPassword")
816+
response = self.client.post(invalidate_submission_url)
817+
self.assertEqual(response.status_code, 403)
818+
819+
self.client.login(username="staff", password="staffPassword")
820+
response = self.client.post(invalidate_submission_url)
821+
self.assertEqual(response.status_code, 302)
822+
823+
self.submission.refresh_from_db()
824+
self.assertEqual(self.submission.status, Submission.STATUS.INVALIDATED)
825+
self.assertEqual(self.submission.feedback, "grader feedback")
826+
self.assertEqual(self.submission.assistant_feedback, "assistant feedback")
827+
self.assertEqual(self.submission.grading_data, {"source": "grader"})
828+
829+
response = self.client.post(invalidate_submission_url)
830+
self.assertEqual(response.status_code, 302)
831+
self.submission.refresh_from_db()
832+
self.assertEqual(self.submission.status, Submission.STATUS.INVALIDATED)
833+
834+
self.client.login(username="grader", password="graderPassword")
835+
response = self.client.post(revalidate_submission_url)
836+
self.assertEqual(response.status_code, 403)
837+
838+
self.client.login(username="staff", password="staffPassword")
839+
response = self.client.post(revalidate_submission_url)
840+
self.assertEqual(response.status_code, 302)
841+
self.submission.refresh_from_db()
842+
self.assertEqual(self.submission.status, Submission.STATUS.READY)
843+
844+
self.submission.set_error()
845+
self.submission.save()
846+
response = self.client.post(invalidate_submission_url)
847+
self.assertEqual(response.status_code, 302)
848+
self.submission.refresh_from_db()
849+
self.assertEqual(self.submission.status, Submission.STATUS.ERROR)
850+
801851
def test_uploading_and_viewing_file(self):
802852
exercise = BaseExercise.objects.create(
803853
order=4,

exercise/urls.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,12 @@
6767
re_path(SUBMISSION_URL_PREFIX + r'approve/$',
6868
staff_views.SubmissionApprovalView.as_view(),
6969
name="submission-approve"),
70+
re_path(SUBMISSION_URL_PREFIX + r'invalidate/$',
71+
staff_views.SubmissionInvalidateView.as_view(),
72+
name="submission-invalidate"),
73+
re_path(SUBMISSION_URL_PREFIX + r'revalidate/$',
74+
staff_views.SubmissionRevalidateView.as_view(),
75+
name="submission-revalidate"),
7076
re_path(SUBMISSION_URL_PREFIX + r'inspect/$',
7177
staff_views.InspectSubmissionView.as_view(),
7278
name="submission-inspect"),

locale/en/LC_MESSAGES/django.po

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -706,6 +706,10 @@ msgstr "course hooks"
706706
msgid "STATUS_READY"
707707
msgstr "Ready"
708708

709+
#: exercise/submission_models.py
710+
msgid "STATUS_INVALIDATED"
711+
msgstr "Invalidated"
712+
709713
#: course/models.py exercise/exercise_models.py
710714
msgid "STATUS_UNLISTED"
711715
msgstr "Unlisted in table of contents"
@@ -4650,6 +4654,18 @@ msgstr "Add tagging"
46504654
msgid "APPROVE_SUBMISSION"
46514655
msgstr "Approve submission"
46524656

4657+
#: exercise/templates/exercise/staff/_assessment_panel.html
4658+
msgid "INVALIDATE_SUBMISSION"
4659+
msgstr "Invalidate submission"
4660+
4661+
#: exercise/templates/exercise/staff/_assessment_panel.html
4662+
msgid "INVALIDATE_SUBMISSION_CONFIRM"
4663+
msgstr "Are you sure you want to invalidate this submission?"
4664+
4665+
#: exercise/templates/exercise/staff/_assessment_panel.html
4666+
msgid "REVALIDATE_SUBMISSION"
4667+
msgstr "Re-validate submission"
4668+
46534669
#: exercise/templates/exercise/staff/_assessment_panel.html
46544670
#: exercise/templates/exercise/staff/_submission_data_modal.html
46554671
msgid "SUBMISSION_DETAILS"

locale/fi/LC_MESSAGES/django.po

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -711,6 +711,10 @@ msgstr "kurssikoukut"
711711
msgid "STATUS_READY"
712712
msgstr "Valmis"
713713

714+
#: exercise/submission_models.py
715+
msgid "STATUS_INVALIDATED"
716+
msgstr "Mitätöity"
717+
714718
#: course/models.py exercise/exercise_models.py
715719
msgid "STATUS_UNLISTED"
716720
msgstr "Näkymätön sisällysluettelossa"
@@ -4662,6 +4666,18 @@ msgstr "Lisää merkintä"
46624666
msgid "APPROVE_SUBMISSION"
46634667
msgstr "Hyväksy pisteytetyksi"
46644668

4669+
#: exercise/templates/exercise/staff/_assessment_panel.html
4670+
msgid "INVALIDATE_SUBMISSION"
4671+
msgstr "Mitätöi palautus"
4672+
4673+
#: exercise/templates/exercise/staff/_assessment_panel.html
4674+
msgid "INVALIDATE_SUBMISSION_CONFIRM"
4675+
msgstr "Haluatko varmasti mitätöidä tämän palautuksen?"
4676+
4677+
#: exercise/templates/exercise/staff/_assessment_panel.html
4678+
msgid "REVALIDATE_SUBMISSION"
4679+
msgstr "Poista palautuksen mitätöinti"
4680+
46654681
#: exercise/templates/exercise/staff/_assessment_panel.html
46664682
#: exercise/templates/exercise/staff/_submission_data_modal.html
46674683
msgid "SUBMISSION_DETAILS"

0 commit comments

Comments
 (0)