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
|
|
|
|