diff options
27 files changed, 1004 insertions, 150 deletions
diff --git a/CHANGELOG.txt b/CHANGELOG.txt index d37f7c7..275624f 100644 --- a/CHANGELOG.txt +++ b/CHANGELOG.txt @@ -1,8 +1,26 @@ -== 0.21.0 (02-07-2020) === +=== 0.23.0 (09-09-2020) === + +* Allow a single user or multiple users to reattempt a quiz + +=== 0.22.1 (28-08-2020) === + +* Avoid duplicate user entry with same email address during upload. +* Fix a bug where user cannot submit zero as answer +* Fix UI in question statistics. +* Fix a bug where the trial question paper was not updated. +* Fix a bug where answers for fill in the blanks type is not shown. + +=== 0.22.0 (27-08-2020) === + +* Fix zero division error if the course does not have any quizzes. +* Improve question statistics +* Add Mathjax support to lesson and module. + +=== 0.21.0 (02-07-2020) === * Added support for hiding test cases for code questions -== 0.20.2 (02-06-2020) === +=== 0.20.2 (02-06-2020) === * Added a custom filter to convert str objects to int in templates * Fixed a bug that prevented users from seeing the last submitted MCQ answers @@ -12,7 +30,7 @@ * Display question solution in view answer paper * Fixed bug to check if attempts are allowed and spare time is available before answer is checked -== 0.20.1 (21-05-2020) === +=== 0.20.1 (21-05-2020) === * Rename celery.py to celery_settings.py to avoid conflicts with the celery app diff --git a/yaksh/fixtures/user_existing_email.csv b/yaksh/fixtures/user_existing_email.csv new file mode 100644 index 0000000..ee5fcd0 --- /dev/null +++ b/yaksh/fixtures/user_existing_email.csv @@ -0,0 +1,2 @@ +firstname, lastname, email +abc, abc, demo_student@test.com diff --git a/yaksh/fixtures/users_add_update_reject.csv b/yaksh/fixtures/users_add_update_reject.csv index 1990179..2b8fcf6 100644 --- a/yaksh/fixtures/users_add_update_reject.csv +++ b/yaksh/fixtures/users_add_update_reject.csv @@ -1,4 +1,4 @@ firstname, lastname, email, institute,department,roll_no,remove,password,username test, test, test@g.com, TEST, TEST, TEST101, FALSE, TEST, test -test2, test, test@g.com, TEST, TEST, TEST101, FALSE, TEST, test2 -test2, test, test@g.com, TEST, TEST, TEST101, TRUE, TEST, test2 +test2, test, test2@g.com, TEST, TEST, TEST101, FALSE, TEST, test2 +test2, test, test2@g.com, TEST, TEST, TEST101, TRUE, TEST, test2 diff --git a/yaksh/migrations/0021_auto_20200703_1556.py b/yaksh/migrations/0021_auto_20200703_1556.py new file mode 100644 index 0000000..713b2d8 --- /dev/null +++ b/yaksh/migrations/0021_auto_20200703_1556.py @@ -0,0 +1,25 @@ +# Generated by Django 3.0.7 on 2020-07-03 10:26 + +import datetime +from django.db import migrations, models +from django.utils.timezone import utc + + +class Migration(migrations.Migration): + + dependencies = [ + ('yaksh', '0020_release_0_21_0'), + ] + + operations = [ + migrations.AlterField( + model_name='course', + name='end_enroll_time', + field=models.DateTimeField(default=datetime.datetime(2198, 12, 31, 18, 7, tzinfo=utc), null=True, verbose_name='End Date and Time for enrollment of course'), + ), + migrations.AlterField( + model_name='quiz', + name='end_date_time', + field=models.DateTimeField(default=datetime.datetime(2198, 12, 31, 18, 7, tzinfo=utc), null=True, verbose_name='End Date and Time of the quiz'), + ), + ] diff --git a/yaksh/migrations/0022_release_0_22_1.py b/yaksh/migrations/0022_release_0_22_1.py new file mode 100644 index 0000000..5275b86 --- /dev/null +++ b/yaksh/migrations/0022_release_0_22_1.py @@ -0,0 +1,30 @@ +# Generated by Django 3.0.7 on 2020-08-28 07:17 + +import datetime +from django.db import migrations, models +from django.utils.timezone import utc + + +class Migration(migrations.Migration): + + dependencies = [ + ('yaksh', '0021_auto_20200703_1556'), + ] + + operations = [ + migrations.AlterField( + model_name='course', + name='end_enroll_time', + field=models.DateTimeField(default=datetime.datetime(2199, 1, 1, 0, 0, tzinfo=utc), null=True, verbose_name='End Date and Time for enrollment of course'), + ), + migrations.AlterField( + model_name='questionpaper', + name='fixed_question_order', + field=models.TextField(blank=True), + ), + migrations.AlterField( + model_name='quiz', + name='end_date_time', + field=models.DateTimeField(default=datetime.datetime(2199, 1, 1, 0, 0, tzinfo=utc), null=True, verbose_name='End Date and Time of the quiz'), + ), + ] diff --git a/yaksh/migrations/0023_release_0_23_0.py b/yaksh/migrations/0023_release_0_23_0.py new file mode 100644 index 0000000..0666fb8 --- /dev/null +++ b/yaksh/migrations/0023_release_0_23_0.py @@ -0,0 +1,46 @@ +# Generated by Django 3.0.7 on 2020-09-09 02:25 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import django.utils.timezone + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('yaksh', '0022_release_0_22_1'), + ] + + operations = [ + migrations.AddField( + model_name='answerpaper', + name='extra_time', + field=models.FloatField(default=0.0, verbose_name='Additional time in mins'), + ), + migrations.AddField( + model_name='answerpaper', + name='is_special', + field=models.BooleanField(default=False), + ), + migrations.CreateModel( + name='MicroManager', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('special_attempt', models.BooleanField(default=False)), + ('attempts_permitted', models.IntegerField(default=0)), + ('permitted_time', models.DateTimeField(default=django.utils.timezone.now)), + ('attempts_utilised', models.IntegerField(default=0)), + ('wait_time', models.IntegerField(default=0, verbose_name='Days to wait before special attempt')), + ('attempt_valid_for', models.IntegerField(default=90, verbose_name='Validity days')), + ('course', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='yaksh.Course')), + ('manager', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='micromanaging', to=settings.AUTH_USER_MODEL)), + ('quiz', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='yaksh.Quiz')), + ('student', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='micromanaged', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'unique_together': {('student', 'course', 'quiz')}, + }, + ), + ] diff --git a/yaksh/models.py b/yaksh/models.py index 1fee87b..aa0b916 100644 --- a/yaksh/models.py +++ b/yaksh/models.py @@ -1045,6 +1045,14 @@ class Course(models.Model): def get_learning_modules(self): return self.learning_module.order_by("order") + def get_learning_module(self, quiz): + modules = self.get_learning_modules() + for module in modules: + for unit in module.get_learning_units(): + if unit.quiz == quiz: + break + return module + def get_unit_completion_status(self, module, user, unit): course_module = self.learning_module.get(id=module.id) learning_unit = course_module.learning_unit.get(id=unit.id) @@ -1695,17 +1703,15 @@ class QuestionPaperManager(models.Manager): def create_trial_paper_to_test_quiz(self, trial_quiz, original_quiz_id): """Creates a trial question paper to test quiz.""" - if self.filter(quiz=trial_quiz).exists(): - trial_questionpaper = self.get(quiz=trial_quiz) - else: - trial_questionpaper, trial_questions = \ - self._create_trial_from_questionpaper(original_quiz_id) - trial_questionpaper.quiz = trial_quiz - trial_questionpaper.fixed_questions\ - .add(*trial_questions["fixed_questions"]) - trial_questionpaper.random_questions\ - .add(*trial_questions["random_questions"]) - trial_questionpaper.save() + trial_quiz.questionpaper_set.all().delete() + trial_questionpaper, trial_questions = \ + self._create_trial_from_questionpaper(original_quiz_id) + trial_questionpaper.quiz = trial_quiz + trial_questionpaper.fixed_questions\ + .add(*trial_questions["fixed_questions"]) + trial_questionpaper.random_questions\ + .add(*trial_questions["random_questions"]) + trial_questionpaper.save() return trial_questionpaper @@ -1729,7 +1735,7 @@ class QuestionPaper(models.Model): total_marks = models.FloatField(default=0.0, blank=True) # Sequence or Order of fixed questions - fixed_question_order = models.CharField(max_length=255, blank=True) + fixed_question_order = models.TextField(blank=True) # Shuffle testcase order. shuffle_testcases = models.BooleanField("Shuffle testcase for each user", @@ -1778,7 +1784,7 @@ class QuestionPaper(models.Model): all_questions = questions return all_questions - def make_answerpaper(self, user, ip, attempt_num, course_id): + def make_answerpaper(self, user, ip, attempt_num, course_id, special=False): """Creates an answer paper for the user to attempt the quiz""" try: ans_paper = AnswerPaper.objects.get(user=user, @@ -1797,6 +1803,7 @@ class QuestionPaper(models.Model): ans_paper.end_time = ans_paper.start_time + \ timedelta(minutes=self.quiz.duration) ans_paper.question_paper = self + ans_paper.is_special = special ans_paper.save() questions = self._get_questions_for_answerpaper() ans_paper.questions.add(*questions) @@ -2141,6 +2148,10 @@ class AnswerPaper(models.Model): # set question order questions_order = models.TextField(blank=True, default='') + extra_time = models.FloatField('Additional time in mins', default=0.0) + + is_special = models.BooleanField(default=False) + objects = AnswerPaperManager() class Meta: @@ -2223,11 +2234,16 @@ class AnswerPaper(models.Model): questions = list(self.questions.all()) return questions + def set_extra_time(self, time=0): + self.extra_time = time + self.save() + def time_left(self): """Return the time remaining for the user in seconds.""" secs = self._get_total_seconds() + extra_time = self.extra_time * 60 total = self.question_paper.quiz.duration*60.0 - remain = max(total - secs, 0) + remain = max(total - (secs - extra_time), 0) return int(remain) def time_left_on_question(self, question): @@ -2712,3 +2728,108 @@ class Comment(ForumBase): def __str__(self): return 'Comment by {0}: {1}'.format(self.creator.username, self.post_field.title) + + +class MicroManager(models.Model): + manager = models.ForeignKey(User, on_delete=models.CASCADE, + related_name='micromanaging', null=True) + student = models.ForeignKey(User, on_delete=models.CASCADE, + related_name='micromanaged') + course = models.ForeignKey(Course, on_delete=models.CASCADE) + quiz = models.ForeignKey(Quiz, on_delete=models.CASCADE, null=True) + special_attempt = models.BooleanField(default=False) + attempts_permitted = models.IntegerField(default=0) + permitted_time = models.DateTimeField(default=timezone.now) + attempts_utilised = models.IntegerField(default=0) + wait_time = models.IntegerField('Days to wait before special attempt', + default=0) + attempt_valid_for = models.IntegerField('Validity days', default=90) + + class Meta: + unique_together = ('student', 'course', 'quiz') + + def set_wait_time(self, days=0): + self.wait_time = days + self.save() + + def increment_attempts_permitted(self): + self.attempts_permitted += 1 + self.save() + + def update_permitted_time(self, permit_time=None): + time_now = timezone.now() + self.permitted_time = time_now if not permit_time else permit_time + self.save() + + def has_student_attempts_exhausted(self): + if self.quiz.attempts_allowed == -1: + return False + question_paper = self.quiz.questionpaper_set.first() + attempts = AnswerPaper.objects.get_total_attempt( + question_paper, self.student, course_id=self.course.id + ) + last_attempt = AnswerPaper.objects.get_user_last_attempt( + question_paper, self.student, self.course.id + ) + if last_attempt: + if last_attempt.is_attempt_inprogress(): + return False + return attempts >= self.quiz.attempts_allowed + + def is_last_attempt_inprogress(self): + question_paper = self.quiz.questionpaper_set.first() + last_attempt = AnswerPaper.objects.get_user_last_attempt( + question_paper, self.student, self.course.id + ) + if last_attempt: + return last_attempt.is_attempt_inprogress() + return False + + def has_quiz_time_exhausted(self): + return not self.quiz.active or self.quiz.is_expired() + + def is_course_exhausted(self): + return not self.course.active or not self.course.is_active_enrollment() + + def is_special_attempt_required(self): + return (self.has_student_attempts_exhausted() or + self.has_quiz_time_exhausted() or self.is_course_exhausted()) + + def allow_special_attempt(self, wait_time=0): + if (self.is_special_attempt_required() and + not self.is_last_attempt_inprogress()): + self.special_attempt = True + if self.attempts_utilised >= self.attempts_permitted: + self.increment_attempts_permitted() + self.update_permitted_time() + self.set_wait_time(days=wait_time) + self.save() + + def has_special_attempt(self): + return (self.special_attempt and + (self.attempts_utilised < self.attempts_permitted)) + + def is_attempt_time_valid(self): + permit_time = self.permitted_time + wait_time = permit_time + timezone.timedelta(days=self.wait_time) + valid_time = permit_time + timezone.timedelta( + days=self.attempt_valid_for) + return wait_time <= timezone.now() <= valid_time + + def can_student_attempt(self): + return self.has_special_attempt() and self.is_attempt_time_valid() + + def get_attempt_number(self): + return self.quiz.attempts_allowed + self.attempts_utilised + 1 + + def increment_attempts_utilised(self): + self.attempts_utilised += 1 + self.save() + + def revoke_special_attempt(self): + self.special_attempt = False + self.save() + + def __str__(self): + return 'MicroManager for {0} - {1}'.format(self.student.username, + self.course.name) diff --git a/yaksh/static/yaksh/css/custom.css b/yaksh/static/yaksh/css/custom.css index f995c61..7756478 100644 --- a/yaksh/static/yaksh/css/custom.css +++ b/yaksh/static/yaksh/css/custom.css @@ -131,3 +131,9 @@ body, .dropdown-menu { #question_card { border: none; } + + +iframe { + display:block; + width:100%; +} diff --git a/yaksh/static/yaksh/js/lesson.js b/yaksh/static/yaksh/js/lesson.js index 55d4846..6eaf6c6 100644 --- a/yaksh/static/yaksh/js/lesson.js +++ b/yaksh/static/yaksh/js/lesson.js @@ -32,13 +32,10 @@ $(document).ready(function(){ }); }); - function preview_text(data){ - var preview_div = $("#preview_text_div"); - if (!preview_div.is(":visible")){ - $("#preview_text_div").toggle(); - } + function preview_text(data) { $("#description_body").empty(); - $("#description_body").append(data); + $("#description_body").html(data); + MathJax.Hub.Queue(["Typeset",MathJax.Hub]); } $("#embed").click(function() { diff --git a/yaksh/static/yaksh/js/question_paper_creation.js b/yaksh/static/yaksh/js/question_paper_creation.js index 9d04728..1159dd3 100644 --- a/yaksh/static/yaksh/js/question_paper_creation.js +++ b/yaksh/static/yaksh/js/question_paper_creation.js @@ -57,7 +57,37 @@ $(document).ready(function(){ $('#design_q').submit(function(eventObj) { $(this).append('<input type="hidden" name="checked_ques" value='+checked_vals+'>'); return true; -}); + }); + + $('#add_checkall').on("change", function () { + if($(this).prop("checked")) { + $("#fixed-available input:checkbox").each(function(index, element) { + if(isNaN($(this).val())) {return}; + $(this).prop("checked", true); + checked_vals.push(parseInt($(this).val())) + }); + } else { + $("#fixed-available input:checkbox").each(function(index, element){ + $(this).prop('checked', false); + checked_vals.pop(parseInt($(this).val())); + }); + } + }); + + $('#remove_checkall').on("change", function () { + if($(this).prop("checked")) { + $("#fixed-added input:checkbox").each(function (index, element) { + if(isNaN($(this).val())) { return }; + $(this).prop('checked', true); + checked_vals.push(parseInt($(this).val())); + }); + } else { + $("#fixed-added input:checkbox").each(function (index, element) { + $(this).prop('checked', false); + checked_vals.pop(parseInt($(this).val())); + }); + } + }); });//document function append_tag(tag){ @@ -69,3 +99,4 @@ function append_tag(tag){ tag_name.value = tag.value; } } + diff --git a/yaksh/templates/yaksh/add_lesson.html b/yaksh/templates/yaksh/add_lesson.html index b984db0..4211b1b 100644 --- a/yaksh/templates/yaksh/add_lesson.html +++ b/yaksh/templates/yaksh/add_lesson.html @@ -9,6 +9,8 @@ </script> <script type="text/javascript" src="{% static 'yaksh/js/jquery-ui.js' %}"> </script> +<script type="text/javascript" src="{% static 'yaksh/js/mathjax/MathJax.js' %}?config=TeX-MML-AM_CHTML"> +</script> {% endblock %} {% block css %} @@ -17,15 +19,15 @@ {% endblock %} {% block content %} -<div class="container"> +<div class="container-fluid"> {% if error %} <div class="alert alert-danger"> {{error}} </div> {% endif %} -<div class="container"> +<div class="container-fluid"> <div class="row justify-content-center form-group"> - <div class="col-md-9 col-md-offset-4"> + <div class="col-md-5 col-md-offset-4"> <a class="btn btn-primary" href="{% url 'yaksh:get_course_modules' course_id %}"> <i class="fa fa-arrow-left"></i> Back </a> @@ -137,18 +139,20 @@ </center> </form> <hr> - <div class="card" id="preview_text_div" style="display: none;"> - <div class="card-heading"> - <center> - <h3>Description Preview</h3> - </center> - </div> - <div class="card-body" id="description_body"> - </div> - </div> </fieldset> </form> </div> + <div class="col-md-6"> + <div class="card" id="preview_text_div"> + <div class="card-header"> + <center> + <h3>Description Preview</h3> + </center> + </div> + <div class="card-body" id="description_body"> + </div> + </div> + </div> </div> </div> {% endblock %}
\ No newline at end of file diff --git a/yaksh/templates/yaksh/add_module.html b/yaksh/templates/yaksh/add_module.html index 262c009..7112485 100644 --- a/yaksh/templates/yaksh/add_module.html +++ b/yaksh/templates/yaksh/add_module.html @@ -11,6 +11,8 @@ </script> <script type="text/javascript" src="{% static 'yaksh/js/jquery-ui.js' %}"> </script> +<script type="text/javascript" src="{% static 'yaksh/js/mathjax/MathJax.js' %}?config=TeX-MML-AM_CHTML"> +</script> {% endblock %} {% block css %} @@ -19,17 +21,7 @@ {% endblock %} {% block content %} -<div class="container"> -{% if messages %} - {% for message in messages %} - <div class="alert alert-dismissible alert-{{ message.tags }}"> - <button type="button" class="close" data-dismiss="alert"> - <i class="fa fa-close"></i> - </button> - <strong>{{ message }}</strong> - </div> - {% endfor %} -{% endif %} +<div class="container-fluid"> {% if course_id %} <a class="btn btn-primary" href="{% url 'yaksh:get_course_modules' course_id %}"> <i class="fa fa-arrow-left"></i> @@ -43,10 +35,23 @@ {% endif %} </div> <br> -{% if status == "add" %} <div class="container"> +{% if messages %} + {% for message in messages %} + <div class="alert alert-dismissible alert-{{ message.tags }}"> + <button type="button" class="close" data-dismiss="alert"> + <i class="fa fa-close"></i> + </button> + <strong>{{ message }}</strong> + </div> + {% endfor %} +{% endif %} +</div> +<br> +{% if status == "add" %} +<div class="container-fluid"> <div class="row justify-content-center form-group"> - <div class="col-md-9 col-md-offset-4"> + <div class="col-md-5 col-md-offset-4"> <form name=frm id=frm action="" method="post"> <fieldset> {% csrf_token %} @@ -92,18 +97,20 @@ </center> </form> <hr> - <div class="card" id="preview_text_div" style="display: none;"> - <div class="card-heading"> - <center> - <h3>Description Preview</h3> - </center> - </div> - <div class="card-body" id="description_body"> - </div> - </div> </fieldset> </form> </div> + <div class="col-md-5"> + <div class="card" id="preview_text_div"> + <div class="card-header"> + <center> + <h3>Description Preview</h3> + </center> + </div> + <div class="card-body" id="description_body"> + </div> + </div> + </div> </div> </div> {% endif %} diff --git a/yaksh/templates/yaksh/add_quiz.html b/yaksh/templates/yaksh/add_quiz.html index 55e3bd6..9b80e0d 100644 --- a/yaksh/templates/yaksh/add_quiz.html +++ b/yaksh/templates/yaksh/add_quiz.html @@ -55,7 +55,7 @@ {% if quiz and course_id %} {% if quiz.questionpaper_set.get.id %} <center> - <a href="{% url 'yaksh:designquestionpaper' quiz.id quiz.questionpaper_set.get.id course_id %}" class="btn btn-primary"> + <a href="{% url 'yaksh:designquestionpaper' course_id quiz.id quiz.questionpaper_set.get.id %}" class="btn btn-primary"> <i class="fa fa-edit"></i> Edit Question Paper </a> <a href="{% url 'yaksh:preview_questionpaper' quiz.questionpaper_set.get.id %}" class="btn btn-info" target="_blank"> @@ -65,11 +65,11 @@ <br> <h4>You can check the quiz by attempting it in the following modes:</h4> <a class="btn btn-outline-info" name="button" href="{% url 'yaksh:test_quiz' 'usermode' quiz.id course_id %}" target="blank"> - User Mode + Try as student </a> - <a class="btn btn-outline-info" name="button" href="{% url 'yaksh:test_quiz' 'godmode' quiz.id course_id %}" target="blank"> - God Mode + <a class="btn btn-outline-primary" name="button" href="{% url 'yaksh:test_quiz' 'godmode' quiz.id course_id %}" target="blank"> + Try as teacher </a> <a data-toggle="modal" data-target="#help"> <span class="text-info"><i class="fa fa-info-circle"></i> Help</span></a> @@ -88,13 +88,13 @@ </div> <div class="modal-body"> <p> - <b>User Mode:</b> Attempt quiz the way normal users will attempt i.e. - + <b>Try as student:</b> Attempt quiz the way students will attempt i.e. - <ul class="list-group list-group-flush"> <li class="list-group-item">Quiz will have the same duration as that of the original quiz.</li> <li class="list-group-item">Quiz won't start if the course is inactive or the quiz time has expired.</li> <li class="list-group-item">You will be notified about quiz prerequisites.(You can still attempt the quiz though)</li> </ul> - <b>God Mode:</b> Attempt quiz without any time or eligibilty constraints. + <b>Try as teacher:</b> Attempt quiz without any time or eligibilty constraints. </p> </div> </div> diff --git a/yaksh/templates/yaksh/design_questionpaper.html b/yaksh/templates/yaksh/design_questionpaper.html index ffbdf5f..fcc3ed5 100644 --- a/yaksh/templates/yaksh/design_questionpaper.html +++ b/yaksh/templates/yaksh/design_questionpaper.html @@ -114,12 +114,47 @@ <div id="fixed-available-wrapper"> <p><u>Select questions to add:</u></p> <div id="fixed-available"> - {% if state == "fixed" or state == "None" %} + {% if questions %} + {% if state == "fixed" or state == "None" %} + <ul class="inputs-list"> + <h5><input id="add_checkall" name="add_checkall" type="checkbox"> Select All </h5> + {% for question in questions %} + <li> + <label> + <input type="checkbox" name="questions" data-qid="{{question.id}}" value={{question.id}}> + <span> + {% if user == question.user %} + <a href="{% url 'yaksh:add_question' question.id %}" target="_blank">{{ question.summary }}</a> + {% else %} + {{question.summary}} + {% endif %} + </span> + <span> {{ question.points }}</span> + </label> + </li> + {% endfor %} + </ul> + {% endif %} + {% endif %} + </div> + </div> + <br /> + <button id="add-fixed" name="add-fixed" class="btn btn-success pull-right" type="submit"> + <i class="fa fa-plus-square"></i> Add to paper + </button> + </div> + <div class="col-md-6"> + <div id="fixed-added-wrapper"> + <p><u>Fixed questions currently in paper:</u></p> + <div id="fixed-added"> + {% if fixed_questions %} <ul class="inputs-list"> - {% for question in questions %} + <h5><input id="remove_checkall" type="checkbox"> Select All </h5> + {% for question in fixed_questions %} <li> <label> - <input type="checkbox" name="questions" data-qid="{{question.id}}" value={{question.id}}> + <input type="checkbox" name="added-questions" + data-qid="{{question.id}}" value={{question.id}}> <span> {% if user == question.user %} <a href="{% url 'yaksh:add_question' question.id %}" target="_blank">{{ question.summary }}</a> @@ -127,7 +162,7 @@ {{question.summary}} {% endif %} </span> - <span> {{ question.points }}</span> + <span> {{ question.points }} </span> </label> </li> {% endfor %} @@ -136,35 +171,6 @@ </div> </div> <br /> - <button id="add-fixed" name="add-fixed" class="btn btn-success pull-right" type="submit"> - <i class="fa fa-plus-square"></i> Add to paper - </button> - </div> - <div class="col-md-6"> - <div id="fixed-added-wrapper"> - <p><u>Fixed questions currently in paper:</u></p> - <div id="fixed-added"> - <ul class="inputs-list"> - {% for question in fixed_questions %} - <li> - <label> - <input type="checkbox" name="added-questions" - data-qid="{{question.id}}" value={{question.id}}> - <span> - {% if user == question.user %} - <a href="{% url 'yaksh:add_question' question.id %}" target="_blank">{{ question.summary }}</a> - {% else %} - {{question.summary}} - {% endif %} - </span> - <span> {{ question.points }} </span> - </label> - </li> - {% endfor %} - </ul> - </div> - </div> - <br /> <button id="remove-fixed" name="remove-fixed" class="btn btn-danger pull-right" type="submit"> <i class="fa fa-minus-square"></i> Remove from paper </button> diff --git a/yaksh/templates/yaksh/micromanaged.html b/yaksh/templates/yaksh/micromanaged.html new file mode 100644 index 0000000..336feec --- /dev/null +++ b/yaksh/templates/yaksh/micromanaged.html @@ -0,0 +1,22 @@ +{% if micromanagers %} +<hr> +<div class="row"> + {% for micromanager in micromanagers %} + {% if micromanager.attempts_permitted > 0 %} + <div class="col-md-8"> + <p> You have been given a special attempt to the {{ micromanager.quiz.description }} by the course creator</p> + </div> + <div class="col-md-3"> + {% if micromanager.can_student_attempt %} + <a class="btn btn-success" href="{% url 'yaksh:special_start' micromanager.id %}"> + Start Special Attempt + </a> + {% else %} + <span class="badge badge-secondary">Exhausted</span> + {% endif %} + </div> + {% endif %} + {% endfor %} +{% endif %} +</div> + diff --git a/yaksh/templates/yaksh/micromonitor.html b/yaksh/templates/yaksh/micromonitor.html new file mode 100644 index 0000000..cc059aa --- /dev/null +++ b/yaksh/templates/yaksh/micromonitor.html @@ -0,0 +1,9 @@ +{% if micromanager %} + {% if micromanager.can_student_attempt %} + <a class="btn btn-danger" href="{% url 'yaksh:revoke_special_attempt' micromanager.id %}">Revoke</a> + {% else %} + <a class="btn btn-success" href="{% url 'yaksh:allow_special_attempt' user_id course_id quiz_id %}">Allow </a> + {% endif %} +{% else %} + <a class="btn btn-success" href="{% url 'yaksh:allow_special_attempt' user_id course_id quiz_id %}">Allow </a> +{% endif %} diff --git a/yaksh/templates/yaksh/monitor.html b/yaksh/templates/yaksh/monitor.html index ef7b033..183ba99 100644 --- a/yaksh/templates/yaksh/monitor.html +++ b/yaksh/templates/yaksh/monitor.html @@ -74,6 +74,18 @@ $(document).ready(function() </div> </div> <br> + <br> + {% if messages %} + {% for message in messages %} + <div class="alert alert-dismissible alert-{{ message.tags }}"> + <button type="button" class="close" data-dismiss="alert"> + <i class="fa fa-close"></i> + </button> + <strong>{{ message }}</strong> + </div> + {% endfor %} + {% endif %} + <br> <div class="row"> <div class="col-md-4"> <a href="{% url 'yaksh:show_statistics' papers.0.question_paper.id course.id %}" class="btn btn-primary"> @@ -102,8 +114,9 @@ $(document).ready(function() <th> Institute <i class="fa fa-sort"></i> </th> <th> Marks <i class="fa fa-sort"></i> </th> <th> Attempts <i class="fa fa-sort"></i> </th> - <th> Time <i class="fa fa-sort"></i> </th> + <th> Time Left <i class="fa fa-sort"></i> </th> <th> Status <i class="fa fa-sort"></i> </th> + <th> Special Attempt <i class="fa fa-sort"></i> </th> </tr> </thead> <tbody> @@ -118,7 +131,20 @@ $(document).ready(function() <td> {{ paper.marks_obtained }} </td> <td> {{ paper.answers.count }} </td> <td id="time_left{{forloop.counter0}}"> {{ paper.time_left }} </td> - <td id="status{{forloop.counter0}}">{{ paper.status }}</td> + <td> {% if paper.is_attempt_inprogress %} + <form method="post" action="{% url 'yaksh:extend_time' paper.id %}"> + {% csrf_token %} + <div class="form-group"> + <label for="extra_time"> Time in mins </label> + <input type="number" class="form-control" id="extra_time" name="extra_time" required> + </div> + <button type="submit" class="btn btn-primary">Extend Time</button> + </form> + {% else %} + <span class="badge badge-secondary"> Completed </span> + {% endif %} + </td> + <td>{% specail_attempt_monitor paper.user.id course.id quiz.id %}</td> </tr> {% endfor %} </tbody> @@ -126,7 +152,6 @@ $(document).ready(function() <!-- CSV Modal --> <div class="modal fade" id="csvModal" role="dialog"> <div class="modal-dialog"> - <!-- Modal content--> <div class="modal-content"> <div class="modal-header"> diff --git a/yaksh/templates/yaksh/question.html b/yaksh/templates/yaksh/question.html index ae2f9f4..3f7e67e 100644 --- a/yaksh/templates/yaksh/question.html +++ b/yaksh/templates/yaksh/question.html @@ -127,14 +127,14 @@ question_type = "{{ question.type }}"; <button type="button" class="close" data-dismiss="alert"> <i class="fa fa-close"></i> </button> - <strong>Note:</strong> {{ notification }} + {{ notification }} </div> {% else %} <div id="notification" class="alert alert-info col-md-8" role="alert"> <button type="button" class="close" data-dismiss="alert"> <i class="fa fa-close"></i> </button> - <strong>Note:</strong> {{ notification }} + {{ notification }} </div> {% endif %} {% else %} @@ -239,21 +239,21 @@ question_type = "{{ question.type }}"; <!-- Integer type question --> {% if question.type == "integer" %} Enter Integer:<br/> - <input autofocus class="form-control" name="answer" type="number" id="integer" value="{{ last_attempt|safe }}" /> + <input autofocus class="form-control" name="answer" type="number" id="integer" value="{{ last_attempt|to_integer }}" /> <br><br> {% endif %} <!-- String type question --> {% if question.type == "string" %} Enter Text:<br/> - <textarea autofocus name="answer" id="string" class="form-control" style="width: 100%">{{ last_attempt|safe }}</textarea> + <textarea autofocus name="answer" id="string" class="form-control" style="width: 100%">{{ last_attempt|to_str }}</textarea> <br/><br/> {% endif %} <!-- Float type question --> {% if question.type == "float" %} Enter Decimal Value :<br/> - <input autofocus class="form-control" name="answer" type="number" step="any" id="float" value="{{ last_attempt|safe }}" /> + <input autofocus class="form-control" name="answer" type="number" step="any" id="float" value="{{ last_attempt|to_float }}" /> <br/><br/> {% endif %} diff --git a/yaksh/templates/yaksh/quit.html b/yaksh/templates/yaksh/quit.html index ccb0893..a801ea8 100644 --- a/yaksh/templates/yaksh/quit.html +++ b/yaksh/templates/yaksh/quit.html @@ -56,7 +56,11 @@ {% csrf_token %} <center> <button class="btn btn-outline-success btn-lg" type="submit" name="yes">Yes</button> + {% if paper.is_special %} + <a class="btn btn-outline-danger btn-lg" name="no" href="{% url 'yaksh:skip_question' paper.questions.first.id paper.attempt_number module_id paper.question_paper.id course_id %}">No</a> + {% else %} <a class="btn btn-outline-danger btn-lg" name="no" href="{% url 'yaksh:start_quiz' paper.attempt_number module_id paper.question_paper.id course_id %}">No</a> + {% endif %} </center> </form> {% endblock content %} diff --git a/yaksh/templates/yaksh/quizzes_user.html b/yaksh/templates/yaksh/quizzes_user.html index a9f5a43..e28cb69 100644 --- a/yaksh/templates/yaksh/quizzes_user.html +++ b/yaksh/templates/yaksh/quizzes_user.html @@ -1,4 +1,5 @@ {% extends "user.html" %} +{% load custom_filters %} {% block title %} Student Dashboard {% endblock %} {% block script %} @@ -104,6 +105,7 @@ {% endif %} </div> </div> + {% show_special_attempt user.id course.data.id %} </div> <div id="collapse{{course.data.id}}" class="collapse hide" data-parent="#accordion"> <div class="card-body"> diff --git a/yaksh/templates/yaksh/show_video.html b/yaksh/templates/yaksh/show_video.html index a2edbe0..9c8d133 100644 --- a/yaksh/templates/yaksh/show_video.html +++ b/yaksh/templates/yaksh/show_video.html @@ -1,8 +1,12 @@ {% extends "user.html" %} +{% load static %} {% load custom_filters %} {% block title %} {{ learning_module.name }} {% endblock %} - +{% block script %} +<script type="text/javascript" src="{% static 'yaksh/js/mathjax/MathJax.js' %}?config=TeX-MML-AM_CHTML"> +</script> +{% endblock %} {% block main %} <div class="wrapper"> <!-- Sidebar --> diff --git a/yaksh/templates/yaksh/statistics_question.html b/yaksh/templates/yaksh/statistics_question.html index 58fd8db..52c29d3 100644 --- a/yaksh/templates/yaksh/statistics_question.html +++ b/yaksh/templates/yaksh/statistics_question.html @@ -1,9 +1,10 @@ {% extends "manage.html" %} +{% block title %} Question Statistics {% endblock %} {% block pagetitle %} Statistics for {{ quiz.description }}{% endblock pagetitle %} {% block content %} -<div class="container"> +<div class="container-fluid"> <div class="row"> <div class="col-md-2"> <ul class="list-group"> @@ -18,13 +19,121 @@ <div class="col-md-9"> {% if question_stats %} <p><b>Total number of participants: {{ total }}</b></p> - <table class="table table-bordered table-responsive-sm"> - <tr class="bg-light yakshred"><th>Question</th><th>Type</th><th>Total</th><th>Answered</th></tr> + <table class="table table-responsive-sm"> + <tr class="bg-light yakshred"><th>Question</th><th></th><th>Type</th><th>Total</th><th>Answered Correctly</th></tr> {% for question, value in question_stats.items %} - <tr><td>{{ question.summary }}</td><td>{{ question.type }}</td><td>{{value.1}}</td><td>{{ value.0 }} ({% widthratio value.0 value.1 100 %}%)</td></tr> + <tr> + <td width="45%">{{ question.summary }} + <div class="collapse" id="collapse_question_{{question.id}}"> + <br> + <div class="card card-body"> + <strong> + Summary: + </strong> + <p> + {{ question.summary }} + </p> + <strong> + Description: + </strong> + <p> + {{ question.description|safe }} + </p> + <strong> + Points: + </strong> + <p> + {{ question.points }} + </p> + <strong> + Type: + </strong> + <p> + {{ question.get_type_display }} + </p> + {% if question.type in 'mcq mcc' %} + <strong> + Test Cases: + </strong> + <p> + <ol> + {% for tc in question.testcase_set.all %} + <li> + {{ tc.mcqtestcase.options }} + {% if tc.mcqtestcase.correct %} + <span class="badge badge-primary">Correct</span> + {% endif %} + </li> + {% endfor %} + </ol> + </p> + {% endif %} + </div> + </div> + </td> + <td> + <button class="btn btn-outline-primary" type="button" data-toggle="collapse" data-target="#collapse_question_{{question.id}}" aria-expanded="false" aria-controls="collapseExample"> + <i class="fa fa-angle-down"></i> More + </button> + </td> + <td>{{ question.type }}</td> + <td>{{value.1}}</td><td>{{ value.0 }} ({% widthratio value.0 value.1 100 %}%)</td> + + + </tr> {% endfor %} </table> {% endif %} + + <!-- The Modal --> + <div class="modal" id="question_detail_modal"> + <div class="modal-dialog"> + <div class="modal-content"> + + <!-- Modal Header --> + <div class="modal-header"> + <h4 class="modal-title">Question Details</h4> + <button type="button" class="close" data-dismiss="modal">×</button> + </div> + + <!-- Modal body --> + <div class="modal-body"> + <table> + <tr> + <td>Summary</td> + <td>{{ question.summary }}</td> + </tr> + <tr> + <td>Description</td> + <td>{{ question.description }}</td> + </tr> <tr> + <td>Type</td> + <td>{{ question.type }}</td> + </tr> <tr> + <td>Points</td> + <td>{{ question.points }}</td> + </tr> + <tr> + {% for tc in question.testcase_set.all %} + tc + {% endfor %} + <br><br> + </tr> + </table> + </div> + + <!-- Modal footer --> + <div class="modal-footer"> + <button type="button" class="btn btn-danger" data-dismiss="modal">Close</button> + </div> + + </div> + </div> + </div> + + </div> + </div> + <!-- end Modal outer --> </div> </div> </div> diff --git a/yaksh/templatetags/custom_filters.py b/yaksh/templatetags/custom_filters.py index 7a065eb..2a01787 100644 --- a/yaksh/templatetags/custom_filters.py +++ b/yaksh/templatetags/custom_filters.py @@ -10,6 +10,7 @@ except ImportError: from pygments import highlight from pygments.lexers import get_lexer_by_name from pygments.formatters import HtmlFormatter +from yaksh.models import User, Course, Quiz register = template.Library() @@ -122,3 +123,45 @@ def highlight_spaces(text): return text.replace( " ", '<span style="background-color:#ffb6db"> </span>' ) + + +@register.filter(name="to_integer") +def to_integer(text): + try: + value = int(text) + except ValueError: + value = '' + return value + + +@register.filter(name="to_float") +def to_float(text): + try: + value = float(text) + except ValueError: + value = '' + return value + + +@register.filter(name="to_str") +def to_str(text): + return text.decode("utf-8") + + +@register.inclusion_tag('yaksh/micromanaged.html') +def show_special_attempt(user_id, course_id): + user = User.objects.get(pk=user_id) + micromanagers = user.micromanaged.filter(course_id=course_id) + context = {'micromanagers': micromanagers} + return context + + +@register.inclusion_tag('yaksh/micromonitor.html') +def specail_attempt_monitor(user_id, course_id, quiz_id): + user = User.objects.get(pk=user_id) + micromanagers = user.micromanaged.filter(course_id=course_id, + quiz_id=quiz_id) + context = {'user_id': user_id, 'course_id': course_id, 'quiz_id': quiz_id} + if micromanagers.exists(): + context['micromanager'] = micromanagers.first() + return context diff --git a/yaksh/test_models.py b/yaksh/test_models.py index 37baf6e..e24a38e 100644 --- a/yaksh/test_models.py +++ b/yaksh/test_models.py @@ -5,7 +5,7 @@ from yaksh.models import User, Profile, Question, Quiz, QuestionPaper,\ QuestionSet, AnswerPaper, Answer, Course, StandardTestCase,\ StdIOBasedTestCase, FileUpload, McqTestCase, AssignmentUpload,\ LearningModule, LearningUnit, Lesson, LessonFile, CourseStatus, \ - create_group, legend_display_types, Post, Comment + create_group, legend_display_types, Post, Comment, MicroManager from yaksh.code_server import ( ServerPool, get_result as get_result_from_code_server ) @@ -27,7 +27,7 @@ from yaksh import settings def setUpModule(): - Group.objects.create(name='moderator') + Group.objects.get_or_create(name='moderator') # create user profile user = User.objects.create_user(username='creator', @@ -103,7 +103,8 @@ def setUpModule(): course.save() LessonFile.objects.create(lesson=lesson) CourseStatus.objects.create(course=course, user=course_user) - + MicroManager.objects.create(manager=user, course=course, quiz=quiz, + student=course_user) def tearDownModule(): User.objects.all().delete() @@ -116,6 +117,7 @@ def tearDownModule(): LearningUnit.objects.all().delete() LearningModule.objects.all().delete() AnswerPaper.objects.all().delete() + MicroManager.objects.all().delete() Group.objects.all().delete() @@ -129,6 +131,141 @@ class GlobalMethodsTestCases(unittest.TestCase): ############################################################################### +class MicroManagerTestCase(unittest.TestCase): + def setUp(self): + self.micromanager = MicroManager.objects.first() + self.course = self.micromanager.course + quiz = self.micromanager.quiz + self.questionpaper = QuestionPaper.objects.create(quiz=quiz) + question = Question.objects.get(summary='Q1') + self.questionpaper.fixed_questions.add(question) + self.questionpaper.update_total_marks() + self.student = User.objects.get(username='course_user') + + def tearDown(self): + self.questionpaper.delete() + + def test_micromanager(self): + # Given + user = User.objects.get(username='creator') + course = Course.objects.get(name='Python Course', creator=user) + quiz = Quiz.objects.get(description='demo quiz 1') + student = User.objects.get(username='course_user') + + # When + micromanager = MicroManager.objects.first() + + # Then + self.assertIsNotNone(micromanager) + self.assertEqual(micromanager.manager, user) + self.assertEqual(micromanager.student, student) + self.assertEqual(micromanager.course, course) + self.assertEqual(micromanager.quiz, quiz) + self.assertFalse(micromanager.special_attempt) + self.assertEqual(micromanager.attempts_permitted, 0) + self.assertEqual(micromanager.attempts_utilised, 0) + self.assertEqual(micromanager.wait_time, 0) + self.assertEqual(micromanager.attempt_valid_for, 90) + self.assertEqual(user.micromanaging.first(), micromanager) + self.assertEqual(student.micromanaged.first(), micromanager) + + def test_set_wait_time(self): + # Given + micromanager = self.micromanager + + # When + micromanager.set_wait_time(days=2) + + # Then + self.assertEqual(micromanager.wait_time, 2) + + def self_increment_attempts_permitted(self): + # Given + micromanager = self.micromanager + + # When + micromanager.increment_attempts_permitted() + + # Then + self.assertEqual(micromanager.attempts_permitted, 1) + + def test_update_permitted_time(self): + # Given + micromanager = self.micromanager + permit_time = timezone.now() + + # When + micromanager.update_permitted_time(permit_time) + + # Then + self.assertEqual(micromanager.permitted_time, permit_time) + + def test_has_student_attempts_exhausted(self): + # Given + micromanager = self.micromanager + + # Then + self.assertFalse(micromanager.has_student_attempts_exhausted()) + + def test_has_quiz_time_exhausted(self): + # Given + micromanager = self.micromanager + + # Then + self.assertFalse(micromanager.has_quiz_time_exhausted()) + + def test_is_special_attempt_required(self): + # Given + micromanager = self.micromanager + attempt = 1 + ip = '127.0.0.1' + + # Then + self.assertFalse(micromanager.is_special_attempt_required()) + + # When + answerpaper = self.questionpaper.make_answerpaper(self.student, ip, + attempt, + self.course.id) + answerpaper.update_marks(state='completed') + + # Then + self.assertTrue(micromanager.is_special_attempt_required()) + + answerpaper.delete() + + def test_allow_special_attempt(self): + # Given + micromanager = self.micromanager + + # When + micromanager.allow_special_attempt() + + # Then + self.assertFalse(micromanager.special_attempt) + + def test_has_special_attempt(self): + # Given + micromanager = self.micromanager + + # Then + self.assertFalse(micromanager.has_special_attempt()) + + def test_is_attempt_time_valid(self): + # Given + micromanager = self.micromanager + + # Then + self.assertTrue(micromanager.is_attempt_time_valid()) + + def test_can_student_attempt(self): + # Given + micromanager = self.micromanager + + # Then + self.assertFalse(micromanager.can_student_attempt()) + + class LessonTestCases(unittest.TestCase): def setUp(self): self.lesson = Lesson.objects.get(name='L1') @@ -842,7 +979,11 @@ class QuestionPaperTestCases(unittest.TestCase): total_marks=0.0, shuffle_questions=True ) - + self.question_paper_with_time_between_attempts.fixed_question_order = \ + "{0}, {1}".format(self.questions[3].id, self.questions[5].id) + self.question_paper_with_time_between_attempts.fixed_questions.add( + self.questions[3], self.questions[5] + ) self.question_paper.fixed_question_order = "{0}, {1}".format( self.questions[3].id, self.questions[5].id ) @@ -1030,7 +1171,7 @@ class QuestionPaperTestCases(unittest.TestCase): qu_list = [str(self.questions_list[0]), str(self.questions_list[1])] trial_paper = \ QuestionPaper.objects.create_trial_paper_to_test_quiz( - self.trial_quiz, self.quiz.id + self.trial_quiz, self.quiz_with_time_between_attempts.id ) trial_paper.random_questions.add(self.question_set_1) trial_paper.random_questions.add(self.question_set_2) diff --git a/yaksh/test_views.py b/yaksh/test_views.py index a7ccac2..df408bb 100644 --- a/yaksh/test_views.py +++ b/yaksh/test_views.py @@ -2755,6 +2755,33 @@ class TestCourseDetail(TestCase): id=uploaded_users.first().id).exists() ) + def test_upload_existing_user_email(self): + # Given + self.client.login( + username=self.user1.username, password=self.user1_plaintext_pass) + csv_file_path = os.path.join(FIXTURES_DIR_PATH, + 'user_existing_email.csv') + csv_file = open(csv_file_path, 'rb') + upload_file = SimpleUploadedFile(csv_file_path, csv_file.read()) + csv_file.close() + + # When + response = self.client.post( + reverse('yaksh:upload_users', + kwargs={'course_id': self.user1_course.id}), + data={'csv_file': upload_file}) + + # Then + uploaded_users = User.objects.filter(email='demo_student@test.com') + self.assertEqual(response.status_code, 302) + messages = [m.message for m in get_messages(response.wsgi_request)] + self.assertIn('demo_student', messages[0]) + self.assertTrue( + self.user1_course.students.filter( + id=uploaded_users.first().id).exists() + ) + self.assertEqual(uploaded_users.count(), 1) + def test_upload_users_add_update_reject(self): # Given self.client.login( @@ -5937,12 +5964,11 @@ class TestQuestionPaper(TestCase): 'add-random': ['']} ) - self.assertEqual(response.status_code, 200) - self.assertTemplateUsed(response, 'yaksh/design_questionpaper.html') - random_set = response.context['random_sets'][0] - added_random_ques = random_set.questions.all() - self.assertIn(self.random_que1, added_random_ques) - self.assertIn(self.random_que2, added_random_ques) + self.assertEqual(response.status_code, 302) + self.assertTrue( + self.question_paper.random_questions.filter( + id__in=[self.random_que1.id, self.random_que2.id]).exists() + ) # Check if questions already exists self.client.login( @@ -5969,10 +5995,11 @@ class TestQuestionPaper(TestCase): data={'checked_ques': [self.fixed_que.id], 'add-fixed': ''} ) - self.assertEqual(response.status_code, 200) - self.assertEqual(response.context['qpaper'], self.fixed_question_paper) - self.assertEqual(response.context['fixed_questions'][0], - self.fixed_que) + self.assertEqual(response.status_code, 302) + self.assertTrue( + self.fixed_question_paper.fixed_questions.filter( + id=self.fixed_que.id).exists() + ) # Add one more fixed question in question paper response = self.client.post( @@ -5983,10 +6010,11 @@ class TestQuestionPaper(TestCase): data={'checked_ques': [self.question_float.id], 'add-fixed': ''} ) - self.assertEqual(response.status_code, 200) - self.assertEqual(response.context['qpaper'], self.fixed_question_paper) - self.assertEqual(response.context['fixed_questions'], - [self.fixed_que, self.question_float]) + self.assertEqual(response.status_code, 302) + self.assertTrue( + self.fixed_question_paper.fixed_questions.filter( + id=self.question_float.id).exists() + ) # Remove fixed question from question paper response = self.client.post( @@ -5997,10 +6025,11 @@ class TestQuestionPaper(TestCase): data={'added-questions': [self.fixed_que.id], 'remove-fixed': ''} ) - self.assertEqual(response.status_code, 200) - self.assertEqual(response.context['qpaper'], self.fixed_question_paper) - self.assertEqual(response.context['fixed_questions'], - [self.question_float]) + self.assertEqual(response.status_code, 302) + self.assertFalse( + self.fixed_question_paper.fixed_questions.filter( + id=self.fixed_que.id).exists() + ) # Remove one more fixed question from question paper response = self.client.post( @@ -6011,9 +6040,11 @@ class TestQuestionPaper(TestCase): data={'added-questions': [self.question_float.id], 'remove-fixed': ''} ) - self.assertEqual(response.status_code, 200) - self.assertEqual(response.context['qpaper'], self.fixed_question_paper) - self.assertEqual(response.context['fixed_questions'], []) + self.assertEqual(response.status_code, 302) + self.assertFalse( + self.fixed_question_paper.fixed_questions.filter( + id=self.question_float.id).exists() + ) # Remove random questions from question paper random_que_set = self.question_paper.random_questions.all().first() @@ -6025,9 +6056,11 @@ class TestQuestionPaper(TestCase): data={'random_sets': random_que_set.id, 'remove-random': ''} ) - self.assertEqual(response.status_code, 200) - self.assertEqual(response.context['qpaper'], self.question_paper) - self.assertEqual(len(response.context['random_sets']), 0) + self.assertEqual(response.status_code, 302) + self.assertFalse( + self.question_paper.random_questions.filter( + id=random_que_set.id).exists() + ) class TestLearningModule(TestCase): diff --git a/yaksh/urls.py b/yaksh/urls.py index 0639b25..13e46fc 100644 --- a/yaksh/urls.py +++ b/yaksh/urls.py @@ -237,4 +237,13 @@ urlpatterns = [ views.mark_notification, name="mark_notification"), path('mark/notifications', views.mark_notification, name="mark_notification"), + url(r'^manage/micromanager/allow_special_attempt/(?P<user_id>\d+)/' + '(?P<course_id>\d+)/(?P<quiz_id>\d+)/$', + views.allow_special_attempt, name='allow_special_attempt'), + url(r'^micromanager/special_start/(?P<micromanager_id>\d+)/$', + views.special_start, name='special_start'), + url(r'^manage/micromanager/special_revoke/(?P<micromanager_id>\d+)/$', + views.revoke_special_attempt, name='revoke_special_attempt'), + url(r'^manage/extend_time/(?P<paper_id>\d+)/$', + views.extend_time, name='extend_time'), ] diff --git a/yaksh/views.py b/yaksh/views.py index 3adb536..01f5e4e 100644 --- a/yaksh/views.py +++ b/yaksh/views.py @@ -3,7 +3,7 @@ import csv from django.http import HttpResponse, JsonResponse, HttpResponseRedirect from django.contrib.auth import login, logout, authenticate from django.shortcuts import render, get_object_or_404, redirect -from django.template import Context, Template +from django.template import Context, Template, loader from django.http import Http404 from django.db.models import Max, Q, F from django.db import models @@ -23,7 +23,7 @@ from django.urls import reverse import json from textwrap import dedent import zipfile -from markdown import Markdown +import markdown try: from StringIO import StringIO as string_io except ImportError: @@ -37,7 +37,8 @@ from yaksh.models import ( QuestionPaper, QuestionSet, Quiz, Question, StandardTestCase, StdIOBasedTestCase, StringTestCase, TestCase, User, get_model_class, FIXTURES_DIR_PATH, MOD_GROUP_NAME, Lesson, LessonFile, - LearningUnit, LearningModule, CourseStatus, question_types, Post, Comment + LearningUnit, LearningModule, CourseStatus, question_types, Post, Comment, + MicroManager ) from yaksh.forms import ( UserRegisterForm, UserLoginForm, QuizForm, QuestionForm, @@ -101,7 +102,9 @@ CSV_FIELDS = ['name', 'username', 'roll_number', 'institute', 'department', def get_html_text(md_text): """Takes markdown text and converts it to html""" - return Markdown().convert(md_text) + return markdown.markdown( + md_text, extensions=['tables', 'fenced_code'] + ) def formfield_callback(field): @@ -481,6 +484,46 @@ def user_login(request): @login_required @email_verified +def special_start(request, micromanager_id=None): + user = request.user + micromanager = get_object_or_404(MicroManager, pk=micromanager_id, + student=user) + course = micromanager.course + quiz = micromanager.quiz + module = course.get_learning_module(quiz) + quest_paper = get_object_or_404(QuestionPaper, quiz=quiz) + + if not course.is_enrolled(user): + msg = 'You are not enrolled in {0} course'.format(course.name) + return quizlist_user(request, msg=msg) + + if not micromanager.can_student_attempt(): + msg = 'Your special attempts are exhausted for {0}'.format( + quiz.description) + return quizlist_user(request, msg=msg) + + last_attempt = AnswerPaper.objects.get_user_last_attempt( + quest_paper, user, course.id) + + if last_attempt: + if last_attempt.is_attempt_inprogress(): + return show_question( + request, last_attempt.current_question(), last_attempt, + course_id=course.id, module_id=module.id, + previous_question=last_attempt.current_question() + ) + + attempt_num = micromanager.get_attempt_number() + ip = request.META['REMOTE_ADDR'] + new_paper = quest_paper.make_answerpaper(user, ip, attempt_num, course.id, + special=True) + micromanager.increment_attempts_utilised() + return show_question(request, new_paper.current_question(), new_paper, + course_id=course.id, module_id=module.id) + + +@login_required +@email_verified def start(request, questionpaper_id=None, attempt_num=None, course_id=None, module_id=None): """Check the user cedentials and if any quiz is available, @@ -641,7 +684,7 @@ def show_question(request, question, paper, error_message=None, request, msg, paper.attempt_number, paper.question_paper.id, course_id=course_id, module_id=module_id ) - if not quiz.active: + if not quiz.active and not paper.is_special: reason = 'The quiz has been deactivated!' return complete( request, reason, paper.attempt_number, paper.question_paper.id, @@ -819,7 +862,7 @@ def check(request, q_id, attempt_num=None, questionpaper_id=None, previous_question=current_question) else: user_answer = request.POST.get('answer') - if not user_answer: + if not str(user_answer): msg = "Please submit a valid answer." return show_question( request, current_question, paper, notification=msg, @@ -1444,6 +1487,13 @@ def design_questionpaper(request, course_id, quiz_id, questionpaper_id=None): question_paper.save() question_paper.fixed_questions.add(*questions) messages.success(request, "Questions added successfully") + return redirect( + 'yaksh:designquestionpaper', + course_id=course_id, + quiz_id=quiz_id, + questionpaper_id=questionpaper_id + ) + else: messages.warning(request, "Please select atleast one question") @@ -1462,6 +1512,12 @@ def design_questionpaper(request, course_id, quiz_id, questionpaper_id=None): question_paper.save() question_paper.fixed_questions.remove(*question_ids) messages.success(request, "Questions removed successfully") + return redirect( + 'yaksh:designquestionpaper', + course_id=course_id, + quiz_id=quiz_id, + questionpaper_id=questionpaper_id + ) else: messages.warning(request, "Please select atleast one question") @@ -1476,6 +1532,12 @@ def design_questionpaper(request, course_id, quiz_id, questionpaper_id=None): random_set.questions.add(*random_ques) question_paper.random_questions.add(random_set) messages.success(request, "Questions removed successfully") + return redirect( + 'yaksh:designquestionpaper', + course_id=course_id, + quiz_id=quiz_id, + questionpaper_id=questionpaper_id + ) else: messages.warning(request, "Please select atleast one question") @@ -1484,6 +1546,12 @@ def design_questionpaper(request, course_id, quiz_id, questionpaper_id=None): if random_set_ids: question_paper.random_questions.remove(*random_set_ids) messages.success(request, "Questions removed successfully") + return redirect( + 'yaksh:designquestionpaper', + course_id=course_id, + quiz_id=quiz_id, + questionpaper_id=questionpaper_id + ) else: messages.warning(request, "Please select question set") @@ -1500,8 +1568,8 @@ def design_questionpaper(request, course_id, quiz_id, questionpaper_id=None): if questions: questions = _remove_already_present(questionpaper_id, questions) - question_paper.update_total_marks() - question_paper.save() + question_paper.update_total_marks() + question_paper.save() random_sets = question_paper.random_questions.all() fixed_questions = question_paper.get_ordered_questions() context = { @@ -2409,8 +2477,10 @@ def _read_user_csv(request, reader, course): messages.info(request, "{0} -- Missing Values".format(counter)) continue users = User.objects.filter(username=username) + if not users.exists(): + users = User.objects.filter(email=email) if users.exists(): - user = users[0] + user = users.last() if remove.strip().lower() == 'true': _remove_from_course(user, course) messages.info(request, "{0} -- {1} -- User rejected".format( @@ -3420,3 +3490,93 @@ def hide_comment(request, course_id, uuid): comment.active = False comment.save() return redirect('yaksh:post_comments', course_id, post_uid) + + +@login_required +@email_verified +def allow_special_attempt(request, user_id, course_id, quiz_id): + user = request.user + + if not is_moderator(user): + raise Http404('You are not allowed to view this page') + + course = get_object_or_404(Course, pk=course_id) + if not course.is_creator(user) and not course.is_teacher(user): + raise Http404('This course does not belong to you') + + quiz = get_object_or_404(Quiz, pk=quiz_id) + student = get_object_or_404(User, pk=user_id) + + if not course.is_enrolled(student): + raise Http404('The student is not enrolled for this course') + + micromanager, created = MicroManager.objects.get_or_create( + course=course, student=student, quiz=quiz + ) + micromanager.manager = user + micromanager.save() + + if (not micromanager.is_special_attempt_required() or + micromanager.is_last_attempt_inprogress()): + name = student.get_full_name() + msg = '{} can attempt normally. No special attempt required!'.format( + name) + elif micromanager.can_student_attempt(): + msg = '{} already has a special attempt!'.format( + student.get_full_name()) + else: + micromanager.allow_special_attempt() + msg = 'A special attempt is provided to {}!'.format( + student.get_full_name()) + + messages.info(request, msg) + return my_redirect('/exam/manage/monitor/{0}/{1}/'.format(quiz_id, + course_id)) + + +@login_required +@email_verified +def revoke_special_attempt(request, micromanager_id): + user = request.user + + if not is_moderator(user): + raise Http404('You are not allowed to view this page') + + micromanager = get_object_or_404(MicroManager, pk=micromanager_id) + course = micromanager.course + if not course.is_creator(user) and not course.is_teacher(user): + raise Http404('This course does not belong to you') + micromanager.revoke_special_attempt() + msg = 'Revoked special attempt for {}'.format( + micromanager.student.get_full_name()) + messages.info(request, msg) + return my_redirect('/exam/manage/monitor/{0}/{1}/'.format( + micromanager.quiz.id, course.id)) + + +@login_required +@email_verified +def extend_time(request, paper_id): + user = request.user + + if not is_moderator(user): + raise Http404('You are not allowed to view this page') + + anspaper = get_object_or_404(AnswerPaper, pk=paper_id) + course = anspaper.course + if not course.is_creator(user) and not course.is_teacher(user): + raise Http404('This course does not belong to you') + + if request.method == "POST": + extra_time = request.POST.get('extra_time', None) + if extra_time is None: + msg = 'Please provide time' + else: + anspaper.set_extra_time(extra_time) + msg = 'Extra {0} minutes given to {1}'.format( + extra_time, anspaper.user.get_full_name()) + else: + msg = 'Bad Request' + messages.info(request, msg) + return my_redirect('/exam/manage/monitor/{0}/{1}/'.format( + anspaper.question_paper.quiz.id, course.id)) |