diff --git a/common/lib/xmodule/xmodule/course_module.py b/common/lib/xmodule/xmodule/course_module.py index 5d66956461..f8b287858c 100644 --- a/common/lib/xmodule/xmodule/course_module.py +++ b/common/lib/xmodule/xmodule/course_module.py @@ -559,6 +559,22 @@ class CourseFields(object): default=False, scope=Scope.settings) + course_survey_name = String( + display_name=_("Pre-Course Survey Name"), + help=_("Name of SurveyForm to display as a pre-course survey to the user."), + default=None, + scope=Scope.settings, + deprecated=True + ) + + course_survey_required = Boolean( + display_name=_("Pre-Course Survey Required"), + help=_("Specify whether students must complete a survey before they can view your course content. If you set this value to true, you must add a name for the survey to the Course Survey Name setting above."), + default=False, + scope=Scope.settings, + deprecated=True + ) + class CourseDescriptor(CourseFields, SequenceDescriptor): module_class = SequenceModule diff --git a/lms/djangoapps/courseware/tests/test_course_survey.py b/lms/djangoapps/courseware/tests/test_course_survey.py new file mode 100644 index 0000000000..ba79989f76 --- /dev/null +++ b/lms/djangoapps/courseware/tests/test_course_survey.py @@ -0,0 +1,157 @@ +""" +Python tests for the Survey workflows +""" + +from collections import OrderedDict + +from django.core.urlresolvers import reverse + +from survey.models import SurveyForm + +from xmodule.modulestore.tests.factories import CourseFactory +from courseware.tests.helpers import LoginEnrollmentTestCase + + +class SurveyViewsTests(LoginEnrollmentTestCase): + """ + All tests for the views.py file + """ + + STUDENT_INFO = [('view@test.com', 'foo')] + + def setUp(self): + """ + Set up the test data used in the specific tests + """ + super(SurveyViewsTests, self).setUp() + + self.test_survey_name = 'TestSurvey' + self.test_form = '' + + self.survey = SurveyForm.create(self.test_survey_name, self.test_form) + + self.student_answers = OrderedDict({ + u'field1': u'value1', + u'field2': u'value2', + }) + + self.course = CourseFactory.create( + course_survey_required=True, + course_survey_name=self.test_survey_name + ) + + self.course_with_bogus_survey = CourseFactory.create( + course_survey_required=True, + course_survey_name="DoesNotExist" + ) + + self.course_without_survey = CourseFactory.create() + + # Create student accounts and activate them. + for i in range(len(self.STUDENT_INFO)): + email, password = self.STUDENT_INFO[i] + username = 'u{0}'.format(i) + self.create_account(username, email, password) + self.activate_user(email) + + email, password = self.STUDENT_INFO[0] + self.login(email, password) + self.enroll(self.course, True) + self.enroll(self.course_without_survey, True) + self.enroll(self.course_with_bogus_survey, True) + + self.view_url = reverse('view_survey', args=[self.test_survey_name]) + self.postback_url = reverse('submit_answers', args=[self.test_survey_name]) + + def _assert_survey_redirect(self, course): + """ + Helper method to assert that all known redirect points do redirect as expected + """ + for view_name in ['courseware', 'info', 'progress']: + resp = self.client.get( + reverse( + view_name, + kwargs={'course_id': unicode(course.id)} + ) + ) + self.assertRedirects( + resp, + reverse('course_survey', kwargs={'course_id': unicode(course.id)}) + ) + + def _assert_no_redirect(self, course): + """ + Helper method to asswer that all known conditionally redirect points do + not redirect as expected + """ + for view_name in ['courseware', 'info', 'progress']: + resp = self.client.get( + reverse( + view_name, + kwargs={'course_id': unicode(course.id)} + ) + ) + self.assertEquals(resp.status_code, 200) + + def test_visiting_course_without_survey(self): + """ + Verifies that going to the courseware which does not have a survey does + not redirect to a survey + """ + self._assert_no_redirect(self.course_without_survey) + + def test_visiting_course_with_survey_redirects(self): + """ + Verifies that going to the courseware with an unanswered survey, redirects to the survey + """ + self._assert_survey_redirect(self.course) + + def test_visiting_course_with_existing_answers(self): + """ + Verifies that going to the courseware with an answered survey, there is no redirect + """ + resp = self.client.post( + self.postback_url, + self.student_answers + ) + self.assertEquals(resp.status_code, 200) + + self._assert_no_redirect(self.course) + + def test_visiting_course_with_bogus_survey(self): + """ + Verifies that going to the courseware with a required, but non-existing survey, does not redirect + """ + self._assert_no_redirect(self.course_with_bogus_survey) + + def test_visiting_survey_with_bogus_survey_name(self): + """ + Verifies that going to the courseware with a required, but non-existing survey, does not redirect + """ + + resp = self.client.get( + reverse( + 'course_survey', + kwargs={'course_id': unicode(self.course_with_bogus_survey.id)} + ) + ) + self.assertRedirects( + resp, + reverse('info', kwargs={'course_id': unicode(self.course_with_bogus_survey.id)}) + ) + + def test_visiting_survey_with_no_course_survey(self): + """ + Verifies that going to the courseware with a required, but non-existing survey, does not redirect + """ + + resp = self.client.get( + reverse( + 'course_survey', + kwargs={'course_id': unicode(self.course_without_survey.id)} + ) + ) + self.assertRedirects( + resp, + reverse('info', kwargs={'course_id': unicode(self.course_without_survey.id)}) + ) diff --git a/lms/djangoapps/courseware/views.py b/lms/djangoapps/courseware/views.py index 04d3e4135a..f337c7f180 100644 --- a/lms/djangoapps/courseware/views.py +++ b/lms/djangoapps/courseware/views.py @@ -56,6 +56,10 @@ from opaque_keys.edx.locations import SlashSeparatedCourseKey from instructor.enrollment import uses_shib from util.db import commit_on_success_with_read_committed + +import survey.utils +import survey.views + from util.views import ensure_valid_course_key log = logging.getLogger("edx.courseware") @@ -303,6 +307,7 @@ def index(request, course_id, chapter=None, section=None, def _index_bulk_op(request, user, course_key, chapter, section, position): course = get_course_with_access(user, 'load', course_key, depth=2) + staff_access = has_access(user, 'staff', course) registered = registered_for_course(course, user) if not registered: @@ -310,6 +315,11 @@ def _index_bulk_op(request, user, course_key, chapter, section, position): log.debug(u'User %s tried to view course %s but is not enrolled', user, course.location.to_deprecated_string()) return redirect(reverse('about_course', args=[course_key.to_deprecated_string()])) + # check to see if there is a required survey that must be taken before + # the user can access the course. + if survey.utils.must_answer_survey(course, user): + return redirect(reverse('course_survey', args=[unicode(course.id)])) + masq = setup_masquerade(request, staff_access) try: @@ -573,6 +583,12 @@ def course_info(request, course_id): with modulestore().bulk_operations(course_key): course = get_course_with_access(request.user, 'load', course_key) + + # check to see if there is a required survey that must be taken before + # the user can access the course. + if survey.utils.must_answer_survey(course, request.user): + return redirect(reverse('course_survey', args=[unicode(course.id)])) + staff_access = has_access(request.user, 'staff', course) masq = setup_masquerade(request, staff_access) # allow staff to toggle masquerade on info page reverifications = fetch_reverify_banner_info(request, course_key) @@ -838,6 +854,12 @@ def _progress(request, course_key, student_id): Course staff are allowed to see the progress of students in their class. """ course = get_course_with_access(request.user, 'load', course_key, depth=None, check_if_enrolled=True) + + # check to see if there is a required survey that must be taken before + # the user can access the course. + if survey.utils.must_answer_survey(course, request.user): + return redirect(reverse('course_survey', args=[unicode(course.id)])) + staff_access = has_access(request.user, 'staff', course) if student_id is None or student_id == request.user.id: @@ -1061,3 +1083,30 @@ def get_course_lti_endpoints(request, course_id): ] return HttpResponse(json.dumps(endpoints), content_type='application/json') + + +@login_required +def course_survey(request, course_id): + """ + URL endpoint to present a survey that is associated with a course_id + Note that the actual implementation of course survey is handled in the + views.py file in the Survey Djangoapp + """ + + course_key = SlashSeparatedCourseKey.from_deprecated_string(course_id) + course = get_course_with_access(request.user, 'load', course_key) + + redirect_url = reverse('info', args=[course_id]) + + # if there is no Survey associated with this course, + # then redirect to the course instead + if not course.course_survey_name: + return redirect(redirect_url) + + return survey.views.view_student_survey( + request.user, + course.course_survey_name, + course=course, + redirect_url=redirect_url, + is_required=course.course_survey_required, + ) diff --git a/lms/djangoapps/survey/__init__.py b/lms/djangoapps/survey/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/lms/djangoapps/survey/admin.py b/lms/djangoapps/survey/admin.py new file mode 100644 index 0000000000..3095c97001 --- /dev/null +++ b/lms/djangoapps/survey/admin.py @@ -0,0 +1,29 @@ +""" +Provide accessors to these models via the Django Admin pages +""" + +from django import forms +from django.contrib import admin +from survey.models import SurveyForm + + +class SurveyFormAdminForm(forms.ModelForm): # pylint: disable=R0924 + """Form providing validation of SurveyForm content.""" + + class Meta: # pylint: disable=C0111 + model = SurveyForm + fields = ('name', 'form') + + def clean_form(self): + """Validate the HTML template.""" + form = self.cleaned_data["form"] + SurveyForm.validate_form_html(form) + return form + + +class SurveyFormAdmin(admin.ModelAdmin): + """Admin for SurveyForm""" + form = SurveyFormAdminForm + + +admin.site.register(SurveyForm, SurveyFormAdmin) diff --git a/lms/djangoapps/survey/exceptions.py b/lms/djangoapps/survey/exceptions.py new file mode 100644 index 0000000000..6b883b35bf --- /dev/null +++ b/lms/djangoapps/survey/exceptions.py @@ -0,0 +1,17 @@ +""" +Specialized exceptions for the Survey Djangoapp +""" + + +class SurveyFormNotFound(Exception): + """ + Thrown when a SurveyForm is not found in the database + """ + pass + + +class SurveyFormNameAlreadyExists(Exception): + """ + Thrown when a SurveyForm is created but that name already exists + """ + pass diff --git a/lms/djangoapps/survey/migrations/0001_initial.py b/lms/djangoapps/survey/migrations/0001_initial.py new file mode 100644 index 0000000000..c2cb7f2307 --- /dev/null +++ b/lms/djangoapps/survey/migrations/0001_initial.py @@ -0,0 +1,97 @@ +# -*- coding: utf-8 -*- +import datetime +from south.db import db +from south.v2 import SchemaMigration +from django.db import models + + +class Migration(SchemaMigration): + + def forwards(self, orm): + # Adding model 'SurveyForm' + db.create_table('survey_surveyform', ( + ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)), + ('created', self.gf('model_utils.fields.AutoCreatedField')(default=datetime.datetime.now)), + ('modified', self.gf('model_utils.fields.AutoLastModifiedField')(default=datetime.datetime.now)), + ('name', self.gf('django.db.models.fields.CharField')(unique=True, max_length=255, db_index=True)), + ('form', self.gf('django.db.models.fields.TextField')()), + )) + db.send_create_signal('survey', ['SurveyForm']) + + # Adding model 'SurveyAnswer' + db.create_table('survey_surveyanswer', ( + ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)), + ('created', self.gf('model_utils.fields.AutoCreatedField')(default=datetime.datetime.now)), + ('modified', self.gf('model_utils.fields.AutoLastModifiedField')(default=datetime.datetime.now)), + ('user', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['auth.User'])), + ('form', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['survey.SurveyForm'])), + ('field_name', self.gf('django.db.models.fields.CharField')(max_length=255, db_index=True)), + ('field_value', self.gf('django.db.models.fields.CharField')(max_length=1024)), + )) + db.send_create_signal('survey', ['SurveyAnswer']) + + def backwards(self, orm): + # Deleting model 'SurveyForm' + db.delete_table('survey_surveyform') + + # Deleting model 'SurveyAnswer' + db.delete_table('survey_surveyanswer') + + models = { + 'auth.group': { + 'Meta': {'object_name': 'Group'}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}), + 'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}) + }, + 'auth.permission': { + 'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'}, + 'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '50'}) + }, + 'auth.user': { + 'Meta': {'object_name': 'User'}, + 'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}), + 'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), + 'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), + 'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}), + 'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}), + 'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'}) + }, + 'contenttypes.contenttype': { + 'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"}, + 'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'}) + }, + 'survey.surveyanswer': { + 'Meta': {'object_name': 'SurveyAnswer'}, + 'created': ('model_utils.fields.AutoCreatedField', [], {'default': 'datetime.datetime.now'}), + 'field_name': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}), + 'field_value': ('django.db.models.fields.CharField', [], {'max_length': '1024'}), + 'form': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['survey.SurveyForm']"}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'modified': ('model_utils.fields.AutoLastModifiedField', [], {'default': 'datetime.datetime.now'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}) + }, + 'survey.surveyform': { + 'Meta': {'object_name': 'SurveyForm'}, + 'created': ('model_utils.fields.AutoCreatedField', [], {'default': 'datetime.datetime.now'}), + 'form': ('django.db.models.fields.TextField', [], {}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'modified': ('model_utils.fields.AutoLastModifiedField', [], {'default': 'datetime.datetime.now'}), + 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '255', 'db_index': 'True'}) + } + } + + complete_apps = ['survey'] diff --git a/lms/djangoapps/survey/migrations/__init__.py b/lms/djangoapps/survey/migrations/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/lms/djangoapps/survey/models.py b/lms/djangoapps/survey/models.py new file mode 100644 index 0000000000..40e176bee4 --- /dev/null +++ b/lms/djangoapps/survey/models.py @@ -0,0 +1,224 @@ +""" +Models to support Course Surveys feature +""" + +import logging +from lxml import etree +from collections import OrderedDict +from django.db import models +from student.models import User +from django.core.exceptions import ValidationError + +from model_utils.models import TimeStampedModel + +from survey.exceptions import SurveyFormNameAlreadyExists, SurveyFormNotFound + +log = logging.getLogger("edx.survey") + + +class SurveyForm(TimeStampedModel): + """ + Model to define a Survey Form that contains the HTML form data + that is presented to the end user. A SurveyForm is not tied to + a particular run of a course, to allow for sharing of Surveys + across courses + """ + name = models.CharField(max_length=255, db_index=True, unique=True) + form = models.TextField() + + def __unicode__(self): + return self.name + + def save(self, *args, **kwargs): + """ + Override save method so we can validate that the form HTML is + actually parseable + """ + + self.validate_form_html(self.form) + + # now call the actual save method + super(SurveyForm, self).save(*args, **kwargs) + + @classmethod + def validate_form_html(cls, html): + """ + Makes sure that the html that is contained in the form field is valid + """ + try: + fields = cls.get_field_names_from_html(html) + except Exception as ex: + log.exception("Cannot parse SurveyForm html: {}".format(ex)) + raise ValidationError("Cannot parse SurveyForm as HTML: {}".format(ex)) + + if not len(fields): + raise ValidationError("SurveyForms must contain at least one form input field") + + @classmethod + def create(cls, name, form, update_if_exists=False): + """ + Helper class method to create a new Survey Form. + + update_if_exists=True means that if a form already exists with that name, then update it. + Otherwise throw an SurveyFormAlreadyExists exception + """ + + survey = cls.get(name, throw_if_not_found=False) + if not survey: + survey = SurveyForm(name=name, form=form) + else: + if update_if_exists: + survey.form = form + else: + raise SurveyFormNameAlreadyExists() + + survey.save() + return survey + + @classmethod + def get(cls, name, throw_if_not_found=True): + """ + Helper class method to look up a Survey Form, throw FormItemNotFound if it does not exists + in the database, unless throw_if_not_found=False then we return None + """ + + survey = None + exists = SurveyForm.objects.filter(name=name).exists() + if exists: + survey = SurveyForm.objects.get(name=name) + elif throw_if_not_found: + raise SurveyFormNotFound() + + return survey + + def get_answers(self, user=None, limit_num_users=10000): + """ + Returns all answers for all users for this Survey + """ + return SurveyAnswer.get_answers(self, user, limit_num_users=limit_num_users) + + def has_user_answered_survey(self, user): + """ + Returns whether a given user has supplied answers to this + survey + """ + return SurveyAnswer.do_survey_answers_exist(self, user) + + def save_user_answers(self, user, answers): + """ + Store answers to the form for a given user. Answers is a dict of simple + name/value pairs + + IMPORTANT: There is no validaton of form answers at this point. All data + supplied to this method is presumed to be previously validated + """ + SurveyAnswer.save_answers(self, user, answers) + + def get_field_names(self): + """ + Returns a list of defined field names for all answers in a survey. This can be + helpful for reporting like features, i.e. adding headers to the reports + This is taken from the set of fields inside the form. + """ + + return SurveyForm.get_field_names_from_html(self.form) + + @classmethod + def get_field_names_from_html(cls, html): + """ + Returns a list of defined field names from a block of HTML + """ + names = [] + + # make sure the form is wrap in some outer single element + # otherwise lxml can't parse it + # NOTE: This wrapping doesn't change the ability to query it + tree = etree.fromstring(u'
no input fields here
') + + with self.assertRaises(ValidationError): + SurveyForm.create('badform', '') + + def test_create_form_already_exists(self): + """ + Make sure we can't create two surveys of the same name + """ + + self._create_test_survey() + with self.assertRaises(SurveyFormNameAlreadyExists): + self._create_test_survey() + + def test_create_form_update_existing(self): + """ + Make sure we can update an existing form + """ + survey = self._create_test_survey() + self.assertIsNotNone(survey) + + survey = SurveyForm.create(self.test_survey_name, self.test_form_update, update_if_exists=True) + self.assertIsNotNone(survey) + + survey = SurveyForm.get(self.test_survey_name) + self.assertIsNotNone(survey) + self.assertEquals(survey.form, self.test_form_update) + + def test_survey_has_no_answers(self): + """ + Create a new survey and assert that there are no answers to that survey + """ + + survey = self._create_test_survey() + self.assertEquals(len(survey.get_answers()), 0) + + def test_user_has_no_answers(self): + """ + Create a new survey with no answers in it and check that a user is determined to not have answered it + """ + + survey = self._create_test_survey() + self.assertFalse(survey.has_user_answered_survey(self.student)) + self.assertEquals(len(survey.get_answers()), 0) + + def test_single_user_answers(self): + """ + Create a new survey and add answers to it + """ + + survey = self._create_test_survey() + self.assertIsNotNone(survey) + + survey.save_user_answers(self.student, self.student_answers) + + self.assertTrue(survey.has_user_answered_survey(self.student)) + + all_answers = survey.get_answers() + self.assertEquals(len(all_answers.keys()), 1) + self.assertTrue(self.student.id in all_answers) + self.assertEquals(all_answers[self.student.id], self.student_answers) + + answers = survey.get_answers(self.student) + self.assertEquals(len(answers.keys()), 1) + self.assertTrue(self.student.id in answers) + self.assertEquals(all_answers[self.student.id], self.student_answers) + + def test_multiple_user_answers(self): + """ + Create a new survey and add answers to it + """ + + survey = self._create_test_survey() + self.assertIsNotNone(survey) + + survey.save_user_answers(self.student, self.student_answers) + survey.save_user_answers(self.student2, self.student2_answers) + + self.assertTrue(survey.has_user_answered_survey(self.student)) + + all_answers = survey.get_answers() + self.assertEquals(len(all_answers.keys()), 2) + self.assertTrue(self.student.id in all_answers) + self.assertTrue(self.student2.id in all_answers) + self.assertEquals(all_answers[self.student.id], self.student_answers) + self.assertEquals(all_answers[self.student2.id], self.student2_answers) + + answers = survey.get_answers(self.student) + self.assertEquals(len(answers.keys()), 1) + self.assertTrue(self.student.id in answers) + self.assertEquals(all_answers[self.student.id], self.student_answers) + + answers = survey.get_answers(self.student2) + self.assertEquals(len(answers.keys()), 1) + self.assertTrue(self.student2.id in answers) + self.assertEquals(all_answers[self.student2.id], self.student2_answers) + + def test_limit_num_users(self): + """ + Verify that the limit_num_users parameter to get_answers() + works as intended + """ + survey = self._create_test_survey() + + survey.save_user_answers(self.student, self.student_answers) + survey.save_user_answers(self.student2, self.student2_answers) + + # even though we have 2 users submitted answers + # limit the result set to just 1 + all_answers = survey.get_answers(limit_num_users=1) + self.assertEquals(len(all_answers.keys()), 1) + + def test_get_field_names(self): + """ + Create a new survey and add answers to it + """ + + survey = self._create_test_survey() + self.assertIsNotNone(survey) + + survey.save_user_answers(self.student, self.student_answers) + survey.save_user_answers(self.student2, self.student2_answers) + + names = survey.get_field_names() + + self.assertEqual(sorted(names), ['ddl', 'field1', 'field2']) diff --git a/lms/djangoapps/survey/tests/test_utils.py b/lms/djangoapps/survey/tests/test_utils.py new file mode 100644 index 0000000000..e4472cd2ad --- /dev/null +++ b/lms/djangoapps/survey/tests/test_utils.py @@ -0,0 +1,116 @@ +""" +Python tests for the Survey models +""" + +from collections import OrderedDict + +from django.test import TestCase +from django.test.client import Client +from django.contrib.auth.models import User + +from survey.models import SurveyForm + +from xmodule.modulestore.tests.factories import CourseFactory + +from survey.utils import is_survey_required_for_course, must_answer_survey + + +class SurveyModelsTests(TestCase): + """ + All tests for the utils.py file + """ + def setUp(self): + """ + Set up the test data used in the specific tests + """ + self.client = Client() + + # Create two accounts + self.password = 'abc' + self.student = User.objects.create_user('student', 'student@test.com', self.password) + self.student2 = User.objects.create_user('student2', 'student2@test.com', self.password) + + self.staff = User.objects.create_user('staff', 'staff@test.com', self.password) + self.staff.is_staff = True + self.staff.save() + + self.test_survey_name = 'TestSurvey' + self.test_form = '' + + self.student_answers = OrderedDict({ + 'field1': 'value1', + 'field2': 'value2', + }) + + self.student2_answers = OrderedDict({ + 'field1': 'value3' + }) + + self.course = CourseFactory.create( + course_survey_required=True, + course_survey_name=self.test_survey_name + ) + + self.survey = SurveyForm.create(self.test_survey_name, self.test_form) + + def test_is_survey_required_for_course(self): + """ + Assert the a requried course survey is when both the flags is set and a survey name + is set on the course descriptor + """ + self.assertTrue(is_survey_required_for_course(self.course)) + + def test_is_survey_not_required_for_course(self): + """ + Assert that if various data is not available or if the survey is not found + then the survey is not considered required + """ + course = CourseFactory.create() + self.assertFalse(is_survey_required_for_course(course)) + + course = CourseFactory.create( + course_survey_required=False + ) + self.assertFalse(is_survey_required_for_course(course)) + + course = CourseFactory.create( + course_survey_required=True, + course_survey_name="NonExisting" + ) + self.assertFalse(is_survey_required_for_course(course)) + + course = CourseFactory.create( + course_survey_required=False, + course_survey_name=self.test_survey_name + ) + self.assertFalse(is_survey_required_for_course(course)) + + def test_user_not_yet_answered_required_survey(self): + """ + Assert that a new course which has a required survey but user has not answered it yet + """ + self.assertTrue(must_answer_survey(self.course, self.student)) + + temp_course = CourseFactory.create( + course_survey_required=False + ) + self.assertFalse(must_answer_survey(temp_course, self.student)) + + temp_course = CourseFactory.create( + course_survey_required=True, + course_survey_name="NonExisting" + ) + self.assertFalse(must_answer_survey(temp_course, self.student)) + + def test_user_has_answered_required_survey(self): + """ + Assert that a new course which has a required survey and user has answers for it + """ + self.survey.save_user_answers(self.student, self.student_answers) + self.assertFalse(must_answer_survey(self.course, self.student)) + + def test_staff_must_answer_survey(self): + """ + Assert that someone with staff level permissions does not have to answer the survey + """ + self.assertFalse(must_answer_survey(self.course, self.staff)) diff --git a/lms/djangoapps/survey/tests/test_views.py b/lms/djangoapps/survey/tests/test_views.py new file mode 100644 index 0000000000..ed0a506cb7 --- /dev/null +++ b/lms/djangoapps/survey/tests/test_views.py @@ -0,0 +1,152 @@ +""" +Python tests for the Survey views +""" + +import json +from collections import OrderedDict + +from django.test import TestCase +from django.test.client import Client +from django.contrib.auth.models import User +from django.core.urlresolvers import reverse + +from survey.models import SurveyForm + +from xmodule.modulestore.tests.factories import CourseFactory + + +class SurveyViewsTests(TestCase): + """ + All tests for the views.py file + """ + def setUp(self): + """ + Set up the test data used in the specific tests + """ + self.client = Client() + + # Create two accounts + self.password = 'abc' + self.student = User.objects.create_user('student', 'student@test.com', self.password) + + self.test_survey_name = 'TestSurvey' + self.test_form = '' + + self.student_answers = OrderedDict({ + u'field1': u'value1', + u'field2': u'value2', + u'ddl': u'1', + }) + + self.course = CourseFactory.create( + course_survey_required=True, + course_survey_name=self.test_survey_name + ) + + self.survey = SurveyForm.create(self.test_survey_name, self.test_form) + + self.view_url = reverse('view_survey', args=[self.test_survey_name]) + self.postback_url = reverse('submit_answers', args=[self.test_survey_name]) + + self.client.login(username=self.student.username, password=self.password) + + def test_unauthenticated_survey_view(self): + """ + Asserts that an unauthenticated user cannot access a survey + """ + anon_user = Client() + + resp = anon_user.get(self.view_url) + self.assertEquals(resp.status_code, 302) + + def test_survey_not_found(self): + """ + Asserts that if we ask for a Survey that does not exist, then we get a 302 redirect + """ + resp = self.client.get(reverse('view_survey', args=['NonExisting'])) + self.assertEquals(resp.status_code, 302) + + def test_authenticated_survey_view(self): + """ + Asserts that an authenticated user can see the survey + """ + resp = self.client.get(self.view_url) + self.assertEquals(resp.status_code, 200) + + # is the SurveyForm html present in the HTML response? + self.assertIn(self.test_form, resp.content) + + def test_unautneticated_survey_postback(self): + """ + Asserts that an anonymous user cannot answer a survey + """ + anon_user = Client() + resp = anon_user.post( + self.postback_url, + self.student_answers + ) + self.assertEquals(resp.status_code, 302) + + def test_survey_postback_to_nonexisting_survey(self): + """ + Asserts that any attempts to post back to a non existing survey returns a 404 + """ + resp = self.client.post( + reverse('submit_answers', args=['NonExisting']), + self.student_answers + ) + self.assertEquals(resp.status_code, 404) + + def test_survey_postback(self): + """ + Asserts that a well formed postback of survey answers is properly stored in the + database + """ + resp = self.client.post( + self.postback_url, + self.student_answers + ) + self.assertEquals(resp.status_code, 200) + data = json.loads(resp.content) + self.assertIn('redirect_url', data) + + answers = self.survey.get_answers(self.student) + self.assertEquals(answers[self.student.id], self.student_answers) + + def test_strip_extra_fields(self): + """ + Verify that any not expected field name in the post-back is not stored + in the database + """ + data = dict.copy(self.student_answers) + + data['csrfmiddlewaretoken'] = 'foo' + data['_redirect_url'] = 'bar' + + resp = self.client.post( + self.postback_url, + data + ) + self.assertEquals(resp.status_code, 200) + answers = self.survey.get_answers(self.student) + self.assertNotIn('csrfmiddlewaretoken', answers[self.student.id]) + self.assertNotIn('_redirect_url', answers[self.student.id]) + + def test_encoding_answers(self): + """ + Verify that if some potentially harmful input data is sent, that is is properly HTML encoded + """ + data = dict.copy(self.student_answers) + + data['field1'] = '' + + resp = self.client.post( + self.postback_url, + data + ) + self.assertEquals(resp.status_code, 200) + answers = self.survey.get_answers(self.student) + self.assertEqual( + '<script type="javascript">alert("Deleting filesystem...")</script>', + answers[self.student.id]['field1'] + ) diff --git a/lms/djangoapps/survey/urls.py b/lms/djangoapps/survey/urls.py new file mode 100644 index 0000000000..727602e9d4 --- /dev/null +++ b/lms/djangoapps/survey/urls.py @@ -0,0 +1,11 @@ +""" +URL mappings for the Survey feature +""" + +from django.conf.urls import patterns, url + + +urlpatterns = patterns('survey.views', # nopep8 + url(r'^(?P