Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion exercise/api/csv/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -282,6 +282,7 @@ def serialize_profiles(self, request: Request, profiles: QuerySet[UserProfile])
.filter(exercise__in=ids, submitters__in=profiles)
.exclude(status__in=(
Submission.STATUS.UNOFFICIAL, Submission.STATUS.ERROR, Submission.STATUS.REJECTED,
Submission.STATUS.INVALIDATED,
))
.values('submitters__user_id', 'exercise_id')
.annotate(count=Count('id'))
Expand Down Expand Up @@ -454,7 +455,11 @@ def serialize_profiles(self, request: Request, profiles: QuerySet[UserProfile])
ids = [e.id for e in exercises]
points = CachedPoints(self.instance, request.user, self.is_course_staff)
revealed_ids = get_revealed_exercise_ids(search_args, points)
exclude_list = [Submission.STATUS.ERROR, Submission.STATUS.REJECTED]
exclude_list = [
Submission.STATUS.ERROR,
Submission.STATUS.REJECTED,
Submission.STATUS.INVALIDATED,
]
show_unofficial = request.GET.get('show_unofficial') == 'true'
if not show_unofficial:
exclude_list.append(Submission.STATUS.UNOFFICIAL)
Expand Down
1 change: 1 addition & 0 deletions exercise/management/commands/export_submissions.py
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,7 @@ def handle(self, *args, **options): # noqa: MC0001
Submission.STATUS.UNOFFICIAL,
Submission.STATUS.ERROR,
Submission.STATUS.REJECTED,
Submission.STATUS.INVALIDATED,
)).values(
'submitters__user_id',
'exercise_id',
Expand Down
18 changes: 18 additions & 0 deletions exercise/migrations/0052_alter_submission_status.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Generated by Django 4.2.29 on 2026-07-02 09:07

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('exercise', '0051_revealrule_show_zero_points_immediately'),
]

