Skip to content

Commit 019adbd

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

12 files changed

Lines changed: 1275 additions & 332 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)