xqueue.py 8.54 KB
Newer Older
1 2
"""
Stub implementation of XQueue for acceptance tests.
3 4 5 6

Configuration values:
    "default" (dict): Default response to be sent to LMS as a grade for a submission
    "<submission>" (dict): Grade response to return for submissions containing the text <submission>
7
    "register_submission_url" (str): URL to send grader payloads when we receive a submission
8 9

If no grade response is configured, a default response will be returned.
10 11
"""

12
import copy
13
import json
14
from threading import Timer
15

16 17 18 19
from requests import post

from .http import StubHttpRequestHandler, StubHttpService, require_params

20 21 22 23 24 25 26 27 28

class StubXQueueHandler(StubHttpRequestHandler):
    """
    A handler for XQueue POST requests.
    """

    DEFAULT_RESPONSE_DELAY = 2
    DEFAULT_GRADE_RESPONSE = {'correct': True, 'score': 1, 'msg': ''}

29
    @require_params('POST', 'xqueue_body', 'xqueue_header')
30 31 32 33 34 35 36 37 38 39 40 41
    def do_POST(self):
        """
        Handle a POST request from the client

        Sends back an immediate success/failure response.
        It then POSTS back to the client with grading results.
        """
        msg = "XQueue received POST request {0} to path {1}".format(self.post_dict, self.path)
        self.log_message(msg)

        # Respond only to grading requests
        if self._is_grade_request():
42 43

            # If configured, send the grader payload to other services.
44 45
            # TODO TNL-3906
            # self._register_submission(self.post_dict['xqueue_body'])
46

47 48 49 50 51 52
            try:
                xqueue_header = json.loads(self.post_dict['xqueue_header'])
                callback_url = xqueue_header['lms_callback_url']

            except KeyError:
                # If the message doesn't have a header or body,
53
                # then it's malformed.  Respond with failure
54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72
                error_msg = "XQueue received invalid grade request"
                self._send_immediate_response(False, message=error_msg)

            except ValueError:
                # If we could not decode the body or header,
                # respond with failure
                error_msg = "XQueue could not decode grade request"
                self._send_immediate_response(False, message=error_msg)

            else:
                # Send an immediate response of success
                # The grade request is formed correctly
                self._send_immediate_response(True)

                # Wait a bit before POSTing back to the callback url with the
                # grade result configured by the server
                # Otherwise, the problem will not realize it's
                # queued and it will keep waiting for a response indefinitely
                delayed_grade_func = lambda: self._send_grade_response(
73
                    callback_url, xqueue_header, self.post_dict['xqueue_body']
74 75
                )

76 77
                delay = self.server.config.get('response_delay', self.DEFAULT_RESPONSE_DELAY)
                Timer(delay, delayed_grade_func).start()
78 79 80 81

        # If we get a request that's not to the grading submission
        # URL, return an error
        else:
82
            self._send_immediate_response(False, message="Invalid request URL")
83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103

    def _send_immediate_response(self, success, message=""):
        """
        Send an immediate success/failure message
        back to the client
        """

        # Send the response indicating success/failure
        response_str = json.dumps(
            {'return_code': 0 if success else 1, 'content': message}
        )

        if self._is_grade_request():
            self.send_response(
                200, content=response_str, headers={'Content-type': 'text/plain'}
            )
            self.log_message("XQueue: sent response {0}".format(response_str))

        else:
            self.send_response(500)

104
    def _send_grade_response(self, postback_url, xqueue_header, xqueue_body_json):
105 106
        """
        POST the grade response back to the client
107 108 109 110 111 112 113 114 115 116 117
        using the response provided by the server configuration.

        Uses the server configuration to determine what response to send:
        1) Specific response for submissions containing matching text in `xqueue_body`
        2) Default submission configured by client
        3) Default submission

        `postback_url` is the URL the client told us to post back to
        `xqueue_header` (dict) is the full header the client sent us, which we will send back
        to the client so it can authenticate us.
        `xqueue_body_json` (json-encoded string) is the body of the submission the client sent us.
118
        """
119 120 121 122 123 124 125 126 127
        # First check if we have a configured response that matches the submission body
        grade_response = None

        # This matches the pattern against the JSON-encoded xqueue_body
        # This is very simplistic, but sufficient to associate a student response
        # with a grading response.
        # There is a danger here that a submission will match multiple response patterns.
        # Rather than fail silently (which could cause unpredictable behavior in tests)
        # we abort and log a debugging message.
128
        for pattern, response in self.server.queue_responses:
129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146

            if pattern in xqueue_body_json:
                if grade_response is None:
                    grade_response = response

                # Multiple matches, so abort and log an error
                else:
                    self.log_error(
                        "Multiple response patterns matched '{0}'".format(xqueue_body_json),
                    )
                    return

        # Fall back to the default grade response configured for this queue,
        # then to the default response.
        if grade_response is None:
            grade_response = self.server.config.get(
                'default', copy.deepcopy(self.DEFAULT_GRADE_RESPONSE)
            )
147 148 149 150 151 152 153 154 155 156

        # Wrap the message in <div> tags to ensure that it is valid XML
        if isinstance(grade_response, dict) and 'msg' in grade_response:
            grade_response['msg'] = "<div>{0}</div>".format(grade_response['msg'])

        data = {
            'xqueue_header': json.dumps(xqueue_header),
            'xqueue_body': json.dumps(grade_response)
        }

157 158
        post(postback_url, data=data)
        self.log_message("XQueue: sent grading response {0} to {1}".format(data, postback_url))
159

160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193
    def _register_submission(self, xqueue_body_json):
        """
        If configured, send the submission's grader payload to another service.
        """
        url = self.server.config.get('register_submission_url')

        # If not configured, do not need to send anything
        if url is not None:

            try:
                xqueue_body = json.loads(xqueue_body_json)
            except ValueError:
                self.log_error(
                    "Could not decode XQueue body as JSON: '{0}'".format(xqueue_body_json))

            else:

                # Retrieve the grader payload, which should be a JSON-encoded dict.
                # We pass the payload directly to the service we are notifying, without
                # inspecting the contents.
                grader_payload = xqueue_body.get('grader_payload')

                if grader_payload is not None:
                    response = post(url, data={'grader_payload': grader_payload})
                    if not response.ok:
                        self.log_error(
                            "Could register submission at URL '{0}'.  Status was {1}".format(
                                url, response.status_code))

                else:
                    self.log_message(
                        "XQueue body is missing 'grader_payload' key: '{0}'".format(xqueue_body)
                    )

194
    def _is_grade_request(self):
195 196 197
        """
        Return a boolean indicating whether the requested URL indicates a submission.
        """
198 199 200 201 202 203 204 205 206
        return 'xqueue/submit' in self.path


class StubXQueueService(StubHttpService):
    """
    A stub XQueue grading server that responds to POST requests to localhost.
    """

    HANDLER_CLASS = StubXQueueHandler
207 208 209 210 211 212 213 214 215 216 217 218 219
    NON_QUEUE_CONFIG_KEYS = ['default', 'register_submission_url']

    @property
    def queue_responses(self):
        """
        Returns a list of (pattern, response) tuples, where `pattern` is a pattern
        to match in the XQueue body, and `response` is a dictionary to return
        as the response from the grader.

        Every configuration key is a queue name,
        except for 'default' and 'register_submission_url' which have special meaning
        """
        return {
220 221
            key: value
            for key, value in self.config.iteritems()
222 223
            if key not in self.NON_QUEUE_CONFIG_KEYS
        }.items()