Passed
Push — master ( b8e570...5310e7 )
by Peter
01:38
created

MachinesView   A

Complexity

Total Complexity 2

Size/Duplication

Total Lines 23
Duplicated Lines 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
wmc 2
c 1
b 0
f 0
dl 0
loc 23
rs 10

1 Method

Rating   Name   Duplication   Size   Complexity  
A post() 0 8 2
1
'''
2
    These are the views being called by the executor.
3
    They security currently relies on a provided shared secret.
4
5
    We therefore assume that executors come from a trusted network.
6
'''
7
8
from datetime import datetime, timedelta
9
import os
10
11
from django.core.exceptions import PermissionDenied
12
from django.core.mail import mail_managers
13
from django.http import Http404, HttpResponse
14
from django.shortcuts import get_object_or_404
15
from django.views.decorators.csrf import csrf_exempt
16
from django.views.generic import DetailView, View
17
from django.utils.decorators import method_decorator
18
19
from opensubmit import settings
20
from opensubmit.models import Assignment, Submission, TestMachine, SubmissionFile
21
from opensubmit.mails import inform_student
22
from opensubmit.views.helpers import BinaryDownloadMixin
23
24
import logging
25
logger = logging.getLogger('OpenSubmit')
26
27
28 View Code Duplication
class ValidityScriptView(BinaryDownloadMixin, DetailView):
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated in your project.
Loading history...
29
    '''
30
    Download of validity test script for an assignment.
31
    '''
32
    model = Assignment
33
34
    def get_object(self, queryset=None):
35
        ass = super().get_object(queryset)
36
        if self.kwargs['secret'] != settings.JOB_EXECUTOR_SECRET:
37
            raise PermissionDenied
38
        else:
39
            if not ass.validity_script_download:
40
                raise PermissionDenied
41
        self.f = ass.attachment_test_validity
42
        self.fname = self.f.name[self.f.name.rfind('/') + 1:]
43
        return ass
44
45
46 View Code Duplication
class FullScriptView(BinaryDownloadMixin, DetailView):
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated in your project.
Loading history...
47
    '''
48
    Download of full test script for an assignment.
49
    '''
50
    model = Assignment
51
52
    def get_object(self, queryset=None):
53
        ass = super().get_object(queryset)
54
        if self.kwargs['secret'] != settings.JOB_EXECUTOR_SECRET:
55
            raise PermissionDenied
56
        self.f = ass.attachment_test_full
57
        self.fname = self.f.name[self.f.name.rfind('/') + 1:]
58
        return ass
59
60
61
@method_decorator(csrf_exempt, name='dispatch')
62
class MachinesView(View):
63
    '''
64
    View for sending details about an executor machine,
65
66
    POST requests are expected to contain the following parameters:
67
                'Config',
68
                'Secret',
69
                'UUID'
70
71
    TODO: Change to a DetailView would demand to have the uuid
72
    in the URL as pk. Demands an incompatible change in the executor protocol.
73
    '''
74
    http_method_names = ['post']
75
76
    def post(self, request):
77
        if self.request.POST['Secret'] != settings.JOB_EXECUTOR_SECRET:
78
            raise PermissionDenied
79
        machine, created = TestMachine.objects.get_or_create(host=request.POST['UUID'])
80
        machine.last_contact = datetime.now()
81
        machine.config = request.POST['Config']
82
        machine.save()
83
        return HttpResponse(status=201)
84
85
86
@csrf_exempt
87
def jobs(request):
88
    ''' This is the view used by the executor.py scripts for getting / putting the test results.
89
        Fetching some file for testing is changing the database, so using GET here is not really RESTish. Whatever.
90
        A visible shared secret in the request is no problem, since the executors come
91
        from trusted networks. The secret only protects this view from outside foreigners.
92
93
        TODO: Make it a real API, based on some framework.
94
        TODO: Factor out state model from this method into some model.
95
96
        POST requests with 'Action'='get_config' are expected to contain the following parameters:
97
                    'MachineId',
98
                    'Config',
99
                    'Secret',
100
                    'UUID'
101
102
        All other POST requests are expected to contain the following parameters:
103
                    'SubmissionFileId',
104
                    'Message',
105
                    'ErrorCode',
106
                    'Action',
107
                    'Secret',
108
                    'UUID'
109
110
        GET requests are expected to contain the following parameters:
111
                    'Secret',
112
                    'UUID'
113
114
        GET reponses deliver the following elements in the header:
115
                    'SubmissionFileId',
116
                    'Timeout',
117
                    'Action',
118
                    'PostRunValidation'
119
    '''
120
    try:
121
        if request.method == 'GET':
122
            secret = request.GET['Secret']
123
            uuid = request.GET['UUID']
124
        elif request.method == 'POST':
125
            secret = request.POST['Secret']
126
            uuid = request.POST['UUID']
127
    except Exception as e:
