Skip to content
Snippets Groups Projects
Commit fea9432b authored by Chris Dodge's avatar Chris Dodge Committed by Martyn James
Browse files

Add the ability for course staff to specify a Survey and make it required so...

Add the ability for course staff to specify a Survey and make it required so that a student must fill it out before starting the course
parent 994ac6da
No related merge requests found
Showing
with 1336 additions and 1 deletion
......@@ -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
......
"""
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 = '<input name="field1"></input>'
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)})
)
......@@ -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,
)
"""
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)
"""
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
# -*- 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']
"""
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 <input> 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'<div>{}</div>'.format(html))
input_fields = tree.findall('.//input') + tree.findall('.//select')
for input_field in input_fields:
if 'name' in input_field.keys() and input_field.attrib['name'] not in names:
names.append(input_field.attrib['name'])
return names
class SurveyAnswer(TimeStampedModel):
"""
Model for the answers that a user gives for a particular form in a course
"""
user = models.ForeignKey(User, db_index=True)
form = models.ForeignKey(SurveyForm, db_index=True)
field_name = models.CharField(max_length=255, db_index=True)
field_value = models.CharField(max_length=1024)
@classmethod
def do_survey_answers_exist(cls, form, user):
"""
Returns whether a user has any answers for a given SurveyForm for a course
This can be used to determine if a user has taken a CourseSurvey.
"""
return SurveyAnswer.objects.filter(form=form, user=user).exists()
@classmethod
def get_answers(cls, form, user=None, limit_num_users=10000):
"""
Returns all answers a user (or all users, when user=None) has given to an instance of a SurveyForm
Return is a nested dict which are simple name/value pairs with an outer key which is the
user id. For example (where 'field3' is an optional field):
results = {
'1': {
'field1': 'value1',
'field2': 'value2',
},
'2': {
'field1': 'value3',
'field2': 'value4',
'field3': 'value5',
}
:
:
}
limit_num_users is to prevent an unintentional huge, in-memory dictionary.
"""
if user:
answers = SurveyAnswer.objects.filter(form=form, user=user)
else:
answers = SurveyAnswer.objects.filter(form=form)
results = OrderedDict()
num_users = 0
for answer in answers:
user_id = answer.user.id
if user_id not in results and num_users < limit_num_users:
results[user_id] = OrderedDict()
num_users = num_users + 1
if user_id in results:
results[user_id][answer.field_name] = answer.field_value
return results
@classmethod
def save_answers(cls, form, 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
"""
for name in answers.keys():
value = answers[name]
# See if there is an answer stored for this user, form, field_name pair or not
# this will allow for update cases. This does include an additional lookup,
# but write operations will be relatively infrequent
answer, __ = SurveyAnswer.objects.get_or_create(user=user, form=form, field_name=name)
answer.field_value = value
answer.save()
"""
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.exceptions import SurveyFormNotFound, SurveyFormNameAlreadyExists
from django.core.exceptions import ValidationError
from survey.models import SurveyForm
class SurveyModelsTests(TestCase):
"""
All tests for the Survey models.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.test_survey_name = 'TestForm'
self.test_form = '<li><input name="field1" /></li><li><input name="field2" /></li><li><select name="ddl"><option>1</option></select></li>'
self.test_form_update = '<input name="field1" />'
self.student_answers = OrderedDict({
'field1': 'value1',
'field2': 'value2',
})
self.student2_answers = OrderedDict({
'field1': 'value3'
})
def _create_test_survey(self):
"""
Helper method to set up test form
"""
return SurveyForm.create(self.test_survey_name, self.test_form)
def test_form_not_found_raise_exception(self):
"""
Asserts that when looking up a form that does not exist
"""
with self.assertRaises(SurveyFormNotFound):
SurveyForm.get(self.test_survey_name)
def test_form_not_found_none(self):
"""
Asserts that when looking up a form that does not exist
"""
self.assertIsNone(SurveyForm.get(self.test_survey_name, throw_if_not_found=False))
def test_create_new_form(self):
"""
Make sure we can create a new form a look it up
"""
survey = self._create_test_survey()
self.assertIsNotNone(survey)
new_survey = SurveyForm.get(self.test_survey_name)
self.assertIsNotNone(new_survey)
self.assertEqual(new_survey.form, self.test_form)
def test_unicode_rendering(self):
"""
See if the survey form returns the expected unicode string
"""
survey = self._create_test_survey()
self.assertIsNotNone(survey)
self.assertEquals(unicode(survey), self.test_survey_name)
def test_create_form_with_malformed_html(self):
"""
Make sure that if a SurveyForm is saved with unparseable html
an exception is thrown
"""
with self.assertRaises(ValidationError):
SurveyForm.create('badform', '<input name="oops" /><<<>')
def test_create_form_with_no_fields(self):
"""
Make sure that if a SurveyForm is saved without any named fields
an exception is thrown
"""
with self.assertRaises(ValidationError):
SurveyForm.create('badform', '<p>no input fields here</p>')
with self.assertRaises(ValidationError):
SurveyForm.create('badform', '<input id="input_without_name" />')
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'])
"""
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 = '<input name="foo"></input>'
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))
"""
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 = '<input name="field1" /><input name="field2" /><select name="ddl"><option>1</option></select>'
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'] = '<script type="javascript">alert("Deleting filesystem...")</script>'
resp = self.client.post(
self.postback_url,
data
)
self.assertEquals(resp.status_code, 200)
answers = self.survey.get_answers(self.student)
self.assertEqual(
'&lt;script type=&quot;javascript&quot;&gt;alert(&quot;Deleting filesystem...&quot;)&lt;/script&gt;',
answers[self.student.id]['field1']
)
"""
URL mappings for the Survey feature
"""
from django.conf.urls import patterns, url
urlpatterns = patterns('survey.views', # nopep8
url(r'^(?P<survey_name>[0-9A-Za-z]+)/$', 'view_survey', name='view_survey'),
url(r'^(?P<survey_name>[0-9A-Za-z]+)/answers/$', 'submit_answers', name='submit_answers'),
)
"""
Helper methods for Surveys
"""
from survey.models import SurveyForm, SurveyAnswer
from courseware.access import has_access
def is_survey_required_for_course(course_descriptor):
"""
Returns whether a Survey is required for this course
"""
# check to see that the Survey name has been defined in the CourseDescriptor
# and that the specified Survey exists
return course_descriptor.course_survey_required and \
SurveyForm.get(course_descriptor.course_survey_name, throw_if_not_found=False)
def must_answer_survey(course_descriptor, user):
"""
Returns whether a user needs to answer a required survey
"""
if not is_survey_required_for_course(course_descriptor):
return False
# this will throw exception if not found, but a non existing survey name will
# be trapped in the above is_survey_required_for_course() method
survey = SurveyForm.get(course_descriptor.course_survey_name)
has_staff_access = has_access(user, 'staff', course_descriptor)
# survey is required and it exists, let's see if user has answered the survey
# course staff do not need to answer survey
answered_survey = SurveyAnswer.do_survey_answers_exist(survey, user)
return not answered_survey and not has_staff_access
"""
View endpoints for Survey
"""
import logging
import json
from django.contrib.auth.decorators import login_required
from django.http import (
HttpResponse, HttpResponseRedirect, HttpResponseNotFound
)
from django.core.urlresolvers import reverse
from django.views.decorators.http import require_POST
from django.conf import settings
from django.utils.html import escape
from edxmako.shortcuts import render_to_response
from survey.models import SurveyForm
from microsite_configuration import microsite
log = logging.getLogger("edx.survey")
@login_required
def view_survey(request, survey_name):
"""
View to render the survey to the end user
"""
redirect_url = request.GET.get('redirect_url')
return view_student_survey(request.user, survey_name, redirect_url=redirect_url)
def view_student_survey(user, survey_name, course=None, redirect_url=None, is_required=False, skip_redirect_url=None):
"""
Shared utility method to render a survey form
NOTE: This method is shared between the Survey and Courseware Djangoapps
"""
redirect_url = redirect_url if redirect_url else reverse('dashboard')
dashboard_redirect_url = reverse('dashboard')
skip_redirect_url = skip_redirect_url if skip_redirect_url else dashboard_redirect_url
survey = SurveyForm.get(survey_name, throw_if_not_found=False)
if not survey:
return HttpResponseRedirect(redirect_url)
# the result set from get_answers, has an outer key with the user_id
# just remove that outer key to make the JSON payload simplier
existing_answers = survey.get_answers(user=user).get(user.id, {})
context = {
'existing_data_json': json.dumps(existing_answers),
'postback_url': reverse('submit_answers', args=[survey_name]),
'redirect_url': redirect_url,
'skip_redirect_url': skip_redirect_url,
'dashboard_redirect_url': dashboard_redirect_url,
'survey_form': survey.form,
'is_required': is_required,
'mail_to_link': microsite.get_value('email_from_address', settings.CONTACT_EMAIL),
'course': course,
}
return render_to_response("survey/survey.html", context)
@require_POST
@login_required
def submit_answers(request, survey_name):
"""
Form submission post-back endpoint.
NOTE: We do not have a formal definition of a Survey Form, it's just some authored HTML
form fields (via Django Admin site). Therefore we do not do any validation of the submission server side. It is
assumed that all validation is done via JavaScript in the survey.html file
"""
survey = SurveyForm.get(survey_name, throw_if_not_found=False)
if not survey:
return HttpResponseNotFound()
answers = {}
for key in request.POST.keys():
# support multi-SELECT form values, by string concatenating them with a comma separator
array_val = request.POST.getlist(key)
answers[key] = request.POST[key] if len(array_val) == 0 else ','.join(array_val)
# the URL we are supposed to redirect to is
# in a hidden form field
redirect_url = answers['_redirect_url'] if '_redirect_url' in answers else reverse('dashboard')
allowed_field_names = survey.get_field_names()
# scrub the answers to make sure nothing malicious from the user gets stored in
# our database, e.g. JavaScript
filtered_answers = {}
for answer_key in answers.keys():
# only allow known input fields
if answer_key in allowed_field_names:
filtered_answers[answer_key] = escape(answers[answer_key])
survey.save_user_answers(request.user, filtered_answers)
response_params = json.dumps({
# The HTTP end-point for the payment processor.
"redirect_url": redirect_url,
})
return HttpResponse(response_params, content_type="text/json")
......@@ -1478,6 +1478,9 @@ INSTALLED_APPS = (
# edX Mobile API
'mobile_api',
# Surveys
'survey',
)
######################### MARKETING SITE ###############################
......
$(function() {
// adding js class for styling with accessibility in mind
$('body').addClass('js');
// form field label styling on focus
$("form :input").focus(function() {
$("label[for='" + this.id + "']").parent().addClass("is-focused");
}).blur(function() {
$("label").parent().removeClass("is-focused");
});
$('.status.message.submission-error').addClass("is-hidden");
toggleSubmitButton(true);
$('#survey-form').on('submit', function() {
/* validate required fields */
var $inputs = $('#survey-form :input');
$('.status.message.submission-error .message-copy').empty();
var cancel_submit = false;
$inputs.each(function() {
/* see if it is a required field and - if so - make sure user presented all information */
if (typeof $(this).attr("required") !== typeof undefined) {
var val = $(this).val();
if (typeof(val) === "string") {
if (val.trim().length === 0) {
var field_label = $(this).parent().find("label");
$(this).parent().addClass('field-error');
$('.status.message.submission-error .message-copy').append("<li class='error-item'>"+field_label.text()+"</li>");
cancel_submit = true;
}
} else if (typeof(val) === "object") {
/* for SELECT statements */
if (val === null || val.length === 0 || val[0] === "") {
var field_label = $(this).parent().find("label");
$(this).parent().addClass('field-error');
$('.status.message.submission-error .message-copy').append("<li class='error-item'>"+field_label.text()+"</li>");
cancel_submit = true;
}
}
}
});
if (cancel_submit) {
$('.status.message.submission-error').
removeClass("is-hidden").
focus();
$("html, body").animate({ scrollTop: 0 }, "fast");
return false;
}
toggleSubmitButton(false);
});
$('#survey-form').on('ajax:error', function() {
toggleSubmitButton(true);
});
$('#survey-form').on('ajax:success', function(event, json, xhr) {
var url = json.redirect_url;
location.href = url;
});
$('#survey-form').on('ajax:error', function(event, jqXHR, textStatus) {
toggleSubmitButton(true);
json = $.parseJSON(jqXHR.responseText);
$('.status.message.submission-error').addClass('is-shown').focus();
$('.status.message.submission-error .message-copy').
html(gettext("There has been an error processing your survey.")).
stop().
css("display", "block");
});
});
function toggleSubmitButton(enable) {
var $submitButton = $('form .form-actions #submit');
if(enable) {
$submitButton.
removeClass('is-disabled').
removeProp('disabled');
}
else {
$submitButton.
addClass('is-disabled').
prop('disabled', true);
}
}
......@@ -121,7 +121,7 @@
// ====================
// edx.org marketing site - needed, but bad overrides with importants
.view-register, .view-login, .view-passwordreset {
.view-register, .view-login, .view-passwordreset, .view-survey {
.form-actions button[type="submit"] {
text-transform: none;
......
......@@ -55,6 +55,7 @@
@import 'multicourse/error-pages';
@import 'multicourse/help';
@import 'multicourse/edge';
@import 'multicourse/survey-page';
@import 'developer'; // used for any developer-created scss that needs further polish/refactoring
@import 'shame'; // used for any bad-form/orphaned scss
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment