Skip to content

Commit eb379a8

Browse files
committed
Fix inefficient query for all submissions page. Implement search feature to limit results. Add download submissions functionality for search results. Fix unit test issues with django-colortag rendering and add unit tests for tag rendering.
Fixes #1448
1 parent 56297c6 commit eb379a8

12 files changed

Lines changed: 1323 additions & 325 deletions

File tree

course/api/views.py

Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
import datetime
2+
from io import BytesIO
3+
import zipfile
14
from typing import Any, Dict, List, Union
25

36
from rest_framework import filters, viewsets, status, mixins
@@ -9,13 +12,15 @@
912
from rest_framework.permissions import IsAdminUser
1013
from django.db.models import Q, QuerySet
1114
from django.http import Http404
15+
from django.http.response import FileResponse
1216
from django.utils import timezone
1317
from django.utils.text import format_lazy
1418
from django.utils.translation import gettext_lazy as _
1519

1620
from aplus.api import api_reverse
1721
from edit_course.operations.configure import configure_from_url
1822
from exercise.cache.content import ModuleContent, LearningObjectContent
23+
from exercise.models import Submission
1924
from lib.api.constants import REGEX_INT, REGEX_INT_ME
2025
from lib.api.filters import FieldValuesFilter
2126
from lib.api.mixins import ListSerializerMixin, MeUserMixin
@@ -183,6 +188,173 @@ def send_mail(self, request, *args, **kwargs):
183188
return Response()
184189
return Response(_("SEND_EMAIL_FAILED"))
185190

191+
@action(
192+
detail=True,
193+
methods=['get'],
194+
url_path='submissions/zip',
195+
url_name='submissions-zip',
196+
)
197+
def submissions_zip(self, request, *args, **kwargs): # pylint: disable=too-many-locals # noqa: MC0001
198+
if not self.instance.is_course_staff(request.user):
199+
return Response(
200+
'Only course staff can download submissions via this API',
201+
status=status.HTTP_403_FORBIDDEN,
202+
)
203+
204+
def parse_csv_param(param_name):
205+
value = request.query_params.get(param_name, '').strip()
206+
if not value:
207+
return []
208+
return [item.strip() for item in value.split(',') if item.strip()]
209+
210+
student_id = request.query_params.get('student_id', '').strip()
211+
submission_status = request.query_params.get('status', '').strip()
212+
exercise_ids = parse_csv_param('exercise_id')
213+
submitter_name = request.query_params.get('submitter_name', '').strip()
214+
start_time = request.query_params.get('start_time', '').strip()
215+
end_time = request.query_params.get('end_time', '').strip()
216+
tag_ids = parse_csv_param('tag_id')
217+
late_penalty = request.query_params.get('late_penalty', '').strip()
218+
assessed_manually = request.query_params.get('assessed_manually', '').strip()
219+
220+
filters = Q(exercise__course_module__course_instance=self.instance.id)
221+
222+
if student_id:
223+
filters &= Q(submitters__id=student_id)
224+
225+
if submission_status:
226+
if submission_status == 'not_ready':
227+
filters &= ~Q(status='ready')
228+
else:
229+
filters &= Q(status=submission_status)
230+
231+
if exercise_ids:
232+
filters &= Q(exercise_id__in=exercise_ids)
233+
234+
if submitter_name:
235+
filters &= (
236+
Q(submitters__user__first_name__icontains=submitter_name)
237+
| Q(submitters__user__last_name__icontains=submitter_name)
238+
| Q(submitters__user__username__icontains=submitter_name)
239+
| Q(submitters__student_id__icontains=submitter_name)
240+
)
241+
242+
if tag_ids:
243+
filters &= Q(submission_taggings__tag_id__in=tag_ids)
244+
245+
if late_penalty:
246+
if late_penalty == 'yes':
247+
filters &= Q(late_penalty_applied__isnull=False)
248+
elif late_penalty == 'no':
249+
filters &= Q(late_penalty_applied__isnull=True)
250+
251+
if assessed_manually:
252+
if assessed_manually == 'yes':
253+
filters &= Q(grader__isnull=False)
254+
elif assessed_manually == 'no':
255+
filters &= Q(grader__isnull=True)
256+
257+
if start_time:
258+
try:
259+
start_dt = datetime.datetime.fromisoformat(start_time)
260+
if timezone.is_naive(start_dt):
261+
start_dt = timezone.make_aware(start_dt)
262+
filters &= Q(submission_time__gte=start_dt)
263+
except (ValueError, TypeError):
264+
pass
265+
266+
if end_time:
267+
try:
268+
end_dt = datetime.datetime.fromisoformat(end_time)
269+
if timezone.is_naive(end_dt):
270+
end_dt = timezone.make_aware(end_dt)
271+
filters &= Q(submission_time__lte=end_dt)
272+
except (ValueError, TypeError):
273+
pass
274+
275+
submissions = (
276+
Submission.objects.filter(filters)
277+
.distinct()
278+
.order_by('submission_time', 'id')
279+
.select_related('exercise')
280+
.prefetch_related('submitters', 'files')
281+
)
282+
283+
def get_group_id(submission):
284+
group_id = None
285+
if submission.meta_data and 'group' in submission.meta_data:
286+
group_id = submission.meta_data['group']
287+
if group_id is None and submission.submission_data:
288+
for item in submission.submission_data:
289+
if isinstance(item, (list, tuple)) and len(item) > 1 and item[0] == '_aplus_group':
290+
group_id = item[1]
291+
break
292+
return group_id
293+
294+
zip_buffer = BytesIO()
295+
submitter_submission_count = {}
296+
with zipfile.ZipFile(zip_buffer, 'w') as zip_file:
297+
info_csv = (
298+
'filename,label,created_at,original_name,points,submission_id,'
299+
'submitter_name,exercise_id,exercise_name,exercise_form_name,submission_index\n'
300+
)
301+
302+
for submission in submissions:
303+
submitters = list(submission.submitters.all())
304+
student_ids = sorted([str(submitter.student_id) for submitter in submitters])
305+
submitters_string = '+'.join(student_ids)
306+
submitted_files = list(submission.files.all())
307+
if not submitted_files:
308+
continue
309+
310+
count_key = (submission.exercise_id, submitters_string)
311+
submitter_submission_count[count_key] = submitter_submission_count.get(count_key, 0) + 1
312+
submission_num = submitter_submission_count[count_key]
313+
314+
group_id = None
315+
if len(submitters) > 1:
316+
group_id = get_group_id(submission)
317+
if group_id is not None:
318+
try:
319+
group_id = int(group_id)
320+
except ValueError:
321+
group_id = None
322+
323+
submission_time = submission.submission_time.strftime('%Y-%m-%d %H:%M:%S %z')
324+
points = submission.service_points
325+
submission_id = submission.id
326+
submitter_name_value = ';'.join(
327+
submitter.user.get_full_name() for submitter in submitters
328+
)
329+
330+
exercise_info = submission.exercise.exercise_info or {}
331+
exercise_name = str(submission.exercise)
332+
exercise_form_name = ';'.join(list((exercise_info.get('form_i18n') or {}).keys()))
333+
334+
label = f'group{group_id}' if group_id is not None else submitters_string
335+
336+
for index, submitted_file in enumerate(submitted_files, start=1):
337+
filename = (
338+
f'exercise{submission.exercise_id}_{submitters_string}_'
339+
f'file{index}_submission{submission_num}'
340+
)
341+
original_name = submitted_file.filename
342+
try:
343+
with submitted_file.file_object.file.open('rb') as file_handle:
344+
zip_file.writestr(filename, file_handle.read())
345+
info_csv += (
346+
f'{filename},{label},{submission_time},{original_name},{points},'
347+
f'{submission_id},{submitter_name_value},{submission.exercise_id},{exercise_name},'
348+
f'{exercise_form_name},{submission_num}\n'
349+
)
350+
except OSError:
351+
continue
352+
353+
zip_file.writestr('info.csv', info_csv)
354+
355+
zip_buffer.seek(0)
356+
return FileResponse(zip_buffer, as_attachment=True, filename='submissions.zip')
357+
186358

187359
class CourseExercisesViewSet(NestedViewSetMixin,
188360
CourseModuleResourceMixin,

0 commit comments

Comments
 (0)