Commit 2fed57a0 by Andy Armstrong

Merge pull request #7350 from edx/learner-profiles

Feature branch for learner profiles
parents 5b644298 cfcb38c4
...@@ -36,7 +36,12 @@ import lms.envs.common ...@@ -36,7 +36,12 @@ import lms.envs.common
# Although this module itself may not use these imported variables, other dependent modules may. # Although this module itself may not use these imported variables, other dependent modules may.
from lms.envs.common import ( from lms.envs.common import (
USE_TZ, TECH_SUPPORT_EMAIL, PLATFORM_NAME, BUGS_EMAIL, DOC_STORE_CONFIG, DATA_DIR, ALL_LANGUAGES, WIKI_ENABLED, USE_TZ, TECH_SUPPORT_EMAIL, PLATFORM_NAME, BUGS_EMAIL, DOC_STORE_CONFIG, DATA_DIR, ALL_LANGUAGES, WIKI_ENABLED,
update_module_store_settings, ASSET_IGNORE_REGEX, COPYRIGHT_YEAR update_module_store_settings, ASSET_IGNORE_REGEX, COPYRIGHT_YEAR, PARENTAL_CONSENT_AGE_LIMIT,
# The following PROFILE_IMAGE_* settings are included as they are
# indirectly accessed through the email opt-in API, which is
# technically accessible through the CMS via legacy URLs.
PROFILE_IMAGE_BACKEND, PROFILE_IMAGE_DEFAULT_FILENAME, PROFILE_IMAGE_DEFAULT_FILE_EXTENSION,
PROFILE_IMAGE_SECRET_KEY, PROFILE_IMAGE_MIN_BYTES, PROFILE_IMAGE_MAX_BYTES,
) )
from path import path from path import path
from warnings import simplefilter from warnings import simplefilter
......
...@@ -249,6 +249,9 @@ FEATURES['USE_MICROSITES'] = True ...@@ -249,6 +249,9 @@ FEATURES['USE_MICROSITES'] = True
# the one in lms/envs/test.py # the one in lms/envs/test.py
FEATURES['ENABLE_DISCUSSION_SERVICE'] = False FEATURES['ENABLE_DISCUSSION_SERVICE'] = False
# Enable a parental consent age limit for testing
PARENTAL_CONSENT_AGE_LIMIT = 13
# Enable content libraries code for the tests # Enable content libraries code for the tests
FEATURES['ENABLE_CONTENT_LIBRARIES'] = True FEATURES['ENABLE_CONTENT_LIBRARIES'] = True
......
...@@ -123,7 +123,7 @@ define([ ...@@ -123,7 +123,7 @@ define([
patchAndVerifyRequest(requests, url, notificationSpy); patchAndVerifyRequest(requests, url, notificationSpy);
AjaxHelpers.respondToDelete(requests); AjaxHelpers.respondWithNoContent(requests);
ViewHelpers.verifyNotificationHidden(notificationSpy); ViewHelpers.verifyNotificationHidden(notificationSpy);
expect($(SELECTORS.itemView)).not.toExist(); expect($(SELECTORS.itemView)).not.toExist();
}; };
......
...@@ -281,7 +281,7 @@ define(["jquery", "sinon", "js/common_helpers/ajax_helpers", "js/views/utils/vie ...@@ -281,7 +281,7 @@ define(["jquery", "sinon", "js/common_helpers/ajax_helpers", "js/views/utils/vie
expect($('.wrapper-alert-announcement')).not.toHaveClass('is-hidden'); expect($('.wrapper-alert-announcement')).not.toHaveClass('is-hidden');
$('.dismiss-button').click(); $('.dismiss-button').click();
AjaxHelpers.expectJsonRequest(requests, 'DELETE', 'dummy_dismiss_url'); AjaxHelpers.expectJsonRequest(requests, 'DELETE', 'dummy_dismiss_url');
AjaxHelpers.respondToDelete(requests); AjaxHelpers.respondWithNoContent(requests);
expect($('.wrapper-alert-announcement')).toHaveClass('is-hidden'); expect($('.wrapper-alert-announcement')).toHaveClass('is-hidden');
}); });
}); });
......
...@@ -33,7 +33,7 @@ define(["jquery", "js/common_helpers/ajax_helpers", "js/spec_helpers/view_helper ...@@ -33,7 +33,7 @@ define(["jquery", "js/common_helpers/ajax_helpers", "js/spec_helpers/view_helper
var reloadSpy = spyOn(ViewUtils, 'reload'); var reloadSpy = spyOn(ViewUtils, 'reload');
$('.dismiss-button').click(); $('.dismiss-button').click();
AjaxHelpers.expectJsonRequest(requests, 'DELETE', 'dummy_dismiss_url'); AjaxHelpers.expectJsonRequest(requests, 'DELETE', 'dummy_dismiss_url');
AjaxHelpers.respondToDelete(requests); AjaxHelpers.respondWithNoContent(requests);
expect(reloadSpy).toHaveBeenCalled(); expect(reloadSpy).toHaveBeenCalled();
}); });
......
...@@ -18,7 +18,10 @@ from opaque_keys.edx.keys import CourseKey ...@@ -18,7 +18,10 @@ from opaque_keys.edx.keys import CourseKey
from embargo import api as embargo_api from embargo import api as embargo_api
from cors_csrf.authentication import SessionAuthenticationCrossDomainCsrf from cors_csrf.authentication import SessionAuthenticationCrossDomainCsrf
from cors_csrf.decorators import ensure_csrf_cookie_cross_domain from cors_csrf.decorators import ensure_csrf_cookie_cross_domain
from util.authentication import SessionAuthenticationAllowInactiveUser, OAuth2AuthenticationAllowInactiveUser from openedx.core.lib.api.authentication import (
SessionAuthenticationAllowInactiveUser,
OAuth2AuthenticationAllowInactiveUser,
)
from util.disable_rate_limit import can_disable_rate_limit from util.disable_rate_limit import can_disable_rate_limit
from enrollment import api from enrollment import api
from enrollment.errors import ( from enrollment.errors import (
......
...@@ -121,9 +121,11 @@ class Migration(SchemaMigration): ...@@ -121,9 +121,11 @@ class Migration(SchemaMigration):
'Meta': {'object_name': 'LinkedInAddToProfileConfiguration'}, 'Meta': {'object_name': 'LinkedInAddToProfileConfiguration'},
'change_date': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}), 'change_date': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}),
'changed_by': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']", 'null': 'True', 'on_delete': 'models.PROTECT'}), 'changed_by': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']", 'null': 'True', 'on_delete': 'models.PROTECT'}),
'dashboard_tracking_code': ('django.db.models.fields.TextField', [], {'blank': 'True'}), 'company_identifier': ('django.db.models.fields.TextField', [], {}),
'dashboard_tracking_code': ('django.db.models.fields.TextField', [], {'default': "''", 'blank': 'True'}),
'enabled': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), 'enabled': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}) 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'trk_partner_name': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '10', 'blank': 'True'})
}, },
'student.loginfailures': { 'student.loginfailures': {
'Meta': {'object_name': 'LoginFailures'}, 'Meta': {'object_name': 'LoginFailures'},
......
...@@ -28,6 +28,7 @@ from django.contrib.auth.hashers import make_password ...@@ -28,6 +28,7 @@ from django.contrib.auth.hashers import make_password
from django.contrib.auth.signals import user_logged_in, user_logged_out from django.contrib.auth.signals import user_logged_in, user_logged_out
from django.db import models, IntegrityError from django.db import models, IntegrityError
from django.db.models import Count from django.db.models import Count
from django.db.models.signals import pre_save, post_save
from django.dispatch import receiver, Signal from django.dispatch import receiver, Signal
from django.core.exceptions import ObjectDoesNotExist from django.core.exceptions import ObjectDoesNotExist
from django.utils.translation import ugettext_noop from django.utils.translation import ugettext_noop
...@@ -40,6 +41,7 @@ from importlib import import_module ...@@ -40,6 +41,7 @@ from importlib import import_module
from opaque_keys.edx.locations import SlashSeparatedCourseKey from opaque_keys.edx.locations import SlashSeparatedCourseKey
import lms.lib.comment_client as cc import lms.lib.comment_client as cc
from util.model_utils import emit_field_changed_events, get_changed_fields_dict
from util.query import use_read_replica_if_available from util.query import use_read_replica_if_available
from xmodule_django.models import CourseKeyField, NoneToEmptyManager from xmodule_django.models import CourseKeyField, NoneToEmptyManager
from xmodule.modulestore.exceptions import ItemNotFoundError from xmodule.modulestore.exceptions import ItemNotFoundError
...@@ -249,6 +251,16 @@ class UserProfile(models.Model): ...@@ -249,6 +251,16 @@ class UserProfile(models.Model):
country = CountryField(blank=True, null=True) country = CountryField(blank=True, null=True)
goals = models.TextField(blank=True, null=True) goals = models.TextField(blank=True, null=True)
allow_certificate = models.BooleanField(default=1) allow_certificate = models.BooleanField(default=1)
bio = models.CharField(blank=True, null=True, max_length=3000, db_index=False)
profile_image_uploaded_at = models.DateTimeField(null=True)
@property
def has_profile_image(self):
"""
Convenience method that returns a boolean indicating whether or not
this user has uploaded a profile image.
"""
return self.profile_image_uploaded_at is not None
def get_meta(self): # pylint: disable=missing-docstring def get_meta(self): # pylint: disable=missing-docstring
js_str = self.meta js_str = self.meta
...@@ -276,6 +288,96 @@ class UserProfile(models.Model): ...@@ -276,6 +288,96 @@ class UserProfile(models.Model):
self.set_meta(meta) self.set_meta(meta)
self.save() self.save()
def requires_parental_consent(self, date=None, age_limit=None, default_requires_consent=True):
"""Returns true if this user requires parental consent.
Args:
date (Date): The date for which consent needs to be tested (defaults to now).
age_limit (int): The age limit at which parental consent is no longer required.
This defaults to the value of the setting 'PARENTAL_CONTROL_AGE_LIMIT'.
default_requires_consent (bool): True if users require parental consent if they
have no specified year of birth (default is True).
Returns:
True if the user requires parental consent.
"""
if age_limit is None:
age_limit = getattr(settings, 'PARENTAL_CONSENT_AGE_LIMIT', None)
if age_limit is None:
return False
# Return True if either:
# a) The user has a year of birth specified and that year is fewer years in the past than the limit.
# b) The user has no year of birth specified and the default is to require consent.
#
# Note: we have to be conservative using the user's year of birth as their birth date could be
# December 31st. This means that if the number of years since their birth year is exactly equal
# to the age limit then we have to assume that they might still not be old enough.
year_of_birth = self.year_of_birth
if year_of_birth is None:
return default_requires_consent
if date is None:
date = datetime.now(UTC)
return date.year - year_of_birth <= age_limit # pylint: disable=maybe-no-member
@receiver(pre_save, sender=UserProfile)
def user_profile_pre_save_callback(sender, **kwargs):
"""
Ensure consistency of a user profile before saving it.
"""
user_profile = kwargs['instance']
# Remove profile images for users who require parental consent
if user_profile.requires_parental_consent() and user_profile.has_profile_image:
user_profile.profile_image_uploaded_at = None
# Cache "old" field values on the model instance so that they can be
# retrieved in the post_save callback when we emit an event with new and
# old field values.
user_profile._changed_fields = get_changed_fields_dict(user_profile, sender)
@receiver(post_save, sender=UserProfile)
def user_profile_post_save_callback(sender, **kwargs):
"""
Emit analytics events after saving the UserProfile.
"""
user_profile = kwargs['instance']
# pylint: disable=protected-access
emit_field_changed_events(
user_profile,
user_profile.user,
sender._meta.db_table,
excluded_fields=['meta']
)
@receiver(pre_save, sender=User)
def user_pre_save_callback(sender, **kwargs):
"""
Capture old fields on the user instance before save and cache them as a
private field on the current model for use in the post_save callback.
"""
user = kwargs['instance']
user._changed_fields = get_changed_fields_dict(user, sender)
@receiver(post_save, sender=User)
def user_post_save_callback(sender, **kwargs):
"""
Emit analytics events after saving the User.
"""
user = kwargs['instance']
# pylint: disable=protected-access
emit_field_changed_events(
user,
user,
sender._meta.db_table,
excluded_fields=['last_login'],
hidden_fields=['password']
)
class UserSignupSource(models.Model): class UserSignupSource(models.Model):
""" """
...@@ -1561,3 +1663,25 @@ class EntranceExamConfiguration(models.Model): ...@@ -1561,3 +1663,25 @@ class EntranceExamConfiguration(models.Model):
except EntranceExamConfiguration.DoesNotExist: except EntranceExamConfiguration.DoesNotExist:
can_skip = False can_skip = False
return can_skip return can_skip
class LanguageProficiency(models.Model):
"""
Represents a user's language proficiency.
Note that we have not found a way to emit analytics change events by using signals directly on this
model or on UserProfile. Therefore if you are changing LanguageProficiency values, it is important
to go through the accounts API (AccountsView) defined in
/edx-platform/openedx/core/djangoapps/user_api/accounts/views.py or its associated api method
(update_account_settings) so that the events are emitted.
"""
class Meta:
unique_together = (('code', 'user_profile'),)
user_profile = models.ForeignKey(UserProfile, db_index=True, related_name='language_proficiencies')
code = models.CharField(
max_length=16,
blank=False,
choices=settings.ALL_LANGUAGES,
help_text=ugettext_lazy("The ISO 639-1 language code for this language.")
)
...@@ -43,7 +43,9 @@ class AutoAuthEnabledTestCase(UrlResetMixin, TestCase): ...@@ -43,7 +43,9 @@ class AutoAuthEnabledTestCase(UrlResetMixin, TestCase):
""" """
self._auto_auth() self._auto_auth()
self.assertEqual(User.objects.count(), 1) self.assertEqual(User.objects.count(), 1)
self.assertTrue(User.objects.all()[0].is_active) user = User.objects.all()[0]
self.assertTrue(user.is_active)
self.assertFalse(user.profile.requires_parental_consent())
def test_create_same_user(self): def test_create_same_user(self):
self._auto_auth(username='test') self._auto_auth(username='test')
......
...@@ -5,7 +5,8 @@ import unittest ...@@ -5,7 +5,8 @@ import unittest
from student.tests.factories import UserFactory, RegistrationFactory, PendingEmailChangeFactory from student.tests.factories import UserFactory, RegistrationFactory, PendingEmailChangeFactory
from student.views import ( from student.views import (
reactivation_email_for_user, change_email_request, do_email_change_request, confirm_email_change reactivation_email_for_user, change_email_request, do_email_change_request, confirm_email_change,
SETTING_CHANGE_INITIATED
) )
from student.models import UserProfile, PendingEmailChange from student.models import UserProfile, PendingEmailChange
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
...@@ -19,6 +20,7 @@ from django.conf import settings ...@@ -19,6 +20,7 @@ from django.conf import settings
from edxmako.shortcuts import render_to_string from edxmako.shortcuts import render_to_string
from edxmako.tests import mako_middleware_process_request from edxmako.tests import mako_middleware_process_request
from util.request import safe_get_host from util.request import safe_get_host
from util.testing import EventTestMixin
class TestException(Exception): class TestException(Exception):
...@@ -198,10 +200,11 @@ class ReactivationEmailTests(EmailTestMixin, TestCase): ...@@ -198,10 +200,11 @@ class ReactivationEmailTests(EmailTestMixin, TestCase):
self.assertTrue(response_data['success']) self.assertTrue(response_data['success'])
class EmailChangeRequestTests(TestCase): class EmailChangeRequestTests(EventTestMixin, TestCase):
"""Test changing a user's email address""" """Test changing a user's email address"""
def setUp(self): def setUp(self):
super(EmailChangeRequestTests, self).setUp('student.views.tracker')
self.user = UserFactory.create() self.user = UserFactory.create()
self.new_email = 'new.email@edx.org' self.new_email = 'new.email@edx.org'
self.req_factory = RequestFactory() self.req_factory = RequestFactory()
...@@ -275,6 +278,7 @@ class EmailChangeRequestTests(TestCase): ...@@ -275,6 +278,7 @@ class EmailChangeRequestTests(TestCase):
send_mail.side_effect = [Exception, None] send_mail.side_effect = [Exception, None]
self.request.POST['new_email'] = "valid@email.com" self.request.POST['new_email'] = "valid@email.com"
self.assertFailedRequest(self.run_request(), 'Unable to send email activation link. Please try again later.') self.assertFailedRequest(self.run_request(), 'Unable to send email activation link. Please try again later.')
self.assert_no_events_were_emitted()
@patch('django.core.mail.send_mail') @patch('django.core.mail.send_mail')
@patch('student.views.render_to_string', Mock(side_effect=mock_render_to_string, autospec=True)) @patch('student.views.render_to_string', Mock(side_effect=mock_render_to_string, autospec=True))
...@@ -295,6 +299,9 @@ class EmailChangeRequestTests(TestCase): ...@@ -295,6 +299,9 @@ class EmailChangeRequestTests(TestCase):
settings.DEFAULT_FROM_EMAIL, settings.DEFAULT_FROM_EMAIL,
[new_email] [new_email]
) )
self.assert_event_emitted(
SETTING_CHANGE_INITIATED, user_id=self.user.id, setting=u'email', old=old_email, new=new_email
)
@patch('django.contrib.auth.models.User.email_user') @patch('django.contrib.auth.models.User.email_user')
......
...@@ -194,7 +194,7 @@ class EnrollmentTest(UrlResetMixin, ModuleStoreTestCase): ...@@ -194,7 +194,7 @@ class EnrollmentTest(UrlResetMixin, ModuleStoreTestCase):
"""Change the student's enrollment status in a course. """Change the student's enrollment status in a course.
Args: Args:
action (string): The action to perform (either "enroll" or "unenroll") action (str): The action to perform (either "enroll" or "unenroll")
Keyword Args: Keyword Args:
course_id (unicode): If provided, use this course ID. Otherwise, use the course_id (unicode): If provided, use this course ID. Otherwise, use the
......
# -*- coding: utf-8 -*-
"""
Test that various events are fired for models in the student app.
"""
from django.test import TestCase
from django_countries.fields import Country
from student.models import PasswordHistory
from student.tests.factories import UserFactory
from student.tests.tests import UserSettingsEventTestMixin
import mock
from django.db.utils import IntegrityError
class TestUserProfileEvents(UserSettingsEventTestMixin, TestCase):
"""
Test that we emit field change events when UserProfile models are changed.
"""
def setUp(self):
super(TestUserProfileEvents, self).setUp()
self.table = 'auth_userprofile'
self.user = UserFactory.create()
self.profile = self.user.profile
self.reset_tracker()
def test_change_one_field(self):
"""
Verify that we emit an event when a single field changes on the user
profile.
"""
self.profile.year_of_birth = 1900
self.profile.save()
self.assert_user_setting_event_emitted(setting='year_of_birth', old=None, new=self.profile.year_of_birth)
# Verify that we remove the temporary `_changed_fields` property from
# the model after we're done emitting events.
with self.assertRaises(AttributeError):
getattr(self.profile, '_changed_fields')
def test_change_many_fields(self):
"""
Verify that we emit one event per field when many fields change on the
user profile in one transaction.
"""
self.profile.gender = u'o'
self.profile.bio = 'test bio'
self.profile.save()
self.assert_user_setting_event_emitted(setting='bio', old=None, new=self.profile.bio)
self.assert_user_setting_event_emitted(setting='gender', old=u'm', new=u'o')
def test_unicode(self):
"""
Verify that the events we emit can handle unicode characters.
"""
old_name = self.profile.name
self.profile.name = u'Dånîél'
self.profile.save()
self.assert_user_setting_event_emitted(setting='name', old=old_name, new=self.profile.name)
def test_country(self):
"""
Verify that we properly serialize the JSON-unfriendly Country field.
"""
self.profile.country = Country(u'AL', 'dummy_flag_url')
self.profile.save()
self.assert_user_setting_event_emitted(setting='country', old=None, new=self.profile.country)
def test_excluded_field(self):
"""
Verify that we don't emit events for ignored fields.
"""
self.profile.meta = {u'foo': u'bar'}
self.profile.save()
self.assert_no_events_were_emitted()
@mock.patch('student.models.UserProfile.save', side_effect=IntegrityError)
def test_no_event_if_save_failed(self, _save_mock):
"""
Verify no event is triggered if the save does not complete. Note that the pre_save
signal is not called in this case either, but the intent is to make it clear that this model
should never emit an event if save fails.
"""
self.profile.gender = "unknown"
with self.assertRaises(IntegrityError):
self.profile.save()
self.assert_no_events_were_emitted()
class TestUserEvents(UserSettingsEventTestMixin, TestCase):
"""
Test that we emit field change events when User models are changed.
"""
def setUp(self):
super(TestUserEvents, self).setUp()
self.user = UserFactory.create()
self.reset_tracker()
self.table = 'auth_user'
def test_change_one_field(self):
"""
Verify that we emit an event when a single field changes on the user.
"""
old_username = self.user.username
self.user.username = u'new username'
self.user.save()
self.assert_user_setting_event_emitted(setting='username', old=old_username, new=self.user.username)
def test_change_many_fields(self):
"""
Verify that we emit one event per field when many fields change on the
user in one transaction.
"""
old_email = self.user.email
old_is_staff = self.user.is_staff
self.user.email = u'foo@bar.com'
self.user.is_staff = True
self.user.save()
self.assert_user_setting_event_emitted(setting='email', old=old_email, new=self.user.email)
self.assert_user_setting_event_emitted(setting='is_staff', old=old_is_staff, new=self.user.is_staff)
def test_password(self):
"""
Verify that password values are not included in the event payload.
"""
self.user.password = u'new password'
self.user.save()
self.assert_user_setting_event_emitted(setting='password', old=None, new=None)
def test_related_fields_ignored(self):
"""
Verify that we don't emit events for related fields.
"""
self.user.passwordhistory_set.add(PasswordHistory(password='new_password'))
self.user.save()
self.assert_no_events_were_emitted()
@mock.patch('django.contrib.auth.models.User.save', side_effect=IntegrityError)
def test_no_event_if_save_failed(self, _save_mock):
"""
Verify no event is triggered if the save does not complete. Note that the pre_save
signal is not called in this case either, but the intent is to make it clear that this model
should never emit an event if save fails.
"""
self.user.password = u'new password'
with self.assertRaises(IntegrityError):
self.user.save()
self.assert_no_events_were_emitted()
"""Unit tests for parental controls."""
import datetime
from django.test import TestCase
from django.test.utils import override_settings
from student.models import UserProfile
from student.tests.factories import UserFactory
class ProfileParentalControlsTest(TestCase):
"""Unit tests for requires_parental_consent."""
password = "test"
def setUp(self):
super(ProfileParentalControlsTest, self).setUp()
self.user = UserFactory.create(password=self.password)
self.profile = UserProfile.objects.get(id=self.user.id)
def set_year_of_birth(self, year_of_birth):
"""
Helper method that creates a mock profile for the specified user.
"""
self.profile.year_of_birth = year_of_birth
self.profile.save()
def test_no_year_of_birth(self):
"""Verify the behavior for users with no specified year of birth."""
self.assertTrue(self.profile.requires_parental_consent())
self.assertTrue(self.profile.requires_parental_consent(default_requires_consent=True))
self.assertFalse(self.profile.requires_parental_consent(default_requires_consent=False))
@override_settings(PARENTAL_CONSENT_AGE_LIMIT=None)
def test_no_parental_controls(self):
"""Verify the behavior for all users when parental controls are not enabled."""
self.assertFalse(self.profile.requires_parental_consent())
self.assertFalse(self.profile.requires_parental_consent(default_requires_consent=True))
self.assertFalse(self.profile.requires_parental_consent(default_requires_consent=False))
# Verify that even a child does not require parental consent
current_year = datetime.datetime.now().year
self.set_year_of_birth(current_year - 10)
self.assertFalse(self.profile.requires_parental_consent())
def test_adult_user(self):
"""Verify the behavior for an adult."""
current_year = datetime.datetime.now().year
self.set_year_of_birth(current_year - 20)
self.assertFalse(self.profile.requires_parental_consent())
self.assertTrue(self.profile.requires_parental_consent(age_limit=21))
def test_child_user(self):
"""Verify the behavior for a child."""
current_year = datetime.datetime.now().year
# Verify for a child born 13 years agp
self.set_year_of_birth(current_year - 13)
self.assertTrue(self.profile.requires_parental_consent())
self.assertTrue(self.profile.requires_parental_consent(date=datetime.date(current_year, 12, 31)))
self.assertFalse(self.profile.requires_parental_consent(date=datetime.date(current_year + 1, 1, 1)))
# Verify for a child born 14 years ago
self.set_year_of_birth(current_year - 14)
self.assertFalse(self.profile.requires_parental_consent())
self.assertFalse(self.profile.requires_parental_consent(date=datetime.date(current_year, 1, 1)))
def test_profile_image(self):
"""Verify that a profile's image obeys parental controls."""
# Verify that an image cannot be set for a user with no year of birth set
self.profile.profile_image_uploaded_at = datetime.datetime.now()
self.profile.save()
self.assertFalse(self.profile.has_profile_image)
# Verify that an image can be set for an adult user
current_year = datetime.datetime.now().year
self.set_year_of_birth(current_year - 20)
self.profile.profile_image_uploaded_at = datetime.datetime.now()
self.profile.save()
self.assertTrue(self.profile.has_profile_image)
# verify that a user's profile image is removed when they switch to requiring parental controls
self.set_year_of_birth(current_year - 10)
self.profile.save()
self.assertFalse(self.profile.has_profile_image)
...@@ -17,20 +17,22 @@ from django.utils.http import int_to_base36 ...@@ -17,20 +17,22 @@ from django.utils.http import int_to_base36
from mock import Mock, patch from mock import Mock, patch
import ddt import ddt
from student.views import password_reset, password_reset_confirm_wrapper from student.views import password_reset, password_reset_confirm_wrapper, SETTING_CHANGE_INITIATED
from student.tests.factories import UserFactory from student.tests.factories import UserFactory
from student.tests.test_email import mock_render_to_string from student.tests.test_email import mock_render_to_string
from util.testing import EventTestMixin
from test_microsite import fake_site_name from test_microsite import fake_site_name
@ddt.ddt @ddt.ddt
class ResetPasswordTests(TestCase): class ResetPasswordTests(EventTestMixin, TestCase):
""" Tests that clicking reset password sends email, and doesn't activate the user """ Tests that clicking reset password sends email, and doesn't activate the user
""" """
request_factory = RequestFactory() request_factory = RequestFactory()
def setUp(self): def setUp(self):
super(ResetPasswordTests, self).setUp('student.views.tracker')
self.user = UserFactory.create() self.user = UserFactory.create()
self.user.is_active = False self.user.is_active = False
self.user.save() self.user.save()
...@@ -55,6 +57,7 @@ class ResetPasswordTests(TestCase): ...@@ -55,6 +57,7 @@ class ResetPasswordTests(TestCase):
'success': True, 'success': True,
'value': "('registration/password_reset_done.html', [])", 'value': "('registration/password_reset_done.html', [])",
}) })
self.assert_no_events_were_emitted()
@patch('student.views.render_to_string', Mock(side_effect=mock_render_to_string, autospec=True)) @patch('student.views.render_to_string', Mock(side_effect=mock_render_to_string, autospec=True))
def test_nonexist_email_password_reset(self): def test_nonexist_email_password_reset(self):
...@@ -71,6 +74,7 @@ class ResetPasswordTests(TestCase): ...@@ -71,6 +74,7 @@ class ResetPasswordTests(TestCase):
'success': True, 'success': True,
'value': "('registration/password_reset_done.html', [])", 'value': "('registration/password_reset_done.html', [])",
}) })
self.assert_no_events_were_emitted()
@patch('student.views.render_to_string', Mock(side_effect=mock_render_to_string, autospec=True)) @patch('student.views.render_to_string', Mock(side_effect=mock_render_to_string, autospec=True))
def test_password_reset_ratelimited(self): def test_password_reset_ratelimited(self):
...@@ -88,6 +92,7 @@ class ResetPasswordTests(TestCase): ...@@ -88,6 +92,7 @@ class ResetPasswordTests(TestCase):
bad_req = self.request_factory.post('/password_reset/', {'email': 'thisdoesnotexist@foo.com'}) bad_req = self.request_factory.post('/password_reset/', {'email': 'thisdoesnotexist@foo.com'})
bad_resp = password_reset(bad_req) bad_resp = password_reset(bad_req)
self.assertEquals(bad_resp.status_code, 403) self.assertEquals(bad_resp.status_code, 403)
self.assert_no_events_were_emitted()
cache.clear() cache.clear()
...@@ -98,6 +103,7 @@ class ResetPasswordTests(TestCase): ...@@ -98,6 +103,7 @@ class ResetPasswordTests(TestCase):
"""Tests contents of reset password email, and that user is not active""" """Tests contents of reset password email, and that user is not active"""
good_req = self.request_factory.post('/password_reset/', {'email': self.user.email}) good_req = self.request_factory.post('/password_reset/', {'email': self.user.email})
good_req.user = self.user
good_resp = password_reset(good_req) good_resp = password_reset(good_req)
self.assertEquals(good_resp.status_code, 200) self.assertEquals(good_resp.status_code, 200)
obj = json.loads(good_resp.content) obj = json.loads(good_resp.content)
...@@ -113,6 +119,10 @@ class ResetPasswordTests(TestCase): ...@@ -113,6 +119,10 @@ class ResetPasswordTests(TestCase):
self.assertEquals(len(to_addrs), 1) self.assertEquals(len(to_addrs), 1)
self.assertIn(self.user.email, to_addrs) self.assertIn(self.user.email, to_addrs)
self.assert_event_emitted(
SETTING_CHANGE_INITIATED, user_id=self.user.id, setting=u'password', old=None, new=None,
)
#test that the user is not active #test that the user is not active
self.user = User.objects.get(pk=self.user.pk) self.user = User.objects.get(pk=self.user.pk)
self.assertFalse(self.user.is_active) self.assertFalse(self.user.is_active)
...@@ -130,12 +140,17 @@ class ResetPasswordTests(TestCase): ...@@ -130,12 +140,17 @@ class ResetPasswordTests(TestCase):
'/password_reset/', {'email': self.user.email} '/password_reset/', {'email': self.user.email}
) )
req.is_secure = Mock(return_value=is_secure) req.is_secure = Mock(return_value=is_secure)
resp = password_reset(req) req.user = self.user
password_reset(req)
_, msg, _, _ = send_email.call_args[0] _, msg, _, _ = send_email.call_args[0]
expected_msg = "Please go to the following page and choose a new password:\n\n" + protocol expected_msg = "Please go to the following page and choose a new password:\n\n" + protocol
self.assertIn(expected_msg, msg) self.assertIn(expected_msg, msg)
self.assert_event_emitted(
SETTING_CHANGE_INITIATED, user_id=self.user.id, setting=u'password', old=None, new=None
)
@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', "Test only valid in LMS") @unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', "Test only valid in LMS")
@patch('django.core.mail.send_mail') @patch('django.core.mail.send_mail')
@ddt.data(('Crazy Awesome Site', 'Crazy Awesome Site'), (None, 'edX')) @ddt.data(('Crazy Awesome Site', 'Crazy Awesome Site'), (None, 'edX'))
...@@ -150,7 +165,8 @@ class ResetPasswordTests(TestCase): ...@@ -150,7 +165,8 @@ class ResetPasswordTests(TestCase):
'/password_reset/', {'email': self.user.email} '/password_reset/', {'email': self.user.email}
) )
req.get_host = Mock(return_value=domain_override) req.get_host = Mock(return_value=domain_override)
resp = password_reset(req) req.user = self.user
password_reset(req)
_, msg, _, _ = send_email.call_args[0] _, msg, _, _ = send_email.call_args[0]
reset_msg = "you requested a password reset for your user account at {}" reset_msg = "you requested a password reset for your user account at {}"
...@@ -164,6 +180,10 @@ class ResetPasswordTests(TestCase): ...@@ -164,6 +180,10 @@ class ResetPasswordTests(TestCase):
sign_off = "The {} Team".format(platform_name) sign_off = "The {} Team".format(platform_name)
self.assertIn(sign_off, msg) self.assertIn(sign_off, msg)
self.assert_event_emitted(
SETTING_CHANGE_INITIATED, user_id=self.user.id, setting=u'password', old=None, new=None
)
@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', "Test only valid in LMS") @unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', "Test only valid in LMS")
@patch("microsite_configuration.microsite.get_value", fake_site_name) @patch("microsite_configuration.microsite.get_value", fake_site_name)
@patch('django.core.mail.send_mail') @patch('django.core.mail.send_mail')
...@@ -176,13 +196,18 @@ class ResetPasswordTests(TestCase): ...@@ -176,13 +196,18 @@ class ResetPasswordTests(TestCase):
'/password_reset/', {'email': self.user.email} '/password_reset/', {'email': self.user.email}
) )
req.get_host = Mock(return_value=None) req.get_host = Mock(return_value=None)
resp = password_reset(req) req.user = self.user
password_reset(req)
_, msg, _, _ = send_email.call_args[0] _, msg, _, _ = send_email.call_args[0]
reset_msg = "you requested a password reset for your user account at openedx.localhost" reset_msg = "you requested a password reset for your user account at openedx.localhost"
self.assertIn(reset_msg, msg) self.assertIn(reset_msg, msg)
self.assert_event_emitted(
SETTING_CHANGE_INITIATED, user_id=self.user.id, setting=u'password', old=None, new=None
)
@patch('student.views.password_reset_confirm') @patch('student.views.password_reset_confirm')
def test_reset_password_bad_token(self, reset_confirm): def test_reset_password_bad_token(self, reset_confirm):
"""Tests bad token and uidb36 in password reset""" """Tests bad token and uidb36 in password reset"""
......
...@@ -21,12 +21,13 @@ from mock import Mock, patch ...@@ -21,12 +21,13 @@ from mock import Mock, patch
from opaque_keys.edx.locations import SlashSeparatedCourseKey from opaque_keys.edx.locations import SlashSeparatedCourseKey
from student.models import ( from student.models import (
anonymous_id_for_user, user_by_anonymous_id, CourseEnrollment, unique_id_for_user, anonymous_id_for_user, user_by_anonymous_id, CourseEnrollment, unique_id_for_user, LinkedInAddToProfileConfiguration
LinkedInAddToProfileConfiguration
) )
from student.views import (process_survey_link, _cert_info, from student.views import (process_survey_link, _cert_info,
change_enrollment, complete_course_mode_info) change_enrollment, complete_course_mode_info)
from student.tests.factories import UserFactory, CourseModeFactory from student.tests.factories import UserFactory, CourseModeFactory
from util.testing import EventTestMixin
from util.model_utils import USER_SETTINGS_CHANGED_EVENT_NAME
from xmodule.modulestore.tests.factories import CourseFactory from xmodule.modulestore.tests.factories import CourseFactory
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
...@@ -485,19 +486,33 @@ class DashboardTest(ModuleStoreTestCase): ...@@ -485,19 +486,33 @@ class DashboardTest(ModuleStoreTestCase):
self.assertContains(response, expected_url) self.assertContains(response, expected_url)
class EnrollmentEventTestMixin(object): class UserSettingsEventTestMixin(EventTestMixin):
""" Mixin with assertions for validating enrollment events. """ """
Mixin for verifying that user setting events were emitted during a test.
"""
def setUp(self): def setUp(self):
super(EnrollmentEventTestMixin, self).setUp() super(UserSettingsEventTestMixin, self).setUp('util.model_utils.tracker')
patcher = patch('student.models.tracker')
self.mock_tracker = patcher.start()
self.addCleanup(patcher.stop)
def assert_no_events_were_emitted(self): def assert_user_setting_event_emitted(self, **kwargs):
"""Ensures no events were emitted since the last event related assertion""" """
self.assertFalse(self.mock_tracker.emit.called) # pylint: disable=maybe-no-member Helper method to assert that we emit the expected user settings events.
self.mock_tracker.reset_mock()
Expected settings are passed in via `kwargs`.
"""
if 'truncated' not in kwargs:
kwargs['truncated'] = []
self.assert_event_emitted(
USER_SETTINGS_CHANGED_EVENT_NAME,
table=self.table, # pylint: disable=no-member
user_id=self.user.id,
**kwargs
)
class EnrollmentEventTestMixin(EventTestMixin):
""" Mixin with assertions for validating enrollment events. """
def setUp(self):
super(EnrollmentEventTestMixin, self).setUp('student.models.tracker')
def assert_enrollment_mode_change_event_was_emitted(self, user, course_key, mode): def assert_enrollment_mode_change_event_was_emitted(self, user, course_key, mode):
"""Ensures an enrollment mode change event was emitted""" """Ensures an enrollment mode change event was emitted"""
......
...@@ -129,6 +129,8 @@ AUDIT_LOG = logging.getLogger("audit") ...@@ -129,6 +129,8 @@ AUDIT_LOG = logging.getLogger("audit")
ReverifyInfo = namedtuple('ReverifyInfo', 'course_id course_name course_number date status display') # pylint: disable=invalid-name ReverifyInfo = namedtuple('ReverifyInfo', 'course_id course_name course_number date status display') # pylint: disable=invalid-name
SETTING_CHANGE_INITIATED = 'edx.user.settings.change_initiated'
def csrf_token(context): def csrf_token(context):
"""A csrf token that can be included in a form.""" """A csrf token that can be included in a form."""
...@@ -620,40 +622,11 @@ def dashboard(request): ...@@ -620,40 +622,11 @@ def dashboard(request):
enrolled_courses_either_paid = frozenset(course.id for course, _enrollment in course_enrollment_pairs enrolled_courses_either_paid = frozenset(course.id for course, _enrollment in course_enrollment_pairs
if _enrollment.is_paid_course()) if _enrollment.is_paid_course())
# get info w.r.t ExternalAuthMap
external_auth_map = None
try:
external_auth_map = ExternalAuthMap.objects.get(user=user)
except ExternalAuthMap.DoesNotExist:
pass
# If there are *any* denied reverifications that have not been toggled off, # If there are *any* denied reverifications that have not been toggled off,
# we'll display the banner # we'll display the banner
denied_banner = any(item.display for item in reverifications["denied"]) denied_banner = any(item.display for item in reverifications["denied"])
language_options = DarkLangConfig.current().released_languages_list
# add in the default language if it's not in the list of released languages
if settings.LANGUAGE_CODE not in language_options:
language_options.append(settings.LANGUAGE_CODE)
# Re-alphabetize language options
language_options.sort()
# try to get the preferred language for the user
preferred_language_code = preferences_api.get_user_preference(request.user, LANGUAGE_KEY)
# try and get the current language of the user
current_language_code = get_language()
if preferred_language_code and preferred_language_code in settings.LANGUAGE_DICT:
# if the user has a preference, get the name from the code
current_language = settings.LANGUAGE_DICT[preferred_language_code]
elif current_language_code in settings.LANGUAGE_DICT:
# if the user's browser is showing a particular language,
# use that as the current language
current_language = settings.LANGUAGE_DICT[current_language_code]
else:
# otherwise, use the default language
current_language = settings.LANGUAGE_DICT[settings.LANGUAGE_CODE]
# Populate the Order History for the side-bar. # Populate the Order History for the side-bar.
order_history_list = order_history(user, course_org_filter=course_org_filter, org_filter_out_set=org_filter_out_set) order_history_list = order_history(user, course_org_filter=course_org_filter, org_filter_out_set=org_filter_out_set)
...@@ -678,7 +651,6 @@ def dashboard(request): ...@@ -678,7 +651,6 @@ def dashboard(request):
'course_enrollment_pairs': course_enrollment_pairs, 'course_enrollment_pairs': course_enrollment_pairs,
'course_optouts': course_optouts, 'course_optouts': course_optouts,
'message': message, 'message': message,
'external_auth_map': external_auth_map,
'staff_access': staff_access, 'staff_access': staff_access,
'errored_courses': errored_courses, 'errored_courses': errored_courses,
'show_courseware_links_for': show_courseware_links_for, 'show_courseware_links_for': show_courseware_links_for,
...@@ -693,11 +665,7 @@ def dashboard(request): ...@@ -693,11 +665,7 @@ def dashboard(request):
'block_courses': block_courses, 'block_courses': block_courses,
'denied_banner': denied_banner, 'denied_banner': denied_banner,
'billing_email': settings.PAYMENT_SUPPORT_EMAIL, 'billing_email': settings.PAYMENT_SUPPORT_EMAIL,
'language_options': language_options,
'current_language': current_language,
'current_language_code': current_language_code,
'user': user, 'user': user,
'duplicate_provider': None,
'logout_url': reverse(logout_user), 'logout_url': reverse(logout_user),
'platform_name': platform_name, 'platform_name': platform_name,
'enrolled_courses_either_paid': enrolled_courses_either_paid, 'enrolled_courses_either_paid': enrolled_courses_either_paid,
...@@ -707,10 +675,6 @@ def dashboard(request): ...@@ -707,10 +675,6 @@ def dashboard(request):
'ccx_membership_triplets': ccx_membership_triplets, 'ccx_membership_triplets': ccx_membership_triplets,
} }
if third_party_auth.is_enabled():
context['duplicate_provider'] = pipeline.get_duplicate_provider(messages.get_messages(request))
context['provider_user_states'] = pipeline.get_provider_user_states(user)
return render_to_response('dashboard.html', context) return render_to_response('dashboard.html', context)
...@@ -1758,13 +1722,14 @@ def auto_auth(request): ...@@ -1758,13 +1722,14 @@ def auto_auth(request):
# If successful, this will return a tuple containing # If successful, this will return a tuple containing
# the new user object. # the new user object.
try: try:
user, _profile, reg = _do_create_account(form) user, profile, reg = _do_create_account(form)
except AccountValidationError: except AccountValidationError:
# Attempt to retrieve the existing user. # Attempt to retrieve the existing user.
user = User.objects.get(username=username) user = User.objects.get(username=username)
user.email = email user.email = email
user.set_password(password) user.set_password(password)
user.save() user.save()
profile = UserProfile.objects.get(user=user)
reg = Registration.objects.get(user=user) reg = Registration.objects.get(user=user)
# Set the user's global staff bit # Set the user's global staff bit
...@@ -1776,6 +1741,12 @@ def auto_auth(request): ...@@ -1776,6 +1741,12 @@ def auto_auth(request):
reg.activate() reg.activate()
reg.save() reg.save()
# ensure parental consent threshold is met
year = datetime.date.today().year
age_limit = settings.PARENTAL_CONSENT_AGE_LIMIT
profile.year_of_birth = (year - age_limit) - 1
profile.save()
# Enroll the user in a course # Enroll the user in a course
if course_key is not None: if course_key is not None:
CourseEnrollment.enroll(user, course_key) CourseEnrollment.enroll(user, course_key)
...@@ -1864,6 +1835,18 @@ def password_reset(request): ...@@ -1864,6 +1835,18 @@ def password_reset(request):
from_email=settings.DEFAULT_FROM_EMAIL, from_email=settings.DEFAULT_FROM_EMAIL,
request=request, request=request,
domain_override=request.get_host()) domain_override=request.get_host())
# When password change is complete, a "edx.user.settings.changed" event will be emitted.
# But because changing the password is multi-step, we also emit an event here so that we can
# track where the request was initiated.
tracker.emit(
SETTING_CHANGE_INITIATED,
{
"setting": "password",
"old": None,
"new": None,
"user_id": request.user.id,
}
)
else: else:
# bad user? tick the rate limiter counter # bad user? tick the rate limiter counter
AUDIT_LOG.info("Bad password_reset user passed in.") AUDIT_LOG.info("Bad password_reset user passed in.")
...@@ -2080,6 +2063,19 @@ def do_email_change_request(user, new_email, activation_key=uuid.uuid4().hex): ...@@ -2080,6 +2063,19 @@ def do_email_change_request(user, new_email, activation_key=uuid.uuid4().hex):
log.error(u'Unable to send email activation link to user from "%s"', from_address, exc_info=True) log.error(u'Unable to send email activation link to user from "%s"', from_address, exc_info=True)
raise ValueError(_('Unable to send email activation link. Please try again later.')) raise ValueError(_('Unable to send email activation link. Please try again later.'))
# When the email address change is complete, a "edx.user.settings.changed" event will be emitted.
# But because changing the email address is multi-step, we also emit an event here so that we can
# track where the request was initiated.
tracker.emit(
SETTING_CHANGE_INITIATED,
{
"setting": "email",
"old": context['old_email'],
"new": context['new_email'],
"user_id": user.id,
}
)
@ensure_csrf_cookie @ensure_csrf_cookie
@transaction.commit_manually @transaction.commit_manually
......
...@@ -114,9 +114,9 @@ AUTH_EMAIL_OPT_IN_KEY = 'email_opt_in' ...@@ -114,9 +114,9 @@ AUTH_EMAIL_OPT_IN_KEY = 'email_opt_in'
# The following are various possible values for the AUTH_ENTRY_KEY. # The following are various possible values for the AUTH_ENTRY_KEY.
AUTH_ENTRY_DASHBOARD = 'dashboard'
AUTH_ENTRY_LOGIN = 'login' AUTH_ENTRY_LOGIN = 'login'
AUTH_ENTRY_REGISTER = 'register' AUTH_ENTRY_REGISTER = 'register'
AUTH_ENTRY_ACCOUNT_SETTINGS = 'account_settings'
# This is left-over from an A/B test # This is left-over from an A/B test
# of the new combined login/registration page (ECOM-369) # of the new combined login/registration page (ECOM-369)
...@@ -142,9 +142,9 @@ def is_api(auth_entry): ...@@ -142,9 +142,9 @@ def is_api(auth_entry):
# We don't use "reverse" here because doing so may cause modules # We don't use "reverse" here because doing so may cause modules
# to load that depend on this module. # to load that depend on this module.
AUTH_DISPATCH_URLS = { AUTH_DISPATCH_URLS = {
AUTH_ENTRY_DASHBOARD: '/dashboard',
AUTH_ENTRY_LOGIN: '/login', AUTH_ENTRY_LOGIN: '/login',
AUTH_ENTRY_REGISTER: '/register', AUTH_ENTRY_REGISTER: '/register',
AUTH_ENTRY_ACCOUNT_SETTINGS: '/account/settings',
# This is left-over from an A/B test # This is left-over from an A/B test
# of the new combined login/registration page (ECOM-369) # of the new combined login/registration page (ECOM-369)
...@@ -156,9 +156,9 @@ AUTH_DISPATCH_URLS = { ...@@ -156,9 +156,9 @@ AUTH_DISPATCH_URLS = {
} }
_AUTH_ENTRY_CHOICES = frozenset([ _AUTH_ENTRY_CHOICES = frozenset([
AUTH_ENTRY_DASHBOARD,
AUTH_ENTRY_LOGIN, AUTH_ENTRY_LOGIN,
AUTH_ENTRY_REGISTER, AUTH_ENTRY_REGISTER,
AUTH_ENTRY_ACCOUNT_SETTINGS,
# This is left-over from an A/B test # This is left-over from an A/B test
# of the new combined login/registration page (ECOM-369) # of the new combined login/registration page (ECOM-369)
...@@ -577,7 +577,7 @@ def login_analytics(strategy, auth_entry, *args, **kwargs): ...@@ -577,7 +577,7 @@ def login_analytics(strategy, auth_entry, *args, **kwargs):
event_name = None event_name = None
if auth_entry in [AUTH_ENTRY_LOGIN, AUTH_ENTRY_LOGIN_2]: if auth_entry in [AUTH_ENTRY_LOGIN, AUTH_ENTRY_LOGIN_2]:
event_name = 'edx.bi.user.account.authenticated' event_name = 'edx.bi.user.account.authenticated'
elif auth_entry in [AUTH_ENTRY_DASHBOARD]: elif auth_entry in [AUTH_ENTRY_ACCOUNT_SETTINGS]:
event_name = 'edx.bi.user.account.linked' event_name = 'edx.bi.user.account.linked'
if event_name is not None: if event_name is not None:
...@@ -623,7 +623,7 @@ def change_enrollment(strategy, auth_entry=None, user=None, *args, **kwargs): ...@@ -623,7 +623,7 @@ def change_enrollment(strategy, auth_entry=None, user=None, *args, **kwargs):
user (User): The user being authenticated. user (User): The user being authenticated.
""" """
# We skip enrollment if the user entered the flow from the "link account" # We skip enrollment if the user entered the flow from the "link account"
# button on the student dashboard. At this point, either: # button on the account settings page. At this point, either:
# #
# 1) The user already had a linked account when they started the enrollment flow, # 1) The user already had a linked account when they started the enrollment flow,
# in which case they would have been enrolled during the normal authentication process. # in which case they would have been enrolled during the normal authentication process.
...@@ -633,7 +633,7 @@ def change_enrollment(strategy, auth_entry=None, user=None, *args, **kwargs): ...@@ -633,7 +633,7 @@ def change_enrollment(strategy, auth_entry=None, user=None, *args, **kwargs):
# args when sending users to this page, successfully authenticating through this page # args when sending users to this page, successfully authenticating through this page
# would also enroll the student in the course. # would also enroll the student in the course.
enroll_course_id = strategy.session_get('enroll_course_id') enroll_course_id = strategy.session_get('enroll_course_id')
if enroll_course_id and auth_entry != AUTH_ENTRY_DASHBOARD: if enroll_course_id and auth_entry != AUTH_ENTRY_ACCOUNT_SETTINGS:
course_id = CourseKey.from_string(enroll_course_id) course_id = CourseKey.from_string(enroll_course_id)
modes = CourseMode.modes_for_course_dict(course_id) modes = CourseMode.modes_for_course_dict(course_id)
......
...@@ -19,6 +19,7 @@ from social.apps.django_app import utils as social_utils ...@@ -19,6 +19,7 @@ from social.apps.django_app import utils as social_utils
from social.apps.django_app import views as social_views from social.apps.django_app import views as social_views
from student import models as student_models from student import models as student_models
from student import views as student_views from student import views as student_views
from student_account.views import account_settings_context
from third_party_auth import middleware, pipeline from third_party_auth import middleware, pipeline
from third_party_auth import settings as auth_settings from third_party_auth import settings as auth_settings
...@@ -110,41 +111,25 @@ class IntegrationTest(testutil.TestCase, test.TestCase): ...@@ -110,41 +111,25 @@ class IntegrationTest(testutil.TestCase, test.TestCase):
self.client = test.Client() self.client = test.Client()
self.request_factory = test.RequestFactory() self.request_factory = test.RequestFactory()
def assert_dashboard_response_looks_correct(self, response, user, duplicate=False, linked=None): def assert_account_settings_context_looks_correct(self, context, user, duplicate=False, linked=None):
"""Asserts the user's dashboard is in the expected state. """Asserts the user's account settings page context is in the expected state.
We check unconditionally that the dashboard 200s and contains the If duplicate is True, we expect context['duplicate_provider'] to contain
user's info. If duplicate is True, we expect the duplicate account the duplicate provider object. If linked is passed, we conditionally
association error to be present. If linked is passed, we conditionally check that the provider is included in context['auth']['providers'] and
check the content and controls in the Account Links section of the its connected state is correct.
sidebar.
""" """
duplicate_account_error_needle = '<section class="dashboard-banner third-party-auth">' if duplicate:
assert_duplicate_presence_fn = self.assertIn if duplicate else self.assertNotIn self.assertEqual(context['duplicate_provider'].NAME, self.PROVIDER_CLASS.NAME)
self.assertEqual(200, response.status_code)
self.assertIn(user.email, response.content.decode('UTF-8'))
self.assertIn(user.username, response.content.decode('UTF-8'))
assert_duplicate_presence_fn(duplicate_account_error_needle, response.content)
if linked is not None:
if linked:
expected_control_text = pipeline.ProviderUserState(
self.PROVIDER_CLASS, user, False).get_unlink_form_name()
else: else:
expected_control_text = pipeline.get_login_url(self.PROVIDER_CLASS.NAME, pipeline.AUTH_ENTRY_DASHBOARD) self.assertIsNone(context['duplicate_provider'])
provider_name = re.search(r'<span class="provider">([^<]+)', response.content, re.DOTALL).groups()[0] if linked is not None:
expected_provider = [
self.assertIn(expected_control_text, response.content) provider for provider in context['auth']['providers'] if provider['name'] == self.PROVIDER_CLASS.NAME
if linked: ][0]
self.assertIn("fa fa-link", response.content) self.assertIsNotNone(expected_provider)
self.assertNotIn("fa fa-unlink", response.content) self.assertEqual(expected_provider['connected'], linked)
else:
self.assertNotIn("fa fa-link", response.content)
self.assertIn("fa fa-unlink", response.content)
self.assertEqual(self.PROVIDER_CLASS.NAME, provider_name)
def assert_exception_redirect_looks_correct(self, expected_uri, auth_entry=None): def assert_exception_redirect_looks_correct(self, expected_uri, auth_entry=None):
"""Tests middleware conditional redirection. """Tests middleware conditional redirection.
...@@ -406,6 +391,11 @@ class IntegrationTest(testutil.TestCase, test.TestCase): ...@@ -406,6 +391,11 @@ class IntegrationTest(testutil.TestCase, test.TestCase):
def test_canceling_authentication_redirects_to_login_when_auth_register_2(self): def test_canceling_authentication_redirects_to_login_when_auth_register_2(self):
self.assert_exception_redirect_looks_correct('/account/register/', auth_entry=pipeline.AUTH_ENTRY_REGISTER_2) self.assert_exception_redirect_looks_correct('/account/register/', auth_entry=pipeline.AUTH_ENTRY_REGISTER_2)
def test_canceling_authentication_redirects_to_account_settings_when_auth_entry_account_settings(self):
self.assert_exception_redirect_looks_correct(
'/account/settings', auth_entry=pipeline.AUTH_ENTRY_ACCOUNT_SETTINGS
)
def test_canceling_authentication_redirects_to_root_when_auth_entry_not_set(self): def test_canceling_authentication_redirects_to_root_when_auth_entry_not_set(self):
self.assert_exception_redirect_looks_correct('/') self.assert_exception_redirect_looks_correct('/')
...@@ -432,7 +422,7 @@ class IntegrationTest(testutil.TestCase, test.TestCase): ...@@ -432,7 +422,7 @@ class IntegrationTest(testutil.TestCase, test.TestCase):
# First we expect that we're in the unlinked state, and that there # First we expect that we're in the unlinked state, and that there
# really is no association in the backend. # really is no association in the backend.
self.assert_dashboard_response_looks_correct(student_views.dashboard(request), request.user, linked=False) self.assert_account_settings_context_looks_correct(account_settings_context(request), request.user, linked=False)
self.assert_social_auth_does_not_exist_for_user(request.user, strategy) self.assert_social_auth_does_not_exist_for_user(request.user, strategy)
# We should be redirected back to the complete page, setting # We should be redirected back to the complete page, setting
...@@ -452,7 +442,7 @@ class IntegrationTest(testutil.TestCase, test.TestCase): ...@@ -452,7 +442,7 @@ class IntegrationTest(testutil.TestCase, test.TestCase):
# Now we expect to be in the linked state, with a backend entry. # Now we expect to be in the linked state, with a backend entry.
self.assert_social_auth_exists_for_user(request.user, strategy) self.assert_social_auth_exists_for_user(request.user, strategy)
self.assert_dashboard_response_looks_correct(student_views.dashboard(request), request.user, linked=True) self.assert_account_settings_context_looks_correct(account_settings_context(request), request.user, linked=True)
def test_full_pipeline_succeeds_for_unlinking_account(self): def test_full_pipeline_succeeds_for_unlinking_account(self):
# First, create, the request and strategy that store pipeline state, # First, create, the request and strategy that store pipeline state,
...@@ -479,7 +469,7 @@ class IntegrationTest(testutil.TestCase, test.TestCase): ...@@ -479,7 +469,7 @@ class IntegrationTest(testutil.TestCase, test.TestCase):
actions.do_complete(strategy, social_views._do_login, user=user) # pylint: disable-msg=protected-access actions.do_complete(strategy, social_views._do_login, user=user) # pylint: disable-msg=protected-access
# First we expect that we're in the linked state, with a backend entry. # First we expect that we're in the linked state, with a backend entry.
self.assert_dashboard_response_looks_correct(student_views.dashboard(request), user, linked=True) self.assert_account_settings_context_looks_correct(account_settings_context(request), user, linked=True)
self.assert_social_auth_exists_for_user(request.user, strategy) self.assert_social_auth_exists_for_user(request.user, strategy)
# Fire off the disconnect pipeline to unlink. # Fire off the disconnect pipeline to unlink.
...@@ -487,7 +477,7 @@ class IntegrationTest(testutil.TestCase, test.TestCase): ...@@ -487,7 +477,7 @@ class IntegrationTest(testutil.TestCase, test.TestCase):
request.social_strategy, request.user, None, redirect_field_name=auth.REDIRECT_FIELD_NAME)) request.social_strategy, request.user, None, redirect_field_name=auth.REDIRECT_FIELD_NAME))
# Now we expect to be in the unlinked state, with no backend entry. # Now we expect to be in the unlinked state, with no backend entry.
self.assert_dashboard_response_looks_correct(student_views.dashboard(request), user, linked=False) self.assert_account_settings_context_looks_correct(account_settings_context(request), user, linked=False)
self.assert_social_auth_does_not_exist_for_user(user, strategy) self.assert_social_auth_does_not_exist_for_user(user, strategy)
def test_linking_already_associated_account_raises_auth_already_associated(self): def test_linking_already_associated_account_raises_auth_already_associated(self):
...@@ -541,8 +531,8 @@ class IntegrationTest(testutil.TestCase, test.TestCase): ...@@ -541,8 +531,8 @@ class IntegrationTest(testutil.TestCase, test.TestCase):
request, request,
exceptions.AuthAlreadyAssociated(self.PROVIDER_CLASS.BACKEND_CLASS.name, 'account is already in use.')) exceptions.AuthAlreadyAssociated(self.PROVIDER_CLASS.BACKEND_CLASS.name, 'account is already in use.'))
self.assert_dashboard_response_looks_correct( self.assert_account_settings_context_looks_correct(
student_views.dashboard(request), user, duplicate=True, linked=True) account_settings_context(request), user, duplicate=True, linked=True)
def test_full_pipeline_succeeds_for_signing_in_to_existing_active_account(self): def test_full_pipeline_succeeds_for_signing_in_to_existing_active_account(self):
# First, create, the request and strategy that store pipeline state, # First, create, the request and strategy that store pipeline state,
...@@ -593,7 +583,7 @@ class IntegrationTest(testutil.TestCase, test.TestCase): ...@@ -593,7 +583,7 @@ class IntegrationTest(testutil.TestCase, test.TestCase):
self.assert_redirect_to_dashboard_looks_correct( self.assert_redirect_to_dashboard_looks_correct(
actions.do_complete(strategy, social_views._do_login, user=user)) actions.do_complete(strategy, social_views._do_login, user=user))
self.assert_dashboard_response_looks_correct(student_views.dashboard(request), user) self.assert_account_settings_context_looks_correct(account_settings_context(request), user)
def test_signin_fails_if_account_not_active(self): def test_signin_fails_if_account_not_active(self):
_, strategy = self.get_request_and_strategy( _, strategy = self.get_request_and_strategy(
...@@ -710,7 +700,7 @@ class IntegrationTest(testutil.TestCase, test.TestCase): ...@@ -710,7 +700,7 @@ class IntegrationTest(testutil.TestCase, test.TestCase):
self.assert_redirect_to_dashboard_looks_correct( self.assert_redirect_to_dashboard_looks_correct(
actions.do_complete(strategy, social_views._do_login, user=created_user)) actions.do_complete(strategy, social_views._do_login, user=created_user))
self.assert_social_auth_exists_for_user(created_user, strategy) self.assert_social_auth_exists_for_user(created_user, strategy)
self.assert_dashboard_response_looks_correct(student_views.dashboard(request), created_user, linked=True) self.assert_account_settings_context_looks_correct(account_settings_context(request), created_user, linked=True)
def test_new_account_registration_assigns_distinct_username_on_collision(self): def test_new_account_registration_assigns_distinct_username_on_collision(self):
original_username = self.get_username() original_username = self.get_username()
......
...@@ -145,9 +145,9 @@ class PipelineEnrollmentTest(UrlResetMixin, ModuleStoreTestCase): ...@@ -145,9 +145,9 @@ class PipelineEnrollmentTest(UrlResetMixin, ModuleStoreTestCase):
strategy = self._fake_strategy() strategy = self._fake_strategy()
strategy.session_set('enroll_course_id', unicode(self.course.id)) strategy.session_set('enroll_course_id', unicode(self.course.id))
# Simulate completing the pipeline from the student dashboard's # Simulate completing the pipeline from the student account settings
# "link account" button. # "link account" button.
result = pipeline.change_enrollment(strategy, 1, user=self.user, auth_entry=pipeline.AUTH_ENTRY_DASHBOARD) # pylint: disable=assignment-from-no-return,redundant-keyword-arg result = pipeline.change_enrollment(strategy, 1, user=self.user, auth_entry=pipeline.AUTH_ENTRY_ACCOUNT_SETTINGS) # pylint: disable=assignment-from-no-return,redundant-keyword-arg
# Verify that we were NOT enrolled # Verify that we were NOT enrolled
self.assertEqual(result, {}) self.assertEqual(result, {})
......
"""
Utilities for django models.
"""
from eventtracking import tracker
from django.conf import settings
from django.core.exceptions import ObjectDoesNotExist
from django.db.models.fields.related import RelatedField
from django_countries.fields import Country
# The setting name used for events when "settings" (account settings, preferences, profile information) change.
USER_SETTINGS_CHANGED_EVENT_NAME = u'edx.user.settings.changed'
def get_changed_fields_dict(instance, model_class):
"""
Helper method for tracking field changes on a model.
Given a model instance and class, return a dict whose keys are that
instance's fields which differ from the last saved ones and whose values
are the old values of those fields. Related fields are not considered.
Args:
instance (Model instance): the model instance with changes that are
being tracked
model_class (Model class): the class of the model instance we are
tracking
Returns:
dict: a mapping of field names to current database values of those
fields, or an empty dict if the model is new
"""
try:
old_model = model_class.objects.get(pk=instance.pk)
except model_class.DoesNotExist:
# Object is new, so fields haven't technically changed. We'll return
# an empty dict as a default value.
return {}
else:
field_names = [
field[0].name for field in model_class._meta.get_fields_with_model()
]
changed_fields = {
field_name: getattr(old_model, field_name) for field_name in field_names
if getattr(old_model, field_name) != getattr(instance, field_name)
}
return changed_fields
def emit_field_changed_events(instance, user, db_table, excluded_fields=None, hidden_fields=None):
"""Emits a settings changed event for each field that has changed.
Note that this function expects that a `_changed_fields` dict has been set
as an attribute on `instance` (see `get_changed_fields_dict`.
Args:
instance (Model instance): the model instance that is being saved
user (User): the user that this instance is associated with
db_table (str): the name of the table that we're modifying
excluded_fields (list): a list of field names for which events should
not be emitted
hidden_fields (list): a list of field names specifying fields whose
values should not be included in the event (None will be used
instead)
Returns:
None
"""
def clean_field(field_name, value):
"""
Prepare a field to be emitted in a JSON serializable format. If
`field_name` is a hidden field, return None.
"""
if field_name in hidden_fields:
return None
# Country is not JSON serializable. Return the country code.
if isinstance(value, Country):
if value.code:
return value.code
else:
return None
return value
excluded_fields = excluded_fields or []
hidden_fields = hidden_fields or []
changed_fields = getattr(instance, '_changed_fields', {})
for field_name in changed_fields:
if field_name not in excluded_fields:
old_value = clean_field(field_name, changed_fields[field_name])
new_value = clean_field(field_name, getattr(instance, field_name))
emit_setting_changed_event(user, db_table, field_name, old_value, new_value)
# Remove the now inaccurate _changed_fields attribute.
if hasattr(instance, '_changed_fields'):
del instance._changed_fields
def emit_setting_changed_event(user, db_table, setting_name, old_value, new_value):
"""Emits an event for a change in a setting.
Args:
user (User): the user that this setting is associated with.
db_table (str): the name of the table that we're modifying.
setting_name (str): the name of the setting being changed.
old_value (object): the value before the change.
new_value (object): the new value being saved.
Returns:
None
"""
# Compute the maximum value length so that two copies can fit into the maximum event size
# in addition to all the other fields recorded.
max_value_length = settings.TRACK_MAX_EVENT / 4
serialized_old_value, old_was_truncated = _get_truncated_setting_value(old_value, max_length=max_value_length)
serialized_new_value, new_was_truncated = _get_truncated_setting_value(new_value, max_length=max_value_length)
truncated_values = []
if old_was_truncated:
truncated_values.append("old")
if new_was_truncated:
truncated_values.append("new")
tracker.emit(
USER_SETTINGS_CHANGED_EVENT_NAME,
{
"setting": setting_name,
"old": serialized_old_value,
"new": serialized_new_value,
"truncated": truncated_values,
"user_id": user.id,
"table": db_table,
}
)
def _get_truncated_setting_value(value, max_length=None):
"""
Returns the truncated form of a setting value.
Returns:
truncated_value (object): the possibly truncated version of the value.
was_truncated (bool): returns true if the serialized value was truncated.
"""
if isinstance(value, basestring) and max_length is not None and len(value) > max_length:
return value[0:max_length], True
else:
return value, False
import sys import sys
from mock import patch
from django.conf import settings from django.conf import settings
from django.core.urlresolvers import clear_url_caches, resolve from django.core.urlresolvers import clear_url_caches, resolve
...@@ -55,3 +57,36 @@ class UrlResetMixin(object): ...@@ -55,3 +57,36 @@ class UrlResetMixin(object):
self._reset_urls(urlconf_modules) self._reset_urls(urlconf_modules)
self.addCleanup(lambda: self._reset_urls(urlconf_modules)) self.addCleanup(lambda: self._reset_urls(urlconf_modules))
class EventTestMixin(object):
"""
Generic mixin for verifying that events were emitted during a test.
"""
def setUp(self, tracker):
super(EventTestMixin, self).setUp()
self.tracker = tracker
patcher = patch(self.tracker)
self.mock_tracker = patcher.start()
self.addCleanup(patcher.stop)
def assert_no_events_were_emitted(self):
"""
Ensures no events were emitted since the last event related assertion.
"""
self.assertFalse(self.mock_tracker.emit.called) # pylint: disable=maybe-no-member
def assert_event_emitted(self, event_name, **kwargs):
"""
Verify that an event was emitted with the given parameters.
"""
self.mock_tracker.emit.assert_any_call( # pylint: disable=maybe-no-member
event_name,
kwargs
)
def reset_tracker(self):
"""
Reset the mock tracker in order to forget about old events.
"""
self.mock_tracker.reset_mock()
define(['sinon', 'underscore'], function(sinon, _) { define(['sinon', 'underscore'], function(sinon, _) {
var fakeServer, fakeRequests, expectRequest, expectJsonRequest, var fakeServer, fakeRequests, expectRequest, expectJsonRequest,
respondWithJson, respondWithError, respondWithTextError, respondToDelete; respondWithJson, respondWithError, respondWithTextError, responseWithNoContent;
/* These utility methods are used by Jasmine tests to create a mock server or /* These utility methods are used by Jasmine tests to create a mock server or
* get reference to mock requests. In either case, the cleanup (restore) is done with * get reference to mock requests. In either case, the cleanup (restore) is done with
...@@ -109,7 +109,7 @@ define(['sinon', 'underscore'], function(sinon, _) { ...@@ -109,7 +109,7 @@ define(['sinon', 'underscore'], function(sinon, _) {
); );
}; };
respondToDelete = function(requests, requestIndex) { respondWithNoContent = function(requests, requestIndex) {
if (_.isUndefined(requestIndex)) { if (_.isUndefined(requestIndex)) {
requestIndex = requests.length - 1; requestIndex = requests.length - 1;
} }
...@@ -125,6 +125,6 @@ define(['sinon', 'underscore'], function(sinon, _) { ...@@ -125,6 +125,6 @@ define(['sinon', 'underscore'], function(sinon, _) {
'respondWithJson': respondWithJson, 'respondWithJson': respondWithJson,
'respondWithError': respondWithError, 'respondWithError': respondWithError,
'respondWithTextError': respondWithTextError, 'respondWithTextError': respondWithTextError,
'respondToDelete': respondToDelete 'respondWithNoContent': respondWithNoContent,
}; };
}); });
"""
Base class for account settings page.
"""
from . import BASE_URL
from bok_choy.page_object import PageObject
from bok_choy.promise import EmptyPromise
from .fields import FieldsMixin
class AccountSettingsPage(FieldsMixin, PageObject):
"""
Tests for Account Settings Page.
"""
url = "{base}/{settings}".format(base=BASE_URL, settings='account/settings')
def is_browser_on_page(self):
return self.q(css='.account-settings-container').present
def sections_structure(self):
"""
Return list of section titles and field titles for each section.
Example: [
{
'title': 'Section Title'
'fields': ['Field 1 title', 'Field 2 title',...]
},
...
]
"""
structure = []
sections = self.q(css='.section')
for section in sections:
section_title_element = section.find_element_by_class_name('section-header')
field_title_elements = section.find_elements_by_class_name('u-field-title')
structure.append({
'title': section_title_element.text,
'fields': [element.text for element in field_title_elements],
})
return structure
def _is_loading_in_progress(self):
"""
Check if loading indicator is visible.
"""
query = self.q(css='.ui-loading-indicator')
return query.present and 'is-hidden' not in query.attrs('class')[0].split()
def wait_for_loading_indicator(self):
"""
Wait for loading indicator to become visible.
"""
EmptyPromise(self._is_loading_in_progress, "Loading is in progress.").fulfill()
...@@ -50,19 +50,18 @@ class DashboardPage(PageObject): ...@@ -50,19 +50,18 @@ class DashboardPage(PageObject):
return self.q(css='h3.course-title > a').map(_get_course_name).results return self.q(css='h3.course-title > a').map(_get_course_name).results
@property @property
def full_name(self): def sidebar_menu_title(self):
"""Return the displayed value for the user's full name""" """
return self.q(css='li.info--username .data').text[0] Return the title value for sidebar menu.
"""
@property return self.q(css='.user-info span.title').text[0]
def email(self):
"""Return the displayed value for the user's email address"""
return self.q(css='li.info--email .data').text[0]
@property @property
def username(self): def sidebar_menu_description(self):
"""Return the displayed value for the user's username""" """
return self.q(css='.username-label').text[0] Return the description text for sidebar menu.
"""
return self.q(css='.user-info span.copy').text[0]
def get_enrollment_mode(self, course_name): def get_enrollment_mode(self, course_name):
"""Get the enrollment mode for a given course on the dashboard. """Get the enrollment mode for a given course on the dashboard.
...@@ -149,27 +148,6 @@ class DashboardPage(PageObject): ...@@ -149,27 +148,6 @@ class DashboardPage(PageObject):
else: else:
return None return None
def change_language(self, code):
"""
Change the language on the dashboard to the language corresponding with `code`.
"""
self.q(css=".edit-language").first.click()
self.q(css='select[name="language"] option[value="{}"]'.format(code)).first.click()
self.q(css="#submit-lang").first.click()
# Clicking the submit-lang button does a jquery ajax post, so make sure that
# has completed before continuing on.
self.wait_for_ajax()
self._changed_lang_promise(code).fulfill()
def _changed_lang_promise(self, code):
def _check_func():
language_is_selected = self.q(css='select[name="language"] option[value="{}"]'.format(code)).selected
modal_is_visible = self.q(css='section#change_language.modal').visible
return (language_is_selected and not modal_is_visible)
return EmptyPromise(_check_func, "language changed and modal hidden")
def pre_requisite_message_displayed(self): def pre_requisite_message_displayed(self):
""" """
Verify if pre-requisite course messages are being displayed. Verify if pre-requisite course messages are being displayed.
...@@ -183,3 +161,28 @@ class DashboardPage(PageObject): ...@@ -183,3 +161,28 @@ class DashboardPage(PageObject):
def get_course_social_sharing_widget(self, widget_name): def get_course_social_sharing_widget(self, widget_name):
""" Retrieves the specified social sharing widget by its classification """ """ Retrieves the specified social sharing widget by its classification """
return self.q(css='a.action-{}'.format(widget_name)) return self.q(css='a.action-{}'.format(widget_name))
def click_username_dropdown(self):
"""
Click username dropdown.
"""
self.q(css='.dropdown').first.click()
@property
def username_dropdown_link_text(self):
"""
Return list username dropdown links.
"""
return self.q(css='.dropdown-menu li a').text
def click_account_settings_link(self):
"""
Click on `Account Settings` link.
"""
self.q(css='.dropdown-menu li a').first.click()
def click_my_profile_link(self):
"""
Click on `My Profile` link.
"""
self.q(css='.dropdown-menu li a').nth(1).click()
...@@ -444,9 +444,9 @@ class DiscussionUserProfilePage(CoursePage): ...@@ -444,9 +444,9 @@ class DiscussionUserProfilePage(CoursePage):
return ( return (
self.q(css='section.discussion-user-threads[data-course-id="{}"]'.format(self.course_id)).present self.q(css='section.discussion-user-threads[data-course-id="{}"]'.format(self.course_id)).present
and and
self.q(css='section.user-profile div.sidebar-username').present self.q(css='section.user-profile a.leaner-profile-link').present
and and
self.q(css='section.user-profile div.sidebar-username').text[0] == self.username self.q(css='section.user-profile a.leaner-profile-link').text[0] == self.username
) )
@wait_for_js @wait_for_js
...@@ -526,6 +526,10 @@ class DiscussionUserProfilePage(CoursePage): ...@@ -526,6 +526,10 @@ class DiscussionUserProfilePage(CoursePage):
"Window is on top" "Window is on top"
).fulfill() ).fulfill()
def click_on_sidebar_username(self):
self.wait_for_page()
self.q(css='.leaner-profile-link').first.click()
class DiscussionTabHomePage(CoursePage, DiscussionPageMixin): class DiscussionTabHomePage(CoursePage, DiscussionPageMixin):
......
# -*- coding: utf-8 -*-
"""
Mixins for fields.
"""
from bok_choy.promise import EmptyPromise
from ...tests.helpers import get_selected_option_text, select_option_by_text
class FieldsMixin(object):
"""
Methods for testing fields in pages.
"""
def field(self, field_id):
"""
Return field with field_id.
"""
query = self.q(css='.u-field-{}'.format(field_id))
return query.text[0] if query.present else None
def wait_for_field(self, field_id):
"""
Wait for a field to appear in DOM.
"""
EmptyPromise(
lambda: self.field(field_id) is not None,
"Field with id \"{0}\" is in DOM.".format(field_id)
).fulfill()
def mode_for_field(self, field_id):
"""
Extract current field mode.
Returns:
`placeholder`/`edit`/`display`
"""
self.wait_for_field(field_id)
query = self.q(css='.u-field-{}'.format(field_id))
if not query.present:
return None
field_classes = query.attrs('class')[0].split()
if 'mode-placeholder' in field_classes:
return 'placeholder'
if 'mode-display' in field_classes:
return 'display'
if 'mode-edit' in field_classes:
return 'edit'
def icon_for_field(self, field_id, icon_id):
"""
Check if field icon is present.
"""
self.wait_for_field(field_id)
query = self.q(css='.u-field-{} .u-field-icon'.format(field_id))
return query.present and icon_id in query.attrs('class')[0].split()
def title_for_field(self, field_id):
"""
Return the title of a field.
"""
self.wait_for_field(field_id)
query = self.q(css='.u-field-{} .u-field-title'.format(field_id))
return query.text[0] if query.present else None
def message_for_field(self, field_id):
"""
Return the current message in a field.
"""
self.wait_for_field(field_id)
query = self.q(css='.u-field-{} .u-field-message'.format(field_id))
return query.text[0] if query.present else None
def wait_for_messsage(self, field_id, message):
"""
Wait for a message to appear in a field.
"""
EmptyPromise(
lambda: message in (self.message_for_field(field_id) or ''),
"Messsage \"{0}\" is visible.".format(message)
).fulfill()
def indicator_for_field(self, field_id):
"""
Return the name of the current indicator in a field.
"""
self.wait_for_field(field_id)
query = self.q(css='.u-field-{} .u-field-message i'.format(field_id))
return [
class_name for class_name
in query.attrs('class')[0].split(' ')
if class_name.startswith('message')
][0].partition('-')[2] if query.present else None
def wait_for_indicator(self, field_id, indicator):
"""
Wait for an indicator to appear in a field.
"""
EmptyPromise(
lambda: indicator == self.indicator_for_field(field_id),
"Indicator \"{0}\" is visible.".format(self.indicator_for_field(field_id))
).fulfill()
def make_field_editable(self, field_id):
"""
Make a field editable.
"""
query = self.q(css='.u-field-{}'.format(field_id))
if not query.present:
return None
field_classes = query.attrs('class')[0].split()
if 'mode-placeholder' in field_classes or 'mode-display' in field_classes:
if field_id == 'bio':
self.q(css='.u-field-bio > .wrapper-u-field').first.click()
else:
self.q(css='.u-field-{}'.format(field_id)).first.click()
def value_for_readonly_field(self, field_id):
"""
Return the value in a readonly field.
"""
self.wait_for_field(field_id)
return self.value_for_text_field(field_id)
def value_for_text_field(self, field_id, value=None):
"""
Get or set the value of a text field.
"""
self.wait_for_field(field_id)
query = self.q(css='.u-field-{} input'.format(field_id))
if not query.present:
return None
if value is not None:
current_value = query.attrs('value')[0]
query.results[0].send_keys(u'\ue003' * len(current_value)) # Delete existing value.
query.results[0].send_keys(value) # Input new value
query.results[0].send_keys(u'\ue007') # Press Enter
return query.attrs('value')[0]
def value_for_textarea_field(self, field_id, value=None):
"""
Get or set the value of a textarea field.
"""
self.wait_for_field(field_id)
self.make_field_editable(field_id)
query = self.q(css='.u-field-{} textarea'.format(field_id))
if not query.present:
return None
if value is not None:
query.fill(value)
query.results[0].send_keys(u'\ue004') # Focus Out using TAB
if self.mode_for_field(field_id) == 'edit':
return query.text[0]
else:
return self.get_non_editable_mode_value(field_id)
def get_non_editable_mode_value(self, field_id):
"""
Return value of field in `display` or `placeholder` mode.
"""
self.wait_for_field(field_id)
return self.q(css='.u-field-{} .u-field-value .u-field-value-readonly'.format(field_id)).text[0]
def value_for_dropdown_field(self, field_id, value=None):
"""
Get or set the value in a dropdown field.
"""
self.wait_for_field(field_id)
self.make_field_editable(field_id)
query = self.q(css='.u-field-{} select'.format(field_id))
if not query.present:
return None
if value is not None:
select_option_by_text(query, value)
if self.mode_for_field(field_id) == 'edit':
return get_selected_option_text(query)
else:
return self.get_non_editable_mode_value(field_id)
def link_title_for_link_field(self, field_id):
"""
Return the title of the link in a link field.
"""
self.wait_for_field(field_id)
query = self.q(css='.u-field-link-title-{}'.format(field_id))
return query.text[0] if query.present else None
def click_on_link_in_link_field(self, field_id):
"""
Click the link in a link field.
"""
self.wait_for_field(field_id)
query = self.q(css='.u-field-{} a'.format(field_id))
if query.present:
query.first.click()
"""
Bok-Choy PageObject class for learner profile page.
"""
from . import BASE_URL
from bok_choy.page_object import PageObject
from .fields import FieldsMixin
from bok_choy.promise import EmptyPromise
from .instructor_dashboard import InstructorDashboardPage
from selenium.webdriver import ActionChains
PROFILE_VISIBILITY_SELECTOR = '#u-field-select-account_privacy option[value="{}"]'
FIELD_ICONS = {
'country': 'fa-map-marker',
'language_proficiencies': 'fa-comment',
}
class LearnerProfilePage(FieldsMixin, PageObject):
"""
PageObject methods for Learning Profile Page.
"""
def __init__(self, browser, username):
"""
Initialize the page.
Arguments:
browser (Browser): The browser instance.
username (str): Profile username.
"""
super(LearnerProfilePage, self).__init__(browser)
self.username = username
@property
def url(self):
"""
Construct a URL to the page.
"""
return BASE_URL + "/u/" + self.username
def is_browser_on_page(self):
"""
Check if browser is showing correct page.
"""
return 'Learner Profile' in self.browser.title
@property
def privacy(self):
"""
Get user profile privacy.
Returns:
'all_users' or 'private'
"""
return 'all_users' if self.q(css=PROFILE_VISIBILITY_SELECTOR.format('all_users')).selected else 'private'
@privacy.setter
def privacy(self, privacy):
"""
Set user profile privacy.
Arguments:
privacy (str): 'all_users' or 'private'
"""
self.wait_for_element_visibility('select#u-field-select-account_privacy', 'Privacy dropdown is visible')
if privacy != self.privacy:
self.q(css=PROFILE_VISIBILITY_SELECTOR.format(privacy)).first.click()
EmptyPromise(lambda: privacy == self.privacy, 'Privacy is set to {}'.format(privacy)).fulfill()
self.wait_for_ajax()
if privacy == 'all_users':
self.wait_for_public_fields()
def field_is_visible(self, field_id):
"""
Check if a field with id set to `field_id` is shown.
Arguments:
field_id (str): field id
Returns:
True/False
"""
self.wait_for_ajax()
return self.q(css='.u-field-{}'.format(field_id)).visible
def field_is_editable(self, field_id):
"""
Check if a field with id set to `field_id` is editable.
Arguments:
field_id (str): field id
Returns:
True/False
"""
self.wait_for_field(field_id)
self.make_field_editable(field_id)
return self.mode_for_field(field_id) == 'edit'
@property
def visible_fields(self):
"""
Return list of visible fields.
"""
self.wait_for_field('username')
fields = ['username', 'country', 'language_proficiencies', 'bio']
return [field for field in fields if self.field_is_visible(field)]
@property
def editable_fields(self):
"""
Return list of editable fields currently shown on page.
"""
self.wait_for_ajax()
self.wait_for_element_visibility('.u-field-username', 'username is not visible')
fields = ['country', 'language_proficiencies', 'bio']
return [field for field in fields if self.field_is_editable(field)]
@property
def privacy_field_visible(self):
"""
Check if profile visibility selector is shown or not.
Returns:
True/False
"""
self.wait_for_ajax()
return self.q(css='#u-field-select-account_privacy').visible
def field_icon_present(self, field_id):
"""
Check if an icon is present for a field. Only dropdown fields have icons.
Arguments:
field_id (str): field id
Returns:
True/False
"""
return self.icon_for_field(field_id, FIELD_ICONS[field_id])
def wait_for_public_fields(self):
"""
Wait for `country`, `language` and `bio` fields to be visible.
"""
EmptyPromise(lambda: self.field_is_visible('country'), 'Country field is visible').fulfill()
EmptyPromise(lambda: self.field_is_visible('language_proficiencies'), 'Language field is visible').fulfill()
EmptyPromise(lambda: self.field_is_visible('bio'), 'About Me field is visible').fulfill()
@property
def profile_forced_private_message(self):
"""
Returns age limit message.
"""
self.wait_for_ajax()
return self.q(css='#u-field-message-account_privacy').text[0]
@property
def age_limit_message_present(self):
"""
Check if age limit message is present.
"""
self.wait_for_ajax()
return self.q(css='#u-field-message-account_privacy').visible
@property
def profile_has_default_image(self):
"""
Return bool if image field has default photo or not.
"""
self.wait_for_field('image')
default_links = self.q(css='.image-frame').attrs('src')
return 'default-profile' in default_links[0] if default_links else False
def mouse_hover(self, element):
"""
Mouse over on given element.
"""
mouse_hover_action = ActionChains(self.browser).move_to_element(element)
mouse_hover_action.perform()
def profile_has_image_with_public_access(self):
"""
Check if image is present with remove/upload access.
"""
self.wait_for_field('image')
self.mouse_hover(self.browser.find_element_by_css_selector('.image-wrapper'))
self.wait_for_element_visibility('.u-field-upload-button', "upload button is visible")
return self.q(css='.u-field-upload-button').visible
def profile_has_image_with_private_access(self):
"""
Check if image is present with remove/upload access.
"""
self.wait_for_field('image')
return self.q(css='.u-field-upload-button').visible
def upload_file(self, filename, wait_for_upload_button=True):
"""
Helper method to upload an image file.
"""
if wait_for_upload_button:
self.wait_for_element_visibility('.u-field-upload-button', "upload button is visible")
file_path = InstructorDashboardPage.get_asset_path(filename)
# make the elements visible.
self.browser.execute_script('$(".u-field-upload-button").css("opacity",1);')
self.browser.execute_script('$(".upload-button-input").css("opacity",1);')
self.wait_for_element_visibility('.upload-button-input', "upload button is visible")
self.browser.execute_script('$(".upload-submit").show();')
# First send_keys will initialize the jquery auto upload plugin.
self.q(css='.upload-button-input').results[0].send_keys(file_path)
self.q(css='.upload-submit').first.click()
self.q(css='.upload-button-input').results[0].send_keys(file_path)
self.wait_for_ajax()
@property
def image_upload_success(self):
"""
Returns the bool, if image is updated or not.
"""
self.wait_for_field('image')
self.wait_for_ajax()
self.wait_for_element_visibility('.image-frame', "image box is visible")
image_link = self.q(css='.image-frame').attrs('src')
return 'default-profile' not in image_link[0]
@property
def profile_image_message(self):
"""
Returns the text message for profile image.
"""
self.wait_for_field('image')
self.wait_for_ajax()
return self.q(css='.message-banner p').text[0]
def remove_profile_image(self):
"""
Removes the profile image.
"""
self.wait_for_field('image')
self.wait_for_ajax()
self.wait_for_element_visibility('.image-wrapper', "remove button is visible")
self.browser.execute_script('$(".u-field-remove-button").css("opacity",1);')
self.mouse_hover(self.browser.find_element_by_css_selector('.image-wrapper'))
self.wait_for_element_visibility('.u-field-remove-button', "remove button is visible")
self.q(css='.u-field-remove-button').first.click()
self.wait_for_ajax()
self.mouse_hover(self.browser.find_element_by_css_selector('.image-wrapper'))
self.wait_for_element_visibility('.u-field-upload-button', "upload button is visible")
return True
@property
def remove_link_present(self):
self.wait_for_field('image')
self.mouse_hover(self.browser.find_element_by_css_selector('.image-wrapper'))
return self.q(css='.u-field-remove-button').visible
...@@ -19,6 +19,8 @@ from ...pages.lms.discussion import ( ...@@ -19,6 +19,8 @@ from ...pages.lms.discussion import (
DiscussionTabHomePage, DiscussionTabHomePage,
DiscussionSortPreferencePage, DiscussionSortPreferencePage,
) )
from ...pages.lms.learner_profile import LearnerProfilePage
from ...fixtures.course import CourseFixture, XBlockFixtureDesc from ...fixtures.course import CourseFixture, XBlockFixtureDesc
from ...fixtures.discussion import ( from ...fixtures.discussion import (
SingleThreadViewFixture, SingleThreadViewFixture,
...@@ -753,6 +755,24 @@ class DiscussionUserProfileTest(UniqueCourseTest): ...@@ -753,6 +755,24 @@ class DiscussionUserProfileTest(UniqueCourseTest):
page.wait_for_ajax() page.wait_for_ajax()
self.assertTrue(page.is_window_on_top()) self.assertTrue(page.is_window_on_top())
def test_redirects_to_learner_profile(self):
"""
Scenario: Verify that learner-profile link is present on forum discussions page and we can navigate to it.
Given that I am on discussion forum user's profile page.
And I can see a username on left sidebar
When I click on my username.
Then I will be navigated to Learner Profile page.
And I can my username on Learner Profile page
"""
learner_profile_page = LearnerProfilePage(self.browser, self.PROFILED_USERNAME)
page = self.check_pages(1)
page.click_on_sidebar_username()
learner_profile_page.wait_for_page()
self.assertTrue(learner_profile_page.field_is_visible('username'))
@attr('shard_1') @attr('shard_1')
class DiscussionSearchAlertTest(UniqueCourseTest): class DiscussionSearchAlertTest(UniqueCourseTest):
......
...@@ -277,25 +277,81 @@ class EventsTestMixin(object): ...@@ -277,25 +277,81 @@ class EventsTestMixin(object):
def setUp(self): def setUp(self):
super(EventsTestMixin, self).setUp() super(EventsTestMixin, self).setUp()
self.event_collection = MongoClient()["test"]["events"] self.event_collection = MongoClient()["test"]["events"]
self.event_collection.drop() self.reset_event_tracking()
self.start_time = datetime.now()
def assert_event_emitted_num_times(self, event_name, event_time, event_user_id, num_times_emitted): def assert_event_emitted_num_times(self, event_name, event_time, event_user_id, num_times_emitted, **kwargs):
""" """
Tests the number of times a particular event was emitted. Tests the number of times a particular event was emitted.
Extra kwargs get passed to the mongo query in the form: "event.<key>: value".
:param event_name: Expected event name (e.g., "edx.course.enrollment.activated") :param event_name: Expected event name (e.g., "edx.course.enrollment.activated")
:param event_time: Latest expected time, after which the event would fire (e.g., the beginning of the test case) :param event_time: Latest expected time, after which the event would fire (e.g., the beginning of the test case)
:param event_user_id: user_id expected in the event :param event_user_id: user_id expected in the event
:param num_times_emitted: number of times the event is expected to appear since the event_time :param num_times_emitted: number of times the event is expected to appear since the event_time
""" """
self.assertEqual( find_kwargs = {
self.event_collection.find(
{
"name": event_name, "name": event_name,
"time": {"$gt": event_time}, "time": {"$gt": event_time},
"event.user_id": int(event_user_id), "event.user_id": int(event_user_id),
} }
).count(), num_times_emitted find_kwargs.update({"event.{}".format(key): value for key, value in kwargs.items()})
matching_events = self.event_collection.find(find_kwargs)
self.assertEqual(matching_events.count(), num_times_emitted, '\n'.join(str(event) for event in matching_events))
def reset_event_tracking(self):
"""
Resets all event tracking so that previously captured events are removed.
"""
self.event_collection.drop()
self.start_time = datetime.now()
def get_matching_events(self, username, event_type):
"""
Returns a cursor for the matching browser events related emitted for the specified username.
"""
return self.event_collection.find({
"username": username,
"event_type": event_type,
"time": {"$gt": self.start_time},
})
def verify_events_of_type(self, username, event_type, expected_events, expected_referers=None):
"""Verify that the expected events of a given type were logged.
Args:
username (str): The name of the user for which events will be tested.
event_type (str): The type of event to be verified.
expected_events (list): A list of dicts representing the events that should
have been fired.
expected_referers (list): A list of strings representing the referers for each event
that should been fired (optional). If present, the actual referers compared
with this list, checking that the expected_referers are the suffixes of
actual_referers. For example, if one event is expected, specifying ["/account/settings"]
will verify that the referer for the single event ends with "/account/settings".
"""
EmptyPromise(
lambda: self.get_matching_events(username, event_type).count() >= len(expected_events),
"Waiting for the minimum number of events of type {type} to have been recorded".format(type=event_type)
).fulfill()
# Verify that the correct events were fired
cursor = self.get_matching_events(username, event_type)
actual_events = []
actual_referers = []
for __ in range(0, cursor.count()):
emitted_data = cursor.next()
event = emitted_data["event"]
if emitted_data["event_source"] == "browser":
event = json.loads(event)
actual_events.append(event)
actual_referers.append(emitted_data["referer"])
self.assertEqual(expected_events, actual_events)
if expected_referers is not None:
self.assertEqual(len(expected_referers), len(actual_referers), "Number of expected referers is incorrect")
for index, actual_referer in enumerate(actual_referers):
self.assertTrue(
actual_referer.endswith(expected_referers[index]),
"Refer '{0}' does not have correct suffix, '{1}'.".format(actual_referer, expected_referers[index])
) )
......
...@@ -173,9 +173,11 @@ class RegisterFromCombinedPageTest(UniqueCourseTest): ...@@ -173,9 +173,11 @@ class RegisterFromCombinedPageTest(UniqueCourseTest):
course_names = self.dashboard_page.wait_for_page().available_courses course_names = self.dashboard_page.wait_for_page().available_courses
self.assertIn(self.course_info["display_name"], course_names) self.assertIn(self.course_info["display_name"], course_names)
self.assertEqual("Test User", self.dashboard_page.full_name) self.assertEqual("want to change your account settings?", self.dashboard_page.sidebar_menu_title.lower())
self.assertEqual(email, self.dashboard_page.email) self.assertEqual(
self.assertEqual(username, self.dashboard_page.username) "click the arrow next to your username above.",
self.dashboard_page.sidebar_menu_description.lower()
)
def test_register_failure(self): def test_register_failure(self):
# Navigate to the registration page # Navigate to the registration page
...@@ -369,61 +371,6 @@ class PayAndVerifyTest(EventsTestMixin, UniqueCourseTest): ...@@ -369,61 +371,6 @@ class PayAndVerifyTest(EventsTestMixin, UniqueCourseTest):
self.assertEqual(enrollment_mode, 'verified') self.assertEqual(enrollment_mode, 'verified')
class LanguageTest(WebAppTest):
"""
Tests that the change language functionality on the dashboard works
"""
def setUp(self):
"""
Initiailize dashboard page
"""
super(LanguageTest, self).setUp()
self.dashboard_page = DashboardPage(self.browser)
self.test_new_lang = 'eo'
# This string is unicode for "ÇÜRRÉNT ÇØÜRSÉS", which should appear in our Dummy Esperanto page
# We store the string this way because Selenium seems to try and read in strings from
# the HTML in this format. Ideally we could just store the raw ÇÜRRÉNT ÇØÜRSÉS string here
self.current_courses_text = u'\xc7\xdcRR\xc9NT \xc7\xd6\xdcRS\xc9S'
self.username = "test"
self.password = "testpass"
self.email = "test@example.com"
def test_change_lang(self):
AutoAuthPage(self.browser).visit()
self.dashboard_page.visit()
# Change language to Dummy Esperanto
self.dashboard_page.change_language(self.test_new_lang)
changed_text = self.dashboard_page.current_courses_text
# We should see the dummy-language text on the page
self.assertIn(self.current_courses_text, changed_text)
def test_language_persists(self):
auto_auth_page = AutoAuthPage(self.browser, username=self.username, password=self.password, email=self.email)
auto_auth_page.visit()
self.dashboard_page.visit()
# Change language to Dummy Esperanto
self.dashboard_page.change_language(self.test_new_lang)
# destroy session
self.browser.delete_all_cookies()
# log back in
auto_auth_page.visit()
self.dashboard_page.visit()
changed_text = self.dashboard_page.current_courses_text
# We should see the dummy-language text on the page
self.assertIn(self.current_courses_text, changed_text)
class CourseWikiTest(UniqueCourseTest): class CourseWikiTest(UniqueCourseTest):
""" """
Tests that verify the course wiki. Tests that verify the course wiki.
......
...@@ -77,6 +77,9 @@ class OrdersViewTests(EnrollmentEventTestMixin, EcommerceApiTestMixin, ModuleSto ...@@ -77,6 +77,9 @@ class OrdersViewTests(EnrollmentEventTestMixin, EcommerceApiTestMixin, ModuleSto
sku=uuid4().hex.decode('ascii') sku=uuid4().hex.decode('ascii')
) )
# Ignore events fired from UserFactory creation
self.reset_tracker()
def test_login_required(self): def test_login_required(self):
""" """
The view should return HTTP 403 status if the user is not logged in. The view should return HTTP 403 status if the user is not logged in.
......
...@@ -15,7 +15,7 @@ from course_modes.models import CourseMode ...@@ -15,7 +15,7 @@ from course_modes.models import CourseMode
from courseware import courses from courseware import courses
from enrollment.api import add_enrollment from enrollment.api import add_enrollment
from student.models import CourseEnrollment from student.models import CourseEnrollment
from util.authentication import SessionAuthenticationAllowInactiveUser from openedx.core.lib.api.authentication import SessionAuthenticationAllowInactiveUser
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
......
...@@ -9,6 +9,7 @@ import xml.sax.saxutils as saxutils ...@@ -9,6 +9,7 @@ import xml.sax.saxutils as saxutils
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
from django.core.context_processors import csrf from django.core.context_processors import csrf
from django.core.urlresolvers import reverse
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.http import Http404, HttpResponseBadRequest from django.http import Http404, HttpResponseBadRequest
from django.views.decorators.http import require_GET from django.views.decorators.http import require_GET
...@@ -411,16 +412,18 @@ def user_profile(request, course_key, user_id): ...@@ -411,16 +412,18 @@ def user_profile(request, course_key, user_id):
'annotated_content_info': _attr_safe_json(annotated_content_info), 'annotated_content_info': _attr_safe_json(annotated_content_info),
}) })
else: else:
django_user = User.objects.get(id=user_id)
context = { context = {
'course': course, 'course': course,
'user': request.user, 'user': request.user,
'django_user': User.objects.get(id=user_id), 'django_user': django_user,
'profiled_user': profiled_user.to_dict(), 'profiled_user': profiled_user.to_dict(),
'threads': _attr_safe_json(threads), 'threads': _attr_safe_json(threads),
'user_info': _attr_safe_json(user_info), 'user_info': _attr_safe_json(user_info),
'annotated_content_info': _attr_safe_json(annotated_content_info), 'annotated_content_info': _attr_safe_json(annotated_content_info),
'page': query_params['page'], 'page': query_params['page'],
'num_pages': query_params['num_pages'], 'num_pages': query_params['num_pages'],
'learner_profile_page_url': reverse('learner_profile', kwargs={'username': django_user.username})
} }
return render_to_response('discussion/user_profile.html', context) return render_to_response('discussion/user_profile.html', context)
......
...@@ -2,14 +2,16 @@ ...@@ -2,14 +2,16 @@
Common utility methods and decorators for Mobile APIs. Common utility methods and decorators for Mobile APIs.
""" """
import functools import functools
from rest_framework import permissions from rest_framework import permissions
from util.authentication import SessionAuthenticationAllowInactiveUser, OAuth2AuthenticationAllowInactiveUser
from opaque_keys.edx.keys import CourseKey from opaque_keys.edx.keys import CourseKey
from xmodule.modulestore.django import modulestore from xmodule.modulestore.django import modulestore
from courseware.courses import get_course_with_access from courseware.courses import get_course_with_access
from openedx.core.lib.api.authentication import (
SessionAuthenticationAllowInactiveUser,
OAuth2AuthenticationAllowInactiveUser,
)
from openedx.core.lib.api.permissions import IsUserInUrl from openedx.core.lib.api.permissions import IsUserInUrl
......
...@@ -9,20 +9,24 @@ import json ...@@ -9,20 +9,24 @@ import json
import mock import mock
import ddt import ddt
import markupsafe import markupsafe
from django.test import TestCase
from django.conf import settings from django.conf import settings
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
from django.core import mail from django.core import mail
from django.contrib import messages
from django.contrib.messages.middleware import MessageMiddleware
from django.test import TestCase
from django.test.utils import override_settings from django.test.utils import override_settings
from django.test.client import RequestFactory
from util.testing import UrlResetMixin
from third_party_auth.tests.testutil import simulate_running_pipeline
from embargo.test_utils import restrict_course from embargo.test_utils import restrict_course
from openedx.core.djangoapps.user_api.accounts.api import activate_account, create_account from openedx.core.djangoapps.user_api.accounts.api import activate_account, create_account
from openedx.core.djangoapps.user_api.accounts import EMAIL_MAX_LENGTH from openedx.core.djangoapps.user_api.accounts import EMAIL_MAX_LENGTH
from student.tests.factories import CourseModeFactory, UserFactory
from student_account.views import account_settings_context
from third_party_auth.tests.testutil import simulate_running_pipeline
from util.testing import UrlResetMixin
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory from xmodule.modulestore.tests.factories import CourseFactory
from student.tests.factories import CourseModeFactory
@ddt.ddt @ddt.ddt
...@@ -499,3 +503,66 @@ class StudentAccountLoginAndRegistrationTest(UrlResetMixin, ModuleStoreTestCase) ...@@ -499,3 +503,66 @@ class StudentAccountLoginAndRegistrationTest(UrlResetMixin, ModuleStoreTestCase)
url=reverse("social:begin", kwargs={"backend": backend_name}), url=reverse("social:begin", kwargs={"backend": backend_name}),
params=urlencode(params) params=urlencode(params)
) )
class AccountSettingsViewTest(TestCase):
""" Tests for the account settings view. """
USERNAME = 'student'
PASSWORD = 'password'
FIELDS = [
'country',
'gender',
'language',
'level_of_education',
'password',
'year_of_birth',
'preferred_language',
]
@mock.patch("django.conf.settings.MESSAGE_STORAGE", 'django.contrib.messages.storage.cookie.CookieStorage')
def setUp(self):
super(AccountSettingsViewTest, self).setUp()
self.user = UserFactory.create(username=self.USERNAME, password=self.PASSWORD)
self.client.login(username=self.USERNAME, password=self.PASSWORD)
self.request = RequestFactory()
self.request.user = self.user
# Python-social saves auth failure notifcations in Django messages.
# See pipeline.get_duplicate_provider() for details.
self.request.COOKIES = {}
MessageMiddleware().process_request(self.request)
messages.error(self.request, 'Facebook is already in use.', extra_tags='Auth facebook')
def test_context(self):
context = account_settings_context(self.request)
user_accounts_api_url = reverse("accounts_api", kwargs={'username': self.user.username})
self.assertEqual(context['user_accounts_api_url'], user_accounts_api_url)
user_preferences_api_url = reverse('preferences_api', kwargs={'username': self.user.username})
self.assertEqual(context['user_preferences_api_url'], user_preferences_api_url)
for attribute in self.FIELDS:
self.assertIn(attribute, context['fields'])
self.assertEqual(
context['user_accounts_api_url'], reverse("accounts_api", kwargs={'username': self.user.username})
)
self.assertEqual(
context['user_preferences_api_url'], reverse('preferences_api', kwargs={'username': self.user.username})
)
self.assertEqual(context['duplicate_provider'].BACKEND_CLASS.name, 'facebook')
self.assertEqual(context['auth']['providers'][0]['name'], 'Facebook')
self.assertEqual(context['auth']['providers'][1]['name'], 'Google')
def test_view(self):
view_path = reverse('account_settings')
response = self.client.get(path=view_path)
for attribute in self.FIELDS:
self.assertIn(attribute, response.content)
...@@ -11,3 +11,8 @@ if settings.FEATURES.get('ENABLE_COMBINED_LOGIN_REGISTRATION'): ...@@ -11,3 +11,8 @@ if settings.FEATURES.get('ENABLE_COMBINED_LOGIN_REGISTRATION'):
url(r'^register/$', 'login_and_registration_form', {'initial_mode': 'register'}, name='account_register'), url(r'^register/$', 'login_and_registration_form', {'initial_mode': 'register'}, name='account_register'),
url(r'^password$', 'password_change_request_handler', name='password_change_request'), url(r'^password$', 'password_change_request_handler', name='password_change_request'),
) )
urlpatterns += patterns(
'student_account.views',
url(r'^settings$', 'account_settings', name='account_settings'),
)
...@@ -5,30 +5,39 @@ import json ...@@ -5,30 +5,39 @@ import json
from ipware.ip import get_ip from ipware.ip import get_ip
from django.conf import settings from django.conf import settings
from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.http import ( from django.http import (
HttpResponse, HttpResponseBadRequest, HttpResponseForbidden HttpResponse, HttpResponseBadRequest, HttpResponseForbidden
) )
from django.shortcuts import redirect from django.shortcuts import redirect
from django.http import HttpRequest from django.http import HttpRequest
from django_countries import countries
from django.core.urlresolvers import reverse, resolve from django.core.urlresolvers import reverse, resolve
from django.utils.translation import ugettext as _ from django.utils.translation import ugettext as _
from django_future.csrf import ensure_csrf_cookie from django_future.csrf import ensure_csrf_cookie
from django.views.decorators.http import require_http_methods from django.views.decorators.http import require_http_methods
from opaque_keys.edx.keys import CourseKey from lang_pref.api import released_languages
from opaque_keys import InvalidKeyError from opaque_keys import InvalidKeyError
from opaque_keys.edx.keys import CourseKey
from edxmako.shortcuts import render_to_response from edxmako.shortcuts import render_to_response
from microsite_configuration import microsite from microsite_configuration import microsite
from embargo import api as embargo_api from embargo import api as embargo_api
import third_party_auth
from external_auth.login_and_register import ( from external_auth.login_and_register import (
login as external_auth_login, login as external_auth_login,
register as external_auth_register register as external_auth_register
) )
from student.models import UserProfile
from student.views import ( from student.views import (
signin_user as old_login_view, signin_user as old_login_view,
register_user as old_register_view register_user as old_register_view
) )
from student_account.helpers import auth_pipeline_urls
import third_party_auth
from third_party_auth import pipeline
from util.bad_request_rate_limiter import BadRequestRateLimiter
from openedx.core.djangoapps.user_api.accounts.api import request_password_change from openedx.core.djangoapps.user_api.accounts.api import request_password_change
from openedx.core.djangoapps.user_api.errors import UserNotFound from openedx.core.djangoapps.user_api.errors import UserNotFound
...@@ -294,3 +303,96 @@ def _external_auth_intercept(request, mode): ...@@ -294,3 +303,96 @@ def _external_auth_intercept(request, mode):
return external_auth_login(request) return external_auth_login(request)
elif mode == "register": elif mode == "register":
return external_auth_register(request) return external_auth_register(request)
@login_required
@require_http_methods(['GET'])
def account_settings(request):
"""Render the current user's account settings page.
Args:
request (HttpRequest)
Returns:
HttpResponse: 200 if the page was sent successfully
HttpResponse: 302 if not logged in (redirect to login page)
HttpResponse: 405 if using an unsupported HTTP method
Example usage:
GET /account/settings
"""
return render_to_response('student_account/account_settings.html', account_settings_context(request))
def account_settings_context(request):
""" Context for the account settings page.
Args:
request: The request object.
Returns:
dict
"""
user = request.user
country_options = [
(country_code, _(country_name)) # pylint: disable=translation-of-non-string
for country_code, country_name in sorted(
countries.countries, key=lambda(__, name): unicode(name)
)
]
year_of_birth_options = [(unicode(year), unicode(year)) for year in UserProfile.VALID_YEARS]
context = {
'auth': {},
'duplicate_provider': None,
'fields': {
'country': {
'options': country_options,
}, 'gender': {
'options': [(choice[0], _(choice[1])) for choice in UserProfile.GENDER_CHOICES], # pylint: disable=translation-of-non-string
}, 'language': {
'options': released_languages(),
}, 'level_of_education': {
'options': [(choice[0], _(choice[1])) for choice in UserProfile.LEVEL_OF_EDUCATION_CHOICES], # pylint: disable=translation-of-non-string
}, 'password': {
'url': reverse('password_reset'),
}, 'year_of_birth': {
'options': year_of_birth_options,
}, 'preferred_language': {
'options': settings.ALL_LANGUAGES,
}
},
'platform_name': settings.PLATFORM_NAME,
'user_accounts_api_url': reverse("accounts_api", kwargs={'username': user.username}),
'user_preferences_api_url': reverse('preferences_api', kwargs={'username': user.username}),
}
if third_party_auth.is_enabled():
# If the account on the third party provider is already connected with another edX account,
# we display a message to the user.
context['duplicate_provider'] = pipeline.get_duplicate_provider(messages.get_messages(request))
auth_states = pipeline.get_provider_user_states(user)
context['auth']['providers'] = [{
'name': state.provider.NAME, # The name of the provider e.g. Facebook
'connected': state.has_account, # Whether the user's edX account is connected with the provider.
# If the user is not connected, they should be directed to this page to authenticate
# with the particular provider.
'connect_url': pipeline.get_login_url(
state.provider.NAME,
pipeline.AUTH_ENTRY_ACCOUNT_SETTINGS,
# The url the user should be directed to after the auth process has completed.
redirect_url=reverse('account_settings'),
),
# If the user is connected, sending a POST request to this url removes the connection
# information for this provider from their edX account.
'disconnect_url': pipeline.get_disconnect_url(state.provider.NAME),
} for state in auth_states]
return context
# -*- coding: utf-8 -*-
""" Tests for student profile views. """
from django.conf import settings
from django.core.urlresolvers import reverse
from django.test import TestCase
from util.testing import UrlResetMixin
from student.tests.factories import UserFactory
from student_profile.views import learner_profile_context
class LearnerProfileViewTest(UrlResetMixin, TestCase):
""" Tests for the student profile view. """
USERNAME = "username"
PASSWORD = "password"
CONTEXT_DATA = [
'default_public_account_fields',
'accounts_api_url',
'preferences_api_url',
'account_settings_page_url',
'has_preferences_access',
'own_profile',
'country_options',
'language_options',
]
def setUp(self):
super(LearnerProfileViewTest, self).setUp()
self.user = UserFactory.create(username=self.USERNAME, password=self.PASSWORD)
self.client.login(username=self.USERNAME, password=self.PASSWORD)
def test_context(self):
"""
Verify learner profile page context data.
"""
context = learner_profile_context(self.user.username, self.USERNAME, self.user.is_staff)
self.assertEqual(
context['data']['default_public_account_fields'],
settings.ACCOUNT_VISIBILITY_CONFIGURATION['public_fields']
)
self.assertEqual(
context['data']['accounts_api_url'],
reverse("accounts_api", kwargs={'username': self.user.username})
)
self.assertEqual(
context['data']['preferences_api_url'],
reverse('preferences_api', kwargs={'username': self.user.username})
)
self.assertEqual(
context['data']['profile_image_upload_url'],
reverse("profile_image_upload", kwargs={'username': self.user.username})
)
self.assertEqual(
context['data']['profile_image_remove_url'],
reverse('profile_image_remove', kwargs={'username': self.user.username})
)
self.assertEqual(
context['data']['profile_image_max_bytes'],
settings.PROFILE_IMAGE_MAX_BYTES
)
self.assertEqual(
context['data']['profile_image_min_bytes'],
settings.PROFILE_IMAGE_MIN_BYTES
)
self.assertEqual(context['data']['account_settings_page_url'], reverse('account_settings'))
for attribute in self.CONTEXT_DATA:
self.assertIn(attribute, context['data'])
def test_view(self):
"""
Verify learner profile page view.
"""
profile_path = reverse('learner_profile', kwargs={'username': self.USERNAME})
response = self.client.get(path=profile_path)
for attribute in self.CONTEXT_DATA:
self.assertIn(attribute, response.content)
def test_undefined_profile_page(self):
"""
Verify that a 404 is returned for a non-existent profile page.
"""
profile_path = reverse('learner_profile', kwargs={'username': "no_such_user"})
response = self.client.get(path=profile_path)
self.assertEqual(404, response.status_code)
""" Views for a student's profile information. """
from django.conf import settings
from django.core.exceptions import ObjectDoesNotExist
from django_countries import countries
from django.core.urlresolvers import reverse
from django.contrib.auth.decorators import login_required
from django.http import HttpResponse
from django.views.decorators.http import require_http_methods
from edxmako.shortcuts import render_to_response
from student.models import User
from django.utils.translation import ugettext as _
@login_required
@require_http_methods(['GET'])
def learner_profile(request, username):
"""Render the profile page for the specified username.
Args:
request (HttpRequest)
username (str): username of user whose profile is requested.
Returns:
HttpResponse: 200 if the page was sent successfully
HttpResponse: 302 if not logged in (redirect to login page)
HttpResponse: 404 if the specified username does not exist
HttpResponse: 405 if using an unsupported HTTP method
Example usage:
GET /account/profile
"""
try:
return render_to_response(
'student_profile/learner_profile.html',
learner_profile_context(request.user.username, username, request.user.is_staff)
)
except ObjectDoesNotExist:
return HttpResponse(status=404)
def learner_profile_context(logged_in_username, profile_username, user_is_staff):
"""Context for the learner profile page.
Args:
logged_in_username (str): Username of user logged In user.
profile_username (str): username of user whose profile is requested.
user_is_staff (bool): Logged In user has staff access.
Returns:
dict
Raises:
ObjectDoesNotExist: the specified profile_username does not exist.
"""
profile_user = User.objects.get(username=profile_username)
country_options = [
(country_code, _(country_name)) # pylint: disable=translation-of-non-string
for country_code, country_name in sorted(
countries.countries, key=lambda(__, name): unicode(name)
)
]
context = {
'data': {
'profile_user_id': profile_user.id,
'default_public_account_fields': settings.ACCOUNT_VISIBILITY_CONFIGURATION['public_fields'],
'default_visibility': settings.ACCOUNT_VISIBILITY_CONFIGURATION['default_visibility'],
'accounts_api_url': reverse("accounts_api", kwargs={'username': profile_username}),
'preferences_api_url': reverse('preferences_api', kwargs={'username': profile_username}),
'profile_image_upload_url': reverse('profile_image_upload', kwargs={'username': profile_username}),
'profile_image_remove_url': reverse('profile_image_remove', kwargs={'username': profile_username}),
'profile_image_max_bytes': settings.PROFILE_IMAGE_MAX_BYTES,
'profile_image_min_bytes': settings.PROFILE_IMAGE_MIN_BYTES,
'account_settings_page_url': reverse('account_settings'),
'has_preferences_access': (logged_in_username == profile_username or user_is_staff),
'own_profile': (logged_in_username == profile_username),
'country_options': country_options,
'language_options': settings.ALL_LANGUAGES,
}
}
return context
...@@ -131,6 +131,10 @@ if STATIC_URL_BASE: ...@@ -131,6 +131,10 @@ if STATIC_URL_BASE:
if not STATIC_URL.endswith("/"): if not STATIC_URL.endswith("/"):
STATIC_URL += "/" STATIC_URL += "/"
# MEDIA_ROOT specifies the directory where user-uploaded files are stored.
MEDIA_ROOT = ENV_TOKENS.get('MEDIA_ROOT', MEDIA_ROOT)
MEDIA_URL = ENV_TOKENS.get('MEDIA_URL', MEDIA_URL)
PLATFORM_NAME = ENV_TOKENS.get('PLATFORM_NAME', PLATFORM_NAME) PLATFORM_NAME = ENV_TOKENS.get('PLATFORM_NAME', PLATFORM_NAME)
# For displaying on the receipt. At Stanford PLATFORM_NAME != MERCHANT_NAME, but PLATFORM_NAME is a fine default # For displaying on the receipt. At Stanford PLATFORM_NAME != MERCHANT_NAME, but PLATFORM_NAME is a fine default
PLATFORM_TWITTER_ACCOUNT = ENV_TOKENS.get('PLATFORM_TWITTER_ACCOUNT', PLATFORM_TWITTER_ACCOUNT) PLATFORM_TWITTER_ACCOUNT = ENV_TOKENS.get('PLATFORM_TWITTER_ACCOUNT', PLATFORM_TWITTER_ACCOUNT)
...@@ -594,3 +598,10 @@ if FEATURES.get('INDIVIDUAL_DUE_DATES'): ...@@ -594,3 +598,10 @@ if FEATURES.get('INDIVIDUAL_DUE_DATES'):
FIELD_OVERRIDE_PROVIDERS += ( FIELD_OVERRIDE_PROVIDERS += (
'courseware.student_field_overrides.IndividualStudentOverrideProvider', 'courseware.student_field_overrides.IndividualStudentOverrideProvider',
) )
# PROFILE IMAGE CONFIG
PROFILE_IMAGE_BACKEND = ENV_TOKENS.get('PROFILE_IMAGE_BACKEND', PROFILE_IMAGE_BACKEND)
PROFILE_IMAGE_DEFAULT_FILENAME = ENV_TOKENS.get('PROFILE_IMAGE_DEFAULT_FILENAME', PROFILE_IMAGE_DEFAULT_FILENAME)
PROFILE_IMAGE_SECRET_KEY = AUTH_TOKENS.get('PROFILE_IMAGE_SECRET_KEY', PROFILE_IMAGE_SECRET_KEY)
PROFILE_IMAGE_MAX_BYTES = ENV_TOKENS.get('PROFILE_IMAGE_MAX_BYTES', PROFILE_IMAGE_MAX_BYTES)
PROFILE_IMAGE_MIN_BYTES = ENV_TOKENS.get('PROFILE_IMAGE_MIN_BYTES', PROFILE_IMAGE_MIN_BYTES)
...@@ -49,6 +49,15 @@ ...@@ -49,6 +49,15 @@
], ],
"port": 27017 "port": 27017
}, },
"TRACKING_BACKENDS": {
"mongo": {
"ENGINE": "track.backends.mongodb.MongoBackend",
"OPTIONS": {
"database": "test",
"collection": "events"
}
}
},
"EVENT_TRACKING_BACKENDS": { "EVENT_TRACKING_BACKENDS": {
"mongo": { "mongo": {
"ENGINE": "eventtracking.backends.mongodb.MongoBackend", "ENGINE": "eventtracking.backends.mongodb.MongoBackend",
......
...@@ -131,6 +131,14 @@ MOCK_SEARCH_BACKING_FILE = ( ...@@ -131,6 +131,14 @@ MOCK_SEARCH_BACKING_FILE = (
import uuid import uuid
SECRET_KEY = uuid.uuid4().hex SECRET_KEY = uuid.uuid4().hex
# Set dummy values for profile image settings.
PROFILE_IMAGE_BACKEND = {
'class': 'storages.backends.overwrite.OverwriteStorage',
'options': {
'location': os.path.join(MEDIA_ROOT, 'profile-images/'),
'base_url': os.path.join(MEDIA_URL, 'profile-images/'),
},
}
##################################################################### #####################################################################
# Lastly, see if the developer has any local overrides. # Lastly, see if the developer has any local overrides.
try: try:
......
...@@ -317,9 +317,6 @@ FEATURES = { ...@@ -317,9 +317,6 @@ FEATURES = {
# Set to True to change the course sorting behavior by their start dates, latest first. # Set to True to change the course sorting behavior by their start dates, latest first.
'ENABLE_COURSE_SORTING_BY_START_DATE': True, 'ENABLE_COURSE_SORTING_BY_START_DATE': True,
# Flag to enable new user account APIs.
'ENABLE_USER_REST_API': False,
# Expose Mobile REST API. Note that if you use this, you must also set # Expose Mobile REST API. Note that if you use this, you must also set
# ENABLE_OAUTH2_PROVIDER to True # ENABLE_OAUTH2_PROVIDER to True
'ENABLE_MOBILE_REST_API': False, 'ENABLE_MOBILE_REST_API': False,
...@@ -780,6 +777,10 @@ STATICFILES_DIRS = [ ...@@ -780,6 +777,10 @@ STATICFILES_DIRS = [
FAVICON_PATH = 'images/favicon.ico' FAVICON_PATH = 'images/favicon.ico'
# User-uploaded content
MEDIA_ROOT = '/edx/var/edxapp/media/'
MEDIA_URL = '/media/'
# Locale/Internationalization # Locale/Internationalization
TIME_ZONE = 'America/New_York' # http://en.wikipedia.org/wiki/List_of_tz_zones_by_name TIME_ZONE = 'America/New_York' # http://en.wikipedia.org/wiki/List_of_tz_zones_by_name
LANGUAGE_CODE = 'en' # http://www.i18nguy.com/unicode/language-identifiers.html LANGUAGE_CODE = 'en' # http://www.i18nguy.com/unicode/language-identifiers.html
...@@ -987,6 +988,12 @@ EDXNOTES_INTERFACE = { ...@@ -987,6 +988,12 @@ EDXNOTES_INTERFACE = {
'url': 'http://localhost:8120/api/v1', 'url': 'http://localhost:8120/api/v1',
} }
########################## Parental controls config #######################
# The age at which a learner no longer requires parental consent, or None
# if parental consent is never required.
PARENTAL_CONSENT_AGE_LIMIT = 13
################################# Jasmine ################################## ################################# Jasmine ##################################
JASMINE_TEST_DIRECTORY = PROJECT_ROOT + '/static/coffee' JASMINE_TEST_DIRECTORY = PROJECT_ROOT + '/static/coffee'
...@@ -1544,7 +1551,7 @@ BULK_EMAIL_RETRY_DELAY_BETWEEN_SENDS = 0.02 ...@@ -1544,7 +1551,7 @@ BULK_EMAIL_RETRY_DELAY_BETWEEN_SENDS = 0.02
############################# Email Opt In #################################### ############################# Email Opt In ####################################
# Minimum age for organization-wide email opt in # Minimum age for organization-wide email opt in
EMAIL_OPTIN_MINIMUM_AGE = 13 EMAIL_OPTIN_MINIMUM_AGE = PARENTAL_CONSENT_AGE_LIMIT
############################## Video ########################################## ############################## Video ##########################################
...@@ -1918,6 +1925,8 @@ TIME_ZONE_DISPLAYED_FOR_DEADLINES = 'UTC' ...@@ -1918,6 +1925,8 @@ TIME_ZONE_DISPLAYED_FOR_DEADLINES = 'UTC'
# Source: # Source:
# http://loc.gov/standards/iso639-2/ISO-639-2_utf-8.txt according to http://en.wikipedia.org/wiki/ISO_639-1 # http://loc.gov/standards/iso639-2/ISO-639-2_utf-8.txt according to http://en.wikipedia.org/wiki/ISO_639-1
# Note that this is used as the set of choices to the `code` field of the
# `LanguageProficiency` model.
ALL_LANGUAGES = ( ALL_LANGUAGES = (
[u"aa", u"Afar"], [u"aa", u"Afar"],
[u"ab", u"Abkhazian"], [u"ab", u"Abkhazian"],
...@@ -2216,7 +2225,7 @@ ONLOAD_BEACON_SAMPLE_RATE = 0.0 ...@@ -2216,7 +2225,7 @@ ONLOAD_BEACON_SAMPLE_RATE = 0.0
ACCOUNT_VISIBILITY_CONFIGURATION = { ACCOUNT_VISIBILITY_CONFIGURATION = {
# Default visibility level for accounts without a specified value # Default visibility level for accounts without a specified value
# The value is one of: 'all_users', 'private' # The value is one of: 'all_users', 'private'
"default_visibility": "private", "default_visibility": "all_users",
# The list of all fields that can be shared with other users # The list of all fields that can be shared with other users
"shareable_fields": [ "shareable_fields": [
...@@ -2224,7 +2233,7 @@ ACCOUNT_VISIBILITY_CONFIGURATION = { ...@@ -2224,7 +2233,7 @@ ACCOUNT_VISIBILITY_CONFIGURATION = {
'profile_image', 'profile_image',
'country', 'country',
'time_zone', 'time_zone',
'languages', 'language_proficiencies',
'bio', 'bio',
], ],
...@@ -2248,3 +2257,29 @@ CHECKPOINT_PATTERN = r'(?P<checkpoint_name>\w+)' ...@@ -2248,3 +2257,29 @@ CHECKPOINT_PATTERN = r'(?P<checkpoint_name>\w+)'
# 'courseware.student_field_overrides.IndividualStudentOverrideProvider' to # 'courseware.student_field_overrides.IndividualStudentOverrideProvider' to
# this setting. # this setting.
FIELD_OVERRIDE_PROVIDERS = () FIELD_OVERRIDE_PROVIDERS = ()
# PROFILE IMAGE CONFIG
# WARNING: Certain django storage backends do not support atomic
# file overwrites (including the default, OverwriteStorage) - instead
# there are separate calls to delete and then write a new file in the
# storage backend. This introduces the risk of a race condition
# occurring when a user uploads a new profile image to replace an
# earlier one (the file will temporarily be deleted).
PROFILE_IMAGE_BACKEND = {
'class': 'storages.backends.overwrite.OverwriteStorage',
'options': {
'location': os.path.join(MEDIA_ROOT, 'profile-images/'),
'base_url': os.path.join(MEDIA_URL, 'profile-images/'),
},
}
PROFILE_IMAGE_DEFAULT_FILENAME = (
'images/edx-theme/default-profile' if FEATURES['IS_EDX_DOMAIN'] else 'images/default-theme/default-profile'
)
PROFILE_IMAGE_DEFAULT_FILE_EXTENSION = 'png'
# This secret key is used in generating unguessable URLs to users'
# profile images. Once it has been set, changing it will make the
# platform unaware of current image URLs, resulting in reverting all
# users' profile images to the default placeholder image.
PROFILE_IMAGE_SECRET_KEY = 'placeholder secret key'
PROFILE_IMAGE_MAX_BYTES = 1024 * 1024
PROFILE_IMAGE_MIN_BYTES = 100
...@@ -74,6 +74,9 @@ FEATURES['ENABLE_COMBINED_LOGIN_REGISTRATION'] = True ...@@ -74,6 +74,9 @@ FEATURES['ENABLE_COMBINED_LOGIN_REGISTRATION'] = True
# Need wiki for courseware views to work. TODO (vshnayder): shouldn't need it. # Need wiki for courseware views to work. TODO (vshnayder): shouldn't need it.
WIKI_ENABLED = True WIKI_ENABLED = True
# Enable a parental consent age limit for testing
PARENTAL_CONSENT_AGE_LIMIT = 13
# Makes the tests run much faster... # Makes the tests run much faster...
SOUTH_TESTS_MIGRATE = False # To disable migrations and use syncdb instead SOUTH_TESTS_MIGRATE = False # To disable migrations and use syncdb instead
...@@ -265,7 +268,6 @@ FEATURES['ENABLE_OAUTH2_PROVIDER'] = True ...@@ -265,7 +268,6 @@ FEATURES['ENABLE_OAUTH2_PROVIDER'] = True
FEATURES['ENABLE_MOBILE_REST_API'] = True FEATURES['ENABLE_MOBILE_REST_API'] = True
FEATURES['ENABLE_MOBILE_SOCIAL_FACEBOOK_FEATURES'] = True FEATURES['ENABLE_MOBILE_SOCIAL_FACEBOOK_FEATURES'] = True
FEATURES['ENABLE_VIDEO_ABSTRACTION_LAYER_API'] = True FEATURES['ENABLE_VIDEO_ABSTRACTION_LAYER_API'] = True
FEATURES['ENABLE_USER_REST_API'] = True
###################### Payment ##############################3 ###################### Payment ##############################3
# Enable fake payment processing page # Enable fake payment processing page
...@@ -473,3 +475,17 @@ FEATURES['CERTIFICATES_HTML_VIEW'] = True ...@@ -473,3 +475,17 @@ FEATURES['CERTIFICATES_HTML_VIEW'] = True
INSTALLED_APPS += ('ccx',) INSTALLED_APPS += ('ccx',)
MIDDLEWARE_CLASSES += ('ccx.overrides.CcxMiddleware',) MIDDLEWARE_CLASSES += ('ccx.overrides.CcxMiddleware',)
FEATURES['CUSTOM_COURSES_EDX'] = True FEATURES['CUSTOM_COURSES_EDX'] = True
# Set dummy values for profile image settings.
PROFILE_IMAGE_BACKEND = {
'class': 'storages.backends.overwrite.OverwriteStorage',
'options': {
'location': MEDIA_ROOT,
'base_url': 'http://example-storage.com/profile-images/',
},
}
PROFILE_IMAGE_DEFAULT_FILENAME = 'default'
PROFILE_IMAGE_DEFAULT_FILE_EXTENSION = 'png'
PROFILE_IMAGE_SECRET_KEY = 'secret'
PROFILE_IMAGE_MAX_BYTES = 1024 * 1024
PROFILE_IMAGE_MIN_BYTES = 100
...@@ -23,10 +23,7 @@ ...@@ -23,10 +23,7 @@
* Specifically: * Specifically:
* - dashboard * - dashboard
* - signInUser * - signInUser
* - passwordReset
* - changeEmail
* - changeEmailSettings * - changeEmailSettings
* - changeName
* - verifyToggleBannerFailedOff * - verifyToggleBannerFailedOff
*/ */
edx.dashboard.legacy.init = function(urls) { edx.dashboard.legacy.init = function(urls) {
...@@ -160,67 +157,6 @@ ...@@ -160,67 +157,6 @@
} }
}); });
$('#pwd_reset_button').click(function() {
$.post(
urls.passwordReset,
{"email" : $('#id_email').val()},
function() {
$("#password_reset_complete_link").click();
}
);
});
$("#submit-lang").click(function(event) {
event.preventDefault();
$.post('/lang_pref/setlang/',
{language: $('#settings-language-value').val()}
).done(function() {
// submit form as normal
$('.settings-language-form').submit();
});
});
$("#change_email_form").submit(function(){
var new_email = $('#new_email_field').val();
var new_password = $('#new_email_password').val();
$.post(
urls.changeEmail,
{"new_email" : new_email, "password" : new_password},
function(data) {
if (data.success) {
$("#change_email_title").html(gettext("Please verify your new email address"));
$("#change_email_form").html(
"<p>" +
gettext("You'll receive a confirmation in your inbox. Please follow the link in the email to confirm your email address change.") +
"</p>"
);
} else {
$("#change_email_error").html(data.error).stop().css("display", "block");
}
}
);
return false;
});
$("#change_name_form").submit(function(){
var new_name = $('#new_name_field').val();
var rationale = $('#name_rationale_field').val();
$.post(
urls.changeName,
{"new_name":new_name, "rationale":rationale},
function(data) {
if(data.success) {
location.reload();
} else {
$("#change_name_error").html(data.error).stop().css("display", "block");
}
}
);
return false;
});
$("#email_settings_form").submit(function(){ $("#email_settings_form").submit(function(){
$.ajax({ $.ajax({
type: "POST", type: "POST",
...@@ -240,24 +176,6 @@ ...@@ -240,24 +176,6 @@
return false; return false;
}); });
accessibleModal(
".edit-name",
"#apply_name_change .close-modal",
"#apply_name_change",
"#dashboard-main"
);
accessibleModal(
".edit-email",
"#change_email .close-modal",
"#change_email",
"#dashboard-main"
);
accessibleModal(
"#pwd_reset_button",
"#password_reset_complete .close-modal",
"#password_reset_complete",
"#dashboard-main"
);
$(".action-email-settings").each(function(index){ $(".action-email-settings").each(function(index){
$(this).attr("id", "email-settings-" + index); $(this).attr("id", "email-settings-" + index);
......
<div class="message-banner" aria-live="polite"></div>
<div class="wrapper-profile">
<div class="ui-loading-indicator">
<p>
<span class="spin">
<i class="icon fa fa-refresh"></i>
</span>
<span class="copy">
Loading
</span>
</p>
</div>
<div class="ui-loading-error is-hidden">
<i class="fa fa-exclamation-triangle message-error" aria-hidden=true></i>
<span class="copy">
An error occurred. Please reload the page.
</span>
</div>
</div>
...@@ -30,6 +30,7 @@ ...@@ -30,6 +30,7 @@
'backbone': 'xmodule_js/common_static/js/vendor/backbone-min', 'backbone': 'xmodule_js/common_static/js/vendor/backbone-min',
'backbone.associations': 'xmodule_js/common_static/js/vendor/backbone-associations-min', 'backbone.associations': 'xmodule_js/common_static/js/vendor/backbone-associations-min',
'backbone.paginator': 'xmodule_js/common_static/js/vendor/backbone.paginator.min', 'backbone.paginator': 'xmodule_js/common_static/js/vendor/backbone.paginator.min',
"backbone-super": "js/vendor/backbone-super",
'tinymce': 'xmodule_js/common_static/js/vendor/tinymce/js/tinymce/tinymce.full.min', 'tinymce': 'xmodule_js/common_static/js/vendor/tinymce/js/tinymce/tinymce.full.min',
'jquery.tinymce': 'xmodule_js/common_static/js/vendor/tinymce/js/tinymce/jquery.tinymce', 'jquery.tinymce': 'xmodule_js/common_static/js/vendor/tinymce/js/tinymce/jquery.tinymce',
'xmodule': 'xmodule_js/src/xmodule', 'xmodule': 'xmodule_js/src/xmodule',
...@@ -58,6 +59,7 @@ ...@@ -58,6 +59,7 @@
// Manually specify LMS files that are not converted to RequireJS // Manually specify LMS files that are not converted to RequireJS
'history': 'js/vendor/history', 'history': 'js/vendor/history',
'js/mustache': 'js/mustache',
'js/verify_student/photocapture': 'js/verify_student/photocapture', 'js/verify_student/photocapture': 'js/verify_student/photocapture',
'js/staff_debug_actions': 'js/staff_debug_actions', 'js/staff_debug_actions': 'js/staff_debug_actions',
'js/vendor/jquery.qubit': 'js/vendor/jquery.qubit', 'js/vendor/jquery.qubit': 'js/vendor/jquery.qubit',
...@@ -88,6 +90,9 @@ ...@@ -88,6 +90,9 @@
'js/student_account/views/RegisterView': 'js/student_account/views/RegisterView', 'js/student_account/views/RegisterView': 'js/student_account/views/RegisterView',
'js/student_account/views/AccessView': 'js/student_account/views/AccessView', 'js/student_account/views/AccessView': 'js/student_account/views/AccessView',
'js/student_profile/profile': 'js/student_profile/profile', 'js/student_profile/profile': 'js/student_profile/profile',
'js/student_profile/views/learner_profile_fields': 'js/student_profile/views/learner_profile_fields',
'js/student_profile/views/learner_profile_factory': 'js/student_profile/views/learner_profile_factory',
'js/student_profile/views/learner_profile_view': 'js/student_profile/views/learner_profile_view',
// edxnotes // edxnotes
'annotator_1.2.9': 'xmodule_js/common_static/js/vendor/edxnotes/annotator-full.min' 'annotator_1.2.9': 'xmodule_js/common_static/js/vendor/edxnotes/annotator-full.min'
...@@ -197,6 +202,9 @@ ...@@ -197,6 +202,9 @@
deps: ['backbone'], deps: ['backbone'],
exports: 'Backbone.Paginator' exports: 'Backbone.Paginator'
}, },
"backbone-super": {
deps: ["backbone"],
},
'youtube': { 'youtube': {
exports: 'YT' exports: 'YT'
}, },
...@@ -583,7 +591,14 @@ ...@@ -583,7 +591,14 @@
'lms/include/js/spec/student_account/enrollment_spec.js', 'lms/include/js/spec/student_account/enrollment_spec.js',
'lms/include/js/spec/student_account/emailoptin_spec.js', 'lms/include/js/spec/student_account/emailoptin_spec.js',
'lms/include/js/spec/student_account/shoppingcart_spec.js', 'lms/include/js/spec/student_account/shoppingcart_spec.js',
'lms/include/js/spec/student_account/account_settings_factory_spec.js',
'lms/include/js/spec/student_account/account_settings_fields_spec.js',
'lms/include/js/spec/student_account/account_settings_view_spec.js',
'lms/include/js/spec/student_profile/profile_spec.js', 'lms/include/js/spec/student_profile/profile_spec.js',
'lms/include/js/spec/views/fields_spec.js',
'lms/include/js/spec/student_profile/learner_profile_factory_spec.js',
'lms/include/js/spec/student_profile/learner_profile_view_spec.js',
'lms/include/js/spec/student_profile/learner_profile_fields_spec.js',
'lms/include/js/spec/verify_student/pay_and_verify_view_spec.js', 'lms/include/js/spec/verify_student/pay_and_verify_view_spec.js',
'lms/include/js/spec/verify_student/webcam_photo_view_spec.js', 'lms/include/js/spec/verify_student/webcam_photo_view_spec.js',
'lms/include/js/spec/verify_student/image_input_spec.js', 'lms/include/js/spec/verify_student/image_input_spec.js',
......
define(['backbone', 'jquery', 'underscore', 'js/common_helpers/ajax_helpers', 'js/common_helpers/template_helpers',
'js/spec/views/fields_helpers',
'js/spec/student_account/helpers',
'js/spec/student_account/account_settings_fields_helpers',
'js/student_account/views/account_settings_factory',
'js/student_account/views/account_settings_view'
],
function (Backbone, $, _, AjaxHelpers, TemplateHelpers, FieldViewsSpecHelpers, Helpers,
AccountSettingsFieldViewSpecHelpers, AccountSettingsPage) {
'use strict';
describe("edx.user.AccountSettingsFactory", function () {
var FIELDS_DATA = {
'country': {
'options': Helpers.FIELD_OPTIONS
}, 'gender': {
'options': Helpers.FIELD_OPTIONS
}, 'language': {
'options': Helpers.FIELD_OPTIONS
}, 'level_of_education': {
'options': Helpers.FIELD_OPTIONS
}, 'password': {
'url': '/password_reset'
}, 'year_of_birth': {
'options': Helpers.FIELD_OPTIONS
}, 'preferred_language': {
'options': Helpers.FIELD_OPTIONS
}
};
var AUTH_DATA = {
'providers': [
{
'name': "Network1",
'connected': true,
'connect_url': 'yetanother1.com/auth/connect',
'disconnect_url': 'yetanother1.com/auth/disconnect'
},
{
'name': "Network2",
'connected': true,
'connect_url': 'yetanother2.com/auth/connect',
'disconnect_url': 'yetanother2.com/auth/disconnect'
}
]
};
var requests;
beforeEach(function () {
setFixtures('<div class="wrapper-account-settings"></div>');
TemplateHelpers.installTemplate('templates/fields/field_readonly');
TemplateHelpers.installTemplate('templates/fields/field_dropdown');
TemplateHelpers.installTemplate('templates/fields/field_link');
TemplateHelpers.installTemplate('templates/fields/field_text');
TemplateHelpers.installTemplate('templates/student_account/account_settings');
});
it("shows loading error when UserAccountModel fails to load", function() {
requests = AjaxHelpers.requests(this);
var context = AccountSettingsPage(
FIELDS_DATA, AUTH_DATA, Helpers.USER_ACCOUNTS_API_URL, Helpers.USER_PREFERENCES_API_URL
);
var accountSettingsView = context.accountSettingsView;
Helpers.expectLoadingIndicatorIsVisible(accountSettingsView, true);
Helpers.expectLoadingErrorIsVisible(accountSettingsView, false);
Helpers.expectSettingsSectionsButNotFieldsToBeRendered(accountSettingsView);
var request = requests[0];
expect(request.method).toBe('GET');
expect(request.url).toBe(Helpers.USER_ACCOUNTS_API_URL);
AjaxHelpers.respondWithError(requests, 500);
Helpers.expectLoadingIndicatorIsVisible(accountSettingsView, false);
Helpers.expectLoadingErrorIsVisible(accountSettingsView, true);
Helpers.expectSettingsSectionsButNotFieldsToBeRendered(accountSettingsView);
});
it("shows loading error when UserPreferencesModel fails to load", function() {
requests = AjaxHelpers.requests(this);
var context = AccountSettingsPage(
FIELDS_DATA, AUTH_DATA, Helpers.USER_ACCOUNTS_API_URL, Helpers.USER_PREFERENCES_API_URL
);
var accountSettingsView = context.accountSettingsView;
Helpers.expectLoadingIndicatorIsVisible(accountSettingsView, true);
Helpers.expectLoadingErrorIsVisible(accountSettingsView, false);
Helpers.expectSettingsSectionsButNotFieldsToBeRendered(accountSettingsView);
var request = requests[0];
expect(request.method).toBe('GET');
expect(request.url).toBe(Helpers.USER_ACCOUNTS_API_URL);
AjaxHelpers.respondWithJson(requests, Helpers.createAccountSettingsData());
Helpers.expectLoadingIndicatorIsVisible(accountSettingsView, true);
Helpers.expectLoadingErrorIsVisible(accountSettingsView, false);
Helpers.expectSettingsSectionsButNotFieldsToBeRendered(accountSettingsView);
request = requests[1];
expect(request.method).toBe('GET');
expect(request.url).toBe(Helpers.USER_PREFERENCES_API_URL);
AjaxHelpers.respondWithError(requests, 500);
Helpers.expectLoadingIndicatorIsVisible(accountSettingsView, false);
Helpers.expectLoadingErrorIsVisible(accountSettingsView, true);
Helpers.expectSettingsSectionsButNotFieldsToBeRendered(accountSettingsView);
});
it("renders fields after the models are successfully fetched", function() {
requests = AjaxHelpers.requests(this);
var context = AccountSettingsPage(
FIELDS_DATA, AUTH_DATA, Helpers.USER_ACCOUNTS_API_URL, Helpers.USER_PREFERENCES_API_URL
);
var accountSettingsView = context.accountSettingsView;
Helpers.expectLoadingIndicatorIsVisible(accountSettingsView, true);
Helpers.expectLoadingErrorIsVisible(accountSettingsView, false);
Helpers.expectSettingsSectionsButNotFieldsToBeRendered(accountSettingsView);
AjaxHelpers.respondWithJson(requests, Helpers.createAccountSettingsData());
AjaxHelpers.respondWithJson(requests, Helpers.createUserPreferencesData());
Helpers.expectLoadingIndicatorIsVisible(accountSettingsView, false);
Helpers.expectLoadingErrorIsVisible(accountSettingsView, false);
Helpers.expectSettingsSectionsAndFieldsToBeRendered(accountSettingsView);
});
it("expects all fields to behave correctly", function () {
requests = AjaxHelpers.requests(this);
var context = AccountSettingsPage(
FIELDS_DATA, AUTH_DATA, Helpers.USER_ACCOUNTS_API_URL, Helpers.USER_PREFERENCES_API_URL
);
var accountSettingsView = context.accountSettingsView;
AjaxHelpers.respondWithJson(requests, Helpers.createAccountSettingsData());
AjaxHelpers.respondWithJson(requests, Helpers.createUserPreferencesData());
var sectionsData = accountSettingsView.options.sectionsData;
expect(sectionsData[0].fields.length).toBe(6);
var textFields = [sectionsData[0].fields[1], sectionsData[0].fields[2]];
for (var i = 0; i < textFields.length ; i++) {
var view = textFields[i].view;
FieldViewsSpecHelpers.verifyTextField(view, {
title: view.options.title,
valueAttribute: view.options.valueAttribute,
helpMessage: view.options.helpMessage,
validValue: 'My Name',
invalidValue1: '',
invalidValue2: '@',
validationError: "Think again!"
}, requests);
}
expect(sectionsData[1].fields.length).toBe(4);
var dropdownFields = [
sectionsData[1].fields[0],
sectionsData[1].fields[1],
sectionsData[1].fields[2]
];
_.each(dropdownFields, function(field) {
var view = field.view;
FieldViewsSpecHelpers.verifyDropDownField(view, {
title: view.options.title,
valueAttribute: view.options.valueAttribute,
helpMessage: '',
validValue: Helpers.FIELD_OPTIONS[1][0],
invalidValue1: Helpers.FIELD_OPTIONS[2][0],
invalidValue2: Helpers.FIELD_OPTIONS[3][0],
validationError: "Nope, this will not do!"
}, requests);
});
var section2Fields = sectionsData[2].fields;
expect(section2Fields.length).toBe(2);
for (var i = 0; i < section2Fields.length; i++) {
var view = section2Fields[i].view;
AccountSettingsFieldViewSpecHelpers.verifyAuthField(view, view.options, requests);
}
});
});
});
define(['backbone', 'jquery', 'underscore', 'js/common_helpers/ajax_helpers', 'js/common_helpers/template_helpers',
'js/spec/views/fields_helpers',
'string_utils'],
function (Backbone, $, _, AjaxHelpers, TemplateHelpers, FieldViewsSpecHelpers) {
'use strict';
var verifyAuthField = function (view, data, requests) {
var selector = '.u-field-value .u-field-link-title-' + view.options.valueAttribute;
spyOn(view, 'redirect_to');
FieldViewsSpecHelpers.expectTitleAndMessageToContain(view, data.title, data.helpMessage);
expect(view.$(selector).text().trim()).toBe('Unlink');
view.$(selector).click();
FieldViewsSpecHelpers.expectMessageContains(view, 'Unlinking');
AjaxHelpers.expectRequest(requests, 'POST', data.disconnectUrl);
AjaxHelpers.respondWithNoContent(requests);
expect(view.$(selector).text().trim()).toBe('Link');
FieldViewsSpecHelpers.expectMessageContains(view, 'Successfully unlinked.');
view.$(selector).click();
FieldViewsSpecHelpers.expectMessageContains(view, 'Linking');
expect(view.redirect_to).toHaveBeenCalledWith(data.connectUrl);
};
return {
verifyAuthField: verifyAuthField
};
});
define(['backbone', 'jquery', 'underscore', 'js/common_helpers/ajax_helpers', 'js/common_helpers/template_helpers',
'js/views/fields',
'js/spec/views/fields_helpers',
'js/spec/student_account/account_settings_fields_helpers',
'js/student_account/views/account_settings_fields',
'js/student_account/models/user_account_model',
'string_utils'],
function (Backbone, $, _, AjaxHelpers, TemplateHelpers, FieldViews, FieldViewsSpecHelpers,
AccountSettingsFieldViewSpecHelpers, AccountSettingsFieldViews) {
'use strict';
describe("edx.AccountSettingsFieldViews", function () {
var requests,
timerCallback;
beforeEach(function () {
TemplateHelpers.installTemplate('templates/fields/field_readonly');
TemplateHelpers.installTemplate('templates/fields/field_dropdown');
TemplateHelpers.installTemplate('templates/fields/field_link');
TemplateHelpers.installTemplate('templates/fields/field_text');
timerCallback = jasmine.createSpy('timerCallback');
jasmine.Clock.useMock();
});
it("sends request to reset password on clicking link in PasswordFieldView", function() {
requests = AjaxHelpers.requests(this);
var fieldData = FieldViewsSpecHelpers.createFieldData(AccountSettingsFieldViews.PasswordFieldView, {
linkHref: '/password_reset',
emailAttribute: 'email'
});
var view = new AccountSettingsFieldViews.PasswordFieldView(fieldData).render();
view.$('.u-field-value > a').click();
AjaxHelpers.expectRequest(requests, 'POST', '/password_reset', "email=legolas%40woodland.middlearth");
AjaxHelpers.respondWithJson(requests, {"success": "true"});
FieldViewsSpecHelpers.expectMessageContains(
view,
"We've sent a message to legolas@woodland.middlearth. " +
"Click the link in the message to reset your password."
);
});
it("sends request to /i18n/setlang/ after changing language preference in LanguagePreferenceFieldView", function() {
requests = AjaxHelpers.requests(this);
var selector = '.u-field-value > select';
var fieldData = FieldViewsSpecHelpers.createFieldData(AccountSettingsFieldViews.DropdownFieldView, {
valueAttribute: 'language',
options: FieldViewsSpecHelpers.SELECT_OPTIONS
});
var view = new AccountSettingsFieldViews.LanguagePreferenceFieldView(fieldData).render();
var data = {'language': FieldViewsSpecHelpers.SELECT_OPTIONS[2][0]};
view.$(selector).val(data[fieldData.valueAttribute]).change();
FieldViewsSpecHelpers.expectAjaxRequestWithData(requests, data);
AjaxHelpers.respondWithNoContent(requests);
AjaxHelpers.expectRequest(
requests,
'POST',
'/i18n/setlang/',
'language=' + data[fieldData.valueAttribute]
);
AjaxHelpers.respondWithNoContent(requests);
FieldViewsSpecHelpers.expectMessageContains(view, "Your changes have been saved.");
data = {'language': FieldViewsSpecHelpers.SELECT_OPTIONS[1][0]};
view.$(selector).val(data[fieldData.valueAttribute]).change();
FieldViewsSpecHelpers.expectAjaxRequestWithData(requests, data);
AjaxHelpers.respondWithNoContent(requests);
AjaxHelpers.expectRequest(
requests,
'POST',
'/i18n/setlang/',
'language=' + data[fieldData.valueAttribute]
);
AjaxHelpers.respondWithError(requests, 500);
FieldViewsSpecHelpers.expectMessageContains(
view,
"You must sign out of edX and sign back in before your language changes take effect."
);
});
it("reads and saves the value correctly for LanguageProficienciesFieldView", function() {
requests = AjaxHelpers.requests(this);
var selector = '.u-field-value > select';
var fieldData = FieldViewsSpecHelpers.createFieldData(AccountSettingsFieldViews.DropdownFieldView, {
valueAttribute: 'language_proficiencies',
options: FieldViewsSpecHelpers.SELECT_OPTIONS
});
fieldData.model.set({'language_proficiencies': [{'code': FieldViewsSpecHelpers.SELECT_OPTIONS[0][0]}]});
var view = new AccountSettingsFieldViews.LanguageProficienciesFieldView(fieldData).render();
expect(view.modelValue()).toBe(FieldViewsSpecHelpers.SELECT_OPTIONS[0][0]);
var data = {'language_proficiencies': [{'code': FieldViewsSpecHelpers.SELECT_OPTIONS[1][0]}]};
view.$(selector).val(FieldViewsSpecHelpers.SELECT_OPTIONS[1][0]).change();
FieldViewsSpecHelpers.expectAjaxRequestWithData(requests, data);
AjaxHelpers.respondWithNoContent(requests);
});
it("correctly links and unlinks from AuthFieldView", function() {
requests = AjaxHelpers.requests(this);
var fieldData = FieldViewsSpecHelpers.createFieldData(FieldViews.LinkFieldView, {
title: 'Yet another social network',
helpMessage: '',
valueAttribute: 'auth-yet-another',
connected: true,
connectUrl: 'yetanother.com/auth/connect',
disconnectUrl: 'yetanother.com/auth/disconnect'
});
var view = new AccountSettingsFieldViews.AuthFieldView(fieldData).render();
AccountSettingsFieldViewSpecHelpers.verifyAuthField(view, fieldData, requests);
});
});
});
define(['backbone', 'jquery', 'underscore', 'js/common_helpers/ajax_helpers', 'js/common_helpers/template_helpers',
'js/spec/student_account/helpers',
'js/views/fields',
'js/student_account/models/user_account_model',
'js/student_account/views/account_settings_view'
],
function (Backbone, $, _, AjaxHelpers, TemplateHelpers, Helpers, FieldViews, UserAccountModel,
AccountSettingsView) {
'use strict';
describe("edx.user.AccountSettingsView", function () {
var createAccountSettingsView = function () {
var model = new UserAccountModel();
model.set(Helpers.createAccountSettingsData());
var sectionsData = [
{
title: "Basic Account Information",
fields: [
{
view: new FieldViews.ReadonlyFieldView({
model: model,
title: "Username",
valueAttribute: "username"
})
},
{
view: new FieldViews.TextFieldView({
model: model,
title: "Full Name",
valueAttribute: "name"
})
}
]
},
{
title: "Additional Information",
fields: [
{
view: new FieldViews.DropdownFieldView({
model: model,
title: "Education Completed",
valueAttribute: "level_of_education",
options: Helpers.FIELD_OPTIONS
})
}
]
}
];
var accountSettingsView = new AccountSettingsView({
el: $('.wrapper-account-settings'),
model: model,
sectionsData : sectionsData
});
return accountSettingsView;
};
beforeEach(function () {
setFixtures('<div class="wrapper-account-settings"></div>');
TemplateHelpers.installTemplate('templates/fields/field_readonly');
TemplateHelpers.installTemplate('templates/fields/field_dropdown');
TemplateHelpers.installTemplate('templates/fields/field_link');
TemplateHelpers.installTemplate('templates/fields/field_text');
TemplateHelpers.installTemplate('templates/student_account/account_settings');
});
it("shows loading error correctly", function() {
var accountSettingsView = createAccountSettingsView();
accountSettingsView.render();
Helpers.expectLoadingIndicatorIsVisible(accountSettingsView, true);
Helpers.expectLoadingErrorIsVisible(accountSettingsView, false);
Helpers.expectSettingsSectionsButNotFieldsToBeRendered(accountSettingsView);
accountSettingsView.showLoadingError();
Helpers.expectLoadingIndicatorIsVisible(accountSettingsView, false);
Helpers.expectLoadingErrorIsVisible(accountSettingsView, true);
Helpers.expectSettingsSectionsButNotFieldsToBeRendered(accountSettingsView);
});
it("renders all fields as expected", function() {
var accountSettingsView = createAccountSettingsView();
accountSettingsView.render();
Helpers.expectLoadingIndicatorIsVisible(accountSettingsView, true);
Helpers.expectLoadingErrorIsVisible(accountSettingsView, false);
Helpers.expectSettingsSectionsButNotFieldsToBeRendered(accountSettingsView);
accountSettingsView.renderFields();
Helpers.expectLoadingIndicatorIsVisible(accountSettingsView, false);
Helpers.expectLoadingErrorIsVisible(accountSettingsView, false);
Helpers.expectSettingsSectionsAndFieldsToBeRendered(accountSettingsView);
});
});
});
define(['underscore'], function(_) {
'use strict';
var USER_ACCOUNTS_API_URL = '/api/user/v0/accounts/student';
var USER_PREFERENCES_API_URL = '/api/user/v0/preferences/student';
var IMAGE_UPLOAD_API_URL = '/api/profile_images/v0/staff/upload';
var IMAGE_REMOVE_API_URL = '/api/profile_images/v0/staff/remove';
var PROFILE_IMAGE = {
image_url_large: '/media/profile-images/image.jpg',
has_image: true
};
var DEFAULT_ACCOUNT_SETTINGS_DATA = {
username: 'student',
name: 'Student',
email: 'student@edx.org',
level_of_education: '',
gender: '',
year_of_birth: '3', // Note: test birth year range is a string from 0-3
requires_parental_consent: false,
country: '',
language: '',
bio: "About the student",
language_proficiencies: [{code: '1'}],
profile_image: PROFILE_IMAGE
};
var createAccountSettingsData = function(options) {
return _.extend(_.extend({}, DEFAULT_ACCOUNT_SETTINGS_DATA), options);
};
var DEFAULT_USER_PREFERENCES_DATA = {
'pref-lang': '2'
};
var createUserPreferencesData = function(options) {
return _.extend(_.extend({}, DEFAULT_USER_PREFERENCES_DATA), options);
};
var FIELD_OPTIONS = [
['0', 'Option 0'],
['1', 'Option 1'],
['2', 'Option 2'],
['3', 'Option 3']
];
var IMAGE_MAX_BYTES = 1024 * 1024;
var IMAGE_MIN_BYTES = 100;
var expectLoadingIndicatorIsVisible = function (view, visible) {
if (visible) {
expect($('.ui-loading-indicator')).not.toHaveClass('is-hidden');
} else {
expect($('.ui-loading-indicator')).toHaveClass('is-hidden');
}
};
var expectLoadingErrorIsVisible = function (view, visible) {
if (visible) {
expect(view.$('.ui-loading-error')).not.toHaveClass('is-hidden');
} else {
expect(view.$('.ui-loading-error')).toHaveClass('is-hidden');
}
};
var expectElementContainsField = function(element, field) {
var view = field.view;
var fieldTitle = $(element).find('.u-field-title').text().trim();
expect(fieldTitle).toBe(view.options.title);
if ('fieldValue' in view) {
expect(view.fieldValue()).toBe(view.modelValue());
} else if (view.fieldType === 'link') {
expect($(element).find('a').length).toBe(1);
} else {
throw new Error('Unexpected field type: ' + view.fieldType);
}
};
var expectSettingsSectionsButNotFieldsToBeRendered = function (accountSettingsView) {
expectSettingsSectionsAndFieldsToBeRendered(accountSettingsView, false)
};
var expectSettingsSectionsAndFieldsToBeRendered = function (accountSettingsView, fieldsAreRendered) {
var sectionsData = accountSettingsView.options.sectionsData;
var sectionElements = accountSettingsView.$('.section');
expect(sectionElements.length).toBe(sectionsData.length);
_.each(sectionElements, function(sectionElement, sectionIndex) {
expect($(sectionElement).find('.section-header').text().trim()).toBe(sectionsData[sectionIndex].title);
var sectionFieldElements = $(sectionElement).find('.u-field');
if (fieldsAreRendered === false) {
expect(sectionFieldElements.length).toBe(0);
} else {
expect(sectionFieldElements.length).toBe(sectionsData[sectionIndex].fields.length);
_.each(sectionFieldElements, function (sectionFieldElement, fieldIndex) {
expectElementContainsField(sectionFieldElement, sectionsData[sectionIndex].fields[fieldIndex]);
});
}
});
};
return {
USER_ACCOUNTS_API_URL: USER_ACCOUNTS_API_URL,
USER_PREFERENCES_API_URL: USER_PREFERENCES_API_URL,
IMAGE_UPLOAD_API_URL: IMAGE_UPLOAD_API_URL,
IMAGE_REMOVE_API_URL: IMAGE_REMOVE_API_URL,
IMAGE_MAX_BYTES: IMAGE_MAX_BYTES,
IMAGE_MIN_BYTES: IMAGE_MIN_BYTES,
PROFILE_IMAGE: PROFILE_IMAGE,
createAccountSettingsData: createAccountSettingsData,
createUserPreferencesData: createUserPreferencesData,
FIELD_OPTIONS: FIELD_OPTIONS,
expectLoadingIndicatorIsVisible: expectLoadingIndicatorIsVisible,
expectLoadingErrorIsVisible: expectLoadingErrorIsVisible,
expectElementContainsField: expectElementContainsField,
expectSettingsSectionsButNotFieldsToBeRendered: expectSettingsSectionsButNotFieldsToBeRendered,
expectSettingsSectionsAndFieldsToBeRendered: expectSettingsSectionsAndFieldsToBeRendered,
};
});
...@@ -99,7 +99,7 @@ define([ ...@@ -99,7 +99,7 @@ define([
{value: "", name: "--"}, {value: "", name: "--"},
{value: "p", name: "Doctorate"}, {value: "p", name: "Doctorate"},
{value: "m", name: "Master's or professional degree"}, {value: "m", name: "Master's or professional degree"},
{value: "b", name: "Bachelor's degree"}, {value: "b", name: "Bachelor's degree"}
], ],
required: false, required: false,
instructions: 'Select your education level.', instructions: 'Select your education level.',
...@@ -115,7 +115,7 @@ define([ ...@@ -115,7 +115,7 @@ define([
{value: "", name: "--"}, {value: "", name: "--"},
{value: "m", name: "Male"}, {value: "m", name: "Male"},
{value: "f", name: "Female"}, {value: "f", name: "Female"},
{value: "o", name: "Other"}, {value: "o", name: "Other"}
], ],
required: false, required: false,
instructions: 'Select your gender.', instructions: 'Select your gender.',
...@@ -131,7 +131,7 @@ define([ ...@@ -131,7 +131,7 @@ define([
{value: "", name: "--"}, {value: "", name: "--"},
{value: 1900, name: "1900"}, {value: 1900, name: "1900"},
{value: 1950, name: "1950"}, {value: 1950, name: "1950"},
{value: 2014, name: "2014"}, {value: 2014, name: "2014"}
], ],
required: false, required: false,
instructions: 'Select your year of birth.', instructions: 'Select your year of birth.',
......
define(['underscore'], function(_) {
'use strict';
var expectProfileElementContainsField = function(element, view) {
var $element = $(element);
var fieldTitle = $element.find('.u-field-title').text().trim();
if (!_.isUndefined(view.options.title)) {
expect(fieldTitle).toBe(view.options.title);
}
if ('fieldValue' in view || 'imageUrl' in view) {
if ('imageUrl' in view) {
expect($($element.find('.image-frame')[0]).attr('src')).toBe(view.imageUrl());
} else if (view.fieldValue()) {
expect(view.fieldValue()).toBe(view.modelValue());
} else if ('optionForValue' in view) {
expect($($element.find('.u-field-value .u-field-value-readonly')[0]).text()).toBe(view.displayValue(view.modelValue()));
}else {
expect($($element.find('.u-field-value .u-field-value-readonly')[0]).text()).toBe(view.modelValue());
}
} else {
throw new Error('Unexpected field type: ' + view.fieldType);
}
};
var expectProfilePrivacyFieldTobeRendered = function(learnerProfileView, othersProfile) {
var accountPrivacyElement = learnerProfileView.$('.wrapper-profile-field-account-privacy');
var privacyFieldElement = $(accountPrivacyElement).find('.u-field');
if (othersProfile) {
expect(privacyFieldElement.length).toBe(0);
} else {
expect(privacyFieldElement.length).toBe(1);
expectProfileElementContainsField(privacyFieldElement, learnerProfileView.options.accountPrivacyFieldView);
}
};
var expectSectionOneTobeRendered = function(learnerProfileView) {
var sectionOneFieldElements = $(learnerProfileView.$('.wrapper-profile-section-one')).find('.u-field');
expect(sectionOneFieldElements.length).toBe(4);
expectProfileElementContainsField(sectionOneFieldElements[0], learnerProfileView.options.profileImageFieldView);
expectProfileElementContainsField(sectionOneFieldElements[1], learnerProfileView.options.usernameFieldView);
_.each(_.rest(sectionOneFieldElements, 2) , function (sectionFieldElement, fieldIndex) {
expectProfileElementContainsField(
sectionFieldElement,
learnerProfileView.options.sectionOneFieldViews[fieldIndex]
);
});
};
var expectSectionTwoTobeRendered = function(learnerProfileView) {
var sectionTwoElement = learnerProfileView.$('.wrapper-profile-section-two');
var sectionTwoFieldElements = $(sectionTwoElement).find('.u-field');
expect(sectionTwoFieldElements.length).toBe(learnerProfileView.options.sectionTwoFieldViews.length);
_.each(sectionTwoFieldElements, function (sectionFieldElement, fieldIndex) {
expectProfileElementContainsField(
sectionFieldElement,
learnerProfileView.options.sectionTwoFieldViews[fieldIndex]
);
});
};
var expectProfileSectionsAndFieldsToBeRendered = function (learnerProfileView, othersProfile) {
expectProfilePrivacyFieldTobeRendered(learnerProfileView, othersProfile);
expectSectionOneTobeRendered(learnerProfileView);
expectSectionTwoTobeRendered(learnerProfileView);
};
var expectLimitedProfileSectionsAndFieldsToBeRendered = function (learnerProfileView, othersProfile) {
expectProfilePrivacyFieldTobeRendered(learnerProfileView, othersProfile);
var sectionOneFieldElements = $(learnerProfileView.$('.wrapper-profile-section-one')).find('.u-field');
expect(sectionOneFieldElements.length).toBe(2);
expectProfileElementContainsField(
sectionOneFieldElements[0],
learnerProfileView.options.profileImageFieldView
);
expectProfileElementContainsField(
sectionOneFieldElements[1],
learnerProfileView.options.usernameFieldView
);
if (othersProfile) {
expect($('.profile-private--message').text())
.toBe('This edX learner is currently sharing a limited profile.');
} else {
expect($('.profile-private--message').text()).toBe('You are currently sharing a limited profile.');
}
};
var expectProfileSectionsNotToBeRendered = function(learnerProfileView) {
expect(learnerProfileView.$('.wrapper-profile-field-account-privacy').length).toBe(0);
expect(learnerProfileView.$('.wrapper-profile-section-one').length).toBe(0);
expect(learnerProfileView.$('.wrapper-profile-section-two').length).toBe(0);
};
return {
expectLimitedProfileSectionsAndFieldsToBeRendered: expectLimitedProfileSectionsAndFieldsToBeRendered,
expectProfileSectionsAndFieldsToBeRendered: expectProfileSectionsAndFieldsToBeRendered,
expectProfileSectionsNotToBeRendered: expectProfileSectionsNotToBeRendered
};
});
define(['backbone', 'jquery', 'underscore', 'js/common_helpers/ajax_helpers', 'js/common_helpers/template_helpers',
'js/spec/student_account/helpers',
'js/spec/student_profile/helpers',
'js/views/fields',
'js/student_account/models/user_account_model',
'js/student_account/models/user_preferences_model',
'js/student_profile/views/learner_profile_view',
'js/student_profile/views/learner_profile_fields',
'js/student_profile/views/learner_profile_factory',
'js/views/message_banner'
],
function (Backbone, $, _, AjaxHelpers, TemplateHelpers, Helpers, LearnerProfileHelpers, FieldViews,
UserAccountModel, UserPreferencesModel, LearnerProfileView, LearnerProfileFields, LearnerProfilePage) {
'use strict';
describe("edx.user.LearnerProfileFactory", function () {
var requests;
beforeEach(function () {
loadFixtures('js/fixtures/student_profile/student_profile.html');
TemplateHelpers.installTemplate('templates/fields/field_readonly');
TemplateHelpers.installTemplate('templates/fields/field_dropdown');
TemplateHelpers.installTemplate('templates/fields/field_textarea');
TemplateHelpers.installTemplate('templates/fields/field_image');
TemplateHelpers.installTemplate('templates/fields/message_banner');
TemplateHelpers.installTemplate('templates/student_profile/learner_profile');
});
var createProfilePage = function(ownProfile) {
return new LearnerProfilePage({
'accounts_api_url': Helpers.USER_ACCOUNTS_API_URL,
'preferences_api_url': Helpers.USER_PREFERENCES_API_URL,
'own_profile': ownProfile,
'account_settings_page_url': Helpers.USER_ACCOUNTS_API_URL,
'country_options': Helpers.FIELD_OPTIONS,
'language_options': Helpers.FIELD_OPTIONS,
'has_preferences_access': true,
'profile_image_max_bytes': Helpers.IMAGE_MAX_BYTES,
'profile_image_min_bytes': Helpers.IMAGE_MIN_BYTES,
'profile_image_upload_url': Helpers.IMAGE_UPLOAD_API_URL,
'profile_image_remove_url': Helpers.IMAGE_REMOVE_API_URL,
'default_visibility': 'all_users'
});
};
it("show loading error when UserAccountModel fails to load", function() {
requests = AjaxHelpers.requests(this);
var context = createProfilePage(true),
learnerProfileView = context.learnerProfileView;
var userAccountRequest = requests[0];
expect(userAccountRequest.method).toBe('GET');
expect(userAccountRequest.url).toBe(Helpers.USER_ACCOUNTS_API_URL);
AjaxHelpers.respondWithError(requests, 500);
Helpers.expectLoadingErrorIsVisible(learnerProfileView, true);
Helpers.expectLoadingIndicatorIsVisible(learnerProfileView, false);
LearnerProfileHelpers.expectProfileSectionsNotToBeRendered(learnerProfileView);
});
it("shows loading error when UserPreferencesModel fails to load", function() {
requests = AjaxHelpers.requests(this);
var context = createProfilePage(true),
learnerProfileView = context.learnerProfileView;
var userAccountRequest = requests[0];
expect(userAccountRequest.method).toBe('GET');
expect(userAccountRequest.url).toBe(Helpers.USER_ACCOUNTS_API_URL);
AjaxHelpers.respondWithJson(requests, Helpers.createAccountSettingsData());
Helpers.expectLoadingIndicatorIsVisible(learnerProfileView, true);
Helpers.expectLoadingErrorIsVisible(learnerProfileView, false);
LearnerProfileHelpers.expectProfileSectionsNotToBeRendered(learnerProfileView);
var userPreferencesRequest = requests[1];
expect(userPreferencesRequest.method).toBe('GET');
expect(userPreferencesRequest.url).toBe(Helpers.USER_PREFERENCES_API_URL);
AjaxHelpers.respondWithError(requests, 500);
Helpers.expectLoadingIndicatorIsVisible(learnerProfileView, false);
Helpers.expectLoadingErrorIsVisible(learnerProfileView, true);
LearnerProfileHelpers.expectProfileSectionsNotToBeRendered(learnerProfileView);
});
it("renders the full profile after models are successfully fetched", function() {
requests = AjaxHelpers.requests(this);
var context = createProfilePage(true),
learnerProfileView = context.learnerProfileView;
AjaxHelpers.respondWithJson(requests, Helpers.createAccountSettingsData());
AjaxHelpers.respondWithJson(requests, Helpers.createUserPreferencesData());
// sets the profile for full view.
context.accountPreferencesModel.set({account_privacy: 'all_users'});
LearnerProfileHelpers.expectProfileSectionsAndFieldsToBeRendered(learnerProfileView, false);
});
it("renders the limited profile for undefined 'year_of_birth'", function() {
requests = AjaxHelpers.requests(this);
var context = createProfilePage(true),
learnerProfileView = context.learnerProfileView;
AjaxHelpers.respondWithJson(requests, Helpers.createAccountSettingsData({
year_of_birth: '',
requires_parental_consent: true
}));
AjaxHelpers.respondWithJson(requests, Helpers.createUserPreferencesData());
LearnerProfileHelpers.expectLimitedProfileSectionsAndFieldsToBeRendered(learnerProfileView);
});
it("renders the limited profile for under 13 users", function() {
requests = AjaxHelpers.requests(this);
var context = createProfilePage(true),
learnerProfileView = context.learnerProfileView;
AjaxHelpers.respondWithJson(requests, Helpers.createAccountSettingsData({
year_of_birth: new Date().getFullYear() - 10,
requires_parental_consent: true
}));
AjaxHelpers.respondWithJson(requests, Helpers.createUserPreferencesData());
LearnerProfileHelpers.expectLimitedProfileSectionsAndFieldsToBeRendered(learnerProfileView);
});
});
});
define(['backbone', 'jquery', 'underscore', 'js/common_helpers/ajax_helpers', 'js/common_helpers/template_helpers',
'js/spec/student_account/helpers',
'js/spec/student_profile/helpers',
'js/views/fields',
'js/student_account/models/user_account_model',
'js/student_account/models/user_preferences_model',
'js/student_profile/views/learner_profile_fields',
'js/student_profile/views/learner_profile_view',
'js/student_account/views/account_settings_fields',
'js/views/message_banner'
],
function (Backbone, $, _, AjaxHelpers, TemplateHelpers, Helpers, LearnerProfileHelpers, FieldViews,
UserAccountModel, AccountPreferencesModel, LearnerProfileFields, LearnerProfileView,
AccountSettingsFieldViews, MessageBannerView) {
'use strict';
describe("edx.user.LearnerProfileView", function () {
var createLearnerProfileView = function (ownProfile, accountPrivacy, profileIsPublic) {
var accountSettingsModel = new UserAccountModel();
accountSettingsModel.set(Helpers.createAccountSettingsData());
accountSettingsModel.set({'profile_is_public': profileIsPublic});
accountSettingsModel.set({'profile_image': Helpers.PROFILE_IMAGE});
var accountPreferencesModel = new AccountPreferencesModel();
accountPreferencesModel.set({account_privacy: accountPrivacy});
accountPreferencesModel.url = Helpers.USER_PREFERENCES_API_URL;
var editable = ownProfile ? 'toggle' : 'never';
var accountPrivacyFieldView = new LearnerProfileFields.AccountPrivacyFieldView({
model: accountPreferencesModel,
required: true,
editable: 'always',
showMessages: false,
title: 'edX learners can see my:',
valueAttribute: "account_privacy",
options: [
['all_users', 'Full Profile'],
['private', 'Limited Profile']
],
helpMessage: '',
accountSettingsPageUrl: '/account/settings/'
});
var messageView = new MessageBannerView({
el: $('.message-banner')
});
var profileImageFieldView = new LearnerProfileFields.ProfileImageFieldView({
model: accountSettingsModel,
valueAttribute: "profile_image",
editable: editable,
messageView: messageView,
imageMaxBytes: Helpers.IMAGE_MAX_BYTES,
imageMinBytes: Helpers.IMAGE_MIN_BYTES,
imageUploadUrl: Helpers.IMAGE_UPLOAD_API_URL,
imageRemoveUrl: Helpers.IMAGE_REMOVE_API_URL
});
var usernameFieldView = new FieldViews.ReadonlyFieldView({
model: accountSettingsModel,
valueAttribute: "username",
helpMessage: ""
});
var sectionOneFieldViews = [
new FieldViews.DropdownFieldView({
model: accountSettingsModel,
required: false,
editable: editable,
showMessages: false,
iconName: 'fa-map-marker',
placeholderValue: '',
valueAttribute: "country",
options: Helpers.FIELD_OPTIONS,
helpMessage: ''
}),
new AccountSettingsFieldViews.LanguageProficienciesFieldView({
model: accountSettingsModel,
required: false,
editable: editable,
showMessages: false,
iconName: 'fa-comment',
placeholderValue: 'Add language',
valueAttribute: "language_proficiencies",
options: Helpers.FIELD_OPTIONS,
helpMessage: ''
})
];
var sectionTwoFieldViews = [
new FieldViews.TextareaFieldView({
model: accountSettingsModel,
editable: editable,
showMessages: false,
title: 'About me',
placeholderValue: "Tell other edX learners a little about yourself: where you live, " +
"what your interests are, why you're taking courses on edX, or what you hope to learn.",
valueAttribute: "bio",
helpMessage: ''
})
];
return new LearnerProfileView(
{
el: $('.wrapper-profile'),
ownProfile: ownProfile,
hasPreferencesAccess: true,
accountSettingsModel: accountSettingsModel,
preferencesModel: accountPreferencesModel,
accountPrivacyFieldView: accountPrivacyFieldView,
usernameFieldView: usernameFieldView,
profileImageFieldView: profileImageFieldView,
sectionOneFieldViews: sectionOneFieldViews,
sectionTwoFieldViews: sectionTwoFieldViews
});
};
beforeEach(function () {
loadFixtures('js/fixtures/student_profile/student_profile.html');
TemplateHelpers.installTemplate('templates/fields/field_readonly');
TemplateHelpers.installTemplate('templates/fields/field_dropdown');
TemplateHelpers.installTemplate('templates/fields/field_textarea');
TemplateHelpers.installTemplate('templates/fields/field_image');
TemplateHelpers.installTemplate('templates/fields/message_banner');
TemplateHelpers.installTemplate('templates/student_profile/learner_profile');
});
it("shows loading error correctly", function() {
var learnerProfileView = createLearnerProfileView(false, 'all_users');
Helpers.expectLoadingIndicatorIsVisible(learnerProfileView, true);
Helpers.expectLoadingErrorIsVisible(learnerProfileView, false);
learnerProfileView.render();
learnerProfileView.showLoadingError();
Helpers.expectLoadingErrorIsVisible(learnerProfileView, true);
});
it("renders all fields as expected for self with full access", function() {
var learnerProfileView = createLearnerProfileView(true, 'all_users', true);
Helpers.expectLoadingIndicatorIsVisible(learnerProfileView, true);
Helpers.expectLoadingErrorIsVisible(learnerProfileView, false);
learnerProfileView.render();
Helpers.expectLoadingErrorIsVisible(learnerProfileView, false);
LearnerProfileHelpers.expectProfileSectionsAndFieldsToBeRendered(learnerProfileView);
});
it("renders all fields as expected for self with limited access", function() {
var learnerProfileView = createLearnerProfileView(true, 'private', false);
Helpers.expectLoadingIndicatorIsVisible(learnerProfileView, true);
Helpers.expectLoadingErrorIsVisible(learnerProfileView, false);
learnerProfileView.render();
Helpers.expectLoadingErrorIsVisible(learnerProfileView, false);
LearnerProfileHelpers.expectLimitedProfileSectionsAndFieldsToBeRendered(learnerProfileView);
});
it("renders the fields as expected for others with full access", function() {
var learnerProfileView = createLearnerProfileView(false, 'all_users', true);
Helpers.expectLoadingIndicatorIsVisible(learnerProfileView, true);
Helpers.expectLoadingErrorIsVisible(learnerProfileView, false);
learnerProfileView.render();
Helpers.expectLoadingErrorIsVisible(learnerProfileView, false);
LearnerProfileHelpers.expectProfileSectionsAndFieldsToBeRendered(learnerProfileView, true);
});
it("renders the fields as expected for others with limited access", function() {
var learnerProfileView = createLearnerProfileView(false, 'private', false);
Helpers.expectLoadingIndicatorIsVisible(learnerProfileView, true);
Helpers.expectLoadingErrorIsVisible(learnerProfileView, false);
learnerProfileView.render();
Helpers.expectLoadingErrorIsVisible(learnerProfileView, false);
LearnerProfileHelpers.expectLimitedProfileSectionsAndFieldsToBeRendered(learnerProfileView, true);
});
});
});
define(['backbone', 'jquery', 'underscore', 'js/common_helpers/ajax_helpers', 'js/common_helpers/template_helpers',
'js/views/fields',
'string_utils'],
function (Backbone, $, _, AjaxHelpers, TemplateHelpers, FieldViews) {
'use strict';
var API_URL = '/api/end_point/v1';
var USERNAME = 'Legolas',
FULLNAME = 'Legolas Thranduil',
EMAIL = 'legolas@woodland.middlearth',
SELECT_OPTIONS = [['si', 'sindarin'], ['el', 'elvish'], ['na', 'nandor']];
var UserAccountModel = Backbone.Model.extend({
idAttribute: 'username',
defaults: {
username: USERNAME,
name: FULLNAME,
email: EMAIL,
language: SELECT_OPTIONS[0][0]
},
url: API_URL
});
var createFieldData = function (fieldType, fieldData) {
var data = {
model: fieldData.model || new UserAccountModel({}),
title: fieldData.title || 'Field Title',
valueAttribute: fieldData.valueAttribute,
helpMessage: fieldData.helpMessage || 'I am a field message',
placeholderValue: fieldData.placeholderValue || 'I am a placeholder message'
};
switch (fieldType) {
case FieldViews.DropdownFieldView:
data['required'] = fieldData.required || false;
data['options'] = fieldData.options || SELECT_OPTIONS;
break;
case FieldViews.LinkFieldView:
case FieldViews.PasswordFieldView:
data['linkTitle'] = fieldData.linkTitle || "Link Title";
data['linkHref'] = fieldData.linkHref || "/path/to/resource";
data['emailAttribute'] = 'email';
break;
}
_.extend(data, fieldData);
return data;
};
var createErrorMessage = function(attribute, user_message) {
var field_errors = {};
field_errors[attribute] = {
"user_message": user_message
};
return {
"field_errors": field_errors
};
};
var expectTitleToContain = function(view, expectedTitle) {
expect(view.$('.u-field-title').text().trim()).toContain(expectedTitle);
};
var expectMessageContains = function(view, expectedText) {
expect(view.$('.u-field-message').html()).toContain(expectedText);
};
var expectTitleAndMessageToContain = function(view, expectedTitle, expectedMessage) {
expectTitleToContain(view, expectedTitle);
expectMessageContains(view, expectedMessage);
};
var expectAjaxRequestWithData = function(requests, data) {
AjaxHelpers.expectJsonRequest(
requests, 'PATCH', API_URL, data
);
};
var verifyMessageUpdates = function (view, data, timerCallback) {
var message = 'Here to help!';
view.showHelpMessage(message);
expectMessageContains(view, message);
view.showHelpMessage();
expectMessageContains(view, view.helpMessage);
view.showInProgressMessage();
expectMessageContains(view, view.indicators.inProgress);
expectMessageContains(view, view.messages.inProgress);
view.showSuccessMessage();
expectMessageContains(view, view.indicators.success);
expectMessageContains(view, view.getMessage('success'));
expect(timerCallback).not.toHaveBeenCalled();
view.showErrorMessage({
responseText: JSON.stringify(createErrorMessage(data.valueAttribute, 'Ops, try again!.')),
status: 400
});
expectMessageContains(view, view.indicators.validationError);
view.showErrorMessage({status: 500});
expectMessageContains(view, view.indicators.error);
expectMessageContains(view, view.indicators.error);
};
var verifySuccessMessageReset = function (view) {
view.showHelpMessage();
expectMessageContains(view, view.helpMessage);
view.showSuccessMessage();
expectMessageContains(view, view.indicators.success);
jasmine.Clock.tick(5000);
// Message gets reset
expectMessageContains(view, view.helpMessage);
view.showSuccessMessage();
expectMessageContains(view, view.indicators.success);
// But if we change the message, it should not get reset.
view.showHelpMessage("Do not reset this!");
jasmine.Clock.tick(5000);
expectMessageContains(view, "Do not reset this!");
};
var verifyEditableField = function (view, data, requests) {
var request_data = {};
var url = view.model.url;
if (data.editable === 'toggle') {
expect(view.el).toHaveClass('mode-placeholder');
expectTitleToContain(view, data.title);
expectMessageContains(view, view.indicators.canEdit);
view.$el.click();
} else {
expectTitleAndMessageToContain(view, data.title, data.helpMessage, false);
}
expect(view.el).toHaveClass('mode-edit');
expect(view.fieldValue()).not.toContain(data.validValue);
view.$(data.valueInputSelector).val(data.validValue).change();
// When the value in the field is changed
expect(view.fieldValue()).toBe(data.validValue);
expectMessageContains(view, view.indicators.inProgress);
expectMessageContains(view, view.messages.inProgress);
request_data[data.valueAttribute] = data.validValue;
AjaxHelpers.expectJsonRequest(
requests, 'PATCH', url, request_data
);
AjaxHelpers.respondWithNoContent(requests);
// When server returns success.
if (data.editable === 'toggle') {
expect(view.el).toHaveClass('mode-display');
view.$el.click();
} else {
expectMessageContains(view, view.indicators.success);
}
view.$(data.valueInputSelector).val(data.invalidValue1).change();
request_data[data.valueAttribute] = data.invalidValue1;
AjaxHelpers.expectJsonRequest(
requests, 'PATCH', url, request_data
);
AjaxHelpers.respondWithError(requests, 500);
// When server returns a 500 error
expectMessageContains(view, view.indicators.error);
expectMessageContains(view, view.messages.error);
expect(view.el).toHaveClass('mode-edit');
view.$(data.valueInputSelector).val(data.invalidValue2).change();
request_data[data.valueAttribute] = data.invalidValue2;
AjaxHelpers.expectJsonRequest(
requests, 'PATCH', url, request_data
);
AjaxHelpers.respondWithError(requests, 400, createErrorMessage(data.valueAttribute, data.validationError));
// When server returns a validation error
expectMessageContains(view, view.indicators.validationError);
expectMessageContains(view, data.validationError);
expect(view.el).toHaveClass('mode-edit');
view.$(data.valueInputSelector).val('').change();
// When the value in the field is changed
expect(view.fieldValue()).toBe('');
request_data[data.valueAttribute] = '';
AjaxHelpers.expectJsonRequest(
requests, 'PATCH', url, request_data
);
AjaxHelpers.respondWithNoContent(requests);
// When server returns success.
if (data.editable === 'toggle') {
expect(view.el).toHaveClass('mode-placeholder');
} else {
expect(view.el).toHaveClass('mode-edit');
}
};
var verifyTextField = function (view, data, requests) {
verifyEditableField(view, _.extend({
valueSelector: '.u-field-value',
valueInputSelector: '.u-field-value > input'
}, data
), requests);
};
var verifyDropDownField = function (view, data, requests) {
verifyEditableField(view, _.extend({
valueSelector: '.u-field-value',
valueInputSelector: '.u-field-value > select'
}, data
), requests);
};
return {
SELECT_OPTIONS: SELECT_OPTIONS,
UserAccountModel: UserAccountModel,
createFieldData: createFieldData,
createErrorMessage: createErrorMessage,
expectTitleToContain: expectTitleToContain,
expectTitleAndMessageToContain: expectTitleAndMessageToContain,
expectMessageContains: expectMessageContains,
expectAjaxRequestWithData: expectAjaxRequestWithData,
verifyMessageUpdates: verifyMessageUpdates,
verifySuccessMessageReset: verifySuccessMessageReset,
verifyEditableField: verifyEditableField,
verifyTextField: verifyTextField,
verifyDropDownField: verifyDropDownField
};
});
define(['backbone', 'jquery', 'underscore', 'js/views/message_banner'
],
function (Backbone, $, _, MessageBannerView) {
'use strict';
describe("MessageBannerView", function () {
beforeEach(function () {
setFixtures('<div class="message-banner"></div>');
TemplateHelpers.installTemplate("templates/fields/message_banner");
});
it('renders message correctly', function() {
var messageSelector = '.message-banner';
var messageView = new MessageBannerView({
el: $(messageSelector)
});
messageView.showMessage('I am message view');
// Verify error message
expect($(messageSelector).text().trim()).toBe('I am message view');
messageView.hideMessage();
expect($(messageSelector).text().trim()).toBe('');
});
});
});
;(function (define, undefined) {
'use strict';
define([
'gettext', 'underscore', 'backbone'
], function (gettext, _, Backbone) {
var UserAccountModel = Backbone.Model.extend({
idAttribute: 'username',
defaults: {
username: '',
name: '',
email: '',
password: '',
language: null,
country: null,
date_joined: "",
gender: null,
goals: "",
level_of_education: null,
mailing_address: "",
year_of_birth: null,
bio: null,
language_proficiencies: [],
requires_parental_consent: true,
profile_image: null,
default_public_account_fields: []
},
parse : function(response) {
if (_.isNull(response)) {
return {};
}
// Currently when a non-staff user A access user B's profile, the only way to tell whether user B's
// profile is public is to check if the api has returned fields other than the default public fields
// specified in settings.ACCOUNT_VISIBILITY_CONFIGURATION.
var profileIsPublic = _.size(_.difference(_.keys(response), this.get('default_public_account_fields'))) > 0;
this.set({'profile_is_public': profileIsPublic}, { silent: true });
return response;
},
hasProfileImage: function () {
var profile_image = this.get('profile_image');
return (_.isObject(profile_image) && profile_image['has_image'] === true);
},
profileImageUrl: function () {
return this.get('profile_image')['image_url_large'];
},
isAboveMinimumAge: function() {
var isBirthDefined = !(_.isUndefined(this.get('year_of_birth')) || _.isNull(this.get('year_of_birth')));
return isBirthDefined && !(this.get("requires_parental_consent"));
}
});
return UserAccountModel;
});
}).call(this, define || RequireJS.define);
;(function (define, undefined) {
'use strict';
define([
'gettext', 'underscore', 'backbone'
], function (gettext, _, Backbone) {
var UserPreferencesModel = Backbone.Model.extend({
idAttribute: 'account_privacy',
defaults: {
account_privacy: 'private'
}
});
return UserPreferencesModel;
});
}).call(this, define || RequireJS.define);
;(function (define, undefined) {
'use strict';
define([
'gettext', 'jquery', 'underscore', 'backbone', 'logger',
'js/views/fields',
'js/student_account/models/user_account_model',
'js/student_account/models/user_preferences_model',
'js/student_account/views/account_settings_fields',
'js/student_account/views/account_settings_view'
], function (gettext, $, _, Backbone, Logger, FieldViews, UserAccountModel, UserPreferencesModel,
AccountSettingsFieldViews, AccountSettingsView) {
return function (fieldsData, authData, userAccountsApiUrl, userPreferencesApiUrl, accountUserId) {
var accountSettingsElement = $('.wrapper-account-settings');
var userAccountModel = new UserAccountModel();
userAccountModel.url = userAccountsApiUrl;
var userPreferencesModel = new UserPreferencesModel();
userPreferencesModel.url = userPreferencesApiUrl;
var sectionsData = [
{
title: gettext('Basic Account Information (required)'),
fields: [
{
view: new FieldViews.ReadonlyFieldView({
model: userAccountModel,
title: gettext('Username'),
valueAttribute: 'username',
helpMessage: gettext('The name that identifies you on the edX site. You cannot change your username.')
})
},
{
view: new FieldViews.TextFieldView({
model: userAccountModel,
title: gettext('Full Name'),
valueAttribute: 'name',
helpMessage: gettext('The name that appears on your edX certificates. Other learners never see your full name.')
})
},
{
view: new AccountSettingsFieldViews.EmailFieldView({
model: userAccountModel,
title: gettext('Email Address'),
valueAttribute: 'email',
helpMessage: gettext('The email address you use to sign in to edX. Communications from edX and your courses are sent to this address.')
})
},
{
view: new AccountSettingsFieldViews.PasswordFieldView({
model: userAccountModel,
title: gettext('Password'),
screenReaderTitle: gettext('Reset your Password'),
valueAttribute: 'password',
emailAttribute: 'email',
linkTitle: gettext('Reset Password'),
linkHref: fieldsData.password.url,
helpMessage: gettext('When you click "Reset Password", a message will be sent to your email address. Click the link in the message to reset your password.')
})
},
{
view: new AccountSettingsFieldViews.LanguagePreferenceFieldView({
model: userPreferencesModel,
title: gettext('Language'),
valueAttribute: 'pref-lang',
required: true,
refreshPageOnSave: true,
helpMessage:
gettext('The language used for the edX site. The site is currently available in a limited number of languages.'),
options: fieldsData.language.options
})
},
{
view: new FieldViews.DropdownFieldView({
model: userAccountModel,
required: true,
title: gettext('Country or Region'),
valueAttribute: 'country',
options: fieldsData['country']['options']
})
}
]
},
{
title: gettext('Additional Information (optional)'),
fields: [
{
view: new FieldViews.DropdownFieldView({
model: userAccountModel,
title: gettext('Education Completed'),
valueAttribute: 'level_of_education',
options: fieldsData.level_of_education.options
})
},
{
view: new FieldViews.DropdownFieldView({
model: userAccountModel,
title: gettext('Gender'),
valueAttribute: 'gender',
options: fieldsData.gender.options
})
},
{
view: new FieldViews.DropdownFieldView({
model: userAccountModel,
title: gettext('Year of Birth'),
valueAttribute: 'year_of_birth',
options: fieldsData['year_of_birth']['options']
})
},
{
view: new AccountSettingsFieldViews.LanguageProficienciesFieldView({
model: userAccountModel,
title: gettext('Preferred Language'),
valueAttribute: 'language_proficiencies',
options: fieldsData.preferred_language.options
})
}
]
}
];
if (_.isArray(authData.providers)) {
var accountsSectionData = {
title: gettext('Connected Accounts'),
fields: _.map(authData.providers, function(provider) {
return {
'view': new AccountSettingsFieldViews.AuthFieldView({
title: provider.name,
screenReaderTitle: interpolate_text(
gettext("Connect your {accountName} account"), {accountName: provider['name']}
),
valueAttribute: 'auth-' + provider.name.toLowerCase(),
helpMessage: '',
connected: provider.connected,
connectUrl: provider.connect_url,
disconnectUrl: provider.disconnect_url
})
};
})
};
sectionsData.push(accountsSectionData);
}
var accountSettingsView = new AccountSettingsView({
model: userAccountModel,
accountUserId: accountUserId,
el: accountSettingsElement,
sectionsData: sectionsData
});
accountSettingsView.render();
var showLoadingError = function () {
accountSettingsView.showLoadingError();
};
var showAccountFields = function () {
// Record that the account settings page was viewed.
Logger.log('edx.user.settings.viewed', {
page: "account",
visibility: null,
user_id: accountUserId
});
// Render the fields
accountSettingsView.renderFields();
};
userAccountModel.fetch({
success: function () {
// Fetch the user preferences model
userPreferencesModel.fetch({
success: showAccountFields,
error: showLoadingError
});
},
error: showLoadingError
});
return {
userAccountModel: userAccountModel,
userPreferencesModel: userPreferencesModel,
accountSettingsView: accountSettingsView
};
};
});
}).call(this, define || RequireJS.define);
;(function (define, undefined) {
'use strict';
define([
'gettext', 'jquery', 'underscore', 'backbone', 'js/mustache', 'js/views/fields'
], function (gettext, $, _, Backbone, RequireMustache, FieldViews) {
var AccountSettingsFieldViews = {};
AccountSettingsFieldViews.EmailFieldView = FieldViews.TextFieldView.extend({
successMessage: function() {
return this.indicators.success + interpolate_text(
gettext(
'We\'ve sent a confirmation message to {new_email_address}. ' +
'Click the link in the message to update your email address.'
),
{'new_email_address': this.fieldValue()}
);
}
});
AccountSettingsFieldViews.LanguagePreferenceFieldView = FieldViews.DropdownFieldView.extend({
saveSucceeded: function () {
var data = {
'language': this.modelValue()
};
var view = this;
$.ajax({
type: 'POST',
url: '/i18n/setlang/',
data: data,
dataType: 'html',
success: function () {
view.showSuccessMessage();
},
error: function () {
view.showNotificationMessage(
view.indicators.error +
gettext(
'You must sign out of edX and sign back in before your language ' +
'changes take effect.'
)
);
}
});
}
});
AccountSettingsFieldViews.PasswordFieldView = FieldViews.LinkFieldView.extend({
initialize: function (options) {
this._super(options);
_.bindAll(this, 'resetPassword');
},
linkClicked: function (event) {
event.preventDefault();
this.resetPassword(event);
},
resetPassword: function () {
var data = {};
data[this.options.emailAttribute] = this.model.get(this.options.emailAttribute);
var view = this;
$.ajax({
type: 'POST',
url: view.options.linkHref,
data: data,
success: function () {
view.showSuccessMessage();
},
error: function (xhr) {
view.showErrorMessage(xhr);
}
});
},
successMessage: function () {
return this.indicators.success + interpolate_text(
gettext(
'We\'ve sent a message to {email_address}. ' +
'Click the link in the message to reset your password.'
),
{'email_address': this.model.get(this.options.emailAttribute)}
);
}
});
AccountSettingsFieldViews.LanguageProficienciesFieldView = FieldViews.DropdownFieldView.extend({
modelValue: function () {
var modelValue = this.model.get(this.options.valueAttribute);
if (_.isArray(modelValue) && modelValue.length > 0) {
return modelValue[0].code;
} else {
return '';
}
},
saveValue: function () {
var attributes = {},
value = this.fieldValue() ? [{'code': this.fieldValue()}] : [];
attributes[this.options.valueAttribute] = value;
this.saveAttributes(attributes);
}
});
AccountSettingsFieldViews.AuthFieldView = FieldViews.LinkFieldView.extend({
initialize: function (options) {
this._super(options);
_.bindAll(this, 'redirect_to', 'disconnect', 'successMessage', 'inProgressMessage');
},
render: function () {
this.$el.html(this.template({
id: this.options.valueAttribute,
title: this.options.title,
screenReaderTitle: this.options.screenReaderTitle,
linkTitle: this.options.connected ? gettext('Unlink') : gettext('Link'),
linkHref: '',
message: this.helpMessage
}));
return this;
},
linkClicked: function (event) {
event.preventDefault();
this.showInProgressMessage();
if (this.options.connected) {
this.disconnect();
} else {
// Direct the user to the providers site to start the authentication process.
// See python-social-auth docs for more information.
this.redirect_to(this.options.connectUrl);
}
},
redirect_to: function (url) {
window.location.href = url;
},
disconnect: function () {
var data = {};
// Disconnects the provider from the user's edX account.
// See python-social-auth docs for more information.
var view = this;
$.ajax({
type: 'POST',
url: this.options.disconnectUrl,
data: data,
dataType: 'html',
success: function () {
view.options.connected = false;
view.render();
view.showSuccessMessage();
},
error: function (xhr) {
view.showErrorMessage(xhr);
}
});
},
inProgressMessage: function() {
return this.indicators.inProgress + (this.options.connected ? gettext('Unlinking') : gettext('Linking'));
},
successMessage: function() {
return this.indicators.success + gettext('Successfully unlinked.');
}
});
return AccountSettingsFieldViews;
});
}).call(this, define || RequireJS.define);
;(function (define, undefined) {
'use strict';
define([
'gettext', 'jquery', 'underscore', 'backbone'
], function (gettext, $, _, Backbone) {
var AccountSettingsView = Backbone.View.extend({
initialize: function () {
this.template = _.template($('#account_settings-tpl').text());
_.bindAll(this, 'render', 'renderFields', 'showLoadingError');
},
render: function () {
this.$el.html(this.template({
sections: this.options.sectionsData
}));
return this;
},
renderFields: function () {
this.$('.ui-loading-indicator').addClass('is-hidden');
var view = this;
_.each(this.$('.account-settings-section-body'), function (sectionEl, index) {
_.each(view.options.sectionsData[index].fields, function (field) {
$(sectionEl).append(field.view.render().el);
});
});
return this;
},
showLoadingError: function () {
this.$('.ui-loading-indicator').addClass('is-hidden');
this.$('.ui-loading-error').removeClass('is-hidden');
}
});
return AccountSettingsView;
});
}).call(this, define || RequireJS.define);
;(function (define, undefined) {
'use strict';
define([
'gettext', 'jquery', 'underscore', 'backbone', 'logger',
'js/student_account/models/user_account_model',
'js/student_account/models/user_preferences_model',
'js/views/fields',
'js/student_profile/views/learner_profile_fields',
'js/student_profile/views/learner_profile_view',
'js/student_account/views/account_settings_fields',
'js/views/message_banner'
], function (gettext, $, _, Backbone, Logger, AccountSettingsModel, AccountPreferencesModel, FieldsView,
LearnerProfileFieldsView, LearnerProfileView, AccountSettingsFieldViews, MessageBannerView) {
return function (options) {
var learnerProfileElement = $('.wrapper-profile');
var defaultVisibility = options.default_visibility;
var AccountPreferencesModelWithDefaults = AccountPreferencesModel.extend({
defaults: {
account_privacy: defaultVisibility
}
});
var accountPreferencesModel = new AccountPreferencesModelWithDefaults();
accountPreferencesModel.url = options.preferences_api_url;
var accountSettingsModel = new AccountSettingsModel({
'default_public_account_fields': options.default_public_account_fields
});
accountSettingsModel.url = options.accounts_api_url;
var editable = options.own_profile ? 'toggle' : 'never';
var messageView = new MessageBannerView({
el: $('.message-banner')
});
var accountPrivacyFieldView = new LearnerProfileFieldsView.AccountPrivacyFieldView({
model: accountPreferencesModel,
required: true,
editable: 'always',
showMessages: false,
title: gettext('edX learners can see my:'),
valueAttribute: "account_privacy",
options: [
['private', gettext('Limited Profile')],
['all_users', gettext('Full Profile')]
],
helpMessage: '',
accountSettingsPageUrl: options.account_settings_page_url
});
var profileImageFieldView = new LearnerProfileFieldsView.ProfileImageFieldView({
model: accountSettingsModel,
valueAttribute: "profile_image",
editable: editable === 'toggle',
messageView: messageView,
imageMaxBytes: options['profile_image_max_bytes'],
imageMinBytes: options['profile_image_min_bytes'],
imageUploadUrl: options['profile_image_upload_url'],
imageRemoveUrl: options['profile_image_remove_url']
});
var usernameFieldView = new FieldsView.ReadonlyFieldView({
model: accountSettingsModel,
valueAttribute: "username",
helpMessage: ""
});
var sectionOneFieldViews = [
new FieldsView.DropdownFieldView({
model: accountSettingsModel,
screenReaderTitle: gettext('Location'),
required: true,
editable: editable,
showMessages: false,
iconName: 'fa-map-marker',
placeholderValue: '',
valueAttribute: "country",
options: options.country_options,
helpMessage: ''
}),
new AccountSettingsFieldViews.LanguageProficienciesFieldView({
model: accountSettingsModel,
screenReaderTitle: gettext('Preferred Language'),
required: false,
editable: editable,
showMessages: false,
iconName: 'fa-comment',
placeholderValue: gettext('Add language'),
valueAttribute: "language_proficiencies",
options: options.language_options,
helpMessage: ''
})
];
var sectionTwoFieldViews = [
new FieldsView.TextareaFieldView({
model: accountSettingsModel,
editable: editable,
showMessages: false,
title: gettext('About me'),
placeholderValue: gettext("Tell other edX learners a little about yourself: where you live, what your interests are, why you're taking courses on edX, or what you hope to learn."),
valueAttribute: "bio",
helpMessage: ''
})
];
var learnerProfileView = new LearnerProfileView({
el: learnerProfileElement,
ownProfile: options.own_profile,
has_preferences_access: options.has_preferences_access,
accountSettingsModel: accountSettingsModel,
preferencesModel: accountPreferencesModel,
accountPrivacyFieldView: accountPrivacyFieldView,
profileImageFieldView: profileImageFieldView,
usernameFieldView: usernameFieldView,
sectionOneFieldViews: sectionOneFieldViews,
sectionTwoFieldViews: sectionTwoFieldViews
});
var showLoadingError = function () {
learnerProfileView.showLoadingError();
};
var getProfileVisibility = function() {
if (options.has_preferences_access) {
return accountPreferencesModel.get('account_privacy');
} else {
return accountSettingsModel.get('profile_is_public') ? 'all_users' : 'private';
}
};
var showLearnerProfileView = function() {
// Record that the profile page was viewed
Logger.log('edx.user.settings.viewed', {
page: "profile",
visibility: getProfileVisibility(),
user_id: options.profile_user_id
});
// Render the view for the first time
learnerProfileView.render();
};
accountSettingsModel.fetch({
success: function () {
// Fetch the preferences model if the user has access
if (options.has_preferences_access) {
accountPreferencesModel.fetch({
success: function() {
if (accountSettingsModel.get('requires_parental_consent')) {
accountPreferencesModel.set('account_privacy', 'private');
}
showLearnerProfileView();
},
error: showLoadingError
});
}
else {
showLearnerProfileView();
}
},
error: showLoadingError
});
return {
accountSettingsModel: accountSettingsModel,
accountPreferencesModel: accountPreferencesModel,
learnerProfileView: learnerProfileView
};
};
});
}).call(this, define || RequireJS.define);
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