diff options
-rw-r--r-- | CHANGELOG.txt | 5 | ||||
-rw-r--r-- | Dockerfile | 18 | ||||
-rw-r--r-- | README.md | 58 | ||||
-rw-r--r-- | README_production.md | 7 | ||||
-rw-r--r-- | online_test/settings.py | 6 | ||||
-rw-r--r-- | requirements/requirements-codeserver.txt | 5 | ||||
-rw-r--r-- | requirements/requirements-common.txt | 6 | ||||
-rw-r--r-- | tasks.py | 95 | ||||
-rw-r--r-- | yaksh/models.py | 20 | ||||
-rw-r--r-- | yaksh/scripts/yaksh_script.sh | 11 | ||||
-rw-r--r-- | yaksh/templates/yaksh/add_course.html | 14 | ||||
-rw-r--r-- | yaksh/templates/yaksh/monitor.html | 47 | ||||
-rw-r--r-- | yaksh/test_models.py | 74 | ||||
-rw-r--r-- | yaksh/test_views.py | 56 | ||||
-rw-r--r-- | yaksh/urls.py | 4 | ||||
-rw-r--r-- | yaksh/views.py | 131 |
16 files changed, 446 insertions, 111 deletions
diff --git a/CHANGELOG.txt b/CHANGELOG.txt index a687446..824a051 100644 --- a/CHANGELOG.txt +++ b/CHANGELOG.txt @@ -13,6 +13,11 @@ * UI changes in grade user, view answerpaper and monitor pages. * Added the facility to create users by uploading CSV. * Fixed a bug that would require shebang to be put in for Bash assertion based questions. +* Bug fixed that did not allow to edit a profile. +* CSV download for quiz attempts enhanced. +* Updated Courses Page to show Active Courses on top. +* Added a Datetime Picker to Add Course Page +* Added invoke script for quickstart and docker deployment === 0.6.0 (11-05-2017) === @@ -1,13 +1,11 @@ -FROM debian:8.2 +FROM ubuntu:16.04 MAINTAINER FOSSEE <pythonsupport@fossee.in> # Update Packages and Install Python & net-tools -RUN apt-get update && apt-get install -y python net-tools python-pip && pip install tornado - -# Copy the project folder from host into container -COPY ./yaksh /src/yaksh - -WORKDIR /src - -# Run Yaksh code server -CMD ["python", "-m", "yaksh.code_server"] +RUN apt-get update && \ +apt-get install -y software-properties-common && \ +echo oracle-java8-installer shared/accepted-oracle-license-v1-1 select true | debconf-set-selections && \ +add-apt-repository -y ppa:webupd8team/java && \ +apt-get update && \ +apt-get install -y oracle-java8-installer && \ +apt-get install -y sudo python net-tools git python3-pip vim libmysqlclient-dev scilab build-essential python3-numpy python3-scipy ipython3 ipython3-notebook python3-pandas python3-nose @@ -37,56 +37,52 @@ Quick Start #### Installation 1. Install yaksh - - For latest stable release + - Clone the repository - $ pip install yaksh + $ git clone https://github.com/FOSSEE/online_test.git - - For the development version + - Go to the online_test directory - $ pip install git+https://github.com/FOSSEE/online_test.git + $ cd ./online_test -#### Short instructions + - Install the dependencies + + - For Python 2 use: + + $ pip install -r ./requirements/requirements-py2.txt -To see a quick demo after installing yaksh do the following: + - For Python 3 (recommended) use: - $ yaksh create_demo yaksh_demo - $ yaksh run yaksh_demo + $ pip install -r ./requirements/requirements-py3.txt + +#### Short instructions -On another terminal start up the code server that executes the user code safely: +1. To run the application do the following: - $ sudo yaksh run_code_server + $ invoke serve -Now point your browser to ```http://localhost:8000/exam```. + - *Note:* The serve command will run the django application server on the 8000 port + and hence this port will be unavailable to other processes. -#### More detailed instructions +1. On another terminal start up the code server that executes the user code safely: -1. On the terminal run: + - To run the code server in a sandboxed docker environment, run the command: - $ yaksh create_demo [project_path] + $ invoke start - - `project_path` is the desired directory of the django project the - basename of which is also the Django project name. This can be a - relative directory. + - Make sure that you have Docker installed on your system beforehand. + [Docker Installation](https://docs.docker.com/engine/installation/#desktop) - - In case a `project_path` is not specified, the project is created - in a `yaksh_demo` subdirectory of the current directory. -1. The script does the following; - 1. Creates a new django project with name as the basename of the specified - `project_path` - 1. Creates a new demo database. - 1. Creates two users, teacher and student. - 1. Loads demo questions. - 1. Loads demo quiz. + - To run the code server without docker, locally use: -1. To run the server, run: + $ invoke start --unsafe - $ yaksh run relpath/or/abspath/to/demo + - Note this command will run the yaksh code server locally on your machine + and is susceptible to malicious code. You will have to install the code + server requirements in sudo mode. -1. In a new terminal run the following command which executes user submitted - code safely: - $ sudo yaksh run_code_server 1. Open your browser and open the URL ```http://localhost:8000/exam``` diff --git a/README_production.md b/README_production.md index ed19523..8b79785 100644 --- a/README_production.md +++ b/README_production.md @@ -167,19 +167,18 @@ To install this app follow the steps below: #### Using Dockerized Code Server - 1. Install [Docker] (https://github.com/FOSSEE/online_test/blob/master/README.md) + 1. Install [Docker](https://github.com/FOSSEE/online_test/blob/master/README.md) 1. Got to the directory where the project is located cd /path/to/online_test 1. Create a docker image. This may take a few minutes - docker build -t yaksha:v1 . + docker build -t yaksh_code_server . 1. Check if the image has been created using the output of, docker images - 1. Run a container using the newly created image. - sudo docker run -d -p 53579:53579 -p 8001:8001 yaksha:v1 + 1. Run the invoke script using the command ```invoke start``` The command will create and run a new docker container (that is running the code_server.py within it), it will also bind the ports of the host with those of the container #### Additional commands available diff --git a/online_test/settings.py b/online_test/settings.py index 90cce9d..790083e 100644 --- a/online_test/settings.py +++ b/online_test/settings.py @@ -15,7 +15,7 @@ BASE_DIR = os.path.dirname(os.path.dirname(__file__)) # The directory where user data can be saved. This directory will be # world-writable and all user code will be written and saved here by the # code server with each user having their own sub-directory. -OUTPUT_DIR = os.path.join(BASE_DIR, 'output') +OUTPUT_DIR = os.path.join(BASE_DIR, "yaksh_data", "output") # Quick-start development settings - unsuitable for production # See https://docs.djangoproject.com/en/1.6/howto/deployment/checklist/ @@ -33,8 +33,6 @@ URL_ROOT = '' # Application definition -FIXTURE_DIRS = os.path.join(BASE_DIR, "yaksh", "fixtures") - INSTALLED_APPS = ( 'django.contrib.admin', 'django.contrib.auth', @@ -100,7 +98,7 @@ SOCIAL_AUTH_LOGIN_ERROR_URL = '/exam/login/' MEDIA_URL = "/data/" -MEDIA_ROOT = os.path.join(BASE_DIR, "yaksh", "data") +MEDIA_ROOT = os.path.join(BASE_DIR, "yaksh_data", "data") # Set this varable to <True> if smtp-server is not allowing to send email. EMAIL_USE_TLS = False diff --git a/requirements/requirements-codeserver.txt b/requirements/requirements-codeserver.txt new file mode 100644 index 0000000..a4f419c --- /dev/null +++ b/requirements/requirements-codeserver.txt @@ -0,0 +1,5 @@ +pytest +six +requests +tornado +psutil diff --git a/requirements/requirements-common.txt b/requirements/requirements-common.txt index 100d693..b4d2e5b 100644 --- a/requirements/requirements-common.txt +++ b/requirements/requirements-common.txt @@ -1,9 +1,9 @@ +-r requirements-codeserver.txt +invoke==0.21.0 django==1.9.5 django-taggit==0.18.1 pytz==2016.4 python-social-auth==0.2.19 -tornado selenium==2.53.6 coverage -psutil -ruamel.yaml==0.15.23 +ruamel.yaml==0.15.23
\ No newline at end of file diff --git a/tasks.py b/tasks.py new file mode 100644 index 0000000..eabf8fb --- /dev/null +++ b/tasks.py @@ -0,0 +1,95 @@ +import invoke +from invoke import task +import os +from yaksh.settings import SERVER_POOL_PORT + +SCRIPT_DIR = os.path.abspath(os.path.dirname(__file__)) +TARGET_CONTAINER_NAME = 'yaksh_code_server' +SRC_IMAGE_NAME = 'fossee/yaksh_codeserver' + +def create_dir(path): + if not os.path.exists(path): + os.makedirs(path) + +@task +def setupdb(ctx): + print("** Setting up & migrating database **") + ctx.run("python manage.py makemigrations") + ctx.run("python manage.py migrate") + ctx.run("python manage.py loaddata demo_fixtures.json") + +@task(setupdb) +def serve(ctx): + print("** Running the Django web server. Press Ctrl-C to Exit **") + ctx.run("python manage.py runserver") + +@task +def clean(ctx): + print("** Discarding database **") + ctx.run("rm -rf {0}".format(os.path.join(SCRIPT_DIR, 'db.sqlite3'))) + +@task +def getimage(ctx, image=SRC_IMAGE_NAME): + try: + result = ctx.run("sudo docker inspect {0}".format(image), hide=True) + except invoke.exceptions.Failure: + print("The docker image {0} does not exist locally".format(image)) + print("\n** Pulling latest image <{0}> from docker hub **".format(image)) + ctx.run("sudo docker pull {0}".format(image)) + +@task +def start(ctx, ports=SERVER_POOL_PORT, image=SRC_IMAGE_NAME, unsafe=False, + version=3): + if unsafe: + with ctx.cd(SCRIPT_DIR): + print("** Initializing local code server **") + ctx.run("sudo python{0} -m yaksh.code_server".format( + version + ) + ) + else: + cmd_params = {'ports': ports, + 'image': SRC_IMAGE_NAME, + 'name': TARGET_CONTAINER_NAME, + 'vol_mount': os.path.join(SCRIPT_DIR, 'yaksh_data'), + 'command': 'sh {0}'.format( + os.path.join(SCRIPT_DIR, + 'yaksh_data', 'yaksh', 'scripts', 'yaksh_script.sh') + ) + } + + getimage(ctx, image=SRC_IMAGE_NAME) + + print("** Preparing code server **") + create_dir(os.path.join(SCRIPT_DIR, 'yaksh_data', 'data')) + create_dir(os.path.join(SCRIPT_DIR, 'yaksh_data', 'output')) + + ctx.run('cp -r {0} {1}'.format( + os.path.join(SCRIPT_DIR, 'yaksh'), + os.path.join(SCRIPT_DIR, 'yaksh_data') + ) + ) + ctx.run('cp {0} {1}'.format( + os.path.join(SCRIPT_DIR, 'requirements', 'requirements-codeserver.txt'), + os.path.join(SCRIPT_DIR, 'yaksh_data') + ) + ) + + print("** Initializing code server within docker container **") + ctx.run( + "sudo docker run \ + -dp {ports}:{ports} --name={name} \ + -v {vol_mount}:{vol_mount} \ + -w {vol_mount} \ + {image} {command}".format(**cmd_params) + ) + +@task +def stop(ctx, container=TARGET_CONTAINER_NAME, hide=True): + result = ctx.run("sudo docker ps -q --filter='name={0}'".format(container)) + if result.stdout: + print ("** Discarding the docker container <{0}>".format(container)) + ctx.run("sudo docker stop {0}".format(container)) + ctx.run("sudo docker rm {0}".format(container)) + else: + print("** Docker container <{0}> not found **".format(container)) diff --git a/yaksh/models.py b/yaksh/models.py index d02b6d6..d698232 100644 --- a/yaksh/models.py +++ b/yaksh/models.py @@ -82,6 +82,8 @@ test_status = ( ('completed', 'Completed'), ) +FIXTURES_DIR_PATH = os.path.join(settings.BASE_DIR, 'yaksh', 'fixtures') + def get_assignment_dir(instance, filename): upload_dir = instance.question_paper.quiz.description.replace(" ", "_") @@ -544,7 +546,7 @@ class Question(models.Model): def create_demo_questions(self, user): zip_file_path = os.path.join( - settings.FIXTURE_DIRS, 'demo_questions.zip' + FIXTURES_DIR_PATH, 'demo_questions.zip' ) files, extract_path = extract_files(zip_file_path) self.read_yaml(extract_path, user, files) @@ -840,6 +842,13 @@ class QuestionPaper(models.Model): objects = QuestionPaperManager() + def get_question_bank(self): + ''' Gets all the questions in the question paper''' + questions = list(self.fixed_questions.all()) + for random_set in self.random_questions.all(): + questions += list(random_set.questions.all()) + return questions + def _create_duplicate_questionpaper(self, quiz): new_questionpaper = QuestionPaper.objects.create(quiz=quiz, shuffle_questions=self.shuffle_questions, @@ -1201,6 +1210,15 @@ class AnswerPaper(models.Model): objects = AnswerPaperManager() + def get_per_question_score(self, question_id): + if question_id not in self.get_questions().values_list('id', flat=True): + return 'NA' + answer = self.get_latest_answer(question_id) + if answer: + return answer.marks + else: + return 0 + def current_question(self): """Returns the current active question to display.""" unanswered_questions = self.questions_unanswered.all() diff --git a/yaksh/scripts/yaksh_script.sh b/yaksh/scripts/yaksh_script.sh new file mode 100644 index 0000000..f39153e --- /dev/null +++ b/yaksh/scripts/yaksh_script.sh @@ -0,0 +1,11 @@ +#!/bin/bash +# Basic script to install pip packages and run the yaksh code server command + +chown -R nobody output +chmod -R a+rwX output +chmod -R a+rX data yaksh +chmod -R o-w data yaksh +echo "** Installing python dependencies **" +pip3 install -r ./requirements-codeserver.txt +echo "** Running code server **" +/usr/bin/sudo -su nobody python3 -m yaksh.code_server diff --git a/yaksh/templates/yaksh/add_course.html b/yaksh/templates/yaksh/add_course.html index 55683f4..b8fc11c 100644 --- a/yaksh/templates/yaksh/add_course.html +++ b/yaksh/templates/yaksh/add_course.html @@ -4,6 +4,11 @@ {% block css %} <link rel="stylesheet" href="{{ URL_ROOT }}/static/yaksh/css/question_quiz.css" type="text/css" /> +<link rel="stylesheet" href="{{ URL_ROOT }}/static/yaksh/css/jquery.datetimepicker.css" type="text/css" /> +{% endblock %} + +{% block script %} +<script src="{{ URL_ROOT }}/static/yaksh/js/jquery.datetimepicker.full.min.js"></script> {% endblock %} {% block content %} @@ -13,9 +18,14 @@ <table class=span1> {{ form.as_table }} </table> + <br/> + <script type="text/javascript"> + $("#id_start_enroll_time").datetimepicker({format: 'Y-m-d H:i:s'}); + $("#id_end_enroll_time").datetimepicker({format: 'Y-m-d H:i:s'}); + </script> </center> - <center><button class="btn primary" type="submit" id="submit" name="questionpaper">Add Course</button> - <button class="btn primary" type="button" name="button" onClick='location.replace("{{URL_ROOT}}/exam/manage/");'>Cancel</button> </center> + <center><button class="btn btn-default" type="submit" id="submit" name="questionpaper">Save </button> + <button class="btn btn-default" type="button" name="button" onClick='location.replace("{{URL_ROOT}}/exam/manage/");'>Cancel</button> </center> </form> {% endblock %} diff --git a/yaksh/templates/yaksh/monitor.html b/yaksh/templates/yaksh/monitor.html index 9ce0dc4..8df2e7d 100644 --- a/yaksh/templates/yaksh/monitor.html +++ b/yaksh/templates/yaksh/monitor.html @@ -76,7 +76,10 @@ $(document).ready(function() <p>Papers in progress:<b> {{ inprogress_papers }} </b></p> <p><a href="{{URL_ROOT}}/exam/manage/statistics/question/{{papers.0.question_paper.id}}">Question Statisitics</a></p> -<p><a href="{{URL_ROOT}}/exam/manage/monitor/download_csv/{{papers.0.question_paper.id}}">Download CSV</a></p> +<p> + <button type="button" class="btn btn-info btn-lg" data-toggle="modal" data-target="#csvModal"> + Download CSV <span class="glyphicon glyphicon-save"></span> + </button></p> <table id="result-table" class="tablesorter table table table-striped"> <thead> <tr> @@ -115,4 +118,46 @@ $(document).ready(function() <h4>No Quiz Found</h4> {% endif %} {% endif %} + +<!-- CSV Modal --> +<div class="modal fade" id="csvModal" role="dialog"> + <div class="modal-dialog"> + + <!-- Modal content--> + <div class="modal-content"> + <div class="modal-header"> + <button type="button" class="close" data-dismiss="modal">×</button> + <h3 class="modal-title">Uncheck unwanted columns</h3> + </div> + <form action="{{URL_ROOT}}/exam/manage/download_quiz_csv/{{ quiz.course.id }}/{{ quiz.id }}/" method="post"> + {% csrf_token %} + <div class="modal-body"> + {% for field in csv_fields %} + <div class="form-check form-check-inline"> + <label class="form-check-label"> + <input class="form-check-input" name="csv_fields" type="checkbox" value="{{ field }}" checked> {{ field }} + </label> + </div> + {% endfor %} + <b>Select Attempt Number: Default latest attempt</b> + <select class="form-control" name = "attempt_number"> + {%for attempt_number in attempt_numbers %} + {% if forloop.last %} + <option value="{{ attempt_number }}" selected>{{ attempt_number }} (Latest)</option> + {% else %} + <option value = "{{ attempt_number }}"> {{ attempt_number }}</option> + {% endif %} + {% endfor %} + </select> + </div> + <div class="modal-footer"> + <button type="submit" class="btn btn-primary"> Download <span class="glyphicon glyphicon-save"></span></button> + <button type="button" class="btn btn-default" data-dismiss="modal">Close</button> + </div> + </form> + </div> + </div> +</div> + + {% endblock %} diff --git a/yaksh/test_models.py b/yaksh/test_models.py index 71969fc..ddacb2a 100644 --- a/yaksh/test_models.py +++ b/yaksh/test_models.py @@ -384,6 +384,26 @@ class QuestionPaperTestCases(unittest.TestCase): self.questions = Question.objects.filter(active=True) self.quiz = Quiz.objects.get(description="demo quiz 1") + # create question paper with only fixed questions + self.question_paper_fixed_questions = QuestionPaper.objects.create( + quiz=self.quiz) + self.question_paper_fixed_questions.fixed_questions.add( + self.questions.get(summary='Q11'), self.questions.get(summary='Q10')) + + # create question paper with only random questions + self.question_paper_random_questions = QuestionPaper.objects.create( + quiz=self.quiz) + self.question_set_random = QuestionSet.objects.create(marks=2, + num_questions=2) + self.question_set_random.questions.add(self.questions.get(summary='Q13'), + self.questions.get(summary='Q5'), self.questions.get(summary='Q7')) + self.question_paper_random_questions.random_questions.add( + self.question_set_random) + + # create question paper with no questions + self.question_paper_no_questions = QuestionPaper.objects.create( + quiz=self.quiz) + # create question paper self.question_paper = QuestionPaper.objects.create(quiz=self.quiz, total_marks=0.0, @@ -441,6 +461,31 @@ class QuestionPaperTestCases(unittest.TestCase): self.trial_course = Course.objects.create_trial_course(self.user) self.trial_quiz = Quiz.objects.create_trial_quiz(self.trial_course, self.user) + + def test_get_question_bank(self): + # Given + summaries = ['Q11', 'Q10'] + questions = list(Question.objects.filter(summary__in=summaries)) + # When + question_bank = self.question_paper_fixed_questions.get_question_bank() + # Then + self.assertSequenceEqual(questions, question_bank) + + # Given + summaries = ['Q13','Q5','Q7'] + questions = list(Question.objects.filter(summary__in=summaries)) + # When + question_bank = self.question_paper_random_questions.get_question_bank() + # Then + self.assertSequenceEqual(questions, question_bank) + + # Given + questions = [] + # When + question_bank = self.question_paper_no_questions.get_question_bank() + # Then + self.assertSequenceEqual(questions, question_bank) + def test_questionpaper(self): """ Test question paper""" self.assertEqual(self.question_paper.quiz.description, 'demo quiz 1') @@ -630,7 +675,7 @@ class AnswerPaperTestCases(unittest.TestCase): error=json.dumps([]) ) self.single_answer.save() - self.answerpaper.answers.add(self.single_answer) + self.answerpaper_single_question.answers.add(self.single_answer) self.question1.language = 'python' self.question1.test_case_type = 'standardtestcase' @@ -691,6 +736,31 @@ class AnswerPaperTestCases(unittest.TestCase): self.user2, self.ip, 1 ) + def test_get_per_question_score(self): + # Given + question_id = self.question4.id + expected_score = 1 + # When + score = self.answerpaper_single_question.get_per_question_score(question_id) + # Then + self.assertEqual(score, expected_score) + + # Given + question_id = self.question2.id + expected_score = 0 + # When + score = self.answerpaper.get_per_question_score(question_id) + # Then + self.assertEqual(score, expected_score) + + # Given + question_id = 131 + expected_score = 'NA' + # When + score = self.answerpaper.get_per_question_score(question_id) + # Then + self.assertEqual(score, expected_score) + def test_returned_question_is_not_none(self): # Test add_completed_question and next_question # When all questions are answered @@ -976,7 +1046,7 @@ class AnswerPaperTestCases(unittest.TestCase): first_answer_obj = first_answer['answer'] self.assertEqual(first_answer_obj.answer, 'Demo answer') self.assertTrue(first_answer_obj.correct) - self.assertEqual(len(answered), 3) + self.assertEqual(len(answered), 2) def test_is_answer_correct(self): self.assertTrue(self.answerpaper.is_answer_correct(self.questions[0])) diff --git a/yaksh/test_views.py b/yaksh/test_views.py index e27d5ef..465fb30 100644 --- a/yaksh/test_views.py +++ b/yaksh/test_views.py @@ -23,7 +23,7 @@ from django.core.files.uploadedfile import SimpleUploadedFile from yaksh.models import User, Profile, Question, Quiz, QuestionPaper,\ QuestionSet, AnswerPaper, Answer, Course, StandardTestCase,\ AssignmentUpload, FileUpload, McqTestCase, IntegerTestCase, StringTestCase,\ - FloatTestCase + FloatTestCase, FIXTURES_DIR_PATH from yaksh.decorators import user_has_profile @@ -196,6 +196,38 @@ class TestProfile(TestCase): self.assertEqual(response.status_code, 200) self.assertTemplateUsed(response, 'yaksh/profile_updated.html') + def test_edit_profile_post_for_user_without_profile(self): + """ + POST request to edit_profile view should update the user's profile + """ + self.client.login( + username=self.user1.username, + password=self.user1_plaintext_pass + ) + response = self.client.post(reverse('yaksh:edit_profile'), + data={ + 'user': self.user1, + 'first_name': 'new_first_name', + 'last_name': 'new_last_name', + 'roll_number': 21, + 'institute': 'new_institute', + 'department': 'Aerospace', + 'position': 'new_position', + 'timezone': 'UTC' + } + ) + updated_profile_user = User.objects.get(id=self.user1.id) + updated_profile = Profile.objects.get(user=updated_profile_user) + self.assertEqual(updated_profile_user.first_name, 'new_first_name') + self.assertEqual(updated_profile_user.last_name, 'new_last_name') + self.assertEqual(updated_profile.roll_number, '21') + self.assertEqual(updated_profile.institute, 'new_institute') + self.assertEqual(updated_profile.department, 'Aerospace') + self.assertEqual(updated_profile.position, 'new_position') + + self.assertEqual(response.status_code, 200) + self.assertTemplateUsed(response, 'yaksh/profile_updated.html') + def test_edit_profile_get(self): """ GET request to edit profile should display profile form @@ -3041,6 +3073,7 @@ class TestDownloadcsv(TestCase): self.mod_group.user_set.add(self.user) self.course = Course.objects.create(name="Python Course", enrollment="Enroll Request", creator=self.user) + self.course.students.add(self.student) self.quiz = Quiz.objects.create( start_date_time=datetime(2014, 10, 9, 10, 8, 15, 0, tzone), @@ -3093,8 +3126,9 @@ class TestDownloadcsv(TestCase): username=self.student.username, password=self.student_plaintext_pass ) - response = self.client.get(reverse('yaksh:download_csv', - kwargs={"questionpaper_id": self.question_paper.id}), + response = self.client.get(reverse('yaksh:download_quiz_csv', + kwargs={"course_id": self.course.id, + "quiz_id": self.quiz.id}), follow=True ) self.assertEqual(response.status_code, 404) @@ -3122,8 +3156,9 @@ class TestDownloadcsv(TestCase): username=self.student.username, password=self.student_plaintext_pass ) - response = self.client.get(reverse('yaksh:download_csv', - kwargs={"questionpaper_id": self.question_paper.id}), + response = self.client.get(reverse('yaksh:download_quiz_csv', + kwargs={"course_id": self.course.id, + "quiz_id": self.quiz.id}), follow=True ) self.assertEqual(response.status_code, 404) @@ -3168,11 +3203,14 @@ class TestDownloadcsv(TestCase): username=self.user.username, password=self.user_plaintext_pass ) - response = self.client.get(reverse('yaksh:download_csv', - kwargs={'questionpaper_id': self.question_paper.id}), + response = self.client.get(reverse('yaksh:download_quiz_csv', + kwargs={"course_id": self.course.id, + "quiz_id": self.quiz.id}), + follow=True ) - file_name = "{0}.csv".format(self.quiz.description) + file_name = "{0}-{1}-attempt{2}.csv".format(self.course.name.replace('.', ''), + self.quiz.description.replace('.', ''), 1) self.assertEqual(response.status_code, 200) self.assertEqual(response.get('Content-Disposition'), 'attachment; filename="{0}"'.format(file_name)) @@ -3300,7 +3338,7 @@ class TestShowQuestions(TestCase): username=self.user.username, password=self.user_plaintext_pass ) - ques_file = os.path.join(settings.FIXTURE_DIRS, "demo_questions.zip") + ques_file = os.path.join(FIXTURES_DIR_PATH, "demo_questions.zip") f = open(ques_file, 'rb') questions_file = SimpleUploadedFile(ques_file, f.read(), content_type="application/zip") diff --git a/yaksh/urls.py b/yaksh/urls.py index a7aeecc..1a50ca2 100644 --- a/yaksh/urls.py +++ b/yaksh/urls.py @@ -56,8 +56,8 @@ urlpatterns = [ views.show_statistics, name="show_statistics"), url(r'^manage/statistics/question/(?P<questionpaper_id>\d+)/(?P<attempt_number>\d+)/$', views.show_statistics, name="show_statistics"), - url(r'^manage/monitor/download_csv/(?P<questionpaper_id>\d+)/$', - views.download_csv, name="download_csv"), + url(r'^manage/download_quiz_csv/(?P<course_id>\d+)/(?P<quiz_id>\d+)/$', + views.download_quiz_csv, name="download_quiz_csv"), url(r'^manage/duplicate_course/(?P<course_id>\d+)/$', views.duplicate_course, name='duplicate_course'), url(r'manage/courses/$', views.courses, name='courses'), diff --git a/yaksh/views.py b/yaksh/views.py index 838041e..71b4abe 100644 --- a/yaksh/views.py +++ b/yaksh/views.py @@ -80,6 +80,10 @@ def add_to_group(users): user.groups.add(group) +CSV_FIELDS = ['name', 'username', 'roll_number', 'institute', 'department', + 'questions', 'marks_obtained', 'out_of', 'percentage', 'status'] + + @email_verified def index(request, next_url=None): """The start page. @@ -792,8 +796,10 @@ def courses(request): ci = RequestContext(request) if not is_moderator(user): raise Http404('You are not allowed to view this page') - courses = Course.objects.filter(creator=user, is_trial=False) - allotted_courses = Course.objects.filter(teachers=user, is_trial=False) + courses = Course.objects.filter( + creator=user, is_trial=False).order_by('-active', '-id') + allotted_courses = Course.objects.filter( + teachers=user, is_trial=False).order_by('-active', '-id') context = {'courses': courses, "allotted_courses": allotted_courses} return my_render_to_response('yaksh/courses.html', context, context_instance=ci) @@ -991,7 +997,12 @@ def monitor(request, quiz_id=None): papers = [] q_paper = None latest_attempts = [] + attempt_numbers = [] else: + if q_paper: + attempt_numbers = AnswerPaper.objects.get_attempt_numbers(q_paper.last().id) + else: + attempt_numbers = [] latest_attempts = [] papers = AnswerPaper.objects.filter(question_paper=q_paper).order_by( 'user__profile__roll_number' @@ -1007,11 +1018,14 @@ def monitor(request, quiz_id=None): attempt_number=last_attempt['last_attempt_num'] ) ) + csv_fields = CSV_FIELDS context = { "papers": papers, "quiz": quiz, "msg": "Quiz Results", - "latest_attempts": latest_attempts + "latest_attempts": latest_attempts, + "csv_fields": csv_fields, + "attempt_numbers": attempt_numbers } return my_render_to_response('yaksh/monitor.html', context, context_instance=ci) @@ -1264,49 +1278,80 @@ def user_data(request, user_id, questionpaper_id=None): context_instance=RequestContext(request)) +def _expand_questions(questions, field_list): + i = field_list.index('questions') + field_list.remove('questions') + for question in questions: + field_list.insert(i, '{0}-{1}'.format(question.summary, question.points)) + return field_list + + @login_required @email_verified -def download_csv(request, questionpaper_id): - user = request.user - if not is_moderator(user): +def download_quiz_csv(request, course_id, quiz_id): + current_user = request.user + if not is_moderator(current_user): raise Http404('You are not allowed to view this page!') - quiz = Quiz.objects.get(questionpaper=questionpaper_id) + course = get_object_or_404(Course, id=course_id) + quiz = get_object_or_404(Quiz, id=quiz_id) + if not course.is_creator(current_user) and not course.is_teacher(current_user): + raise Http404('The quiz does not belong to your course') + users = course.get_enrolled().order_by('first_name') + if not users: + return monitor(request, quiz_id) + csv_fields = [] + attempt_number = None + question_paper = quiz.questionpaper_set.last() + last_attempt_number =AnswerPaper.objects.get_attempt_numbers(question_paper.id).last() + if request.method == 'POST': + csv_fields = request.POST.getlist('csv_fields') + attempt_number = request.POST.get('attempt_number', last_attempt_number) + if not csv_fields: + csv_fields = CSV_FIELDS + if not attempt_number: + attempt_number = last_attempt_number + + questions = question_paper.get_question_bank() + answerpapers = AnswerPaper.objects.filter(question_paper=question_paper, + attempt_number=attempt_number) + if not answerpapers: + return monitor(request, quiz_id) - if not quiz.course.is_creator(user) and not quiz.course.is_teacher(user): - raise Http404('The question paper does not belong to your course') - papers = AnswerPaper.objects.get_latest_attempts(questionpaper_id) - if not papers: - return monitor(request, questionpaper_id) response = HttpResponse(content_type='text/csv') - response['Content-Disposition'] = 'attachment; filename="{0}.csv"'.format( - (quiz.description).replace('.', '')) + response['Content-Disposition'] = 'attachment; filename="{0}-{1}-attempt{2}.csv"'.format( + course.name.replace('.', ''), quiz.description.replace('.', ''), + attempt_number) writer = csv.writer(response) - header = [ - 'name', - 'username', - 'roll_number', - 'institute', - 'marks_obtained', - 'total_marks', - 'percentage', - 'questions', - 'questions_answered', - 'status' - ] - writer.writerow(header) - for paper in papers: - row = [ - '{0} {1}'.format(paper.user.first_name, paper.user.last_name), - paper.user.username, - paper.user.profile.roll_number, - paper.user.profile.institute, - paper.marks_obtained, - paper.question_paper.total_marks, - paper.percent, - paper.questions.all(), - paper.questions_answered.all(), - paper.status - ] + if 'questions' in csv_fields: + csv_fields = _expand_questions(questions, csv_fields) + writer.writerow(csv_fields) + + csv_fields_values = {'name': 'user.get_full_name().title()', + 'roll_number': 'user.profile.roll_number', + 'institute': 'user.profile.institute', + 'department': 'user.profile.department', + 'username': 'user.username', + 'marks_obtained': 'answerpaper.marks_obtained', + 'out_of': 'question_paper.total_marks', + 'percentage': 'answerpaper.percent', 'status': 'answerpaper.status'} + questions_scores = {} + for question in questions: + questions_scores['{0}-{1}'.format(question.summary, question.points)] \ + = 'answerpaper.get_per_question_score({0})'.format(question.id) + csv_fields_values.update(questions_scores) + + users = users.exclude(id=course.creator.id).exclude(id__in=course.teachers.all()) + for user in users: + row = [] + answerpaper = None + papers = answerpapers.filter(user=user) + if papers: + answerpaper = papers.first() + for field in csv_fields: + try: + row.append(eval(csv_fields_values[field])) + except AttributeError: + row.append('-') writer.writerow(row) return response @@ -1406,7 +1451,6 @@ def view_profile(request): @login_required -@has_profile @email_verified def edit_profile(request): """ edit profile details facility for moderator and students """ @@ -1418,7 +1462,10 @@ def edit_profile(request): else: template = 'user.html' context = {'template': template} - profile = Profile.objects.get(user_id=user.id) + try: + profile = Profile.objects.get(user_id=user.id) + except Profile.DoesNotExist: + profile = None if request.method == 'POST': form = ProfileForm(request.POST, user=user, instance=profile) |