128
        logger.error(
129
            "Error finding the neccessary data in the executor request: " + str(e))
130
        raise PermissionDenied
131
132
    if secret != settings.JOB_EXECUTOR_SECRET:
133
        raise PermissionDenied
134
135
    # Update last_contact information for test machine
136
    machine, created = TestMachine.objects.update_or_create(
137
        host=uuid, defaults={'last_contact': datetime.now()})
138
    if created:
139
        # ask for configuration of new execution hosts by returning the according action
140
        logger.debug(
141
            "Test machine is unknown, creating entry and asking executor for configuration.")
142
        response = HttpResponse()
143
        response['Action'] = 'get_config'
144
        response['APIVersion'] = '1.0.0'  # semantic versioning
145
        response['MachineId'] = machine.pk
146
        return response
147
148
    if not machine.enabled:
149
        # Act like no jobs are given for him
150
        raise Http404
151
152
    if request.method == "GET":
153
        # Clean up submissions where the answer from the executors took too long
154
        pending_submissions = Submission.pending_tests.filter(
155
            file_upload__fetched__isnull=False)
156
        #logger.debug("%u pending submission(s)"%(len(pending_submissions)))
157
        for sub in pending_submissions:
158
            max_delay = timedelta(
159
                seconds=sub.assignment.attachment_test_timeout)
160
            # There is a small chance that meanwhile the result was delivered, so fetched became NULL
161
            if sub.file_upload.fetched and sub.file_upload.fetched + max_delay < datetime.now():
162
                logger.debug(
163
                    "Resetting executor fetch status for submission %u, due to timeout" % sub.pk)
164
                # TODO:  Late delivery for such a submission by the executor may lead to result overwriting. Check this.
165
                sub.clean_fetch_date()
166
                if sub.state == Submission.TEST_VALIDITY_PENDING:
167
                    sub.save_validation_result(
168
                        machine, "Killed due to non-reaction. Please check your application for deadlocks or keyboard input.", "Killed due to non-reaction on timeout signals.")
169
                    sub.state = Submission.TEST_VALIDITY_FAILED
170
                    sub.inform_student(sub.state)
171
                if sub.state == Submission.TEST_FULL_PENDING:
172
                    sub.save_fulltest_result(
173
                        machine, "Killed due to non-reaction on timeout signals. Student not informed, since this was the full test.")
174
                    sub.state = Submission.TEST_FULL_FAILED
175
                sub.save()
176
177
        # Now get an appropriate submission.
178
        submissions = Submission.pending_tests
179
        submissions = submissions.filter(assignment__in=machine.assignments.all()) \
180
                                 .filter(file_upload__isnull=False) \
181
                                 .filter(file_upload__fetched__isnull=True)
182
        if len(submissions) == 0:
183
            # Nothing found to be fetchable
184
            #logger.debug("No pending work for executors")
185
            raise Http404
186
        else:
187
            sub = submissions[0]
188
        sub.save_fetch_date()
189
        sub.modified = datetime.now()
190
        sub.save()
191
192
        # create HTTP response with file download
193
        f = sub.file_upload.attachment
194
        # on dev server, we sometimes have stale database entries
195
        if not os.access(f.path, os.F_OK):
196
            mail_managers('Warning: Missing file',
197
                          'Missing file on storage for submission file entry %u: %s' % (
198
                              sub.file_upload.pk, str(sub.file_upload.attachment)), fail_silently=True)
199
            raise Http404
200
        response = HttpResponse(f, content_type='application/binary')
201
        response['APIVersion'] = '1.0.0'  # semantic versioning
202
        response['Content-Disposition'] = 'attachment; filename="%s"' % sub.file_upload.basename()
203
        response['SubmissionFileId'] = str(sub.file_upload.pk)
204
        response['SubmissionOriginalFilename'] = sub.file_upload.original_filename
205
        response['SubmissionId'] = str(sub.pk)
206
        response['SubmitterName'] = sub.submitter.get_full_name()
207
        response['SubmitterStudentId'] = sub.submitter.profile.student_id
208
        response['AuthorNames'] = sub.authors.all()
209
        response['SubmitterStudyProgram'] = str(sub.submitter.profile.study_program)
210
        response['Course'] = str(sub.assignment.course)
211
        response['Assignment'] = str(sub.assignment)
212
        response['Timeout'] = sub.assignment.attachment_test_timeout
213
        if sub.state == Submission.TEST_VALIDITY_PENDING:
214
            response['Action'] = 'test_validity'
215
            response['PostRunValidation'] = sub.assignment.validity_test_url()
216
        elif sub.state == Submission.TEST_FULL_PENDING or sub.state == Submission.CLOSED_TEST_FULL_PENDING:
217
            response['Action'] = 'test_full'
218
            response['PostRunValidation'] = sub.assignment.full_test_url()
219
        else:
220
            assert (False)
221
        logger.debug("Delivering submission %u as new %s job" %
222
                     (sub.pk, response['Action']))
223
        return response
224
225
    elif request.method == "POST":
226
        # first check if this is just configuration data, and not a job result
227
        if request.POST['Action'] == 'get_config':
228
            machine = TestMachine.objects.get(
229
                pk=int(request.POST['MachineId']))
230
            machine.config = request.POST['Config']
231
            machine.save()
232
            return HttpResponse(status=201)
233
234
        # executor.py is providing the results as POST parameters
235
        sid = request.POST['SubmissionFileId']
236
        submission_file = get_object_or_404(SubmissionFile, pk=sid)
237
        sub = submission_file.submissions.all()[0]
238
        logger.debug("Storing executor results for submission %u" % (sub.pk))
239
        error_code = int(request.POST['ErrorCode'])
240
        # Job state: Waiting for validity test
241
        # Possible with + without full test
242
        # Possible with + without grading
243
        if request.POST['Action'] == 'test_validity' and sub.state == Submission.TEST_VALIDITY_PENDING:
244
            sub.save_validation_result(
245
                machine, request.POST['Message'], request.POST['MessageTutor'])
246
            if error_code == 0:
247
                # We have a full test
248
                if sub.assignment.attachment_test_full:
249
                    logger.debug(
250
                        "Validity test working, setting state to pending full test")
251
                    sub.state = Submission.TEST_FULL_PENDING
252
                # We have no full test
253
                else:
254
                    logger.debug(
255
                        "Validity test working, setting state to tested")
256
                    sub.state = Submission.SUBMITTED_TESTED
257
                    if not sub.assignment.is_graded():
258
                        # Assignment is not graded. We are done here.
259
                        sub.state = Submission.CLOSED
260
                        sub.inform_student(Submission.CLOSED)
261
            else:
262
                logger.debug(
263
                    "Validity test not working, setting state to failed")
264
                sub.state = Submission.TEST_VALIDITY_FAILED
265
            sub.inform_student(sub.state)
266
        # Job state: Waiting for full test
267
        # Possible with + without grading
268
        elif request.POST['Action'] == 'test_full' and sub.state == Submission.TEST_FULL_PENDING:
269
            sub.save_fulltest_result(
270
                machine, request.POST['MessageTutor'])
271
            if error_code == 0:
272
                if sub.assignment.is_graded():
273
                    logger.debug("Full test working, setting state to tested (since graded)")
274
                    sub.state = Submission.SUBMITTED_TESTED
275
                else:
276
                    logger.debug("Full test working, setting state to closed (since not graded)")
277
                    sub.state = Submission.CLOSED
278
                    inform_student(sub, Submission.CLOSED)
279
            else:
280
                logger.debug("Full test not working, setting state to failed")
281
                sub.state = Submission.TEST_FULL_FAILED
282
                # full tests may be performed several times and are meant to be a silent activity
283
                # therefore, we send no mail to the student here
284
        # Job state: Waiting for full test of already closed jobs ("re-test")
285
        # Grading is already done
286
        elif request.POST['Action'] == 'test_full' and sub.state == Submission.CLOSED_TEST_FULL_PENDING:
287
            logger.debug(
288
                "Closed full test done, setting state to closed again")
289
            sub.save_fulltest_result(
290
                machine, request.POST['MessageTutor'])
291
            sub.state = Submission.CLOSED
292
            # full tests may be performed several times and are meant to be a silent activity
293
            # therefore, we send no mail to the student here
294
        elif request.POST['Action'] == 'test_validity' and sub.state == Submission.TEST_VALIDITY_FAILED:
295
            # Can happen if the validation is set to failed due to timeout, but the executor delivers the late result.
296
            # Happens in reality only with >= 2 executors, since the second one is pulling for new jobs and triggers
297
            # the timeout check while the first one is still stucked with the big job.
298
            # Can be ignored.
299
            logger.debug(
300
                "Ignoring executor result, since the submission is already marked as failed.")
301
        else:
302
            msg = '''
303
                Dear OpenSubmit administrator,
304
305
                the executors returned some result, but this does not fit to the current submission state.
306
                This is a strong indication for a bug in OpenSubmit - sorry for that.
307
                The system will ignore the report from executor and mark the job as to be repeated.
308
                Please report this on the project GitHub page for further investigation.
309
310
                Submission ID: %u
311
                Submission File ID reported by the executor: %u
312
                Action reported by the executor: %s
313
                Current state of the submission: %s (%s)
314
                Message from the executor: %s
315
                Error code from the executor: %u
316
                ''' % (sub.pk, submission_file.pk, request.POST['Action'],
317
                       sub.state_for_tutors(), sub.state,
318
                       request.POST['Message'], error_code)
319
            mail_managers('Warning: Inconsistent job state',
320
                          msg, fail_silently=True)
321
        # Mark work as done
322
        sub.save()
323
        sub.clean_fetch_date()
324
        return HttpResponse(status=201)
325
326