Commit 9f3b9c7b by Martyn James

Merge pull request #5818 from edx/release

Merging latest updates to release
parents 640072a6 1d4c781e
......@@ -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
......
......@@ -592,3 +592,17 @@ class VideoTranscriptsMixin(object):
raise ValueError
return content, filename, Transcript.mime_types[transcript_format]
def get_default_transcript_language(self):
"""
Returns the default transcript language for this video module.
"""
if self.transcript_language in self.transcripts:
transcript_language = self.transcript_language
elif self.sub:
transcript_language = u'en'
elif len(self.transcripts) > 0:
transcript_language = sorted(self.transcripts)[0]
else:
transcript_language = u'en'
return transcript_language
......@@ -152,12 +152,7 @@ class VideoModule(VideoFields, VideoTranscriptsMixin, VideoStudentViewHandlers,
transcript_language = u'en'
languages = {'en': 'English'}
else:
if self.transcript_language in self.transcripts:
transcript_language = self.transcript_language
elif self.sub:
transcript_language = u'en'
else:
transcript_language = sorted(self.transcripts.keys())[0]
transcript_language = self.get_default_transcript_language()
native_languages = {lang: label for lang, label in settings.LANGUAGES if len(lang) == 2}
languages = {
......@@ -177,7 +172,6 @@ class VideoModule(VideoFields, VideoTranscriptsMixin, VideoStudentViewHandlers,
sorted_languages = OrderedDict(sorted_languages)
return track_url, transcript_language, sorted_languages
def get_html(self):
transcript_download_format = self.transcript_download_format if not (self.download_track and self.track) else None
sources = filter(None, self.html5_sources)
......@@ -201,16 +195,22 @@ class VideoModule(VideoFields, VideoTranscriptsMixin, VideoStudentViewHandlers,
# internally for download links (source, html5_sources) and the youtube
# stream.
if self.edx_video_id and edxval_api:
val_video_urls = edxval_api.get_urls_for_profiles(
self.edx_video_id, ["desktop_mp4", "youtube"]
)
# VAL will always give us the keys for the profiles we asked for, but
# if it doesn't have an encoded video entry for that Video + Profile, the
# value will map to `None`
if val_video_urls["desktop_mp4"] and self.download_video:
download_video_link = val_video_urls["desktop_mp4"]
if val_video_urls["youtube"]:
youtube_streams = "1.00:{}".format(val_video_urls["youtube"])
try:
val_video_urls = edxval_api.get_urls_for_profiles(
self.edx_video_id, ["desktop_mp4", "youtube"]
)
# VAL will always give us the keys for the profiles we asked for, but
# if it doesn't have an encoded video entry for that Video + Profile, the
# value will map to `None`
if val_video_urls["desktop_mp4"] and self.download_video:
download_video_link = val_video_urls["desktop_mp4"]
if val_video_urls["youtube"]:
youtube_streams = "1.00:{}".format(val_video_urls["youtube"])
except edxval_api.ValInternalError:
# VAL raises this exception if it can't find data for the edx video ID. This can happen if the
# course data is ported to a machine that does not have the VAL data. So for now, pass on this
# exception and fallback to whatever we find in the VideoDescriptor.
log.warning("Could not retrieve information from VAL for edx Video ID: %s.", self.edx_video_id)
# If there was no edx_video_id, or if there was no download specified
# for it, we fall back on whatever we find in the VideoDescriptor
......
"""
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,
)
......@@ -44,7 +44,7 @@ class CourseField(serializers.RelatedField):
return {
"id": course_id,
"name": course.display_name,
"number": course.number,
"number": course.display_number_with_default,
"org": course.display_org_with_default,
"start": course.start,
"end": course.end,
......
......@@ -4,6 +4,7 @@ Tests for users API
from rest_framework.test import APITestCase
from xmodule.modulestore.tests.factories import CourseFactory
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from xmodule.modulestore.django import modulestore
from courseware.tests.factories import UserFactory
from django.core.urlresolvers import reverse
from mobile_api.users.serializers import CourseEnrollmentSerializer
......@@ -93,3 +94,16 @@ class TestUserApi(ModuleStoreTestCase, APITestCase):
serialized = CourseEnrollmentSerializer(CourseEnrollment.enrollments_for_user(self.user)[0]).data # pylint: disable=E1101
self.assertEqual(serialized['course']['video_outline'], None)
self.assertEqual(serialized['course']['name'], self.course.display_name)
self.assertEqual(serialized['course']['number'], self.course.id.course)
self.assertEqual(serialized['course']['org'], self.course.id.org)
def test_course_serializer_with_display_overrides(self):
self.course.display_coursenumber = "overridden_number"
self.course.display_organization = "overridden_org"
modulestore().update_item(self.course, self.user.id)
self.client.login(username=self.username, password=self.password)
self._enroll()
serialized = CourseEnrollmentSerializer(CourseEnrollment.enrollments_for_user(self.user)[0]).data # pylint: disable=E1101
self.assertEqual(serialized['course']['number'], self.course.display_coursenumber)
self.assertEqual(serialized['course']['org'], self.course.display_organization)
......@@ -40,7 +40,8 @@ class BlockOutline(object):
block = child_to_parent[block]
if block is not self.start_block:
block_path.append({
'name': block.display_name,
# to be consistent with other edx-platform clients, return the defaulted display name
'name': block.display_name_with_default,
'category': block.category,
})
return reversed(block_path)
......@@ -76,20 +77,30 @@ class BlockOutline(object):
kwargs=kwargs,
request=self.request,
)
return unit_url, section_url
return unit_url, section_url, block_path
user = self.request.user
while stack:
curr_block = stack.pop()
if curr_block.hide_from_toc:
# For now, if the 'hide_from_toc' setting is set on the block, do not traverse down
# the hierarchy. The reason being is that these blocks may not have human-readable names
# to display on the mobile clients.
# Eventually, we'll need to figure out how we want these blocks to be displayed on the
# mobile clients. As, they are still accessible in the browser, just not navigatable
# from the table-of-contents.
continue
if curr_block.category in self.categories_to_outliner:
if not has_access(user, 'load', curr_block, course_key=self.course_id):
continue
summary_fn = self.categories_to_outliner[curr_block.category]
block_path = list(path(curr_block))
unit_url, section_url = find_urls(curr_block)
unit_url, section_url, _ = find_urls(curr_block)
yield {
"path": block_path,
"named_path": [b["name"] for b in block_path[:-1]],
......@@ -145,7 +156,7 @@ def video_summary(course, course_id, video_descriptor, request, local_cache):
"size": size,
"name": video_descriptor.display_name,
"transcripts": transcripts,
"language": video_descriptor.transcript_language,
"language": video_descriptor.get_default_transcript_language(),
"category": video_descriptor.category,
"id": unicode(video_descriptor.scope_ids.usage_id),
}
......@@ -4,6 +4,7 @@ Tests for video outline API
from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory
from xmodule.video_module import transcripts_utils
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from xmodule.modulestore.django import modulestore
from courseware.tests.factories import UserFactory
from courseware.tests.tests import TEST_DATA_MONGO_MODULESTORE
from django.core.urlresolvers import reverse
......@@ -27,13 +28,13 @@ class TestVideoOutline(ModuleStoreTestCase, APITestCase):
super(TestVideoOutline, self).setUp()
self.user = UserFactory.create()
self.course = CourseFactory.create(mobile_available=True)
section = ItemFactory.create(
self.section = ItemFactory.create(
parent_location=self.course.location,
category="chapter",
display_name=u"test factory section omega \u03a9",
)
self.sub_section = ItemFactory.create(
parent_location=section.location,
parent_location=self.section.location,
category="sequential",
display_name=u"test subsection omega \u03a9",
)
......@@ -50,6 +51,12 @@ class TestVideoOutline(ModuleStoreTestCase, APITestCase):
metadata={'graded': True, 'format': 'Homework'},
display_name=u"test unit omega 2 \u03a9",
)
self.nameless_unit = ItemFactory.create(
parent_location=self.sub_section.location,
category="vertical",
metadata={'graded': True, 'format': 'Homework'},
display_name=None,
)
self.edx_video_id = 'testing-123'
......@@ -90,15 +97,28 @@ class TestVideoOutline(ModuleStoreTestCase, APITestCase):
}
]})
subid = uuid4().hex
self.video = ItemFactory.create(
parent_location=self.unit.location,
category="video",
edx_video_id=self.edx_video_id,
display_name=u"test video omega \u03a9",
sub=subid
)
self.client.login(username=self.user.username, password='test')
def test_course_not_available(self):
nonmobile = CourseFactory.create()
url = reverse('video-summary-list', kwargs={'course_id': unicode(nonmobile.id)})
response = self.client.get(url)
self.assertEqual(response.status_code, 403)
def _get_video_summary_list(self):
"""
Calls the video-summary-list endpoint, expecting a success response
"""
url = reverse('video-summary-list', kwargs={'course_id': unicode(self.course.id)})
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
return response.data # pylint: disable=E1103
def _create_video_with_subs(self):
"""
Creates and returns a video with stored subtitles.
"""
subid = uuid4().hex
transcripts_utils.save_subs_to_store({
'start': [100, 200, 240, 390, 1000],
'end': [200, 240, 380, 1000, 1500],
......@@ -111,16 +131,16 @@ class TestVideoOutline(ModuleStoreTestCase, APITestCase):
]},
subid,
self.course)
self.client.login(username=self.user.username, password='test')
def test_course_not_available(self):
nonmobile = CourseFactory.create()
url = reverse('video-summary-list', kwargs={'course_id': unicode(nonmobile.id)})
response = self.client.get(url)
self.assertEqual(response.status_code, 403)
return ItemFactory.create(
parent_location=self.unit.location,
category="video",
edx_video_id=self.edx_video_id,
display_name=u"test video omega \u03a9",
sub=subid
)
def test_course_list(self):
self._create_video_with_subs()
ItemFactory.create(
parent_location=self.other_unit.location,
category="video",
......@@ -133,7 +153,6 @@ class TestVideoOutline(ModuleStoreTestCase, APITestCase):
display_name=u"test video omega 3 \u03a9",
source=self.html5_video_url
)
ItemFactory.create(
parent_location=self.unit.location,
category="video",
......@@ -142,11 +161,7 @@ class TestVideoOutline(ModuleStoreTestCase, APITestCase):
visible_to_staff_only=True,
)
url = reverse('video-summary-list', kwargs={'course_id': unicode(self.course.id)})
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
course_outline = response.data # pylint: disable=E1103
course_outline = self._get_video_summary_list()
self.assertEqual(len(course_outline), 3)
vid = course_outline[0]
self.assertTrue('test_subsection_omega_%CE%A9' in vid['section_url'])
......@@ -157,14 +172,77 @@ class TestVideoOutline(ModuleStoreTestCase, APITestCase):
self.assertTrue('en' in vid['summary']['transcripts'])
self.assertEqual(course_outline[1]['summary']['video_url'], self.html5_video_url)
self.assertEqual(course_outline[1]['summary']['size'], 0)
self.assertEqual(course_outline[1]['path'][2]['name'], self.other_unit.display_name)
self.assertEqual(course_outline[2]['summary']['video_url'], self.html5_video_url)
self.assertEqual(course_outline[2]['summary']['size'], 0)
def test_transcripts(self):
def test_course_list_with_nameless_unit(self):
ItemFactory.create(
parent_location=self.nameless_unit.location,
category="video",
edx_video_id=self.edx_video_id,
display_name=u"test draft video omega 2 \u03a9"
)
course_outline = self._get_video_summary_list()
self.assertEqual(len(course_outline), 1)
self.assertEqual(course_outline[0]['path'][2]['name'], self.nameless_unit.location.block_id)
def test_course_list_with_hidden_blocks(self):
hidden_subsection = ItemFactory.create(
parent_location=self.section.location,
category="sequential",
hide_from_toc=True,
)
unit_within_hidden_subsection = ItemFactory.create(
parent_location=hidden_subsection.location,
category="vertical",
)
hidden_unit = ItemFactory.create(
parent_location=self.sub_section.location,
category="vertical",
hide_from_toc=True,
)
ItemFactory.create(
parent_location=unit_within_hidden_subsection.location,
category="video",
edx_video_id=self.edx_video_id,
)
ItemFactory.create(
parent_location=hidden_unit.location,
category="video",
edx_video_id=self.edx_video_id,
)
course_outline = self._get_video_summary_list()
self.assertEqual(len(course_outline), 0)
def test_course_list_transcripts(self):
video = ItemFactory.create(
parent_location=self.nameless_unit.location,
category="video",
edx_video_id=self.edx_video_id,
display_name=u"test draft video omega 2 \u03a9"
)
transcript_cases = [
({}, "en"),
({"en": 1}, "en"),
({"lang1": 1}, "lang1"),
({"lang1": 1, "en": 2}, "en"),
({"lang1": 1, "lang2": 2}, "lang1"),
]
for transcript_case in transcript_cases:
video.transcripts = transcript_case[0]
modulestore().update_item(video, self.user.id)
course_outline = self._get_video_summary_list()
self.assertEqual(len(course_outline), 1)
self.assertEqual(course_outline[0]['summary']['language'], transcript_case[1])
def test_transcripts_detail(self):
video = self._create_video_with_subs()
kwargs = {
'course_id': unicode(self.course.id),
'block_id': unicode(self.video.scope_ids.usage_id.block_id),
'block_id': unicode(video.scope_ids.usage_id.block_id),
'lang': 'pl'
}
url = reverse('video-transcripts-detail', kwargs=kwargs)
......
"""
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")
......@@ -277,10 +277,6 @@ FEATURES = {
# ENABLE_OAUTH2_PROVIDER to True
'ENABLE_MOBILE_REST_API': False,
# Video Abstraction Layer used to allow video teams to manage video assets
# independently of courseware. https://github.com/edx/edx-val
'ENABLE_VIDEO_ABSTRACTION_LAYER_API': False,
# Enable the new dashboard, account, and profile pages
'ENABLE_NEW_DASHBOARD': False,
......@@ -1478,6 +1474,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
......
......@@ -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
......
// 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;
}
}
}
<%! 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>
......@@ -78,6 +78,8 @@ urlpatterns = ('', # nopep8
if settings.FEATURES["ENABLE_MOBILE_REST_API"]:
urlpatterns += (
url(r'^api/mobile/v0.5/', include('mobile_api.urls')),
# Video Abstraction Layer used to allow video teams to manage video assets
# independently of courseware. https://github.com/edx/edx-val
url(r'^api/val/v0/', include('edxval.urls')),
)
......@@ -259,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),
......@@ -446,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 += (
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment