Skip to content

Commit 253dd24

Browse files
sayravaiihalaij1
authored andcommitted
Fix inefficient query for all submissions page and implement search feature
Fixes #1448
1 parent 2fa117d commit 253dd24

10 files changed

Lines changed: 1272 additions & 332 deletions

File tree

course/api/views.py

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

187360
class CourseExercisesViewSet(NestedViewSetMixin,
188361
CourseModuleResourceMixin,

0 commit comments

Comments
 (0)