diff options
author | parth | 2011-12-09 04:02:19 +0530 |
---|---|---|
committer | parth | 2011-12-09 04:02:19 +0530 |
commit | 861d2cc5e36835f60bace61a919e73b4bd27274b (patch) | |
tree | d08d776f58167903781c371d147f5b9e1f83e168 /testapp | |
parent | 7104f495d01fb934af11c8dfd09da087174c1b12 (diff) | |
download | online_test-861d2cc5e36835f60bace61a919e73b4bd27274b.tar.gz online_test-861d2cc5e36835f60bace61a919e73b4bd27274b.tar.bz2 online_test-861d2cc5e36835f60bace61a919e73b4bd27274b.zip |
Moved all the apps to testapp folder
Diffstat (limited to 'testapp')
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) < 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)), +) |