diff options
Diffstat (limited to 'yaksh')
-rwxr-xr-x[-rw-r--r--] | yaksh/code_server.py | 274 | ||||
-rw-r--r-- | yaksh/live_server_tests/load_test.py | 4 | ||||
-rw-r--r-- | yaksh/live_server_tests/selenium_test.py | 16 | ||||
-rw-r--r-- | yaksh/models.py | 11 | ||||
-rw-r--r-- | yaksh/settings.py | 6 | ||||
-rw-r--r-- | yaksh/static/yaksh/css/ontop.css | 20 | ||||
-rw-r--r-- | yaksh/static/yaksh/js/question.js | 12 | ||||
-rw-r--r-- | yaksh/static/yaksh/js/requesthandler.js | 126 | ||||
-rw-r--r-- | yaksh/templates/base.html | 6 | ||||
-rw-r--r-- | yaksh/templates/yaksh/question.html | 56 | ||||
-rw-r--r-- | yaksh/tests/test_code_server.py | 163 | ||||
-rw-r--r-- | yaksh/urls.py | 1 | ||||
-rw-r--r-- | yaksh/views.py | 95 |
13 files changed, 516 insertions, 274 deletions
diff --git a/yaksh/code_server.py b/yaksh/code_server.py index 834c5af..75dd9b2 100644..100755 --- a/yaksh/code_server.py +++ b/yaksh/code_server.py @@ -1,59 +1,34 @@ #!/usr/bin/env python -"""This server runs an HTTP server (using tornado) and several code servers -using XMLRPC that can be submitted code -and tests and returns the output. It *should* be run as root and will run as -the user 'nobody' so as to minimize any damange by errant code. This can be -configured by editing settings.py to run as many servers as desired. One can -also specify the ports on the command line. Here are examples:: +"""This server runs an HTTP server (using tornado) to which code can be +submitted for checking. This is asynchronous so once submitted the user can +check for the result. - $ sudo ./code_server.py - # Runs servers based on settings.py:SERVER_PORTS one server per port given. - -or:: - - $ sudo ./code_server.py 8001 8002 8003 8004 8005 - # Runs 5 servers on ports specified. - -All these servers should be running as nobody. This will also start a server -pool that defaults to port 50000 and is configurable in -settings.py:SERVER_POOL_PORT. This port exposes a `get_server_port` function -that returns an available server. +It *should* be run as root and will run as the user 'nobody' so as to minimize +any damange by errant code. This can be configured by editing settings.py to +run as many servers as desired. """ # Standard library imports from __future__ import unicode_literals +from argparse import ArgumentParser import json -from multiprocessing import Process, Queue +from multiprocessing import Process, Queue, Manager import os -from os.path import isdir, dirname, abspath, join, isfile +from os.path import dirname, abspath import pwd -import re -import signal -import stat -import subprocess import sys - -try: - from SimpleXMLRPCServer import SimpleXMLRPCServer -except ImportError: - # The above import will not work on Python-3.x. - from xmlrpc.server import SimpleXMLRPCServer - -try: - from urllib import unquote -except ImportError: - # The above import will not work on Python-3.x. - from urllib.parse import unquote +import time # Library imports +import requests from tornado.ioloop import IOLoop from tornado.web import Application, RequestHandler +from six.moves import urllib # Local imports -from .settings import SERVER_PORTS, SERVER_POOL_PORT -from .language_registry import create_evaluator_instance +from .settings import N_CODE_SERVERS, SERVER_POOL_PORT from .grader import Grader @@ -69,70 +44,45 @@ def run_as_nobody(): os.seteuid(nobody.pw_uid) -############################################################################### -# `CodeServer` class. -############################################################################### -class CodeServer(object): - """A code server that executes user submitted test code, tests it and - reports if the code was correct or not. +def check_code(pid, job_queue, results): + """Check the code, this runs forever. """ - def __init__(self, port, queue): - self.port = port - self.queue = queue - - # Public Protocol ########## - def check_code(self, language, json_data, in_dir=None): - """Calls relevant EvaluateCode class based on language to check the - answer code - """ + while True: + uid, json_data, user_dir = job_queue.get(True) + results[uid] = dict(status='running', pid=pid, result=None) data = json.loads(json_data) - grader = Grader(in_dir) - result = grader.evaluate(data) - - # Put us back into the server pool queue since we are free now. - self.queue.put(self.port) - - return json.dumps(result) - - def run(self): - """Run XMLRPC server, serving our methods.""" - server = SimpleXMLRPCServer(("0.0.0.0", self.port)) - self.server = server - server.register_instance(self) - self.queue.put(self.port) - server.serve_forever() + grader = Grader(user_dir) + result = grader.evaluate(data) + results[uid] = dict(status='done', result=json.dumps(result)) ############################################################################### # `ServerPool` class. ############################################################################### class ServerPool(object): - """Manages a pool of CodeServer objects.""" - def __init__(self, ports, pool_port=50000): - """Create a pool of servers. Uses a shared Queue to get available - servers. + """Manages a pool of processes checking code.""" + def __init__(self, n, pool_port=50000): + """Create a pool of servers. Parameters ---------- - ports : list(int) - List of ports at which the CodeServer's should run. + n : int + Number of code servers to run pool_port : int Port at which the server pool should serve. """ + self.n = n + self.manager = Manager() + self.results = self.manager.dict() self.my_port = pool_port - self.ports = ports - queue = Queue(maxsize=len(self.ports)) - self.queue = queue - servers = [] + + self.job_queue = Queue() processes = [] - for port in self.ports: - server = CodeServer(port, queue) - servers.append(server) - p = Process(target=server.run) + for i in range(n): + p = self._make_process(i) processes.append(p) - self.servers = servers self.processes = processes self.app = self._make_app() @@ -143,28 +93,56 @@ class ServerPool(object): app.listen(self.my_port) return app + def _make_process(self, pid): + return Process( + target=check_code, args=(pid, self.job_queue, self.results) + ) + def _start_code_servers(self): for proc in self.processes: if proc.pid is None: proc.start() - # Public Protocol ########## + def _handle_dead_process(self, result): + if result.get('status') == 'running': + pid = result.get('pid') + proc = self.processes[pid] + if not proc.is_alive(): + # If the processes is dead, something bad happened so + # restart that process. + new_proc = self._make_process(pid) + self.processes[pid] = new_proc + new_proc.start() + result['status'] = 'done' + result['result'] = json.dumps(dict( + success=False, weight=0.0, + error=['Process ended with exit code %s.' + % proc.exitcode] + )) - def get_server_port(self): - """Get available server port from ones in the pool. This will block - till it gets an available server. - """ - return self.queue.get() + # Public Protocol ########## def get_status(self): - """Returns current queue size and total number of ports used.""" - try: - qs = self.queue.qsize() - except NotImplementedError: - # May not work on OS X so we return a dummy. - qs = len(self.ports) - - return qs, len(self.ports) + """Returns current job queue size, total number of processes alive. + """ + qs = sum(r['status'] == 'not started' + for r in self.results.values()) + alive = sum(p.is_alive() for p in self.processes) + n_running = sum(r['status'] == 'running' + for r in self.results.values()) + + return qs, alive, n_running + + def submit(self, uid, json_data, user_dir): + self.results[uid] = dict(status='not started') + self.job_queue.put((uid, json_data, user_dir)) + + def get_result(self, uid): + result = self.results.get(uid, dict(status='unknown')) + self._handle_dead_process(result) + if result.get('status') == 'done': + self.results.pop(uid) + return json.dumps(result) def run(self): """Run server which returns an available server port where code @@ -189,30 +167,102 @@ class MainHandler(RequestHandler): def get(self): path = self.request.path[1:] if len(path) == 0: - port = self.server.get_server_port() - self.write(str(port)) - elif path == "status": - q_size, total = self.server.get_status() - result = "%d servers out of %d are free.\n"%(q_size, total) - load = float(total - q_size)/total*100 - result += "Load: %s%%\n"%load + q_size, alive, running = self.server.get_status() + result = "%d processes, %d running, %d queued" % ( + alive, running, q_size + ) self.write(result) + else: + uid = path + json_result = self.server.get_result(uid) + self.write(json_result) + + def post(self): + uid = self.get_argument('uid') + json_data = self.get_argument('json_data') + user_dir = self.get_argument('user_dir') + self.server.submit(uid, json_data, user_dir) + self.write('OK') + + +def submit(url, uid, json_data, user_dir): + '''Submit a job to the code server. + + Parameters + ---------- + + url : str + URL of the server pool. + + uid : str + Unique ID of the submission. + + json_data : jsonized str + Data to send to the code checker. + + user_dir : str + User directory. + ''' + requests.post( + url, data=dict(uid=uid, json_data=json_data, user_dir=user_dir) + ) + + +def get_result(url, uid, block=False): + '''Get the status of a job submitted to the code server. + + Returns the result currently known in the form of a dict. The dictionary + contains two keys, 'status' and 'result'. The status can be one of + ['running', 'not started', 'done', 'unknown']. The result is the result of + the code execution as a jsonized string. + + Parameters + ---------- + + url : str + URL of the server pool. + + uid : str + Unique ID of the submission. + + block : bool + Set to True if you wish to block till result is done. + + ''' + def _get_data(): + r = requests.get(urllib.parse.urljoin(url, str(uid))) + return json.loads(r.content.decode('utf-8')) + data = _get_data() + if block: + while data.get('status') != 'done': + time.sleep(0.1) + data = _get_data() + + return data ############################################################################### def main(args=None): - if args: - ports = [int(x) for x in args] - else: - ports = SERVER_PORTS - - server_pool = ServerPool(ports=ports, pool_port=SERVER_POOL_PORT) - # This is done *after* the server pool is created because when the tornado - # app calls listen(), it cannot be nobody. + parser = ArgumentParser(description=__doc__) + parser.add_argument( + 'n', nargs='?', type=int, default=N_CODE_SERVERS, + help="Number of servers to run." + ) + parser.add_argument( + '-p', '--port', dest='port', default=SERVER_POOL_PORT, + help="Port at which the http server should run." + ) + + options = parser.parse_args(args) + + # Called before serverpool is created so that the multiprocessing + # can work properly. run_as_nobody() + server_pool = ServerPool(n=options.n, pool_port=options.port) server_pool.run() + if __name__ == '__main__': args = sys.argv[1:] main(args) diff --git a/yaksh/live_server_tests/load_test.py b/yaksh/live_server_tests/load_test.py index 47d267c..5ab1cc2 100644 --- a/yaksh/live_server_tests/load_test.py +++ b/yaksh/live_server_tests/load_test.py @@ -26,7 +26,9 @@ class YakshSeleniumTests(StaticLiveServerTestCase): "yaksh.cpp_code_evaluator.CppCodeEvaluator" settings.code_evaluators['bash']['standardtestcase'] = \ "yaksh.bash_code_evaluator.BashCodeEvaluator" - code_server_pool = ServerPool(ports=settings.SERVER_PORTS, pool_port=settings.SERVER_POOL_PORT) + code_server_pool = ServerPool( + n=settings.N_CODE_SERVERS, pool_port=settings.SERVER_POOL_PORT + ) cls.code_server_pool = code_server_pool cls.code_server_thread = t = Thread(target=code_server_pool.run) t.start() diff --git a/yaksh/live_server_tests/selenium_test.py b/yaksh/live_server_tests/selenium_test.py index bc400fd..31efcac 100644 --- a/yaksh/live_server_tests/selenium_test.py +++ b/yaksh/live_server_tests/selenium_test.py @@ -8,6 +8,20 @@ from selenium.common.exceptions import WebDriverException import multiprocessing import argparse + +class ElementDisplay(object): + '''Custom expected condition ''' + def __init__(self, locator): + self.locator = locator + + def __call__(self, driver): + try: + element = EC._find_element(driver, self.locator) + return element.value_of_css_property("display") == "none" + except Exception as e: + return False + + class SeleniumTestError(Exception): pass @@ -53,6 +67,8 @@ class SeleniumTest(): submit_answer_elem = self.driver.find_element_by_id("check") self.driver.execute_script('global_editor.editor.setValue({});'.format(answer)) submit_answer_elem.click() + WebDriverWait(self.driver, 90).until(ElementDisplay( + (By.XPATH, "//*[@id='ontop']"))) def test_c_question(self, question_label): # Incorrect Answer diff --git a/yaksh/models.py b/yaksh/models.py index 063e1e3..d9e07fd 100644 --- a/yaksh/models.py +++ b/yaksh/models.py @@ -28,7 +28,7 @@ import tempfile from textwrap import dedent from ast import literal_eval from .file_utils import extract_files, delete_files -from yaksh.xmlrpc_clients import code_server +from yaksh.code_server import submit, SERVER_POOL_PORT from django.conf import settings from django.forms.models import model_to_dict @@ -1306,7 +1306,7 @@ class AnswerPaper(models.Model): if question.type == 'code': return self.answers.filter(question=question).order_by('-id') - def validate_answer(self, user_answer, question, json_data=None): + def validate_answer(self, user_answer, question, json_data=None, uid=None): """ Checks whether the answer submitted by the user is right or wrong. If right then returns correct = True, success and @@ -1367,10 +1367,9 @@ class AnswerPaper(models.Model): elif question.type == 'code' or question.type == "upload": user_dir = self.user.profile.get_user_dir() - json_result = code_server.run_code( - question.language, json_data, user_dir - ) - result = json.loads(json_result) + url = 'http://localhost:%s' % SERVER_POOL_PORT + submit(url, uid, json_data, user_dir) + result = {'uid': uid, 'status': 'running'} return result def regrade(self, question_id): diff --git a/yaksh/settings.py b/yaksh/settings.py index 72f9fda..d500d93 100644 --- a/yaksh/settings.py +++ b/yaksh/settings.py @@ -1,9 +1,9 @@ """ settings for yaksh app. """ -# The ports the code server should run on. This will run one separate -# server for each port listed in the following list. -SERVER_PORTS = [8001] # range(8001, 8026) + +# The number of code server processes to run.. +N_CODE_SERVERS = 5 # The server pool port. This is the server which returns available server # ports so as to minimize load. This is some random number where no other diff --git a/yaksh/static/yaksh/css/ontop.css b/yaksh/static/yaksh/css/ontop.css new file mode 100644 index 0000000..fb22066 --- /dev/null +++ b/yaksh/static/yaksh/css/ontop.css @@ -0,0 +1,20 @@ +#ontop { + position: fixed; + display: none; + width: 100%; + height: 100%; + top:0; + bottom: 0; + left:0; + right: 0; + background-color: rgba(0,0,0,0.5); + z-index: 1001; /* 1001 coz sidebar is 1000. So will be on top of sidebar*/ +} + +#state { + position: absolute; + top: 50%; + left: 50%; + font-size: 30px; + color: white; +} diff --git a/yaksh/static/yaksh/js/question.js b/yaksh/static/yaksh/js/question.js deleted file mode 100644 index 96ff3de..0000000 --- a/yaksh/static/yaksh/js/question.js +++ /dev/null @@ -1,12 +0,0 @@ -function submitCode() -{ - document.forms["code"].submit(); - var x = document.getElementById("status"); - x.innerHTML = "<strong>Checking answer ...</strong>"; - x = document.getElementById("check"); - x.disabled = true; - x.value = "Checking Answer ..."; - if (document.getElementById("skip")!=null) { - document.getElementById("skip").disabled = true; - } -} diff --git a/yaksh/static/yaksh/js/requesthandler.js b/yaksh/static/yaksh/js/requesthandler.js new file mode 100644 index 0000000..9fcf5b6 --- /dev/null +++ b/yaksh/static/yaksh/js/requesthandler.js @@ -0,0 +1,126 @@ +request_status = "initial"; +count = 0; +MAX_COUNT = 14 + +function reset_values() { + request_status = "initial"; + count = 0; +} +function check_state(state, uid) { + if ((state == "running" || state == "not started") && count < MAX_COUNT) { + count++; + setTimeout(function() {get_result(uid);}, 2000); + } else if (state == "unknown") { + reset_values(); + notify("Request timeout. Try again later."); + unlock_screen(); + } else { + reset_values() + notify("Please try again."); + unlock_screen(); + } +} + +function notify(text) { + var notice = document.getElementById("notification"); + notice.classList.add("alert"); + notice.classList.add("alert-success"); + notice.innerHTML = text; +} + +function lock_screen() { + document.getElementById("ontop").style.display = "block"; +} + +function unlock_screen() { + document.getElementById("ontop").style.display = "none"; +} + +function get_result(uid){ + var url = "/exam/get_result/" + uid + "/"; + ajax_check_code(url, "GET", "html", null, uid) +} + +function response_handler(method_type, content_type, data, uid){ + if(content_type.indexOf("text/html") !== -1) { + if( method_type === "POST") { + reset_values(); + } + unlock_screen(); + document.open(); + document.write(data); + document.close(); + } else if(content_type.indexOf("application/json") !== -1) { + res = JSON.parse(data); + request_status = res.status; + if(method_type === "POST") { + uid = res.uid; + } + check_state(request_status, uid); + } else { + reset_values(); + unlock_screen(); + } +} + +function ajax_check_code(url, method_type, data_type, data, uid) { + $.ajax({ + method: method_type, + url: url, + data: data, + dataType: data_type, + success: function(data, status, xhr) { + content_type = xhr.getResponseHeader("content-type"); + response_handler(method_type, content_type, data, uid) + }, + error: function(xhr, text_status, error_thrown ) { + reset_values(); + unlock_screen(); + notify("There is some problem. Try later.") + } + }); + +} + +var global_editor = {}; +$(document).ready(function(){ + // Codemirror object, language modes and initial content + // Get the textarea node + var textarea_node = document.querySelector('#answer'); + + var mode_dict = { + 'python': 'python', + 'c': 'text/x-csrc', + 'cpp': 'text/x-c++src', + 'java': 'text/x-java', + 'bash': 'text/x-sh', + 'scilab': 'text/x-csrc' + } + + // Code mirror Options + var options = { + mode: mode_dict[lang], + gutter: true, + lineNumbers: true, + onChange: function (instance, changes) { + render(); + } + }; + + // Initialize the codemirror editor + global_editor.editor = CodeMirror.fromTextArea(textarea_node, options); + + // Setting code editors initial content + global_editor.editor.setValue(init_val); + + function reset_editor() { + global_editor.editor.setValue(init_val); + global_editor.editor.clearHistory(); + } + $('#code').submit(function(e) { + lock_screen(); + var data = $(this).serializeArray(); + ajax_check_code($(this).attr("action"), "POST", "html", data, null) + e.preventDefault(); // To stop the default form submission. + }); +}); diff --git a/yaksh/templates/base.html b/yaksh/templates/base.html index 35c6976..cbe396f 100644 --- a/yaksh/templates/base.html +++ b/yaksh/templates/base.html @@ -19,6 +19,7 @@ <link rel="stylesheet" href="{{ URL_ROOT }}/static/yaksh/css/theme.css" type="text/css" /> <link rel="stylesheet" href="{{ URL_ROOT }}/static/yaksh/css/sticky-footer.css" type="text/css" /> <link rel="stylesheet" href="{{ URL_ROOT }}/static/yaksh/css/dashboard.css" type="text/css" /> + <link rel="stylesheet" href="{{ URL_ROOT }}/static/yaksh/css/ontop.css" type="text/css" /> {% block meta %} @@ -36,6 +37,11 @@ </head> <body {% block onload %} {% endblock %}> + <div id="ontop"> + <div id="state"> + Checking... + </div> + </div> {% block nav %} {% endblock %} <div class="container"> diff --git a/yaksh/templates/yaksh/question.html b/yaksh/templates/yaksh/question.html index 74dd8c3..3a3066c 100644 --- a/yaksh/templates/yaksh/question.html +++ b/yaksh/templates/yaksh/question.html @@ -15,7 +15,7 @@ {% endblock %} {% block script %} -<script src="{{ URL_ROOT }}/static/yaksh/js/question.js"></script> +<script src="{{ URL_ROOT }}/static/yaksh/js/requesthandler.js"></script> <script src="{{ URL_ROOT }}/static/yaksh/js/codemirror/lib/codemirror.js"></script> <script src="{{ URL_ROOT }}/static/yaksh/js/codemirror/mode/python/python.js"></script> <script src="{{ URL_ROOT }}/static/yaksh/js/codemirror/mode/clike/clike.js"></script> @@ -79,7 +79,7 @@ function validate(){ } else { - return true; + send_request(); } } @@ -89,47 +89,8 @@ function call_skip(url) form.action = url form.submit(); } -</script> -<script> - - var init_val = '{{ last_attempt|escape_quotes|safe }}'; - var global_editor = {}; - $(document).ready(function(){ - // Codemirror object, language modes and initial content - // Get the textarea node - var textarea_node = document.querySelector('#answer'); - - var lang = "{{ question.language }}" - var mode_dict = { - 'python': 'python', - 'c': 'text/x-csrc', - 'cpp': 'text/x-c++src', - 'java': 'text/x-java', - 'bash': 'text/x-sh', - 'scilab': 'text/x-csrc' - } - - // Code mirror Options - var options = { - mode: mode_dict[lang], - gutter: true, - lineNumbers: true, - onChange: function (instance, changes) { - render(); - } - }; - - // Initialize the codemirror editor - global_editor.editor = CodeMirror.fromTextArea(textarea_node, options); - - // Setting code editors initial content - global_editor.editor.setValue(init_val); - - function reset_editor() { - global_editor.editor.setValue(init_val); - global_editor.editor.clearHistory(); - } - }); +init_val = '{{ last_attempt|escape_quotes|safe }}'; +lang = "{{ question.language }}" </script> {% endblock script %} @@ -140,14 +101,17 @@ function call_skip(url) <p id="status"></p> {% if notification %} {% if question.type == "code" %} - <div class="alert alert-success" role="alert"> + <div id="notification" class="alert alert-success" role="alert"> <strong>Note:</strong> {{ notification }} </div> {% else %} - <div class="alert alert-warning" role="alert"> + <div id="notification" class="alert alert-warning" role="alert"> <strong>Note:</strong> {{ notification }} </div> {% endif %} + {% else %} + <div id="notification" role="alert"> + </div> {% endif %} <form id="code" action="{{URL_ROOT}}/exam/{{ question.id }}/check/{{ paper.attempt_number }}/{{ paper.question_paper.id }}/" method="post" enctype="multipart/form-data"> {% csrf_token %} @@ -245,7 +209,7 @@ function call_skip(url) {% else %} {% if question in paper.get_questions_unanswered %} - <button class="btn btn-primary" type="submit" name="check" id="check" onClick="submitCode();">Check Answer <span class="glyphicon glyphicon-cog"></span></button> + <button class="btn btn-primary" type="submit" name="check" id="check" >Check Answer <span class="glyphicon glyphicon-cog"></span></button> {% endif %} {% endif %} diff --git a/yaksh/tests/test_code_server.py b/yaksh/tests/test_code_server.py index 47c1da7..5f80f2d 100644 --- a/yaksh/tests/test_code_server.py +++ b/yaksh/tests/test_code_server.py @@ -8,9 +8,8 @@ from threading import Thread import unittest from six.moves import urllib -from yaksh.code_server import ServerPool, SERVER_POOL_PORT +from yaksh.code_server import ServerPool, SERVER_POOL_PORT, submit, get_result from yaksh import settings -from yaksh.xmlrpc_clients import CodeServerProxy class TestCodeServer(unittest.TestCase): @@ -19,8 +18,7 @@ class TestCodeServer(unittest.TestCase): def setUpClass(cls): settings.code_evaluators['python']['standardtestcase'] = \ "yaksh.python_assertion_evaluator.PythonAssertionEvaluator" - ports = range(8001, 8006) - server_pool = ServerPool(ports=ports, pool_port=SERVER_POOL_PORT) + server_pool = ServerPool(n=5, pool_port=SERVER_POOL_PORT) cls.server_pool = server_pool cls.server_thread = t = Thread(target=server_pool.run) t.start() @@ -33,70 +31,78 @@ class TestCodeServer(unittest.TestCase): "python_assertion_evaluator.PythonAssertionEvaluator" def setUp(self): - self.code_server = CodeServerProxy() + self.url = 'http://localhost:%s' % SERVER_POOL_PORT def test_infinite_loop(self): # Given - testdata = {'metadata': {'user_answer': 'while True: pass', - 'language': 'python', - 'partial_grading': False - }, - 'test_case_data': [{'test_case':'assert 1==2', - 'test_case_type': 'standardtestcase', - 'weight': 0.0 - }] - } + testdata = { + 'metadata': { + 'user_answer': 'while True: pass', + 'language': 'python', + 'partial_grading': False + }, + 'test_case_data': [ + {'test_case': 'assert 1==2', + 'test_case_type': 'standardtestcase', + 'weight': 0.0} + ] + } # When - result = self.code_server.run_code( - 'python', json.dumps(testdata), '' - ) + submit(self.url, '0', json.dumps(testdata), '') + result = get_result(self.url, '0') # Then - data = json.loads(result) + self.assertTrue(result.get('status') in ['running', 'not started']) + + # When + result = get_result(self.url, '0', block=True) + + # Then + data = json.loads(result.get('result')) self.assertFalse(data['success']) self.assertTrue('infinite loop' in data['error'][0]) def test_correct_answer(self): # Given - testdata = {'metadata': { 'user_answer': 'def f(): return 1', - 'language': 'python', - 'partial_grading': False - }, - 'test_case_data': [{'test_case':'assert f() == 1', - 'test_case_type': 'standardtestcase', - 'weight': 0.0 - }] - } + testdata = { + 'metadata': { + 'user_answer': 'def f(): return 1', + 'language': 'python', + 'partial_grading': False + }, + 'test_case_data': [{'test_case': 'assert f() == 1', + 'test_case_type': 'standardtestcase', + 'weight': 0.0}] + } # When - result = self.code_server.run_code( - 'python', json.dumps(testdata), '' - ) + submit(self.url, '0', json.dumps(testdata), '') + result = get_result(self.url, '0', block=True) # Then - data = json.loads(result) + data = json.loads(result.get('result')) self.assertTrue(data['success']) def test_wrong_answer(self): # Given - testdata = {'metadata': { 'user_answer': 'def f(): return 1', - 'language': 'python', - 'partial_grading': False - }, - 'test_case_data': [{'test_case':'assert f() == 2', - 'test_case_type': 'standardtestcase', - 'weight': 0.0 - }] - } + testdata = { + 'metadata': { + 'user_answer': 'def f(): return 1', + 'language': 'python', + 'partial_grading': False + }, + 'test_case_data': [{'test_case': 'assert f() == 2', + 'test_case_type': 'standardtestcase', + 'weight': 0.0}] + } # When - result = self.code_server.run_code( - 'python', json.dumps(testdata), '' - ) + submit(self.url, '0', json.dumps(testdata), '') + result = get_result(self.url, '0', block=True) # Then - data = json.loads(result) + data = json.loads(result.get('result')) self.assertFalse(data['success']) self.assertTrue('AssertionError' in data['error'][0]) @@ -104,28 +110,27 @@ class TestCodeServer(unittest.TestCase): # Given results = Queue() - def run_code(): + def run_code(uid): """Run an infinite loop.""" - testdata = {'metadata': { 'user_answer': 'while True: pass', - 'language': 'python', - 'partial_grading': False - }, - 'test_case_data': [{'test_case':'assert 1==2', - 'test_case_type': 'standardtestcase', - 'weight': 0.0 - }] - } - result = self.code_server.run_code( - 'python', json.dumps(testdata), '' - ) - results.put(json.loads(result)) + testdata = { + 'metadata': { + 'user_answer': 'while True: pass', + 'language': 'python', + 'partial_grading': False + }, + 'test_case_data': [{'test_case': 'assert 1==2', + 'test_case_type': 'standardtestcase', + 'weight': 0.0}] + } + submit(self.url, uid, json.dumps(testdata), '') + result = get_result(self.url, uid, block=True) + results.put(json.loads(result.get('result'))) N = 10 # When - import time threads = [] for i in range(N): - t = Thread(target=run_code) + t = Thread(target=run_code, args=(str(i),)) threads.append(t) t.start() @@ -142,16 +147,48 @@ class TestCodeServer(unittest.TestCase): def test_server_pool_status(self): # Given - url = "http://localhost:%s/status"%SERVER_POOL_PORT + url = "http://localhost:%s/" % SERVER_POOL_PORT # When response = urllib.request.urlopen(url) data = response.read().decode('utf-8') # Then - expect = 'out of 5 are free' + expect = '5 processes, 0 running, 0 queued' self.assertTrue(expect in data) - expect = 'Load:' + + def test_killing_process_revives_it(self): + # Given + testdata = { + 'metadata': { + 'user_answer': 'import sys; sys.exit()', + 'language': 'python', + 'partial_grading': False + }, + 'test_case_data': [{'test_case': '', + 'test_case_type': 'standardtestcase', + 'weight': 0.0}] + } + + # When + submit(self.url, '0', json.dumps(testdata), '') + result = get_result(self.url, '0', block=True) + + # Then + data = json.loads(result.get('result')) + self.assertFalse(data['success']) + self.assertTrue('Process ended with exit code' in data['error'][0]) + + # Now check the server status to see if the right number + # processes are running. + url = "http://localhost:%s/" % SERVER_POOL_PORT + + # When + response = urllib.request.urlopen(url) + data = response.read().decode('utf-8') + + # Then + expect = '5 processes, 0 running, 0 queued' self.assertTrue(expect in data) diff --git a/yaksh/urls.py b/yaksh/urls.py index 4aa3276..c236640 100644 --- a/yaksh/urls.py +++ b/yaksh/urls.py @@ -20,6 +20,7 @@ urlpatterns = [ views.complete), url(r'^register/$', views.user_register, name="register"), url(r'^(?P<q_id>\d+)/check/$', views.check), + url(r'^get_result/(?P<uid>\d+)/$', views.get_result), url(r'^(?P<q_id>\d+)/check/(?P<attempt_num>\d+)/(?P<questionpaper_id>\d+)/$',\ views.check), url(r'^(?P<q_id>\d+)/skip/(?P<attempt_num>\d+)/(?P<questionpaper_id>\d+)/$', diff --git a/yaksh/views.py b/yaksh/views.py index 1c6feca..3f9f622 100644 --- a/yaksh/views.py +++ b/yaksh/views.py @@ -4,7 +4,7 @@ import os from datetime import datetime, timedelta import collections import csv -from django.http import HttpResponse +from django.http import HttpResponse, JsonResponse from django.core.urlresolvers import reverse from django.contrib.auth import login, logout, authenticate from django.shortcuts import render_to_response, get_object_or_404, redirect @@ -31,6 +31,7 @@ except ImportError: from io import BytesIO as string_io import re # Local imports. +from yaksh.code_server import get_result as get_result_from_code_server, SERVER_POOL_PORT from yaksh.models import ( Answer, AnswerPaper, AssignmentUpload, Course, FileUpload, FloatTestCase, HookTestCase, IntegerTestCase, McqTestCase, Profile, @@ -44,7 +45,6 @@ from yaksh.forms import ( UploadFileForm, get_object_form, FileForm, QuestionPaperForm ) from .settings import URL_ROOT -from yaksh.models import AssignmentUpload from .file_utils import extract_files from .send_emails import send_user_mail, generate_activation_key, send_bulk_mail from .decorators import email_verified, has_profile @@ -532,7 +532,6 @@ def check(request, q_id, attempt_num=None, questionpaper_id=None): current_question = get_object_or_404(Question, pk=q_id) if request.method == 'POST': - snippet_code = request.POST.get('snippet') # Add the answer submitted, regardless of it being correct or not. if current_question.type == 'mcq': user_answer = request.POST.get('answer') @@ -592,8 +591,7 @@ def check(request, q_id, attempt_num=None, questionpaper_id=None): next_q = paper.add_completed_question(current_question.id) return show_question(request, next_q, paper) else: - user_code = request.POST.get('answer') - user_answer = snippet_code + "\n" + user_code if snippet_code else user_code + user_answer = request.POST.get('answer') if not user_answer: msg = ["Please submit a valid option or code"] return show_question( @@ -604,6 +602,7 @@ def check(request, q_id, attempt_num=None, questionpaper_id=None): correct=False, error=json.dumps([]) ) new_answer.save() + uid = new_answer.id paper.answers.add(new_answer) # If we were not skipped, we were asked to check. For any non-mcq # questions, we obtain the results via XML-RPC with the code executed @@ -612,37 +611,71 @@ def check(request, q_id, attempt_num=None, questionpaper_id=None): if current_question.type == 'code' or \ current_question.type == 'upload' else None result = paper.validate_answer( - user_answer, current_question, json_data + user_answer, current_question, json_data, uid ) - if result.get('success'): - new_answer.marks = (current_question.points * result['weight'] / - current_question.get_maximum_test_case_weight()) \ - if current_question.partial_grading and \ - current_question.type == 'code' or current_question.type == 'upload' \ - else current_question.points - new_answer.correct = result.get('success') - error_message = None - new_answer.error = json.dumps(result.get('error')) - next_question = paper.add_completed_question(current_question.id) + if current_question.type in ['code', 'upload']: + if paper.time_left() <= 0: + url = 'http://localhost:%s' % SERVER_POOL_PORT + result_details = get_result_from_code_server(url, uid, block=True) + result = json.loads(result_details.get('result')) + next_question, error_message, paper = _update_paper(request, uid, + result) + return show_question(request, next_question, paper, error_message) + else: + return JsonResponse(result) else: - new_answer.marks = (current_question.points * result['weight'] / - current_question.get_maximum_test_case_weight()) \ - if current_question.partial_grading and \ - current_question.type == 'code' or current_question.type == 'upload' \ - else 0 - error_message = result.get('error') if current_question.type == 'code' \ - or current_question.type == 'upload' else None - new_answer.error = json.dumps(result.get('error')) - next_question = current_question if current_question.type == 'code' \ - or current_question.type == 'upload' \ - else paper.add_completed_question(current_question.id) - new_answer.save() - paper.update_marks('inprogress') - paper.set_end_time(timezone.now()) - return show_question(request, next_question, paper, error_message) + next_question, error_message, paper = _update_paper(request, uid, result) + return show_question(request, next_question, paper, error_message) else: return show_question(request, current_question, paper) + +@csrf_exempt +def get_result(request, uid): + result = {} + url = 'http://localhost:%s' % SERVER_POOL_PORT + result_state = get_result_from_code_server(url, uid) + result['status'] = result_state.get('status') + if result['status'] == 'done': + result = json.loads(result_state.get('result')) + next_question, error_message, paper = _update_paper(request, uid, result) + return show_question(request, next_question, paper, error_message) + return JsonResponse(result) + + +def _update_paper(request, uid, result): + new_answer = Answer.objects.get(id=uid) + current_question = new_answer.question + paper = new_answer.answerpaper_set.first() + + if result.get('success'): + new_answer.marks = (current_question.points * result['weight'] / + current_question.get_maximum_test_case_weight()) \ + if current_question.partial_grading and \ + current_question.type == 'code' or current_question.type == 'upload' \ + else current_question.points + new_answer.correct = result.get('success') + error_message = None + new_answer.error = json.dumps(result.get('error')) + next_question = paper.add_completed_question(current_question.id) + else: + new_answer.marks = (current_question.points * result['weight'] / + current_question.get_maximum_test_case_weight()) \ + if current_question.partial_grading and \ + current_question.type == 'code' or current_question.type == 'upload' \ + else 0 + error_message = result.get('error') if current_question.type == 'code' \ + or current_question.type == 'upload' else None + new_answer.error = json.dumps(result.get('error')) + next_question = current_question if current_question.type == 'code' \ + or current_question.type == 'upload' \ + else paper.add_completed_question(current_question.id) + new_answer.save() + paper.update_marks('inprogress') + paper.set_end_time(timezone.now()) + return next_question, error_message, paper + + @login_required @email_verified def quit(request, reason=None, attempt_num=None, questionpaper_id=None): |