1
|
|
|
from django.db import models |
2
|
|
|
from django.utils import timezone |
3
|
|
|
from django.conf import settings |
4
|
|
|
from django.urls import reverse |
5
|
|
|
|
6
|
|
|
from .submission import Submission |
7
|
|
|
from .submissionfile import SubmissionFile |
8
|
|
|
from .submissiontestresult import SubmissionTestResult |
9
|
|
|
|
10
|
|
|
import os |
11
|
|
|
from itertools import groupby |
12
|
|
|
|
13
|
|
|
import logging |
14
|
|
|
logger = logging.getLogger('OpenSubmit') |
15
|
|
|
|
16
|
|
|
|
17
|
|
|
class Assignment(models.Model): |
18
|
|
|
''' |
19
|
|
|
An assignment for which students can submit their solution. |
20
|
|
|
''' |
21
|
|
|
|
22
|
|
|
title = models.CharField(max_length=200) |
23
|
|
|
course = models.ForeignKey('Course', related_name='assignments') |
24
|
|
|
download = models.URLField(max_length=200, blank=True, null=True, verbose_name="As link", help_text="External link to the assignment description.") |
25
|
|
|
description = models.FileField(upload_to="assignment_desc", blank=True, null=True, verbose_name='As file', help_text="Uploaded document with the assignment description.") |
26
|
|
|
created = models.DateTimeField(auto_now_add=True, editable=False) |
27
|
|
|
gradingScheme = models.ForeignKey('GradingScheme', related_name="assignments", verbose_name="grading scheme", blank=True, null=True, help_text="Grading scheme for this assignment. Leave empty to have an ungraded assignment.") |
28
|
|
|
publish_at = models.DateTimeField(default=timezone.now, help_text="Shown for students after this point in time. Users with backend rights always see it.") |
29
|
|
|
soft_deadline = models.DateTimeField(blank=True, null=True, help_text="Deadline shown to students. After this point in time, submissions are still possible. Leave empty for only using a hard deadline.") |
30
|
|
|
hard_deadline = models.DateTimeField(blank=True, null=True, help_text="Deadline after which submissions are no longer possible. Can be empty.") |
31
|
|
|
has_attachment = models.BooleanField(default=False, verbose_name="Student file upload ?", help_text="Activate this if the students must upload a (document / ZIP /TGZ) file as solution. Otherwise, they can only provide notes.") |
32
|
|
|
attachment_test_timeout = models.IntegerField(default=30, verbose_name="Timout for tests", help_text="Timeout (in seconds) after which the compilation / validation test / full test is cancelled. The submission is marked as invalid in this case. Intended for student code with deadlocks.") |
33
|
|
|
attachment_test_validity = models.FileField(upload_to="testscripts", blank=True, null=True, verbose_name='Validation script', help_text="If given, the student upload is uncompressed and the script is executed for it on a test machine. Student submissions are marked as valid if this script was successful.") |
34
|
|
|
validity_script_download = models.BooleanField(default=False, verbose_name='Download of validation script ?', help_text='If activated, the students can download the validation script for offline analysis.') |
35
|
|
|
attachment_test_full = models.FileField(upload_to="testscripts", blank=True, null=True, verbose_name='Full test script', help_text='Same as the validation script, but executed AFTER the hard deadline to determine final grading criterias for the submission. Results are not shown to students.') |
36
|
|
|
test_machines = models.ManyToManyField('TestMachine', blank=True, related_name="assignments", help_text="The test machines that will take care of submissions for this assignment.") |
37
|
|
|
max_authors = models.PositiveSmallIntegerField(default=1, help_text="Maximum number of authors (= group size) for this assignment.") |
38
|
|
|
|
39
|
|
|
class Meta: |
40
|
|
|
app_label = 'opensubmit' |
41
|
|
|
|
42
|
|
|
def __str__(self): |
43
|
|
|
return self.title |
44
|
|
|
|
45
|
|
|
def directory_name(self): |
46
|
|
|
''' The assignment name in a format that is suitable for a directory name. ''' |
47
|
|
|
return self.title.replace(" ", "_").replace("\\", "_").replace(",","").lower() |
48
|
|
|
|
49
|
|
|
def directory_name_with_course(self): |
50
|
|
|
''' The assignment name in a format that is suitable for a directory name. ''' |
51
|
|
|
coursename = self.course.directory_name() |
52
|
|
|
assignmentname = self.title.replace(" ", "_").replace("\\", "_").replace(",","").lower() |
53
|
|
|
return coursename + os.sep + assignmentname |
54
|
|
|
|
55
|
|
|
def gradable_submissions(self): |
56
|
|
|
qs = self.valid_submissions() |
57
|
|
|
qs = qs.filter(state__in=[Submission.GRADING_IN_PROGRESS, Submission.SUBMITTED_TESTED, Submission.TEST_FULL_FAILED, Submission.SUBMITTED]) |
58
|
|
|
return qs |
59
|
|
|
|
60
|
|
|
def grading_unfinished_submissions(self): |
61
|
|
|
qs = self.valid_submissions() |
62
|
|
|
qs = qs.filter(state__in=[Submission.GRADING_IN_PROGRESS]) |
63
|
|
|
return qs |
64
|
|
|
|
65
|
|
|
def graded_submissions(self): |
66
|
|
|
qs = self.valid_submissions().filter(state__in=[Submission.GRADED]) |
67
|
|
|
return qs |
68
|
|
|
|
69
|
|
|
def grading_url(self): |
70
|
|
|
''' |
71
|
|
|
Determines the teacher backend link to the filtered list of gradable submissions for this assignment. |
72
|
|
|
''' |
73
|
|
|
grading_url="%s?coursefilter=%u&assignmentfilter=%u&statefilter=tobegraded"%( |
74
|
|
|
reverse('teacher:opensubmit_submission_changelist'), |
75
|
|
|
self.course.pk, self.pk |
76
|
|
|
) |
77
|
|
|
return grading_url |
78
|
|
|
|
79
|
|
|
def authors(self): |
80
|
|
|
qs = self.valid_submissions().values_list('authors',flat=True).distinct() |
81
|
|
|
return qs |
82
|
|
|
|
83
|
|
|
def valid_submissions(self): |
84
|
|
|
qs = self.submissions.exclude(state=Submission.WITHDRAWN) |
85
|
|
|
return qs |
86
|
|
|
|
87
|
|
|
def has_perf_results(self): |
88
|
|
|
''' |
89
|
|
|
Figure out if any submission for this assignment has performance data being available. |
90
|
|
|
''' |
91
|
|
|
num_results = SubmissionTestResult.objects.filter(perf_data__isnull=False).filter(submission_file__submissions__assignment=self).count() |
92
|
|
|
return num_results != 0 |
93
|
|
|
|
94
|
|
|
def is_graded(self): |
95
|
|
|
''' |
96
|
|
|
Checks if this a graded assignment. |
97
|
|
|
''' |
98
|
|
|
return self.gradingScheme is not None |
99
|
|
|
|
100
|
|
|
def validity_test_url(self): |
101
|
|
|
''' |
102
|
|
|
Return absolute download URL for validity test script. |
103
|
|
|
''' |
104
|
|
|
if self.pk and self.has_validity_test(): |
105
|
|
|
return settings.HOST + reverse('validity_script_secret', args=[self.pk, settings.JOB_EXECUTOR_SECRET]) |
106
|
|
|
else: |
107
|
|
|
return None |
108
|
|
|
|
109
|
|
|
def full_test_url(self): |
110
|
|
|
''' |
111
|
|
|
Return absolute download URL for full test script. |
112
|
|
|
Using reverse() seems to be broken with FORCE_SCRIPT in use, so we use direct URL formulation. |
113
|
|
|
''' |
114
|
|
|
if self.pk and self.has_full_test(): |
115
|
|
|
return settings.HOST + reverse('full_testscript_secret', args=[self.pk, settings.JOB_EXECUTOR_SECRET]) |
116
|
|
|
else: |
117
|
|
|
return None |
118
|
|
|
|
119
|
|
|
def url(self): |
120
|
|
|
''' |
121
|
|
|
Return absolute URL for assignment description. |
122
|
|
|
''' |
123
|
|
|
if self.pk: |
124
|
|
|
if self.has_description(): |
125
|
|
|
return settings.HOST + reverse('assignment_description_file', args=[self.pk]) |
126
|
|
|
else: |
127
|
|
|
return self.download |
128
|
|
|
else: |
129
|
|
|
return None |
130
|
|
|
|
131
|
|
|
def has_validity_test(self): |
132
|
|
|
return str(self.attachment_test_validity).strip() != "" |
133
|
|
|
|
134
|
|
|
def has_full_test(self): |
135
|
|
|
return str(self.attachment_test_full).strip() != "" |
136
|
|
|
|
137
|
|
|
def has_description(self): |
138
|
|
|
return str(self.description).strip() != "" |
139
|
|
|
|
140
|
|
|
def attachment_is_tested(self): |
141
|
|
|
return self.has_validity_test() or self.has_full_test() |
142
|
|
|
|
143
|
|
|
def can_create_submission(self, user=None): |
144
|
|
|
''' |
145
|
|
|
Central access control for submitting things related to assignments. |
146
|
|
|
''' |
147
|
|
|
if user: |
148
|
|
|
# Super users, course owners and tutors should be able to test their validations |
149
|
|
|
# before the submission is officially possible. |
150
|
|
|
# They should also be able to submit after the deadline. |
151
|
|
|
if user.is_superuser or user is self.course.owner or self.course.tutors.filter(pk=user.pk).exists(): |
152
|
|
|
return True |
153
|
|
|
if self.course not in user.profile.user_courses(): |
154
|
|
|
# The user is not enrolled in this assignment's course. |
155
|
|
|
logger.debug('Submission not possible, user not enrolled in the course.') |
156
|
|
|
return False |
157
|
|
|
|
158
|
|
|
if user.authored.filter(assignment=self).exclude(state=Submission.WITHDRAWN).count() > 0: |
159
|
|
|
# User already has a valid submission for this assignment. |
160
|
|
|
logger.debug('Submission not possible, user already has one for this assignment.') |
161
|
|
|
return False |
162
|
|
|
|
163
|
|
|
if self.hard_deadline and self.hard_deadline < timezone.now(): |
164
|
|
|
# Hard deadline has been reached. |
165
|
|
|
logger.debug('Submission not possible, hard deadline passed.') |
166
|
|
|
return False |
167
|
|
|
|
168
|
|
|
if self.publish_at > timezone.now() and not user.profile.can_see_future(): |
169
|
|
|
# The assignment has not yet been published. |
170
|
|
|
logger.debug('Submission not possible, assignment has not yet been published.') |
171
|
|
|
return False |
172
|
|
|
|
173
|
|
|
return True |
174
|
|
|
|
175
|
|
|
def add_to_zipfile(self, z): |
176
|
|
|
if self.description: |
177
|
|
|
sourcepath = settings.MEDIA_ROOT + self.description.name |
178
|
|
|
targetpath = self.directory_name_with_course() |
179
|
|
|
targetname = os.path.basename(self.description.name) |
180
|
|
|
z.write(sourcepath, targetpath + os.sep + targetname) |
181
|
|
|
|
182
|
|
|
def duplicate_files(self): |
183
|
|
|
''' |
184
|
|
|
Search for duplicates of submission file uploads for this assignment. |
185
|
|
|
This includes the search in other course, whether inactive or not. |
186
|
|
|
Returns a list of lists, where each latter is a set of duplicate submissions |
187
|
|
|
with at least on of them for this assignment |
188
|
|
|
''' |
189
|
|
|
result=list() |
190
|
|
|
files = SubmissionFile.valid_ones.order_by('md5') |
191
|
|
|
|
192
|
|
|
for key, dup_group in groupby(files, lambda f: f.md5): |
193
|
|
|
file_list=[entry for entry in dup_group] |
194
|
|
|
if len(file_list)>1: |
195
|
|
|
for entry in file_list: |
196
|
|
|
if entry.submissions.filter(assignment=self).count()>0: |
197
|
|
|
result.append([key, file_list]) |
198
|
|
|
break |
199
|
|
|
return result |
200
|
|
|
|
201
|
|
|
|
202
|
|
|
|