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