diff options
44 files changed, 2235 insertions, 107 deletions
diff --git a/.coveragerc b/.coveragerc index 1ddb3fc..a762d08 100644 --- a/.coveragerc +++ b/.coveragerc @@ -17,6 +17,7 @@ omit = yaksh/pipeline/* yaksh/migrations/* yaksh/templatetags/__init__.py + yaksh/templatetags/test_custom_filters.py yaksh/middleware/__init__.py setup.py tasks.py diff --git a/.travis.yml b/.travis.yml index b1a8402..59eaa66 100644 --- a/.travis.yml +++ b/.travis.yml @@ -21,6 +21,7 @@ install: script: - coverage erase - coverage run -p manage.py test -v 2 --settings online_test.test_settings yaksh + - coverage run -p manage.py test -v 2 --settings online_test.test_settings grades - coverage run -p manage.py test -v 2 --settings online_test.test_settings yaksh.live_server_tests.load_test after_success: diff --git a/CHANGELOG.txt b/CHANGELOG.txt index d3364e0..01d80dd 100644 --- a/CHANGELOG.txt +++ b/CHANGELOG.txt @@ -1,8 +1,29 @@ -=== 0.8.0 === +=== 0.8.0 (23-03-2018) === + +* Refactored the add_group command to allow creation of moderator group and add users to moderator group and renamed to create_moderator. +* Deprecated multiple management commands: dump_user_data, load_exam, load_question_xml, results2csv. +* Changed the unit of time_between_attempts field within Quiz model from days to hours. +* Fixed assignment upload feature. +* Error output is displayed using Ajax instead of reloading the entire page. +* Added Mathjax to the repository. +* Added Yaksh logo on the website. +* Changed travis build distribution from precise (Ubuntu 12.04 LTS) to trusty (Ubuntu 14.04 LTS). +* Fixed a bug that allowed creation of multiple answerpapers. +* Added MCQ/MCC Shuffle testcases feature for question paper. +* Added Arrange in Correct Order question type feature. +* Added a feature to create a course with lessons, quizzes and exercises. +* Fixed a bug where a oauth users' email is not verified. +* Added a feature to show per student course completion status. +* Fixed a bug where a moderator could change question paper of other moderator. +* Fixed a bug where a teacher could not access question paper for a course. +* Fixed a bug where a teacher could become the course creator while editing a course. +* Updated clone course feature to create copy of course, lessons, quizzes and learning modules. +* Changed Student dashboard to show the days remaining for a course to start. +* Changed UI for student and moderator dashboard. +* Updated documentation +* Added a feature where a moderator can create exercises. +* Added grading feature which allows a moderator to create a grading system and apply it for a course. -* Added a management command to add users to moderator group -* Renamed add_group management command to create_mod_group -* Deprecated multiple management commands: dump_user_data, load_exam, load_question_xml, results2csv === 0.7.0 (15-11-2017) === @@ -24,8 +45,8 @@ * Added a Datetimepicker to edit course Page. * Added invoke script for quickstart and docker deployment. * Added the facility to send mails to all students enrolled in a course. -* Fixed a bug that would cause issue during email activation key check -* Added comprehensive tests for views.py +* Fixed a bug that would cause issue during email activation key check. +* Added comprehensive tests for views.py. * Fixed a bug that allowed moderators to set cyclic quiz prerequisites in a course. * Added a feature that redirects to edit profile page if user has no profile. * Fixed a bug that would allow enrolled students to attempt quizzes for deactivated courses. @@ -48,7 +69,7 @@ === 0.6.0 (11-05-2017) === * Added a course code field that can be used to search a course. -* Updated the documentation to describe the course code feature +* Updated the documentation to describe the course code feature. * Fixed a bug that prevented redirection based on 'next' parameter after login. * Fixed a bug that littered residual system processes created by code evaluators. * Added the facility to allow moderators to see and download uploaded assignments. @@ -71,4 +92,3 @@ * Fixed a bug that displayed the elements of stdout testcase output as unicode. * Fixed a bug that prevented users from logging in using Google OAuth. * Added coverage reports to travis.yml. - diff --git a/README_production.rst b/README_production.rst index a9bd55b..13fb8f9 100644 --- a/README_production.rst +++ b/README_production.rst @@ -222,33 +222,3 @@ Follow these steps to deploy and run the Django Server, MySQL instance and Code invoke remove 12. You can use ``invoke --list`` to get a list of all the available commands - - -.. _add-commands: - -###################################### -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: - -:: - - $ python manage.py help [command] - -where [command] is one of the above. diff --git a/grades/__init__.py b/grades/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/grades/__init__.py diff --git a/grades/admin.py b/grades/admin.py new file mode 100644 index 0000000..548791e --- /dev/null +++ b/grades/admin.py @@ -0,0 +1,9 @@ +from django.contrib import admin +from grades.models import GradingSystem, GradeRange + + +class GradingSystemAdmin(admin.ModelAdmin): + readonly_fields = ('creator',) + +admin.site.register(GradingSystem, GradingSystemAdmin) +admin.site.register(GradeRange) diff --git a/grades/apps.py b/grades/apps.py new file mode 100644 index 0000000..6d0985e --- /dev/null +++ b/grades/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class GradesConfig(AppConfig): + name = 'grades' diff --git a/grades/forms.py b/grades/forms.py new file mode 100644 index 0000000..130659d --- /dev/null +++ b/grades/forms.py @@ -0,0 +1,8 @@ +from grades.models import GradingSystem +from django import forms + + +class GradingSystemForm(forms.ModelForm): + class Meta: + model = GradingSystem + fields = ['name', 'description'] diff --git a/grades/migrations/0001_initial.py b/grades/migrations/0001_initial.py new file mode 100644 index 0000000..04a3006 --- /dev/null +++ b/grades/migrations/0001_initial.py @@ -0,0 +1,43 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.9.5 on 2018-02-12 11:12 +from __future__ import unicode_literals + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='GradeRange', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('lower_limit', models.FloatField()), + ('upper_limit', models.FloatField()), + ('grade', models.CharField(max_length=10)), + ('description', models.CharField(blank=True, max_length=127, null=True)), + ], + ), + migrations.CreateModel( + name='GradingSystem', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=255, unique=True)), + ('description', models.TextField(default='About the grading system!')), + ('creator', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + ), + migrations.AddField( + model_name='graderange', + name='system', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='grades.GradingSystem'), + ), + ] diff --git a/grades/migrations/__init__.py b/grades/migrations/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/grades/migrations/__init__.py diff --git a/grades/migrations/default_grading_system.py b/grades/migrations/default_grading_system.py new file mode 100644 index 0000000..85390d6 --- /dev/null +++ b/grades/migrations/default_grading_system.py @@ -0,0 +1,41 @@ +from django.db import migrations + + +def create_default_system(apps, schema_editor): + GradingSystem = apps.get_model('grades', 'GradingSystem') + GradeRange = apps.get_model('grades', 'GradeRange') + db = schema_editor.connection.alias + + default_system = GradingSystem.objects.using(db).create(name='default') + + graderanges_objects = [ + GradeRange(system=default_system, lower_limit=0, upper_limit=40, + grade='F', description='Fail'), + GradeRange(system=default_system, lower_limit=40, upper_limit=55, + grade='P', description='Pass'), + GradeRange(system=default_system, lower_limit=55, upper_limit=60, + grade='C', description='Average'), + GradeRange(system=default_system, lower_limit=60, upper_limit=75, + grade='B', description='Satisfactory'), + GradeRange(system=default_system, lower_limit=75, upper_limit=90, + grade='A', description='Good'), + GradeRange(system=default_system, lower_limit=90, upper_limit=101, + grade='A+', description='Excellent') + ] + GradeRange.objects.using(db).bulk_create(graderanges_objects) + + +def delete_default_system(apps, schema_editor): + GradingSystem = apps.get_model('grades', 'GradingSystem') + GradeRange = apps.get_model('grades', 'GradeRange') + db = schema_editor.connection.alias + + default_system = GradingSystem.objects.using(db).get(creator=None) + GradeRange.object.using(db).filter(system=default_system).delete() + default_system.delete() + + +class Migration(migrations.Migration): + dependencies = [('grades', '0001_initial'), ] + operations = [migrations.RunPython(create_default_system, + delete_default_system), ] diff --git a/grades/models.py b/grades/models.py new file mode 100644 index 0000000..fcea510 --- /dev/null +++ b/grades/models.py @@ -0,0 +1,46 @@ +from django.db import models +from django.contrib.auth.models import User + + +class GradingSystem(models.Model): + name = models.CharField(max_length=255, unique=True) + description = models.TextField(default='About the grading system!') + creator = models.ForeignKey(User, null=True, blank=True) + + def get_grade(self, marks): + ranges = self.graderange_set.all() + lower_limits = ranges.values_list('lower_limit', flat=True) + upper_limits = ranges.values_list('upper_limit', flat=True) + lower_limit = self._get_lower_limit(marks, lower_limits) + upper_limit = self._get_upper_limit(marks, upper_limits) + grade_range = ranges.filter(lower_limit=lower_limit, + upper_limit=upper_limit).first() + if grade_range: + return grade_range.grade + + def _get_upper_limit(self, marks, upper_limits): + greater_than = [upper_limit for upper_limit in upper_limits + if upper_limit > marks] + if greater_than: + return min(greater_than, key=lambda x: x-marks) + + def _get_lower_limit(self, marks, lower_limits): + less_than = [] + for lower_limit in lower_limits: + if lower_limit == marks: + return lower_limit + if lower_limit < marks: + less_than.append(lower_limit) + if less_than: + return max(less_than, key=lambda x: x-marks) + + def __str__(self): + return self.name.title() + + +class GradeRange(models.Model): + system = models.ForeignKey(GradingSystem) + lower_limit = models.FloatField() + upper_limit = models.FloatField() + grade = models.CharField(max_length=10) + description = models.CharField(max_length=127, null=True, blank=True) diff --git a/grades/templates/add_grades.html b/grades/templates/add_grades.html new file mode 100644 index 0000000..a3f52da --- /dev/null +++ b/grades/templates/add_grades.html @@ -0,0 +1,35 @@ +{% extends "manage.html" %} +{% block main %} +<html> +<a href="{% url 'grades:grading_systems'%}" class="btn btn-danger"> Back to Grading Systems </a> +<br><br> +<p><b>Note: For grade range lower limit is inclusive and upper limit is exclusive</b></p> +<br> +{% if not system_id %} + <form action="{% url 'grades:add_grade' %}" method="POST"> +{% else %} + <form action="{% url 'grades:edit_grade' system_id %}" method="POST"> +{% endif %} + {% csrf_token %} + <table class="table"> + {{ grade_form }} + </table> + {{ formset.management_form }} + <br> + <b><u>Grade Ranges</u></b> + <hr> + {% for form in formset %} + <div> + {{ form }} + </div> + <hr> + {% endfor %} + {% if not is_default %} + <input type="submit" id="add" name="add" value="Add" class="btn btn-info"> + <input type="submit" id="save" name="save" value="Save" class="btn btn-success"> + {% else %} + <p><b>Note: This is a default grading system. You cannot change this.</b></p> + {% endif %} +</form> +</html> +{% endblock %} diff --git a/grades/templates/grading_systems.html b/grades/templates/grading_systems.html new file mode 100644 index 0000000..3a71ebf --- /dev/null +++ b/grades/templates/grading_systems.html @@ -0,0 +1,74 @@ +{% extends "manage.html" %} +{% block main %} +<html> + <a href="{% url 'grades:add_grade' %}" class="btn btn-primary"> Add a Grading System </a> + <a href="{% url 'yaksh:courses' %}" class="btn btn-danger"> Back to Courses </a> + <br><br> + <b> Available Grading Systems: </b> + <table class="table"> + <tr> + <th>Grading System</th> + <th>Grading Ranges</th> + </tr> + <tr> + <td> + <a href="{% url 'grades:edit_grade' default_grading_system.id %}"> + {{ default_grading_system.name }}</a> (<b>Default Grading System</b>) + </td> + <td> + <table class="table"> + <tr> + <th>Lower Limit</th> + <th>Upper Limit</th> + <th>Grade</th> + <th>Description</th> + </tr> + {% for range in default_grading_system.graderange_set.all %} + <tr> + <td>{{range.lower_limit}}</td> + <td>{{range.upper_limit}}</td> + <td>{{range.grade}}</td> + {% if range.description %} + <td>{{range.description}}</td> + {% else %} + <td>------</td> + {% endif %} + </tr> + {% endfor %} + </table> + </td> + </tr> + {% if grading_systems %} + {% for system in grading_systems %} + <tr> + <td> + <a href="{% url 'grades:edit_grade' system.id %}">{{ system.name }}</a> + </td> + <td> + <table class="table"> + <tr> + <th>Lower Limit</th> + <th>Upper Limit</th> + <th>Grade</th> + <th>Description</th> + </tr> + {% for range in system.graderange_set.all %} + <tr> + <td>{{range.lower_limit}}</td> + <td>{{range.upper_limit}}</td> + <td>{{range.grade}}</td> + {% if range.description %} + <td>{{range.description}}</td> + {% else %} + <td>------</td> + {% endif %} + </tr> + {% endfor %} + </table> + </td> + </tr> + {% endfor %} + </table> + {% endif %} +</html> +{% endblock %} diff --git a/grades/tests/__init__.py b/grades/tests/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/grades/tests/__init__.py diff --git a/grades/tests/test_models.py b/grades/tests/test_models.py new file mode 100644 index 0000000..f8d5c5c --- /dev/null +++ b/grades/tests/test_models.py @@ -0,0 +1,28 @@ +from django.test import TestCase +from grades.models import GradingSystem, GradeRange + + +class GradingSystemTestCase(TestCase): + def setUp(self): + GradingSystem.objects.create(name='unusable') + + def test_get_grade(self): + # Given + grading_system = GradingSystem.objects.get(name='default') + expected_grades = {0: 'F', 31: 'F', 49: 'P', 55: 'C', 60: 'B', 80: 'A', + 95: 'A+', 100: 'A+', 100.5: 'A+', 101: None, + 109: None} + for marks in expected_grades.keys(): + # When + grade = grading_system.get_grade(marks) + # Then + self.assertEqual(expected_grades.get(marks), grade) + + def test_grade_system_unusable(self): + # Given + # System with out ranges + grading_system = GradingSystem.objects.get(name='unusable') + # When + grade = grading_system.get_grade(29) + # Then + self.assertIsNone(grade) diff --git a/grades/tests/test_views.py b/grades/tests/test_views.py new file mode 100644 index 0000000..c944f03 --- /dev/null +++ b/grades/tests/test_views.py @@ -0,0 +1,105 @@ +from django.test import TestCase, Client +from django.contrib.auth.models import User +from django.core.urlresolvers import reverse +from grades.models import GradingSystem + + +def setUpModule(): + user = User.objects.create_user(username='grades_user', + password='grades_user') + + +def tearDownModule(): + User.objects.all().delete() + + +class GradeViewTest(TestCase): + def setUp(self): + self.client = Client() + + def tearDown(self): + self.client.logout() + + def test_grade_view(self): + # Given + # URL redirection due to no login credentials + status_code = 302 + # When + response = self.client.get(reverse('grades:grading_systems')) + # Then + self.assertEqual(response.status_code, status_code) + + # Given + # successful login and grading systems views + self.client.login(username='grades_user', password='grades_user') + status_code = 200 + # When + response = self.client.get(reverse('grades:grading_systems')) + # Then + self.assertEqual(response.status_code, status_code) + self.assertTemplateUsed(response, 'grading_systems.html') + + +class AddGradingSystemTest(TestCase): + def setUp(self): + self.client = Client() + + def tearDown(self): + self.client.logout() + + def test_add_grades_view(self): + # Given + status_code = 302 + # When + response = self.client.get(reverse('grades:add_grade')) + # Then + self.assertEqual(response.status_code, status_code) + + # Given + status_code = 200 + self.client.login(username='grades_user', password='grades_user') + # When + response = self.client.get(reverse('grades:add_grade')) + # Then + self.assertEqual(response.status_code, status_code) + self.assertTemplateUsed(response, 'add_grades.html') + + def test_add_grades_post(self): + # Given + self.client.login(username='grades_user', password='grades_user') + data = {'name': ['new_sys'], 'description': ['About grading system!'], + 'graderange_set-MIN_NUM_FORMS': ['0'], + 'graderange_set-TOTAL_FORMS': ['0'], + 'graderange_set-MAX_NUM_FORMS': ['1000'], 'add': ['Add'], + 'graderange_set-INITIAL_FORMS': ['0']} + # When + response = self.client.post(reverse('grades:add_grade'), data) + # Then + grading_systems = GradingSystem.objects.filter(name='new_sys') + self.assertEqual(len(grading_systems), 1) + + # Given + grading_system = grading_systems.first() + # When + ranges = grading_system.graderange_set.all() + # Then + self.assertEqual(len(ranges), 0) + + # Given + data = {'graderange_set-0-upper_limit': ['40'], + 'graderange_set-0-description': ['Fail'], + 'graderange_set-0-lower_limit': ['0'], + 'graderange_set-0-system': [''], 'name': ['new_sys'], + 'graderange_set-MIN_NUM_FORMS': ['0'], + 'graderange_set-TOTAL_FORMS': ['1'], + 'graderange_set-MAX_NUM_FORMS': ['1000'], + 'graderange_set-0-id': [''], + 'description': ['About the grading system!'], + 'graderange_set-0-grade': ['F'], + 'graderange_set-INITIAL_FORMS': ['0'], 'save': ['Save']} + # When + response = self.client.post(reverse('grades:edit_grade', + kwargs={'system_id': 2}), data) + # Then + ranges = grading_system.graderange_set.all() + self.assertEqual(len(ranges), 1) diff --git a/grades/urls.py b/grades/urls.py new file mode 100644 index 0000000..49276ba9 --- /dev/null +++ b/grades/urls.py @@ -0,0 +1,10 @@ +from django.conf.urls import url, patterns +from grades import views + +urlpatterns = [ + url(r'^$', views.grading_systems, name="grading_systems_home"), + url(r'^grading_systems/$', views.grading_systems, name="grading_systems"), + url(r'^add_grade/$', views.add_grading_system, name="add_grade"), + url(r'^add_grade/(?P<system_id>\d+)/$', views.add_grading_system, + name="edit_grade"), +] diff --git a/grades/views.py b/grades/views.py new file mode 100644 index 0000000..10f9999 --- /dev/null +++ b/grades/views.py @@ -0,0 +1,45 @@ +from django.shortcuts import render +from django.contrib.auth.decorators import login_required +from django.forms import inlineformset_factory +from grades.forms import GradingSystemForm +from grades.models import GradingSystem, GradeRange + + +@login_required +def grading_systems(request): + user = request.user + default_grading_system = GradingSystem.objects.get(name='default') + grading_systems = GradingSystem.objects.filter(creator=user) + return render(request, 'grading_systems.html', {'default_grading_system': + default_grading_system, 'grading_systems': grading_systems}) + + +@login_required +def add_grading_system(request, system_id=None): + user = request.user + grading_system = None + if system_id is not None: + grading_system = GradingSystem.objects.get(id=system_id) + GradeRangeFormSet = inlineformset_factory(GradingSystem, GradeRange, + fields='__all__', extra=0) + grade_form = GradingSystemForm(instance=grading_system) + is_default = grading_system is not None and grading_system.name == 'default' + + if request.method == 'POST': + formset = GradeRangeFormSet(request.POST, instance=grading_system) + grade_form = GradingSystemForm(request.POST, instance=grading_system) + if grade_form.is_valid(): + system = grade_form.save(commit=False) + system.creator = user + system.save() + system_id = system.id + if formset.is_valid(): + formset.save() + if 'add' in request.POST: + GradeRangeFormSet = inlineformset_factory(GradingSystem, GradeRange, + fields='__all__', extra=1) + formset = GradeRangeFormSet(instance=grading_system) + + return render(request, 'add_grades.html', {'formset': formset, + 'grade_form': grade_form, "system_id": system_id, + 'is_default': is_default}) diff --git a/online_test/__init__.py b/online_test/__init__.py index a71c5c7..32a90a3 100644 --- a/online_test/__init__.py +++ b/online_test/__init__.py @@ -1 +1 @@ -__version__ = '0.7.0' +__version__ = '0.8.0' diff --git a/online_test/settings.py b/online_test/settings.py index c55a056..797d982 100644 --- a/online_test/settings.py +++ b/online_test/settings.py @@ -44,6 +44,7 @@ INSTALLED_APPS = ( 'yaksh', 'taggit', 'social.apps.django_app.default', + 'grades', ) MIDDLEWARE_CLASSES = ( diff --git a/online_test/urls.py b/online_test/urls.py index ce0de41..3e62fd6 100644 --- a/online_test/urls.py +++ b/online_test/urls.py @@ -13,5 +13,6 @@ urlpatterns = [ url(r'^exam/', include('yaksh.urls', namespace='yaksh', app_name='yaksh')), url(r'^exam/reset/', include('yaksh.urls_password_reset')), url(r'^', include('social.apps.django_app.urls', namespace='social')), + url(r'^grades/', include('grades.urls', namespace='grades', app_name='grades')), ] urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) @@ -6,6 +6,7 @@ README = open(os.path.join(os.path.dirname(__file__), 'README.rst')).read() # allow setup.py to be run from any path os.chdir(os.path.normpath(os.path.join(os.path.abspath(__file__), os.pardir))) + def get_version(): import os data = {} @@ -24,6 +25,7 @@ install_requires = [ 'invoke==0.21.0', 'six', 'requests', + 'markdown==2.6.9', ] setup( diff --git a/yaksh/evaluator_tests/test_simple_question_types.py b/yaksh/evaluator_tests/test_simple_question_types.py index cbf2abd..dfb82a2 100644 --- a/yaksh/evaluator_tests/test_simple_question_types.py +++ b/yaksh/evaluator_tests/test_simple_question_types.py @@ -1,10 +1,11 @@ import unittest from datetime import datetime, timedelta from django.utils import timezone +from textwrap import dedent import pytz from yaksh.models import User, Profile, Question, Quiz, QuestionPaper,\ QuestionSet, AnswerPaper, Answer, Course, IntegerTestCase, FloatTestCase,\ - StringTestCase, McqTestCase + StringTestCase, McqTestCase, ArrangeTestCase def setUpModule(): @@ -39,15 +40,17 @@ def setUpModule(): duration=30, active=True, attempts_allowed=1, time_between_attempts=0, pass_criteria=0, description='demo quiz 100', - instructions="Demo Instructions" + instructions="Demo Instructions", + creator=user ) question_paper = QuestionPaper.objects.create(quiz=quiz, total_marks=1.0) def tearDownModule(): - User.objects.get(username="demo_user_100").delete() - User.objects.get(username="demo_user_101").delete() + User.objects.filter(username__in=["demo_user_100", "demo_user_101"])\ + .delete() + class IntegerQuestionTestCases(unittest.TestCase): @classmethod @@ -116,11 +119,9 @@ class IntegerQuestionTestCases(unittest.TestCase): # Regrade # Given - self.answer.correct = True - self.answer.marks = 1 - - self.answer.answer = 200 - self.answer.save() + regrade_answer = Answer.objects.get(id=self.answer.id) + regrade_answer.answer = 200 + regrade_answer.save() # When details = self.answerpaper.regrade(self.question1.id) @@ -128,6 +129,7 @@ class IntegerQuestionTestCases(unittest.TestCase): # Then self.answer = self.answerpaper.answers.filter(question=self.question1 ).last() + self.assertEqual(self.answer, regrade_answer) self.assertTrue(details[0]) self.assertEqual(self.answer.marks, 0) self.assertFalse(self.answer.correct) @@ -153,11 +155,9 @@ class IntegerQuestionTestCases(unittest.TestCase): # Regrade # Given - self.answer.correct = True - self.answer.marks = 1 - - self.answer.answer = 25 - self.answer.save() + regrade_answer = Answer.objects.get(id=self.answer.id) + regrade_answer.answer = 25 + regrade_answer.save() # When details = self.answerpaper.regrade(self.question1.id) @@ -165,6 +165,7 @@ class IntegerQuestionTestCases(unittest.TestCase): # Then self.answer = self.answerpaper.answers.filter(question=self.question1 ).last() + self.assertEqual(self.answer, regrade_answer) self.assertTrue(details[0]) self.assertEqual(self.answer.marks, 1) self.assertTrue(self.answer.correct) @@ -250,11 +251,9 @@ class StringQuestionTestCases(unittest.TestCase): # Regrade # Given - answer.correct = True - answer.marks = 1 - - answer.answer = "hello, mars!" - answer.save() + regrade_answer = Answer.objects.get(id=answer.id) + regrade_answer.answer = "hello, mars!" + regrade_answer.save() # When details = self.answerpaper.regrade(self.question1.id) @@ -262,6 +261,7 @@ class StringQuestionTestCases(unittest.TestCase): # Then answer = self.answerpaper.answers.filter(question=self.question1)\ .last() + self.assertEqual(answer, regrade_answer) self.assertTrue(details[0]) self.assertEqual(answer.marks, 0) self.assertFalse(answer.correct) @@ -284,11 +284,9 @@ class StringQuestionTestCases(unittest.TestCase): # Regrade # Given - answer.correct = True - answer.marks = 1 - - answer.answer = "hello, earth!" - answer.save() + regrade_answer = Answer.objects.get(id=answer.id) + regrade_answer.answer = "hello, earth!" + regrade_answer.save() # When details = self.answerpaper.regrade(self.question1.id) @@ -296,6 +294,7 @@ class StringQuestionTestCases(unittest.TestCase): # Then answer = self.answerpaper.answers.filter(question=self.question1)\ .last() + self.assertEqual(answer, regrade_answer) self.assertTrue(details[0]) self.assertEqual(answer.marks, 1) self.assertTrue(answer.correct) @@ -317,11 +316,9 @@ class StringQuestionTestCases(unittest.TestCase): # Regrade # Given - answer.correct = True - answer.marks = 1 - - answer.answer = "hello, earth!" - answer.save() + regrade_answer = Answer.objects.get(id=answer.id) + regrade_answer.answer = "hello, earth!" + regrade_answer.save() # When details = self.answerpaper.regrade(self.question2.id) @@ -329,6 +326,7 @@ class StringQuestionTestCases(unittest.TestCase): # Then answer = self.answerpaper.answers.filter(question=self.question2)\ .last() + self.assertEqual(answer, regrade_answer) self.assertTrue(details[0]) self.assertEqual(answer.marks, 0) self.assertFalse(answer.correct) @@ -351,11 +349,9 @@ class StringQuestionTestCases(unittest.TestCase): # Regrade # Given - answer.correct = True - answer.marks = 1 - - answer.answer = "Hello, EARTH!" - answer.save() + regrade_answer = Answer.objects.get(id=answer.id) + regrade_answer.answer = "Hello, EARTH!" + regrade_answer.save() # When details = self.answerpaper.regrade(self.question2.id) @@ -363,6 +359,7 @@ class StringQuestionTestCases(unittest.TestCase): # Then answer = self.answerpaper.answers.filter(question=self.question2)\ .last() + self.assertEqual(answer, regrade_answer) self.assertTrue(details[0]) self.assertEqual(answer.marks, 1) self.assertTrue(answer.correct) @@ -432,13 +429,11 @@ class FloatQuestionTestCases(unittest.TestCase): # Then self.assertTrue(result['success']) - # Regrade + # Regrade with wrong answer # Given - self.answer.correct = True - self.answer.marks = 1 - - self.answer.answer = 0.0 - self.answer.save() + regrade_answer = Answer.objects.get(id=self.answer.id) + regrade_answer.answer = 0.0 + regrade_answer.save() # When details = self.answerpaper.regrade(self.question1.id) @@ -446,6 +441,7 @@ class FloatQuestionTestCases(unittest.TestCase): # Then self.answer = self.answerpaper.answers.filter(question=self.question1 ).last() + self.assertEqual(self.answer, regrade_answer) self.assertTrue(details[0]) self.assertEqual(self.answer.marks, 0) self.assertFalse(self.answer.correct) @@ -470,11 +466,270 @@ class FloatQuestionTestCases(unittest.TestCase): # Regrade # Given - self.answer.correct = True - self.answer.marks = 1 + regrade_answer = Answer.objects.get(id=self.answer.id) + regrade_answer.answer = 99.9 + regrade_answer.save() + + # When + details = self.answerpaper.regrade(self.question1.id) + + # Then + self.answer = self.answerpaper.answers.filter(question=self.question1 + ).last() + self.assertEqual(self.answer, regrade_answer) + self.assertTrue(details[0]) + self.assertEqual(self.answer.marks, 1) + self.assertTrue(self.answer.correct) +class MCQQuestionTestCases(unittest.TestCase): + @classmethod + def setUpClass(self): + #Creating User + self.user = User.objects.get(username='demo_user_100') + self.user2 = User.objects.get(username='demo_user_101') + self.user_ip = '127.0.0.1' + + #Creating Course + self.course = Course.objects.get(name="Python Course 100") + # Creating Quiz + self.quiz = Quiz.objects.get(description="demo quiz 100") + # Creating Question paper + self.question_paper = QuestionPaper.objects.get(quiz=self.quiz) + self.question_paper.shuffle_testcases = True + self.question_paper.save() + #Creating Question + self.question1 = Question.objects.create(summary='mcq1', points=1, + type='code', user=self.user, + ) + self.question1.language = 'python' + self.question1.type = "mcq" + self.question1.test_case_type = 'Mcqtestcase' + self.question1.description = 'Which option is Correct?' + self.question1.save() + + # For questions + self.mcq_based_testcase_1 = McqTestCase(question=self.question1, + options="Correct", + correct=True, + type='mcqtestcase', + ) + self.mcq_based_testcase_1.save() + + self.mcq_based_testcase_2 = McqTestCase(question=self.question1, + options="Incorrect", + correct=False, + type='mcqtestcase', + ) + self.mcq_based_testcase_2.save() + + self.mcq_based_testcase_3 = McqTestCase(question=self.question1, + options="Incorrect", + correct=False, + type='mcqtestcase', + ) + self.mcq_based_testcase_3.save() + + self.mcq_based_testcase_4 = McqTestCase(question=self.question1, + options="Incorrect", + correct=False, + type='mcqtestcase', + ) + self.mcq_based_testcase_4.save() + + self.question_paper.fixed_questions.add(self.question1) + + self.answerpaper = self.question_paper.make_answerpaper( + user=self.user, ip=self.user_ip, + attempt_num=1, + course_id=self.course.id + ) + + # Answerpaper for user 2 + self.answerpaper2 = self.question_paper.make_answerpaper( + user=self.user2, ip=self.user_ip, + attempt_num=1, + course_id=self.course.id + ) + + @classmethod + def tearDownClass(self): + self.question1.delete() + self.answerpaper.delete() + self.answerpaper2.delete() - self.answer.answer = 99.9 + def test_shuffle_test_cases(self): + # Given + # When + + user_testcase = self.question1.get_ordered_test_cases( + self.answerpaper + ) + order1 = [tc.id for tc in user_testcase] + user2_testcase = self.question1.get_ordered_test_cases( + self.answerpaper2 + ) + order2 = [tc.id for tc in user2_testcase] + self.question_paper.shuffle_testcases = False + self.question_paper.save() + answerpaper3 = self.question_paper.make_answerpaper( + user=self.user2, ip=self.user_ip, + attempt_num=self.answerpaper.attempt_number+1, + course_id=self.course.id + ) + not_ordered_testcase = self.question1.get_ordered_test_cases( + answerpaper3 ) + get_test_cases = self.question1.get_test_cases() + # Then + self.assertNotEqual(order1, order2) + self.assertEqual(get_test_cases, not_ordered_testcase) + + +class ArrangeQuestionTestCases(unittest.TestCase): + @classmethod + def setUpClass(self): + # Creating Course + self.course = Course.objects.get(name="Python Course 100") + # Creating Quiz + self.quiz = Quiz.objects.get(description="demo quiz 100") + # Creating Question paper + self.question_paper = QuestionPaper.objects.get(quiz=self.quiz, + total_marks=1.0) + + #Creating User + self.user = User.objects.get(username='demo_user_100') + #Creating Question + self.question1 = Question.objects.create(summary='arrange1', + points=1.0, + user=self.user + ) + self.question1.language = 'python' + self.question1.type = "arrange" + self.question1.description = "Arrange alphabets in ascending order" + self.question1.test_case_type = 'arrangetestcase' + self.question1.save() + + #Creating answerpaper + + self.answerpaper = AnswerPaper.objects.create(user=self.user, + user_ip='101.0.0.1', + start_time=timezone.now(), + question_paper=self.question_paper, + end_time=timezone.now() + +timedelta(minutes=5), + attempt_number=1, + course=self.course + ) + self.answerpaper.questions.add(self.question1) + self.answerpaper.save() + # For question + self.arrange_testcase_1 = ArrangeTestCase(question=self.question1, + options="A", + type = 'arrangetestcase', + ) + self.arrange_testcase_1.save() + self.testcase_1_id = self.arrange_testcase_1.id + self.arrange_testcase_2 = ArrangeTestCase(question=self.question1, + options="B", + type = 'arrangetestcase', + ) + self.arrange_testcase_2.save() + self.testcase_2_id = self.arrange_testcase_2.id + self.arrange_testcase_3 = ArrangeTestCase(question=self.question1, + options="C", + type = 'arrangetestcase', + ) + self.arrange_testcase_3.save() + self.testcase_3_id = self.arrange_testcase_3.id + @classmethod + def tearDownClass(self): + self.question1.delete() + self.answerpaper.delete() + + def test_validate_regrade_arrange_correct_answer(self): + # Given + arrange_answer = [self.testcase_1_id, + self.testcase_2_id, + self.testcase_3_id, + ] + self.answer = Answer(question=self.question1, + answer=arrange_answer, + ) + self.answer.save() + self.answerpaper.answers.add(self.answer) + + # When + json_data = None + result = self.answerpaper.validate_answer(arrange_answer, + self.question1, + json_data, + ) + # Then + self.assertTrue(result['success']) + + # Regrade with wrong answer + # Given + regrade_answer = Answer.objects.get(id=self.answer.id) + + # Try regrade with wrong data structure + # When + regrade_answer.answer = 1 + regrade_answer.save() + details = self.answerpaper.regrade(self.question1.id) + err_msg = dedent("""\ + User: {0}; Quiz: {1}; Question: {2}. + {3} answer not a list.""".format( + self.user.username, + self.quiz.description, + self.question1.summary, + self.question1.type + ) ) + self.assertFalse(details[0]) + self.assertEqual(details[1], err_msg) + + + # Try regrade with incorrect answer + # When + regrade_answer.answer = [self.testcase_1_id, + self.testcase_3_id, + self.testcase_2_id, + ] + regrade_answer.save() + # Then + details = self.answerpaper.regrade(self.question1.id) + self.answer = self.answerpaper.answers.filter(question=self.question1 + ).last() + self.assertEqual(self.answer, regrade_answer) + self.assertTrue(details[0]) + self.assertEqual(self.answer.marks, 0) + self.assertFalse(self.answer.correct) + + def test_validate_regrade_arrange_incorrect_answer(self): + # Given + arrange_answer = [self.testcase_1_id, + self.testcase_3_id, + self.testcase_2_id, + ] + self.answer = Answer(question=self.question1, + answer=arrange_answer, + ) self.answer.save() + self.answerpaper.answers.add(self.answer) + + # When + json_data = None + result = self.answerpaper.validate_answer(arrange_answer, + self.question1, json_data + ) + + # Then + self.assertFalse(result['success']) + # Regrade with wrong answer + # Given + regrade_answer = Answer.objects.get(id=self.answer.id) + regrade_answer.answer = [self.testcase_1_id, + self.testcase_2_id, + self.testcase_3_id, + ] + regrade_answer.save() # When details = self.answerpaper.regrade(self.question1.id) @@ -482,6 +737,7 @@ class FloatQuestionTestCases(unittest.TestCase): # Then self.answer = self.answerpaper.answers.filter(question=self.question1 ).last() + self.assertEqual(self.answer, regrade_answer) self.assertTrue(details[0]) self.assertEqual(self.answer.marks, 1) self.assertTrue(self.answer.correct) diff --git a/yaksh/forms.py b/yaksh/forms.py index 97b3108..41c9176 100644 --- a/yaksh/forms.py +++ b/yaksh/forms.py @@ -35,6 +35,7 @@ question_types = ( ("integer", "Answer in Integer"), ("string", "Answer in String"), ("float", "Answer in Float"), + ("arrange", "Arrange in Correct Order"), ) test_case_types = ( @@ -281,7 +282,7 @@ class CourseForm(forms.ModelForm): class Meta: model = Course fields = ['name', 'enrollment', 'active', 'code', 'instructions', - 'start_enroll_time', 'end_enroll_time'] + 'start_enroll_time', 'end_enroll_time', 'grading_system'] class ProfileForm(forms.ModelForm): diff --git a/yaksh/migrations/0009_auto_20180113_1124.py b/yaksh/migrations/0009_auto_20180113_1124.py new file mode 100644 index 0000000..dbbcb30 --- /dev/null +++ b/yaksh/migrations/0009_auto_20180113_1124.py @@ -0,0 +1,149 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.9.5 on 2018-01-13 11:24 +from __future__ import unicode_literals + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import yaksh.models + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('yaksh', '0008_release_0_7_0'), + ] + + operations = [ + migrations.CreateModel( + name='CourseStatus', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('grade', models.CharField(blank=True, max_length=255, null=True)), + ('total_marks', models.FloatField(default=0.0)), + ], + ), + migrations.CreateModel( + name='LearningModule', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=255)), + ('description', models.TextField(blank=True, default=None, null=True)), + ('order', models.IntegerField(default=0)), + ('check_prerequisite', models.BooleanField(default=True)), + ('html_data', models.TextField(blank=True, null=True)), + ('is_trial', models.BooleanField(default=False)), + ('creator', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='module_creator', to=settings.AUTH_USER_MODEL)), + ], + ), + migrations.CreateModel( + name='LearningUnit', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('order', models.IntegerField()), + ('type', models.CharField(max_length=16)), + ('check_prerequisite', models.BooleanField(default=True)), + ], + ), + migrations.CreateModel( + name='Lesson', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=255)), + ('description', models.TextField()), + ('html_data', models.TextField(blank=True, null=True)), + ('creator', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + ), + migrations.CreateModel( + name='LessonFile', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('file', models.FileField(upload_to=yaksh.models.get_file_dir)), + ('lesson', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='lesson', to='yaksh.Lesson')), + ], + ), + migrations.RemoveField( + model_name='quiz', + name='course', + ), + migrations.RemoveField( + model_name='quiz', + name='language', + ), + migrations.RemoveField( + model_name='quiz', + name='prerequisite', + ), + migrations.AddField( + model_name='answerpaper', + name='course', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='yaksh.Course'), + ), + migrations.AddField( + model_name='question', + name='min_time', + field=models.IntegerField(default=0, verbose_name='time in minutes'), + ), + migrations.AddField( + model_name='question', + name='solution', + field=models.TextField(blank=True), + ), + migrations.AddField( + model_name='quiz', + name='creator', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='quiz', + name='is_exercise', + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name='quiz', + name='weightage', + field=models.FloatField(default=1.0), + ), + migrations.AddField( + model_name='learningunit', + name='lesson', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='yaksh.Lesson'), + ), + migrations.AddField( + model_name='learningunit', + name='quiz', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='yaksh.Quiz'), + ), + migrations.AddField( + model_name='learningmodule', + name='learning_unit', + field=models.ManyToManyField(related_name='learning_unit', to='yaksh.LearningUnit'), + ), + migrations.AddField( + model_name='coursestatus', + name='completed_units', + field=models.ManyToManyField(related_name='completed_units', to='yaksh.LearningUnit'), + ), + migrations.AddField( + model_name='coursestatus', + name='course', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='yaksh.Course'), + ), + migrations.AddField( + model_name='coursestatus', + name='current_unit', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='current_unit', to='yaksh.LearningUnit'), + ), + migrations.AddField( + model_name='coursestatus', + name='user', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='course', + name='learning_module', + field=models.ManyToManyField(related_name='learning_module', to='yaksh.LearningModule'), + ), + ] diff --git a/yaksh/migrations/0010_auto_20180226_1324.py b/yaksh/migrations/0010_auto_20180226_1324.py new file mode 100644 index 0000000..b400da7 --- /dev/null +++ b/yaksh/migrations/0010_auto_20180226_1324.py @@ -0,0 +1,34 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.9.5 on 2018-02-26 13:24 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('yaksh', '0009_auto_20180113_1124'), + ] + + operations = [ + migrations.AddField( + model_name='learningmodule', + name='active', + field=models.BooleanField(default=True), + ), + migrations.AddField( + model_name='lesson', + name='active', + field=models.BooleanField(default=True), + ), + migrations.AlterField( + model_name='quiz', + name='time_between_attempts', + field=models.FloatField(verbose_name='Time Between Quiz Attempts in hours'), + ), + migrations.AlterUniqueTogether( + name='answerpaper', + unique_together=set([('user', 'question_paper', 'attempt_number', 'course')]), + ), + ] diff --git a/yaksh/migrations/0011_release_0_8_0.py b/yaksh/migrations/0011_release_0_8_0.py new file mode 100644 index 0000000..41a2abd --- /dev/null +++ b/yaksh/migrations/0011_release_0_8_0.py @@ -0,0 +1,68 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.9.5 on 2018-03-23 10:46 +from __future__ import unicode_literals + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('grades', 'default_grading_system'), + ('yaksh', '0010_auto_20180226_1324'), + ] + + operations = [ + migrations.CreateModel( + name='ArrangeTestCase', + fields=[ + ('testcase_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='yaksh.TestCase')), + ('options', models.TextField(default=None)), + ], + bases=('yaksh.testcase',), + ), + migrations.CreateModel( + name='TestCaseOrder', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('order', models.TextField()), + ('answer_paper', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='answer_paper', to='yaksh.AnswerPaper')), + ], + ), + migrations.RenameField( + model_name='coursestatus', + old_name='total_marks', + new_name='percentage', + ), + migrations.AddField( + model_name='course', + name='grading_system', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='grades.GradingSystem'), + ), + migrations.AddField( + model_name='questionpaper', + name='shuffle_testcases', + field=models.BooleanField(default=True, verbose_name='Shuffle testcase for each user'), + ), + migrations.AlterField( + model_name='question', + name='type', + field=models.CharField(choices=[('mcq', 'Single Correct Choice'), ('mcc', 'Multiple Correct Choices'), ('code', 'Code'), ('upload', 'Assignment Upload'), ('integer', 'Answer in Integer'), ('string', 'Answer in String'), ('float', 'Answer in Float'), ('arrange', 'Arrange in Correct Order')], max_length=24), + ), + migrations.AlterField( + model_name='quiz', + name='weightage', + field=models.FloatField(default=100, help_text='Will be considered as percentage'), + ), + migrations.AlterField( + model_name='testcase', + name='type', + field=models.CharField(choices=[('standardtestcase', 'Standard Testcase'), ('stdiobasedtestcase', 'StdIO Based Testcase'), ('mcqtestcase', 'MCQ Testcase'), ('hooktestcase', 'Hook Testcase'), ('integertestcase', 'Integer Testcase'), ('stringtestcase', 'String Testcase'), ('floattestcase', 'Float Testcase'), ('arrangetestcase', 'Arrange Testcase')], max_length=24, null=True), + ), + migrations.AddField( + model_name='testcaseorder', + name='question', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='yaksh.Question'), + ), + ] diff --git a/yaksh/models.py b/yaksh/models.py index d9eea57..d011bb0 100644 --- a/yaksh/models.py +++ b/yaksh/models.py @@ -35,7 +35,7 @@ from yaksh.code_server import ( from yaksh.settings import SERVER_POOL_PORT, SERVER_HOST_NAME from django.conf import settings from django.forms.models import model_to_dict - +from grades.models import GradingSystem languages = ( ("python", "Python"), @@ -54,6 +54,8 @@ question_types = ( ("integer", "Answer in Integer"), ("string", "Answer in String"), ("float", "Answer in Float"), + ("arrange", "Arrange in Correct Order"), + ) enrollment_methods = ( @@ -69,6 +71,7 @@ test_case_types = ( ("integertestcase", "Integer Testcase"), ("stringtestcase", "String Testcase"), ("floattestcase", "Float Testcase"), + ("arrangetestcase", "Arrange Testcase"), ) string_check_type = ( @@ -328,7 +331,8 @@ class Quiz(models.Model): allow_skip = models.BooleanField("Allow students to skip questions", default=True) - weightage = models.FloatField(default=1.0) + weightage = models.FloatField(help_text='Will be considered as percentage', + default=100) is_exercise = models.BooleanField(default=False) @@ -602,6 +606,8 @@ class Course(models.Model): null=True ) + grading_system = models.ForeignKey(GradingSystem, null=True, blank=True) + objects = CourseManager() def _create_duplicate_instance(self, creator, course_name=None): @@ -772,6 +778,14 @@ class Course(models.Model): percent = round((count / len(modules))) return percent + def get_grade(self, user): + course_status = CourseStatus.objects.filter(course=self, user=user) + if course_status.exists(): + grade = course_status.first().get_grade() + else: + grade = "NA" + return grade + def days_before_start(self): """ Get the days remaining for the start of the course """ if timezone.now() < self.start_enroll_time: @@ -793,7 +807,44 @@ class CourseStatus(models.Model): course = models.ForeignKey(Course) user = models.ForeignKey(User) grade = models.CharField(max_length=255, null=True, blank=True) - total_marks = models.FloatField(default=0.0) + percentage = models.FloatField(default=0.0) + + def get_grade(self): + return self.grade + + def set_grade(self): + if self.is_course_complete(): + self.calculate_percentage() + if self.course.grading_system is None: + grading_system = GradingSystem.objects.get(name='default') + else: + grading_system = self.course.grading_system + grade = grading_system.get_grade(self.percentage) + self.grade = grade + self.save() + + def calculate_percentage(self): + if self.is_course_complete(): + quizzes = self.course.get_quizzes() + total_weightage = 0 + sum = 0 + for quiz in quizzes: + total_weightage += quiz.weightage + marks = AnswerPaper.objects.get_user_best_of_attempts_marks( + quiz, self.user.id, self.course.id) + out_of = quiz.questionpaper_set.first().total_marks + sum += (marks/out_of)*quiz.weightage + self.percentage = (sum/total_weightage)*100 + self.save() + + def is_course_complete(self): + modules = self.course.get_learning_modules() + complete = False + for module in modules: + complete = module.get_status(self.user, self.course) == 'completed' + if not complete: + break + return complete ############################################################################### @@ -1276,8 +1327,8 @@ class QuestionPaper(models.Model): question_ids = [] for question in questions: question_ids.append(str(question.id)) - if self.shuffle_testcases and \ - question.type in ["mcq", "mcc"]: + if (question.type == "arrange") or (self.shuffle_testcases + and question.type in ["mcq", "mcc"]): testcases = question.get_test_cases() random.shuffle(testcases) testcases_ids = ",".join([str(tc.id) for tc in testcases] @@ -1849,6 +1900,15 @@ class AnswerPaper(models.Model): result['success'] = True result['error'] = ['Correct answer'] + elif question.type == 'arrange': + testcase_ids = sorted( + [tc.id for tc in question.get_test_cases()] + ) + if user_answer == testcase_ids: + result['success'] = True + result['error'] = ['Correct answer'] + + elif question.type == 'code' or question.type == "upload": user_dir = self.user.profile.get_user_dir() url = '{0}:{1}'.format(SERVER_HOST_NAME, server_port) @@ -1870,13 +1930,21 @@ class AnswerPaper(models.Model): user_answer = self.answers.filter(question=question).last() if not user_answer: return False, msg + 'Did not answer.' - if question.type == 'mcc': + if question.type in ['mcc', 'arrange']: try: - answer = eval(user_answer.answer) + answer = literal_eval(user_answer.answer) if type(answer) is not list: - return False, msg + 'MCC answer not a list.' + return (False, + msg + '{0} answer not a list.'.format( + question.type + ) + ) except Exception: - return False, msg + 'MCC answer submission error' + return (False, + msg + '{0} answer submission error'.format( + question.type + ) + ) else: answer = user_answer.answer json_data = question.consolidate_answer_data(answer) \ @@ -2069,6 +2137,18 @@ class FloatTestCase(TestCase): ) +class ArrangeTestCase(TestCase): + + options = models.TextField(default=None) + + def get_field_value(self): + return {"test_case_type": "arrangetestcase", + "options": self.options} + + def __str__(self): + return u'Arrange Testcase | Option: {0}'.format(self.options) + + ############################################################################## class TestCaseOrder(models.Model): """Testcase order contains a set of ordered test cases for a given question @@ -2083,3 +2163,6 @@ class TestCaseOrder(models.Model): #Order of the test case for a question. order = models.TextField() + + +############################################################################## diff --git a/yaksh/static/yaksh/js/jquery-sortable.js b/yaksh/static/yaksh/js/jquery-sortable.js new file mode 100644 index 0000000..376880c --- /dev/null +++ b/yaksh/static/yaksh/js/jquery-sortable.js @@ -0,0 +1,693 @@ +/* =================================================== + * jquery-sortable.js v0.9.13 + * http://johnny.github.com/jquery-sortable/ + * =================================================== + * Copyright (c) 2012 Jonas von Andrian + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * * Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * * Redistributions in binary form must reproduce the above copyright + * notice, this list of conditions and the following disclaimer in the + * documentation and/or other materials provided with the distribution. + * * The name of the author may not be used to endorse or promote products + * derived from this software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL <COPYRIGHT HOLDER> BE LIABLE FOR ANY + * DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND + * ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + * ========================================================== */ + + +!function ( $, window, pluginName, undefined){ + var containerDefaults = { + // If true, items can be dragged from this container + drag: true, + // If true, items can be droped onto this container + drop: true, + // Exclude items from being draggable, if the + // selector matches the item + exclude: "", + // If true, search for nested containers within an item.If you nest containers, + // either the original selector with which you call the plugin must only match the top containers, + // or you need to specify a group (see the bootstrap nav example) + nested: true, + // If true, the items are assumed to be arranged vertically + vertical: true + }, // end container defaults + groupDefaults = { + // This is executed after the placeholder has been moved. + // $closestItemOrContainer contains the closest item, the placeholder + // has been put at or the closest empty Container, the placeholder has + // been appended to. + afterMove: function ($placeholder, container, $closestItemOrContainer) { + }, + // The exact css path between the container and its items, e.g. "> tbody" + containerPath: "", + // The css selector of the containers + containerSelector: "ol, ul", + // Distance the mouse has to travel to start dragging + distance: 0, + // Time in milliseconds after mousedown until dragging should start. + // This option can be used to prevent unwanted drags when clicking on an element. + delay: 0, + // The css selector of the drag handle + handle: "", + // The exact css path between the item and its subcontainers. + // It should only match the immediate items of a container. + // No item of a subcontainer should be matched. E.g. for ol>div>li the itemPath is "> div" + itemPath: "", + // The css selector of the items + itemSelector: "li", + // The class given to "body" while an item is being dragged + bodyClass: "dragging", + // The class giving to an item while being dragged + draggedClass: "dragged", + // Check if the dragged item may be inside the container. + // Use with care, since the search for a valid container entails a depth first search + // and may be quite expensive. + isValidTarget: function ($item, container) { + return true + }, + // Executed before onDrop if placeholder is detached. + // This happens if pullPlaceholder is set to false and the drop occurs outside a container. + onCancel: function ($item, container, _super, event) { + }, + // Executed at the beginning of a mouse move event. + // The Placeholder has not been moved yet. + onDrag: function ($item, position, _super, event) { + $item.css(position) + }, + // Called after the drag has been started, + // that is the mouse button is being held down and + // the mouse is moving. + // The container is the closest initialized container. + // Therefore it might not be the container, that actually contains the item. + onDragStart: function ($item, container, _super, event) { + $item.css({ + height: $item.outerHeight(), + width: $item.outerWidth() + }) + $item.addClass(container.group.options.draggedClass) + $("body").addClass(container.group.options.bodyClass) + }, + // Called when the mouse button is being released + onDrop: function ($item, container, _super, event) { + $item.removeClass(container.group.options.draggedClass).removeAttr("style") + $("body").removeClass(container.group.options.bodyClass) + }, + // Called on mousedown. If falsy value is returned, the dragging will not start. + // Ignore if element clicked is input, select or textarea + onMousedown: function ($item, _super, event) { + if (!event.target.nodeName.match(/^(input|select|textarea)$/i)) { + event.preventDefault() + return true + } + }, + // The class of the placeholder (must match placeholder option markup) + placeholderClass: "placeholder", + // Template for the placeholder. Can be any valid jQuery input + // e.g. a string, a DOM element. + // The placeholder must have the class "placeholder" + placeholder: '<li class="placeholder"></li>', + // If true, the position of the placeholder is calculated on every mousemove. + // If false, it is only calculated when the mouse is above a container. + pullPlaceholder: true, + // Specifies serialization of the container group. + // The pair $parent/$children is either container/items or item/subcontainers. + serialize: function ($parent, $children, parentIsContainer) { + var result = $.extend({}, $parent.data()) + + if(parentIsContainer) + return [$children] + else if ($children[0]){ + result.children = $children + } + + delete result.subContainers + delete result.sortable + + return result + }, + // Set tolerance while dragging. Positive values decrease sensitivity, + // negative values increase it. + tolerance: 0 + }, // end group defaults + containerGroups = {}, + groupCounter = 0, + emptyBox = { + left: 0, + top: 0, + bottom: 0, + right:0 + }, + eventNames = { + start: "touchstart.sortable mousedown.sortable", + drop: "touchend.sortable touchcancel.sortable mouseup.sortable", + drag: "touchmove.sortable mousemove.sortable", + scroll: "scroll.sortable" + }, + subContainerKey = "subContainers" + + /* + * a is Array [left, right, top, bottom] + * b is array [left, top] + */ + function d(a,b) { + var x = Math.max(0, a[0] - b[0], b[0] - a[1]), + y = Math.max(0, a[2] - b[1], b[1] - a[3]) + return x+y; + } + + function setDimensions(array, dimensions, tolerance, useOffset) { + var i = array.length, + offsetMethod = useOffset ? "offset" : "position" + tolerance = tolerance || 0 + + while(i--){ + var el = array[i].el ? array[i].el : $(array[i]), + // use fitting method + pos = el[offsetMethod]() + pos.left += parseInt(el.css('margin-left'), 10) + pos.top += parseInt(el.css('margin-top'),10) + dimensions[i] = [ + pos.left - tolerance, + pos.left + el.outerWidth() + tolerance, + pos.top - tolerance, + pos.top + el.outerHeight() + tolerance + ] + } + } + + function getRelativePosition(pointer, element) { + var offset = element.offset() + return { + left: pointer.left - offset.left, + top: pointer.top - offset.top + } + } + + function sortByDistanceDesc(dimensions, pointer, lastPointer) { + pointer = [pointer.left, pointer.top] + lastPointer = lastPointer && [lastPointer.left, lastPointer.top] + + var dim, + i = dimensions.length, + distances = [] + + while(i--){ + dim = dimensions[i] + distances[i] = [i,d(dim,pointer), lastPointer && d(dim, lastPointer)] + } + distances = distances.sort(function (a,b) { + return b[1] - a[1] || b[2] - a[2] || b[0] - a[0] + }) + + // last entry is the closest + return distances + } + + function ContainerGroup(options) { + this.options = $.extend({}, groupDefaults, options) + this.containers = [] + + if(!this.options.rootGroup){ + this.scrollProxy = $.proxy(this.scroll, this) + this.dragProxy = $.proxy(this.drag, this) + this.dropProxy = $.proxy(this.drop, this) + this.placeholder = $(this.options.placeholder) + + if(!options.isValidTarget) + this.options.isValidTarget = undefined + } + } + + ContainerGroup.get = function (options) { + if(!containerGroups[options.group]) { + if(options.group === undefined) + options.group = groupCounter ++ + + containerGroups[options.group] = new ContainerGroup(options) + } + + return containerGroups[options.group] + } + + ContainerGroup.prototype = { + dragInit: function (e, itemContainer) { + this.$document = $(itemContainer.el[0].ownerDocument) + + // get item to drag + var closestItem = $(e.target).closest(this.options.itemSelector); + // using the length of this item, prevents the plugin from being started if there is no handle being clicked on. + // this may also be helpful in instantiating multidrag. + if (closestItem.length) { + this.item = closestItem; + this.itemContainer = itemContainer; + if (this.item.is(this.options.exclude) || !this.options.onMousedown(this.item, groupDefaults.onMousedown, e)) { + return; + } + this.setPointer(e); + this.toggleListeners('on'); + this.setupDelayTimer(); + this.dragInitDone = true; + } + }, + drag: function (e) { + if(!this.dragging){ + if(!this.distanceMet(e) || !this.delayMet) + return + + this.options.onDragStart(this.item, this.itemContainer, groupDefaults.onDragStart, e) + this.item.before(this.placeholder) + this.dragging = true + } + + this.setPointer(e) + // place item under the cursor + this.options.onDrag(this.item, + getRelativePosition(this.pointer, this.item.offsetParent()), + groupDefaults.onDrag, + e) + + var p = this.getPointer(e), + box = this.sameResultBox, + t = this.options.tolerance + + if(!box || box.top - t > p.top || box.bottom + t < p.top || box.left - t > p.left || box.right + t < p.left) + if(!this.searchValidTarget()){ + this.placeholder.detach() + this.lastAppendedItem = undefined + } + }, + drop: function (e) { + this.toggleListeners('off') + + this.dragInitDone = false + + if(this.dragging){ + // processing Drop, check if placeholder is detached + if(this.placeholder.closest("html")[0]){ + this.placeholder.before(this.item).detach() + } else { + this.options.onCancel(this.item, this.itemContainer, groupDefaults.onCancel, e) + } + this.options.onDrop(this.item, this.getContainer(this.item), groupDefaults.onDrop, e) + + // cleanup + this.clearDimensions() + this.clearOffsetParent() + this.lastAppendedItem = this.sameResultBox = undefined + this.dragging = false + } + }, + searchValidTarget: function (pointer, lastPointer) { + if(!pointer){ + pointer = this.relativePointer || this.pointer + lastPointer = this.lastRelativePointer || this.lastPointer + } + + var distances = sortByDistanceDesc(this.getContainerDimensions(), + pointer, + lastPointer), + i = distances.length + + while(i--){ + var index = distances[i][0], + distance = distances[i][1] + + if(!distance || this.options.pullPlaceholder){ + var container = this.containers[index] + if(!container.disabled){ + if(!this.$getOffsetParent()){ + var offsetParent = container.getItemOffsetParent() + pointer = getRelativePosition(pointer, offsetParent) + lastPointer = getRelativePosition(lastPointer, offsetParent) + } + if(container.searchValidTarget(pointer, lastPointer)) + return true + } + } + } + if(this.sameResultBox) + this.sameResultBox = undefined + }, + movePlaceholder: function (container, item, method, sameResultBox) { + var lastAppendedItem = this.lastAppendedItem + if(!sameResultBox && lastAppendedItem && lastAppendedItem[0] === item[0]) + return; + + item[method](this.placeholder) + this.lastAppendedItem = item + this.sameResultBox = sameResultBox + this.options.afterMove(this.placeholder, container, item) + }, + getContainerDimensions: function () { + if(!this.containerDimensions) + setDimensions(this.containers, this.containerDimensions = [], this.options.tolerance, !this.$getOffsetParent()) + return this.containerDimensions + }, + getContainer: function (element) { + return element.closest(this.options.containerSelector).data(pluginName) + }, + $getOffsetParent: function () { + if(this.offsetParent === undefined){ + var i = this.containers.length - 1, + offsetParent = this.containers[i].getItemOffsetParent() + + if(!this.options.rootGroup){ + while(i--){ + if(offsetParent[0] != this.containers[i].getItemOffsetParent()[0]){ + // If every container has the same offset parent, + // use position() which is relative to this parent, + // otherwise use offset() + // compare #setDimensions + offsetParent = false + break; + } + } + } + + this.offsetParent = offsetParent + } + return this.offsetParent + }, + setPointer: function (e) { + var pointer = this.getPointer(e) + + if(this.$getOffsetParent()){ + var relativePointer = getRelativePosition(pointer, this.$getOffsetParent()) + this.lastRelativePointer = this.relativePointer + this.relativePointer = relativePointer + } + + this.lastPointer = this.pointer + this.pointer = pointer + }, + distanceMet: function (e) { + var currentPointer = this.getPointer(e) + return (Math.max( + Math.abs(this.pointer.left - currentPointer.left), + Math.abs(this.pointer.top - currentPointer.top) + ) >= this.options.distance) + }, + getPointer: function(e) { + var o = e.originalEvent || e.originalEvent.touches && e.originalEvent.touches[0] + return { + left: e.pageX || o.pageX, + top: e.pageY || o.pageY + } + }, + setupDelayTimer: function () { + var that = this + this.delayMet = !this.options.delay + + // init delay timer if needed + if (!this.delayMet) { + clearTimeout(this._mouseDelayTimer); + this._mouseDelayTimer = setTimeout(function() { + that.delayMet = true + }, this.options.delay) + } + }, + scroll: function (e) { + this.clearDimensions() + this.clearOffsetParent() // TODO is this needed? + }, + toggleListeners: function (method) { + var that = this, + events = ['drag','drop','scroll'] + + $.each(events,function (i,event) { + that.$document[method](eventNames[event], that[event + 'Proxy']) + }) + }, + clearOffsetParent: function () { + this.offsetParent = undefined + }, + // Recursively clear container and item dimensions + clearDimensions: function () { + this.traverse(function(object){ + object._clearDimensions() + }) + }, + traverse: function(callback) { + callback(this) + var i = this.containers.length + while(i--){ + this.containers[i].traverse(callback) + } + }, + _clearDimensions: function(){ + this.containerDimensions = undefined + }, + _destroy: function () { + containerGroups[this.options.group] = undefined + } + } + + function Container(element, options) { + this.el = element + this.options = $.extend( {}, containerDefaults, options) + + this.group = ContainerGroup.get(this.options) + this.rootGroup = this.options.rootGroup || this.group + this.handle = this.rootGroup.options.handle || this.rootGroup.options.itemSelector + + var itemPath = this.rootGroup.options.itemPath + this.target = itemPath ? this.el.find(itemPath) : this.el + + this.target.on(eventNames.start, this.handle, $.proxy(this.dragInit, this)) + + if(this.options.drop) + this.group.containers.push(this) + } + + Container.prototype = { + dragInit: function (e) { + var rootGroup = this.rootGroup + + if( !this.disabled && + !rootGroup.dragInitDone && + this.options.drag && + this.isValidDrag(e)) { + rootGroup.dragInit(e, this) + } + }, + isValidDrag: function(e) { + return e.which == 1 || + e.type == "touchstart" && e.originalEvent.touches.length == 1 + }, + searchValidTarget: function (pointer, lastPointer) { + var distances = sortByDistanceDesc(this.getItemDimensions(), + pointer, + lastPointer), + i = distances.length, + rootGroup = this.rootGroup, + validTarget = !rootGroup.options.isValidTarget || + rootGroup.options.isValidTarget(rootGroup.item, this) + + if(!i && validTarget){ + rootGroup.movePlaceholder(this, this.target, "append") + return true + } else + while(i--){ + var index = distances[i][0], + distance = distances[i][1] + if(!distance && this.hasChildGroup(index)){ + var found = this.getContainerGroup(index).searchValidTarget(pointer, lastPointer) + if(found) + return true + } + else if(validTarget){ + this.movePlaceholder(index, pointer) + return true + } + } + }, + movePlaceholder: function (index, pointer) { + var item = $(this.items[index]), + dim = this.itemDimensions[index], + method = "after", + width = item.outerWidth(), + height = item.outerHeight(), + offset = item.offset(), + sameResultBox = { + left: offset.left, + right: offset.left + width, + top: offset.top, + bottom: offset.top + height + } + if(this.options.vertical){ + var yCenter = (dim[2] + dim[3]) / 2, + inUpperHalf = pointer.top <= yCenter + if(inUpperHalf){ + method = "before" + sameResultBox.bottom -= height / 2 + } else + sameResultBox.top += height / 2 + } else { + var xCenter = (dim[0] + dim[1]) / 2, + inLeftHalf = pointer.left <= xCenter + if(inLeftHalf){ + method = "before" + sameResultBox.right -= width / 2 + } else + sameResultBox.left += width / 2 + } + if(this.hasChildGroup(index)) + sameResultBox = emptyBox + this.rootGroup.movePlaceholder(this, item, method, sameResultBox) + }, + getItemDimensions: function () { + if(!this.itemDimensions){ + this.items = this.$getChildren(this.el, "item").filter( + ":not(." + this.group.options.placeholderClass + ", ." + this.group.options.draggedClass + ")" + ).get() + setDimensions(this.items, this.itemDimensions = [], this.options.tolerance) + } + return this.itemDimensions + }, + getItemOffsetParent: function () { + var offsetParent, + el = this.el + // Since el might be empty we have to check el itself and + // can not do something like el.children().first().offsetParent() + if(el.css("position") === "relative" || el.css("position") === "absolute" || el.css("position") === "fixed") + offsetParent = el + else + offsetParent = el.offsetParent() + return offsetParent + }, + hasChildGroup: function (index) { + return this.options.nested && this.getContainerGroup(index) + }, + getContainerGroup: function (index) { + var childGroup = $.data(this.items[index], subContainerKey) + if( childGroup === undefined){ + var childContainers = this.$getChildren(this.items[index], "container") + childGroup = false + + if(childContainers[0]){ + var options = $.extend({}, this.options, { + rootGroup: this.rootGroup, + group: groupCounter ++ + }) + childGroup = childContainers[pluginName](options).data(pluginName).group + } + $.data(this.items[index], subContainerKey, childGroup) + } + return childGroup + }, + $getChildren: function (parent, type) { + var options = this.rootGroup.options, + path = options[type + "Path"], + selector = options[type + "Selector"] + + parent = $(parent) + if(path) + parent = parent.find(path) + + return parent.children(selector) + }, + _serialize: function (parent, isContainer) { + var that = this, + childType = isContainer ? "item" : "container", + + children = this.$getChildren(parent, childType).not(this.options.exclude).map(function () { + return that._serialize($(this), !isContainer) + }).get() + + return this.rootGroup.options.serialize(parent, children, isContainer) + }, + traverse: function(callback) { + $.each(this.items || [], function(item){ + var group = $.data(this, subContainerKey) + if(group) + group.traverse(callback) + }); + + callback(this) + }, + _clearDimensions: function () { + this.itemDimensions = undefined + }, + _destroy: function() { + var that = this; + + this.target.off(eventNames.start, this.handle); + this.el.removeData(pluginName) + + if(this.options.drop) + this.group.containers = $.grep(this.group.containers, function(val){ + return val != that + }) + + $.each(this.items || [], function(){ + $.removeData(this, subContainerKey) + }) + } + } + + var API = { + enable: function() { + this.traverse(function(object){ + object.disabled = false + }) + }, + disable: function (){ + this.traverse(function(object){ + object.disabled = true + }) + }, + serialize: function () { + return this._serialize(this.el, true) + }, + refresh: function() { + this.traverse(function(object){ + object._clearDimensions() + }) + }, + destroy: function () { + this.traverse(function(object){ + object._destroy(); + }) + } + } + + $.extend(Container.prototype, API) + + /** + * jQuery API + * + * Parameters are + * either options on init + * or a method name followed by arguments to pass to the method + */ + $.fn[pluginName] = function(methodOrOptions) { + var args = Array.prototype.slice.call(arguments, 1) + + return this.map(function(){ + var $t = $(this), + object = $t.data(pluginName) + + if(object && API[methodOrOptions]) + return API[methodOrOptions].apply(object, args) || this + else if(!object && (methodOrOptions === undefined || + typeof methodOrOptions === "object")) + $t.data(pluginName, new Container($t, methodOrOptions)) + + return this + }); + }; + +}(jQuery, window, 'sortable'); diff --git a/yaksh/static/yaksh/js/requesthandler.js b/yaksh/static/yaksh/js/requesthandler.js index ec2391a..a215ce4 100644 --- a/yaksh/static/yaksh/js/requesthandler.js +++ b/yaksh/static/yaksh/js/requesthandler.js @@ -177,6 +177,13 @@ if (question_type == 'upload' || question_type == 'code') { global_editor.editor.setValue(init_val); global_editor.editor.clearHistory(); } - - }); +function user_arranged_options(){ + var temp_array = [] + var add_array = document.getElementById("arrange_order"); + var ans_array = order_array.children().get() + var answer_is = $.each(ans_array, function( index, value ) { + temp_array.push(value.id); + }); + add_array.value = temp_array +} diff --git a/yaksh/templates/manage.html b/yaksh/templates/manage.html index 17ce23e..c1f9da3 100644 --- a/yaksh/templates/manage.html +++ b/yaksh/templates/manage.html @@ -18,7 +18,7 @@ <li><a href="{{ URL_ROOT }}/exam/manage/courses">Courses</a></li> <li><a href="{{ URL_ROOT }}/exam/manage/monitor">Monitor</a></li> <li><a href="{{ URL_ROOT }}/exam/manage/gradeuser">Grade User</a></li> - <li><a href="{{ URL_ROOT }}/exam/manage/grader"> Grader </a></li> + <li><a href="{{ url_root }}/exam/manage/grader"> Regrade </a></li> <li><a href="{{ URL_ROOT }}/exam/reset/changepassword">Change Password</a></li> <li><a href="{{ URL_ROOT }}/exam/viewprofile"> {{ user.get_full_name.title }} </a></li> <li><a href="{{URL_ROOT}}/exam/logout/" id="logout">Logout</a></li> diff --git a/yaksh/templates/yaksh/add_question.html b/yaksh/templates/yaksh/add_question.html index ed69657..79c132c 100644 --- a/yaksh/templates/yaksh/add_question.html +++ b/yaksh/templates/yaksh/add_question.html @@ -64,6 +64,7 @@ <option value="integertestcase">Integer </option> <option value="stringtestcase"> String </option> <option value="floattestcase"> Float </option> + <option value="arrangetestcase">Arrange options </option> </select></p> <center> <button class="btn" type="submit" name="save_question">Save</button> diff --git a/yaksh/templates/yaksh/course_detail.html b/yaksh/templates/yaksh/course_detail.html index a5d10a7..9fcae68 100644 --- a/yaksh/templates/yaksh/course_detail.html +++ b/yaksh/templates/yaksh/course_detail.html @@ -136,12 +136,14 @@ <th>Sr No.</th> <th>Students</th> <th>Total</th> + <th>Grade</th> <th colspan="{{modules|length}}">Modules</th> </tr> <tr> <th scope="row"></th> <th></th> <th></th> + <th></th> {% if modules %} {% for module in modules %} <th> @@ -171,6 +173,10 @@ {% course_completion_percent course student as c_percent %} {{c_percent}} % </td> + <td> + {% course_grade course student as grade %} + {{grade}} + </td> {% if modules %} {% for module in modules %} <td> diff --git a/yaksh/templates/yaksh/course_modules.html b/yaksh/templates/yaksh/course_modules.html index afbae75..6c93e97 100644 --- a/yaksh/templates/yaksh/course_modules.html +++ b/yaksh/templates/yaksh/course_modules.html @@ -17,6 +17,7 @@ <center>{{ msg }}</center> </div> {% endif %} +<b>Grade: {% if grade %} {{ grade }} {% else %} Will be available once the course is complete {% endif %}</b> {% if learning_modules %} <table class="table"> {% for module in learning_modules %} diff --git a/yaksh/templates/yaksh/courses.html b/yaksh/templates/yaksh/courses.html index dabf8eb..ba09c6d 100644 --- a/yaksh/templates/yaksh/courses.html +++ b/yaksh/templates/yaksh/courses.html @@ -56,6 +56,10 @@ <a href="{{URL_ROOT}}/exam/manage/courses/all_learning_module"> Add/View Modules</a> </li> + <li> + <a href="{% url 'grades:grading_systems'%}"> + Add/View Grading Systems </a> + </li> </ul> </div> </div> diff --git a/yaksh/templates/yaksh/grade_user.html b/yaksh/templates/yaksh/grade_user.html index 93f00e0..8430e91 100644 --- a/yaksh/templates/yaksh/grade_user.html +++ b/yaksh/templates/yaksh/grade_user.html @@ -167,7 +167,7 @@ Status : <b style="color: red;"> Failed </b><br/> {% endif %} {% endfor %} - {% elif question.type == "integer" or "string" or "float" %} + {% elif question.type == "integer" or question.type == "string" or question.type == "float" %} <h5> <u>Correct Answer:</u></h5> {% for testcase in question.get_test_cases %} <strong>{{ testcase.correct|safe }}</strong> @@ -175,6 +175,14 @@ Status : <b style="color: red;"> Failed </b><br/> <strong>{{ testcase.error_margin|safe }}</strong> {% endif %} {% endfor %} + {% elif question.type == "arrange" %} + <h5> <u>Correct Order:</u></h5> + <div class="list-group" > + {% for testcase in question.get_test_cases %} + <li class="list-group-item"><strong>{{ testcase.options|safe }}</strong></li> + {% endfor %} + </div> + {% else %} <h5> <u>Test cases: </u></h5> {% for testcase in question.get_test_cases %} @@ -307,6 +315,15 @@ Status : <b style="color: red;"> Failed </b><br/> {% endif %} {% endfor %} </div> + + {% elif question.type == "arrange"%} + <div class="well well-sm"> + {% get_answer_for_arrange_options ans.answer.answer question as tc_list %} + {% for testcases in tc_list %} + <li>{{ testcases.options.strip|safe }}</li> + {% endfor %} + </div> + {% else %} <div class="well well-sm"> {{ ans.answer.answer.strip|safe }} diff --git a/yaksh/templates/yaksh/question.html b/yaksh/templates/yaksh/question.html index 9d6ce48..b65073a 100644 --- a/yaksh/templates/yaksh/question.html +++ b/yaksh/templates/yaksh/question.html @@ -21,6 +21,7 @@ <script src="{{ URL_ROOT }}/static/yaksh/js/codemirror/mode/clike/clike.js"></script> <script src="{{ URL_ROOT }}/static/yaksh/js/codemirror/mode/shell/shell.js"></script> <script src="{{ URL_ROOT }}/static/yaksh/js/mathjax/MathJax.js?config=TeX-MML-AM_CHTML"></script> +<script src="{{ URL_ROOT }}/static/yaksh/js/jquery-sortable.js"></script> <script> init_val = '{{ last_attempt|escape_quotes|safe }}'; lang = "{{ question.language }}" @@ -175,10 +176,12 @@ question_type = "{{ question.type }}" {% else %} <h5>(CASE SENSITIVE)</h5> {% endif %} - {% elif question.type == "float" %} (FILL IN THE BLANKS WITH FLOAT ANSWER) + {% elif question.type == "arrange" %} + (ARRANGE THE OPTIONS IN CORRECT ORDER) {% endif %} + </u> <font class=pull-right>(Marks : {{ question.points }}) </font> </h4> @@ -251,6 +254,27 @@ question_type = "{{ question.type }}" <input type=file id="assignment" name="assignment" multiple=""> <hr> {% endif %} + + {% if question.type == "arrange" %} + {% if last_attempt %} + {% get_answer_for_arrange_options last_attempt question as test_cases %} + {% endif %} + <input name="answer" type="hidden" id='arrange_order'/> + <div class="list-group"> + <ol class="arrange"> + {% for test_case in test_cases %} + <li class="list-group-item" id={{test_case.id}}>{{test_case.options| safe }}</li> + {% endfor %} + </ol> + </div> + + <script type="text/javascript"> + var arrange = $("ol.arrange"); + var order_array = $(arrange).sortable(['serialize']); + </script> + {% endif %} + + {% if question.type == "code" %} <div class="row"> <div class="col-md-9"> @@ -269,6 +293,9 @@ question_type = "{{ question.type }}" <br><button class="btn btn-primary" type="submit" name="check" id="check">Submit Answer</button> {% elif question.type == "upload" %} <br><button class="btn btn-primary" type="submit" name="check" id="check" onClick="return validate();">Upload</button> + {% elif question.type == "arrange" %} + <br><button class="btn btn-primary" type="submit" name="check" id="check" onClick="return user_arranged_options();">Submit Answer</button> + {% else %} {% if question in paper.get_questions_unanswered or quiz.is_exercise %} diff --git a/yaksh/templates/yaksh/user_data.html b/yaksh/templates/yaksh/user_data.html index ce2533e..9449fcc 100644 --- a/yaksh/templates/yaksh/user_data.html +++ b/yaksh/templates/yaksh/user_data.html @@ -80,6 +80,13 @@ User IP address: {{ paper.user_ip }} <strong>{{ testcase.correct|safe }}</strong> {% endfor %} + {% elif question.type == "arrange" %} + <h5> <u>Correct Order:</u></h5> + <div class="list-group" > + {% for testcase in question.get_test_cases %} + <li class="list-group-item"><strong>{{ testcase.options|safe }}</strong></li> + {% endfor %} + </div> {% else %} <h5> <u>Test cases: </u></h5> @@ -99,6 +106,7 @@ User IP address: {{ paper.user_ip }} {% endif %} <div class="panel-body"> <h5><u>Student answer:</u></h5> + {% if question.type == "mcc"%} <div class="well well-sm"> {% for testcases in question.get_test_cases %} @@ -107,6 +115,7 @@ User IP address: {{ paper.user_ip }} {% endif %} {% endfor %} </div> + {% elif question.type == "mcq"%} <div class="well well-sm"> {% for testcases in question.get_test_cases %} @@ -115,6 +124,15 @@ User IP address: {{ paper.user_ip }} {% endif %} {% endfor %} </div> + + {% elif question.type == "arrange"%} + <div class="well well-sm"> + {% get_answer_for_arrange_options answers.0.answer question as tc_list %} + {% for testcases in tc_list %} + <li>{{ testcases.options.strip|safe }}</li> + {% endfor %} + </div> + {%else%} <div class="well well-sm"> {{ answers.0.answer|safe }} diff --git a/yaksh/templates/yaksh/view_answerpaper.html b/yaksh/templates/yaksh/view_answerpaper.html index 971ef77..7cbec91 100644 --- a/yaksh/templates/yaksh/view_answerpaper.html +++ b/yaksh/templates/yaksh/view_answerpaper.html @@ -35,9 +35,9 @@ End time : {{ paper.end_time }} <br/> Percentage obtained: {{ paper.percent }}% <br/> {% if paper.passed %} - Status : <b style="color: red;"> Failed </b><br/> - {% else %} Status : <b style="color: green;"> Passed </b><br/> + {% else %} + Status : <b style="color: red;"> Failed </b><br/> {% endif %} </p> @@ -67,12 +67,20 @@ {% endif %} {% endfor %} - {% elif question.type == "integer" or "string" or "float" %} + {% elif question.type == "integer" or question.type == "string" or question.type == "float" %} <h5> <u>Correct Answer:</u></h5> {% for testcase in question.get_test_cases %} <strong>{{ testcase.correct|safe }}</strong> {% endfor %} + {% elif question.type == "arrange" %} + <h5> <u>Correct Order:</u></h5> + <div class="list-group"> + {% for testcase in question.get_test_cases %} + <li class="list-group-item"><strong>{{ testcase.options|safe }}</strong></li> + {% endfor %} + </div> + {% else %} <h5> <u>Test cases: </u></h5> {% for testcase in question.get_test_cases %} @@ -108,6 +116,13 @@ {% endif %} {% endfor %} </div> + {% elif question.type == "arrange"%} + <div class="well well-sm"> + {% get_answer_for_arrange_options answers.0.answer question as tc_list %} + {% for testcases in tc_list %} + <li>{{ testcases.options.strip|safe }}</li> + {% endfor %} + </div> {% elif question.type == "upload" and has_user_assignment %} <a href="{{URL_ROOT}}/exam/download/user_assignment/{{question.id}}/{{data.user.id}}/{{paper.question_paper.quiz.id}}"> <div class="well well-sm"> diff --git a/yaksh/templatetags/custom_filters.py b/yaksh/templatetags/custom_filters.py index fa0802f..0c5eb5a 100644 --- a/yaksh/templatetags/custom_filters.py +++ b/yaksh/templatetags/custom_filters.py @@ -1,5 +1,6 @@ from django import template from django.template.defaultfilters import stringfilter +from ast import literal_eval import os try: from itertools import zip_longest @@ -65,5 +66,23 @@ def course_completion_percent(course, user): @register.simple_tag +def course_grade(course, user): + return course.get_grade(user) + + +@register.simple_tag def get_ordered_testcases(question, answerpaper): - return question.get_ordered_test_cases(answerpaper)
\ No newline at end of file + return question.get_ordered_test_cases(answerpaper) + +@register.simple_tag +def get_answer_for_arrange_options(ans, question): + if type(ans) == bytes: + ans = ans.decode("utf-8") + else: + ans = str(ans) + answer = literal_eval(ans) + testcases = [] + for answer_id in answer: + tc = question.get_test_case(id=int(answer_id)) + testcases.append(tc) + return testcases diff --git a/yaksh/templatetags/test_custom_filters.py b/yaksh/templatetags/test_custom_filters.py new file mode 100644 index 0000000..7cef957 --- /dev/null +++ b/yaksh/templatetags/test_custom_filters.py @@ -0,0 +1,152 @@ +import unittest +from datetime import datetime, timedelta +from django.utils import timezone +import pytz + +# local imports +from yaksh.models import (User, Profile, Question, Quiz, QuestionPaper, + QuestionSet, AnswerPaper, Answer, Course, + IntegerTestCase, FloatTestCase, + StringTestCase, McqTestCase, ArrangeTestCase, + TestCaseOrder + ) + +from yaksh.templatetags.custom_filters import (completed, inprogress, + get_ordered_testcases, + get_answer_for_arrange_options + ) + + +def setUpModule(): + # Create user profile + teacher = User.objects.create_user(username='teacher2000', + password='demo', + email='teacher2000@test.com') + Profile.objects.create(user=teacher, roll_number=2000, institute='IIT', + department='Chemical', position='Teacher') + # Create a course + course = Course.objects.create(name="Python Course 2000", + enrollment="Enroll Request", + creator=teacher) + # Create a quiz + quiz = Quiz.objects.create(start_date_time=datetime( + 2015, 10, 9, 10, 8, 15, 0, + tzinfo=pytz.utc), + end_date_time=datetime( + 2199, 10, 9, 10, 8, 15, 0, + tzinfo=pytz.utc), + duration=30, active=True, + attempts_allowed=1, time_between_attempts=0, + description='demo quiz 2000', + pass_criteria=0, + instructions="Demo Instructions", + creator=teacher + ) + # Create a question paper + question_paper = QuestionPaper.objects.create(quiz=quiz, + total_marks=1.0) + # Create an answer paper + answerpaper = AnswerPaper.objects.create(user=teacher, + user_ip='101.0.0.1', + start_time=timezone.now(), + question_paper=question_paper, + end_time=timezone.now() + +timedelta(minutes=5), + attempt_number=1, + course=course + ) +def tearDownModule(): + User.objects.get(username="teacher2000").delete() + + +class CustomFiltersTestCases(unittest.TestCase): + + @classmethod + def setUpClass(self): + self.course = Course.objects.get(name="Python Course 2000") + self.quiz = Quiz.objects.get(description="demo quiz 2000") + self.question_paper = QuestionPaper.objects.get(quiz=self.quiz) + self.user = User.objects.get(username='teacher2000') + self.question1 = Question.objects.create(summary='int1', points=1, + type='code', user=self.user) + self.question1.language = 'python' + self.question1.type = "arrange" + self.question1.description = "Arrange alphabets in ascending order" + self.question1.test_case_type = 'arrangetestcase' + self.question1.save() + self.question_paper.fixed_questions.add(self.question1) + self.question_paper.save() + #Creating answerpaper + + self.answerpaper = AnswerPaper.objects.get(user=self.user, + course=self.course, + question_paper=self.question_paper + ) + self.answerpaper.questions.add(self.question1) + self.answerpaper.save() + # For question + self.arrange_testcase_1 = ArrangeTestCase(question=self.question1, + options="A", + type = 'arrangetestcase', + ) + self.arrange_testcase_1.save() + self.testcase_1_id = self.arrange_testcase_1.id + self.arrange_testcase_2 = ArrangeTestCase(question=self.question1, + options="B", + type = 'arrangetestcase', + ) + self.arrange_testcase_2.save() + self.testcase_2_id = self.arrange_testcase_2.id + self.arrange_testcase_3 = ArrangeTestCase(question=self.question1, + options="C", + type = 'arrangetestcase', + ) + self.arrange_testcase_3.save() + self.testcase_3_id = self.arrange_testcase_3.id + + @classmethod + def tearDownClass(self): + self.question1.delete() + self.answerpaper.delete() + + def test_completed_inprogress(self): + # Test in progress + answerpaper = AnswerPaper.objects.filter(id=self.answerpaper.id) + + self.assertEqual(inprogress(answerpaper), 1) + self.assertEqual(completed(answerpaper), 0) + # Test completed + self.answerpaper.status='completed' + self.answerpaper.save() + self.assertEqual(inprogress(answerpaper), 0) + self.assertEqual(completed(answerpaper), 1) + + def test_get_answer_for_arrange_options(self): + arrange_ans = [self.arrange_testcase_3, + self.arrange_testcase_2, + self.arrange_testcase_1, + ] + arrange_ans_id = [tc.id for tc in arrange_ans] + user_ans_order = get_answer_for_arrange_options(arrange_ans_id, + self.question1 + ) + self.assertSequenceEqual(arrange_ans, user_ans_order) + + def test_get_ordered_testcases(self): + new_answerpaper = self.question_paper.make_answerpaper(self.user, + "101.0.0.1",2, + self.course.id + ) + tc_order = TestCaseOrder.objects.get(answer_paper=new_answerpaper, + question=self.question1 + ) + testcases = [self.question1.get_test_case(id=ids) + for ids in tc_order.order.split(",") + ] + + ordered_testcases = get_ordered_testcases(self.question1, + new_answerpaper + ) + self.assertSequenceEqual(testcases, ordered_testcases) + + new_answerpaper.delete() diff --git a/yaksh/test_models.py b/yaksh/test_models.py index 49bba00..41730c3 100644 --- a/yaksh/test_models.py +++ b/yaksh/test_models.py @@ -1372,12 +1372,20 @@ class AnswerPaperTestCases(unittest.TestCase): def test_get_question_answer(self): """ Test get_question_answer() method of Answer Paper""" + questions = self.answerpaper.questions.all() answered = self.answerpaper.get_question_answers() - first_answer = list(answered.values())[0][0] - first_answer_obj = first_answer['answer'] - self.assertEqual(first_answer_obj.answer, 'Demo answer') - self.assertTrue(first_answer_obj.correct) - self.assertEqual(len(answered), 2) + for question in questions: + answers_saved = Answer.objects.filter(question=question) + error_list = [json.loads(ans.error) for ans in answers_saved] + if answers_saved: + self.assertEqual(len(answered[question]), len(answers_saved)) + ans = [] + err = [] + for val in answered[question]: + ans.append(val.get('answer')) + err.append(val.get('error_list')) + self.assertEqual(set(ans), set(answers_saved)) + self.assertEqual(error_list, err) def test_is_answer_correct(self): self.assertTrue(self.answerpaper.is_answer_correct(self.questions[0])) @@ -1830,3 +1838,101 @@ class AssignmentUploadTestCases(unittest.TestCase): actual_file_name = self.quiz.description.replace(" ", "_") file_name = file_name.replace(" ", "_") self.assertIn(actual_file_name, file_name) + + +class CourseStatusTestCases(unittest.TestCase): + def setUp(self): + user = User.objects.get(username='creator') + self.course = Course.objects.create(name="Demo Course", creator=user, + enrollment="Enroll Request") + self.module = LearningModule.objects.create(name='M1', creator=user, + description='module one') + self.quiz1 = Quiz.objects.create(time_between_attempts=0, weightage=50, + description='qz1') + self.quiz2 = Quiz.objects.create(time_between_attempts=0, weightage=100, + description='qz2') + question = Question.objects.first() + self.qpaper1 = QuestionPaper.objects.create(quiz=self.quiz1) + self.qpaper2 = QuestionPaper.objects.create(quiz=self.quiz2) + self.qpaper1.fixed_questions.add(question) + self.qpaper2.fixed_questions.add(question) + self.qpaper1.update_total_marks() + self.qpaper2.update_total_marks() + self.qpaper1.save() + self.qpaper2.save() + self.unit_1_quiz = LearningUnit.objects.create(order=1, type='quiz', + quiz=self.quiz1) + self.unit_2_quiz = LearningUnit.objects.create(order=2, type='quiz', + quiz=self.quiz2) + self.module.learning_unit.add(self.unit_1_quiz) + self.module.learning_unit.add(self.unit_2_quiz) + self.module.save() + self.course.learning_module.add(self.module) + student = User.objects.get(username='course_user') + self.course.students.add(student) + self.course.save() + + attempt = 1 + ip = '127.0.0.1' + self.answerpaper1 = self.qpaper1.make_answerpaper(student, ip, attempt, + self.course.id) + self.answerpaper2 = self.qpaper2.make_answerpaper(student, ip, attempt, + self.course.id) + + self.course_status = CourseStatus.objects.create(course=self.course, + user=student) + + def tearDown(self): + self.course_status.delete() + self.answerpaper1.delete() + self.answerpaper2.delete() + self.qpaper1.delete() + self.qpaper2.delete() + self.quiz1.delete() + self.quiz2.delete() + self.unit_1_quiz.delete() + self.unit_2_quiz.delete() + self.module.delete() + self.course.delete() + + def test_course_is_complete(self): + # When + self.course_status.completed_units.add(self.unit_1_quiz) + # Then + self.assertFalse(self.course_status.is_course_complete()) + + # When + self.course_status.completed_units.add(self.unit_2_quiz) + # Then + self.assertTrue(self.course_status.is_course_complete()) + + # Given + self.answerpaper1.marks_obtained = 1 + self.answerpaper1.save() + self.answerpaper2.marks_obtained = 0 + self.answerpaper2.save() + # When + self.course_status.calculate_percentage() + # Then + self.assertEqual(round(self.course_status.percentage, 2), 33.33) + # When + self.course_status.set_grade() + # Then + self.assertEqual(self.course_status.get_grade(), 'F') + + # Given + self.answerpaper1.marks_obtained = 0 + self.answerpaper1.save() + self.answerpaper2.marks_obtained = 1 + self.answerpaper2.save() + # When + self.course_status.calculate_percentage() + # Then + self.assertEqual(round(self.course_status.percentage, 2), 66.67) + # When + self.course_status.set_grade() + # Then + self.assertEqual(self.course_status.get_grade(), 'B') + + # Test get course grade after completion + self.assertEqual(self.course.get_grade(self.answerpaper1.user), 'B') diff --git a/yaksh/views.py b/yaksh/views.py index c22500d..1cb77fc 100644 --- a/yaksh/views.py +++ b/yaksh/views.py @@ -618,7 +618,7 @@ def show_question(request, question, paper, error_message=None, notification=Non if question.type == "code" else 'You have already attempted this question' ) - if question.type in ['mcc', 'mcq']: + if question.type in ['mcc', 'mcq', 'arrange']: test_cases = question.get_ordered_test_cases(paper) else: test_cases = question.get_test_cases() @@ -728,6 +728,9 @@ def check(request, q_id, attempt_num=None, questionpaper_id=None, elif current_question.type == 'mcc': user_answer = request.POST.getlist('answer') + elif current_question.type == 'arrange': + user_answer_ids = request.POST.get('answer').split(',') + user_answer = [int(ids) for ids in user_answer_ids] elif current_question.type == 'upload': # if time-up at upload question then the form is submitted without # validation @@ -1669,6 +1672,10 @@ def grade_user(request, quiz_id=None, user_id=None, attempt_number=None, 'comments_%d' % paper.question_paper.id, 'No comments') paper.save() + course_status = CourseStatus.objects.filter(course=course, user=user) + if course_status.exists(): + course_status.first().set_grade() + return my_render_to_response( 'yaksh/grade_user.html', context, context_instance=ci ) @@ -1924,14 +1931,27 @@ def regrade(request, course_id, question_id=None, answerpaper_id=None, answerpaper = get_object_or_404(AnswerPaper, pk=answerpaper_id) for question in answerpaper.questions.all(): details.append(answerpaper.regrade(question.id)) + course_status = CourseStatus.objects.filter(user=answerpaper.user, + course=answerpaper.course) + if course_status.exists(): + course_status.first().set_grade() if questionpaper_id is not None and question_id is not None: answerpapers = AnswerPaper.objects.filter(questions=question_id, question_paper_id=questionpaper_id, course_id=course_id) for answerpaper in answerpapers: details.append(answerpaper.regrade(question_id)) + course_status = CourseStatus.objects.filter(user=answerpaper.user, + course=answerpaper.course) + if course_status.exists(): + course_status.first().set_grade() if answerpaper_id is not None and question_id is not None: answerpaper = get_object_or_404(AnswerPaper, pk=answerpaper_id) details.append(answerpaper.regrade(question_id)) + course_status = CourseStatus.objects.filter(user=answerpaper.user, + course=answerpaper.course) + if course_status.exists(): + course_status.first().set_grade() + return grader(request, extra_context={'details': details}) @@ -2720,6 +2740,12 @@ def course_modules(request, course_id, msg=None): learning_modules = course.get_learning_modules() context = {"course": course, "learning_modules": learning_modules, "user": user, "msg": msg} + course_status = CourseStatus.objects.filter(course=course, user=user) + if course_status.exists(): + course_status = course_status.first() + if not course_status.grade: + course_status.set_grade() + context['grade'] = course_status.get_grade() return my_render_to_response('yaksh/course_modules.html', context) |