summaryrefslogtreecommitdiff
path: root/testapp
diff options
context:
space:
mode:
authorparth2011-12-09 04:02:19 +0530
committerparth2011-12-09 04:02:19 +0530
commit861d2cc5e36835f60bace61a919e73b4bd27274b (patch)
treed08d776f58167903781c371d147f5b9e1f83e168 /testapp
parent7104f495d01fb934af11c8dfd09da087174c1b12 (diff)
downloadonline_test-861d2cc5e36835f60bace61a919e73b4bd27274b.tar.gz
online_test-861d2cc5e36835f60bace61a919e73b4bd27274b.tar.bz2
online_test-861d2cc5e36835f60bace61a919e73b4bd27274b.zip
Moved all the apps to testapp folder
Diffstat (limited to 'testapp')
-rw-r--r--testapp/README.txt190
-rw-r--r--testapp/__init__.py0
-rw-r--r--testapp/apache/django.wsgi18
-rwxr-xr-xtestapp/code_server.py360
-rw-r--r--testapp/docs/sample.args2
-rwxr-xr-xtestapp/docs/sample.sh2
-rw-r--r--testapp/docs/sample_questions.py59
-rw-r--r--testapp/docs/sample_questions.xml43
-rw-r--r--testapp/exam/__init__.py0
-rw-r--r--testapp/exam/admin.py5
-rw-r--r--testapp/exam/forms.py95
-rw-r--r--testapp/exam/management/__init__.py0
-rw-r--r--testapp/exam/management/commands/__init__.py0
-rw-r--r--testapp/exam/management/commands/dump_user_data.py98
-rw-r--r--testapp/exam/management/commands/load_exam.py55
-rw-r--r--testapp/exam/management/commands/load_questions_xml.py73
-rw-r--r--testapp/exam/management/commands/results2csv.py69
-rw-r--r--testapp/exam/migrations/0001_initial.py193
-rw-r--r--testapp/exam/migrations/__init__.py0
-rw-r--r--testapp/exam/models.py221
-rw-r--r--testapp/exam/tests.py16
-rw-r--r--testapp/exam/urls.py16
-rw-r--r--testapp/exam/views.py351
-rw-r--r--testapp/exam/xmlrpc_clients.py78
-rwxr-xr-xtestapp/manage.py14
-rw-r--r--testapp/output/README.txt4
-rw-r--r--testapp/settings.py188
-rw-r--r--testapp/static/exam/css/base.css26
-rw-r--r--testapp/templates/404.html5
-rw-r--r--testapp/templates/500.html7
-rw-r--r--testapp/templates/base.html25
-rw-r--r--testapp/templates/exam/complete.html12
-rw-r--r--testapp/templates/exam/grade_user.html83
-rw-r--r--testapp/templates/exam/intro.html53
-rw-r--r--testapp/templates/exam/login.html20
-rw-r--r--testapp/templates/exam/monitor.html67
-rw-r--r--testapp/templates/exam/question.html91
-rw-r--r--testapp/templates/exam/quit.html14
-rw-r--r--testapp/templates/exam/register.html17
-rw-r--r--testapp/templates/exam/user_data.html84
-rw-r--r--testapp/test_server.py83
-rw-r--r--testapp/urls.py23
42 files changed, 2760 insertions, 0 deletions
diff --git a/testapp/README.txt b/testapp/README.txt
new file mode 100644
index 0000000..a265675
--- /dev/null
+++ b/testapp/README.txt
@@ -0,0 +1,190 @@
+Introduction
+============
+
+This app provides an "exam" app that lets users take an online
+programming quiz. Currently only Python and simple Bash scripts can be
+tested. At FOSSEE, Nishanth had implemented a nice django based app to
+test for multiple-choice questions. However, I was inspired by a
+programming contest that I saw at PyCon APAC 2011. Chris Boesch, who
+administered the contest, used a nice web application that he had built
+on top of GAE that basically checked your Python code, live. This made
+it fun and interesting. Their application can be seen at
+http://singpath.com
+
+I wanted an implementation that was not tied to GAE and decided to write
+one myself and the result is the "exam" app. The idea being that I can
+use this to test students programming skills and not have to worry about
+grading their answers myself and I can do so on my machines.
+
+You can define fairly complicated programming problems and have users
+solve the problem and the solution is checked immediately. The system
+supports pretty much arbitrary Python and uses "test cases" to test the
+implementations of the students. It also supports simple bash scripts
+-- see the sample questions in "docs/". In addition it supports simple
+multiple choice questions. Since it runs on your Python, you could
+technically test any Python based library. It is distributed under the
+BSD license.
+
+It can use a lot more work but the basics work and the app scales to
+over 500+ simultaneous users. :)
+
+Dependencies
+=============
+
+Before you install/deploy, make sure you have the following installed:
+
+ - Django 1.3 or above.
+ - South (tested with 0.7.3).
+
+That and a running Python is pretty much all you need. Of course, for
+serious deployment you are going to need Apache or some other decent
+webserver.
+
+
+Installation and Deployment
+=============================
+
+To install/deploy this app follow the steps below:
+
+ 1. Clone this repository and cd to the cloned repo.
+
+ 2. Run::
+
+ $ python manage.py syncdb
+ [ enter password etc.]
+
+ $ python manage.py migrate exam
+
+ 3. Add questions by editing the "docs/sample_questions.py" or any other
+ file in the same format and then run the following::
+
+ $ python manage.py load_exam docs/sample_questions.py
+
+ Note that you can supply multiple Python files as arguments and all of
+ those will be added to the database.
+
+ 4. First run the python server provided. This ensures that the code is
+ executed in a safe environment. Do this like so::
+
+ $ sudo python code_server.py
+
+ Put this in the background once it has started since this will not
+ return back the prompt. It is important that the server be running
+ *before* students start attempting the exam. Using sudo is
+ necessary since the server is run as the user "nobody". This runs
+ on the ports configured in the settings.py file in the variable
+ "SERVER_PORTS". The "SERVER_TIMEOUT" also can be changed there.
+ This is the maximum time allowed to execute the submitted code.
+ Note that this will likely spawn multiple processes as "nobody"
+ depending on the number of server ports specified.
+
+ 5. Now, run::
+
+ $ python manage.py runserver <desired_ip>:<desired_port>
+
+ For deployment use Apache or a real webserver, see below for more
+ information.
+
+ 6. Go to http://deserved_host_or_ip:desired_port/admin
+
+ 7. Login with your credentials and look at the questions and modify if
+ needed. Create a new Quiz, set the date and duration or
+ activate/deactivate the quiz.
+
+ 8. Now ask users to login at:
+
+ http://host:port/exam
+
+ And you should be all set.
+
+ 9. Note that the directory "output" will contain directories, one for each
+ user. Users can potentially write output into these that can be used
+ for checking later.
+
+ 10. As admin user you can visit http://host/exam/monitor to view
+ results and user data interactively. You could also "grade" the
+ papers manually if needed.
+
+ 11. You may dump the results and user data using the results2csv and
+ dump_user_data commands.
+
+WARNING: django is running in debug mode for this currently, CHANGE it
+during deployment. To do this, edit settings.py and set DEBUG to False.
+Also look at other settings and change them suitably.
+
+The file docs/sample_questions.py is a template that you can use for your
+own questions.
+
+Additional commands available
+==============================
+
+We provide several convenient commands for you to use:
+
+ - load_exam : load questions and a quiz from a python file. See
+ docs/sample_questions.py
+
+ - load_questions_xml : load questions from XML file, see
+ docs/sample_questions.xml use of this is deprecated in favor of
+ load_exam.
+
+ - results2csv : Dump the quiz results into a CSV file for further
+ processing.
+
+ - dump_user_data : Dump out relevalt user data for either all users or
+ specified users.
+
+For more information on these do this::
+
+ $ ./manage.py help [command]
+
+where [command] is one of the above.
+
+Deploying via Apache
+=====================
+
+For any serious deployment, you will need to deploy the app using a real
+webserver like Apache. The ``apache/django.wsgi`` script should make it
+easy to deploy this using mod_wsgi. You will need to add a line of the
+form:
+
+ WSGIScriptAlias / "/var/www/online_test/apache/django.wsgi"
+
+to your apache.conf. For more details see the Django docs here:
+
+https://docs.djangoproject.com/en/1.3/howto/deployment/modwsgi/
+
+
+Sometimes you might be in the situation where you are not hosted as
+"host.org/exam/" but as "host.org/foo/exam/" for whatever reason. In
+this case edit "settings.py" and set the "URL_ROOT" to the root you
+have to serve at. In the above example for "host.org/foo/exam" set
+URL_ROOT='/foo'.
+
+License
+=======
+
+This is distributed under the terms of the BSD license. Copyright
+information is at the bottom of this file.
+
+Authors
+=======
+
+Main author: Prabhu Ramachandran
+
+I gratefully acknowledge help from the following:
+
+ - Nishanth Amuluru originally from FOSSEE who wrote bulk of the
+ login/registration code. He wrote an initial first cut of a quiz app
+ which supported only simple questions which provided motivation for
+ this app. The current codebase does not share too much from his
+ implementation although there are plenty of similarities.
+
+ - Harish Badrinath (FOSSEE) -- who provided a first cut of the bash
+ related scripts.
+
+ - Srikant Patnaik and Thomas Stephen Lee, who helped deploy and test
+ the code.
+
+
+Copyright (c) 2011 Prabhu Ramachandran and FOSSEE (fossee.in)
+
diff --git a/testapp/__init__.py b/testapp/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/testapp/__init__.py
diff --git a/testapp/apache/django.wsgi b/testapp/apache/django.wsgi
new file mode 100644
index 0000000..ef88526
--- /dev/null
+++ b/testapp/apache/django.wsgi
@@ -0,0 +1,18 @@
+import os
+from os.path import dirname, abspath
+import sys
+
+# This file is inside online_test/apache/django.wsgi
+# pth should be online_test
+pth = abspath(dirname(dirname(__file__)))
+if pth not in sys.path:
+ sys.path.append(pth)
+# Now add the parent of online_test also.
+pth = dirname(pth)
+if pth not in sys.path:
+ sys.path.append(pth)
+
+os.environ['DJANGO_SETTINGS_MODULE'] = 'online_test.settings'
+
+import django.core.handlers.wsgi
+application = django.core.handlers.wsgi.WSGIHandler()
diff --git a/testapp/code_server.py b/testapp/code_server.py
new file mode 100755
index 0000000..1276c76
--- /dev/null
+++ b/testapp/code_server.py
@@ -0,0 +1,360 @@
+#!/usr/bin/env python
+"""This server runs an XMLRPC server 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::
+
+ $ 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.
+"""
+import sys
+import traceback
+from SimpleXMLRPCServer import SimpleXMLRPCServer
+import pwd
+import os
+import stat
+from os.path import isdir, dirname, abspath, join, isfile
+import signal
+from multiprocessing import Process, Queue
+import subprocess
+
+# Local imports.
+from settings import SERVER_PORTS, SERVER_TIMEOUT, SERVER_POOL_PORT
+
+MY_DIR = abspath(dirname(__file__))
+
+def run_as_nobody():
+ """Runs the current process as nobody."""
+ # Set the effective uid and to that of nobody.
+ nobody = pwd.getpwnam('nobody')
+ os.setegid(nobody.pw_gid)
+ os.seteuid(nobody.pw_uid)
+
+
+# Raised when the code times-out.
+# c.f. http://pguides.net/python/timeout-a-function
+class TimeoutException(Exception):
+ pass
+
+def timeout_handler(signum, frame):
+ """A handler for the ALARM signal."""
+ raise TimeoutException('Code took too long to run.')
+
+
+################################################################################
+# `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 __init__(self, port, queue):
+ self.port = port
+ self.queue = queue
+ msg = 'Code took more than %s seconds to run. You probably '\
+ 'have an infinite loop in your code.'%SERVER_TIMEOUT
+ self.timeout_msg = msg
+
+ def run_python_code(self, answer, test_code, in_dir=None):
+ """Tests given Python function (`answer`) with the `test_code` supplied.
+ If the optional `in_dir` keyword argument is supplied it changes the
+ directory to that directory (it does not change it back to the original when
+ done). This function also timesout when the function takes more than
+ SERVER_TIMEOUT seconds to run to prevent runaway code.
+
+ Returns
+ -------
+
+ A tuple: (success, error message).
+
+ """
+ if in_dir is not None and isdir(in_dir):
+ os.chdir(in_dir)
+
+ # Add a new signal handler for the execution of this code.
+ old_handler = signal.signal(signal.SIGALRM, timeout_handler)
+ signal.alarm(SERVER_TIMEOUT)
+
+ success = False
+ tb = None
+ try:
+ submitted = compile(answer, '<string>', mode='exec')
+ g = {}
+ exec submitted in g
+ _tests = compile(test_code, '<string>', mode='exec')
+ exec _tests in g
+ except TimeoutException:
+ err = self.timeout_msg
+ except AssertionError:
+ type, value, tb = sys.exc_info()
+ info = traceback.extract_tb(tb)
+ fname, lineno, func, text = info[-1]
+ text = str(test_code).splitlines()[lineno-1]
+ err = "{0} {1} in: {2}".format(type.__name__, str(value), text)
+ except:
+ type, value = sys.exc_info()[:2]
+ err = "Error: {0}".format(repr(value))
+ else:
+ success = True
+ err = 'Correct answer'
+ finally:
+ del tb
+ # Set back any original signal handler.
+ signal.signal(signal.SIGALRM, old_handler)
+
+ # Cancel the signal if any, see signal.alarm documentation.
+ signal.alarm(0)
+
+ # Put us back into the server pool queue since we are free now.
+ self.queue.put(self.port)
+
+ return success, err
+
+ def run_bash_code(self, answer, test_code, in_dir=None):
+ """Tests given Bash code (`answer`) with the `test_code` supplied.
+
+ The testcode should typically contain two lines, the first is a path to
+ the reference script we are to compare against. The second is a path
+ to the arguments to be supplied to the reference and submitted script.
+ The output of these will be compared for correctness.
+
+ If the path's start with a "/" then we assume they are absolute paths.
+ If not, we assume they are relative paths w.r.t. the location of this
+ code_server script.
+
+ If the optional `in_dir` keyword argument is supplied it changes the
+ directory to that directory (it does not change it back to the original when
+ done).
+
+ Returns
+ -------
+
+ A tuple: (success, error message).
+
+ """
+ if in_dir is not None and isdir(in_dir):
+ os.chdir(in_dir)
+
+ def _set_exec(fname):
+ os.chmod(fname, stat.S_IRUSR|stat.S_IWUSR|stat.S_IXUSR
+ |stat.S_IRGRP|stat.S_IWGRP|stat.S_IXGRP
+ |stat.S_IROTH|stat.S_IWOTH|stat.S_IXOTH)
+ submit_f = open('submit.sh', 'w')
+ submit_f.write(answer.lstrip()); submit_f.close()
+ submit_path = abspath(submit_f.name)
+ _set_exec(submit_path)
+
+ ref_path, test_case_path = test_code.strip().splitlines()
+ if not ref_path.startswith('/'):
+ ref_path = join(MY_DIR, ref_path)
+ if not test_case_path.startswith('/'):
+ test_case_path = join(MY_DIR, test_case_path)
+
+ # Add a new signal handler for the execution of this code.
+ old_handler = signal.signal(signal.SIGALRM, timeout_handler)
+ signal.alarm(SERVER_TIMEOUT)
+
+ # Do whatever testing needed.
+ success = False
+ try:
+ success, err = self.check_bash_script(ref_path, submit_path, test_case_path)
+ except TimeoutException:
+ err = self.timeout_msg
+ except:
+ type, value = sys.exc_info()[:2]
+ err = "Error: {0}".format(repr(value))
+ finally:
+ # Set back any original signal handler.
+ signal.signal(signal.SIGALRM, old_handler)
+
+ # Delete the created file.
+ os.remove(submit_path)
+
+ # Cancel the signal if any, see signal.alarm documentation.
+ signal.alarm(0)
+
+ # Put us back into the server pool queue since we are free now.
+ self.queue.put(self.port)
+
+ return success, err
+
+ def _run_command(self, cmd_args, *args, **kw):
+ """Run a command in a subprocess while blocking, the process is killed
+ if it takes more than 2 seconds to run. Return the Popen object, the
+ stdout and stderr.
+ """
+ try:
+ proc = subprocess.Popen(cmd_args, *args, **kw)
+ stdout, stderr = proc.communicate()
+ except TimeoutException:
+ # Runaway code, so kill it.
+ proc.kill()
+ # Re-raise exception.
+ raise
+ return proc, stdout, stderr
+
+ def check_bash_script(self, ref_script_path, submit_script_path,
+ test_case_path=None):
+ """ Function validates student script using instructor script as
+ reference. Test cases can optionally be provided. The first argument
+ ref_script_path, is the path to instructor script, it is assumed to
+ have executable permission. The second argument submit_script_path, is
+ the path to the student script, it is assumed to have executable
+ permission. The Third optional argument is the path to test the
+ scripts. Each line in this file is a test case and each test case is
+ passed to the script as standard arguments.
+
+ Returns
+ --------
+
+ returns (True, "Correct answer") : If the student script passes all test
+ cases/have same output, when compared to the instructor script
+
+ returns (False, error_msg): If
+ the student script fails a single test/have dissimilar output, when
+ compared to the instructor script.
+
+ Returns (False, error_msg): If mandatory arguments are not files or if the
+ required permissions are not given to the file(s).
+
+ """
+ if not isfile(ref_script_path):
+ return False, "No file at %s"%ref_script_path
+ if not isfile(submit_script_path):
+ return False, 'No file at %s'%submit_script_path
+ if not os.access(ref_script_path, os.X_OK):
+ return False, 'Script %s is not executable'%ref_script_path
+ if not os.access(submit_script_path, os.X_OK):
+ return False, 'Script %s is not executable'%submit_script_path
+
+ if test_case_path is None:
+ ret = self._run_command(ref_script_path, stdin=None,
+ stdout=subprocess.PIPE, stderr=subprocess.PIPE)
+ proc, inst_stdout, inst_stderr = ret
+ ret = self._run_command(submit_script_path, stdin=None,
+ stdout=subprocess.PIPE, stderr=subprocess.PIPE)
+ proc, stdnt_stdout, stdnt_stderr = ret
+ if inst_stdout == stdnt_stdout:
+ return True, 'Correct answer'
+ else:
+ err = "Error: expected %s, got %s"%(inst_stderr, stdnt_stderr)
+ return False, err
+ else:
+ if not isfile(test_case_path):
+ return False, "No test case at %s"%test_case_path
+ if not os.access(ref_script_path, os.R_OK):
+ return False, "Test script %s, not readable"%test_case_path
+ valid_answer = True # We initially make it one, so that we can stop
+ # once a test case fails
+ loop_count = 0 # Loop count has to be greater than or equal to one.
+ # Useful for caching things like empty test files,etc.
+ test_cases = open(test_case_path).readlines()
+ num_lines = len(test_cases)
+ for test_case in test_cases:
+ loop_count += 1
+ if valid_answer:
+ args = [ ref_script_path ] + [x for x in test_case.split()]
+ ret = self._run_command(args, stdin=None,
+ stdout=subprocess.PIPE, stderr=subprocess.PIPE)
+ proc, inst_stdout, inst_stderr = ret
+ args = [ submit_script_path ] + [x for x in test_case.split()]
+ ret = self._run_command(args, stdin=None,
+ stdout=subprocess.PIPE, stderr=subprocess.PIPE)
+ proc, stdnt_stdout, stdnt_stderr = ret
+ valid_answer = inst_stdout == stdnt_stdout
+ if valid_answer and (num_lines == loop_count):
+ return True, "Correct answer"
+ else:
+ err = "Error: expected %s, got %s"%(inst_stdout+inst_stderr,
+ stdnt_stdout+stdnt_stderr)
+ return False, err
+
+
+ def run(self):
+ """Run XMLRPC server, serving our methods.
+ """
+ server = SimpleXMLRPCServer(("localhost", self.port))
+ self.server = server
+ server.register_instance(self)
+ self.queue.put(self.port)
+ server.serve_forever()
+
+
+################################################################################
+# `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.
+
+ Parameters
+ ----------
+
+ ports : list(int)
+ List of ports at which the CodeServer's should run.
+
+ pool_port : int
+ Port at which the server pool should serve.
+ """
+ self.my_port = pool_port
+ self.ports = ports
+ queue = Queue(maxsize=len(ports))
+ self.queue = queue
+ servers = []
+ for port in ports:
+ server = CodeServer(port, queue)
+ servers.append(server)
+ p = Process(target=server.run)
+ p.start()
+ self.servers = servers
+
+ def get_server_port(self):
+ """Get available server port from ones in the pool. This will block
+ till it gets an available server.
+ """
+ q = self.queue
+ was_waiting = True if q.empty() else False
+ port = q.get()
+ if was_waiting:
+ print '*'*80
+ print "No available servers, was waiting but got server later at %d."%port
+ print '*'*80
+ sys.stdout.flush()
+ return port
+
+ def run(self):
+ """Run server which returns an available server port where code
+ can be executed.
+ """
+ server = SimpleXMLRPCServer(("localhost", self.my_port))
+ self.server = server
+ server.register_instance(self)
+ server.serve_forever()
+
+
+################################################################################
+def main():
+ run_as_nobody()
+ if len(sys.argv) == 1:
+ ports = SERVER_PORTS
+ else:
+ ports = [int(x) for x in sys.argv[1:]]
+
+ server_pool = ServerPool(ports=ports, pool_port=SERVER_POOL_PORT)
+ server_pool.run()
+
+if __name__ == '__main__':
+ main()
diff --git a/testapp/docs/sample.args b/testapp/docs/sample.args
new file mode 100644
index 0000000..4d9f00d
--- /dev/null
+++ b/testapp/docs/sample.args
@@ -0,0 +1,2 @@
+1 2
+2 1
diff --git a/testapp/docs/sample.sh b/testapp/docs/sample.sh
new file mode 100755
index 0000000..e935cb3
--- /dev/null
+++ b/testapp/docs/sample.sh
@@ -0,0 +1,2 @@
+#!/bin/bash
+[[ $# -eq 2 ]] && echo $(( $1 + $2 )) && exit $(( $1 + $2 ))
diff --git a/testapp/docs/sample_questions.py b/testapp/docs/sample_questions.py
new file mode 100644
index 0000000..aa7f239
--- /dev/null
+++ b/testapp/docs/sample_questions.py
@@ -0,0 +1,59 @@
+from datetime import date
+
+questions = [
+Question(
+ summary='Factorial',
+ points=2,
+ type="python",
+ description='''
+Write a function called <code>fact</code> which takes a single integer argument
+(say <code>n</code>) and returns the factorial of the number.
+For example:<br/>
+<code>fact(3) -> 6</code>
+''',
+ test='''
+assert fact(0) == 1
+assert fact(5) == 120
+'''),
+
+Question(
+ summary='Simple function',
+ points=1,
+ type="python",
+ description='''Create a simple function called <code>sqr</code> which takes a single
+argument and returns the square of the argument. For example: <br/>
+<code>sqr(3) -> 9</code>.''',
+ test='''
+import math
+assert sqr(3) == 9
+assert abs(sqr(math.sqrt(2)) - 2.0) < 1e-14
+ '''),
+Question(
+ summary='Bash addition',
+ points=2,
+ type="bash",
+ description='''Write a shell script which takes two arguments on the
+ command line and prints the sum of the two on the output.''',
+ test='''\
+docs/sample.sh
+docs/sample.args
+'''),
+Question(
+ summary='Size of integer in Python',
+ points=0.5,
+ type="mcq",
+ description='''What is the largest integer value that can be represented
+in Python?''',
+ options='''No Limit
+2**32
+2**32 - 1
+None of the above
+''',
+ test = "No Limit"
+ ),
+]
+
+quiz = Quiz(start_date=date.today(),
+ duration=10,
+ description='Basic Python Quiz 1'
+ )
diff --git a/testapp/docs/sample_questions.xml b/testapp/docs/sample_questions.xml
new file mode 100644
index 0000000..53c76f8
--- /dev/null
+++ b/testapp/docs/sample_questions.xml
@@ -0,0 +1,43 @@
+<question_bank>
+
+<question>
+<summary>
+Factorial
+</summary>
+<description>
+Write a function called "fact" which takes a single integer argument (say "n")
+and returns the factorial of the number.
+For example fact(3) -> 6
+</description>
+<points>2</points>
+<type>python</type>
+<test>
+assert fact(0) == 1
+assert fact(5) == 120
+</test>
+<options>
+</options>
+</question>
+
+<question>
+<summary>
+Simple function
+</summary>
+<description>
+Create a simple function called "sqr" which takes a single argument and
+returns the square of the argument
+For example sqr(3) -> 9.
+</description>
+<points>1</points>
+<type>python</type>
+<test>
+import math
+assert sqr(3) == 9
+assert abs(sqr(math.sqrt(2)) - 2.0) &lt; 1e-14
+</test>
+<options>
+</options>
+</question>
+
+
+</question_bank>
diff --git a/testapp/exam/__init__.py b/testapp/exam/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/testapp/exam/__init__.py
diff --git a/testapp/exam/admin.py b/testapp/exam/admin.py
new file mode 100644
index 0000000..8482ef9
--- /dev/null
+++ b/testapp/exam/admin.py
@@ -0,0 +1,5 @@
+from exam.models import Question, Quiz
+from django.contrib import admin
+
+admin.site.register(Question)
+admin.site.register(Quiz)
diff --git a/testapp/exam/forms.py b/testapp/exam/forms.py
new file mode 100644
index 0000000..a5ca26f
--- /dev/null
+++ b/testapp/exam/forms.py
@@ -0,0 +1,95 @@
+from django import forms
+from exam.models import Profile
+
+from django.contrib.auth import authenticate
+from django.contrib.auth.models import User
+
+from string import letters, punctuation, digits
+
+UNAME_CHARS = letters + "._" + digits
+PWD_CHARS = letters + punctuation + digits
+
+class UserRegisterForm(forms.Form):
+
+ username = forms.CharField(max_length=30,
+ help_text='Letters, digits, period and underscores only.')
+ email = forms.EmailField()
+ password = forms.CharField(max_length=30,
+ widget=forms.PasswordInput())
+ confirm_password = forms.CharField(max_length=30,
+ widget=forms.PasswordInput())
+ first_name = forms.CharField(max_length=30)
+ last_name = forms.CharField(max_length=30)
+ roll_number = forms.CharField(max_length=30,
+ help_text="Use a dummy if you don't have one.")
+ institute = forms.CharField(max_length=128,
+ help_text='Institute/Organization')
+ department = forms.CharField(max_length=64,
+ help_text='Department you work/study at')
+ position = forms.CharField(max_length=64,
+ help_text='Student/Faculty/Researcher/Industry/etc.')
+
+ def clean_username(self):
+ u_name = self.cleaned_data["username"]
+
+ if u_name.strip(UNAME_CHARS):
+ msg = "Only letters, digits, period and underscore characters are "\
+ "allowed in username"
+ raise forms.ValidationError(msg)
+
+ try:
+ User.objects.get(username__exact = u_name)
+ raise forms.ValidationError("Username already exists.")
+ except User.DoesNotExist:
+ return u_name
+
+ def clean_password(self):
+ pwd = self.cleaned_data['password']
+ if pwd.strip(PWD_CHARS):
+ raise forms.ValidationError("Only letters, digits and punctuation are \
+ allowed in password")
+ return pwd
+
+ def clean_confirm_password(self):
+ c_pwd = self.cleaned_data['confirm_password']
+ pwd = self.data['password']
+ if c_pwd != pwd:
+ raise forms.ValidationError("Passwords do not match")
+
+ return c_pwd
+
+ def save(self):
+ u_name = self.cleaned_data["username"]
+ u_name = u_name.lower()
+ pwd = self.cleaned_data["password"]
+ email = self.cleaned_data['email']
+ new_user = User.objects.create_user(u_name, email, pwd)
+
+ new_user.first_name = self.cleaned_data["first_name"]
+ new_user.last_name = self.cleaned_data["last_name"]
+ new_user.save()
+
+ cleaned_data = self.cleaned_data
+ new_profile = Profile(user=new_user)
+ new_profile.roll_number = cleaned_data["roll_number"]
+ new_profile.institute = cleaned_data["institute"]
+ new_profile.department = cleaned_data["department"]
+ new_profile.position = cleaned_data["position"]
+ new_profile.save()
+
+ return u_name, pwd
+
+class UserLoginForm(forms.Form):
+ username = forms.CharField(max_length = 30)
+ password = forms.CharField(max_length=30, widget=forms.PasswordInput())
+
+ def clean(self):
+ super(UserLoginForm, self).clean()
+ u_name, pwd = self.cleaned_data["username"], self.cleaned_data["password"]
+ user = authenticate(username = u_name, password = pwd)
+
+ if not user:
+ raise forms.ValidationError("Invalid username/password")
+
+ return user
+
diff --git a/testapp/exam/management/__init__.py b/testapp/exam/management/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/testapp/exam/management/__init__.py
diff --git a/testapp/exam/management/commands/__init__.py b/testapp/exam/management/commands/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/testapp/exam/management/commands/__init__.py
diff --git a/testapp/exam/management/commands/dump_user_data.py b/testapp/exam/management/commands/dump_user_data.py
new file mode 100644
index 0000000..ec016bb
--- /dev/null
+++ b/testapp/exam/management/commands/dump_user_data.py
@@ -0,0 +1,98 @@
+import sys
+
+# Django imports.
+from django.core.management.base import BaseCommand
+from django.template import Template, Context
+
+# Local imports.
+from exam.views import get_user_data
+from exam.models import User
+
+data_template = Template('''\
+===============================================================================
+Data for {{ data.user.get_full_name.title }} ({{ data.user.username }})
+
+Name: {{ data.user.get_full_name.title }}
+Username: {{ data.user.username }}
+{% if data.profile %}\
+Roll number: {{ data.profile.roll_number }}
+Position: {{ data.profile.position }}
+Department: {{ data.profile.department }}
+Institute: {{ data.profile.institute }}
+{% endif %}\
+Email: {{ data.user.email }}
+Date joined: {{ data.user.date_joined }}
+Last login: {{ data.user.last_login }}
+{% for paper in data.papers %}
+Paper: {{ paper.quiz.description }}
+---------------------------------------
+Marks obtained: {{ paper.get_total_marks }}
+Questions correctly answered: {{ paper.get_answered_str }}
+Total attempts at questions: {{ paper.answers.count }}
+Start time: {{ paper.start_time }}
+User IP address: {{ paper.user_ip }}
+{% if paper.answers.count %}
+Answers
+-------
+{% for question, answers in paper.get_question_answers.items %}
+Question: {{ question.id }}. {{ question.summary }} (Points: {{ question.points }})
+{% if question.type == "mcq" %}\
+###############################################################################
+Choices: {% for option in question.options.strip.splitlines %} {{option}}, {% endfor %}
+Student answer: {{ answers.0|safe }}
+{% else %}{# non-mcq questions #}\
+{% for answer in answers %}\
+###############################################################################
+{{ answer.answer.strip|safe }}
+# Autocheck: {{ answer.error|safe }}
+{% endfor %}{# for answer in answers #}\
+{% endif %}\
+{% with answers|last as answer %}\
+Marks: {{answer.marks}}
+{% endwith %}\
+{% endfor %}{# for question, answers ... #}\
+
+Teacher comments
+-----------------
+{{ paper.comments|default:"None" }}
+{% endif %}{# if paper.answers.count #}\
+{% endfor %}{# for paper in data.papers #}
+''')
+
+
+def dump_user_data(unames, stdout):
+ '''Dump user data given usernames (a sequence) if none is given dump all
+ their data. The data is dumped to stdout.
+ '''
+ if not unames:
+ try:
+ users = User.objects.all()
+ except User.DoesNotExist:
+ pass
+ else:
+ users = []
+ for uname in unames:
+ try:
+ user = User.objects.get(username__exact = uname)
+ except User.DoesNotExist:
+ stdout.write('User %s does not exist'%uname)
+ else:
+ users.append(user)
+
+ for user in users:
+ data = get_user_data(user.username)
+ context = Context({'data': data})
+ result = data_template.render(context)
+ stdout.write(result.encode('ascii', 'xmlcharrefreplace'))
+
+class Command(BaseCommand):
+ args = '<username1> ... <usernamen>'
+ help = '''Dumps all user data to stdout, optional usernames can be
+ specified. If none is specified all user data is dumped.
+ '''
+
+ def handle(self, *args, **options):
+ """Handle the command."""
+ # Dump data.
+ dump_user_data(args, self.stdout)
+
diff --git a/testapp/exam/management/commands/load_exam.py b/testapp/exam/management/commands/load_exam.py
new file mode 100644
index 0000000..3f247a1
--- /dev/null
+++ b/testapp/exam/management/commands/load_exam.py
@@ -0,0 +1,55 @@
+# System library imports.
+from os.path import basename
+
+# Django imports.
+from django.core.management.base import BaseCommand
+
+# Local imports.
+from exam.models import Question, Quiz
+
+def clear_exam():
+ """Deactivate all questions from the database."""
+ for question in Question.objects.all():
+ question.active = False
+ question.save()
+
+ # Deactivate old quizzes.
+ for quiz in Quiz.objects.all():
+ quiz.active = False
+ quiz.save()
+
+def load_exam(filename):
+ """Load questions and quiz from the given Python file. The Python file
+ should declare a list of name "questions" which define all the questions
+ in pure Python. It can optionally load a Quiz from an optional 'quiz'
+ object.
+ """
+ # Simply exec the given file and we are done.
+ exec(open(filename).read())
+
+ if 'questions' not in locals():
+ msg = 'No variable named "questions" with the Questions in file.'
+ raise NameError(msg)
+
+ for question in questions:
+ question.save()
+
+ if 'quiz' in locals():
+ quiz.save()
+
+class Command(BaseCommand):
+ args = '<q_file1.py q_file2.py>'
+ help = '''loads the questions from given Python files which declare the
+ questions in a list called "questions".'''
+
+ def handle(self, *args, **options):
+ """Handle the command."""
+ # Delete existing stuff.
+ clear_exam()
+
+ # Load from files.
+ for fname in args:
+ self.stdout.write('Importing from {0} ... '.format(basename(fname)))
+ load_exam(fname)
+ self.stdout.write('Done\n')
+
diff --git a/testapp/exam/management/commands/load_questions_xml.py b/testapp/exam/management/commands/load_questions_xml.py
new file mode 100644
index 0000000..8bc2701
--- /dev/null
+++ b/testapp/exam/management/commands/load_questions_xml.py
@@ -0,0 +1,73 @@
+# System library imports.
+from os.path import basename
+from xml.dom.minidom import parse
+from htmlentitydefs import name2codepoint
+import re
+
+# Django imports.
+from django.core.management.base import BaseCommand
+
+# Local imports.
+from exam.models import Question
+
+def decode_html(html_str):
+ """Un-escape or decode HTML strings to more usable Python strings.
+ From here: http://wiki.python.org/moin/EscapingHtml
+ """
+ return re.sub('&(%s);' % '|'.join(name2codepoint),
+ lambda m: unichr(name2codepoint[m.group(1)]), html_str)
+
+def clear_questions():
+ """Deactivate all questions from the database."""
+ for question in Question.objects.all():
+ question.active = False
+ question.save()
+
+def load_questions_xml(filename):
+ """Load questions from the given XML file."""
+ q_bank = parse(filename).getElementsByTagName("question")
+
+ for question in q_bank:
+
+ summary_node = question.getElementsByTagName("summary")[0]
+ summary = (summary_node.childNodes[0].data).strip()
+
+ desc_node = question.getElementsByTagName("description")[0]
+ description = (desc_node.childNodes[0].data).strip()
+
+ type_node = question.getElementsByTagName("type")[0]
+ type = (type_node.childNodes[0].data).strip()
+
+ points_node = question.getElementsByTagName("points")[0]
+ points = float((points_node.childNodes[0].data).strip()) \
+ if points_node else 1.0
+
+ test_node = question.getElementsByTagName("test")[0]
+ test = decode_html((test_node.childNodes[0].data).strip())
+
+ opt_node = question.getElementsByTagName("options")[0]
+ opt = decode_html((opt_node.childNodes[0].data).strip())
+
+ new_question = Question(summary=summary,
+ description=description,
+ points=points,
+ options=opt,
+ type=type,
+ test=test)
+ new_question.save()
+
+class Command(BaseCommand):
+ args = '<q_file1.xml q_file2.xml>'
+ help = 'loads the questions from given XML files'
+
+ def handle(self, *args, **options):
+ """Handle the command."""
+ # Delete existing stuff.
+ clear_questions()
+
+ # Load from files.
+ for fname in args:
+ self.stdout.write('Importing from {0} ... '.format(basename(fname)))
+ load_questions_xml(fname)
+ self.stdout.write('Done\n')
+
diff --git a/testapp/exam/management/commands/results2csv.py b/testapp/exam/management/commands/results2csv.py
new file mode 100644
index 0000000..2993745
--- /dev/null
+++ b/testapp/exam/management/commands/results2csv.py
@@ -0,0 +1,69 @@
+# System library imports.
+import sys
+from os.path import basename
+
+# Django imports.
+from django.core.management.base import BaseCommand
+from django.template import Template, Context
+
+# Local imports.
+from exam.models import Quiz, QuestionPaper
+
+result_template = Template('''\
+"name","username","rollno","email","answered","total","attempts","position",\
+"department","institute"
+{% for paper in papers %}\
+"{{ paper.user.get_full_name.title }}",\
+"{{ paper.user.username }}",\
+"{{ paper.profile.roll_number }}",\
+"{{ paper.user.email }}",\
+"{{ paper.get_answered_str }}",\
+{{ paper.get_total_marks }},\
+{{ paper.answers.count }},\
+"{{ paper.profile.position }}",\
+"{{ paper.profile.department }}",\
+"{{ paper.profile.institute }}"
+{% endfor %}\
+''')
+
+def results2csv(filename, stdout):
+ """Write exam data to a CSV file. It prompts the user to choose the
+ appropriate quiz.
+ """
+ qs = Quiz.objects.all()
+
+ if len(qs) > 1:
+ print "Select quiz to save:"
+ for q in qs:
+ stdout.write('%d. %s\n'%(q.id, q.description))
+ quiz_id = int(raw_input("Please select quiz: "))
+ try:
+ quiz = Quiz.objects.get(id=quiz_id)
+ except Quiz.DoesNotExist:
+ stdout.write("Sorry, quiz %d does not exist!\n"%quiz_id)
+ sys.exit(1)
+ else:
+ quiz = qs[0]
+
+ papers = QuestionPaper.objects.filter(quiz=quiz,
+ user__profile__isnull=False)
+ stdout.write("Saving results of %s to %s ... "%(quiz.description,
+ basename(filename)))
+ # Render the data and write it out.
+ f = open(filename, 'w')
+ context = Context({'papers': papers})
+ f.write(result_template.render(context))
+ f.close()
+
+ stdout.write('Done\n')
+
+class Command(BaseCommand):
+ args = '<results.csv>'
+ help = '''Writes out the results of a quiz to a CSV file. Prompt user
+ to select appropriate quiz if there are multiple.
+ '''
+
+ def handle(self, *args, **options):
+ """Handle the command."""
+ # Save to file.
+ results2csv(args[0], self.stdout)
diff --git a/testapp/exam/migrations/0001_initial.py b/testapp/exam/migrations/0001_initial.py
new file mode 100644
index 0000000..49048cc
--- /dev/null
+++ b/testapp/exam/migrations/0001_initial.py
@@ -0,0 +1,193 @@
+# encoding: utf-8
+import datetime
+from south.db import db
+from south.v2 import SchemaMigration
+from django.db import models
+
+class Migration(SchemaMigration):
+
+ def forwards(self, orm):
+
+ # Adding model 'Profile'
+ db.create_table('exam_profile', (
+ ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)),
+ ('user', self.gf('django.db.models.fields.related.OneToOneField')(to=orm['auth.User'], unique=True)),
+ ('roll_number', self.gf('django.db.models.fields.CharField')(max_length=20)),
+ ('institute', self.gf('django.db.models.fields.CharField')(max_length=128)),
+ ('department', self.gf('django.db.models.fields.CharField')(max_length=64)),
+ ('position', self.gf('django.db.models.fields.CharField')(max_length=64)),
+ ))
+ db.send_create_signal('exam', ['Profile'])
+
+ # Adding model 'Question'
+ db.create_table('exam_question', (
+ ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)),
+ ('summary', self.gf('django.db.models.fields.CharField')(max_length=256)),
+ ('description', self.gf('django.db.models.fields.TextField')()),
+ ('points', self.gf('django.db.models.fields.FloatField')(default=1.0)),
+ ('test', self.gf('django.db.models.fields.TextField')(blank=True)),
+ ('options', self.gf('django.db.models.fields.TextField')(blank=True)),
+ ('type', self.gf('django.db.models.fields.CharField')(max_length=24)),
+ ('active', self.gf('django.db.models.fields.BooleanField')(default=True)),
+ ))
+ db.send_create_signal('exam', ['Question'])
+
+ # Adding model 'Answer'
+ db.create_table('exam_answer', (
+ ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)),
+ ('question', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['exam.Question'])),
+ ('answer', self.gf('django.db.models.fields.TextField')()),
+ ('error', self.gf('django.db.models.fields.TextField')()),
+ ('marks', self.gf('django.db.models.fields.FloatField')(default=0.0)),
+ ('correct', self.gf('django.db.models.fields.BooleanField')(default=False)),
+ ))
+ db.send_create_signal('exam', ['Answer'])
+
+ # Adding model 'Quiz'
+ db.create_table('exam_quiz', (
+ ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)),
+ ('start_date', self.gf('django.db.models.fields.DateField')()),
+ ('duration', self.gf('django.db.models.fields.IntegerField')(default=20)),
+ ('active', self.gf('django.db.models.fields.BooleanField')(default=True)),
+ ('description', self.gf('django.db.models.fields.CharField')(max_length=256)),
+ ))
+ db.send_create_signal('exam', ['Quiz'])
+
+ # Adding model 'QuestionPaper'
+ db.create_table('exam_questionpaper', (
+ ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)),
+ ('user', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['auth.User'])),
+ ('profile', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['exam.Profile'])),
+ ('quiz', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['exam.Quiz'])),
+ ('start_time', self.gf('django.db.models.fields.DateTimeField')()),
+ ('user_ip', self.gf('django.db.models.fields.CharField')(max_length=15)),
+ ('key', self.gf('django.db.models.fields.CharField')(max_length=10)),
+ ('active', self.gf('django.db.models.fields.BooleanField')(default=True)),
+ ('questions', self.gf('django.db.models.fields.CharField')(max_length=128)),
+ ('questions_answered', self.gf('django.db.models.fields.CharField')(max_length=128)),
+ ('comments', self.gf('django.db.models.fields.TextField')()),
+ ))
+ db.send_create_signal('exam', ['QuestionPaper'])
+
+ # Adding M2M table for field answers on 'QuestionPaper'
+ db.create_table('exam_questionpaper_answers', (
+ ('id', models.AutoField(verbose_name='ID', primary_key=True, auto_created=True)),
+ ('questionpaper', models.ForeignKey(orm['exam.questionpaper'], null=False)),
+ ('answer', models.ForeignKey(orm['exam.answer'], null=False))
+ ))
+ db.create_unique('exam_questionpaper_answers', ['questionpaper_id', 'answer_id'])
+
+
+ def backwards(self, orm):
+
+ # Deleting model 'Profile'
+ db.delete_table('exam_profile')
+
+ # Deleting model 'Question'
+ db.delete_table('exam_question')
+
+ # Deleting model 'Answer'
+ db.delete_table('exam_answer')
+
+ # Deleting model 'Quiz'
+ db.delete_table('exam_quiz')
+
+ # Deleting model 'QuestionPaper'
+ db.delete_table('exam_questionpaper')
+
+ # Removing M2M table for field answers on 'QuestionPaper'
+ db.delete_table('exam_questionpaper_answers')
+
+
+ models = {
+ 'auth.group': {
+ 'Meta': {'object_name': 'Group'},
+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+ 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}),
+ 'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'})
+ },
+ 'auth.permission': {
+ 'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'},
+ 'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
+ 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}),
+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+ 'name': ('django.db.models.fields.CharField', [], {'max_length': '50'})
+ },
+ 'auth.user': {
+ 'Meta': {'object_name': 'User'},
+ 'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
+ 'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}),
+ 'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
+ 'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}),
+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+ 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
+ 'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
+ 'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
+ 'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
+ 'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
+ 'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}),
+ 'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}),
+ 'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'})
+ },
+ 'contenttypes.contenttype': {
+ 'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"},
+ 'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+ 'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
+ 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'})
+ },
+ 'exam.answer': {
+ 'Meta': {'object_name': 'Answer'},
+ 'answer': ('django.db.models.fields.TextField', [], {}),
+ 'correct': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
+ 'error': ('django.db.models.fields.TextField', [], {}),
+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+ 'marks': ('django.db.models.fields.FloatField', [], {'default': '0.0'}),
+ 'question': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['exam.Question']"})
+ },
+ 'exam.profile': {
+ 'Meta': {'object_name': 'Profile'},
+ 'department': ('django.db.models.fields.CharField', [], {'max_length': '64'}),
+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+ 'institute': ('django.db.models.fields.CharField', [], {'max_length': '128'}),
+ 'position': ('django.db.models.fields.CharField', [], {'max_length': '64'}),
+ 'roll_number': ('django.db.models.fields.CharField', [], {'max_length': '20'}),
+ 'user': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['auth.User']", 'unique': 'True'})
+ },
+ 'exam.question': {
+ 'Meta': {'object_name': 'Question'},
+ 'active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
+ 'description': ('django.db.models.fields.TextField', [], {}),
+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+ 'options': ('django.db.models.fields.TextField', [], {'blank': 'True'}),
+ 'points': ('django.db.models.fields.FloatField', [], {'default': '1.0'}),
+ 'summary': ('django.db.models.fields.CharField', [], {'max_length': '256'}),
+ 'test': ('django.db.models.fields.TextField', [], {'blank': 'True'}),
+ 'type': ('django.db.models.fields.CharField', [], {'max_length': '24'})
+ },
+ 'exam.questionpaper': {
+ 'Meta': {'object_name': 'QuestionPaper'},
+ 'active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
+ 'answers': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['exam.Answer']", 'symmetrical': 'False'}),
+ 'comments': ('django.db.models.fields.TextField', [], {}),
+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+ 'key': ('django.db.models.fields.CharField', [], {'max_length': '10'}),
+ 'profile': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['exam.Profile']"}),
+ 'questions': ('django.db.models.fields.CharField', [], {'max_length': '128'}),
+ 'questions_answered': ('django.db.models.fields.CharField', [], {'max_length': '128'}),
+ 'quiz': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['exam.Quiz']"}),
+ 'start_time': ('django.db.models.fields.DateTimeField', [], {}),
+ 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}),
+ 'user_ip': ('django.db.models.fields.CharField', [], {'max_length': '15'})
+ },
+ 'exam.quiz': {
+ 'Meta': {'object_name': 'Quiz'},
+ 'active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
+ 'description': ('django.db.models.fields.CharField', [], {'max_length': '256'}),
+ 'duration': ('django.db.models.fields.IntegerField', [], {'default': '20'}),
+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+ 'start_date': ('django.db.models.fields.DateField', [], {})
+ }
+ }
+
+ complete_apps = ['exam']
diff --git a/testapp/exam/migrations/__init__.py b/testapp/exam/migrations/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/testapp/exam/migrations/__init__.py
diff --git a/testapp/exam/models.py b/testapp/exam/models.py
new file mode 100644
index 0000000..717e02e
--- /dev/null
+++ b/testapp/exam/models.py
@@ -0,0 +1,221 @@
+import datetime
+from django.db import models
+from django.contrib.auth.models import User
+
+################################################################################
+class Profile(models.Model):
+ """Profile for a user to store roll number and other details."""
+ user = models.OneToOneField(User)
+ roll_number = models.CharField(max_length=20)
+ institute = models.CharField(max_length=128)
+ department = models.CharField(max_length=64)
+ position = models.CharField(max_length=64)
+
+
+QUESTION_TYPE_CHOICES = (
+ ("python", "Python"),
+ ("bash", "Bash"),
+ ("mcq", "MultipleChoice"),
+ )
+
+################################################################################
+class Question(models.Model):
+ """A question in the database."""
+
+ # A one-line summary of the question.
+ summary = models.CharField(max_length=256)
+
+ # The question text, should be valid HTML.
+ description = models.TextField()
+
+ # Number of points for the question.
+ points = models.FloatField(default=1.0)
+
+ # Test cases for the question in the form of code that is run.
+ test = models.TextField(blank=True)
+
+ # Any multiple choice options. Place one option per line.
+ options = models.TextField(blank=True)
+
+ # The type of question.
+ type = models.CharField(max_length=24, choices=QUESTION_TYPE_CHOICES)
+
+ # Is this question active or not. If it is inactive it will not be used
+ # when creating a QuestionPaper.
+ active = models.BooleanField(default=True)
+
+ def __unicode__(self):
+ return self.summary
+
+
+################################################################################
+class Answer(models.Model):
+ """Answers submitted by users.
+ """
+ # The question for which we are an answer.
+ question = models.ForeignKey(Question)
+
+ # The answer submitted by the user.
+ answer = models.TextField()
+
+ # Error message when auto-checking the answer.
+ error = models.TextField()
+
+ # Marks obtained for the answer. This can be changed by the teacher if the
+ # grading is manual.
+ marks = models.FloatField(default=0.0)
+
+ # Is the answer correct.
+ correct = models.BooleanField(default=False)
+
+ def __unicode__(self):
+ return self.answer
+
+################################################################################
+class Quiz(models.Model):
+ """A quiz that students will participate in. One can think of this
+ as the "examination" event.
+ """
+
+ # The starting/ending date of the quiz.
+ start_date = models.DateField("Date of the quiz")
+
+ # This is always in minutes.
+ duration = models.IntegerField("Duration of quiz in minutes", default=20)
+
+ # Is the quiz active. The admin should deactivate the quiz once it is
+ # complete.
+ active = models.BooleanField(default=True)
+
+ # Description of quiz.
+ description = models.CharField(max_length=256)
+
+ class Meta:
+ verbose_name_plural = "Quizzes"
+
+ def __unicode__(self):
+ desc = self.description or 'Quiz'
+ return '%s: on %s for %d minutes'%(desc, self.start_date, self.duration)
+
+
+################################################################################
+class QuestionPaper(models.Model):
+ """A question paper for a student -- one per student typically.
+ """
+ # The user taking this question paper.
+ user = models.ForeignKey(User)
+
+ # The user's profile, we store a reference to make it easier to access the
+ # data.
+ profile = models.ForeignKey(Profile)
+
+ # The Quiz to which this question paper is attached to.
+ quiz = models.ForeignKey(Quiz)
+
+ # The time when this paper was started by the user.
+ start_time = models.DateTimeField()
+
+ # User's IP which is logged.
+ user_ip = models.CharField(max_length=15)
+ # Unused currently.
+ key = models.CharField(max_length=10)
+
+ # used to allow/stop a user from retaking the question paper.
+ active = models.BooleanField(default = True)
+
+ # The questions (a list of ids separated by '|')
+ questions = models.CharField(max_length=128)
+ # The questions successfully answered (a list of ids separated by '|')
+ questions_answered = models.CharField(max_length=128)
+
+ # All the submitted answers.
+ answers = models.ManyToManyField(Answer)
+
+ # Teacher comments on the question paper.
+ comments = models.TextField()
+
+ def current_question(self):
+ """Returns the current active question to display."""
+ qs = self.questions.split('|')
+ if len(qs) > 0:
+ return qs[0]
+ else:
+ return ''
+
+ def questions_left(self):
+ """Returns the number of questions left."""
+ qs = self.questions
+ if len(qs) == 0:
+ return 0
+ else:
+ return qs.count('|') + 1
+
+ def completed_question(self, question_id):
+ """Removes the question from the list of questions and returns
+ the next."""
+ qa = self.questions_answered
+ if len(qa) > 0:
+ self.questions_answered = '|'.join([qa, str(question_id)])
+ else:
+ self.questions_answered = str(question_id)
+ qs = self.questions.split('|')
+ qs.remove(unicode(question_id))
+ self.questions = '|'.join(qs)
+ self.save()
+ if len(qs) == 0:
+ return ''
+ else:
+ return qs[0]
+
+ def skip(self):
+ """Skip the current question and return the next available question."""
+ qs = self.questions.split('|')
+ if len(qs) == 0:
+ return ''
+ else:
+ # Put head at the end.
+ head = qs.pop(0)
+ qs.append(head)
+ self.questions = '|'.join(qs)
+ self.save()
+ return qs[0]
+
+ def time_left(self):
+ """Return the time remaining for the user in seconds."""
+ dt = datetime.datetime.now() - self.start_time
+ try:
+ secs = dt.total_seconds()
+ except AttributeError:
+ # total_seconds is new in Python 2.7. :(
+ secs = dt.seconds + dt.days*24*3600
+ total = self.quiz.duration*60.0
+ remain = max(total - secs, 0)
+ return int(remain)
+
+ def get_answered_str(self):
+ """Returns the answered questions, sorted and as a nice string."""
+ qa = self.questions_answered.split('|')
+ answered = ', '.join(sorted(qa))
+ return answered if answered else 'None'
+
+ def get_total_marks(self):
+ """Returns the total marks earned by student for this paper."""
+ return sum([x.marks for x in self.answers.filter(marks__gt=0.0)])
+
+ def get_question_answers(self):
+ """Return a dictionary with keys as questions and a list of the corresponding
+ answers.
+ """
+ q_a = {}
+ for answer in self.answers.all():
+ question = answer.question
+ if question in q_a:
+ q_a[question].append(answer)
+ else:
+ q_a[question] = [answer]
+ return q_a
+
+ def __unicode__(self):
+ u = self.user
+ return u'Question paper for {0} {1}'.format(u.first_name, u.last_name)
+
diff --git a/testapp/exam/tests.py b/testapp/exam/tests.py
new file mode 100644
index 0000000..501deb7
--- /dev/null
+++ b/testapp/exam/tests.py
@@ -0,0 +1,16 @@
+"""
+This file demonstrates writing tests using the unittest module. These will pass
+when you run "manage.py test".
+
+Replace this with more appropriate tests for your application.
+"""
+
+from django.test import TestCase
+
+
+class SimpleTest(TestCase):
+ def test_basic_addition(self):
+ """
+ Tests that 1 + 1 always equals 2.
+ """
+ self.assertEqual(1 + 1, 2)
diff --git a/testapp/exam/urls.py b/testapp/exam/urls.py
new file mode 100644
index 0000000..34e329f
--- /dev/null
+++ b/testapp/exam/urls.py
@@ -0,0 +1,16 @@
+from django.conf.urls.defaults import patterns, include, url
+
+urlpatterns = patterns('exam.views',
+ url(r'^$', 'index'),
+ url(r'^login/$', 'user_login'),
+ url(r'^register/$', 'user_register'),
+ url(r'^start/$', 'start'),
+ url(r'^quit/$', 'quit'),
+ url(r'^complete/$', 'complete'),
+ url(r'^monitor/$', 'monitor'),
+ url(r'^monitor/(?P<quiz_id>\d+)/$', 'monitor'),
+ url(r'^user_data/(?P<username>[a-zA-Z0-9_.]+)/$', 'user_data'),
+ url(r'^grade_user/(?P<username>[a-zA-Z0-9_.]+)/$', 'grade_user'),
+ url(r'^(?P<q_id>\d+)/$', 'question'),
+ url(r'^(?P<q_id>\d+)/check/$', 'check'),
+)
diff --git a/testapp/exam/views.py b/testapp/exam/views.py
new file mode 100644
index 0000000..c178a0b
--- /dev/null
+++ b/testapp/exam/views.py
@@ -0,0 +1,351 @@
+import random
+import string
+import os
+import stat
+from os.path import dirname, pardir, abspath, join, exists
+import datetime
+
+from django.contrib.auth import login, logout, authenticate
+from django.shortcuts import render_to_response, get_object_or_404, redirect
+from django.template import RequestContext
+from django.http import Http404
+from django.db.models import Sum
+
+# Local imports.
+from exam.models import Quiz, Question, QuestionPaper, Profile, Answer, User
+from exam.forms import UserRegisterForm, UserLoginForm
+from exam.xmlrpc_clients import code_server
+from settings import URL_ROOT
+
+# The directory where user data can be saved.
+OUTPUT_DIR = abspath(join(dirname(__file__), pardir, 'output'))
+
+
+def my_redirect(url):
+ """An overridden redirect to deal with URL_ROOT-ing. See settings.py
+ for details."""
+ return redirect(URL_ROOT + url)
+
+def my_render_to_response(template, context=None, **kwargs):
+ """Overridden render_to_response.
+ """
+ if context is None:
+ context = {'URL_ROOT': URL_ROOT}
+ else:
+ context['URL_ROOT'] = URL_ROOT
+ return render_to_response(template, context, **kwargs)
+
+
+def gen_key(no_of_chars):
+ """Generate a random key of the number of characters."""
+ allowed_chars = string.digits+string.uppercase
+ return ''.join([random.choice(allowed_chars) for i in range(no_of_chars)])
+
+def get_user_dir(user):
+ """Return the output directory for the user."""
+ user_dir = join(OUTPUT_DIR, str(user.username))
+ if not exists(user_dir):
+ os.mkdir(user_dir)
+ # Make it rwx by others.
+ os.chmod(user_dir, stat.S_IROTH | stat.S_IWOTH | stat.S_IXOTH \
+ | stat.S_IRUSR | stat.S_IWUSR | stat.S_IXUSR \
+ | stat.S_IRGRP | stat.S_IWGRP | stat.S_IXGRP)
+ return user_dir
+
+def index(request):
+ """The start page.
+ """
+ user = request.user
+ if user.is_authenticated():
+ return my_redirect("/exam/start/")
+
+ return my_redirect("/exam/login/")
+
+def user_register(request):
+ """ Register a new user.
+ Create a user and corresponding profile and store roll_number also."""
+
+ user = request.user
+ if user.is_authenticated():
+ return my_redirect("/exam/start/")
+
+ if request.method == "POST":
+ form = UserRegisterForm(request.POST)
+ if form.is_valid():
+ data = form.cleaned_data
+ u_name, pwd = form.save()
+
+ new_user = authenticate(username = u_name, password = pwd)
+ login(request, new_user)
+ return my_redirect("/exam/start/")
+
+ else:
+ return my_render_to_response('exam/register.html',
+ {'form':form},
+ context_instance=RequestContext(request))
+ else:
+ form = UserRegisterForm()
+ return my_render_to_response('exam/register.html',
+ {'form':form},
+ context_instance=RequestContext(request))
+
+def user_login(request):
+ """Take the credentials of the user and log the user in."""
+
+ user = request.user
+ if user.is_authenticated():
+ return my_redirect("/exam/start/")
+
+ if request.method == "POST":
+ form = UserLoginForm(request.POST)
+ if form.is_valid():
+ user = form.cleaned_data
+ login(request, user)
+ return my_redirect("/exam/start/")
+ else:
+ context = {"form": form}
+ return my_render_to_response('exam/login.html', context,
+ context_instance=RequestContext(request))
+ else:
+ form = UserLoginForm()
+ context = {"form": form}
+ return my_render_to_response('exam/login.html', context,
+ context_instance=RequestContext(request))
+
+def start(request):
+ user = request.user
+ try:
+ # Right now the app is designed so there is only one active quiz
+ # at a particular time.
+ quiz = Quiz.objects.get(active=True)
+ except Quiz.DoesNotExist:
+ msg = 'No active quiz found, please contact your '\
+ 'instructor/administrator. Please login again thereafter.'
+ return complete(request, reason=msg)
+ try:
+ old_paper = QuestionPaper.objects.get(user=user, quiz=quiz)
+ q = old_paper.current_question()
+ return show_question(request, q)
+ except QuestionPaper.DoesNotExist:
+ ip = request.META['REMOTE_ADDR']
+ key = gen_key(10)
+ try:
+ profile = user.get_profile()
+ except Profile.DoesNotExist:
+ msg = 'You do not have a profile and cannot take the quiz!'
+ raise Http404(msg)
+
+ new_paper = QuestionPaper(user=user, user_ip=ip, key=key,
+ quiz=quiz, profile=profile)
+ new_paper.start_time = datetime.datetime.now()
+
+ # Make user directory.
+ user_dir = get_user_dir(user)
+
+ questions = [ str(_.id) for _ in Question.objects.filter(active=True) ]
+ random.shuffle(questions)
+
+ new_paper.questions = "|".join(questions)
+ new_paper.save()
+
+ # Show the user the intro page.
+ context = {'user': user}
+ ci = RequestContext(request)
+ return my_render_to_response('exam/intro.html', context,
+ context_instance=ci)
+
+def question(request, q_id):
+ user = request.user
+ if not user.is_authenticated():
+ return my_redirect('/exam/login/')
+ q = get_object_or_404(Question, pk=q_id)
+ try:
+ paper = QuestionPaper.objects.get(user=request.user, quiz__active=True)
+ except QuestionPaper.DoesNotExist:
+ return my_redirect('/exam/start')
+ if not paper.quiz.active:
+ return complete(request, reason='The quiz has been deactivated!')
+
+ time_left = paper.time_left()
+ if time_left == 0:
+ return complete(request, reason='Your time is up!')
+ quiz_name = paper.quiz.description
+ context = {'question': q, 'paper': paper, 'user': user,
+ 'quiz_name': quiz_name,
+ 'time_left': time_left}
+ ci = RequestContext(request)
+ return my_render_to_response('exam/question.html', context,
+ context_instance=ci)
+
+def show_question(request, q_id):
+ """Show a question if possible."""
+ if len(q_id) == 0:
+ msg = 'Congratulations! You have successfully completed the quiz.'
+ return complete(request, msg)
+ else:
+ return question(request, q_id)
+
+def check(request, q_id):
+ user = request.user
+ if not user.is_authenticated():
+ return my_redirect('/exam/login/')
+ question = get_object_or_404(Question, pk=q_id)
+ paper = QuestionPaper.objects.get(user=user, quiz__active=True)
+ answer = request.POST.get('answer')
+ skip = request.POST.get('skip', None)
+
+ if skip is not None:
+ next_q = paper.skip()
+ return show_question(request, next_q)
+
+ # Add the answer submitted, regardless of it being correct or not.
+ new_answer = Answer(question=question, answer=answer, correct=False)
+ new_answer.save()
+ 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
+ # safely in a separate process (the code_server.py) running as nobody.
+ if question.type == 'mcq':
+ success = True # Only one attempt allowed for MCQ's.
+ if answer.strip() == question.test.strip():
+ new_answer.correct = True
+ new_answer.marks = question.points
+ new_answer.error = 'Correct answer'
+ else:
+ new_answer.error = 'Incorrect answer'
+ else:
+ user_dir = get_user_dir(user)
+ success, err_msg = code_server.run_code(answer, question.test,
+ user_dir, question.type)
+ new_answer.error = err_msg
+ if success:
+ # Note the success and save it along with the marks.
+ new_answer.correct = success
+ new_answer.marks = question.points
+
+ new_answer.save()
+
+ if not success: # Should only happen for non-mcq questions.
+ time_left = paper.time_left()
+ if time_left == 0:
+ return complete(request, reason='Your time is up!')
+ if not paper.quiz.active:
+ return complete(request, reason='The quiz has been deactivated!')
+
+ context = {'question': question, 'error_message': err_msg,
+ 'paper': paper, 'last_attempt': answer,
+ 'quiz_name': paper.quiz.description,
+ 'time_left': time_left}
+ ci = RequestContext(request)
+
+ return my_render_to_response('exam/question.html', context,
+ context_instance=ci)
+ else:
+ next_q = paper.completed_question(question.id)
+ return show_question(request, next_q)
+
+def quit(request):
+ return my_render_to_response('exam/quit.html',
+ context_instance=RequestContext(request))
+
+def complete(request, reason=None):
+ user = request.user
+ no = False
+ message = reason or 'The quiz has been completed. Thank you.'
+ if request.method == 'POST' and 'no' in request.POST:
+ no = request.POST.get('no', False)
+ if not no:
+ # Logout the user and quit with the message given.
+ logout(request)
+ context = {'message': message}
+ return my_render_to_response('exam/complete.html', context)
+ else:
+ return my_redirect('/exam/')
+
+
+def monitor(request, quiz_id=None):
+ """Monitor the progress of the papers taken so far."""
+ user = request.user
+ if not user.is_authenticated() and not user.is_staff:
+ raise Http404('You are not allowed to view this page!')
+
+ if quiz_id is None:
+ quizzes = Quiz.objects.all()
+ context = {'papers': [],
+ 'quiz': None,
+ 'quizzes':quizzes}
+ return my_render_to_response('exam/monitor.html', context,
+ context_instance=RequestContext(request))
+ # quiz_id is not None.
+ try:
+ quiz = Quiz.objects.get(id=quiz_id)
+ except Quiz.DoesNotExist:
+ papers = []
+ quiz = None
+ else:
+ papers = QuestionPaper.objects.all().annotate(
+ total=Sum('answers__marks')).order_by('-total')
+
+ context = {'papers': papers, 'quiz': quiz, 'quizzes': None}
+ return my_render_to_response('exam/monitor.html', context,
+ context_instance=RequestContext(request))
+
+def get_user_data(username):
+ """For a given username, this returns a dictionary of important data
+ related to the user including all the user's answers submitted.
+ """
+ user = User.objects.get(username=username)
+ papers = QuestionPaper.objects.filter(user=user)
+
+ data = {}
+ try:
+ profile = user.get_profile()
+ except Profile.DoesNotExist:
+ # Admin user may have a paper by accident but no profile.
+ profile = None
+ data['user'] = user
+ data['profile'] = profile
+ data['papers'] = papers
+ return data
+
+def user_data(request, username):
+ """Render user data."""
+ current_user = request.user
+ if not current_user.is_authenticated() and not current_user.is_staff:
+ raise Http404('You are not allowed to view this page!')
+
+ data = get_user_data(username)
+
+ context = {'data': data}
+ return my_render_to_response('exam/user_data.html', context,
+ context_instance=RequestContext(request))
+
+def grade_user(request, username):
+ """Present an interface with which we can easily grade a user's papers
+ and update all their marks and also give comments for each paper.
+ """
+ current_user = request.user
+ if not current_user.is_authenticated() and not current_user.is_staff:
+ raise Http404('You are not allowed to view this page!')
+
+ data = get_user_data(username)
+ if request.method == 'POST':
+ papers = data['papers']
+ for paper in papers:
+ for question, answers in paper.get_question_answers().iteritems():
+ marks = float(request.POST.get('q%d_marks'%question.id))
+ last_ans = answers[-1]
+ last_ans.marks = marks
+ last_ans.save()
+ paper.comments = request.POST.get('comments_%d'%paper.quiz.id)
+ paper.save()
+
+ context = {'data': data}
+ return my_render_to_response('exam/user_data.html', context,
+ context_instance=RequestContext(request))
+ else:
+ context = {'data': data}
+ return my_render_to_response('exam/grade_user.html', context,
+ context_instance=RequestContext(request))
+
diff --git a/testapp/exam/xmlrpc_clients.py b/testapp/exam/xmlrpc_clients.py
new file mode 100644
index 0000000..817e37d
--- /dev/null
+++ b/testapp/exam/xmlrpc_clients.py
@@ -0,0 +1,78 @@
+from xmlrpclib import ServerProxy
+import time
+import random
+import socket
+
+from settings import SERVER_PORTS, SERVER_POOL_PORT
+
+
+class ConnectionError(Exception):
+ pass
+
+################################################################################
+# `CodeServerProxy` class.
+################################################################################
+class CodeServerProxy(object):
+ """A class that manages accesing the farm of Python servers and making
+ calls to them such that no one XMLRPC server is overloaded.
+ """
+ def __init__(self):
+ pool_url = 'http://localhost:%d'%(SERVER_POOL_PORT)
+ self.pool_server = ServerProxy(pool_url)
+ self.methods = {"python": 'run_python_code',
+ "bash": 'run_bash_code'}
+
+ def run_code(self, answer, test_code, user_dir, language):
+ """Tests given code (`answer`) with the `test_code` supplied. If the
+ optional `in_dir` keyword argument is supplied it changes the directory
+ to that directory (it does not change it back to the original when
+ done). The parameter language specifies which language to use for the
+ tests.
+
+ Parameters
+ ----------
+ answer : str
+ The user's answer for the question.
+ test_code : str
+ The test code to check the user code with.
+ user_dir : str (directory)
+ The directory to run the tests inside.
+ language : str
+ The programming language to use.
+
+ Returns
+ -------
+ A tuple: (success, error message).
+ """
+ method_name = self.methods[language]
+ try:
+ server = self._get_server()
+ method = getattr(server, method_name)
+ result = method(answer, test_code, user_dir)
+ except ConnectionError:
+ result = [False, 'Unable to connect to any code servers!']
+ return result
+
+ def _get_server(self):
+ # Get a suitable server from our pool of servers. This may block. We
+ # try about 60 times, essentially waiting at most for about 30 seconds.
+ done, count = False, 60
+
+ while not done and count > 0:
+ try:
+ port = self.pool_server.get_server_port()
+ except socket.error:
+ # Wait a while try again.
+ time.sleep(random.random())
+ count -= 1
+ else:
+ done = True
+ if not done:
+ raise ConnectionError("Couldn't connect to a server!")
+ proxy = ServerProxy('http://localhost:%d'%port)
+ return proxy
+
+# views.py calls this Python server which forwards the request to one
+# of the running servers.
+code_server = CodeServerProxy()
+
diff --git a/testapp/manage.py b/testapp/manage.py
new file mode 100755
index 0000000..3e4eedc
--- /dev/null
+++ b/testapp/manage.py
@@ -0,0 +1,14 @@
+#!/usr/bin/env python
+from django.core.management import execute_manager
+import imp
+try:
+ imp.find_module('settings') # Assumed to be in the same directory.
+except ImportError:
+ import sys
+ sys.stderr.write("Error: Can't find the file 'settings.py' in the directory containing %r. It appears you've customized things.\nYou'll have to run django-admin.py, passing it your settings module.\n" % __file__)
+ sys.exit(1)
+
+import settings
+
+if __name__ == "__main__":
+ execute_manager(settings)
diff --git a/testapp/output/README.txt b/testapp/output/README.txt
new file mode 100644
index 0000000..3163ed4
--- /dev/null
+++ b/testapp/output/README.txt
@@ -0,0 +1,4 @@
+This directory contains files generated/saved by users as per their
+username. The test executor will chdir into this user directory for each
+user when they run the test. Do not delete this directory and ensure that
+it is writeable by all. \ No newline at end of file
diff --git a/testapp/settings.py b/testapp/settings.py
new file mode 100644
index 0000000..f1c48b9
--- /dev/null
+++ b/testapp/settings.py
@@ -0,0 +1,188 @@
+# Django settings for tester project.
+
+from os.path import dirname, join, basename, abspath
+
+DEBUG = True
+TEMPLATE_DEBUG = DEBUG
+
+# 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 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
+# service is running. It should be > 1024 and less < 65535 though.
+SERVER_POOL_PORT = 53579
+
+# Timeout for the code to run in seconds. This is an integer!
+SERVER_TIMEOUT = 2
+
+# The root of the URL, for example you might be in the situation where you
+# are not hosted as host.org/exam/ but as host.org/foo/exam/ for whatever
+# reason set this to the root you have to serve at. In the above example
+# host.org/foo/exam set URL_ROOT='/foo'
+URL_ROOT = ''
+
+
+ADMINS = (
+ # ('Your Name', 'your_email@example.com'),
+)
+
+MANAGERS = ADMINS
+
+CURDIR = abspath(dirname(__file__))
+DB_FILE = join(CURDIR, 'exam.db')
+
+DATABASES = {
+ 'default': {
+ 'ENGINE': 'django.db.backends.sqlite3', # Add 'postgresql_psycopg2', 'postgresql', 'mysql', 'sqlite3' or 'oracle'.
+ 'NAME': DB_FILE, # Or path to database file if using sqlite3.
+ 'USER': '', # Not used with sqlite3.
+ 'PASSWORD': '', # Not used with sqlite3.
+ 'HOST': '', # Set to empty string for localhost. Not used with sqlite3.
+ 'PORT': '', # Set to empty string for default. Not used with sqlite3.
+ }
+}
+
+# Local time zone for this installation. Choices can be found here:
+# http://en.wikipedia.org/wiki/List_of_tz_zones_by_name
+# although not all choices may be available on all operating systems.
+# On Unix systems, a value of None will cause Django to use the same
+# timezone as the operating system.
+# If running in a Windows environment this must be set to the same as your
+# system time zone.
+TIME_ZONE = 'Asia/Kolkata'
+
+# Language code for this installation. All choices can be found here:
+# http://www.i18nguy.com/unicode/language-identifiers.html
+LANGUAGE_CODE = 'en-us'
+
+SITE_ID = 1
+
+# If you set this to False, Django will make some optimizations so as not
+# to load the internationalization machinery.
+USE_I18N = True
+
+# If you set this to False, Django will not format dates, numbers and
+# calendars according to the current locale
+USE_L10N = True
+
+# Absolute filesystem path to the directory that will hold user-uploaded files.
+# Example: "/home/media/media.lawrence.com/media/"
+MEDIA_ROOT = ''
+
+# URL that handles the media served from MEDIA_ROOT. Make sure to use a
+# trailing slash.
+# Examples: "http://media.lawrence.com/media/", "http://example.com/media/"
+MEDIA_URL = ''
+
+# Absolute path to the directory static files should be collected to.
+# Don't put anything in this directory yourself; store your static files
+# in apps' "static/" subdirectories and in STATICFILES_DIRS.
+# Example: "/home/media/media.lawrence.com/static/"
+STATIC_ROOT = '/tmp/static/'
+
+# URL prefix for static files.
+# Example: "http://media.lawrence.com/static/"
+STATIC_URL = '/static/'
+
+# URL prefix for admin static files -- CSS, JavaScript and images.
+# Make sure to use a trailing slash.
+# Examples: "http://foo.com/static/admin/", "/static/admin/".
+ADMIN_MEDIA_PREFIX = URL_ROOT + '/static/admin/'
+
+# Additional locations of static files
+STATICFILES_DIRS = (
+ # Put strings here, like "/home/html/static" or "C:/www/django/static".
+ # Always use forward slashes, even on Windows.
+ # Don't forget to use absolute paths, not relative paths.
+ join(CURDIR, 'static'),
+)
+
+# List of finder classes that know how to find static files in
+# various locations.
+STATICFILES_FINDERS = (
+ 'django.contrib.staticfiles.finders.FileSystemFinder',
+ 'django.contrib.staticfiles.finders.AppDirectoriesFinder',
+# 'django.contrib.staticfiles.finders.DefaultStorageFinder',
+)
+
+# Make this unique, and don't share it with anybody.
+SECRET_KEY = '9h*01@*#3ok+lbj5k=ym^eb)e=rf-g70&n0^nb_q6mtk!r(qr)'
+
+# List of callables that know how to import templates from various sources.
+TEMPLATE_LOADERS = (
+ 'django.template.loaders.filesystem.Loader',
+ 'django.template.loaders.app_directories.Loader',
+# 'django.template.loaders.eggs.Loader',
+)
+
+MIDDLEWARE_CLASSES = (
+ 'django.middleware.common.CommonMiddleware',
+ 'django.contrib.sessions.middleware.SessionMiddleware',
+ 'django.middleware.csrf.CsrfViewMiddleware',
+ 'django.contrib.auth.middleware.AuthenticationMiddleware',
+ 'django.contrib.messages.middleware.MessageMiddleware',
+)
+
+ROOT_URLCONF = '%s.urls'%(basename(CURDIR))
+
+TEMPLATE_DIRS = (
+ # Put strings here, like "/home/html/django_templates" or "C:/www/django/templates".
+ # Always use forward slashes, even on Windows.
+ # Don't forget to use absolute paths, not relative paths.
+ join(CURDIR, "templates"),
+)
+
+INSTALLED_APPS = (
+ 'django.contrib.auth',
+ 'django.contrib.contenttypes',
+ 'django.contrib.sessions',
+ 'django.contrib.sites',
+ 'django.contrib.messages',
+ 'django.contrib.staticfiles',
+ # Uncomment the next line to enable the admin:
+ 'django.contrib.admin',
+ # Uncomment the next line to enable admin documentation:
+ # 'django.contrib.admindocs',
+ 'south',
+ 'exam',
+)
+
+# A sample logging configuration. The only tangible logging
+# performed by this configuration is to send an email to
+# the site admins on every HTTP 500 error.
+# See http://docs.djangoproject.com/en/dev/topics/logging for
+# more details on how to customize your logging configuration.
+LOGGING = {
+ 'version': 1,
+ 'disable_existing_loggers': False,
+ 'formatters': {
+ 'verbose': {
+ 'format': '%(levelname)s %(asctime)s %(module)s %(process)d %(thread)d %(message)s'
+ },
+ 'simple': {
+ 'format': '%(levelname)s %(message)s'
+ },
+ },
+ 'handlers': {
+ 'console':{
+ 'level':'DEBUG',
+ 'class':'logging.StreamHandler',
+ 'formatter': 'simple'
+ },
+ 'mail_admins': {
+ 'level': 'ERROR',
+ 'class': 'django.utils.log.AdminEmailHandler'
+ }
+ },
+ 'loggers': {
+ 'django.request': {
+ 'handlers': ['mail_admins', 'console'],
+ 'level': 'ERROR',
+ 'propagate': True,
+ },
+ }
+}
+
+AUTH_PROFILE_MODULE = 'exam.Profile'
diff --git a/testapp/static/exam/css/base.css b/testapp/static/exam/css/base.css
new file mode 100644
index 0000000..1323116
--- /dev/null
+++ b/testapp/static/exam/css/base.css
@@ -0,0 +1,26 @@
+body { font-family: 'Georgia', serif; font-size: 17px; color: #000; background: #eee;}
+h1, h2, h3, h4 { font-family: 'Garamond', 'Georgia', serif; font-weight: normal; }
+h1 { margin: 0 0 30px 0; font-size: 36px;}
+h1 span { display: none; }
+h2 { font-size: 26px; margin: 15px 0 5px 0; }
+h3 { font-size: 22px; margin: 15px 0 5px 0; }
+h4 { font-size: 15px; margin: 15px 0 5px 0; }
+
+.box { width: 700px; margin: 10px auto ; }
+.page { margin: 2em auto; width: 35em; border: 5px solid #ccc;
+ padding: 0.8em; background: white; }
+.entries { list-style: none; margin: 0; padding: 0; }
+.entries li { margin: 0.8em 1.2em; }
+.entries li h2 { margin-left: -1em; }
+.add-entry { font-size: 0.9em; border-bottom: 1px solid #ccc; }
+.add-entry dl { font-weight: bold; }
+.metanav { text-align: right; font-size: 0.8em; padding: 0.3em;
+ margin-bottom: 1em; background: #fafafa; }
+.flash { background: #CEE5F5; padding: 0.5em;
+ border: 1px solid #AACBE2; }
+.error { background: #F0D6D6; padding: 0.5em; }
+textarea, code,
+pre { font-family: 'Consolas', 'Menlo', 'Deja Vu Sans Mono', 'Bitstream Vera Sans Mono',
+ monospace!important; font-size: 14px; background: #eee; }
+pre { padding: 0px 30px; margin: 15px -30px; line-height: 1.3; }
+
diff --git a/testapp/templates/404.html b/testapp/templates/404.html
new file mode 100644
index 0000000..7d33dd3
--- /dev/null
+++ b/testapp/templates/404.html
@@ -0,0 +1,5 @@
+{% extends "base.html" %}
+
+{% block content %}
+The requested page does not exist.
+{% endblock %}
diff --git a/testapp/templates/500.html b/testapp/templates/500.html
new file mode 100644
index 0000000..d02721f
--- /dev/null
+++ b/testapp/templates/500.html
@@ -0,0 +1,7 @@
+{% extends "base.html" %}
+
+{% block content %}
+Internal Server error.<br />
+This event will be reported.<br />
+Sorry for the inconvinience.
+{% endblock %}
diff --git a/testapp/templates/base.html b/testapp/templates/base.html
new file mode 100644
index 0000000..c2bbabb
--- /dev/null
+++ b/testapp/templates/base.html
@@ -0,0 +1,25 @@
+<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN"
+ "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
+<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">
+
+<head>
+<title>
+{% block title %}
+{% endblock %}
+</title>
+{% block meta %}
+{% endblock %}
+<link rel="stylesheet" href="{{ URL_ROOT }}/static/exam/css/base.css" type="text/css" />
+{% block css %}
+{% endblock %}
+{% block script %}
+{% endblock %}
+</head>
+
+<body {% block onload %}{% endblock %}>
+ <div class=box>
+{% block content %}
+{% endblock %}
+ </div>
+</body>
+</html>
diff --git a/testapp/templates/exam/complete.html b/testapp/templates/exam/complete.html
new file mode 100644
index 0000000..4c3f3d5
--- /dev/null
+++ b/testapp/templates/exam/complete.html
@@ -0,0 +1,12 @@
+{% extends "base.html" %}
+
+{% block title %}Good bye!{% endblock %}
+
+{% block content %}
+<h2> Good bye! </h2>
+
+<p> {{message}} </p>
+<br />
+<p>You may now close the browser.</p>
+
+{% endblock content %}
diff --git a/testapp/templates/exam/grade_user.html b/testapp/templates/exam/grade_user.html
new file mode 100644
index 0000000..75ed2e0
--- /dev/null
+++ b/testapp/templates/exam/grade_user.html
@@ -0,0 +1,83 @@
+{% extends "base.html" %}
+
+{% block title %} Grading papers for {{ data.user.get_full_name.title }} {% endblock title %}
+
+{% block content %}
+
+<h1> Grading papers for {{ data.user.get_full_name.title }} </h1>
+
+<p>
+Name: {{ data.user.get_full_name.title }}
+{% if data.profile %}
+(roll number: {{ data.profile.roll_number }}) <br/>
+{{ data.profile.position }},
+{{ data.profile.department }},
+{{ data.profile.institute }}
+{% endif %}
+</p>
+
+{% if data.papers %}
+
+{% for paper in data.papers %}
+
+<h2> Quiz: {{ paper.quiz.description }} </h2>
+
+<p>
+Questions correctly answered: {{ paper.get_answered_str }} <br/>
+Total attempts at questions: {{ paper.answers.count }} <br/>
+Marks obtained: {{ paper.get_total_marks }} <br/>
+Start time: {{ paper.start_time }} <br/>
+</p>
+
+{% if paper.answers.count %}
+<h3> Answers </h3>
+<form id="q{{ paper.quiz.id }}_form"
+ action="{{URL_ROOT}}/exam/grade_user/{{data.user.username}}/" method="post">
+{% csrf_token %}
+{% for question, answers in paper.get_question_answers.items %}
+<p><strong>
+ <a href="{{URL_ROOT}}/admin/exam/question/{{question.id}}">
+ Question: {{ question.id }}. {{ question.summary }} </a>
+ (Points: {{ question.points }})</strong> </p>
+{% if question.type == "mcq" %}
+<p> Choices:
+{% for option in question.options.strip.splitlines %} {{option}}, {% endfor %}
+</p>
+<p>Student answer: {{ answers.0 }}</p>
+{% else %}{# non-mcq questions #}
+<pre>
+{% for answer in answers %}################################################################################
+{{ answer.answer.strip }}
+# Autocheck: {{ answer.error }}
+{% endfor %}</pre>
+{% endif %} {# if question.type #}
+{% with answers|last as answer %}
+Marks: <input id="q{{ question.id }}" type="text"
+ name="q{{ question.id }}_marks" size="4"
+ value="{{ answer.marks }}" />
+{% endwith %}
+{% endfor %} {# for question, answers ... #}
+<h3>Teacher comments: </h3>
+<textarea id="comments_{{paper.quiz.id}}" rows="10" cols="80"
+ name="comments_{{ paper.quiz.id }}">{{ paper.comments }}</textarea>
+<br/>
+<input type="submit" name="submit_{{paper.quiz.id}}" value="Save marks" />
+</form>
+{% endif %} {# if paper.answers.count #}
+
+{% endfor %} {# for paper in data.papers #}
+
+{% endif %} {# if data.papers #}
+
+{% if data.papers.count > 1 %}
+<a href="{{URL_ROOT}}/exam/monitor/">
+ Monitor quiz</a>
+{% else %}
+{% with data.papers.0 as paper %}
+<a href="{{URL_ROOT}}/exam/monitor/{{paper.quiz.id}}/">
+ Monitor quiz</a>
+{% endwith %}
+{% endif %}
+<br />
+<a href="{{URL_ROOT}}/admin/">Admin</a>
+{% endblock content %}
diff --git a/testapp/templates/exam/intro.html b/testapp/templates/exam/intro.html
new file mode 100644
index 0000000..1d3e5de
--- /dev/null
+++ b/testapp/templates/exam/intro.html
@@ -0,0 +1,53 @@
+{% extends "base.html" %}
+
+{% block title %}Instructions and Rules {% endblock %}
+
+{% block content %}
+<h2>Important rules and instructions</h2>
+
+<p> Welcome <strong>{{user.first_name.title}} {{user.last_name.title}}</strong>,
+to the programming quiz! </p>
+
+<p>
+This examination system has been developed with the intention of making you
+learn programming and be assessed in an interactive and fun manner.
+You will be presented with a series of programming questions and problems that
+you will answer online and get immediate feedback for.
+</p>
+
+<p> Here are some important instructions and rules that you should understand
+carefully.
+</p>
+
+<ul>
+
+ <li>For any programming questions, you can submit solutions as many times as
+ you want without a penalty. You may skip questions and solve them later.
+ </li>
+
+ <li> You <strong>may</strong> use your computer's Python/IPython shell or
+ an editor to solve
+ the problem and cut/paste the solution to the web interface.
+ </li>
+
+ <li> <strong>You are <strong>not allowed</strong> to use any internet
+ resources, i.e. no google etc.</strong> </li>
+
+ <li> Do not copy or share the questions or answers with anyone until the
+ exam is complete <strong>for everyone</strong>.</li>
+
+ <li> <strong>All</strong> your attempts at the questions are logged.
+ Do not try to outsmart and break the testing system. If you do, we know
+ who you are and we will expell you from the course. You have been warned.
+ </li>
+
+</ul>
+
+<p> We hope you enjoy taking this exam.</p>
+
+<form action="{{URL_ROOT}}/exam/start/" method="post" align="center">
+{% csrf_token %}
+<input type="submit" name="start" value="Start Exam!">
+</form>
+
+{% endblock content %} \ No newline at end of file
diff --git a/testapp/templates/exam/login.html b/testapp/templates/exam/login.html
new file mode 100644
index 0000000..8e6352e
--- /dev/null
+++ b/testapp/templates/exam/login.html
@@ -0,0 +1,20 @@
+{% extends "base.html" %}
+
+{% block title %}Login{% endblock title %}
+
+{% block content %}
+<p> Welcome to the Examination.
+Please login to proceed.</p>
+
+<form action="" method="post">
+{% csrf_token %}
+
+<table>
+{{ form.as_table }}
+</table>
+
+<input type="submit" value="Login" />
+</form>
+<!-- <a href="{{URL_ROOT}}/exam/forgotpassword/">Forgot Password</a> <br /> -->
+<a href="{{URL_ROOT}}/exam/register/">New User Registration</a>
+{% endblock content %} \ No newline at end of file
diff --git a/testapp/templates/exam/monitor.html b/testapp/templates/exam/monitor.html
new file mode 100644
index 0000000..fb6cb58
--- /dev/null
+++ b/testapp/templates/exam/monitor.html
@@ -0,0 +1,67 @@
+{% extends "base.html" %}
+
+{% block title %} Quiz results {% endblock title %}
+
+{% block meta %} <meta http-equiv="refresh" content="30"/> {% endblock meta %}
+
+{% block content %}
+
+{% if not quizzes and not quiz %}
+<h1> Quiz results </h1>
+
+<p> No quizzes available. </p>
+
+{% endif %}
+
+{# ############################################################### #}
+{# This is rendered when we are just viewing exam/monitor #}
+{% if quizzes %}
+<h1> Available quizzes </h1>
+
+<ul>
+{% for quiz in quizzes %}
+<li><a href="{{URL_ROOT}}/exam/monitor/{{quiz.id}}/">{{ quiz.description }}</a></li>
+{% endfor %}
+</ul>
+{% endif %}
+
+{# ############################################################### #}
+{# This is rendered when we are just viewing exam/monitor/quiz_num #}
+{% if quiz %}
+<h1> {{ quiz.description }} results </h1>
+{% if papers %}
+{# <p> Quiz: {{ quiz_name }}</p> #}
+<p>Number of papers: {{ papers|length }} </p>
+
+<table border="1" cellpadding="3">
+ <tr>
+ <th> Name </th>
+ <th> Username </th>
+ <th> Roll number </th>
+ <th> Institute </th>
+ <th> Questions answered </th>
+ <th> Total marks </th>
+ <th> Attempts </th>
+ </tr>
+ {% for paper in papers %}
+ <tr>
+ <td> <a href="{{URL_ROOT}}/exam/user_data/{{paper.user.username}}">
+ {{ paper.user.get_full_name.title }}</a> </td>
+ <td> <a href="{{URL_ROOT}}/exam/user_data/{{paper.user.username}}">
+ {{ paper.user.username }}</a> </td>
+ <td> {{ paper.profile.roll_number }} </td>
+ <td> {{ paper.profile.institute }} </td>
+ <td> {{ paper.get_answered_str }} </td>
+ <td> {{ paper.get_total_marks }} </td>
+ <td> {{ paper.answers.count }} </td>
+ </tr>
+ {% endfor %}
+</table>
+{% else %}
+<p> No answer papers so far. </p>
+{% endif %} {# if papers #}
+{% endif %}
+
+<a href="{{URL_ROOT}}/admin/">Admin</a>
+
+{% endblock content %}
diff --git a/testapp/templates/exam/question.html b/testapp/templates/exam/question.html
new file mode 100644
index 0000000..8b589b6
--- /dev/null
+++ b/testapp/templates/exam/question.html
@@ -0,0 +1,91 @@
+{% extends "base.html" %}
+
+{% block title %} Answer question {% endblock %}
+
+{% block script %}
+<script type="text/javascript">
+<!--
+var time_left = {{ time_left }};
+
+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 ...";
+ document.getElementById("skip").disabled = true;
+}
+
+function secs_to_time(secs)
+{
+ var h = Math.floor(secs/3600);
+ var h_s = (h > 0) ? h+'h:' : '';
+ var m = Math.floor((secs%3600)/60);
+ var m_s = (m > 0) ? m+'m:' : '';
+ var s_s = Math.floor(secs%60) + 's';
+ return h_s + m_s + s_s;
+}
+
+function update_time()
+{
+ time_left -= 1;
+ if (time_left) {
+ var elem = document.getElementById("time_left");
+ var t_str = secs_to_time(time_left);
+ elem.innerHTML = "<strong> Time left: " + t_str + "</strong>";
+ setTimeout("update_time()", 1000);
+ }
+ else {
+ document.forms["code"].submit();
+ }
+}
+//-->
+</script>
+{% endblock script %}
+
+{% block onload %} onload="update_time()" {% endblock %}
+
+{% block content %}
+<h3> {{ question.summary }} </h3>
+
+<p>{{ question.description|safe }}
+<br/>
+(Marks: {{ question.points }}) </p>
+
+{% if error_message %}<p><strong>ERROR:</strong></p><pre>{{ error_message }}</pre>{% endif %}
+
+<p id="status"></p>
+
+<form id="code" action="{{URL_ROOT}}/exam/{{ question.id }}/check/" method="post">
+{% csrf_token %}
+{% if question.type == "mcq" %}
+{% for option in question.options.strip.splitlines %}
+<input name="answer" type="radio" value="{{option}}" />{{option}} <br/>
+{% endfor %}
+{% else %}
+<textarea rows="20" cols="100" name="answer">{% if last_attempt %}{{last_attempt.strip}}{% else %}{% if question.type == "bash" %}#!/bin/bash{% else %}# Enter your answer here.{% endif %}{% endif %}</textarea>
+{% endif %}
+<br/>
+{% if question.type == "mcq" %}
+<input id="check" type="submit" name="check" value="Submit answer"/>
+{% else %}
+<input id="check" type="submit" name="check" value="Check Answer"
+onclick="submitCode();"/>
+{% endif %}
+<input id="skip" type="submit" name="skip" value="Skip question" />
+</form>
+
+<p> {{ user.first_name.title }} {{ user.last_name.title }},
+you have {{ paper.questions_left }} question(s) left in {{ quiz_name }}.</p>
+
+<p id="time_left"> <strong> Time left: </strong> </p>
+
+<hr/>
+<form id="logout" action="{{URL_ROOT}}/exam/quit/" method="post">
+{% csrf_token %}
+<input type="submit" name="quit" value="Quit exam and logout" />
+</form>
+
+{% endblock content %}
diff --git a/testapp/templates/exam/quit.html b/testapp/templates/exam/quit.html
new file mode 100644
index 0000000..37b5c08
--- /dev/null
+++ b/testapp/templates/exam/quit.html
@@ -0,0 +1,14 @@
+{% extends "base.html" %}
+
+{% block title %}Quit exam {% endblock %}
+
+{% block content %}
+<p>Your current answers are saved.</p>
+<p> Are you sure you wish to quit the exam?</p>
+
+<form action="{{URL_ROOT}}/exam/complete/" method="post">
+{% csrf_token %}
+<input type="submit" name="yes" value="Yes!" />
+<input type="submit" name="no" value="No!" />
+</form>
+{% endblock content %} \ No newline at end of file
diff --git a/testapp/templates/exam/register.html b/testapp/templates/exam/register.html
new file mode 100644
index 0000000..921e7b5
--- /dev/null
+++ b/testapp/templates/exam/register.html
@@ -0,0 +1,17 @@
+{% extends "base.html" %}
+
+{% block title %}Registration form {% endblock %}
+
+{% block content %}
+Please provide the following details.
+<form action="" method="post">
+{% csrf_token %}
+
+<table>
+{{ form.as_table }}
+</table>
+
+<input type="submit" value="Register" />
+</form>
+
+{% endblock content %} \ No newline at end of file
diff --git a/testapp/templates/exam/user_data.html b/testapp/templates/exam/user_data.html
new file mode 100644
index 0000000..9fb442a
--- /dev/null
+++ b/testapp/templates/exam/user_data.html
@@ -0,0 +1,84 @@
+{% extends "base.html" %}
+
+{% block title %} Data for user {{ data.user.get_full_name.title }} {% endblock title %}
+
+{% block content %}
+
+<h1> Data for user {{ data.user.get_full_name.title }} </h1>
+
+<p>
+Name: {{ data.user.get_full_name.title }} <br/>
+Username: {{ data.user.username }} <br/>
+{% if data.profile %}
+Roll number: {{ data.profile.roll_number }} <br/>
+Position: {{ data.profile.position }} <br/>
+Department: {{ data.profile.department }} <br/>
+Institute: {{ data.profile.institute }} <br/>
+{% endif %}
+Email: {{ data.user.email }} <br/>
+Date joined: {{ data.user.date_joined }} <br/>
+Last login: {{ data.user.last_login }}
+</p>
+
+{% if data.papers %}
+<p><a href="{{URL_ROOT}}/exam/grade_user/{{ data.user.username }}/">
+ Grade/correct paper</a>
+</p>
+
+{% for paper in data.papers %}
+
+<h2> Quiz: {{ paper.quiz.description }} </h2>
+
+<p>
+Questions correctly answered: {{ paper.get_answered_str }} <br/>
+Total attempts at questions: {{ paper.answers.count }} <br/>
+Marks obtained: {{ paper.get_total_marks }} <br/>
+Start time: {{ paper.start_time }} <br/>
+User IP address: {{ paper.user_ip }}
+</p>
+
+{% if paper.answers.count %}
+<h3> Answers </h3>
+{% for question, answers in paper.get_question_answers.items %}
+<p><strong> Question: {{ question.id }}. {{ question.summary }} (Points: {{ question.points }})</strong> </p>
+{% if question.type == "mcq" %}
+<p> Choices:
+{% for option in question.options.strip.splitlines %} {{option}}, {% endfor %}
+</p>
+<p>Student answer: {{ answers.0 }}</p>
+{% else %}{# non-mcq questions #}
+<pre>
+{% for answer in answers %}################################################################################
+{{ answer.answer.strip }}
+# Autocheck: {{ answer.error }}
+{% endfor %}</pre>
+{% endif %}
+{% with answers|last as answer %}
+<p><em>Marks: {{answer.marks}} </em> </p>
+{% endwith %}
+{% endfor %} {# for question, answers ... #}
+<h3>Teacher comments: </h3>
+{{ paper.comments|default:"None" }}
+{% endif %} {# if paper.answers.count #}
+
+{% endfor %} {# for paper in data.papers #}
+
+{% endif %} {# if data.papers #}
+<br />
+<hr />
+<a href="{{URL_ROOT}}/exam/grade_user/{{ data.user.username }}/">
+ Grade/correct paper</a>
+<br/>
+{% if data.papers.count > 1 %}
+<a href="{{URL_ROOT}}/exam/monitor/">
+ Monitor quiz</a>
+{% else %}
+{% with data.papers.0 as paper %}
+<a href="{{URL_ROOT}}/exam/monitor/{{paper.quiz.id}}/">
+ Monitor quiz</a>
+{% endwith %}
+{% endif %}
+<br />
+<a href="{{URL_ROOT}}/admin/">Admin</a>
+
+{% endblock content %}
diff --git a/testapp/test_server.py b/testapp/test_server.py
new file mode 100644
index 0000000..be9f876
--- /dev/null
+++ b/testapp/test_server.py
@@ -0,0 +1,83 @@
+"""Simple test suite for the code server. Running this requires that one start
+up the code server as::
+
+ $ sudo ./code_server.py
+
+"""
+from exam.xmlrpc_clients import code_server
+
+def check_result(result, check='correct answer'):
+ if check != 'correct answer':
+ assert result[0] == False
+ else:
+ assert result[0] == True
+ if "unable to connect" in result[1].lower():
+ assert result[0], result[1]
+ assert check in result[1].lower(), result[1]
+
+def test_python():
+ """Test if server runs Python code as expected."""
+ src = 'while True: pass'
+ result = code_server.run_code(src, '', '/tmp', language="python")
+ check_result(result, 'more than ')
+
+ src = 'x = 1'
+ result = code_server.run_code(src, 'assert x == 1', '/tmp',
+ language="python")
+ check_result(result, 'correct answer')
+
+ result = code_server.run_code(src, 'assert x == 0', '/tmp',
+ language="python")
+ check_result(result, 'assertionerror')
+
+ src = 'abracadabra'
+ result = code_server.run_code(src, 'assert x == 0', '/tmp',
+ language="python")
+ check_result(result, 'nameerror')
+
+def test_bash():
+ """Test if server runs Bash code as expected."""
+ src = """
+#!/bin/bash
+ [[ $# -eq 2 ]] && echo $(( $1 + $2 )) && exit $(( $1 + $2 ))
+ """
+ result = code_server.run_code(src,
+ 'docs/sample.sh\ndocs/sample.args', '/tmp', language="bash")
+ check_result(result)
+
+ src = """
+#!/bin/bash
+ [[ $# -eq 2 ]] && echo $(( $1 - $2 )) && exit $(( $1 - $2 ))
+ """
+ result = code_server.run_code(src,
+ 'docs/sample.sh\ndocs/sample.args', '/tmp', language="bash")
+ check_result(result, 'error')
+
+ src = """\
+#!/bin/bash
+ while [ 1 ] ; do echo "" > /dev/null ; done
+ """
+ result = code_server.run_code(src,
+ 'docs/sample.sh\ndocs/sample.args', '/tmp', language="bash")
+ check_result(result, 'more than ')
+
+ src = '''
+#!/bin/bash
+ while [ 1 ] ; do echo "" > /dev/null
+ '''
+ result = code_server.run_code(src,
+ 'docs/sample.sh\ndocs/sample.args', '/tmp', language="bash")
+ check_result(result, 'error')
+
+ src = '''# Enter your code here.
+#!/bin/bash
+ while [ 1 ] ; do echo "" > /dev/null
+ '''
+ result = code_server.run_code(src,
+ 'docs/sample.sh\ndocs/sample.args', '/tmp', language="bash")
+ check_result(result, 'oserror')
+
+if __name__ == '__main__':
+ test_python()
+ test_bash()
+
diff --git a/testapp/urls.py b/testapp/urls.py
new file mode 100644
index 0000000..d956bfc
--- /dev/null
+++ b/testapp/urls.py
@@ -0,0 +1,23 @@
+from django.conf.urls.defaults import patterns, include, url
+
+# Uncomment the next two lines to enable the admin:
+from django.contrib import admin
+admin.autodiscover()
+
+from settings import URL_ROOT
+
+if URL_ROOT.startswith('/'):
+ URL_BASE = r'^%s/exam/'%URL_ROOT[1:]
+ ADMIN_BASE = r'^%s/admin/'%URL_ROOT[1:]
+else:
+ URL_BASE = r'^exam/'
+ ADMIN_BASE = r'^admin/'
+
+urlpatterns = patterns('',
+ url(URL_BASE, include('exam.urls')),
+
+ # Uncomment the admin/doc line below to enable admin documentation:
+ # url(r'^admin/doc/', include('django.contrib.admindocs.urls')),
+ # Uncomment the next line to enable the admin:
+ url(ADMIN_BASE, include(admin.site.urls)),
+)