diff options
Diffstat (limited to 'testapp/exam')
-rw-r--r-- | testapp/exam/__init__.py | 0 | ||||
-rw-r--r-- | testapp/exam/admin.py | 5 | ||||
-rw-r--r-- | testapp/exam/forms.py | 95 | ||||
-rw-r--r-- | testapp/exam/management/__init__.py | 0 | ||||
-rw-r--r-- | testapp/exam/management/commands/__init__.py | 0 | ||||
-rw-r--r-- | testapp/exam/management/commands/dump_user_data.py | 98 | ||||
-rw-r--r-- | testapp/exam/management/commands/load_exam.py | 55 | ||||
-rw-r--r-- | testapp/exam/management/commands/load_questions_xml.py | 73 | ||||
-rw-r--r-- | testapp/exam/management/commands/results2csv.py | 69 | ||||
-rw-r--r-- | testapp/exam/migrations/0001_initial.py | 193 | ||||
-rw-r--r-- | testapp/exam/migrations/__init__.py | 0 | ||||
-rw-r--r-- | testapp/exam/models.py | 221 | ||||
-rw-r--r-- | testapp/exam/tests.py | 16 | ||||
-rw-r--r-- | testapp/exam/urls.py | 16 | ||||
-rw-r--r-- | testapp/exam/views.py | 351 | ||||
-rw-r--r-- | testapp/exam/xmlrpc_clients.py | 78 |
16 files changed, 1270 insertions, 0 deletions
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() + |