operations = [
migrations.AlterField(
model_name='submission',
name='status',
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'),
),
]
30 changes: 30 additions & 0 deletions exercise/staff_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -654,6 +654,36 @@ def post(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse:
return self.redirect(self.submission.get_inspect_url())


class SubmissionInvalidateView(SubmissionMixin, BaseRedirectView):
"""A POST-only view that marks a submission as invalidated."""
access_mode = ACCESS.TEACHER

def post(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse:
if self.submission.status == Submission.STATUS.INVALIDATED:
messages.info(self.request, _('SUBMISSION_ALREADY_INVALIDATED'))
elif self.submission.status != Submission.STATUS.READY:
messages.error(self.request, _('ONLY_READY_SUBMISSIONS_CAN_BE_INVALIDATED'))
else:
self.submission.set_invalidated()
self.submission.save()
messages.success(self.request, _('SUBMISSION_INVALIDATED'))
return self.redirect(self.submission.get_inspect_url())


class SubmissionRevalidateView(SubmissionMixin, BaseRedirectView):
"""A POST-only view that restores an invalidated submission to ready."""
access_mode = ACCESS.TEACHER

def post(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse:
if self.submission.status != Submission.STATUS.INVALIDATED:
messages.info(self.request, _('SUBMISSION_NOT_INVALIDATED'))
else:
self.submission.set_revalidated()
self.submission.save()
messages.success(self.request, _('SUBMISSION_REVALIDATED'))
return self.redirect(self.submission.get_inspect_url())


class SubmissionApprovalByModuleView(CourseInstanceMixin, BaseRedirectView):
"""
A POST-only view that approves a student's late or unofficial submissions
Expand Down
15 changes: 15 additions & 0 deletions exercise/submission_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ def exclude_errors(self):
return self.exclude(status__in=(
Submission.STATUS.ERROR,
Submission.STATUS.REJECTED,
Submission.STATUS.INVALIDATED,
))

def exclude_unofficial(self):
Expand Down Expand Up @@ -285,6 +286,7 @@ def exclude_errors(self):
return self.exclude(status__in=(
Submission.STATUS.ERROR,
Submission.STATUS.REJECTED,
Submission.STATUS.INVALIDATED,
))

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

def set_invalidated(self):
if self.status != self.STATUS.READY:
raise ValueError("Only ready submissions can be invalidated")
self.status = self.STATUS.INVALIDATED
self.clear_pending()

def set_revalidated(self):
if self.status != self.STATUS.INVALIDATED:
raise ValueError("Only invalidated submissions can be re-validated")
self.status = self.STATUS.READY
self.clear_pending()

@property
def is_assessed(self) -> bool:
"""Return whether the submission has been manually assessed"""
Expand Down
57 changes: 57 additions & 0 deletions exercise/templates/exercise/staff/_assessment_panel.html
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,17 @@
</button>
{% endif %}
{% endif %}
{% if is_teacher %}
{% if submission.status == submission.STATUS.INVALIDATED %}
<button type="button" class="aplus-button--secondary aplus-button--sm" data-bs-toggle="modal" data-bs-target="#revalidate-submission-modal">
{% translate 'REVALIDATE_SUBMISSION' %}
</button>
{% elif submission.status == submission.STATUS.READY %}
<button type="button" class="aplus-button--danger aplus-button--sm" data-bs-toggle="modal" data-bs-target="#invalidate-submission-modal">
{% translate 'INVALIDATE_SUBMISSION' %}
</button>
{% endif %}
{% endif %}
<button
data-bs-toggle="modal"
data-bs-target="#details-modal"
Expand Down Expand Up @@ -223,6 +234,52 @@
</div>
</div>

<div id="invalidate-submission-modal" class="modal" tabindex="-1" aria-labelledby="invalidate-submission-modal-label" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 id="invalidate-submission-modal-label" class="modal-title">{% translate 'INVALIDATE_SUBMISSION' %}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="{% translate 'CLOSE' %}"></button>
</div>
<div class="modal-body">
<form id="invalidate-submission-form" class="d-flex flex-nowrap" method="post" action="{{ submission|url:'submission-invalidate' }}">
{% csrf_token %}
<span>
{% translate 'INVALIDATE_SUBMISSION_CONFIRM' %}
</span>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{% translate 'CLOSE' %}</button>
<button type="submit" form="invalidate-submission-form" class="btn btn-primary">{% translate 'YES' %}</button>
</div>
</div>
</div>
</div>

<div id="revalidate-submission-modal" class="modal" tabindex="-1" aria-labelledby="revalidate-submission-modal-label" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 id="revalidate-submission-modal-label" class="modal-title">{% translate 'REVALIDATE_SUBMISSION' %}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="{% translate 'CLOSE' %}"></button>
</div>
<div class="modal-body">
<form id="revalidate-submission-form" class="d-flex flex-nowrap" method="post" action="{{ submission|url:'submission-revalidate' }}">
{% csrf_token %}
<span>
{% translate 'REVALIDATE_SUBMISSION_CONFIRM' %}
</span>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{% translate 'CLOSE' %}</button>
<button type="submit" form="revalidate-submission-form" class="btn btn-primary">{% translate 'YES' %}</button>
</div>
</div>
</div>
</div>

<link rel="stylesheet" href="{% static 'exercise/css/submission_tagging_dropdown.css' %}" />

<script>
Expand Down
50 changes: 50 additions & 0 deletions exercise/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -798,6 +798,56 @@ def test_exercise_staff_views(self) -> None:
response = self.client.get(list_submissions_url)
self.assertEqual(response.status_code, 403)

def test_submission_invalidate_view(self):
invalidate_submission_url = self.submission.get_url('submission-invalidate')
revalidate_submission_url = self.submission.get_url('submission-revalidate')
self.submission.feedback = "grader feedback"
self.submission.assistant_feedback = "assistant feedback"
self.submission.grading_data = {"source": "grader"}
self.submission.set_points(7, 10, no_penalties=True)
self.submission.set_ready()
self.submission.save()

self.client.login(username="testUser", password="testPassword")
response = self.client.post(invalidate_submission_url)
self.assertEqual(response.status_code, 403)

self.client.login(username="grader", password="graderPassword")
response = self.client.post(invalidate_submission_url)
self.assertEqual(response.status_code, 403)

self.client.login(username="staff", password="staffPassword")
response = self.client.post(invalidate_submission_url)
self.assertEqual(response.status_code, 302)

self.submission.refresh_from_db()
self.assertEqual(self.submission.status, Submission.STATUS.INVALIDATED)
self.assertEqual(self.submission.feedback, "grader feedback")
self.assertEqual(self.submission.assistant_feedback, "assistant feedback")
self.assertEqual(self.submission.grading_data, {"source": "grader"})

response = self.client.post(invalidate_submission_url)
self.assertEqual(response.status_code, 302)
self.submission.refresh_from_db()
self.assertEqual(self.submission.status, Submission.STATUS.INVALIDATED)

self.client.login(username="grader", password="graderPassword")
response = self.client.post(revalidate_submission_url)
self.assertEqual(response.status_code, 403)

self.client.login(username="staff", password="staffPassword")
response = self.client.post(revalidate_submission_url)
self.assertEqual(response.status_code, 302)
self.submission.refresh_from_db()
self.assertEqual(self.submission.status, Submission.STATUS.READY)

self.submission.set_error()
self.submission.save()
response = self.client.post(invalidate_submission_url)
self.assertEqual(response.status_code, 302)
self.submission.refresh_from_db()
self.assertEqual(self.submission.status, Submission.STATUS.ERROR)

def test_uploading_and_viewing_file(self):
exercise = BaseExercise.objects.create(
order=4,
Expand Down
6 changes: 6 additions & 0 deletions exercise/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,12 @@
re_path(SUBMISSION_URL_PREFIX + r'approve/$',
staff_views.SubmissionApprovalView.as_view(),
name="submission-approve"),
re_path(SUBMISSION_URL_PREFIX + r'invalidate/$',
staff_views.SubmissionInvalidateView.as_view(),
name="submission-invalidate"),
re_path(SUBMISSION_URL_PREFIX + r'revalidate/$',
staff_views.SubmissionRevalidateView.as_view(),
name="submission-revalidate"),
re_path(SUBMISSION_URL_PREFIX + r'inspect/$',
staff_views.InspectSubmissionView.as_view(),
name="submission-inspect"),
Expand Down
Loading
Loading