diff --git a/exercise/api/csv/views.py b/exercise/api/csv/views.py index 1b47037e1..6d6dd5eba 100644 --- a/exercise/api/csv/views.py +++ b/exercise/api/csv/views.py @@ -158,6 +158,8 @@ def submitted_field(submission, name): response = Response(data) if isinstance(getattr(request, 'accepted_renderer'), CSVRenderer): response['Content-Disposition'] = 'attachment; filename="submissions.csv"' + else: + response['Content-Disposition'] = 'attachment; filename="submissions.json"' return response def get_renderer_context(self): diff --git a/exercise/api/views.py b/exercise/api/views.py index 66aafa870..c41073e60 100644 --- a/exercise/api/views.py +++ b/exercise/api/views.py @@ -1,11 +1,13 @@ +from io import BytesIO +import zipfile + from aplus_auth.payload import Permission from django.core.exceptions import PermissionDenied -from django.http.response import HttpResponse +from django.http.response import HttpResponse, FileResponse from django.utils import timezone from django.utils.translation import gettext_lazy as _ from wsgiref.util import FileWrapper -from rest_framework import mixins, permissions, viewsets -from rest_framework import status +from rest_framework import mixins, permissions, status, viewsets from rest_framework.response import Response from rest_framework.reverse import reverse from rest_framework.decorators import action @@ -28,6 +30,7 @@ from course.api.mixins import CourseResourceMixin from exercise.async_views import _post_async_submission +from ..cache.points import ExercisePoints from ..models import ( Submission, SubmittedFile, @@ -322,6 +325,107 @@ def submit(self, request, *args, **kwargs): return Response(data, status=status_code, headers=headers) + @action( + detail=False, + url_path='zip', + url_name='zip', + methods=['get'], + ) + def zip(self, request, exercise_id, *args, **kwargs): # noqa: MC0001 + if not self.instance.is_teacher(request.user): + return Response( + 'Only a teacher can download submissions via this API', + status=status.HTTP_403_FORBIDDEN, + ) + exercise = None + try: + exercise = BaseExercise.objects.get(id=exercise_id) + except BaseExercise.DoesNotExist: + return Response('Exercise not found', status=status.HTTP_404_NOT_FOUND) + + best = request.query_params.get('best') == 'yes' + submissions = Submission.objects.filter(exercise__id=exercise_id).order_by('submission_time') + + def get_group_id(submission): + group_id = None + if 'group' in submission.meta_data: + group_id = submission.meta_data['group'] + if group_id is None: + for lst in submission.submission_data: + if '_aplus_group' in lst: + group_id = lst[1] + break + return group_id + + def handle_submission(submission, submitters, info_csv): + group_id = None + if len(submitters) > 1: + group_id = get_group_id(submission) + if group_id is not None: + try: + group_id = int(group_id) + except ValueError: + return info_csv + submission_time = submission.submission_time.strftime('%Y-%m-%d %H:%M:%S %z') + submitted_files = SubmittedFile.objects.filter(submission=submission) + student_ids = sorted([submitter.student_id for submitter in submitters]) + submitters_string = '+'.join(student_ids) + submission_num = list( + dict.fromkeys( # Remove duplicates + Submission.objects.filter(exercise__id=exercise_id, submitters__in=submitters) + .order_by('submission_time') + ) + ).index(submission) + 1 + for i, submitted_file in enumerate(submitted_files, start=1): + filename = f"{submitters_string}_file{i}_submission{submission_num}" + try: + with submitted_file.file_object.file.open('rb') as file: + zip_file.writestr(f'{filename}', file.read()) + if group_id is not None: + info_csv += f"{filename},group{group_id},{submission_time}\n" + else: + info_csv += f"{filename},{submitters_string},{submission_time}\n" + except OSError: + pass + return info_csv + + # Create a zip file in memory + zip_buffer = BytesIO() + with zipfile.ZipFile(zip_buffer, 'w') as zip_file: + info_csv = "filename,label,created_at\n" + if best: + unique_submitters = [] + for submission in submissions: + submitters = submission.submitters.all() + if any(self.instance.is_course_staff(submitter.user) for submitter in submitters): + # Skip staff submissions + continue + for submitter in submitters: + if submitter not in unique_submitters: + unique_submitters.append(submitter) + unique_submissions = [] + for submitter in unique_submitters: + submission_entry = ExercisePoints.get(exercise, submitter).best_submission + if submission_entry is None: + continue + submission = submissions.filter(id=submission_entry.id).first() + # Prevent duplicate best submissions due to group submissions + if submission not in unique_submissions: + unique_submissions.append(submission) + info_csv = handle_submission(submission, submission.submitters.all(), info_csv) + else: + for submission in submissions: + submitters = submission.submitters.all() + if any(self.instance.is_course_staff(submitter.user) for submitter in submitters): + # Skip staff submissions + continue + info_csv = handle_submission(submission, submitters, info_csv) + zip_file.writestr('info.csv', info_csv) + zip_buffer.seek(0) + + response = FileResponse(zip_buffer, as_attachment=True, filename='submissions.zip') + return response + def get_access_mode(self): # The API is not supposed to use the access mode permission in views, # but this is currently required so that enrollment exercises work in diff --git a/exercise/staff_views.py b/exercise/staff_views.py index 0b346b935..c52d0f562 100644 --- a/exercise/staff_views.py +++ b/exercise/staff_views.py @@ -92,6 +92,7 @@ def get_common_objects(self) -> None: for submission in qs: format_submission(submission, self.pseudonymize) + self.has_files = any(len(submission.files.all()) > 0 for submission in qs) self.limited = self.request.GET.get('limited', False) self.not_all_url = self.exercise.get_submission_list_url() + "?limited=true" self.all_url = self.exercise.get_submission_list_url() @@ -102,7 +103,16 @@ def get_common_objects(self) -> None: self.percentage_graded = ( f"{graded_submitters} / {total_submitters} ({percentage}%)" ) - self.note("limited", "not_all_url", "all_url", "submissions", "default_limit", "count", "percentage_graded") + self.note( + "has_files", + "limited", + "not_all_url", + "all_url", + "submissions", + "default_limit", + "count", + "percentage_graded", + ) class SubmissionsSummaryView(ExerciseBaseView): @@ -141,6 +151,8 @@ def get_common_objects(self) -> None: .order_by() ) + self.has_files = any(len(submission.files.all()) > 0 for submission in self.exercise.submissions.all()) + # Get a dict of submitters, accessed by their id. profiles = ( UserProfile.objects @@ -160,7 +172,7 @@ def get_common_objects(self) -> None: if submitter_id is not None: profile = profiles[submitter_id] self.submitters.append({'profile': profile, **submitter_summary}) - self.note('submitters') + self.note('submitters', 'has_files') class InspectSubmitterView(ExerciseBaseView, BaseRedirectView): diff --git a/exercise/templates/exercise/staff/_submissions_table.html b/exercise/templates/exercise/staff/_submissions_table.html index 98e4def44..d9a5575af 100644 --- a/exercise/templates/exercise/staff/_submissions_table.html +++ b/exercise/templates/exercise/staff/_submissions_table.html @@ -27,7 +27,7 @@ data-toggle="dropdown" id="download-data" > - {% translate "DOWNLOAD" %} + {% translate "DOWNLOAD_POINTS" %}
+ {% if has_files %} + + + + {% endif %} +{% exercise_text_stats exercise %} | @@ -140,4 +162,4 @@ {% include "exercise/staff/_regrade_submissions.html" %} - \ No newline at end of file + diff --git a/exercise/templates/exercise/staff/_submitters_table.html b/exercise/templates/exercise/staff/_submitters_table.html index 468bb09df..690617ae7 100644 --- a/exercise/templates/exercise/staff/_submitters_table.html +++ b/exercise/templates/exercise/staff/_submitters_table.html @@ -26,7 +26,7 @@ data-toggle="dropdown" id="download-data" > - {% translate "DOWNLOAD" %} + {% translate "DOWNLOAD_POINTS" %}
+ {% if has_files %} + + + + {% endif %} +{% exercise_text_stats exercise %} diff --git a/exercise/templatetags/exercise.py b/exercise/templatetags/exercise.py index 3498adf19..bac316bd6 100644 --- a/exercise/templatetags/exercise.py +++ b/exercise/templatetags/exercise.py @@ -348,6 +348,30 @@ def get_format_info(format): # pylint: disable=redefined-builtin def get_format_info_list(formats): return [get_format_info(format) for format in formats.split()] + +@register.simple_tag +def get_zip_info(type): # pylint: disable=redefined-builtin + zip_infos = { + 'all' : { + 'best': 'no', + 'verbose_name': _('ALL_SUBMISSIONS'), + }, + 'best': { + 'best': 'yes', + 'verbose_name': _('BEST_SUBMISSIONS'), + }, + } + try: + return zip_infos[type] + except KeyError as e: + raise RuntimeError('Invalid zip type: \'{}\''.format(type)) from e + + +@register.simple_tag +def get_zip_info_list(types): + return [get_zip_info(type) for type in types.split()] + + @register.simple_tag def get_regrade_info(index): regrade_infos = { diff --git a/locale/en/LC_MESSAGES/django.po b/locale/en/LC_MESSAGES/django.po index 2690036aa..b1747de86 100644 --- a/locale/en/LC_MESSAGES/django.po +++ b/locale/en/LC_MESSAGES/django.po @@ -1004,6 +1004,14 @@ msgstr "All results" msgid "VISUALIZATIONS" msgstr "Visualizations" +#: course/templates/course/_course_menu.html +msgid "UNPSEUDONYMIZE" +msgstr "Depseudonymize" + +#: course/templates/course/_course_menu.html +msgid "PSEUDONYMIZE" +msgstr "Pseudonymize" + #: course/templates/course/_course_menu.html news/templates/news/edit.html #: news/templates/news/list.html msgid "EDIT_NEWS" @@ -1025,14 +1033,6 @@ msgstr "Deadline deviations" msgid "SUBMISSION_DEVIATIONS" msgstr "Submission deviations" -#: course/templates/course/_course_menu.html -msgid "UNPSEUDONYMIZE" -msgstr "Depseudonymize" - -#: course/templates/course/_course_menu.html -msgid "PSEUDONYMIZE" -msgstr "Pseudonymize" - #: course/templates/course/_enroll_form.html msgid "ENROLL_THROUGH_SIS" msgstr "" @@ -4021,8 +4021,6 @@ msgid "VIEW_FILE -- %(filename)s" msgstr "View the file %(filename)s" #: exercise/templates/exercise/_file_link.html -#: exercise/templates/exercise/staff/_submissions_table.html -#: exercise/templates/exercise/staff/_submitters_table.html msgid "DOWNLOAD" msgstr "Download" @@ -4493,6 +4491,16 @@ msgstr "Start manual assessment" msgid "SUMMARY" msgstr "Summary" +#: exercise/templates/exercise/staff/_submissions_table.html +#: exercise/templates/exercise/staff/_submitters_table.html +msgid "DOWNLOAD_POINTS" +msgstr "Download points" + +#: exercise/templates/exercise/staff/_submissions_table.html +#: exercise/templates/exercise/staff/_submitters_table.html +msgid "DOWNLOAD_SUBMISSIONS" +msgstr "Download submissions" + #: exercise/templates/exercise/staff/_submissions_table.html msgid "NUM_OF_SUBMISSIONS_DISPLAYED -- %(count)s, %(url)s" msgstr "%(count)s submissions." @@ -4660,6 +4668,7 @@ msgstr "Edit submitters SID " #: exercise/templates/exercise/staff/inspect_submission.html #: exercise/templates/exercise/staff/list_submissions.html #: exercise/templates/exercise/staff/submissions_summary.html +#: exercise/templatetags/exercise.py msgid "ALL_SUBMISSIONS" msgstr "All submissions" @@ -4868,14 +4877,6 @@ msgstr "Submission" msgid "FILES_IN_SUBMISSION" msgstr "Files in this submission" -#: exercise/templates/exercise/submission_plain.html -#, python-format -msgid "SUBMISSION_GRADING_RETRIED -- %(num_retries)s, %(max_retries)s" -msgstr "" -"Grading this submission did not finish in time, and it has been " -"retried %(num_retries)s time(s). Retries continue until successful, " -"or until the limit of %(max_retries)s retries is reached." - #: exercise/templates/exercise/submission.html #: exercise/templates/exercise/submission_plain.html msgid "NO_GRADER_FEEDBACK_FOR_SUBMISSION" @@ -4893,6 +4894,14 @@ msgstr "Date" msgid "FILES" msgstr "Files" +#: exercise/templates/exercise/submission_plain.html +#, python-format +msgid "SUBMISSION_GRADING_RETRIED -- %(num_retries)s, %(max_retries)s" +msgstr "" +"Grading this submission did not finish in time, and it has been retried " +"%(num_retries)s time(s). Retries continue until successful, or until the " +"limit of %(max_retries)s retries is reached." + #: exercise/templatetags/exercise.py #, python-brace-format msgid "PERSONAL_EXTENDED_DEADLINE_PLURAL -- {count}" @@ -4923,6 +4932,10 @@ msgstr "Results are currently hidden." msgid "EXCEL_COMPATIBLE_CSV" msgstr "Excel compatible CSV" +#: exercise/templatetags/exercise.py +msgid "BEST_SUBMISSIONS" +msgstr "Best submissions" + #: exercise/templatetags/exercise.py msgid "INCOMPLETE" msgstr "Incomplete" @@ -5599,8 +5612,6 @@ msgid "LABEL_SEEN" msgstr "seen" #: notification/models.py -#, fuzzy -#| msgid "LABEL_GRADE" msgid "LABEL_REGRADE_WHEN_SEEN" msgstr "regrade when seen" diff --git a/locale/fi/LC_MESSAGES/django.po b/locale/fi/LC_MESSAGES/django.po index 1028ccd75..106cef476 100644 --- a/locale/fi/LC_MESSAGES/django.po +++ b/locale/fi/LC_MESSAGES/django.po @@ -1010,6 +1010,14 @@ msgstr "Kaikki pisteet" msgid "VISUALIZATIONS" msgstr "Visualisoinnit" +#: course/templates/course/_course_menu.html +msgid "UNPSEUDONYMIZE" +msgstr "Pseudonymisointi pois päältä" + +#: course/templates/course/_course_menu.html +msgid "PSEUDONYMIZE" +msgstr "Pseudonymisointi päälle" + #: course/templates/course/_course_menu.html news/templates/news/edit.html #: news/templates/news/list.html msgid "EDIT_NEWS" @@ -1031,14 +1039,6 @@ msgstr "Määräaikojen poikkeamat" msgid "SUBMISSION_DEVIATIONS" msgstr "Palautuskertojen poikkeamat" -#: course/templates/course/_course_menu.html -msgid "UNPSEUDONYMIZE" -msgstr "Pseudonymisointi pois päältä" - -#: course/templates/course/_course_menu.html -msgid "PSEUDONYMIZE" -msgstr "Pseudonymisointi päälle" - #: course/templates/course/_enroll_form.html msgid "ENROLL_THROUGH_SIS" msgstr "" @@ -4031,8 +4031,6 @@ msgid "VIEW_FILE -- %(filename)s" msgstr "Katsele tiedostoa %(filename)s" #: exercise/templates/exercise/_file_link.html -#: exercise/templates/exercise/staff/_submissions_table.html -#: exercise/templates/exercise/staff/_submitters_table.html msgid "DOWNLOAD" msgstr "Lataa" @@ -4508,6 +4506,16 @@ msgstr "Aloita manuaalinen arvostelu" msgid "SUMMARY" msgstr "Yhteenveto" +#: exercise/templates/exercise/staff/_submissions_table.html +#: exercise/templates/exercise/staff/_submitters_table.html +msgid "DOWNLOAD_POINTS" +msgstr "Lataa pisteet" + +#: exercise/templates/exercise/staff/_submissions_table.html +#: exercise/templates/exercise/staff/_submitters_table.html +msgid "DOWNLOAD_SUBMISSIONS" +msgstr "Lataa palautukset" + #: exercise/templates/exercise/staff/_submissions_table.html msgid "NUM_OF_SUBMISSIONS_DISPLAYED -- %(count)s, %(url)s" msgstr "%(count)s palautusta." @@ -4674,6 +4682,7 @@ msgstr "Muokkaa palauttajia SID " #: exercise/templates/exercise/staff/inspect_submission.html #: exercise/templates/exercise/staff/list_submissions.html #: exercise/templates/exercise/staff/submissions_summary.html +#: exercise/templatetags/exercise.py msgid "ALL_SUBMISSIONS" msgstr "Kaikki palautukset" @@ -4882,14 +4891,6 @@ msgstr "Palautus" msgid "FILES_IN_SUBMISSION" msgstr "Tiedostot tässä palautuksessa" -#: exercise/templates/exercise/submission_plain.html -#, python-format -msgid "SUBMISSION_GRADING_RETRIED -- %(num_retries)s, %(max_retries)s" -msgstr "" -"Palautuksen arvostelu ei valmistunut määräajassa, ja sitä on " -"yritetty uudelleen %(num_retries)s kertaa. Palautusta yritetään " -"automaattisesti enintään %(max_retries)s kertaa." - #: exercise/templates/exercise/submission.html #: exercise/templates/exercise/submission_plain.html msgid "NO_GRADER_FEEDBACK_FOR_SUBMISSION" @@ -4907,6 +4908,14 @@ msgstr "Päiväys" msgid "FILES" msgstr "Tiedostot" +#: exercise/templates/exercise/submission_plain.html +#, python-format +msgid "SUBMISSION_GRADING_RETRIED -- %(num_retries)s, %(max_retries)s" +msgstr "" +"Palautuksen arvostelu ei valmistunut määräajassa, ja sitä on yritetty " +"uudelleen %(num_retries)s kertaa. Palautusta yritetään automaattisesti " +"enintään %(max_retries)s kertaa." + #: exercise/templatetags/exercise.py #, python-brace-format msgid "PERSONAL_EXTENDED_DEADLINE_PLURAL -- {count}" @@ -4937,6 +4946,10 @@ msgstr "Tulokset on tällä hetkellä piilotettu." msgid "EXCEL_COMPATIBLE_CSV" msgstr "Excel-yhteensopiva CSV" +#: exercise/templatetags/exercise.py +msgid "BEST_SUBMISSIONS" +msgstr "Parhaat palautukset" + #: exercise/templatetags/exercise.py msgid "INCOMPLETE" msgstr "Keskeneräiset"