1
|
|
|
from django.db import models |
2
|
|
|
from django.contrib.auth.models import User |
3
|
|
|
from django.utils import timezone |
4
|
|
|
from django.core.urlresolvers import reverse |
5
|
|
|
|
6
|
|
|
from datetime import datetime |
7
|
|
|
import tempfile |
8
|
|
|
import zipfile |
9
|
|
|
import tarfile |
10
|
|
|
|
11
|
|
|
from opensubmit import mails |
12
|
|
|
|
13
|
|
|
from .submissionfile import upload_path, SubmissionFile |
14
|
|
|
from .submissiontestresult import SubmissionTestResult |
15
|
|
|
|
16
|
|
|
import logging |
17
|
|
|
import shutil |
18
|
|
|
import os |
19
|
|
|
logger = logging.getLogger('OpenSubmit') |
20
|
|
|
|
21
|
|
|
|
22
|
|
|
class ValidSubmissionsManager(models.Manager): |
23
|
|
|
''' |
24
|
|
|
A model manager used by the Submission model. It returns a sorted list |
25
|
|
|
of submissions that are not withdrawn. |
26
|
|
|
''' |
27
|
|
|
|
28
|
|
|
def get_queryset(self): |
29
|
|
|
submissions = Submission.objects.exclude( |
30
|
|
|
state__in=[Submission.WITHDRAWN, Submission.RECEIVED]).order_by('pk') |
31
|
|
|
return submissions |
32
|
|
|
|
33
|
|
|
|
34
|
|
|
class PendingStudentTestsManager(models.Manager): |
35
|
|
|
''' |
36
|
|
|
A model manager used by the Submission model. It returns a sorted list |
37
|
|
|
of executor work to be done that relates to compilation and |
38
|
|
|
validation test jobs for students. |
39
|
|
|
The basic approach is that compilation should happen before validation in FIFO order, |
40
|
|
|
under the assumption is that the time effort is increasing. |
41
|
|
|
''' |
42
|
|
|
|
43
|
|
|
def get_queryset(self): |
44
|
|
|
jobs = Submission.objects.filter( |
45
|
|
|
state=Submission.TEST_VALIDITY_PENDING).order_by('-modified') |
46
|
|
|
return jobs |
47
|
|
|
|
48
|
|
|
|
49
|
|
|
class PendingFullTestsManager(models.Manager): |
50
|
|
|
''' |
51
|
|
|
A model manager used by the Submission model. It returns a sorted list |
52
|
|
|
of full test executor work to be done. |
53
|
|
|
The basic approach is that non-graded job validation wins over closed job |
54
|
|
|
re-evaluation triggered by the teachers, |
55
|
|
|
under the assumption is that the time effort is increasing. |
56
|
|
|
''' |
57
|
|
|
|
58
|
|
|
def get_queryset(self): |
59
|
|
|
jobs = Submission.objects.filter( |
60
|
|
|
state__in=[Submission.TEST_FULL_PENDING, |
61
|
|
|
Submission.CLOSED_TEST_FULL_PENDING] |
62
|
|
|
).order_by('-state').order_by('-modified') |
63
|
|
|
return jobs |
64
|
|
|
|
65
|
|
|
|
66
|
|
|
class PendingTestsManager(models.Manager): |
67
|
|
|
''' |
68
|
|
|
A combination of both, with focus on getting the full tests later than the validity tests. |
69
|
|
|
''' |
70
|
|
|
|
71
|
|
|
def get_queryset(self): |
72
|
|
|
jobs = Submission.objects.filter( |
73
|
|
|
state__in=[Submission.TEST_FULL_PENDING, # PF |
74
|
|
|
Submission.CLOSED_TEST_FULL_PENDING, # CT |
75
|
|
|
Submission.TEST_VALIDITY_PENDING] # PV |
76
|
|
|
).order_by('-state').order_by('-modified') |
77
|
|
|
return jobs |
78
|
|
|
|
79
|
|
|
|
80
|
|
|
class Submission(models.Model): |
81
|
|
|
''' |
82
|
|
|
A student submission for an assignment. |
83
|
|
|
''' |
84
|
|
|
|
85
|
|
|
RECEIVED = 'R' # Only for initialization, this should never persist |
86
|
|
|
WITHDRAWN = 'W' # Withdrawn by the student |
87
|
|
|
SUBMITTED = 'S' # Submitted, no tests so far |
88
|
|
|
TEST_VALIDITY_PENDING = 'PV' # Submitted, validity test planned |
89
|
|
|
TEST_VALIDITY_FAILED = 'FV' # Submitted, validity test failed |
90
|
|
|
TEST_FULL_PENDING = 'PF' # Submitted, full test planned |
91
|
|
|
TEST_FULL_FAILED = 'FF' # Submitted, full test failed |
92
|
|
|
SUBMITTED_TESTED = 'ST' # Submitted, all tests performed, grading planned |
93
|
|
|
GRADING_IN_PROGRESS = 'GP' # Grading in progress, but not finished |
94
|
|
|
GRADED = 'G' # Graded, student notification not done |
95
|
|
|
CLOSED = 'C' # Graded, student notification done |
96
|
|
|
CLOSED_TEST_FULL_PENDING = 'CT' # Keep grading status, full test planned |
97
|
|
|
|
98
|
|
|
# Docs start: States |
99
|
|
|
# State description in teacher backend |
100
|
|
|
STATES = ( |
101
|
|
|
|
102
|
|
|
# The submission is currently uploaded, |
103
|
|
|
# some internal processing still takes place. |
104
|
|
|
(RECEIVED, 'Received'), |
105
|
|
|
|
106
|
|
|
# The submission was withdrawn by the student |
107
|
|
|
# before the deadline. No further automated action |
108
|
|
|
# will take place with this submission. |
109
|
|
|
(WITHDRAWN, 'Withdrawn'), |
110
|
|
|
|
111
|
|
|
# The submission is completely uploaded. |
112
|
|
|
# If code validation is configured, the state will |
113
|
|
|
# directly change to TEST_VALIDITY_PENDING. |
114
|
|
|
(SUBMITTED, 'Submitted'), |
115
|
|
|
|
116
|
|
|
# The submission is waiting to be validated with the |
117
|
|
|
# validation script on one of the test machines. |
118
|
|
|
# The submission remains in this state until some |
119
|
|
|
# validation result was sent from the test machines. |
120
|
|
|
(TEST_VALIDITY_PENDING, 'Validity test pending'), |
121
|
|
|
|
122
|
|
|
# The validation of the student sources on the |
123
|
|
|
# test machine failed. No further automated action will |
124
|
|
|
# take place with this submission. |
125
|
|
|
# The students get informed by email. |
126
|
|
|
(TEST_VALIDITY_FAILED, 'Validity test failed'), |
127
|
|
|
|
128
|
|
|
# The submission is waiting to be checked with the |
129
|
|
|
# full test script on one of the test machines. |
130
|
|
|
# The submission remains in this state until |
131
|
|
|
# some result was sent from the test machines. |
132
|
|
|
(TEST_FULL_PENDING, 'Full test pending'), |
133
|
|
|
|
134
|
|
|
# The (compilation and) validation of the student |
135
|
|
|
# sources on the test machine worked, only the full test |
136
|
|
|
# failed. No further automated action will take place with |
137
|
|
|
# this submission. |
138
|
|
|
(TEST_FULL_FAILED, 'All but full test passed, grading pending'), |
139
|
|
|
|
140
|
|
|
# The compilation (if configured) and the validation and |
141
|
|
|
# the full test (if configured) of the submission were |
142
|
|
|
# successful. No further automated action will take |
143
|
|
|
# place with this submission. |
144
|
|
|
(SUBMITTED_TESTED, 'All tests passed, grading pending'), |
145
|
|
|
|
146
|
|
|
# Some grading took place in the teacher backend, |
147
|
|
|
# and the submission was explicitly marked with |
148
|
|
|
# 'grading not finished'. This allows correctors to have |
149
|
|
|
# multiple runs over the submissions and see which |
150
|
|
|
# of the submissions were already investigated. |
151
|
|
|
(GRADING_IN_PROGRESS, 'Grading not finished'), |
152
|
|
|
|
153
|
|
|
# Some grading took place in the teacher backend, |
154
|
|
|
# and the submission was explicitly marked with |
155
|
|
|
# 'grading not finished'. This allows correctors |
156
|
|
|
# to have multiple runs over the submissions and |
157
|
|
|
# see which of the submissions were already investigated. |
158
|
|
|
(GRADED, 'Grading finished'), |
159
|
|
|
|
160
|
|
|
# The submission is closed, meaning that in the |
161
|
|
|
# teacher backend, the submission was marked |
162
|
|
|
# as closed to trigger the student notification |
163
|
|
|
# for their final assignment grades. |
164
|
|
|
# Students are notified by email. |
165
|
|
|
(CLOSED, 'Closed, student notified'), |
166
|
|
|
|
167
|
|
|
# The submission is closed, but marked for |
168
|
|
|
# another full test run. |
169
|
|
|
# This is typically used to have some post-assignment |
170
|
|
|
# analysis of student submissions |
171
|
|
|
# by the help of full test scripts. |
172
|
|
|
# Students never get any notification about this state. |
173
|
|
|
(CLOSED_TEST_FULL_PENDING, 'Closed, full test pending') |
174
|
|
|
) |
175
|
|
|
|
176
|
|
|
# State description in student dashboard |
177
|
|
|
STUDENT_STATES = ( |
178
|
|
|
(RECEIVED, 'Received'), |
179
|
|
|
(WITHDRAWN, 'Withdrawn'), |
180
|
|
|
(SUBMITTED, 'Waiting for grading'), |
181
|
|
|
(TEST_VALIDITY_PENDING, 'Waiting for validation test'), |
182
|
|
|
(TEST_VALIDITY_FAILED, 'Validation failed'), |
183
|
|
|
(TEST_FULL_PENDING, 'Waiting for grading'), |
184
|
|
|
(TEST_FULL_FAILED, 'Waiting for grading'), |
185
|
|
|
(SUBMITTED_TESTED, 'Waiting for grading'), |
186
|
|
|
(GRADING_IN_PROGRESS, 'Waiting for grading'), |
187
|
|
|
(GRADED, 'Waiting for grading'), |
188
|
|
|
(CLOSED, 'Done'), |
189
|
|
|
(CLOSED_TEST_FULL_PENDING, 'Done') |
190
|
|
|
) |
191
|
|
|
# Docs end: States |
192
|
|
|
|
193
|
|
|
assignment = models.ForeignKey('Assignment', related_name='submissions') |
194
|
|
|
submitter = models.ForeignKey(User, related_name='submitted') |
195
|
|
|
# includes also submitter, see submission_post_save() handler |
196
|
|
|
authors = models.ManyToManyField(User, related_name='authored') |
197
|
|
|
notes = models.TextField(max_length=200, blank=True) |
198
|
|
|
file_upload = models.ForeignKey( |
199
|
|
|
'SubmissionFile', related_name='submissions', blank=True, null=True, verbose_name='New upload') |
200
|
|
|
created = models.DateTimeField(auto_now_add=True, editable=False) |
201
|
|
|
modified = models.DateTimeField( |
202
|
|
|
auto_now=True, editable=False, blank=True, null=True) |
203
|
|
|
grading = models.ForeignKey('Grading', blank=True, null=True) |
204
|
|
|
grading_notes = models.TextField(max_length=10000, blank=True, null=True, |
205
|
|
|
help_text="Specific notes about the grading for this submission.") |
206
|
|
|
grading_file = models.FileField(upload_to=upload_path, blank=True, null=True, |
207
|
|
|
help_text="Additional information about the grading as file.") |
208
|
|
|
state = models.CharField(max_length=2, choices=STATES, default=RECEIVED) |
209
|
|
|
|
210
|
|
|
objects = models.Manager() |
211
|
|
|
pending_student_tests = PendingStudentTestsManager() |
212
|
|
|
pending_full_tests = PendingFullTestsManager() |
213
|
|
|
pending_tests = PendingTestsManager() |
214
|
|
|
valid_ones = ValidSubmissionsManager() |
215
|
|
|
|
216
|
|
|
class Meta: |
217
|
|
|
app_label = 'opensubmit' |
218
|
|
|
|
219
|
|
|
@staticmethod |
220
|
|
|
def qs_valid(qs): |
221
|
|
|
''' |
222
|
|
|
A filtering of the given Submission queryset for all submissions that were successfully validated. This includes the following cases: |
223
|
|
|
|
224
|
|
|
- The submission was submitted and there are no tests. |
225
|
|
|
- The submission was successfully validity-tested, regardless of the full test status (not existent / failed / success). |
226
|
|
|
- The submission was graded or the grading was already started. |
227
|
|
|
- The submission was closed. |
228
|
|
|
|
229
|
|
|
The idea is to get all submissions that were a valid solution, regardless of the point in time where you check the list. |
230
|
|
|
''' |
231
|
|
|
return qs.filter(state__in=[Submission.SUBMITTED, Submission.SUBMITTED_TESTED, Submission.TEST_FULL_FAILED, Submission.GRADING_IN_PROGRESS, Submission.GRADED, Submission.CLOSED, Submission.CLOSED_TEST_FULL_PENDING]) |
232
|
|
|
|
233
|
|
|
@staticmethod |
234
|
|
|
def qs_tobegraded(qs): |
235
|
|
|
''' |
236
|
|
|
A filtering of the given Submission queryset for all submissions that are gradeable. This includes the following cases: |
237
|
|
|
|
238
|
|
|
- The submission was submitted and there are no tests. |
239
|
|
|
- The submission was successfully validity-tested, regardless of the full test status (not existent / failed / success). |
240
|
|
|
- The grading was already started, but not finished. |
241
|
|
|
|
242
|
|
|
The idea is to get a list of work to be done for the correctors. |
243
|
|
|
''' |
244
|
|
|
return qs.filter(state__in=[Submission.SUBMITTED, Submission.SUBMITTED_TESTED, Submission.TEST_FULL_FAILED, Submission.GRADING_IN_PROGRESS]) |
245
|
|
|
|
246
|
|
|
@staticmethod |
247
|
|
|
def qs_notified(qs): |
248
|
|
|
''' |
249
|
|
|
A filtering of the given Submission queryset for all submissions were students already got notification about their grade. This includes the following cases: |
250
|
|
|
|
251
|
|
|
- The submission was closed. |
252
|
|
|
- The submission was closed and full tests were re-started. |
253
|
|
|
|
254
|
|
|
The idea is to get indirectly a list of emails that went out. |
255
|
|
|
''' |
256
|
|
|
return qs.filter(state__in=[Submission.CLOSED, Submission.CLOSED_TEST_FULL_PENDING]) |
257
|
|
|
|
258
|
|
|
@staticmethod |
259
|
|
|
def qs_notwithdrawn(qs): |
260
|
|
|
''' |
261
|
|
|
A filtering of the given Submission queryset for all submissions that were not withdrawn. This excludes the following cases: |
262
|
|
|
|
263
|
|
|
- The submission was withdrawn. |
264
|
|
|
- The submission was received and never left that status due to an error. |
265
|
|
|
|
266
|
|
|
The idea is a list of all submissions, but without the garbage. |
267
|
|
|
''' |
268
|
|
|
return qs.exclude(state__in=[Submission.WITHDRAWN, Submission.RECEIVED]) |
269
|
|
|
|
270
|
|
|
def __str__(self): |
271
|
|
|
if self.pk: |
272
|
|
|
return str(self.pk) |
273
|
|
|
else: |
274
|
|
|
return "New Submission instance" |
275
|
|
|
|
276
|
|
|
def author_list(self): |
277
|
|
|
''' The list of authors als text, for admin submission list overview.''' |
278
|
|
|
author_list = [self.submitter] + \ |
279
|
|
|
[author for author in self.authors.all().exclude(pk=self.submitter.pk)] |
280
|
|
|
return ",\n".join([author.get_full_name() for author in author_list]) |
281
|
|
|
author_list.admin_order_field = 'submitter' |
282
|
|
|
|
283
|
|
|
def course(self): |
284
|
|
|
''' The course of this submission as text, for admin submission list overview.''' |
285
|
|
|
return self.assignment.course |
286
|
|
|
course.admin_order_field = 'assignment__course' |
287
|
|
|
|
288
|
|
|
def grading_status_text(self): |
289
|
|
|
''' |
290
|
|
|
A rendering of the grading that is an answer on the question |
291
|
|
|
"Is grading finished?". |
292
|
|
|
Used in duplicate view and submission list on the teacher backend. |
293
|
|
|
''' |
294
|
|
|
if self.assignment.is_graded(): |
295
|
|
|
if self.is_grading_finished(): |
296
|
|
|
return str('Yes ({0})'.format(self.grading)) |
297
|
|
|
else: |
298
|
|
|
return str('No') |
299
|
|
|
else: |
300
|
|
|
return str('Not graded') |
301
|
|
|
grading_status_text.admin_order_field = 'grading__title' |
302
|
|
|
grading_status_text.short_description = "Grading finished?" |
303
|
|
|
|
304
|
|
|
def has_grading_notes(self): |
305
|
|
|
''' Determines if the submission has grading notes. |
306
|
|
|
Used for submission list overview in teacher backend. |
307
|
|
|
''' |
308
|
|
|
if self.grading_notes is not None and len(self.grading_notes) > 0: |
309
|
|
|
return True |
310
|
|
|
else: |
311
|
|
|
return False |
312
|
|
|
has_grading_notes.short_description = "Grading notes?" |
313
|
|
|
has_grading_notes.admin_order_field = 'grading_notes' |
314
|
|
|
has_grading_notes.boolean = True # show nice little icon |
315
|
|
|
|
316
|
|
|
def grading_value_text(self): |
317
|
|
|
''' |
318
|
|
|
A rendering of the grading that is an answer to the question |
319
|
|
|
"What is the grade?". |
320
|
|
|
''' |
321
|
|
|
if self.assignment.is_graded(): |
322
|
|
|
if self.is_grading_finished(): |
323
|
|
|
return str(self.grading) |
324
|
|
|
else: |
325
|
|
|
return str('pending') |
326
|
|
|
else: |
327
|
|
|
if self.is_grading_finished(): |
328
|
|
|
return str('done') |
329
|
|
|
else: |
330
|
|
|
return str('not done') |
331
|
|
|
|
332
|
|
|
def grading_means_passed(self): |
333
|
|
|
''' |
334
|
|
|
Information if the given grading means passed. |
335
|
|
|
Non-graded assignments are always passed. |
336
|
|
|
''' |
337
|
|
|
if self.assignment.is_graded(): |
338
|
|
|
if self.grading and self.grading.means_passed: |
339
|
|
|
return True |
340
|
|
|
else: |
341
|
|
|
return False |
342
|
|
|
else: |
343
|
|
|
return True |
344
|
|
|
|
345
|
|
|
def log(self, level, format_string, *args, **kwargs): |
346
|
|
|
level_mapping = { |
347
|
|
|
'CRITICAL': logging.CRITICAL, |
348
|
|
|
'ERROR': logging.ERROR, |
349
|
|
|
'WARNING': logging.WARNING, |
350
|
|
|
'INFO': logging.INFO, |
351
|
|
|
'DEBUG': logging.DEBUG, |
352
|
|
|
'NOTSET': logging.NOTSET, |
353
|
|
|
} |
354
|
|
|
level_numeric = level_mapping[level] if level in level_mapping else level_mapping['NOTSET'] |
355
|
|
|
if self.pk: |
356
|
|
|
log_prefix = "<{} pk={}>".format(self.__class__.__name__, self.pk) |
357
|
|
|
else: |
358
|
|
|
log_prefix = "<{} new>".format(self.__class__.__name__) |
359
|
|
|
return logger.log(level_numeric, "{} {}".format(log_prefix, format_string.format(*args, **kwargs))) |
360
|
|
|
|
361
|
|
|
def can_modify(self, user=None): |
362
|
|
|
"""Determines whether the submission can be modified. |
363
|
|
|
Returns a boolean value. |
364
|
|
|
The 'user' parameter is optional and additionally checks whether |
365
|
|
|
the given user is authorized to perform these actions. |
366
|
|
|
|
367
|
|
|
This function checks the submission states and assignment deadlines.""" |
368
|
|
|
|
369
|
|
|
# The user must be authorized to commit these actions. |
370
|
|
|
if user and not self.user_can_modify(user): |
371
|
|
|
#self.log('DEBUG', "Submission cannot be modified, user is not an authorized user ({!r} not in {!r})", user, self.authorized_users) |
372
|
|
|
return False |
373
|
|
|
|
374
|
|
|
# Modification of submissions, that are withdrawn, graded or currently being graded, is prohibited. |
375
|
|
|
if self.state in [self.WITHDRAWN, self.GRADED, self.GRADING_IN_PROGRESS, ]: |
376
|
|
|
#self.log('DEBUG', "Submission cannot be modified, is in state '{}'", self.state) |
377
|
|
|
return False |
378
|
|
|
|
379
|
|
|
# Modification of closed submissions is prohibited. |
380
|
|
|
if self.is_closed(): |
381
|
|
|
if self.assignment.is_graded(): |
382
|
|
|
# There is a grading procedure, so taking it back would invalidate the tutors work |
383
|
|
|
#self.log('DEBUG', "Submission cannot be modified, it is closed and graded") |
384
|
|
|
return False |
385
|
|
|
else: |
386
|
|
|
#self.log('DEBUG', "Closed submission can be modified, since it has no grading scheme.") |
387
|
|
|
return True |
388
|
|
|
|
389
|
|
|
# Submissions, that are executed right now, cannot be modified |
390
|
|
|
if self.state in [self.TEST_VALIDITY_PENDING, self.TEST_FULL_PENDING]: |
391
|
|
|
if not self.file_upload: |
392
|
|
|
self.log( |
393
|
|
|
'CRITICAL', "Submission is in invalid state! State is '{}', but there is no file uploaded!", self.state) |
394
|
|
|
raise AssertionError() |
395
|
|
|
return False |
396
|
|
|
if self.file_upload.is_executed(): |
397
|
|
|
# The above call informs that the uploaded file is being executed, or execution has been completed. |
398
|
|
|
# Since the current state is 'PENDING', the execution cannot yet be completed. |
399
|
|
|
# Thus, the submitted file is being executed right now. |
400
|
|
|
return False |
401
|
|
|
|
402
|
|
|
# Submissions must belong to an assignment. |
403
|
|
|
if not self.assignment: |
404
|
|
|
self.log('CRITICAL', "Submission does not belong to an assignment!") |
405
|
|
|
raise AssertionError() |
406
|
|
|
|
407
|
|
|
# Submissions, that belong to an assignment where the hard deadline has passed, |
408
|
|
|
# cannot be modified. |
409
|
|
|
if self.assignment.hard_deadline and timezone.now() > self.assignment.hard_deadline: |
410
|
|
|
#self.log('DEBUG', "Submission cannot be modified - assignment's hard deadline has passed (hard deadline is: {})", self.assignment.hard_deadline) |
411
|
|
|
return False |
412
|
|
|
|
413
|
|
|
# The soft deadline has no effect (yet). |
414
|
|
|
if self.assignment.soft_deadline: |
415
|
|
|
if timezone.now() > self.assignment.soft_deadline: |
416
|
|
|
# The soft deadline has passed |
417
|
|
|
pass # do nothing. |
418
|
|
|
|
419
|
|
|
#self.log('DEBUG', "Submission can be modified.") |
420
|
|
|
return True |
421
|
|
|
|
422
|
|
|
def can_withdraw(self, user=None): |
423
|
|
|
"""Determines whether a submisison can be withdrawn. |
424
|
|
|
Returns a boolean value. |
425
|
|
|
|
426
|
|
|
Requires: can_modify. |
427
|
|
|
|
428
|
|
|
Currently, the conditions for modifications and withdrawal are the same.""" |
429
|
|
|
return self.can_modify(user=user) |
430
|
|
|
|
431
|
|
|
def can_reupload(self, user=None): |
432
|
|
|
"""Determines whether a submission can be re-uploaded. |
433
|
|
|
Returns a boolean value. |
434
|
|
|
|
435
|
|
|
Requires: can_modify. |
436
|
|
|
|
437
|
|
|
Re-uploads are allowed only when test executions have failed.""" |
438
|
|
|
|
439
|
|
|
# Re-uploads are allowed only when test executions have failed. |
440
|
|
|
if self.state not in (self.TEST_VALIDITY_FAILED, self.TEST_FULL_FAILED): |
441
|
|
|
return False |
442
|
|
|
|
443
|
|
|
# It must be allowed to modify the submission. |
444
|
|
|
if not self.can_modify(user=user): |
445
|
|
|
return False |
446
|
|
|
|
447
|
|
|
return True |
448
|
|
|
|
449
|
|
|
def user_can_modify(self, user): |
450
|
|
|
"""Determines whether a user is allowed to modify a specific submission in general. |
451
|
|
|
Returns a boolean value. |
452
|
|
|
|
453
|
|
|
A user is authorized when he is part of the authorized users (submitter and authors).""" |
454
|
|
|
return user in self.authorized_users |
455
|
|
|
|
456
|
|
|
@property |
457
|
|
|
def authorized_users(self): |
458
|
|
|
"""Returns a list of all authorized users (submitter and additional authors).""" |
459
|
|
|
return [self.submitter, ] + list(self.authors.all()) |
460
|
|
|
|
461
|
|
|
def is_withdrawn(self): |
462
|
|
|
return self.state == self.WITHDRAWN |
463
|
|
|
|
464
|
|
|
def is_closed(self): |
465
|
|
|
return self.state in [self.CLOSED, self.CLOSED_TEST_FULL_PENDING] |
466
|
|
|
|
467
|
|
|
def is_grading_finished(self): |
468
|
|
|
return self.state in [self.GRADED, self.CLOSED, self.CLOSED_TEST_FULL_PENDING] |
469
|
|
|
|
470
|
|
|
def show_grading(self): |
471
|
|
|
return self.assignment.gradingScheme != None and self.is_closed() |
472
|
|
|
|
473
|
|
|
def get_initial_state(self): |
474
|
|
|
''' |
475
|
|
|
Return first state for this submission after upload, |
476
|
|
|
which depends on the kind of assignment. |
477
|
|
|
''' |
478
|
|
|
if not self.assignment.attachment_is_tested(): |
479
|
|
|
return Submission.SUBMITTED |
480
|
|
|
else: |
481
|
|
|
if self.assignment.attachment_test_validity: |
482
|
|
|
return Submission.TEST_VALIDITY_PENDING |
483
|
|
|
elif self.assignment.attachment_test_full: |
484
|
|
|
return Submission.TEST_FULL_PENDING |
485
|
|
|
|
486
|
|
|
def state_for_students(self): |
487
|
|
|
''' |
488
|
|
|
Return human-readable description of current state for students. |
489
|
|
|
''' |
490
|
|
|
try: |
491
|
|
|
return dict(self.STUDENT_STATES)[self.state] |
492
|
|
|
except: |
493
|
|
|
# deal with old databases that have pre 0.7 states, such as "FC" |
494
|
|
|
return "Unknown" |
495
|
|
|
|
496
|
|
|
def state_for_tutors(self): |
497
|
|
|
''' |
498
|
|
|
Return human-readable description of current state for tutors. |
499
|
|
|
''' |
500
|
|
|
return dict(self.STATES)[self.state] |
501
|
|
|
|
502
|
|
|
def grading_file_url(self): |
503
|
|
|
# to implement access protection, we implement our own download |
504
|
|
|
# this implies that the Apache media serving is disabled |
505
|
|
|
return reverse('submission_grading_file', args=(self.pk,)) |
506
|
|
|
|
507
|
|
|
def _save_test_result(self, machine, text_student, text_tutor, kind, perf_data): |
508
|
|
|
result = SubmissionTestResult( |
509
|
|
|
result=text_student, |
510
|
|
|
result_tutor=text_tutor, |
511
|
|
|
machine=machine, |
512
|
|
|
kind=kind, |
513
|
|
|
perf_data=perf_data, |
514
|
|
|
submission_file=self.file_upload) |
515
|
|
|
result.save() |
516
|
|
|
|
517
|
|
|
def _get_test_result(self, kind): |
518
|
|
|
try: |
519
|
|
|
return self.file_upload.test_results.filter(kind=kind).order_by('-created')[0] |
520
|
|
|
except: |
521
|
|
|
return None |
522
|
|
|
|
523
|
|
|
def save_fetch_date(self): |
524
|
|
|
SubmissionFile.objects.filter( |
525
|
|
|
pk=self.file_upload.pk).update(fetched=datetime.now()) |
526
|
|
|
|
527
|
|
|
def get_fetch_date(self): |
528
|
|
|
return self.file_upload.fetched |
529
|
|
|
|
530
|
|
|
def clean_fetch_date(self): |
531
|
|
|
SubmissionFile.objects.filter( |
532
|
|
|
pk=self.file_upload.pk).update(fetched=None) |
533
|
|
|
|
534
|
|
|
def save_validation_result(self, machine, text_student, text_tutor, perf_data=None): |
535
|
|
|
self._save_test_result( |
536
|
|
|
machine, text_student, text_tutor, SubmissionTestResult.VALIDITY_TEST, perf_data) |
537
|
|
|
|
538
|
|
|
def save_fulltest_result(self, machine, text_tutor, perf_data=None): |
539
|
|
|
self._save_test_result( |
540
|
|
|
machine, None, text_tutor, SubmissionTestResult.FULL_TEST, perf_data) |
541
|
|
|
|
542
|
|
|
def get_validation_result(self): |
543
|
|
|
''' |
544
|
|
|
Return the most recent validity test result object for this submission. |
545
|
|
|
''' |
546
|
|
|
return self._get_test_result(SubmissionTestResult.VALIDITY_TEST) |
547
|
|
|
|
548
|
|
|
def get_fulltest_result(self): |
549
|
|
|
''' |
550
|
|
|
Return the most recent full test result object for this submission. |
551
|
|
|
''' |
552
|
|
|
return self._get_test_result(SubmissionTestResult.FULL_TEST) |
553
|
|
|
|
554
|
|
|
def inform_student(self, state): |
555
|
|
|
''' |
556
|
|
|
We hand-in explicitely about which new state we want to inform, |
557
|
|
|
since this may not be reflected in the model at the moment. |
558
|
|
|
''' |
559
|
|
|
mails.inform_student(self, state) |
560
|
|
|
|
561
|
|
|
def info_file(self, delete=True): |
562
|
|
|
''' |
563
|
|
|
Prepares an open temporary file with information about the submission. |
564
|
|
|
Closing it will delete it, which must be considered by the caller. |
565
|
|
|
This file is not readable, since the tempfile library wants either readable or writable files. |
566
|
|
|
''' |
567
|
|
|
info = tempfile.NamedTemporaryFile( |
568
|
|
|
mode='wt', encoding='utf-8', delete=delete) |
569
|
|
|
info.write("Submission ID:\t%u\n" % self.pk) |
570
|
|
|
info.write("Submitter:\t%s (%u)\n" % |
571
|
|
|
(self.submitter.get_full_name(), self.submitter.pk)) |
572
|
|
|
info.write("Authors:\n") |
573
|
|
|
for auth in self.authors.all(): |
574
|
|
|
info.write("\t%s (%u)\n" % (auth.get_full_name(), auth.pk)) |
575
|
|
|
info.write("\n") |
576
|
|
|
info.write("Creation:\t%s\n" % str(self.created)) |
577
|
|
|
info.write("Last modification:\t%s\n" % str(self.modified)) |
578
|
|
|
info.write("Status:\t%s\n" % self.state_for_students()) |
579
|
|
|
if self.grading: |
580
|
|
|
info.write("Grading:\t%s\n" % str(self.grading)) |
581
|
|
|
if self.notes: |
582
|
|
|
notes = self.notes |
583
|
|
|
info.write("Author notes:\n-------------\n%s\n\n" % notes) |
584
|
|
|
if self.grading_notes: |
585
|
|
|
notes = self.grading_notes |
586
|
|
|
info.write("Grading notes:\n--------------\n%s\n\n" % notes) |
587
|
|
|
info.flush() # no closing here, because it disappears then |
588
|
|
|
return info |
589
|
|
|
|
590
|
|
|
def copy_file_upload(self, targetdir): |
591
|
|
|
''' |
592
|
|
|
Copies the currently valid file upload into the given directory. |
593
|
|
|
If possible, the content is un-archived in the target directory. |
594
|
|
|
''' |
595
|
|
|
assert(self.file_upload) |
596
|
|
|
# unpack student data to temporary directory |
597
|
|
|
# os.chroot is not working with tarfile support |
598
|
|
|
tempdir = tempfile.mkdtemp() |
599
|
|
|
try: |
600
|
|
|
if zipfile.is_zipfile(self.file_upload.absolute_path()): |
601
|
|
|
f = zipfile.ZipFile(self.file_upload.absolute_path(), 'r') |
602
|
|
|
f.extractall(targetdir) |
603
|
|
|
elif tarfile.is_tarfile(self.file_upload.absolute_path()): |
604
|
|
|
tar = tarfile.open(self.file_upload.absolute_path()) |
605
|
|
|
tar.extractall(targetdir) |
606
|
|
|
tar.close() |
607
|
|
|
else: |
608
|
|
|
# unpacking not possible, just copy it |
609
|
|
|
shutil.copyfile(self.file_upload.absolute_path(), |
610
|
|
|
targetdir + "/" + self.file_upload.basename()) |
611
|
|
|
except IOError: |
612
|
|
|
logger.error("I/O exception while accessing %s." % |
613
|
|
|
(self.file_upload.absolute_path())) |
614
|
|
|
pass |
615
|
|
|
|
616
|
|
|
def add_to_zipfile(self, z): |
617
|
|
|
submitter = "user" + str(self.submitter.pk) |
618
|
|
|
assdir = self.assignment.directory_name_with_course() |
619
|
|
|
if self.modified: |
620
|
|
|
modified = self.modified.strftime("%Y_%m_%d_%H_%M_%S") |
621
|
|
|
else: |
622
|
|
|
modified = self.created.strftime("%Y_%m_%d_%H_%M_%S") |
623
|
|
|
state = self.state_for_students().replace(" ", "_").lower() |
624
|
|
|
submdir = "%s/%u_%s/" % (assdir, self.pk, state) |
625
|
|
|
if self.file_upload: |
626
|
|
|
# Copy student upload |
627
|
|
|
tempdir = tempfile.mkdtemp() |
628
|
|
|
self.copy_file_upload(tempdir) |
629
|
|
|
# Add content to final ZIP file |
630
|
|
|
allfiles = [(subdir, files) |
631
|
|
|
for (subdir, dirs, files) in os.walk(tempdir)] |
632
|
|
|
for subdir, files in allfiles: |
633
|
|
|
for f in files: |
634
|
|
|
zip_relative_dir = subdir.replace(tempdir, "") |
635
|
|
|
zip_relative_file = '%s/%s' % (zip_relative_dir, f) |
636
|
|
|
z.write(subdir + "/" + f, submdir + 'student_files/%s' % |
637
|
|
|
zip_relative_file, zipfile.ZIP_DEFLATED) |
638
|
|
|
# add text file with additional information |
639
|
|
|
info = self.info_file() |
640
|
|
|
z.write(info.name, submdir + "info.txt") |
641
|
|
|
|