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