|
1
|
|
|
# Submission admin interface |
|
2
|
|
|
|
|
3
|
|
|
from django.contrib.admin import SimpleListFilter, ModelAdmin |
|
4
|
|
|
from django.utils.translation import ugettext_lazy as _ |
|
5
|
|
|
from django.db.models import Q |
|
6
|
|
|
from django.shortcuts import redirect |
|
7
|
|
|
from django.utils.safestring import mark_safe |
|
8
|
|
|
from django.http import HttpResponse |
|
9
|
|
|
from django.utils.html import format_html |
|
10
|
|
|
from opensubmit.models import Assignment, Submission, Grading |
|
11
|
|
|
from django.utils import timesince |
|
12
|
|
|
|
|
13
|
|
|
import io |
|
14
|
|
|
import zipfile |
|
15
|
|
|
|
|
16
|
|
|
|
|
17
|
|
|
def grading_file(submission): |
|
18
|
|
|
''' Determines if the submission has a grading file, |
|
19
|
|
|
leads to nice little icon in the submission overview. |
|
20
|
|
|
''' |
|
21
|
|
|
if submission.grading_file.name: |
|
22
|
|
|
return True |
|
23
|
|
|
else: |
|
24
|
|
|
return False |
|
25
|
|
|
|
|
26
|
|
|
|
|
27
|
|
|
grading_file.boolean = True # show nice little icon |
|
28
|
|
|
|
|
29
|
|
|
|
|
30
|
|
|
def test_results(submission): |
|
31
|
|
|
return "Foo" |
|
32
|
|
|
|
|
33
|
|
|
|
|
34
|
|
|
class SubmissionStateFilter(SimpleListFilter): |
|
35
|
|
|
|
|
36
|
|
|
''' This custom filter allows to filter the submissions according to their state. |
|
37
|
|
|
Additionally, only submissions the user is tutor for are shown. |
|
38
|
|
|
''' |
|
39
|
|
|
title = _('Submission Status') |
|
40
|
|
|
parameter_name = 'statefilter' |
|
41
|
|
|
|
|
42
|
|
|
def lookups(self, request, model_admin): |
|
43
|
|
|
return ( |
|
44
|
|
|
('notwithdrawn', _('All, except withdrawn')), |
|
45
|
|
|
('valid', _('Tested')), |
|
46
|
|
|
('tobegraded', _('Grading needed')), |
|
47
|
|
|
('gradingunfinished', _('Grading not finished')), |
|
48
|
|
|
('graded', _('Grading finished')), |
|
49
|
|
|
('closed', _('Closed, student notified')), |
|
50
|
|
|
) |
|
51
|
|
|
|
|
52
|
|
|
def queryset(self, request, qs): |
|
53
|
|
|
qs = qs.filter(assignment__course__in=list( |
|
54
|
|
|
request.user.profile.tutor_courses())) |
|
55
|
|
|
if self.value() == 'notwithdrawn': |
|
56
|
|
|
return Submission.qs_notwithdrawn(qs) |
|
57
|
|
|
elif self.value() == 'valid': |
|
58
|
|
|
return Submission.qs_valid(qs) |
|
59
|
|
|
elif self.value() == 'tobegraded': |
|
60
|
|
|
return Submission.qs_tobegraded(qs) |
|
61
|
|
|
elif self.value() == 'gradingunfinished': |
|
62
|
|
|
return qs.filter(state__in=[Submission.GRADING_IN_PROGRESS]) |
|
63
|
|
|
elif self.value() == 'graded': |
|
64
|
|
|
return qs.filter(state__in=[Submission.GRADED]) |
|
65
|
|
|
elif self.value() == 'closed': |
|
66
|
|
|
return Submission.qs_notified(qs) |
|
67
|
|
|
else: |
|
68
|
|
|
return qs |
|
69
|
|
|
|
|
70
|
|
|
|
|
71
|
|
View Code Duplication |
class SubmissionAssignmentFilter(SimpleListFilter): |
|
|
|
|
|
|
72
|
|
|
|
|
73
|
|
|
''' This custom filter allows to filter the submissions according to their |
|
74
|
|
|
assignment. Only submissions from courses were the user is tutor are |
|
75
|
|
|
considered. |
|
76
|
|
|
''' |
|
77
|
|
|
title = _('Assignment') |
|
78
|
|
|
parameter_name = 'assignmentfilter' |
|
79
|
|
|
|
|
80
|
|
|
def lookups(self, request, model_admin): |
|
81
|
|
|
tutor_assignments = Assignment.objects.filter(course__in=list( |
|
82
|
|
|
request.user.profile.tutor_courses())).order_by('title') |
|
83
|
|
|
return ((ass.pk, ass.title) for ass in tutor_assignments) |
|
84
|
|
|
|
|
85
|
|
|
def queryset(self, request, qs): |
|
86
|
|
|
if self.value(): |
|
87
|
|
|
return qs.filter(assignment__exact=self.value()) |
|
88
|
|
|
else: |
|
89
|
|
|
return qs.filter(assignment__course__in=list(request.user.profile.tutor_courses())) |
|
90
|
|
|
|
|
91
|
|
|
|
|
92
|
|
View Code Duplication |
class SubmissionCourseFilter(SimpleListFilter): |
|
|
|
|
|
|
93
|
|
|
|
|
94
|
|
|
''' This custom filter allows to filter the submissions according to |
|
95
|
|
|
the course they belong to. Additionally, only submission that the |
|
96
|
|
|
user is a tutor for are returned in any of the filter settings. |
|
97
|
|
|
''' |
|
98
|
|
|
title = _('Course') |
|
99
|
|
|
parameter_name = 'coursefilter' |
|
100
|
|
|
|
|
101
|
|
|
def lookups(self, request, model_admin): |
|
102
|
|
|
return ((c.pk, c.title) for c in list(request.user.profile.tutor_courses())) |
|
103
|
|
|
|
|
104
|
|
|
def queryset(self, request, qs): |
|
105
|
|
|
if self.value(): |
|
106
|
|
|
return qs.filter(assignment__course__exact=self.value()) |
|
107
|
|
|
else: |
|
108
|
|
|
return qs.filter(assignment__course__in=list(request.user.profile.tutor_courses())) |
|
109
|
|
|
|
|
110
|
|
|
|
|
111
|
|
|
class SubmissionAdmin(ModelAdmin): |
|
112
|
|
|
|
|
113
|
|
|
''' This is our version of the admin view for a single submission. |
|
114
|
|
|
''' |
|
115
|
|
|
list_display = ['__str__', 'created', 'modified', 'author_list', 'course', |
|
116
|
|
|
'assignment', 'state', 'grading_status_text', 'has_grading_notes'] |
|
117
|
|
|
list_filter = (SubmissionStateFilter, SubmissionCourseFilter, |
|
118
|
|
|
SubmissionAssignmentFilter) |
|
119
|
|
|
filter_horizontal = ('authors',) |
|
120
|
|
|
actions = ['downloadArchiveAction', 'setInitialStateAction', 'setFullPendingStateAction', 'setGradingNotFinishedStateAction', |
|
121
|
|
|
'setGradingFinishedStateAction', 'closeAndNotifyAction', 'sendMailAction', 'notifyAction', 'getPerformanceResultsAction'] |
|
122
|
|
|
search_fields = ['=id', '=authors__email', '=authors__first_name', |
|
123
|
|
|
'=authors__last_name', '=authors__username', '=notes'] |
|
124
|
|
|
change_list_template = "admin/change_list_filter_sidebar.html" |
|
125
|
|
|
|
|
126
|
|
|
class Media: |
|
127
|
|
|
css = {'all': ('css/teacher.css',)} |
|
128
|
|
|
|
|
129
|
|
|
fieldsets = ( |
|
130
|
|
|
('General', |
|
131
|
|
|
{'fields': ('assignment', 'assignment_info', ('submitter', 'modified')), }), |
|
132
|
|
|
('Authors', |
|
133
|
|
|
{'fields': ('authors',), |
|
134
|
|
|
'classes': ('grp-collapse grp-closed',) |
|
135
|
|
|
}), |
|
136
|
|
|
('Student submission', {'fields': (('file_link', 'file_upload'), 'notes')}), |
|
137
|
|
|
('Test results', |
|
138
|
|
|
{'fields': (('validationtest_result_student', 'validationtest_result_tutor'), 'fulltest_result'), |
|
139
|
|
|
}), |
|
140
|
|
|
('Grading', |
|
141
|
|
|
{'fields': (('grading', 'grading_status'), 'grading_notes', 'grading_file',), }), |
|
142
|
|
|
) |
|
143
|
|
|
|
|
144
|
|
|
def assignment_info(self, instance): |
|
145
|
|
|
message = 'Course: %s<br/>' % instance.assignment.course |
|
146
|
|
|
message += 'Deadline: %s (%s ago)' % (instance.assignment.hard_deadline, |
|
147
|
|
|
timesince.timesince(instance.assignment.hard_deadline)) |
|
148
|
|
|
if instance.can_modify(instance.submitter): |
|
149
|
|
|
message += '''<p style="color: red">Warning: Assignment is still open. Saving grading information will disable withdrawal and re-upload for the authors.</p>''' |
|
150
|
|
|
return mark_safe(message) |
|
151
|
|
|
assignment_info.short_description = "Details" |
|
152
|
|
|
|
|
153
|
|
|
def file_link(self, instance): |
|
154
|
|
|
''' |
|
155
|
|
|
Renders the link to the student upload file. |
|
156
|
|
|
''' |
|
157
|
|
|
sfile = instance.file_upload |
|
158
|
|
|
if not sfile: |
|
159
|
|
|
return mark_safe('No file submitted by student.') |
|
160
|
|
|
else: |
|
161
|
|
|
return mark_safe('<a href="%s">%s</a><br/>(<a href="%s" target="_new">Preview</a>)' % (sfile.get_absolute_url(), sfile.basename(), sfile.get_preview_url())) |
|
162
|
|
|
file_link.short_description = "Stored upload" |
|
163
|
|
|
|
|
164
|
|
|
def _render_test_result(self, result_obj, enabled, show_student_data): |
|
165
|
|
|
if not result_obj: |
|
166
|
|
|
if enabled: |
|
167
|
|
|
return mark_safe('Enabled, no results.') |
|
168
|
|
|
else: |
|
169
|
|
|
return mark_safe('Not enabled, no results.') |
|
170
|
|
|
else: |
|
171
|
|
|
if show_student_data: |
|
172
|
|
|
text = result_obj.result |
|
173
|
|
|
else: |
|
174
|
|
|
text = result_obj.result_tutor |
|
175
|
|
|
return format_html("{1}<div style='font-size:80%'><br/>(Generated by {0})</div>", result_obj.machine, text) |
|
176
|
|
|
|
|
177
|
|
|
def validationtest_result_student(self, instance): |
|
178
|
|
|
result_obj = instance.get_validation_result() |
|
179
|
|
|
return self._render_test_result(result_obj, instance.assignment.attachment_test_validity, True) |
|
180
|
|
|
validationtest_result_student.short_description = "Validation test" |
|
181
|
|
|
validationtest_result_student.allow_tags = True |
|
182
|
|
|
|
|
183
|
|
|
def validationtest_result_tutor(self, instance): |
|
184
|
|
|
result_obj = instance.get_validation_result() |
|
185
|
|
|
return self._render_test_result(result_obj, instance.assignment.attachment_test_validity, False) |
|
186
|
|
|
validationtest_result_tutor.short_description = "Validation test (tutor only)" |
|
187
|
|
|
validationtest_result_tutor.allow_tags = True |
|
188
|
|
|
|
|
189
|
|
|
def fulltest_result(self, instance): |
|
190
|
|
|
result_obj = instance.get_fulltest_result() |
|
191
|
|
|
return self._render_test_result(result_obj, instance.assignment.attachment_test_full, False) |
|
192
|
|
|
fulltest_result.short_description = "Full test (tutor only)" |
|
193
|
|
|
fulltest_result.allow_tags = True |
|
194
|
|
|
|
|
195
|
|
|
def grading_status(self, instance): |
|
196
|
|
|
message = '''<input type="radio" name="newstate" value="unfinished"> Grading not finished</input> |
|
197
|
|
|
<input type="radio" name="newstate" value="finished"> Grading finished</input> |
|
198
|
|
|
''' |
|
199
|
|
|
return mark_safe(message) |
|
200
|
|
|
grading_status.short_description = "Status" |
|
201
|
|
|
|
|
202
|
|
|
def get_queryset(self, request): |
|
203
|
|
|
''' Restrict the listed submission for the current user.''' |
|
204
|
|
|
qs = super(SubmissionAdmin, self).get_queryset(request) |
|
205
|
|
|
if request.user.is_superuser: |
|
206
|
|
|
return qs |
|
207
|
|
|
else: |
|
208
|
|
|
return qs.filter(Q(assignment__course__tutors__pk=request.user.pk) | Q(assignment__course__owner=request.user)).distinct() |
|
209
|
|
|
|
|
210
|
|
|
def get_readonly_fields(self, request, obj=None): |
|
211
|
|
|
''' Make some of the form fields read-only, but only if the view used to |
|
212
|
|
|
modify an existing submission. Overriding the ModelAdmin getter |
|
213
|
|
|
is the documented way to do that. |
|
214
|
|
|
''' |
|
215
|
|
|
# Pseudo-fields generated by functions always must show up in the readonly list |
|
216
|
|
|
pseudo_fields = ('file_link', 'validationtest_result_student', 'validationtest_result_tutor', 'fulltest_result', |
|
217
|
|
|
'grading_status', 'assignment_info', 'modified') |
|
218
|
|
|
if obj: |
|
219
|
|
|
return ('assignment', 'submitter', 'notes') + pseudo_fields |
|
220
|
|
|
else: |
|
221
|
|
|
# New manual submission |
|
222
|
|
|
return pseudo_fields |
|
223
|
|
|
|
|
224
|
|
|
def formfield_for_dbfield(self, db_field, **kwargs): |
|
225
|
|
|
''' Offer grading choices from the assignment definition as potential form |
|
226
|
|
|
field values for 'grading'. |
|
227
|
|
|
When no object is given in the form, the this is a new manual submission |
|
228
|
|
|
''' |
|
229
|
|
|
if db_field.name == "grading": |
|
230
|
|
|
submurl = kwargs['request'].path |
|
231
|
|
|
try: |
|
232
|
|
|
# Does not work on new submission action by admin or with a change of URLs. The former is expectable. |
|
233
|
|
|
submid = [int(s) for s in submurl.split('/') if s.isdigit()][0] |
|
234
|
|
|
kwargs["queryset"] = Submission.objects.get( |
|
235
|
|
|
pk=submid).assignment.gradingScheme.gradings |
|
236
|
|
|
except: |
|
237
|
|
|
kwargs["queryset"] = Grading.objects.none() |
|
238
|
|
|
return super(SubmissionAdmin, self).formfield_for_dbfield(db_field, **kwargs) |
|
239
|
|
|
|
|
240
|
|
|
def save_model(self, request, obj, form, change): |
|
241
|
|
|
''' Our custom addition to the view |
|
242
|
|
|
adds an easy radio button choice for the new state. This is meant to be for tutors. |
|
243
|
|
|
We need to peel this choice from the form data and set the state accordingly. |
|
244
|
|
|
The radio buttons have no default, so that we can keep the existing state |
|
245
|
|
|
if the user makes no explicit choice. |
|
246
|
|
|
Everything else can be managed as prepared by the framework. |
|
247
|
|
|
''' |
|
248
|
|
|
if 'newstate' in request.POST: |
|
249
|
|
|
if request.POST['newstate'] == 'finished': |
|
250
|
|
|
obj.state = Submission.GRADED |
|
251
|
|
|
elif request.POST['newstate'] == 'unfinished': |
|
252
|
|
|
obj.state = Submission.GRADING_IN_PROGRESS |
|
253
|
|
|
obj.save() |
|
254
|
|
|
|
|
255
|
|
|
def setInitialStateAction(self, request, queryset): |
|
256
|
|
|
for subm in queryset: |
|
257
|
|
|
subm.state = subm.get_initial_state() |
|
258
|
|
|
subm.save() |
|
259
|
|
|
setInitialStateAction.short_description = "Re-run validation test for selection submissions (student visible)" |
|
260
|
|
|
|
|
261
|
|
|
def setGradingNotFinishedStateAction(self, request, queryset): |
|
262
|
|
|
''' |
|
263
|
|
|
Set all marked submissions to "grading not finished". |
|
264
|
|
|
This is intended to support grading corrections on a larger scale. |
|
265
|
|
|
''' |
|
266
|
|
|
for subm in queryset: |
|
267
|
|
|
subm.state = Submission.GRADING_IN_PROGRESS |
|
268
|
|
|
subm.save() |
|
269
|
|
|
setGradingNotFinishedStateAction.short_description = "Mark selected submissions as 'Grading not finished'" |
|
270
|
|
|
|
|
271
|
|
|
def setGradingFinishedStateAction(self, request, queryset): |
|
272
|
|
|
''' |
|
273
|
|
|
Set all marked submissions to "grading finished". |
|
274
|
|
|
This is intended to support grading corrections on a larger scale. |
|
275
|
|
|
''' |
|
276
|
|
|
for subm in queryset: |
|
277
|
|
|
subm.state = Submission.GRADED |
|
278
|
|
|
subm.save() |
|
279
|
|
|
setGradingFinishedStateAction.short_description = "Mark selected submissions as 'Grading finished'" |
|
280
|
|
|
|
|
281
|
|
|
def setFullPendingStateAction(self, request, queryset): |
|
282
|
|
|
# do not restart tests for withdrawn solutions |
|
283
|
|
|
queryset = queryset.exclude(state=Submission.WITHDRAWN) |
|
284
|
|
|
# do not restart tests for queued things |
|
285
|
|
|
queryset = queryset.exclude(state=Submission.TEST_VALIDITY_PENDING) |
|
286
|
|
|
queryset = queryset.exclude(state=Submission.TEST_FULL_PENDING) |
|
287
|
|
|
numchanged = 0 |
|
288
|
|
|
for subm in queryset: |
|
289
|
|
|
if subm.assignment.has_full_test(): |
|
290
|
|
|
if subm.state == Submission.CLOSED: |
|
291
|
|
|
subm.state = Submission.CLOSED_TEST_FULL_PENDING |
|
292
|
|
|
else: |
|
293
|
|
|
subm.state = Submission.TEST_FULL_PENDING |
|
294
|
|
|
subm.save() |
|
295
|
|
|
numchanged += 1 |
|
296
|
|
|
if numchanged == 0: |
|
297
|
|
|
self.message_user( |
|
298
|
|
|
request, "Nothing changed, no testable submission found.") |
|
299
|
|
|
else: |
|
300
|
|
|
self.message_user( |
|
301
|
|
|
request, "Changed status of %u submissions." % numchanged) |
|
302
|
|
|
setFullPendingStateAction.short_description = "Re-run full test for selected submissions (student invisible)" |
|
303
|
|
|
|
|
304
|
|
|
def closeAndNotifyAction(self, request, queryset): |
|
305
|
|
|
''' Close all submissions were the tutor sayed that the grading is finished, |
|
306
|
|
|
and inform the student. CLosing only graded submissions is a safeguard, |
|
307
|
|
|
since backend users tend to checkbox-mark all submissions without thinking. |
|
308
|
|
|
''' |
|
309
|
|
|
mails = [] |
|
310
|
|
|
qs = queryset.filter(Q(state=Submission.GRADED)) |
|
311
|
|
|
for subm in qs: |
|
312
|
|
|
subm.inform_student(Submission.CLOSED) |
|
313
|
|
|
mails.append(str(subm.pk)) |
|
314
|
|
|
# works in bulk because inform_student never fails |
|
315
|
|
|
qs.update(state=Submission.CLOSED) |
|
316
|
|
|
if len(mails) == 0: |
|
317
|
|
|
self.message_user(request, "Nothing closed, no mails sent.") |
|
318
|
|
|
else: |
|
319
|
|
|
self.message_user( |
|
320
|
|
|
request, "Mail sent for submissions: " + ",".join(mails)) |
|
321
|
|
|
closeAndNotifyAction.short_description = "Close + send student notification for selected submissions" |
|
322
|
|
|
|
|
323
|
|
|
def sendMailAction(self, request, queryset): |
|
324
|
|
|
receiver_list = [] |
|
325
|
|
|
for submission in queryset: |
|
326
|
|
|
for author in submission.authors.all(): |
|
327
|
|
|
if author.pk not in receiver_list: |
|
328
|
|
|
receiver_list.append(author.pk) |
|
329
|
|
|
return redirect('mailstudents', user_list=','.join([str(pk) for pk in receiver_list])) |
|
330
|
|
|
sendMailAction.short_description = "Send eMail to authors of selected submissions" |
|
331
|
|
|
|
|
332
|
|
|
def downloadArchiveAction(self, request, queryset): |
|
333
|
|
|
''' |
|
334
|
|
|
Download selected submissions as archive, for targeted correction. |
|
335
|
|
|
''' |
|
336
|
|
|
output = io.BytesIO() |
|
337
|
|
|
z = zipfile.ZipFile(output, 'w') |
|
338
|
|
|
|
|
339
|
|
|
for sub in queryset: |
|
340
|
|
|
sub.add_to_zipfile(z) |
|
341
|
|
|
|
|
342
|
|
|
z.close() |
|
343
|
|
|
# go back to start in ZIP file so that Django can deliver it |
|
344
|
|
|
output.seek(0) |
|
345
|
|
|
response = HttpResponse( |
|
346
|
|
|
output, content_type="application/x-zip-compressed") |
|
347
|
|
|
response['Content-Disposition'] = 'attachment; filename=submissions.zip' |
|
348
|
|
|
return response |
|
349
|
|
|
downloadArchiveAction.short_description = "Download selected submissions as ZIP archive" |
|
350
|
|
|
|
|
351
|
|
|
# ToDo: Must be refactored to consider new performance data storage model |
|
352
|
|
|
# def getPerformanceResultsAction(self, request, queryset): |
|
353
|
|
|
# qs = queryset.exclude(state=Submission.WITHDRAWN) # avoid accidental addition of withdrawn solutions |
|
354
|
|
|
# response = HttpResponse(content_type="text/csv") |
|
355
|
|
|
# response.write("Submission ID;Course;Assignment;Authors;Performance Data\n") |
|
356
|
|
|
# for subm in qs: |
|
357
|
|
|
# if subm.file_upload and subm.file_upload.perf_data is not None: |
|
358
|
|
|
# auth = ", ".join([author.get_full_name() for author in subm.authors.all()]) |
|
359
|
|
|
# response.write("%u;%s;%s;%s;" % (subm.pk, course(subm), subm.assignment, auth)) |
|
360
|
|
|
# response.write(subm.file_upload.perf_data) |
|
361
|
|
|
# response.write("\n") |
|
362
|
|
|
# return response |
|
363
|
|
|
# getPerformanceResultsAction.short_description = "Download performance data as CSV" |
|
364
|
|
|
|