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
This commit is contained in:
committed by
Martyn James
parent
994ac6da6d
commit
fea9432b50
@@ -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
|
||||
|
||||
|
||||
157
lms/djangoapps/courseware/tests/test_course_survey.py
Normal file
157
lms/djangoapps/courseware/tests/test_course_survey.py
Normal file
@@ -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 = '<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,
|
||||
)
|
||||
|
||||
0
lms/djangoapps/survey/__init__.py
Normal file
0
lms/djangoapps/survey/__init__.py
Normal file
29
lms/djangoapps/survey/admin.py
Normal file
29
lms/djangoapps/survey/admin.py
Normal file
@@ -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)
|
||||
17
lms/djangoapps/survey/exceptions.py
Normal file
17
lms/djangoapps/survey/exceptions.py
Normal file
@@ -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
|
||||
97
lms/djangoapps/survey/migrations/0001_initial.py
Normal file
97
lms/djangoapps/survey/migrations/0001_initial.py
Normal file
@@ -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']
|
||||
0
lms/djangoapps/survey/migrations/__init__.py
Normal file
0
lms/djangoapps/survey/migrations/__init__.py
Normal file
224
lms/djangoapps/survey/models.py
Normal file
224
lms/djangoapps/survey/models.py
Normal file
@@ -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 <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()
|
||||
0
lms/djangoapps/survey/tests/__init__.py
Normal file
0
lms/djangoapps/survey/tests/__init__.py
Normal file
224
lms/djangoapps/survey/tests/test_models.py
Normal file
224
lms/djangoapps/survey/tests/test_models.py
Normal file
@@ -0,0 +1,224 @@
|
||||
"""
|
||||
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'])
|
||||
116
lms/djangoapps/survey/tests/test_utils.py
Normal file
116
lms/djangoapps/survey/tests/test_utils.py
Normal file
@@ -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 = '<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))
|
||||
152
lms/djangoapps/survey/tests/test_views.py
Normal file
152
lms/djangoapps/survey/tests/test_views.py
Normal file
@@ -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 = '<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(
|
||||
'<script type="javascript">alert("Deleting filesystem...")</script>',
|
||||
answers[self.student.id]['field1']
|
||||
)
|
||||
11
lms/djangoapps/survey/urls.py
Normal file
11
lms/djangoapps/survey/urls.py
Normal file
@@ -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<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'),
|
||||
)
|
||||
37
lms/djangoapps/survey/utils.py
Normal file
37
lms/djangoapps/survey/utils.py
Normal file
@@ -0,0 +1,37 @@
|
||||
"""
|
||||
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
|
||||
109
lms/djangoapps/survey/views.py
Normal file
109
lms/djangoapps/survey/views.py
Normal file
@@ -0,0 +1,109 @@
|
||||
"""
|
||||
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 ###############################
|
||||
|
||||
93
lms/static/js/course_survey.js
Normal file
93
lms/static/js/course_survey.js
Normal file
@@ -0,0 +1,93 @@
|
||||
$(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
|
||||
|
||||
@@ -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
|
||||
|
||||
162
lms/static/sass/multicourse/_survey-page.scss
Normal file
162
lms/static/sass/multicourse/_survey-page.scss
Normal file
@@ -0,0 +1,162 @@
|
||||
// full-page course survey styles
|
||||
|
||||
.view-survey {
|
||||
|
||||
.container {
|
||||
padding: ($baseline*1.5) 0;
|
||||
}
|
||||
|
||||
.content-primary {
|
||||
@include float(left);
|
||||
@include margin-right(flex-gutter());
|
||||
width: flex-grid(9,12);
|
||||
}
|
||||
|
||||
.content-supplementary {
|
||||
@include float(left);
|
||||
width: flex-grid(3,12);
|
||||
margin-top: ($baseline*2);
|
||||
}
|
||||
|
||||
.header-survey {
|
||||
|
||||
.title {
|
||||
@extend %t-title4;
|
||||
@extend %t-weight4;
|
||||
margin-bottom: $baseline;
|
||||
border-bottom: 1px solid $gray-l4;
|
||||
padding-bottom: ($baseline/2);
|
||||
}
|
||||
|
||||
.course-info {
|
||||
@extend %t-title;
|
||||
padding-bottom: ($baseline/4);
|
||||
}
|
||||
|
||||
.course-org,
|
||||
.course-number {
|
||||
@extend %t-title8;
|
||||
display: inline-block;
|
||||
text-transform: uppercase;
|
||||
color: $gray-l1;
|
||||
}
|
||||
|
||||
.course-org {
|
||||
@include margin-right($baseline/4);
|
||||
}
|
||||
|
||||
.course-name {
|
||||
@extend %t-title5;
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
// reset nasty LMS default styles
|
||||
form {
|
||||
|
||||
h1, h2 {
|
||||
text-align: inherit;
|
||||
letter-spacing: inherit;
|
||||
text-transform: inherit;
|
||||
}
|
||||
}
|
||||
|
||||
.instructions {
|
||||
margin-bottom: $baseline;
|
||||
font-style: italic;
|
||||
@extend %t-copy-base;
|
||||
}
|
||||
|
||||
.message.submission-error {
|
||||
display: block;
|
||||
margin-bottom: ($baseline);
|
||||
border-top: 3px solid $error-color;
|
||||
@include padding( ($baseline), ($baseline*1.5), ($baseline*1.5), ($baseline*1.5) );
|
||||
background-color: tint($error-color,85%);
|
||||
|
||||
.message-title {
|
||||
@extend %t-title5;
|
||||
@extend %t-weight4;
|
||||
margin-bottom: ($baseline/2);
|
||||
color: $error-color;
|
||||
}
|
||||
|
||||
.message-copy {
|
||||
@extend %ui-no-list;
|
||||
line-height: 1.3;
|
||||
|
||||
.error-item {
|
||||
margin-bottom: ($baseline/3);
|
||||
}
|
||||
}
|
||||
|
||||
&.is-hidden {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.list-input {
|
||||
@extend %ui-no-list;
|
||||
|
||||
.field {
|
||||
margin-bottom: $baseline;
|
||||
|
||||
&.required label:after {
|
||||
content: "*";
|
||||
@include margin-left($baseline/4);
|
||||
}
|
||||
|
||||
.tip {
|
||||
@extend %t-copy-sub2;
|
||||
display: block;
|
||||
margin-top: ($baseline/4);
|
||||
color: $gray;
|
||||
}
|
||||
|
||||
&.is-focused {
|
||||
|
||||
.tip {
|
||||
color: $base-font-color;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.action-primary {
|
||||
@extend %m-btn-primary;
|
||||
@extend %t-copy-base;
|
||||
@include padding-left($baseline*2);
|
||||
}
|
||||
|
||||
.action-cancel {
|
||||
@extend %t-copy-sub1;
|
||||
@include margin-left($baseline);
|
||||
}
|
||||
|
||||
// override basic label styles
|
||||
label {
|
||||
@extend %t-copy-base;
|
||||
@extend %t-weight4;
|
||||
display: block;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
// override basic form input styles
|
||||
button, input, select, textarea {
|
||||
@extend %t-copy-sub1;
|
||||
}
|
||||
|
||||
.bit {
|
||||
margin-bottom: $baseline;
|
||||
|
||||
.title {
|
||||
@extend %t-title7;
|
||||
@extend %t-weight4;
|
||||
}
|
||||
|
||||
p {
|
||||
@extend %t-copy-sub1;
|
||||
color: $gray;
|
||||
}
|
||||
}
|
||||
}
|
||||
69
lms/templates/survey/survey.html
Normal file
69
lms/templates/survey/survey.html
Normal file
@@ -0,0 +1,69 @@
|
||||
<%! from django.utils.translation import ugettext as _ %>
|
||||
|
||||
<%inherit file="../main.html" />
|
||||
|
||||
<%namespace name='static' file='../static_content.html'/>
|
||||
|
||||
<%! from django.core.urlresolvers import reverse %>
|
||||
<%! from django.utils import html %>
|
||||
|
||||
<%block name="pagetitle">${_("User Survey")}</%block>
|
||||
|
||||
<%block name="bodyclass">view-survey</%block>
|
||||
|
||||
<%block name="js_extra">
|
||||
<script type="text/javascript" src="/static/js/course_survey.js"></script>
|
||||
</%block>
|
||||
|
||||
<section class="container">
|
||||
<section role="main" class="content-primary">
|
||||
<form role="form" id="survey-form" method="post" data-remote="true" action="${postback_url}" novalidate>
|
||||
<input type="hidden" name="csrfmiddlewaretoken" value="${ csrf_token }" />
|
||||
<input type="hidden" name="_redirect_url" value="${redirect_url}" />
|
||||
|
||||
% if course:
|
||||
<div class="header-survey">
|
||||
<h4 class="course-info">
|
||||
<span class="course-org">${course.display_org_with_default}</span><span class="course-number"> ${course.display_number_with_default}</span>
|
||||
<span class="course-name">${course.display_name}</span>
|
||||
</h4>
|
||||
<h3 class="title">${_("Pre-Course Survey")}</h3>
|
||||
</div>
|
||||
|
||||
<p class="instructions">
|
||||
${_("You can begin your course as soon as you complete the following form. Required fields are marked with an asterisk (*).")}
|
||||
</p>
|
||||
% endif
|
||||
|
||||
<div role="alert" class="status message submission-error" tabindex="-1">
|
||||
<h3 class="message-title">${_("You are missing the following required fields:")} </h3>
|
||||
<ul class="message-copy"> </ul>
|
||||
</div>
|
||||
|
||||
${survey_form}
|
||||
|
||||
<div class="form-actions">
|
||||
<button name="submit" type="submit" id="submit" class="action action-primary action-update">${_('Submit')}</button>
|
||||
<a class="action action-cancel" href='${dashboard_redirect_url}'>${_("Cancel and Return to Dashboard")}</a>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<aside class="content-supplementary" role="complementary">
|
||||
|
||||
<div class="bit">
|
||||
<h3 class="title">${_('Why do I need to complete this information?')}</h3>
|
||||
<p>
|
||||
${_('We use the information you provide to improve our course for both current and future students. The more we know about your specific needs, the better we can make your course experience.')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="bit">
|
||||
<h3 class="title">${_('Who can I contact if I have questions?')}</h3>
|
||||
<p>
|
||||
${_('If you have any questions about this course or this form, you can contact <a href="{mail_to_link}"">{mail_to_link}</a>.').format(mail_to_link=mail_to_link)}
|
||||
</p>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
</section>
|
||||
@@ -261,6 +261,10 @@ if settings.COURSEWARE_ENABLED:
|
||||
url(r'^courses/{}/syllabus$'.format(settings.COURSE_ID_PATTERN),
|
||||
'courseware.views.syllabus', name="syllabus"), # TODO arjun remove when custom tabs in place, see courseware/courses.py
|
||||
|
||||
#Survey associated with a course
|
||||
url(r'^courses/{}/survey$'.format(settings.COURSE_ID_PATTERN),
|
||||
'courseware.views.course_survey', name="course_survey"),
|
||||
|
||||
url(r'^courses/{}/book/(?P<book_index>\d+)/$'.format(settings.COURSE_ID_PATTERN),
|
||||
'staticbook.views.index', name="book"),
|
||||
url(r'^courses/{}/book/(?P<book_index>\d+)/(?P<page>\d+)$'.format(settings.COURSE_ID_PATTERN),
|
||||
@@ -448,6 +452,10 @@ urlpatterns += (
|
||||
url(r'^shoppingcart/', include('shoppingcart.urls')),
|
||||
)
|
||||
|
||||
# Survey Djangoapp
|
||||
urlpatterns += (
|
||||
url(r'^survey/', include('survey.urls')),
|
||||
)
|
||||
|
||||
if settings.FEATURES.get('AUTH_USE_OPENID_PROVIDER'):
|
||||
urlpatterns += (
|
||||
|
||||
Reference in New Issue
Block a user