Merge pull request #7350 from edx/learner-profiles

Feature branch for learner profiles
This commit is contained in:
Andy Armstrong
2015-04-17 18:17:50 -04:00
156 changed files with 10010 additions and 826 deletions

View File

@@ -36,7 +36,12 @@ import lms.envs.common
# Although this module itself may not use these imported variables, other dependent modules may.
from lms.envs.common import (
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 warnings import simplefilter

View File

@@ -249,6 +249,9 @@ FEATURES['USE_MICROSITES'] = True
# the one in lms/envs/test.py
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
FEATURES['ENABLE_CONTENT_LIBRARIES'] = True

View File

@@ -123,7 +123,7 @@ define([
patchAndVerifyRequest(requests, url, notificationSpy);
AjaxHelpers.respondToDelete(requests);
AjaxHelpers.respondWithNoContent(requests);
ViewHelpers.verifyNotificationHidden(notificationSpy);
expect($(SELECTORS.itemView)).not.toExist();
};

View File

@@ -281,7 +281,7 @@ define(["jquery", "sinon", "js/common_helpers/ajax_helpers", "js/views/utils/vie
expect($('.wrapper-alert-announcement')).not.toHaveClass('is-hidden');
$('.dismiss-button').click();
AjaxHelpers.expectJsonRequest(requests, 'DELETE', 'dummy_dismiss_url');
AjaxHelpers.respondToDelete(requests);
AjaxHelpers.respondWithNoContent(requests);
expect($('.wrapper-alert-announcement')).toHaveClass('is-hidden');
});
});

View File

@@ -33,7 +33,7 @@ define(["jquery", "js/common_helpers/ajax_helpers", "js/spec_helpers/view_helper
var reloadSpy = spyOn(ViewUtils, 'reload');
$('.dismiss-button').click();
AjaxHelpers.expectJsonRequest(requests, 'DELETE', 'dummy_dismiss_url');
AjaxHelpers.respondToDelete(requests);
AjaxHelpers.respondWithNoContent(requests);
expect(reloadSpy).toHaveBeenCalled();
});

View File

@@ -18,7 +18,10 @@ from opaque_keys.edx.keys import CourseKey
from embargo import api as embargo_api
from cors_csrf.authentication import SessionAuthenticationCrossDomainCsrf
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 enrollment import api
from enrollment.errors import (

View File

@@ -121,9 +121,11 @@ class Migration(SchemaMigration):
'Meta': {'object_name': 'LinkedInAddToProfileConfiguration'},
'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'}),
'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'}),
'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': {
'Meta': {'object_name': 'LoginFailures'},

View File

@@ -0,0 +1,194 @@
# -*- coding: utf-8 -*-
from south.utils import datetime_utils as datetime
from south.db import db
from south.v2 import SchemaMigration
from django.db import models
class Migration(SchemaMigration):
def forwards(self, orm):
# Adding field 'UserProfile.bio'
db.add_column('auth_userprofile', 'bio',
self.gf('django.db.models.fields.CharField')(max_length=3000, null=True, blank=True, db_index=False),
keep_default=False)
def backwards(self, orm):
# Deleting field 'UserProfile.bio'
db.delete_column('auth_userprofile', 'bio')
models = {
'auth.group': {
'Meta': {'object_name': 'Group'},
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}),
'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'})
},
'auth.permission': {
'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'},
'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'name': ('django.db.models.fields.CharField', [], {'max_length': '50'})
},
'auth.user': {
'Meta': {'object_name': 'User'},
'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}),
'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}),
'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}),
'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'})
},
'contenttypes.contenttype': {
'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"},
'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
'name': ('django.db.models.fields.CharField', [], {'max_length': '100'})
},
'student.anonymoususerid': {
'Meta': {'object_name': 'AnonymousUserId'},
'anonymous_user_id': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '32'}),
'course_id': ('xmodule_django.models.CourseKeyField', [], {'db_index': 'True', 'max_length': '255', 'blank': 'True'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"})
},
'student.courseaccessrole': {
'Meta': {'unique_together': "(('user', 'org', 'course_id', 'role'),)", 'object_name': 'CourseAccessRole'},
'course_id': ('xmodule_django.models.CourseKeyField', [], {'db_index': 'True', 'max_length': '255', 'blank': 'True'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'org': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '64', 'blank': 'True'}),
'role': ('django.db.models.fields.CharField', [], {'max_length': '64', 'db_index': 'True'}),
'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"})
},
'student.courseenrollment': {
'Meta': {'ordering': "('user', 'course_id')", 'unique_together': "(('user', 'course_id'),)", 'object_name': 'CourseEnrollment'},
'course_id': ('xmodule_django.models.CourseKeyField', [], {'max_length': '255', 'db_index': 'True'}),
'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'null': 'True', 'db_index': 'True', 'blank': 'True'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
'mode': ('django.db.models.fields.CharField', [], {'default': "'honor'", 'max_length': '100'}),
'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"})
},
'student.courseenrollmentallowed': {
'Meta': {'unique_together': "(('email', 'course_id'),)", 'object_name': 'CourseEnrollmentAllowed'},
'auto_enroll': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
'course_id': ('xmodule_django.models.CourseKeyField', [], {'max_length': '255', 'db_index': 'True'}),
'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'null': 'True', 'db_index': 'True', 'blank': 'True'}),
'email': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'})
},
'student.dashboardconfiguration': {
'Meta': {'object_name': 'DashboardConfiguration'},
'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'}),
'enabled': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'recent_enrollment_time_delta': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'})
},
'student.entranceexamconfiguration': {
'Meta': {'unique_together': "(('user', 'course_id'),)", 'object_name': 'EntranceExamConfiguration'},
'course_id': ('xmodule_django.models.CourseKeyField', [], {'max_length': '255', 'db_index': 'True'}),
'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'null': 'True', 'db_index': 'True', 'blank': 'True'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'skip_entrance_exam': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
'updated': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'db_index': 'True', 'blank': 'True'}),
'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"})
},
'student.linkedinaddtoprofileconfiguration': {
'Meta': {'object_name': 'LinkedInAddToProfileConfiguration'},
'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'}),
'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'}),
'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': {
'Meta': {'object_name': 'LoginFailures'},
'failure_count': ('django.db.models.fields.IntegerField', [], {'default': '0'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'lockout_until': ('django.db.models.fields.DateTimeField', [], {'null': 'True'}),
'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"})
},
'student.passwordhistory': {
'Meta': {'object_name': 'PasswordHistory'},
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}),
'time_set': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"})
},
'student.pendingemailchange': {
'Meta': {'object_name': 'PendingEmailChange'},
'activation_key': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '32', 'db_index': 'True'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'new_email': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '255', 'blank': 'True'}),
'user': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['auth.User']", 'unique': 'True'})
},
'student.pendingnamechange': {
'Meta': {'object_name': 'PendingNameChange'},
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'new_name': ('django.db.models.fields.CharField', [], {'max_length': '255', 'blank': 'True'}),
'rationale': ('django.db.models.fields.CharField', [], {'max_length': '1024', 'blank': 'True'}),
'user': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['auth.User']", 'unique': 'True'})
},
'student.registration': {
'Meta': {'object_name': 'Registration', 'db_table': "'auth_registration'"},
'activation_key': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '32', 'db_index': 'True'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']", 'unique': 'True'})
},
'student.userprofile': {
'Meta': {'object_name': 'UserProfile', 'db_table': "'auth_userprofile'"},
'allow_certificate': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
'bio': ('django.db.models.fields.CharField', [], {'db_index': 'False', 'null': 'True', 'blank': 'True'}),
'city': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
'country': ('django_countries.fields.CountryField', [], {'max_length': '2', 'null': 'True', 'blank': 'True'}),
'courseware': ('django.db.models.fields.CharField', [], {'default': "'course.xml'", 'max_length': '255', 'blank': 'True'}),
'gender': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '6', 'null': 'True', 'blank': 'True'}),
'goals': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'language': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '255', 'blank': 'True'}),
'level_of_education': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '6', 'null': 'True', 'blank': 'True'}),
'location': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '255', 'blank': 'True'}),
'mailing_address': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
'meta': ('django.db.models.fields.TextField', [], {'blank': 'True'}),
'name': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '255', 'blank': 'True'}),
'user': ('django.db.models.fields.related.OneToOneField', [], {'related_name': "'profile'", 'unique': 'True', 'to': "orm['auth.User']"}),
'year_of_birth': ('django.db.models.fields.IntegerField', [], {'db_index': 'True', 'null': 'True', 'blank': 'True'})
},
'student.usersignupsource': {
'Meta': {'object_name': 'UserSignupSource'},
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'site': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}),
'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"})
},
'student.userstanding': {
'Meta': {'object_name': 'UserStanding'},
'account_status': ('django.db.models.fields.CharField', [], {'max_length': '31', 'blank': 'True'}),
'changed_by': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']", 'blank': 'True'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'standing_last_changed_at': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'blank': 'True'}),
'user': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'standing'", 'unique': 'True', 'to': "orm['auth.User']"})
},
'student.usertestgroup': {
'Meta': {'object_name': 'UserTestGroup'},
'description': ('django.db.models.fields.TextField', [], {'blank': 'True'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'name': ('django.db.models.fields.CharField', [], {'max_length': '32', 'db_index': 'True'}),
'users': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.User']", 'db_index': 'True', 'symmetrical': 'False'})
}
}
complete_apps = ['student']

View File

@@ -0,0 +1,195 @@
# -*- coding: utf-8 -*-
from south.utils import datetime_utils as datetime
from south.db import db
from south.v2 import SchemaMigration
from django.db import models
class Migration(SchemaMigration):
def forwards(self, orm):
# Adding field 'UserProfile.profile_image_uploaded_at'
db.add_column('auth_userprofile', 'profile_image_uploaded_at',
self.gf('django.db.models.fields.DateTimeField')(null=True),
keep_default=False)
def backwards(self, orm):
# Deleting field 'UserProfile.profile_image_uploaded_at'
db.delete_column('auth_userprofile', 'profile_image_uploaded_at')
models = {
'auth.group': {
'Meta': {'object_name': 'Group'},
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}),
'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'})
},
'auth.permission': {
'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'},
'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'name': ('django.db.models.fields.CharField', [], {'max_length': '50'})
},
'auth.user': {
'Meta': {'object_name': 'User'},
'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}),
'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}),
'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}),
'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'})
},
'contenttypes.contenttype': {
'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"},
'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
'name': ('django.db.models.fields.CharField', [], {'max_length': '100'})
},
'student.anonymoususerid': {
'Meta': {'object_name': 'AnonymousUserId'},
'anonymous_user_id': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '32'}),
'course_id': ('xmodule_django.models.CourseKeyField', [], {'db_index': 'True', 'max_length': '255', 'blank': 'True'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"})
},
'student.courseaccessrole': {
'Meta': {'unique_together': "(('user', 'org', 'course_id', 'role'),)", 'object_name': 'CourseAccessRole'},
'course_id': ('xmodule_django.models.CourseKeyField', [], {'db_index': 'True', 'max_length': '255', 'blank': 'True'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'org': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '64', 'blank': 'True'}),
'role': ('django.db.models.fields.CharField', [], {'max_length': '64', 'db_index': 'True'}),
'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"})
},
'student.courseenrollment': {
'Meta': {'ordering': "('user', 'course_id')", 'unique_together': "(('user', 'course_id'),)", 'object_name': 'CourseEnrollment'},
'course_id': ('xmodule_django.models.CourseKeyField', [], {'max_length': '255', 'db_index': 'True'}),
'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'null': 'True', 'db_index': 'True', 'blank': 'True'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
'mode': ('django.db.models.fields.CharField', [], {'default': "'honor'", 'max_length': '100'}),
'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"})
},
'student.courseenrollmentallowed': {
'Meta': {'unique_together': "(('email', 'course_id'),)", 'object_name': 'CourseEnrollmentAllowed'},
'auto_enroll': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
'course_id': ('xmodule_django.models.CourseKeyField', [], {'max_length': '255', 'db_index': 'True'}),
'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'null': 'True', 'db_index': 'True', 'blank': 'True'}),
'email': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'})
},
'student.dashboardconfiguration': {
'Meta': {'object_name': 'DashboardConfiguration'},
'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'}),
'enabled': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'recent_enrollment_time_delta': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'})
},
'student.entranceexamconfiguration': {
'Meta': {'unique_together': "(('user', 'course_id'),)", 'object_name': 'EntranceExamConfiguration'},
'course_id': ('xmodule_django.models.CourseKeyField', [], {'max_length': '255', 'db_index': 'True'}),
'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'null': 'True', 'db_index': 'True', 'blank': 'True'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'skip_entrance_exam': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
'updated': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'db_index': 'True', 'blank': 'True'}),
'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"})
},
'student.linkedinaddtoprofileconfiguration': {
'Meta': {'object_name': 'LinkedInAddToProfileConfiguration'},
'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'}),
'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'}),
'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': {
'Meta': {'object_name': 'LoginFailures'},
'failure_count': ('django.db.models.fields.IntegerField', [], {'default': '0'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'lockout_until': ('django.db.models.fields.DateTimeField', [], {'null': 'True'}),
'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"})
},
'student.passwordhistory': {
'Meta': {'object_name': 'PasswordHistory'},
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}),
'time_set': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"})
},
'student.pendingemailchange': {
'Meta': {'object_name': 'PendingEmailChange'},
'activation_key': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '32', 'db_index': 'True'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'new_email': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '255', 'blank': 'True'}),
'user': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['auth.User']", 'unique': 'True'})
},
'student.pendingnamechange': {
'Meta': {'object_name': 'PendingNameChange'},
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'new_name': ('django.db.models.fields.CharField', [], {'max_length': '255', 'blank': 'True'}),
'rationale': ('django.db.models.fields.CharField', [], {'max_length': '1024', 'blank': 'True'}),
'user': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['auth.User']", 'unique': 'True'})
},
'student.registration': {
'Meta': {'object_name': 'Registration', 'db_table': "'auth_registration'"},
'activation_key': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '32', 'db_index': 'True'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']", 'unique': 'True'})
},
'student.userprofile': {
'Meta': {'object_name': 'UserProfile', 'db_table': "'auth_userprofile'"},
'allow_certificate': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
'bio': ('django.db.models.fields.CharField', [], {'db_index': 'False', 'null': 'True', 'blank': 'True'}),
'city': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
'country': ('django_countries.fields.CountryField', [], {'max_length': '2', 'null': 'True', 'blank': 'True'}),
'courseware': ('django.db.models.fields.CharField', [], {'default': "'course.xml'", 'max_length': '255', 'blank': 'True'}),
'gender': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '6', 'null': 'True', 'blank': 'True'}),
'goals': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
'has_profile_image': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'language': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '255', 'blank': 'True'}),
'level_of_education': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '6', 'null': 'True', 'blank': 'True'}),
'location': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '255', 'blank': 'True'}),
'mailing_address': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
'meta': ('django.db.models.fields.TextField', [], {'blank': 'True'}),
'name': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '255', 'blank': 'True'}),
'user': ('django.db.models.fields.related.OneToOneField', [], {'related_name': "'profile'", 'unique': 'True', 'to': "orm['auth.User']"}),
'year_of_birth': ('django.db.models.fields.IntegerField', [], {'db_index': 'True', 'null': 'True', 'blank': 'True'})
},
'student.usersignupsource': {
'Meta': {'object_name': 'UserSignupSource'},
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'site': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}),
'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"})
},
'student.userstanding': {
'Meta': {'object_name': 'UserStanding'},
'account_status': ('django.db.models.fields.CharField', [], {'max_length': '31', 'blank': 'True'}),
'changed_by': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']", 'blank': 'True'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'standing_last_changed_at': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'blank': 'True'}),
'user': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'standing'", 'unique': 'True', 'to': "orm['auth.User']"})
},
'student.usertestgroup': {
'Meta': {'object_name': 'UserTestGroup'},
'description': ('django.db.models.fields.TextField', [], {'blank': 'True'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'name': ('django.db.models.fields.CharField', [], {'max_length': '32', 'db_index': 'True'}),
'users': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.User']", 'db_index': 'True', 'symmetrical': 'False'})
}
}
complete_apps = ['student']

View File

@@ -0,0 +1,210 @@
# -*- coding: utf-8 -*-
from south.utils import datetime_utils as datetime
from south.db import db
from south.v2 import SchemaMigration
from django.db import models
class Migration(SchemaMigration):
def forwards(self, orm):
# Adding model 'LanguageProficiency'
db.create_table('student_languageproficiency', (
('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)),
('user_profile', self.gf('django.db.models.fields.related.ForeignKey')(related_name='language_proficiencies', to=orm['student.UserProfile'])),
('code', self.gf('django.db.models.fields.CharField')(max_length=16)),
))
db.send_create_signal('student', ['LanguageProficiency'])
# Adding unique constraint on 'LanguageProficiency', fields ['code', 'user_profile']
db.create_unique('student_languageproficiency', ['code', 'user_profile_id'])
def backwards(self, orm):
# Removing unique constraint on 'LanguageProficiency', fields ['code', 'user_profile']
db.delete_unique('student_languageproficiency', ['code', 'user_profile_id'])
# Deleting model 'LanguageProficiency'
db.delete_table('student_languageproficiency')
models = {
'auth.group': {
'Meta': {'object_name': 'Group'},
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}),
'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'})
},
'auth.permission': {
'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'},
'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'name': ('django.db.models.fields.CharField', [], {'max_length': '50'})
},
'auth.user': {
'Meta': {'object_name': 'User'},
'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}),
'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}),
'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}),
'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'})
},
'contenttypes.contenttype': {
'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"},
'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
'name': ('django.db.models.fields.CharField', [], {'max_length': '100'})
},
'student.anonymoususerid': {
'Meta': {'object_name': 'AnonymousUserId'},
'anonymous_user_id': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '32'}),
'course_id': ('xmodule_django.models.CourseKeyField', [], {'db_index': 'True', 'max_length': '255', 'blank': 'True'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"})
},
'student.courseaccessrole': {
'Meta': {'unique_together': "(('user', 'org', 'course_id', 'role'),)", 'object_name': 'CourseAccessRole'},
'course_id': ('xmodule_django.models.CourseKeyField', [], {'db_index': 'True', 'max_length': '255', 'blank': 'True'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'org': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '64', 'blank': 'True'}),
'role': ('django.db.models.fields.CharField', [], {'max_length': '64', 'db_index': 'True'}),
'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"})
},
'student.courseenrollment': {
'Meta': {'ordering': "('user', 'course_id')", 'unique_together': "(('user', 'course_id'),)", 'object_name': 'CourseEnrollment'},
'course_id': ('xmodule_django.models.CourseKeyField', [], {'max_length': '255', 'db_index': 'True'}),
'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'null': 'True', 'db_index': 'True', 'blank': 'True'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
'mode': ('django.db.models.fields.CharField', [], {'default': "'honor'", 'max_length': '100'}),
'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"})
},
'student.courseenrollmentallowed': {
'Meta': {'unique_together': "(('email', 'course_id'),)", 'object_name': 'CourseEnrollmentAllowed'},
'auto_enroll': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
'course_id': ('xmodule_django.models.CourseKeyField', [], {'max_length': '255', 'db_index': 'True'}),
'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'null': 'True', 'db_index': 'True', 'blank': 'True'}),
'email': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'})
},
'student.dashboardconfiguration': {
'Meta': {'object_name': 'DashboardConfiguration'},
'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'}),
'enabled': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'recent_enrollment_time_delta': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'})
},
'student.entranceexamconfiguration': {
'Meta': {'unique_together': "(('user', 'course_id'),)", 'object_name': 'EntranceExamConfiguration'},
'course_id': ('xmodule_django.models.CourseKeyField', [], {'max_length': '255', 'db_index': 'True'}),
'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'null': 'True', 'db_index': 'True', 'blank': 'True'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'skip_entrance_exam': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
'updated': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'db_index': 'True', 'blank': 'True'}),
'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"})
},
'student.languageproficiency': {
'Meta': {'unique_together': "(('code', 'user_profile'),)", 'object_name': 'LanguageProficiency'},
'code': ('django.db.models.fields.CharField', [], {'max_length': '16'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'user_profile': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'language_proficiencies'", 'to': "orm['student.UserProfile']"})
},
'student.linkedinaddtoprofileconfiguration': {
'Meta': {'object_name': 'LinkedInAddToProfileConfiguration'},
'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'}),
'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'}),
'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': {
'Meta': {'object_name': 'LoginFailures'},
'failure_count': ('django.db.models.fields.IntegerField', [], {'default': '0'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'lockout_until': ('django.db.models.fields.DateTimeField', [], {'null': 'True'}),
'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"})
},
'student.passwordhistory': {
'Meta': {'object_name': 'PasswordHistory'},
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}),
'time_set': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"})
},
'student.pendingemailchange': {
'Meta': {'object_name': 'PendingEmailChange'},
'activation_key': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '32', 'db_index': 'True'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'new_email': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '255', 'blank': 'True'}),
'user': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['auth.User']", 'unique': 'True'})
},
'student.pendingnamechange': {
'Meta': {'object_name': 'PendingNameChange'},
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'new_name': ('django.db.models.fields.CharField', [], {'max_length': '255', 'blank': 'True'}),
'rationale': ('django.db.models.fields.CharField', [], {'max_length': '1024', 'blank': 'True'}),
'user': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['auth.User']", 'unique': 'True'})
},
'student.registration': {
'Meta': {'object_name': 'Registration', 'db_table': "'auth_registration'"},
'activation_key': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '32', 'db_index': 'True'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']", 'unique': 'True'})
},
'student.userprofile': {
'Meta': {'object_name': 'UserProfile', 'db_table': "'auth_userprofile'"},
'allow_certificate': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
'bio': ('django.db.models.fields.CharField', [], {'db_index': 'False', 'null': 'True', 'blank': 'True'}),
'city': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
'country': ('django_countries.fields.CountryField', [], {'max_length': '2', 'null': 'True', 'blank': 'True'}),
'courseware': ('django.db.models.fields.CharField', [], {'default': "'course.xml'", 'max_length': '255', 'blank': 'True'}),
'gender': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '6', 'null': 'True', 'blank': 'True'}),
'goals': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
'profile_image_uploaded_at': ('django.db.models.fields.DateTimeField', [], {'null': 'True'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'language': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '255', 'blank': 'True'}),
'level_of_education': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '6', 'null': 'True', 'blank': 'True'}),
'location': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '255', 'blank': 'True'}),
'mailing_address': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
'meta': ('django.db.models.fields.TextField', [], {'blank': 'True'}),
'name': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '255', 'blank': 'True'}),
'user': ('django.db.models.fields.related.OneToOneField', [], {'related_name': "'profile'", 'unique': 'True', 'to': "orm['auth.User']"}),
'year_of_birth': ('django.db.models.fields.IntegerField', [], {'db_index': 'True', 'null': 'True', 'blank': 'True'})
},
'student.usersignupsource': {
'Meta': {'object_name': 'UserSignupSource'},
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'site': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}),
'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"})
},
'student.userstanding': {
'Meta': {'object_name': 'UserStanding'},
'account_status': ('django.db.models.fields.CharField', [], {'max_length': '31', 'blank': 'True'}),
'changed_by': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']", 'blank': 'True'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'standing_last_changed_at': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'blank': 'True'}),
'user': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'standing'", 'unique': 'True', 'to': "orm['auth.User']"})
},
'student.usertestgroup': {
'Meta': {'object_name': 'UserTestGroup'},
'description': ('django.db.models.fields.TextField', [], {'blank': 'True'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'name': ('django.db.models.fields.CharField', [], {'max_length': '32', 'db_index': 'True'}),
'users': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.User']", 'db_index': 'True', 'symmetrical': 'False'})
}
}
complete_apps = ['student']

View File

@@ -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.db import models, IntegrityError
from django.db.models import Count
from django.db.models.signals import pre_save, post_save
from django.dispatch import receiver, Signal
from django.core.exceptions import ObjectDoesNotExist
from django.utils.translation import ugettext_noop
@@ -40,6 +41,7 @@ from importlib import import_module
from opaque_keys.edx.locations import SlashSeparatedCourseKey
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 xmodule_django.models import CourseKeyField, NoneToEmptyManager
from xmodule.modulestore.exceptions import ItemNotFoundError
@@ -249,6 +251,16 @@ class UserProfile(models.Model):
country = CountryField(blank=True, null=True)
goals = models.TextField(blank=True, null=True)
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
js_str = self.meta
@@ -276,6 +288,96 @@ class UserProfile(models.Model):
self.set_meta(meta)
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):
"""
@@ -1561,3 +1663,25 @@ class EntranceExamConfiguration(models.Model):
except EntranceExamConfiguration.DoesNotExist:
can_skip = False
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.")
)

View File

@@ -43,7 +43,9 @@ class AutoAuthEnabledTestCase(UrlResetMixin, TestCase):
"""
self._auto_auth()
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):
self._auto_auth(username='test')

View File

@@ -5,7 +5,8 @@ import unittest
from student.tests.factories import UserFactory, RegistrationFactory, PendingEmailChangeFactory
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 django.core.urlresolvers import reverse
@@ -19,6 +20,7 @@ from django.conf import settings
from edxmako.shortcuts import render_to_string
from edxmako.tests import mako_middleware_process_request
from util.request import safe_get_host
from util.testing import EventTestMixin
class TestException(Exception):
@@ -198,10 +200,11 @@ class ReactivationEmailTests(EmailTestMixin, TestCase):
self.assertTrue(response_data['success'])
class EmailChangeRequestTests(TestCase):
class EmailChangeRequestTests(EventTestMixin, TestCase):
"""Test changing a user's email address"""
def setUp(self):
super(EmailChangeRequestTests, self).setUp('student.views.tracker')
self.user = UserFactory.create()
self.new_email = 'new.email@edx.org'
self.req_factory = RequestFactory()
@@ -275,6 +278,7 @@ class EmailChangeRequestTests(TestCase):
send_mail.side_effect = [Exception, None]
self.request.POST['new_email'] = "valid@email.com"
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('student.views.render_to_string', Mock(side_effect=mock_render_to_string, autospec=True))
@@ -295,6 +299,9 @@ class EmailChangeRequestTests(TestCase):
settings.DEFAULT_FROM_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')

View File

@@ -194,7 +194,7 @@ class EnrollmentTest(UrlResetMixin, ModuleStoreTestCase):
"""Change the student's enrollment status in a course.
Args:
action (string): The action to perform (either "enroll" or "unenroll")
action (str): The action to perform (either "enroll" or "unenroll")
Keyword Args:
course_id (unicode): If provided, use this course ID. Otherwise, use the

View File

@@ -0,0 +1,148 @@
# -*- 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()

View File

@@ -0,0 +1,86 @@
"""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)

View File

@@ -17,20 +17,22 @@ from django.utils.http import int_to_base36
from mock import Mock, patch
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.test_email import mock_render_to_string
from util.testing import EventTestMixin
from test_microsite import fake_site_name
@ddt.ddt
class ResetPasswordTests(TestCase):
class ResetPasswordTests(EventTestMixin, TestCase):
""" Tests that clicking reset password sends email, and doesn't activate the user
"""
request_factory = RequestFactory()
def setUp(self):
super(ResetPasswordTests, self).setUp('student.views.tracker')
self.user = UserFactory.create()
self.user.is_active = False
self.user.save()
@@ -55,6 +57,7 @@ class ResetPasswordTests(TestCase):
'success': True,
'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))
def test_nonexist_email_password_reset(self):
@@ -71,6 +74,7 @@ class ResetPasswordTests(TestCase):
'success': True,
'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))
def test_password_reset_ratelimited(self):
@@ -88,6 +92,7 @@ class ResetPasswordTests(TestCase):
bad_req = self.request_factory.post('/password_reset/', {'email': 'thisdoesnotexist@foo.com'})
bad_resp = password_reset(bad_req)
self.assertEquals(bad_resp.status_code, 403)
self.assert_no_events_were_emitted()
cache.clear()
@@ -98,6 +103,7 @@ class ResetPasswordTests(TestCase):
"""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.user = self.user
good_resp = password_reset(good_req)
self.assertEquals(good_resp.status_code, 200)
obj = json.loads(good_resp.content)
@@ -113,6 +119,10 @@ class ResetPasswordTests(TestCase):
self.assertEquals(len(to_addrs), 1)
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
self.user = User.objects.get(pk=self.user.pk)
self.assertFalse(self.user.is_active)
@@ -130,12 +140,17 @@ class ResetPasswordTests(TestCase):
'/password_reset/', {'email': self.user.email}
)
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]
expected_msg = "Please go to the following page and choose a new password:\n\n" + protocol
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")
@patch('django.core.mail.send_mail')
@ddt.data(('Crazy Awesome Site', 'Crazy Awesome Site'), (None, 'edX'))
@@ -150,7 +165,8 @@ class ResetPasswordTests(TestCase):
'/password_reset/', {'email': self.user.email}
)
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]
reset_msg = "you requested a password reset for your user account at {}"
@@ -164,6 +180,10 @@ class ResetPasswordTests(TestCase):
sign_off = "The {} Team".format(platform_name)
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")
@patch("microsite_configuration.microsite.get_value", fake_site_name)
@patch('django.core.mail.send_mail')
@@ -176,13 +196,18 @@ class ResetPasswordTests(TestCase):
'/password_reset/', {'email': self.user.email}
)
req.get_host = Mock(return_value=None)
resp = password_reset(req)
req.user = self.user
password_reset(req)
_, msg, _, _ = send_email.call_args[0]
reset_msg = "you requested a password reset for your user account at openedx.localhost"
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')
def test_reset_password_bad_token(self, reset_confirm):
"""Tests bad token and uidb36 in password reset"""

View File

@@ -21,12 +21,13 @@ from mock import Mock, patch
from opaque_keys.edx.locations import SlashSeparatedCourseKey
from student.models import (
anonymous_id_for_user, user_by_anonymous_id, CourseEnrollment, unique_id_for_user,
LinkedInAddToProfileConfiguration
anonymous_id_for_user, user_by_anonymous_id, CourseEnrollment, unique_id_for_user, LinkedInAddToProfileConfiguration
)
from student.views import (process_survey_link, _cert_info,
change_enrollment, complete_course_mode_info)
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.django_utils import ModuleStoreTestCase
@@ -485,19 +486,33 @@ class DashboardTest(ModuleStoreTestCase):
self.assertContains(response, expected_url)
class EnrollmentEventTestMixin(object):
""" Mixin with assertions for validating enrollment events. """
class UserSettingsEventTestMixin(EventTestMixin):
"""
Mixin for verifying that user setting events were emitted during a test.
"""
def setUp(self):
super(EnrollmentEventTestMixin, self).setUp()
patcher = patch('student.models.tracker')
self.mock_tracker = patcher.start()
self.addCleanup(patcher.stop)
super(UserSettingsEventTestMixin, self).setUp('util.model_utils.tracker')
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
self.mock_tracker.reset_mock()
def assert_user_setting_event_emitted(self, **kwargs):
"""
Helper method to assert that we emit the expected user settings events.
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):
"""Ensures an enrollment mode change event was emitted"""

View File

@@ -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
SETTING_CHANGE_INITIATED = 'edx.user.settings.change_initiated'
def csrf_token(context):
"""A csrf token that can be included in a form."""
@@ -620,40 +622,11 @@ def dashboard(request):
enrolled_courses_either_paid = frozenset(course.id for course, _enrollment in course_enrollment_pairs
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,
# we'll display the banner
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.
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):
'course_enrollment_pairs': course_enrollment_pairs,
'course_optouts': course_optouts,
'message': message,
'external_auth_map': external_auth_map,
'staff_access': staff_access,
'errored_courses': errored_courses,
'show_courseware_links_for': show_courseware_links_for,
@@ -693,11 +665,7 @@ def dashboard(request):
'block_courses': block_courses,
'denied_banner': denied_banner,
'billing_email': settings.PAYMENT_SUPPORT_EMAIL,
'language_options': language_options,
'current_language': current_language,
'current_language_code': current_language_code,
'user': user,
'duplicate_provider': None,
'logout_url': reverse(logout_user),
'platform_name': platform_name,
'enrolled_courses_either_paid': enrolled_courses_either_paid,
@@ -707,10 +675,6 @@ def dashboard(request):
'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)
@@ -1758,13 +1722,14 @@ def auto_auth(request):
# If successful, this will return a tuple containing
# the new user object.
try:
user, _profile, reg = _do_create_account(form)
user, profile, reg = _do_create_account(form)
except AccountValidationError:
# Attempt to retrieve the existing user.
user = User.objects.get(username=username)
user.email = email
user.set_password(password)
user.save()
profile = UserProfile.objects.get(user=user)
reg = Registration.objects.get(user=user)
# Set the user's global staff bit
@@ -1776,6 +1741,12 @@ def auto_auth(request):
reg.activate()
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
if course_key is not None:
CourseEnrollment.enroll(user, course_key)
@@ -1864,6 +1835,18 @@ def password_reset(request):
from_email=settings.DEFAULT_FROM_EMAIL,
request=request,
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:
# bad user? tick the rate limiter counter
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):
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.'))
# 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
@transaction.commit_manually

View File

@@ -114,9 +114,9 @@ AUTH_EMAIL_OPT_IN_KEY = 'email_opt_in'
# The following are various possible values for the AUTH_ENTRY_KEY.
AUTH_ENTRY_DASHBOARD = 'dashboard'
AUTH_ENTRY_LOGIN = 'login'
AUTH_ENTRY_REGISTER = 'register'
AUTH_ENTRY_ACCOUNT_SETTINGS = 'account_settings'
# This is left-over from an A/B test
# of the new combined login/registration page (ECOM-369)
@@ -142,9 +142,9 @@ def is_api(auth_entry):
# We don't use "reverse" here because doing so may cause modules
# to load that depend on this module.
AUTH_DISPATCH_URLS = {
AUTH_ENTRY_DASHBOARD: '/dashboard',
AUTH_ENTRY_LOGIN: '/login',
AUTH_ENTRY_REGISTER: '/register',
AUTH_ENTRY_ACCOUNT_SETTINGS: '/account/settings',
# This is left-over from an A/B test
# of the new combined login/registration page (ECOM-369)
@@ -156,9 +156,9 @@ AUTH_DISPATCH_URLS = {
}
_AUTH_ENTRY_CHOICES = frozenset([
AUTH_ENTRY_DASHBOARD,
AUTH_ENTRY_LOGIN,
AUTH_ENTRY_REGISTER,
AUTH_ENTRY_ACCOUNT_SETTINGS,
# This is left-over from an A/B test
# of the new combined login/registration page (ECOM-369)
@@ -577,7 +577,7 @@ def login_analytics(strategy, auth_entry, *args, **kwargs):
event_name = None
if auth_entry in [AUTH_ENTRY_LOGIN, AUTH_ENTRY_LOGIN_2]:
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'
if event_name is not None:
@@ -623,7 +623,7 @@ def change_enrollment(strategy, auth_entry=None, user=None, *args, **kwargs):
user (User): The user being authenticated.
"""
# 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,
# 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):
# args when sending users to this page, successfully authenticating through this page
# would also enroll the student in the course.
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)
modes = CourseMode.modes_for_course_dict(course_id)

View File

@@ -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 student import models as student_models
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 settings as auth_settings
@@ -110,41 +111,25 @@ class IntegrationTest(testutil.TestCase, test.TestCase):
self.client = test.Client()
self.request_factory = test.RequestFactory()
def assert_dashboard_response_looks_correct(self, response, user, duplicate=False, linked=None):
"""Asserts the user's dashboard is in the expected state.
def assert_account_settings_context_looks_correct(self, context, user, duplicate=False, linked=None):
"""Asserts the user's account settings page context is in the expected state.
We check unconditionally that the dashboard 200s and contains the
user's info. If duplicate is True, we expect the duplicate account
association error to be present. If linked is passed, we conditionally
check the content and controls in the Account Links section of the
sidebar.
If duplicate is True, we expect context['duplicate_provider'] to contain
the duplicate provider object. If linked is passed, we conditionally
check that the provider is included in context['auth']['providers'] and
its connected state is correct.
"""
duplicate_account_error_needle = '<section class="dashboard-banner third-party-auth">'
assert_duplicate_presence_fn = self.assertIn if duplicate else self.assertNotIn
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 duplicate:
self.assertEqual(context['duplicate_provider'].NAME, self.PROVIDER_CLASS.NAME)
else:
self.assertIsNone(context['duplicate_provider'])
if linked is not None:
if linked:
expected_control_text = pipeline.ProviderUserState(
self.PROVIDER_CLASS, user, False).get_unlink_form_name()
else:
expected_control_text = pipeline.get_login_url(self.PROVIDER_CLASS.NAME, pipeline.AUTH_ENTRY_DASHBOARD)
provider_name = re.search(r'<span class="provider">([^<]+)', response.content, re.DOTALL).groups()[0]
self.assertIn(expected_control_text, response.content)
if linked:
self.assertIn("fa fa-link", response.content)
self.assertNotIn("fa fa-unlink", response.content)
else:
self.assertNotIn("fa fa-link", response.content)
self.assertIn("fa fa-unlink", response.content)
self.assertEqual(self.PROVIDER_CLASS.NAME, provider_name)
expected_provider = [
provider for provider in context['auth']['providers'] if provider['name'] == self.PROVIDER_CLASS.NAME
][0]
self.assertIsNotNone(expected_provider)
self.assertEqual(expected_provider['connected'], linked)
def assert_exception_redirect_looks_correct(self, expected_uri, auth_entry=None):
"""Tests middleware conditional redirection.
@@ -406,6 +391,11 @@ class IntegrationTest(testutil.TestCase, test.TestCase):
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)
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):
self.assert_exception_redirect_looks_correct('/')
@@ -432,7 +422,7 @@ class IntegrationTest(testutil.TestCase, test.TestCase):
# First we expect that we're in the unlinked state, and that there
# 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)
# We should be redirected back to the complete page, setting
@@ -452,7 +442,7 @@ class IntegrationTest(testutil.TestCase, test.TestCase):
# 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_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):
# First, create, the request and strategy that store pipeline state,
@@ -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
# 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)
# Fire off the disconnect pipeline to unlink.
@@ -487,7 +477,7 @@ class IntegrationTest(testutil.TestCase, test.TestCase):
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.
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)
def test_linking_already_associated_account_raises_auth_already_associated(self):
@@ -541,8 +531,8 @@ class IntegrationTest(testutil.TestCase, test.TestCase):
request,
exceptions.AuthAlreadyAssociated(self.PROVIDER_CLASS.BACKEND_CLASS.name, 'account is already in use.'))
self.assert_dashboard_response_looks_correct(
student_views.dashboard(request), user, duplicate=True, linked=True)
self.assert_account_settings_context_looks_correct(
account_settings_context(request), user, duplicate=True, linked=True)
def test_full_pipeline_succeeds_for_signing_in_to_existing_active_account(self):
# First, create, the request and strategy that store pipeline state,
@@ -593,7 +583,7 @@ class IntegrationTest(testutil.TestCase, test.TestCase):
self.assert_redirect_to_dashboard_looks_correct(
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):
_, strategy = self.get_request_and_strategy(
@@ -710,7 +700,7 @@ class IntegrationTest(testutil.TestCase, test.TestCase):
self.assert_redirect_to_dashboard_looks_correct(
actions.do_complete(strategy, social_views._do_login, user=created_user))
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):
original_username = self.get_username()

View File

@@ -145,9 +145,9 @@ class PipelineEnrollmentTest(UrlResetMixin, ModuleStoreTestCase):
strategy = self._fake_strategy()
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.
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
self.assertEqual(result, {})

View File

@@ -0,0 +1,147 @@
"""
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

View File

@@ -1,5 +1,7 @@
import sys
from mock import patch
from django.conf import settings
from django.core.urlresolvers import clear_url_caches, resolve
@@ -55,3 +57,36 @@ class UrlResetMixin(object):
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()

View File

@@ -1,6 +1,6 @@
define(['sinon', 'underscore'], function(sinon, _) {
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
* get reference to mock requests. In either case, the cleanup (restore) is done with
@@ -109,7 +109,7 @@ define(['sinon', 'underscore'], function(sinon, _) {
);
};
respondToDelete = function(requests, requestIndex) {
respondWithNoContent = function(requests, requestIndex) {
if (_.isUndefined(requestIndex)) {
requestIndex = requests.length - 1;
}
@@ -125,6 +125,6 @@ define(['sinon', 'underscore'], function(sinon, _) {
'respondWithJson': respondWithJson,
'respondWithError': respondWithError,
'respondWithTextError': respondWithTextError,
'respondToDelete': respondToDelete
'respondWithNoContent': respondWithNoContent,
};
});

View File

@@ -0,0 +1,59 @@
"""
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()

View File

@@ -50,19 +50,18 @@ class DashboardPage(PageObject):
return self.q(css='h3.course-title > a').map(_get_course_name).results
@property
def full_name(self):
"""Return the displayed value for the user's full name"""
return self.q(css='li.info--username .data').text[0]
def sidebar_menu_title(self):
"""
Return the title value for sidebar menu.
"""
return self.q(css='.user-info span.title').text[0]
@property
def email(self):
"""Return the displayed value for the user's email address"""
return self.q(css='li.info--email .data').text[0]
@property
def username(self):
"""Return the displayed value for the user's username"""
return self.q(css='.username-label').text[0]
def sidebar_menu_description(self):
"""
Return the description text for sidebar menu.
"""
return self.q(css='.user-info span.copy').text[0]
def get_enrollment_mode(self, course_name):
"""Get the enrollment mode for a given course on the dashboard.
@@ -149,27 +148,6 @@ class DashboardPage(PageObject):
else:
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):
"""
Verify if pre-requisite course messages are being displayed.
@@ -183,3 +161,28 @@ class DashboardPage(PageObject):
def get_course_social_sharing_widget(self, widget_name):
""" Retrieves the specified social sharing widget by its classification """
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()

View File

@@ -444,9 +444,9 @@ class DiscussionUserProfilePage(CoursePage):
return (
self.q(css='section.discussion-user-threads[data-course-id="{}"]'.format(self.course_id)).present
and
self.q(css='section.user-profile div.sidebar-username').present
self.q(css='section.user-profile a.leaner-profile-link').present
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
@@ -526,6 +526,10 @@ class DiscussionUserProfilePage(CoursePage):
"Window is on top"
).fulfill()
def click_on_sidebar_username(self):
self.wait_for_page()
self.q(css='.leaner-profile-link').first.click()
class DiscussionTabHomePage(CoursePage, DiscussionPageMixin):

View File

@@ -0,0 +1,222 @@
# -*- 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()

View File

@@ -0,0 +1,271 @@
"""
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

View File

@@ -19,6 +19,8 @@ from ...pages.lms.discussion import (
DiscussionTabHomePage,
DiscussionSortPreferencePage,
)
from ...pages.lms.learner_profile import LearnerProfilePage
from ...fixtures.course import CourseFixture, XBlockFixtureDesc
from ...fixtures.discussion import (
SingleThreadViewFixture,
@@ -753,6 +755,24 @@ class DiscussionUserProfileTest(UniqueCourseTest):
page.wait_for_ajax()
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')
class DiscussionSearchAlertTest(UniqueCourseTest):

View File

@@ -277,26 +277,82 @@ class EventsTestMixin(object):
def setUp(self):
super(EventsTestMixin, self).setUp()
self.event_collection = MongoClient()["test"]["events"]
self.event_collection.drop()
self.start_time = datetime.now()
self.reset_event_tracking()
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.
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_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 num_times_emitted: number of times the event is expected to appear since the event_time
"""
self.assertEqual(
self.event_collection.find(
{
"name": event_name,
"time": {"$gt": event_time},
"event.user_id": int(event_user_id),
}
).count(), num_times_emitted
)
find_kwargs = {
"name": event_name,
"time": {"$gt": event_time},
"event.user_id": int(event_user_id),
}
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])
)
class UniqueCourseTest(WebAppTest):

View File

@@ -0,0 +1,466 @@
# -*- coding: utf-8 -*-
"""
End-to-end tests for the Account Settings page.
"""
from unittest import skip
from bok_choy.web_app_test import WebAppTest
from ...pages.lms.account_settings import AccountSettingsPage
from ...pages.lms.auto_auth import AutoAuthPage
from ...pages.lms.dashboard import DashboardPage
from ..helpers import EventsTestMixin
class AccountSettingsTestMixin(EventsTestMixin, WebAppTest):
"""
Mixin with helper methods to test the account settings page.
"""
CHANGE_INITIATED_EVENT_NAME = u"edx.user.settings.change_initiated"
USER_SETTINGS_CHANGED_EVENT_NAME = 'edx.user.settings.changed'
ACCOUNT_SETTINGS_REFERER = u"/account/settings"
def log_in_as_unique_user(self, email=None):
"""
Create a unique user and return the account's username and id.
"""
username = "test_{uuid}".format(uuid=self.unique_id[0:6])
auto_auth_page = AutoAuthPage(self.browser, username=username, email=email).visit()
user_id = auto_auth_page.get_user_id()
return username, user_id
def assert_event_emitted_num_times(self, user_id, setting, num_times):
"""
Verify a particular user settings change event was emitted a certain
number of times.
"""
# pylint disable=no-member
super(AccountSettingsTestMixin, self).assert_event_emitted_num_times(
self.USER_SETTINGS_CHANGED_EVENT_NAME, self.start_time, user_id, num_times, setting=setting
)
def verify_settings_changed_events(self, username, user_id, events, table=None):
"""
Verify a particular set of account settings change events were fired.
"""
expected_referers = [self.ACCOUNT_SETTINGS_REFERER] * len(events)
for event in events:
event[u"user_id"] = long(user_id)
event[u"table"] = u"auth_userprofile" if table is None else table
event[u"truncated"] = []
self.verify_events_of_type(
username, self.USER_SETTINGS_CHANGED_EVENT_NAME, events,
expected_referers=expected_referers
)
class DashboardMenuTest(AccountSettingsTestMixin, WebAppTest):
"""
Tests that the dashboard menu works correctly with the account settings page.
"""
def test_link_on_dashboard_works(self):
"""
Scenario: Verify that the "Account Settings" link works from the dashboard.
Given that I am a registered user
And I visit my dashboard
And I click on "Account Settings" in the top drop down
Then I should see my account settings page
"""
self.log_in_as_unique_user()
dashboard_page = DashboardPage(self.browser)
dashboard_page.visit()
dashboard_page.click_username_dropdown()
self.assertIn('Account Settings', dashboard_page.username_dropdown_link_text)
dashboard_page.click_account_settings_link()
class AccountSettingsPageTest(AccountSettingsTestMixin, WebAppTest):
"""
Tests that verify behaviour of the Account Settings page.
"""
SUCCESS_MESSAGE = 'Your changes have been saved.'
def setUp(self):
"""
Initialize account and pages.
"""
super(AccountSettingsPageTest, self).setUp()
self.username, self.user_id = self.log_in_as_unique_user()
self.visit_account_settings_page()
def visit_account_settings_page(self):
"""
Visit the account settings page for the current user.
"""
self.account_settings_page = AccountSettingsPage(self.browser)
self.account_settings_page.visit()
self.account_settings_page.wait_for_ajax()
def test_page_view_event(self):
"""
Scenario: An event should be recorded when the "Account Settings"
page is viewed.
Given that I am a registered user
And I visit my account settings page
Then a page view analytics event should be recorded
"""
self.verify_events_of_type(
self.username,
u"edx.user.settings.viewed",
[{
u"user_id": long(self.user_id),
u"page": u"account",
u"visibility": None,
}]
)
def test_all_sections_and_fields_are_present(self):
"""
Scenario: Verify that all sections and fields are present on the page.
"""
expected_sections_structure = [
{
'title': 'Basic Account Information (required)',
'fields': [
'Username',
'Full Name',
'Email Address',
'Password',
'Language',
'Country or Region'
]
},
{
'title': 'Additional Information (optional)',
'fields': [
'Education Completed',
'Gender',
'Year of Birth',
'Preferred Language',
]
},
{
'title': 'Connected Accounts',
'fields': [
'Facebook',
'Google',
]
}
]
self.assertEqual(self.account_settings_page.sections_structure(), expected_sections_structure)
def _test_readonly_field(self, field_id, title, value):
"""
Test behavior of a readonly field.
"""
self.assertEqual(self.account_settings_page.title_for_field(field_id), title)
self.assertEqual(self.account_settings_page.value_for_readonly_field(field_id), value)
def _test_text_field(
self, field_id, title, initial_value, new_invalid_value, new_valid_values, success_message=SUCCESS_MESSAGE,
assert_after_reload=True
):
"""
Test behaviour of a text field.
"""
self.assertEqual(self.account_settings_page.title_for_field(field_id), title)
self.assertEqual(self.account_settings_page.value_for_text_field(field_id), initial_value)
self.assertEqual(
self.account_settings_page.value_for_text_field(field_id, new_invalid_value), new_invalid_value
)
self.account_settings_page.wait_for_indicator(field_id, 'validation-error')
self.browser.refresh()
self.assertNotEqual(self.account_settings_page.value_for_text_field(field_id), new_invalid_value)
for new_value in new_valid_values:
self.assertEqual(self.account_settings_page.value_for_text_field(field_id, new_value), new_value)
self.account_settings_page.wait_for_messsage(field_id, success_message)
if assert_after_reload:
self.browser.refresh()
self.assertEqual(self.account_settings_page.value_for_text_field(field_id), new_value)
def _test_dropdown_field(
self, field_id, title, initial_value, new_values, success_message=SUCCESS_MESSAGE, reloads_on_save=False
):
"""
Test behaviour of a dropdown field.
"""
self.assertEqual(self.account_settings_page.title_for_field(field_id), title)
self.assertEqual(self.account_settings_page.value_for_dropdown_field(field_id), initial_value)
for new_value in new_values:
self.assertEqual(self.account_settings_page.value_for_dropdown_field(field_id, new_value), new_value)
self.account_settings_page.wait_for_messsage(field_id, success_message)
if reloads_on_save:
self.account_settings_page.wait_for_loading_indicator()
else:
self.browser.refresh()
self.account_settings_page.wait_for_page()
self.assertEqual(self.account_settings_page.value_for_dropdown_field(field_id), new_value)
def _test_link_field(self, field_id, title, link_title, success_message):
"""
Test behaviour a link field.
"""
self.assertEqual(self.account_settings_page.title_for_field(field_id), title)
self.assertEqual(self.account_settings_page.link_title_for_link_field(field_id), link_title)
self.account_settings_page.click_on_link_in_link_field(field_id)
self.account_settings_page.wait_for_messsage(field_id, success_message)
def test_username_field(self):
"""
Test behaviour of "Username" field.
"""
self._test_readonly_field('username', 'Username', self.username)
def test_full_name_field(self):
"""
Test behaviour of "Full Name" field.
"""
self._test_text_field(
u'name',
u'Full Name',
self.username,
u'@',
[u'another name', self.username],
)
self.verify_settings_changed_events(
self.username, self.user_id,
[
{
u"setting": u"name",
u"old": self.username,
u"new": u"another name",
},
{
u"setting": u"name",
u"old": u'another name',
u"new": self.username,
}
]
)
def test_email_field(self):
"""
Test behaviour of "Email" field.
"""
email = u"test@example.com"
username, user_id = self.log_in_as_unique_user(email=email)
self.visit_account_settings_page()
self._test_text_field(
u'email',
u'Email Address',
email,
u'@',
[u'me@here.com', u'you@there.com'],
success_message='Click the link in the message to update your email address.',
assert_after_reload=False
)
self.verify_events_of_type(
username,
self.CHANGE_INITIATED_EVENT_NAME,
[
{
u"user_id": long(user_id),
u"setting": u"email",
u"old": email,
u"new": u'me@here.com'
},
{
u"user_id": long(user_id),
u"setting": u"email",
u"old": email, # NOTE the first email change was never confirmed, so old has not changed.
u"new": u'you@there.com'
}
],
[self.ACCOUNT_SETTINGS_REFERER, self.ACCOUNT_SETTINGS_REFERER]
)
# Email is not saved until user confirms, so no events should have been
# emitted.
self.assert_event_emitted_num_times(user_id, 'email', 0)
def test_password_field(self):
"""
Test behaviour of "Password" field.
"""
self._test_link_field(
u'password',
u'Password',
u'Reset Password',
success_message='Click the link in the message to reset your password.',
)
self.verify_events_of_type(
self.username,
self.CHANGE_INITIATED_EVENT_NAME,
[{
u"user_id": int(self.user_id),
u"setting": "password",
u"old": None,
u"new": None
}],
[self.ACCOUNT_SETTINGS_REFERER]
)
# Like email, since the user has not confirmed their password change,
# the field has not yet changed, so no events will have been emitted.
self.assert_event_emitted_num_times(self.user_id, 'password', 0)
@skip(
'On bokchoy test servers, language changes take a few reloads to fully realize '
'which means we can no longer reliably match the strings in the html in other tests.'
)
def test_language_field(self):
"""
Test behaviour of "Language" field.
"""
self._test_dropdown_field(
u'pref-lang',
u'Language',
u'English',
[u'Dummy Language (Esperanto)', u'English'],
reloads_on_save=True,
)
def test_education_completed_field(self):
"""
Test behaviour of "Education Completed" field.
"""
self._test_dropdown_field(
u'level_of_education',
u'Education Completed',
u'',
[u'Bachelor\'s degree', u''],
)
self.verify_settings_changed_events(
self.username, self.user_id,
[
{
u"setting": u"level_of_education",
u"old": None,
u"new": u'b',
},
{
u"setting": u"level_of_education",
u"old": u'b',
u"new": None,
}
]
)
def test_gender_field(self):
"""
Test behaviour of "Gender" field.
"""
self._test_dropdown_field(
u'gender',
u'Gender',
u'',
[u'Female', u''],
)
self.verify_settings_changed_events(
self.username, self.user_id,
[
{
u"setting": u"gender",
u"old": None,
u"new": u'f',
},
{
u"setting": u"gender",
u"old": u'f',
u"new": None,
}
]
)
def test_year_of_birth_field(self):
"""
Test behaviour of "Year of Birth" field.
"""
# Note that when we clear the year_of_birth here we're firing an event.
self.assertEqual(self.account_settings_page.value_for_dropdown_field('year_of_birth', ''), '')
self.reset_event_tracking()
self._test_dropdown_field(
u'year_of_birth',
u'Year of Birth',
u'',
[u'1980', u''],
)
self.verify_settings_changed_events(
self.username, self.user_id,
[
{
u"setting": u"year_of_birth",
u"old": None,
u"new": 1980L,
},
{
u"setting": u"year_of_birth",
u"old": 1980L,
u"new": None,
}
]
)
def test_country_field(self):
"""
Test behaviour of "Country or Region" field.
"""
self._test_dropdown_field(
u'country',
u'Country or Region',
u'',
[u'Pakistan', u'Palau'],
)
def test_preferred_language_field(self):
"""
Test behaviour of "Preferred Language" field.
"""
self._test_dropdown_field(
u'language_proficiencies',
u'Preferred Language',
u'',
[u'Pushto', u''],
)
self.verify_settings_changed_events(
self.username, self.user_id,
[
{
u"setting": u"language_proficiencies",
u"old": [],
u"new": [{u"code": u"ps"}],
},
{
u"setting": u"language_proficiencies",
u"old": [{u"code": u"ps"}],
u"new": [],
}
],
table=u"student_languageproficiency"
)
def test_connected_accounts(self):
"""
Test that fields for third party auth providers exist.
Currently there is no way to test the whole authentication process
because that would require accounts with the providers.
"""
for field_id, title, link_title in [
['auth-facebook', 'Facebook', 'Link'],
['auth-google', 'Google', 'Link'],
]:
self.assertEqual(self.account_settings_page.title_for_field(field_id), title)
self.assertEqual(self.account_settings_page.link_title_for_link_field(field_id), link_title)

View File

@@ -0,0 +1,704 @@
# -*- coding: utf-8 -*-
"""
End-to-end tests for Student's Profile Page.
"""
from datetime import datetime
from bok_choy.web_app_test import WebAppTest
from ...pages.common.logout import LogoutPage
from ...pages.lms.account_settings import AccountSettingsPage
from ...pages.lms.auto_auth import AutoAuthPage
from ...pages.lms.learner_profile import LearnerProfilePage
from ...pages.lms.dashboard import DashboardPage
from ..helpers import EventsTestMixin
class LearnerProfileTestMixin(EventsTestMixin):
"""
Mixin with helper methods for testing learner profile pages.
"""
PRIVACY_PUBLIC = u'all_users'
PRIVACY_PRIVATE = u'private'
PUBLIC_PROFILE_FIELDS = ['username', 'country', 'language_proficiencies', 'bio']
PRIVATE_PROFILE_FIELDS = ['username']
PUBLIC_PROFILE_EDITABLE_FIELDS = ['country', 'language_proficiencies', 'bio']
USER_SETTINGS_CHANGED_EVENT_NAME = u"edx.user.settings.changed"
def log_in_as_unique_user(self):
"""
Create a unique user and return the account's username and id.
"""
username = "test_{uuid}".format(uuid=self.unique_id[0:6])
auto_auth_page = AutoAuthPage(self.browser, username=username).visit()
user_id = auto_auth_page.get_user_id()
return username, user_id
def set_public_profile_fields_data(self, profile_page):
"""
Fill in the public profile fields of a user.
"""
profile_page.value_for_dropdown_field('language_proficiencies', 'English')
profile_page.value_for_dropdown_field('country', 'United Kingdom')
profile_page.value_for_textarea_field('bio', 'Nothing Special')
def visit_profile_page(self, username, privacy=None):
"""
Visits a user's profile page.
"""
profile_page = LearnerProfilePage(self.browser, username)
# Change the privacy if requested by loading the page and
# changing the drop down
if privacy is not None:
profile_page.visit()
profile_page.wait_for_page()
profile_page.privacy = privacy
if privacy == self.PRIVACY_PUBLIC:
self.set_public_profile_fields_data(profile_page)
# Reset event tracking so that the tests only see events from
# loading the profile page.
self.reset_event_tracking()
# Load the page
profile_page.visit()
profile_page.wait_for_page()
return profile_page
def set_birth_year(self, birth_year):
"""
Set birth year for the current user to the specified value.
"""
account_settings_page = AccountSettingsPage(self.browser)
account_settings_page.visit()
account_settings_page.wait_for_page()
self.assertEqual(
account_settings_page.value_for_dropdown_field('year_of_birth', str(birth_year)),
str(birth_year)
)
def verify_profile_page_is_public(self, profile_page, is_editable=True):
"""
Verify that the profile page is currently public.
"""
self.assertEqual(profile_page.visible_fields, self.PUBLIC_PROFILE_FIELDS)
if is_editable:
self.assertTrue(profile_page.privacy_field_visible)
self.assertEqual(profile_page.editable_fields, self.PUBLIC_PROFILE_EDITABLE_FIELDS)
else:
self.assertEqual(profile_page.editable_fields, [])
def verify_profile_page_is_private(self, profile_page, is_editable=True):
"""
Verify that the profile page is currently private.
"""
if is_editable:
self.assertTrue(profile_page.privacy_field_visible)
self.assertEqual(profile_page.visible_fields, self.PRIVATE_PROFILE_FIELDS)
def verify_profile_page_view_event(self, requesting_username, profile_user_id, visibility=None):
"""
Verifies that the correct view event was captured for the profile page.
"""
self.verify_events_of_type(
requesting_username,
u"edx.user.settings.viewed",
[{
u"user_id": int(profile_user_id),
u"page": u"profile",
u"visibility": unicode(visibility),
}]
)
def assert_event_emitted_num_times(self, profile_user_id, setting, num_times):
"""
Verify a particular user settings change event was emitted a certain
number of times.
"""
# pylint disable=no-member
super(LearnerProfileTestMixin, self).assert_event_emitted_num_times(
self.USER_SETTINGS_CHANGED_EVENT_NAME, self.start_time, profile_user_id, num_times, setting=setting
)
def verify_user_preference_changed_event(self, username, user_id, setting, old_value=None, new_value=None):
"""
Verifies that the correct user preference changed event was recorded.
"""
self.verify_events_of_type(
username,
self.USER_SETTINGS_CHANGED_EVENT_NAME,
[{
u"user_id": long(user_id),
u"table": u"user_api_userpreference",
u"setting": unicode(setting),
u"old": old_value,
u"new": new_value,
u"truncated": [],
}],
expected_referers=["/u/{username}".format(username=username)],
)
class OwnLearnerProfilePageTest(LearnerProfileTestMixin, WebAppTest):
"""
Tests that verify a student's own profile page.
"""
def verify_profile_forced_private_message(self, username, birth_year, message=None):
"""
Verify age limit messages for a user.
"""
self.set_birth_year(birth_year=birth_year if birth_year is not None else "")
profile_page = self.visit_profile_page(username)
self.assertTrue(profile_page.privacy_field_visible)
self.assertEqual(profile_page.age_limit_message_present, message is not None)
self.assertIn(message, profile_page.profile_forced_private_message)
def test_profile_defaults_to_public(self):
"""
Scenario: Verify that a new user's profile defaults to public.
Given that I am a new user.
When I go to my profile page.
Then I see that the profile visibility is set to public.
"""
username, user_id = self.log_in_as_unique_user()
profile_page = self.visit_profile_page(username)
self.verify_profile_page_is_public(profile_page)
def assert_default_image_has_public_access(self, profile_page):
"""
Assert that profile image has public access.
"""
self.assertTrue(profile_page.profile_has_default_image)
self.assertTrue(profile_page.profile_has_image_with_public_access())
def test_make_profile_public(self):
"""
Scenario: Verify that the user can change their privacy.
Given that I am a registered user
And I visit my private profile page
And I set the profile visibility to public
Then a user preference changed event should be recorded
When I reload the page
Then the profile visibility should be shown as public
"""
username, user_id = self.log_in_as_unique_user()
profile_page = self.visit_profile_page(username, privacy=self.PRIVACY_PRIVATE)
profile_page.privacy = self.PRIVACY_PUBLIC
self.verify_user_preference_changed_event(
username, user_id, "account_privacy",
old_value=self.PRIVACY_PRIVATE, # Note: default value was public, so we first change to private
new_value=self.PRIVACY_PUBLIC,
)
# Reload the page and verify that the profile is now public
self.browser.refresh()
profile_page.wait_for_page()
self.verify_profile_page_is_public(profile_page)
def test_make_profile_private(self):
"""
Scenario: Verify that the user can change their privacy.
Given that I am a registered user
And I visit my public profile page
And I set the profile visibility to private
Then a user preference changed event should be recorded
When I reload the page
Then the profile visibility should be shown as private
"""
username, user_id = self.log_in_as_unique_user()
profile_page = self.visit_profile_page(username, privacy=self.PRIVACY_PUBLIC)
profile_page.privacy = self.PRIVACY_PRIVATE
self.verify_user_preference_changed_event(
username, user_id, "account_privacy",
old_value=None, # Note: no old value as the default preference is public
new_value=self.PRIVACY_PRIVATE,
)
# Reload the page and verify that the profile is now private
self.browser.refresh()
profile_page.wait_for_page()
self.verify_profile_page_is_private(profile_page)
def test_dashboard_learner_profile_link(self):
"""
Scenario: Verify that my profile link is present on dashboard page and we can navigate to correct page.
Given that I am a registered user.
When I go to Dashboard page.
And I click on username dropdown.
Then I see My Profile link in the dropdown menu.
When I click on My Profile link.
Then I will be navigated to My Profile page.
"""
username, user_id = self.log_in_as_unique_user()
dashboard_page = DashboardPage(self.browser)
dashboard_page.visit()
dashboard_page.click_username_dropdown()
self.assertTrue('My Profile' in dashboard_page.username_dropdown_link_text)
dashboard_page.click_my_profile_link()
my_profile_page = LearnerProfilePage(self.browser, username)
my_profile_page.wait_for_page()
def test_fields_on_my_private_profile(self):
"""
Scenario: Verify that desired fields are shown when looking at her own private profile.
Given that I am a registered user.
And I visit My Profile page.
And I set the profile visibility to private.
And I reload the page.
Then I should see the profile visibility selector dropdown.
Then I see some of the profile fields are shown.
"""
username, user_id = self.log_in_as_unique_user()
profile_page = self.visit_profile_page(username, privacy=self.PRIVACY_PRIVATE)
self.verify_profile_page_is_private(profile_page)
self.verify_profile_page_view_event(username, user_id, visibility=self.PRIVACY_PRIVATE)
def test_fields_on_my_public_profile(self):
"""
Scenario: Verify that desired fields are shown when looking at her own public profile.
Given that I am a registered user.
And I visit My Profile page.
And I set the profile visibility to public.
And I reload the page.
Then I should see the profile visibility selector dropdown.
Then I see all the profile fields are shown.
And `location`, `language` and `about me` fields are editable.
"""
username, user_id = self.log_in_as_unique_user()
profile_page = self.visit_profile_page(username, privacy=self.PRIVACY_PUBLIC)
self.verify_profile_page_is_public(profile_page)
self.verify_profile_page_view_event(username, user_id, visibility=self.PRIVACY_PUBLIC)
def _test_dropdown_field(self, profile_page, field_id, new_value, displayed_value, mode):
"""
Test behaviour of a dropdown field.
"""
profile_page.value_for_dropdown_field(field_id, new_value)
self.assertEqual(profile_page.get_non_editable_mode_value(field_id), displayed_value)
self.assertTrue(profile_page.mode_for_field(field_id), mode)
self.browser.refresh()
profile_page.wait_for_page()
self.assertEqual(profile_page.get_non_editable_mode_value(field_id), displayed_value)
self.assertTrue(profile_page.mode_for_field(field_id), mode)
def _test_textarea_field(self, profile_page, field_id, new_value, displayed_value, mode):
"""
Test behaviour of a textarea field.
"""
profile_page.value_for_textarea_field(field_id, new_value)
self.assertEqual(profile_page.get_non_editable_mode_value(field_id), displayed_value)
self.assertTrue(profile_page.mode_for_field(field_id), mode)
self.browser.refresh()
profile_page.wait_for_page()
self.assertEqual(profile_page.get_non_editable_mode_value(field_id), displayed_value)
self.assertTrue(profile_page.mode_for_field(field_id), mode)
def test_country_field(self):
"""
Test behaviour of `Country` field.
Given that I am a registered user.
And I visit My Profile page.
And I set the profile visibility to public and set default values for public fields.
Then I set country value to `Pakistan`.
Then displayed country should be `Pakistan` and country field mode should be `display`
And I reload the page.
Then displayed country should be `Pakistan` and country field mode should be `display`
And I make `country` field editable
Then `country` field mode should be `edit`
And `country` field icon should be visible.
"""
username, user_id = self.log_in_as_unique_user()
profile_page = self.visit_profile_page(username, privacy=self.PRIVACY_PUBLIC)
self._test_dropdown_field(profile_page, 'country', 'Pakistan', 'Pakistan', 'display')
profile_page.make_field_editable('country')
self.assertTrue(profile_page.mode_for_field('country'), 'edit')
self.assertTrue(profile_page.field_icon_present('country'))
def test_language_field(self):
"""
Test behaviour of `Language` field.
Given that I am a registered user.
And I visit My Profile page.
And I set the profile visibility to public and set default values for public fields.
Then I set language value to `Urdu`.
Then displayed language should be `Urdu` and language field mode should be `display`
And I reload the page.
Then displayed language should be `Urdu` and language field mode should be `display`
Then I set empty value for language.
Then displayed language should be `Add language` and language field mode should be `placeholder`
And I reload the page.
Then displayed language should be `Add language` and language field mode should be `placeholder`
And I make `language` field editable
Then `language` field mode should be `edit`
And `language` field icon should be visible.
"""
username, user_id = self.log_in_as_unique_user()
profile_page = self.visit_profile_page(username, privacy=self.PRIVACY_PUBLIC)
self._test_dropdown_field(profile_page, 'language_proficiencies', 'Urdu', 'Urdu', 'display')
self._test_dropdown_field(profile_page, 'language_proficiencies', '', 'Add language', 'placeholder')
profile_page.make_field_editable('language_proficiencies')
self.assertTrue(profile_page.mode_for_field('language_proficiencies'), 'edit')
self.assertTrue(profile_page.field_icon_present('language_proficiencies'))
def test_about_me_field(self):
"""
Test behaviour of `About Me` field.
Given that I am a registered user.
And I visit My Profile page.
And I set the profile visibility to public and set default values for public fields.
Then I set about me value to `Eat Sleep Code`.
Then displayed about me should be `Eat Sleep Code` and about me field mode should be `display`
And I reload the page.
Then displayed about me should be `Eat Sleep Code` and about me field mode should be `display`
Then I set empty value for about me.
Then displayed about me should be `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.` and about me
field mode should be `placeholder`
And I reload the page.
Then displayed about me should be `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.` and about me
field mode should be `placeholder`
And I make `about me` field editable
Then `about me` field mode should be `edit`
"""
placeholder_value = (
"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."
)
username, user_id = self.log_in_as_unique_user()
profile_page = self.visit_profile_page(username, privacy=self.PRIVACY_PUBLIC)
self._test_textarea_field(profile_page, 'bio', 'Eat Sleep Code', 'Eat Sleep Code', 'display')
self._test_textarea_field(profile_page, 'bio', '', placeholder_value, 'placeholder')
profile_page.make_field_editable('bio')
self.assertTrue(profile_page.mode_for_field('bio'), 'edit')
def test_birth_year_not_set(self):
"""
Verify message if birth year is not set.
Given that I am a registered user.
And birth year is not set for the user.
And I visit my profile page.
Then I should see a message that the profile is private until the year of birth is set.
"""
username, user_id = self.log_in_as_unique_user()
message = "You must specify your birth year before you can share your full profile."
self.verify_profile_forced_private_message(username, birth_year=None, message=message)
self.verify_profile_page_view_event(username, user_id, visibility=self.PRIVACY_PRIVATE)
def test_user_is_under_age(self):
"""
Verify message if user is under age.
Given that I am a registered user.
And birth year is set so that age is less than 13.
And I visit my profile page.
Then I should see a message that the profile is private as I am under thirteen.
"""
username, user_id = self.log_in_as_unique_user()
under_age_birth_year = datetime.now().year - 10
self.verify_profile_forced_private_message(
username,
birth_year=under_age_birth_year,
message='You must be over 13 to share a full profile.'
)
self.verify_profile_page_view_event(username, user_id, visibility=self.PRIVACY_PRIVATE)
def test_user_can_only_see_default_image_for_private_profile(self):
"""
Scenario: Default profile image behaves correctly for under age user.
Given that I am on my profile page with private access
And I can see default image
When I move my cursor to the image
Then i cannot see the upload/remove image text
And i cannot upload/remove the image.
"""
year_of_birth = datetime.now().year - 5
username, user_id = self.log_in_as_unique_user()
profile_page = self.visit_profile_page(username, privacy=self.PRIVACY_PRIVATE)
self.verify_profile_forced_private_message(
username,
year_of_birth,
message='You must be over 13 to share a full profile.'
)
self.assertTrue(profile_page.profile_has_default_image)
self.assertFalse(profile_page.profile_has_image_with_private_access())
def test_user_can_see_default_image_for_public_profile(self):
"""
Scenario: Default profile image behaves correctly for public profile.
Given that I am on my profile page with public access
And I can see default image
When I move my cursor to the image
Then i can see the upload/remove image text
And i am able to upload new image
"""
username, user_id = self.log_in_as_unique_user()
profile_page = self.visit_profile_page(username, privacy=self.PRIVACY_PUBLIC)
self.assert_default_image_has_public_access(profile_page)
def test_user_can_upload_the_profile_image_with_success(self):
"""
Scenario: Upload profile image works correctly.
Given that I am on my profile page with public access
And I can see default image
When I move my cursor to the image
Then i can see the upload/remove image text
When i upload new image via file uploader
Then i can see the changed image
And i can also see the latest image after reload.
"""
username, user_id = self.log_in_as_unique_user()
profile_page = self.visit_profile_page(username, privacy=self.PRIVACY_PUBLIC)
self.assert_default_image_has_public_access(profile_page)
profile_page.upload_file(filename='image.jpg')
self.assertTrue(profile_page.image_upload_success)
profile_page.visit()
self.assertTrue(profile_page.image_upload_success)
self.assert_event_emitted_num_times(user_id, 'profile_image_uploaded_at', 1)
def test_user_can_see_error_for_exceeding_max_file_size_limit(self):
"""
Scenario: Upload profile image does not work for > 1MB image file.
Given that I am on my profile page with public access
And I can see default image
When I move my cursor to the image
Then i can see the upload/remove image text
When i upload new > 1MB image via file uploader
Then i can see the error message for file size limit
And i can still see the default image after page reload.
"""
username, user_id = self.log_in_as_unique_user()
profile_page = self.visit_profile_page(username, privacy=self.PRIVACY_PUBLIC)
self.assert_default_image_has_public_access(profile_page)
profile_page.upload_file(filename='larger_image.jpg')
self.assertEqual(profile_page.profile_image_message, "The file must be smaller than 1 MB in size.")
profile_page.visit()
self.assertTrue(profile_page.profile_has_default_image)
self.assert_event_emitted_num_times(user_id, 'profile_image_uploaded_at', 0)
def test_user_can_see_error_for_file_size_below_the_min_limit(self):
"""
Scenario: Upload profile image does not work for < 100 Bytes image file.
Given that I am on my profile page with public access
And I can see default image
When I move my cursor to the image
Then i can see the upload/remove image text
When i upload new < 100 Bytes image via file uploader
Then i can see the error message for minimum file size limit
And i can still see the default image after page reload.
"""
username, user_id = self.log_in_as_unique_user()
profile_page = self.visit_profile_page(username, privacy=self.PRIVACY_PUBLIC)
self.assert_default_image_has_public_access(profile_page)
profile_page.upload_file(filename='list-icon-visited.png')
self.assertEqual(profile_page.profile_image_message, "The file must be at least 100 bytes in size.")
profile_page.visit()
self.assertTrue(profile_page.profile_has_default_image)
self.assert_event_emitted_num_times(user_id, 'profile_image_uploaded_at', 0)
def test_user_can_see_error_for_wrong_file_type(self):
"""
Scenario: Upload profile image does not work for wrong file types.
Given that I am on my profile page with public access
And I can see default image
When I move my cursor to the image
Then i can see the upload/remove image text
When i upload new csv file via file uploader
Then i can see the error message for wrong/unsupported file type
And i can still see the default image after page reload.
"""
username, user_id = self.log_in_as_unique_user()
profile_page = self.visit_profile_page(username, privacy=self.PRIVACY_PUBLIC)
self.assert_default_image_has_public_access(profile_page)
profile_page.upload_file(filename='cohort_users_only_username.csv')
self.assertEqual(
profile_page.profile_image_message,
"The file must be one of the following types: .gif, .png, .jpeg, .jpg."
)
profile_page.visit()
self.assertTrue(profile_page.profile_has_default_image)
self.assert_event_emitted_num_times(user_id, 'profile_image_uploaded_at', 0)
def test_user_can_remove_profile_image(self):
"""
Scenario: Remove profile image works correctly.
Given that I am on my profile page with public access
And I can see default image
When I move my cursor to the image
Then i can see the upload/remove image text
When i click on the remove image link
Then i can see the default image
And i can still see the default image after page reload.
"""
username, user_id = self.log_in_as_unique_user()
profile_page = self.visit_profile_page(username, privacy=self.PRIVACY_PUBLIC)
self.assert_default_image_has_public_access(profile_page)
profile_page.upload_file(filename='image.jpg')
self.assertTrue(profile_page.image_upload_success)
self.assertTrue(profile_page.remove_profile_image())
self.assertTrue(profile_page.profile_has_default_image)
profile_page.visit()
self.assertTrue(profile_page.profile_has_default_image)
self.assert_event_emitted_num_times(user_id, 'profile_image_uploaded_at', 2)
def test_user_cannot_remove_default_image(self):
"""
Scenario: Remove profile image does not works for default images.
Given that I am on my profile page with public access
And I can see default image
When I move my cursor to the image
Then i can see only the upload image text
And i cannot see the remove image text
"""
username, user_id = self.log_in_as_unique_user()
profile_page = self.visit_profile_page(username, privacy=self.PRIVACY_PUBLIC)
self.assert_default_image_has_public_access(profile_page)
self.assertFalse(profile_page.remove_link_present)
def test_eventing_after_multiple_uploads(self):
"""
Scenario: An event is fired when a user with a profile image uploads another image
Given that I am on my profile page with public access
And I upload a new image via file uploader
When I upload another image via the file uploader
Then two upload events have been emitted
"""
username, user_id = self.log_in_as_unique_user()
profile_page = self.visit_profile_page(username, privacy=self.PRIVACY_PUBLIC)
self.assert_default_image_has_public_access(profile_page)
profile_page.upload_file(filename='image.jpg')
self.assertTrue(profile_page.image_upload_success)
profile_page.upload_file(filename='image.jpg', wait_for_upload_button=False)
self.assert_event_emitted_num_times(user_id, 'profile_image_uploaded_at', 2)
class DifferentUserLearnerProfilePageTest(LearnerProfileTestMixin, WebAppTest):
"""
Tests that verify viewing the profile page of a different user.
"""
def test_different_user_private_profile(self):
"""
Scenario: Verify that desired fields are shown when looking at a different user's private profile.
Given that I am a registered user.
And I visit a different user's private profile page.
Then I shouldn't see the profile visibility selector dropdown.
Then I see some of the profile fields are shown.
"""
different_username, different_user_id = self._initialize_different_user(privacy=self.PRIVACY_PRIVATE)
username, __ = self.log_in_as_unique_user()
profile_page = self.visit_profile_page(different_username)
self.verify_profile_page_is_private(profile_page, is_editable=False)
self.verify_profile_page_view_event(username, different_user_id, visibility=self.PRIVACY_PRIVATE)
def test_different_user_under_age(self):
"""
Scenario: Verify that an under age user's profile is private to others.
Given that I am a registered user.
And I visit an under age user's profile page.
Then I shouldn't see the profile visibility selector dropdown.
Then I see that only the private fields are shown.
"""
under_age_birth_year = datetime.now().year - 10
different_username, different_user_id = self._initialize_different_user(
privacy=self.PRIVACY_PUBLIC,
birth_year=under_age_birth_year
)
username, __ = self.log_in_as_unique_user()
profile_page = self.visit_profile_page(different_username)
self.verify_profile_page_is_private(profile_page, is_editable=False)
self.verify_profile_page_view_event(username, different_user_id, visibility=self.PRIVACY_PRIVATE)
def test_different_user_public_profile(self):
"""
Scenario: Verify that desired fields are shown when looking at a different user's public profile.
Given that I am a registered user.
And I visit a different user's public profile page.
Then I shouldn't see the profile visibility selector dropdown.
Then all the profile fields are shown.
Then I shouldn't see the profile visibility selector dropdown.
Also `location`, `language` and `about me` fields are not editable.
"""
different_username, different_user_id = self._initialize_different_user(privacy=self.PRIVACY_PUBLIC)
username, __ = self.log_in_as_unique_user()
profile_page = self.visit_profile_page(different_username)
profile_page.wait_for_public_fields()
self.verify_profile_page_is_public(profile_page, is_editable=False)
self.verify_profile_page_view_event(username, different_user_id, visibility=self.PRIVACY_PUBLIC)
def _initialize_different_user(self, privacy=None, birth_year=None):
"""
Initialize the profile page for a different test user
"""
username, user_id = self.log_in_as_unique_user()
# Set the privacy for the new user
if privacy is None:
privacy = self.PRIVACY_PUBLIC
self.visit_profile_page(username, privacy=privacy)
# Set the user's year of birth
if birth_year:
self.set_birth_year(birth_year)
# Log the user out
LogoutPage(self.browser).visit()
return username, user_id

View File

@@ -173,9 +173,11 @@ class RegisterFromCombinedPageTest(UniqueCourseTest):
course_names = self.dashboard_page.wait_for_page().available_courses
self.assertIn(self.course_info["display_name"], course_names)
self.assertEqual("Test User", self.dashboard_page.full_name)
self.assertEqual(email, self.dashboard_page.email)
self.assertEqual(username, self.dashboard_page.username)
self.assertEqual("want to change your account settings?", self.dashboard_page.sidebar_menu_title.lower())
self.assertEqual(
"click the arrow next to your username above.",
self.dashboard_page.sidebar_menu_description.lower()
)
def test_register_failure(self):
# Navigate to the registration page
@@ -369,61 +371,6 @@ class PayAndVerifyTest(EventsTestMixin, UniqueCourseTest):
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):
"""
Tests that verify the course wiki.

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 B

View File

@@ -77,6 +77,9 @@ class OrdersViewTests(EnrollmentEventTestMixin, EcommerceApiTestMixin, ModuleSto
sku=uuid4().hex.decode('ascii')
)
# Ignore events fired from UserFactory creation
self.reset_tracker()
def test_login_required(self):
"""
The view should return HTTP 403 status if the user is not logged in.

View File

@@ -15,7 +15,7 @@ from course_modes.models import CourseMode
from courseware import courses
from enrollment.api import add_enrollment
from student.models import CourseEnrollment
from util.authentication import SessionAuthenticationAllowInactiveUser
from openedx.core.lib.api.authentication import SessionAuthenticationAllowInactiveUser
log = logging.getLogger(__name__)

View File

@@ -9,6 +9,7 @@ import xml.sax.saxutils as saxutils
from django.contrib.auth.decorators import login_required
from django.core.context_processors import csrf
from django.core.urlresolvers import reverse
from django.contrib.auth.models import User
from django.http import Http404, HttpResponseBadRequest
from django.views.decorators.http import require_GET
@@ -411,16 +412,18 @@ def user_profile(request, course_key, user_id):
'annotated_content_info': _attr_safe_json(annotated_content_info),
})
else:
django_user = User.objects.get(id=user_id)
context = {
'course': course,
'user': request.user,
'django_user': User.objects.get(id=user_id),
'django_user': django_user,
'profiled_user': profiled_user.to_dict(),
'threads': _attr_safe_json(threads),
'user_info': _attr_safe_json(user_info),
'annotated_content_info': _attr_safe_json(annotated_content_info),
'page': query_params['page'],
'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)

View File

@@ -2,14 +2,16 @@
Common utility methods and decorators for Mobile APIs.
"""
import functools
from rest_framework import permissions
from util.authentication import SessionAuthenticationAllowInactiveUser, OAuth2AuthenticationAllowInactiveUser
from opaque_keys.edx.keys import CourseKey
from xmodule.modulestore.django import modulestore
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

View File

@@ -9,20 +9,24 @@ import json
import mock
import ddt
import markupsafe
from django.test import TestCase
from django.conf import settings
from django.core.urlresolvers import reverse
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.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 openedx.core.djangoapps.user_api.accounts.api import activate_account, create_account
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.factories import CourseFactory
from student.tests.factories import CourseModeFactory
@ddt.ddt
@@ -499,3 +503,66 @@ class StudentAccountLoginAndRegistrationTest(UrlResetMixin, ModuleStoreTestCase)
url=reverse("social:begin", kwargs={"backend": backend_name}),
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)

View File

@@ -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'^password$', 'password_change_request_handler', name='password_change_request'),
)
urlpatterns += patterns(
'student_account.views',
url(r'^settings$', 'account_settings', name='account_settings'),
)

View File

@@ -5,30 +5,39 @@ import json
from ipware.ip import get_ip
from django.conf import settings
from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.http import (
HttpResponse, HttpResponseBadRequest, HttpResponseForbidden
)
from django.shortcuts import redirect
from django.http import HttpRequest
from django_countries import countries
from django.core.urlresolvers import reverse, resolve
from django.utils.translation import ugettext as _
from django_future.csrf import ensure_csrf_cookie
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.edx.keys import CourseKey
from edxmako.shortcuts import render_to_response
from microsite_configuration import microsite
from embargo import api as embargo_api
import third_party_auth
from external_auth.login_and_register import (
login as external_auth_login,
register as external_auth_register
)
from student.models import UserProfile
from student.views import (
signin_user as old_login_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.errors import UserNotFound
@@ -294,3 +303,96 @@ def _external_auth_intercept(request, mode):
return external_auth_login(request)
elif mode == "register":
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

View File

@@ -0,0 +1,97 @@
# -*- 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)

View File

@@ -0,0 +1,87 @@
""" 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

View File

@@ -131,6 +131,10 @@ if STATIC_URL_BASE:
if not STATIC_URL.endswith("/"):
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)
# 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)
@@ -594,3 +598,10 @@ if FEATURES.get('INDIVIDUAL_DUE_DATES'):
FIELD_OVERRIDE_PROVIDERS += (
'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)

View File

@@ -49,6 +49,15 @@
],
"port": 27017
},
"TRACKING_BACKENDS": {
"mongo": {
"ENGINE": "track.backends.mongodb.MongoBackend",
"OPTIONS": {
"database": "test",
"collection": "events"
}
}
},
"EVENT_TRACKING_BACKENDS": {
"mongo": {
"ENGINE": "eventtracking.backends.mongodb.MongoBackend",

View File

@@ -131,6 +131,14 @@ MOCK_SEARCH_BACKING_FILE = (
import uuid
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.
try:

View File

@@ -317,9 +317,6 @@ FEATURES = {
# Set to True to change the course sorting behavior by their start dates, latest first.
'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
# ENABLE_OAUTH2_PROVIDER to True
'ENABLE_MOBILE_REST_API': False,
@@ -780,6 +777,10 @@ STATICFILES_DIRS = [
FAVICON_PATH = 'images/favicon.ico'
# User-uploaded content
MEDIA_ROOT = '/edx/var/edxapp/media/'
MEDIA_URL = '/media/'
# Locale/Internationalization
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
@@ -987,6 +988,12 @@ EDXNOTES_INTERFACE = {
'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_TEST_DIRECTORY = PROJECT_ROOT + '/static/coffee'
@@ -1544,7 +1551,7 @@ BULK_EMAIL_RETRY_DELAY_BETWEEN_SENDS = 0.02
############################# 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 ##########################################
@@ -1918,6 +1925,8 @@ TIME_ZONE_DISPLAYED_FOR_DEADLINES = 'UTC'
# Source:
# 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 = (
[u"aa", u"Afar"],
[u"ab", u"Abkhazian"],
@@ -2216,7 +2225,7 @@ ONLOAD_BEACON_SAMPLE_RATE = 0.0
ACCOUNT_VISIBILITY_CONFIGURATION = {
# Default visibility level for accounts without a specified value
# 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
"shareable_fields": [
@@ -2224,7 +2233,7 @@ ACCOUNT_VISIBILITY_CONFIGURATION = {
'profile_image',
'country',
'time_zone',
'languages',
'language_proficiencies',
'bio',
],
@@ -2248,3 +2257,29 @@ CHECKPOINT_PATTERN = r'(?P<checkpoint_name>\w+)'
# 'courseware.student_field_overrides.IndividualStudentOverrideProvider' to
# this setting.
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

View File

@@ -74,6 +74,9 @@ FEATURES['ENABLE_COMBINED_LOGIN_REGISTRATION'] = True
# Need wiki for courseware views to work. TODO (vshnayder): shouldn't need it.
WIKI_ENABLED = True
# Enable a parental consent age limit for testing
PARENTAL_CONSENT_AGE_LIMIT = 13
# Makes the tests run much faster...
SOUTH_TESTS_MIGRATE = False # To disable migrations and use syncdb instead
@@ -265,7 +268,6 @@ FEATURES['ENABLE_OAUTH2_PROVIDER'] = True
FEATURES['ENABLE_MOBILE_REST_API'] = True
FEATURES['ENABLE_MOBILE_SOCIAL_FACEBOOK_FEATURES'] = True
FEATURES['ENABLE_VIDEO_ABSTRACTION_LAYER_API'] = True
FEATURES['ENABLE_USER_REST_API'] = True
###################### Payment ##############################3
# Enable fake payment processing page
@@ -473,3 +475,17 @@ FEATURES['CERTIFICATES_HTML_VIEW'] = True
INSTALLED_APPS += ('ccx',)
MIDDLEWARE_CLASSES += ('ccx.overrides.CcxMiddleware',)
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

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 576 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1023 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 993 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

View File

@@ -23,10 +23,7 @@
* Specifically:
* - dashboard
* - signInUser
* - passwordReset
* - changeEmail
* - changeEmailSettings
* - changeName
* - verifyToggleBannerFailedOff
*/
edx.dashboard.legacy.init = function(urls) {
@@ -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(){
$.ajax({
type: "POST",
@@ -240,24 +176,6 @@
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){
$(this).attr("id", "email-settings-" + index);

View File

@@ -0,0 +1,20 @@
<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>

View File

@@ -30,6 +30,7 @@
'backbone': 'xmodule_js/common_static/js/vendor/backbone-min',
'backbone.associations': 'xmodule_js/common_static/js/vendor/backbone-associations-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',
'jquery.tinymce': 'xmodule_js/common_static/js/vendor/tinymce/js/tinymce/jquery.tinymce',
'xmodule': 'xmodule_js/src/xmodule',
@@ -58,6 +59,7 @@
// Manually specify LMS files that are not converted to RequireJS
'history': 'js/vendor/history',
'js/mustache': 'js/mustache',
'js/verify_student/photocapture': 'js/verify_student/photocapture',
'js/staff_debug_actions': 'js/staff_debug_actions',
'js/vendor/jquery.qubit': 'js/vendor/jquery.qubit',
@@ -88,6 +90,9 @@
'js/student_account/views/RegisterView': 'js/student_account/views/RegisterView',
'js/student_account/views/AccessView': 'js/student_account/views/AccessView',
'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
'annotator_1.2.9': 'xmodule_js/common_static/js/vendor/edxnotes/annotator-full.min'
@@ -197,6 +202,9 @@
deps: ['backbone'],
exports: 'Backbone.Paginator'
},
"backbone-super": {
deps: ["backbone"],
},
'youtube': {
exports: 'YT'
},
@@ -583,7 +591,14 @@
'lms/include/js/spec/student_account/enrollment_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/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/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/webcam_photo_view_spec.js',
'lms/include/js/spec/verify_student/image_input_spec.js',

View File

@@ -0,0 +1,196 @@
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);
}
});
});
});

View File

@@ -0,0 +1,30 @@
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
};
});

View File

@@ -0,0 +1,125 @@
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);
});
});
});

View File

@@ -0,0 +1,102 @@
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);
});
});
});

View File

@@ -0,0 +1,126 @@
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,
};
});

View File

@@ -99,7 +99,7 @@ define([
{value: "", name: "--"},
{value: "p", name: "Doctorate"},
{value: "m", name: "Master's or professional degree"},
{value: "b", name: "Bachelor's degree"},
{value: "b", name: "Bachelor's degree"}
],
required: false,
instructions: 'Select your education level.',
@@ -115,7 +115,7 @@ define([
{value: "", name: "--"},
{value: "m", name: "Male"},
{value: "f", name: "Female"},
{value: "o", name: "Other"},
{value: "o", name: "Other"}
],
required: false,
instructions: 'Select your gender.',
@@ -131,7 +131,7 @@ define([
{value: "", name: "--"},
{value: 1900, name: "1900"},
{value: 1950, name: "1950"},
{value: 2014, name: "2014"},
{value: 2014, name: "2014"}
],
required: false,
instructions: 'Select your year of birth.',

View File

@@ -0,0 +1,113 @@
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
};
});

View File

@@ -0,0 +1,138 @@
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);
});
});
});

View File

@@ -0,0 +1,291 @@
define(['backbone', 'jquery', 'underscore', 'js/common_helpers/ajax_helpers', 'js/common_helpers/template_helpers',
'js/spec/student_account/helpers',
'js/student_account/models/user_account_model',
'js/student_profile/views/learner_profile_fields',
'js/views/message_banner'
],
function (Backbone, $, _, AjaxHelpers, TemplateHelpers, Helpers, UserAccountModel, LearnerProfileFields,
MessageBannerView) {
'use strict';
describe("edx.user.LearnerProfileFields", function () {
var MOCK_YEAR_OF_BIRTH = 1989;
var MOCK_IMAGE_MAX_BYTES = 64;
var MOCK_IMAGE_MIN_BYTES = 16;
var createImageView = function (options) {
var yearOfBirth = _.isUndefined(options.yearOfBirth) ? MOCK_YEAR_OF_BIRTH : options.yearOfBirth;
var imageMaxBytes = _.isUndefined(options.imageMaxBytes) ? MOCK_IMAGE_MAX_BYTES : options.imageMaxBytes;
var imageMinBytes = _.isUndefined(options.imageMinBytes) ? MOCK_IMAGE_MIN_BYTES : options.imageMinBytes;
var imageData = {
image_url_large: '/media/profile-images/default.jpg',
has_image: options.hasImage ? true : false
};
var accountSettingsModel = new UserAccountModel();
accountSettingsModel.set({'profile_image': imageData});
accountSettingsModel.set({'year_of_birth': yearOfBirth});
accountSettingsModel.set({'requires_parental_consent': _.isEmpty(yearOfBirth) ? true : false});
accountSettingsModel.url = Helpers.USER_ACCOUNTS_API_URL;
var messageView = new MessageBannerView({
el: $('.message-banner')
});
return new LearnerProfileFields.ProfileImageFieldView({
model: accountSettingsModel,
valueAttribute: "profile_image",
editable: options.ownProfile,
messageView: messageView,
imageMaxBytes: imageMaxBytes,
imageMinBytes: imageMinBytes,
imageUploadUrl: Helpers.IMAGE_UPLOAD_API_URL,
imageRemoveUrl: Helpers.IMAGE_REMOVE_API_URL
});
};
beforeEach(function () {
loadFixtures('js/fixtures/student_profile/student_profile.html');
TemplateHelpers.installTemplate('templates/student_profile/learner_profile');
TemplateHelpers.installTemplate('templates/fields/field_image');
TemplateHelpers.installTemplate("templates/fields/message_banner");
});
var createFakeImageFile = function (size) {
var fileFakeData = 'i63ljc6giwoskyb9x5sw0169bdcmcxr3cdz8boqv0lik971972cmd6yknvcxr5sw0nvc169bdcmcxsdf';
return new Blob(
[ fileFakeData.substr(0, size) ],
{ type: 'image/jpg' }
);
};
var initializeUploader = function (view) {
view.$('.upload-button-input').fileupload({
url: Helpers.IMAGE_UPLOAD_API_URL,
type: 'POST',
add: view.fileSelected,
done: view.imageChangeSucceeded,
fail: view.imageChangeFailed
});
};
describe("ProfileImageFieldView", function () {
var verifyImageUploadButtonMessage = function (view, inProgress) {
var iconName = inProgress ? 'fa-spinner' : 'fa-camera';
var message = inProgress ? view.titleUploading : view.uploadButtonTitle();
expect(view.$('.upload-button-icon i').attr('class')).toContain(iconName);
expect(view.$('.upload-button-title').text().trim()).toBe(message);
};
var verifyImageRemoveButtonMessage = function (view, inProgress) {
var iconName = inProgress ? 'fa-spinner' : 'fa-remove';
var message = inProgress ? view.titleRemoving : view.removeButtonTitle();
expect(view.$('.remove-button-icon i').attr('class')).toContain(iconName);
expect(view.$('.remove-button-title').text().trim()).toBe(message);
};
it("can upload profile image", function() {
var imageView = createImageView({ownProfile: true, hasImage: false});
imageView.render();
var requests = AjaxHelpers.requests(this);
var imageName = 'profile_image.jpg';
initializeUploader(imageView);
// Remove button should not be present for default image
expect(imageView.$('.u-field-remove-button').css('display') === 'none').toBeTruthy();
// For default image, image title should be `Upload an image`
verifyImageUploadButtonMessage(imageView, false);
// Add image to upload queue. Validate the image size and send POST request to upload image
imageView.$('.upload-button-input').fileupload('add', {files: [createFakeImageFile(60)]});
// Verify image upload progress message
verifyImageUploadButtonMessage(imageView, true);
// Verify if POST request received for image upload
AjaxHelpers.expectRequest(requests, 'POST', Helpers.IMAGE_UPLOAD_API_URL, new FormData());
// Send 204 NO CONTENT to confirm the image upload success
AjaxHelpers.respondWithNoContent(requests);
// Upon successful image upload, account settings model will be fetched to
// get the url for newly uploaded image, So we need to send the response for that GET
var data = {profile_image: {
image_url_large: '/media/profile-images/' + imageName,
has_image: true
}};
AjaxHelpers.respondWithJson(requests, data);
// Verify uploaded image name
expect(imageView.$('.image-frame').attr('src')).toContain(imageName);
// Remove button should be present after successful image upload
expect(imageView.$('.u-field-remove-button').css('display') !== 'none').toBeTruthy();
// After image upload, image title should be `Change image`
verifyImageUploadButtonMessage(imageView, false);
});
it("can remove profile image", function() {
var imageView = createImageView({ownProfile: true, hasImage: false});
imageView.render();
var requests = AjaxHelpers.requests(this);
// Verify image remove title
verifyImageRemoveButtonMessage(imageView, false);
imageView.$('.u-field-remove-button').click();
// Verify image remove progress message
verifyImageRemoveButtonMessage(imageView, true);
// Verify if POST request received for image remove
AjaxHelpers.expectRequest(requests, 'POST', Helpers.IMAGE_REMOVE_API_URL, null);
// Send 204 NO CONTENT to confirm the image removal success
AjaxHelpers.respondWithNoContent(requests);
// Upon successful image removal, account settings model will be fetched to get default image url
// So we need to send the response for that GET
var data = {profile_image: {
image_url_large: '/media/profile-images/default.jpg',
has_image: false
}};
AjaxHelpers.respondWithJson(requests, data);
// Remove button should not be present for default image
expect(imageView.$('.u-field-remove-button').css('display') === 'none').toBeTruthy();
});
it("can't remove default profile image", function() {
var imageView = createImageView({ownProfile: true, hasImage: false});
imageView.render();
spyOn(imageView, 'clickedRemoveButton');
// Remove button should not be present for default image
expect(imageView.$('.u-field-remove-button').css('display') === 'none').toBeTruthy();
imageView.$('.u-field-remove-button').click();
// Remove button click handler should not be called
expect(imageView.clickedRemoveButton).not.toHaveBeenCalled();
});
it("can't upload image having size greater than max size", function() {
var imageView = createImageView({ownProfile: true, hasImage: false});
imageView.render();
initializeUploader(imageView);
// Add image to upload queue, this will validate the image size
imageView.$('.upload-button-input').fileupload('add', {files: [createFakeImageFile(70)]});
// Verify error message
expect($('.message-banner').text().trim())
.toBe('The file must be smaller than 64 bytes in size.');
});
it("can't upload image having size less than min size", function() {
var imageView = createImageView({ownProfile: true, hasImage: false});
imageView.render();
initializeUploader(imageView);
// Add image to upload queue, this will validate the image size
imageView.$('.upload-button-input').fileupload('add', {files: [createFakeImageFile(10)]});
// Verify error message
expect($('.message-banner').text().trim()).toBe('The file must be at least 16 bytes in size.');
});
it("can't upload and remove image if parental consent required", function() {
var imageView = createImageView({ownProfile: true, hasImage: false, yearOfBirth: ''});
imageView.render();
spyOn(imageView, 'clickedUploadButton');
spyOn(imageView, 'clickedRemoveButton');
expect(imageView.$('.u-field-upload-button').css('display') === 'none').toBeTruthy();
expect(imageView.$('.u-field-remove-button').css('display') === 'none').toBeTruthy();
imageView.$('.u-field-upload-button').click();
imageView.$('.u-field-remove-button').click();
expect(imageView.clickedUploadButton).not.toHaveBeenCalled();
expect(imageView.clickedRemoveButton).not.toHaveBeenCalled();
});
it("can't upload and remove image on others profile", function() {
var imageView = createImageView({ownProfile: false});
imageView.render();
spyOn(imageView, 'clickedUploadButton');
spyOn(imageView, 'clickedRemoveButton');
expect(imageView.$('.u-field-upload-button').css('display') === 'none').toBeTruthy();
expect(imageView.$('.u-field-remove-button').css('display') === 'none').toBeTruthy();
imageView.$('.u-field-upload-button').click();
imageView.$('.u-field-remove-button').click();
expect(imageView.clickedUploadButton).not.toHaveBeenCalled();
expect(imageView.clickedRemoveButton).not.toHaveBeenCalled();
});
it("shows message if we try to navigate away during image upload/remove", function() {
var imageView = createImageView({ownProfile: true, hasImage: false});
spyOn(imageView, 'onBeforeUnload');
imageView.render();
initializeUploader(imageView);
// Add image to upload queue, this will validate image size and send POST request to upload image
imageView.$('.upload-button-input').fileupload('add', {files: [createFakeImageFile(60)]});
// Verify image upload progress message
verifyImageUploadButtonMessage(imageView, true);
$(window).trigger('beforeunload');
expect(imageView.onBeforeUnload).toHaveBeenCalled();
});
it("shows error message for HTTP 500", function() {
var imageView = createImageView({ownProfile: true, hasImage: false});
imageView.render();
var requests = AjaxHelpers.requests(this);
initializeUploader(imageView);
// Add image to upload queue. Validate the image size and send POST request to upload image
imageView.$('.upload-button-input').fileupload('add', {files: [createFakeImageFile(60)]});
// Verify image upload progress message
verifyImageUploadButtonMessage(imageView, true);
// Verify if POST request received for image upload
AjaxHelpers.expectRequest(requests, 'POST', Helpers.IMAGE_UPLOAD_API_URL, new FormData());
// Send HTTP 500
AjaxHelpers.respondWithError(requests);
expect($('.message-banner').text().trim()).toBe(imageView.errorMessage);
});
});
});
});

View File

@@ -0,0 +1,198 @@
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);
});
});
});

View File

@@ -0,0 +1,233 @@
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
};
});

View File

@@ -0,0 +1,301 @@
define(['backbone', 'jquery', 'underscore', 'js/common_helpers/ajax_helpers', 'js/common_helpers/template_helpers',
'js/views/fields',
'js/spec/views/fields_helpers',
'string_utils'],
function (Backbone, $, _, AjaxHelpers, TemplateHelpers, FieldViews, FieldViewsSpecHelpers) {
'use strict';
var USERNAME = 'Legolas',
BIO = "My Name is Theon Greyjoy. I'm member of House Greyjoy";
describe("edx.FieldViews", function () {
var requests,
timerCallback;
var fieldViewClasses = [
FieldViews.ReadonlyFieldView,
FieldViews.TextFieldView,
FieldViews.DropdownFieldView,
FieldViews.LinkFieldView,
FieldViews.TextareaFieldView
];
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');
TemplateHelpers.installTemplate('templates/fields/field_textarea');
timerCallback = jasmine.createSpy('timerCallback');
jasmine.Clock.useMock();
});
it("updates messages correctly for all fields", function() {
for (var i = 0; i < fieldViewClasses.length; i++) {
var fieldViewClass = fieldViewClasses[i];
var fieldData = FieldViewsSpecHelpers.createFieldData(fieldViewClass, {
title: 'Username',
valueAttribute: 'username',
helpMessage: 'The username that you use to sign in to edX.'
});
var view = new fieldViewClass(fieldData).render();
FieldViewsSpecHelpers.verifyMessageUpdates(view, fieldData, timerCallback);
}
});
it("resets to help message some time after success message is set", function() {
for (var i = 0; i < fieldViewClasses.length; i++) {
var fieldViewClass = fieldViewClasses[i];
var fieldData = FieldViewsSpecHelpers.createFieldData(fieldViewClass, {
title: 'Username',
valueAttribute: 'username',
helpMessage: 'The username that you use to sign in to edX.'
});
var view = new fieldViewClass(fieldData).render();
FieldViewsSpecHelpers.verifySuccessMessageReset(view, fieldData, timerCallback);
}
});
it("sends a PATCH request when saveAttributes is called", function() {
requests = AjaxHelpers.requests(this);
var fieldViewClass = FieldViews.EditableFieldView;
var fieldData = FieldViewsSpecHelpers.createFieldData(fieldViewClass, {
title: 'Preferred Language',
valueAttribute: 'language',
helpMessage: 'Your preferred language.'
});
var view = new fieldViewClass(fieldData);
view.saveAttributes(
{'language': 'ur'},
{'headers': {'Priority': 'Urgent'}}
);
var request = requests[0];
expect(request.method).toBe('PATCH');
expect(request.requestHeaders['Content-Type']).toBe('application/merge-patch+json;charset=utf-8');
expect(request.requestHeaders.Priority).toBe('Urgent');
expect(request.requestBody).toBe('{"language":"ur"}');
});
it("correctly renders and updates ReadonlyFieldView", function() {
var fieldData = FieldViewsSpecHelpers.createFieldData(FieldViews.ReadonlyFieldView, {
title: 'Username',
valueAttribute: 'username',
helpMessage: 'The username that you use to sign in to edX.'
});
var view = new FieldViews.ReadonlyFieldView(fieldData).render();
FieldViewsSpecHelpers.expectTitleAndMessageToContain(view, fieldData.title, fieldData.helpMessage, false);
expect(view.$('.u-field-value input').val().trim()).toBe(USERNAME);
view.model.set({'username': 'bookworm'});
expect(view.$('.u-field-value input').val().trim()).toBe('bookworm');
});
it("correctly renders, updates and persists changes to TextFieldView when editable == always", function() {
requests = AjaxHelpers.requests(this);
var fieldData = FieldViewsSpecHelpers.createFieldData(FieldViews.TextFieldView, {
title: 'Full Name',
valueAttribute: 'name',
helpMessage: 'How are you?'
});
var view = new FieldViews.TextFieldView(fieldData).render();
FieldViewsSpecHelpers.verifyTextField(view, {
title: fieldData.title,
valueAttribute: fieldData.valueAttribute,
helpMessage: fieldData.helpMessage,
validValue: 'My Name',
invalidValue1: 'Your Name',
invalidValue2: 'Her Name',
validationError: "Think again!"
}, requests);
});
it("correctly renders and updates DropdownFieldView when editable == never", function() {
requests = AjaxHelpers.requests(this);
var fieldData = FieldViewsSpecHelpers.createFieldData(FieldViews.DropdownFieldView, {
title: 'Full Name',
valueAttribute: 'name',
helpMessage: 'edX full name',
editable: 'never'
});
var view = new FieldViews.DropdownFieldView(fieldData).render();
FieldViewsSpecHelpers.expectTitleAndMessageToContain(view, fieldData.title, fieldData.helpMessage, false);
expect(view.el).toHaveClass('mode-hidden');
view.model.set({'name': fieldData.options[1][0]});
expect(view.el).toHaveClass('mode-display');
view.$el.click();
expect(view.el).toHaveClass('mode-display');
});
it("correctly renders, updates and persists changes to DropdownFieldView when editable == always", function() {
requests = AjaxHelpers.requests(this);
var fieldData = FieldViewsSpecHelpers.createFieldData(FieldViews.DropdownFieldView, {
title: 'Full Name',
valueAttribute: 'name',
helpMessage: 'edX full name'
});
var view = new FieldViews.DropdownFieldView(fieldData).render();
FieldViewsSpecHelpers.verifyDropDownField(view, {
title: fieldData.title,
valueAttribute: fieldData.valueAttribute,
helpMessage: fieldData.helpMessage,
validValue: FieldViewsSpecHelpers.SELECT_OPTIONS[0][0],
invalidValue1: FieldViewsSpecHelpers.SELECT_OPTIONS[1][0],
invalidValue2: FieldViewsSpecHelpers.SELECT_OPTIONS[2][0],
validationError: "Nope, this will not do!"
}, requests);
});
it("correctly renders, updates and persists changes to DropdownFieldView when editable == toggle", function() {
requests = AjaxHelpers.requests(this);
var fieldData = FieldViewsSpecHelpers.createFieldData(FieldViews.DropdownFieldView, {
title: 'Full Name',
valueAttribute: 'name',
helpMessage: 'edX full name',
editable: 'toggle'
});
var view = new FieldViews.DropdownFieldView(fieldData).render();
FieldViewsSpecHelpers.verifyDropDownField(view, {
title: fieldData.title,
valueAttribute: fieldData.valueAttribute,
helpMessage: fieldData.helpMessage,
editable: 'toggle',
validValue: FieldViewsSpecHelpers.SELECT_OPTIONS[0][0],
invalidValue1: FieldViewsSpecHelpers.SELECT_OPTIONS[1][0],
invalidValue2: FieldViewsSpecHelpers.SELECT_OPTIONS[2][0],
validationError: "Nope, this will not do!"
}, requests);
});
it("only shows empty option in DropdownFieldView if required is false or model value is not set", function() {
requests = AjaxHelpers.requests(this);
var editableOptions = ['toggle', 'always'];
_.each(editableOptions, function(editable) {
var fieldData = FieldViewsSpecHelpers.createFieldData(FieldViews.DropdownFieldView, {
title: 'Drop Down Field',
valueAttribute: 'drop-down',
helpMessage: 'edX drop down',
editable: editable,
required:true
});
var view = new FieldViews.DropdownFieldView(fieldData).render();
expect(view.modelValueIsSet()).toBe(false);
expect(view.displayValue()).toBe('');
if(editable === 'toggle') { view.showEditMode(true); }
view.$('.u-field-value > select').val(FieldViewsSpecHelpers.SELECT_OPTIONS[0]).change();
expect(view.fieldValue()).toBe(FieldViewsSpecHelpers.SELECT_OPTIONS[0][0]);
AjaxHelpers.respondWithNoContent(requests);
if(editable === 'toggle') { view.showEditMode(true); }
// When server returns success, there should no longer be an empty option.
expect($(view.$('.u-field-value option')[0]).val()).toBe('si');
});
});
it("correctly renders and updates TextAreaFieldView when editable == never", function() {
var fieldData = FieldViewsSpecHelpers.createFieldData(FieldViews.TextareaFieldView, {
title: 'About me',
valueAttribute: 'bio',
helpMessage: 'Wicked is good',
placeholderValue: "Tell other edX learners a little about yourself: where you live, " +
"what your interests are, why youre taking courses on edX, or what you hope to learn.",
editable: 'never'
});
// set bio to empty to see the placeholder.
fieldData.model.set({bio: ''});
var view = new FieldViews.TextareaFieldView(fieldData).render();
FieldViewsSpecHelpers.expectTitleAndMessageToContain(view, fieldData.title, fieldData.helpMessage, false);
expect(view.el).toHaveClass('mode-hidden');
expect(view.$('.u-field-value .u-field-value-readonly').text()).toBe(fieldData.placeholderValue);
var bio = 'Too much to tell!';
view.model.set({'bio': bio});
expect(view.el).toHaveClass('mode-display');
expect(view.$('.u-field-value .u-field-value-readonly').text()).toBe(bio);
view.$el.click();
expect(view.el).toHaveClass('mode-display');
});
it("correctly renders, updates and persists changes to TextAreaFieldView when editable == toggle", function() {
requests = AjaxHelpers.requests(this);
var valueInputSelector = '.u-field-value > textarea';
var fieldData = FieldViewsSpecHelpers.createFieldData(FieldViews.TextareaFieldView, {
title: 'About me',
valueAttribute: 'bio',
helpMessage: 'Wicked is good',
placeholderValue: "Tell other edX learners a little about yourself: where you live, " +
"what your interests are, why youre taking courses on edX, or what you hope to learn.",
editable: 'toggle'
});
fieldData.model.set({'bio': ''});
var view = new FieldViews.TextareaFieldView(fieldData).render();
FieldViewsSpecHelpers.expectTitleToContain(view, fieldData.title);
FieldViewsSpecHelpers.expectMessageContains(view, view.indicators.canEdit);
expect(view.el).toHaveClass('mode-placeholder');
expect(view.$('.u-field-value .u-field-value-readonly').text()).toBe(fieldData.placeholderValue);
view.$('.wrapper-u-field').click();
expect(view.el).toHaveClass('mode-edit');
view.$(valueInputSelector).val(BIO).focusout();
expect(view.fieldValue()).toBe(BIO);
AjaxHelpers.expectJsonRequest(
requests, 'PATCH', view.model.url, {'bio': BIO}
);
AjaxHelpers.respondWithNoContent(requests);
expect(view.el).toHaveClass('mode-display');
view.$('.wrapper-u-field').click();
view.$(valueInputSelector).val('').focusout();
AjaxHelpers.respondWithNoContent(requests);
expect(view.el).toHaveClass('mode-placeholder');
expect(view.$('.u-field-value .u-field-value-readonly').text()).toBe(fieldData.placeholderValue);
});
it("correctly renders LinkFieldView", function() {
var fieldData = FieldViewsSpecHelpers.createFieldData(FieldViews.LinkFieldView, {
title: 'Title',
linkTitle: 'Link title',
helpMessage: 'Click the link.',
valueAttribute: 'password-reset'
});
var view = new FieldViews.LinkFieldView(fieldData).render();
FieldViewsSpecHelpers.expectTitleAndMessageToContain(view, fieldData.title, fieldData.helpMessage, false);
expect(view.$('.u-field-value > a .u-field-link-title-' + view.options.valueAttribute).text().trim()).toBe(fieldData.linkTitle);
});
});
});

View File

@@ -0,0 +1,27 @@
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('');
});
});
});

View File

@@ -0,0 +1,59 @@
;(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);

View File

@@ -0,0 +1,16 @@
;(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);

View File

@@ -0,0 +1,190 @@
;(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);

View File

@@ -0,0 +1,183 @@
;(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);

View File

@@ -0,0 +1,41 @@
;(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);

View File

@@ -0,0 +1,174 @@
;(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);

View File

@@ -0,0 +1,111 @@
;(function (define, undefined) {
'use strict';
define([
'gettext', 'jquery', 'underscore', 'backbone', 'js/views/fields', 'backbone-super'
], function (gettext, $, _, Backbone, FieldViews) {
var LearnerProfileFieldViews = {};
LearnerProfileFieldViews.AccountPrivacyFieldView = FieldViews.DropdownFieldView.extend({
render: function () {
this._super();
this.showNotificationMessage();
this.updateFieldValue();
return this;
},
showNotificationMessage: function () {
var accountSettingsLink = '<a href="' + this.options.accountSettingsPageUrl + '">' + gettext('Account Settings page.') + '</a>';
if (this.profileIsPrivate) {
this._super(interpolate_text(
gettext("You must specify your birth year before you can share your full profile. To specify your birth year, go to the {account_settings_page_link}"),
{'account_settings_page_link': accountSettingsLink}
));
} else if (this.requiresParentalConsent) {
this._super(interpolate_text(
gettext('You must be over 13 to share a full profile. If you are over 13, make sure that you have specified a birth year on the {account_settings_page_link}'),
{'account_settings_page_link': accountSettingsLink}
));
}
else {
this._super('');
}
return this._super();
},
updateFieldValue: function() {
if (!this.isAboveMinimumAge) {
this.$('.u-field-value select').val('private');
this.disableField(true);
}
}
});
LearnerProfileFieldViews.ProfileImageFieldView = FieldViews.ImageFieldView.extend({
imageUrl: function () {
return this.model.profileImageUrl();
},
imageAltText: function () {
return interpolate_text(
gettext("Profile image for {username}"), {username: this.model.get('username')}
);
},
imageChangeSucceeded: function (e, data) {
var view = this;
// Update model to get the latest urls of profile image.
this.model.fetch().done(function () {
view.setCurrentStatus('');
}).fail(function () {
view.showErrorMessage(view.errorMessage);
});
},
imageChangeFailed: function (e, data) {
this.setCurrentStatus('');
this.showImageChangeFailedMessage(data.jqXHR.status, data.jqXHR.responseText);
this.render();
},
showImageChangeFailedMessage: function (status, responseText) {
if (_.contains([400, 404], status)) {
try {
var errors = JSON.parse(responseText);
this.showErrorMessage(errors.user_message);
} catch (error) {
this.showErrorMessage(this.errorMessage);
}
} else {
this.showErrorMessage(this.errorMessage);
}
},
showErrorMessage: function (message) {
this.options.messageView.showMessage(message);
},
isEditingAllowed: function () {
return this.model.isAboveMinimumAge();
},
isShowingPlaceholder: function () {
return !this.model.hasProfileImage();
},
clickedRemoveButton: function (e, data) {
this.options.messageView.hideMessage();
this._super(e, data);
},
fileSelected: function (e, data) {
this.options.messageView.hideMessage();
this._super(e, data);
}
});
return LearnerProfileFieldViews;
});
}).call(this, define || RequireJS.define);

View File

@@ -0,0 +1,72 @@
;(function (define, undefined) {
'use strict';
define([
'gettext', 'jquery', 'underscore', 'backbone'
], function (gettext, $, _, Backbone) {
var LearnerProfileView = Backbone.View.extend({
initialize: function () {
this.template = _.template($('#learner_profile-tpl').text());
_.bindAll(this, 'showFullProfile', 'render', 'renderFields', 'showLoadingError');
this.listenTo(this.options.preferencesModel, "change:" + 'account_privacy', this.render);
},
showFullProfile: function () {
var isAboveMinimumAge = this.options.accountSettingsModel.isAboveMinimumAge();
if (this.options.ownProfile) {
return isAboveMinimumAge && this.options.preferencesModel.get('account_privacy') === 'all_users';
} else {
return this.options.accountSettingsModel.get('profile_is_public');
}
},
render: function () {
this.$el.html(this.template({
username: this.options.accountSettingsModel.get('username'),
ownProfile: this.options.ownProfile,
showFullProfile: this.showFullProfile()
}));
this.renderFields();
return this;
},
renderFields: function() {
var view = this;
if (this.options.ownProfile) {
var fieldView = this.options.accountPrivacyFieldView,
settings = this.options.accountSettingsModel;
fieldView.profileIsPrivate = !settings.get('year_of_birth');
fieldView.requiresParentalConsent = settings.get('requires_parental_consent');
fieldView.isAboveMinimumAge = settings.isAboveMinimumAge();
fieldView.undelegateEvents();
this.$('.wrapper-profile-field-account-privacy').append(fieldView.render().el);
fieldView.delegateEvents();
}
this.$('.profile-section-one-fields').append(this.options.usernameFieldView.render().el);
var imageView = this.options.profileImageFieldView;
this.$('.profile-image-field').append(imageView.render().el);
if (this.showFullProfile()) {
_.each(this.options.sectionOneFieldViews, function (fieldView) {
view.$('.profile-section-one-fields').append(fieldView.render().el);
});
_.each(this.options.sectionTwoFieldViews, function (fieldView) {
view.$('.profile-section-two-fields').append(fieldView.render().el);
});
}
},
showLoadingError: function () {
this.$('.ui-loading-indicator').addClass('is-hidden');
this.$('.ui-loading-error').removeClass('is-hidden');
}
});
return LearnerProfileView;
});
}).call(this, define || RequireJS.define);

114
lms/static/js/vendor/backbone-super.js vendored Normal file
View File

@@ -0,0 +1,114 @@
// https://github.com/lukasolson/backbone-super
// MIT License
// This is a plugin, constructed from parts of Backbone.js and John Resig's inheritance script.
// (See http://backbonejs.org, http://ejohn.org/blog/simple-javascript-inheritance/)
// No credit goes to me as I did absolutely nothing except patch these two together.
(function(root, factory) {
// Set up Backbone appropriately for the environment. Start with AMD.
if (typeof define === 'function' && define.amd) {
define(['underscore', 'backbone'], function(_, Backbone) {
// Export global even in AMD case in case this script is loaded with
// others that may still expect a global Backbone.
factory( _, Backbone);
});
// Next for Node.js or CommonJS.
} else if (typeof exports !== 'undefined' && typeof require === 'function') {
var _ = require('underscore'),
Backbone = require('backbone');
factory(_, Backbone);
// Finally, as a browser global.
} else {
factory(root._, root.Backbone);
}
}(this, function factory(_, Backbone) {
Backbone.Model.extend = Backbone.Collection.extend = Backbone.Router.extend = Backbone.View.extend = function(protoProps, classProps) {
var child = inherits(this, protoProps, classProps);
child.extend = this.extend;
return child;
};
var unImplementedSuper = function(method){throw "Super does not implement this method: " + method;};
var fnTest = /\b_super\b/;
var makeWrapper = function(parentProto, name, fn) {
var wrapper = function() {
var tmp = this._super;
// Add a new ._super() method that is the same method
// but on the super-class
this._super = parentProto[name] || unImplementedSuper(name);
// The method only need to be bound temporarily, so we
// remove it when we're done executing
var ret;
try {
ret = fn.apply(this, arguments);
} finally {
this._super = tmp;
}
return ret;
};
//we must move properties from old function to new
for (var prop in fn) {
wrapper[prop] = fn[prop];
delete fn[prop];
}
return wrapper;
};
var ctor = function(){}, inherits = function(parent, protoProps, staticProps) {
var child, parentProto = parent.prototype;
// The constructor function for the new subclass is either defined by you
// (the "constructor" property in your `extend` definition), or defaulted
// by us to simply call the parent's constructor.
if (protoProps && protoProps.hasOwnProperty('constructor')) {
child = protoProps.constructor;
} else {
child = function(){ return parent.apply(this, arguments); };
}
// Inherit class (static) properties from parent.
_.extend(child, parent, staticProps);
// Set the prototype chain to inherit from `parent`, without calling
// `parent`'s constructor function.
ctor.prototype = parentProto;
child.prototype = new ctor();
// Add prototype properties (instance properties) to the subclass,
// if supplied.
if (protoProps) {
_.extend(child.prototype, protoProps);
// Copy the properties over onto the new prototype
for (var name in protoProps) {
// Check if we're overwriting an existing function
if (typeof protoProps[name] == "function" && fnTest.test(protoProps[name])) {
child.prototype[name] = makeWrapper(parentProto, name, protoProps[name]);
}
}
}
// Add static properties to the constructor function, if supplied.
if (staticProps) _.extend(child, staticProps);
// Correctly set child's `prototype.constructor`.
child.prototype.constructor = child;
// Set a convenience property in case the parent's prototype is needed later.
child.__super__ = parentProto;
return child;
};
return inherits;
}));

View File

@@ -0,0 +1,733 @@
;(function (define, undefined) {
'use strict';
define([
'gettext', 'jquery', 'underscore', 'backbone', 'js/mustache', 'backbone-super'
], function (gettext, $, _, Backbone, RequireMustache) {
var Mustache = window.Mustache || RequireMustache;
var messageRevertDelay = 4000;
var FieldViews = {};
FieldViews.FieldView = Backbone.View.extend({
fieldType: 'generic',
className: function () {
return 'u-field' + ' u-field-' + this.fieldType + ' u-field-' + this.options.valueAttribute;
},
tagName: 'div',
indicators: {
'canEdit': '<i class="icon fa fa-pencil message-can-edit" aria-hidden="true"></i><span class="sr">' + gettext("Editable") + '</span>',
'error': '<i class="fa fa-exclamation-triangle message-error" aria-hidden="true"></i><span class="sr">' + gettext("Error") + '</span>',
'validationError': '<i class="fa fa-exclamation-triangle message-validation-error" aria-hidden="true"></i><span class="sr">' + gettext("Validation Error") + '</span>',
'inProgress': '<i class="fa fa-spinner fa-pulse message-in-progress" aria-hidden="true"></i><span class="sr">' + gettext("In Progress") + '</span>',
'success': '<i class="fa fa-check message-success" aria-hidden="true"></i><span class="sr">' + gettext("Success") + '</span>',
'plus': '<i class="fa fa-plus placeholder" aria-hidden="true"></i><span class="sr">' + gettext("Placeholder")+ '</span>'
},
messages: {
'canEdit': '',
'error': gettext('An error occurred. Please try again.'),
'validationError': '',
'inProgress': gettext('Saving'),
'success': gettext('Your changes have been saved.')
},
initialize: function () {
this.template = _.template($(this.templateSelector).text());
this.helpMessage = this.options.helpMessage || '';
this.showMessages = _.isUndefined(this.options.showMessages) ? true : this.options.showMessages;
_.bindAll(this, 'modelValue', 'modelValueIsSet', 'showNotificationMessage','getNotificationMessage',
'getMessage', 'title', 'showHelpMessage', 'showInProgressMessage', 'showSuccessMessage',
'showErrorMessage'
);
},
modelValue: function () {
return this.model.get(this.options.valueAttribute);
},
modelValueIsSet: function() {
return (this.modelValue() === true);
},
title: function (text) {
return this.$('.u-field-title').html(text);
},
getMessage: function(message_status) {
if ((message_status + 'Message') in this) {
return this[message_status + 'Message'].call(this);
} else if (this.showMessages) {
return this.indicators[message_status] + this.messages[message_status];
}
return this.indicators[message_status];
},
showHelpMessage: function (message) {
if (_.isUndefined(message) || _.isNull(message)) {
message = this.helpMessage;
}
this.$('.u-field-message-notification').html('');
this.$('.u-field-message-help').html(message);
},
getNotificationMessage: function() {
return this.$('.u-field-message-notification').html();
},
showNotificationMessage: function(message) {
this.$('.u-field-message-help').html('');
this.$('.u-field-message-notification').html(message);
},
showCanEditMessage: function(show) {
if (!_.isUndefined(show) && show) {
this.showNotificationMessage(this.getMessage('canEdit'));
} else {
this.showNotificationMessage('');
}
},
showInProgressMessage: function () {
this.showNotificationMessage(this.getMessage('inProgress'));
},
showSuccessMessage: function () {
var successMessage = this.getMessage('success');
this.showNotificationMessage(successMessage);
if (this.options.refreshPageOnSave) {
document.location.reload();
}
var view = this;
var context = Date.now();
this.lastSuccessMessageContext = context;
setTimeout(function () {
if ((context === view.lastSuccessMessageContext) && (view.getNotificationMessage() === successMessage)) {
view.showHelpMessage();
}
}, messageRevertDelay);
},
showErrorMessage: function (xhr) {
if (xhr.status === 400) {
try {
var errors = JSON.parse(xhr.responseText),
validationErrorMessage = Mustache.escapeHtml(
errors.field_errors[this.options.valueAttribute].user_message
),
message = this.indicators.validationError + validationErrorMessage;
this.showNotificationMessage(message);
} catch (error) {
this.showNotificationMessage(this.getMessage('error'));
}
} else {
this.showNotificationMessage(this.getMessage('error'));
}
}
});
FieldViews.EditableFieldView = FieldViews.FieldView.extend({
initialize: function (options) {
_.bindAll(this, 'saveAttributes', 'saveSucceeded', 'showDisplayMode', 'showEditMode',
'startEditing', 'finishEditing'
);
this._super(options);
this.editable = _.isUndefined(this.options.editable) ? 'always': this.options.editable;
this.$el.addClass('editable-' + this.editable);
if (this.editable === 'always') {
this.showEditMode(false);
} else {
this.showDisplayMode(false);
}
},
saveAttributes: function (attributes, options) {
var view = this;
var defaultOptions = {
contentType: 'application/merge-patch+json',
patch: true,
wait: true,
success: function () {
view.saveSucceeded();
},
error: function (model, xhr) {
view.showErrorMessage(xhr);
}
};
this.showInProgressMessage();
this.model.save(attributes, _.extend(defaultOptions, options));
},
saveSucceeded: function () {
this.showSuccessMessage();
},
showDisplayMode: function(render) {
this.mode = 'display';
if (render) { this.render(); }
this.$el.removeClass('mode-edit');
this.$el.toggleClass('mode-hidden', (this.editable === 'never' && !this.modelValueIsSet()));
this.$el.toggleClass('mode-placeholder', (this.editable === 'toggle' && !this.modelValueIsSet()));
this.$el.toggleClass('mode-display', (this.modelValueIsSet()));
},
showEditMode: function(render) {
this.mode = 'edit';
if (render) { this.render(); }
this.$el.removeClass('mode-hidden');
this.$el.removeClass('mode-placeholder');
this.$el.removeClass('mode-display');
this.$el.addClass('mode-edit');
},
startEditing: function () {
if (this.editable === 'toggle' && this.mode !== 'edit') {
this.showEditMode(true);
}
},
finishEditing: function() {
if (this.fieldValue() !== this.modelValue()) {
this.saveValue();
} else {
if (this.editable === 'always') {
this.showEditMode(true);
} else {
this.showDisplayMode(true);
}
}
}
});
FieldViews.ReadonlyFieldView = FieldViews.FieldView.extend({
fieldType: 'readonly',
templateSelector: '#field_readonly-tpl',
initialize: function (options) {
this._super(options);
_.bindAll(this, 'render', 'fieldValue', 'updateValueInField');
this.listenTo(this.model, "change:" + this.options.valueAttribute, this.updateValueInField);
},
render: function () {
this.$el.html(this.template({
id: this.options.valueAttribute,
title: this.options.title,
value: this.modelValue(),
message: this.helpMessage
}));
this.delegateEvents();
return this;
},
fieldValue: function () {
return this.$('.u-field-value input').val();
},
updateValueInField: function () {
this.$('.u-field-value input').val(Mustache.escapeHtml(this.modelValue()));
}
});
FieldViews.TextFieldView = FieldViews.EditableFieldView.extend({
fieldType: 'text',
templateSelector: '#field_text-tpl',
events: {
'change input': 'saveValue'
},
initialize: function (options) {
this._super(options);
_.bindAll(this, 'render', 'fieldValue', 'updateValueInField', 'saveValue');
this.listenTo(this.model, "change:" + this.options.valueAttribute, this.updateValueInField);
},
render: function () {
this.$el.html(this.template({
id: this.options.valueAttribute,
title: this.options.title,
value: this.modelValue(),
message: this.helpMessage
}));
this.delegateEvents();
return this;
},
fieldValue: function () {
return this.$('.u-field-value input').val();
},
updateValueInField: function () {
var value = (_.isUndefined(this.modelValue()) || _.isNull(this.modelValue())) ? '' : this.modelValue();
this.$('.u-field-value input').val(Mustache.escapeHtml(value));
},
saveValue: function () {
var attributes = {};
attributes[this.options.valueAttribute] = this.fieldValue();
this.saveAttributes(attributes);
}
});
FieldViews.DropdownFieldView = FieldViews.EditableFieldView.extend({
fieldType: 'dropdown',
templateSelector: '#field_dropdown-tpl',
events: {
'click': 'startEditing',
'change select': 'finishEditing',
'focusout select': 'finishEditing'
},
initialize: function (options) {
_.bindAll(this, 'render', 'optionForValue', 'fieldValue', 'displayValue', 'updateValueInField', 'saveValue');
this._super(options);
this.listenTo(this.model, "change:" + this.options.valueAttribute, this.updateValueInField);
},
render: function () {
this.$el.html(this.template({
id: this.options.valueAttribute,
mode: this.mode,
title: this.options.title,
screenReaderTitle: this.options.screenReaderTitle || this.options.title,
iconName: this.options.iconName,
showBlankOption: (!this.options.required || !this.modelValueIsSet()),
selectOptions: this.options.options,
message: this.helpMessage
}));
this.delegateEvents();
this.updateValueInField();
if (this.editable === 'toggle') {
this.showCanEditMessage(this.mode === 'display');
}
return this;
},
modelValueIsSet: function() {
var value = this.modelValue();
if (_.isUndefined(value) || _.isNull(value) || value === '') {
return false;
} else {
return !(_.isUndefined(this.optionForValue(value)));
}
},
optionForValue: function(value) {
return _.find(this.options.options, function(option) { return option[0] === value; });
},
fieldValue: function () {
return this.$('.u-field-value select').val();
},
displayValue: function (value) {
if (value) {
var option = this.optionForValue(value);
return (option ? option[1] : '');
} else {
return '';
}
},
updateValueInField: function () {
if (this.mode === 'display') {
var value = this.displayValue(this.modelValue() || '');
if (this.modelValueIsSet() === false) {
value = this.options.placeholderValue || '';
}
this.$('.u-field-value').attr('aria-label', this.options.title);
this.$('.u-field-value-readonly').html(Mustache.escapeHtml(value));
this.showDisplayMode(false);
} else {
this.$('.u-field-value select').val(this.modelValue() || '');
}
},
saveValue: function () {
var attributes = {};
attributes[this.options.valueAttribute] = this.fieldValue();
this.saveAttributes(attributes);
},
showEditMode: function(render) {
this._super(render);
if (this.editable === 'toggle') {
this.$('.u-field-value select').focus();
}
},
saveSucceeded: function() {
if (this.editable === 'toggle') {
this.showDisplayMode(true);
} else {
this.showEditMode(true);
}
this._super();
},
disableField: function(disable) {
this.$('.u-field-value select').prop('disabled', disable);
}
});
FieldViews.TextareaFieldView = FieldViews.EditableFieldView.extend({
fieldType: 'textarea',
templateSelector: '#field_textarea-tpl',
events: {
'click .wrapper-u-field': 'startEditing',
'click .u-field-placeholder': 'startEditing',
'focusout textarea': 'finishEditing',
'change textarea': 'adjustTextareaHeight',
'keyup textarea': 'adjustTextareaHeight',
'keydown textarea': 'adjustTextareaHeight',
'paste textarea': 'adjustTextareaHeight',
'cut textarea': 'adjustTextareaHeight'
},
initialize: function (options) {
_.bindAll(this, 'render', 'adjustTextareaHeight', 'fieldValue', 'saveValue', 'updateView');
this._super(options);
this.listenTo(this.model, "change:" + this.options.valueAttribute, this.updateView);
},
render: function () {
var value = this.modelValue();
if (this.mode === 'display') {
value = value || this.options.placeholderValue;
}
this.$el.html(this.template({
id: this.options.valueAttribute,
screenReaderTitle: this.options.screenReaderTitle || this.options.title,
mode: this.mode,
value: value,
message: this.helpMessage,
placeholderValue: this.options.placeholderValue
}));
this.delegateEvents();
this.title((this.modelValue() || this.mode === 'edit') ? this.options.title : this.indicators['plus'] + this.options.title);
if (this.editable === 'toggle') {
this.showCanEditMessage(this.mode === 'display');
}
return this;
},
adjustTextareaHeight: function() {
var textarea = this.$('textarea');
textarea.css('height', 'auto').css('height', textarea.prop('scrollHeight') + 10);
},
modelValue: function() {
var value = this._super();
return value ? $.trim(value) : '';
},
fieldValue: function () {
return this.$('.u-field-value textarea').val();
},
saveValue: function () {
var attributes = {};
attributes[this.options.valueAttribute] = this.fieldValue();
this.saveAttributes(attributes);
},
updateView: function () {
if (this.mode !== 'edit') {
this.showDisplayMode(true);
}
},
modelValueIsSet: function() {
return !(this.modelValue() === '');
},
showEditMode: function(render) {
this._super(render);
this.adjustTextareaHeight();
this.$('.u-field-value textarea').focus();
},
saveSucceeded: function() {
this._super();
if (this.editable === 'toggle') {
this.showDisplayMode(true);
}
}
});
FieldViews.LinkFieldView = FieldViews.FieldView.extend({
fieldType: 'link',
templateSelector: '#field_link-tpl',
events: {
'click a': 'linkClicked'
},
initialize: function (options) {
this._super(options);
_.bindAll(this, 'render', 'linkClicked');
},
render: function () {
this.$el.html(this.template({
id: this.options.valueAttribute,
title: this.options.title,
screenReaderTitle: this.options.screenReaderTitle || this.options.title,
linkTitle: this.options.linkTitle,
linkHref: this.options.linkHref,
message: this.helpMessage
}));
this.delegateEvents();
return this;
},
linkClicked: function (event) {
event.preventDefault();
}
});
FieldViews.ImageFieldView = FieldViews.FieldView.extend({
fieldType: 'image',
templateSelector: '#field_image-tpl',
uploadButtonSelector: '.upload-button-input',
titleAdd: gettext("Upload an image"),
titleEdit: gettext("Change image"),
titleRemove: gettext("Remove"),
titleUploading: gettext("Uploading"),
titleRemoving: gettext("Removing"),
titleImageAlt: '',
iconUpload: '<i class="icon fa fa-camera" aria-hidden="true"></i>',
iconRemove: '<i class="icon fa fa-remove" aria-hidden="true"></i>',
iconProgress: '<i class="icon fa fa-spinner fa-pulse fa-spin" aria-hidden="true"></i>',
errorMessage: gettext("An error has occurred. Refresh the page, and then try again."),
events: {
'click .u-field-upload-button': 'clickedUploadButton',
'click .u-field-remove-button': 'clickedRemoveButton',
'click .upload-submit': 'clickedUploadButton'
},
initialize: function (options) {
this._super(options);
_.bindAll(this, 'render', 'imageChangeSucceeded', 'imageChangeFailed', 'fileSelected',
'watchForPageUnload', 'onBeforeUnload');
this.listenTo(this.model, "change:" + this.options.valueAttribute, this.render);
},
render: function () {
this.$el.html(this.template({
id: this.options.valueAttribute,
imageUrl: _.result(this, 'imageUrl'),
imageAltText: _.result(this, 'imageAltText'),
uploadButtonIcon: _.result(this, 'iconUpload'),
uploadButtonTitle: _.result(this, 'uploadButtonTitle'),
removeButtonIcon: _.result(this, 'iconRemove'),
removeButtonTitle: _.result(this, 'removeButtonTitle')
}));
this.delegateEvents();
this.updateButtonsVisibility();
this.watchForPageUnload();
return this;
},
showErrorMessage: function (message) {
return message;
},
imageUrl: function () {
return '';
},
uploadButtonTitle: function () {
if (this.isShowingPlaceholder()) {
return _.result(this, 'titleAdd');
} else {
return _.result(this, 'titleEdit');
}
},
removeButtonTitle: function () {
return this.titleRemove;
},
isEditingAllowed: function () {
return true;
},
isShowingPlaceholder: function () {
return false;
},
setUploadButtonVisibility: function (state) {
this.$('.u-field-upload-button').css('display', state);
},
setRemoveButtonVisibility: function (state) {
this.$('.u-field-remove-button').css('display', state);
},
updateButtonsVisibility: function () {
if (!this.isEditingAllowed() || !this.options.editable) {
this.setUploadButtonVisibility('none');
}
if (this.isShowingPlaceholder() || !this.options.editable) {
this.setRemoveButtonVisibility('none');
}
},
clickedUploadButton: function () {
$(this.uploadButtonSelector).fileupload({
url: this.options.imageUploadUrl,
type: 'POST',
add: this.fileSelected,
done: this.imageChangeSucceeded,
fail: this.imageChangeFailed
});
},
clickedRemoveButton: function () {
var view = this;
this.setCurrentStatus('removing');
this.setUploadButtonVisibility('none');
this.showRemovalInProgressMessage();
$.ajax({
type: 'POST',
url: this.options.imageRemoveUrl
}).done(function () {
view.imageChangeSucceeded();
}).fail(function (jqXHR) {
view.showImageChangeFailedMessage(jqXHR.status, jqXHR.responseText);
});
},
imageChangeSucceeded: function () {
this.render();
},
imageChangeFailed: function (e, data) {
},
showImageChangeFailedMessage: function (status, responseText) {
},
fileSelected: function (e, data) {
if (_.isUndefined(data.files[0].size) || this.validateImageSize(data.files[0].size)) {
data.formData = {file: data.files[0]};
this.setCurrentStatus('uploading');
this.setRemoveButtonVisibility('none');
this.showUploadInProgressMessage();
data.submit();
}
},
validateImageSize: function (imageBytes) {
var humanReadableSize;
if (imageBytes < this.options.imageMinBytes) {
humanReadableSize = this.bytesToHumanReadable(this.options.imageMinBytes);
this.showErrorMessage(
interpolate_text(
gettext("The file must be at least {size} in size."), {size: humanReadableSize}
)
);
return false;
} else if (imageBytes > this.options.imageMaxBytes) {
humanReadableSize = this.bytesToHumanReadable(this.options.imageMaxBytes);
this.showErrorMessage(
interpolate_text(
gettext("The file must be smaller than {size} in size."), {size: humanReadableSize}
)
);
return false;
}
return true;
},
showUploadInProgressMessage: function () {
this.$('.u-field-upload-button').css('opacity', 1);
this.$('.upload-button-icon').html(this.iconProgress);
this.$('.upload-button-title').html(this.titleUploading);
},
showRemovalInProgressMessage: function () {
this.$('.u-field-remove-button').css('opacity', 1);
this.$('.remove-button-icon').html(this.iconProgress);
this.$('.remove-button-title').html(this.titleRemoving);
},
setCurrentStatus: function (status) {
this.$('.image-wrapper').attr('data-status', status);
},
getCurrentStatus: function () {
return this.$('.image-wrapper').attr('data-status');
},
watchForPageUnload: function () {
$(window).on('beforeunload', this.onBeforeUnload);
},
onBeforeUnload: function () {
var status = this.getCurrentStatus();
if (status === 'uploading') {
return gettext("Upload is in progress. To avoid errors, stay on this page until the process is complete.");
} else if (status === 'removing') {
return gettext("Removal is in progress. To avoid errors, stay on this page until the process is complete.");
}
},
bytesToHumanReadable: function (size) {
var units = [gettext('bytes'), gettext('KB'), gettext('MB')];
var i = 0;
while(size >= 1024) {
size /= 1024;
++i;
}
return size.toFixed(1)*1 + ' ' + units[i];
}
});
return FieldViews;
});
}).call(this, define || RequireJS.define);

View File

@@ -0,0 +1,37 @@
;(function (define, undefined) {
'use strict';
define([
'gettext', 'jquery', 'underscore', 'backbone'
], function (gettext, $, _, Backbone) {
var MessageBannerView = Backbone.View.extend({
initialize: function () {
this.template = _.template($('#message_banner-tpl').text());
},
render: function () {
if (_.isUndefined(this.message) || _.isNull(this.message)) {
this.$el.html('');
} else {
this.$el.html(this.template({
message: this.message
}));
}
return this;
},
showMessage: function (message) {
this.message = message;
this.render();
},
hideMessage: function () {
this.message = null;
this.render();
}
});
return MessageBannerView;
});
}).call(this, define || RequireJS.define);

View File

@@ -83,6 +83,7 @@ fixture_paths:
- templates/instructor/instructor_dashboard_2
- templates/dashboard
- templates/edxnotes
- templates/fields
- templates/student_account
- templates/student_profile
- templates/verify_student

View File

@@ -47,6 +47,7 @@
"annotator_1.2.9": "js/vendor/edxnotes/annotator-full.min",
"date": "js/vendor/date",
"backbone": "js/vendor/backbone-min",
"backbone-super": "js/vendor/backbone-super",
"underscore.string": "js/vendor/underscore.string.min",
// Files needed by OVA
"annotator": "js/vendor/ova/annotator-full",
@@ -87,6 +88,9 @@
deps: ["underscore", "jquery"],
exports: "Backbone"
},
"backbone-super": {
deps: ["backbone"],
},
"logger": {
exports: "Logger"
},

View File

@@ -28,14 +28,9 @@
@include animation(rotateCW $tmg-s1 linear infinite);
}
.ui-loading {
.ui-loading-base {
@include animation(fadeIn $tmg-f2 linear 1);
@extend %ui-well;
@extend %t-copy-base;
opacity: .6;
background-color: $white;
padding: ($baseline*1.5) $baseline;
text-align: center;
.spin {
@extend %anim-rotateCW;
@@ -46,3 +41,12 @@
padding-left: ($baseline/4);
}
}
.ui-loading {
@extend .ui-loading-base;
@extend %ui-well;
opacity: 0.6;
background-color: $white;
padding: ($baseline*1.5) $baseline;
text-align: center;
}

View File

@@ -44,6 +44,8 @@
@import 'elements/system-feedback';
// base - specific views
@import "views/account-settings";
@import "views/learner-profile";
@import 'views/verification';
@import 'views/shoppingcart';
@import 'views/login-register';

View File

@@ -45,6 +45,8 @@
@import 'elements/system-feedback';
// base - specific views
@import "views/account-settings";
@import "views/learner-profile";
@import 'views/login-register';
@import 'views/verification';
@import 'views/decoupled-verification';

View File

@@ -43,6 +43,7 @@
@import 'elements/controls';
// shared - course
@import 'shared/fields';
@import 'shared/forms';
@import 'shared/footer';
@import 'shared/header';

View File

@@ -43,6 +43,7 @@
@import 'elements/controls';
// shared - course
@import 'shared/fields';
@import 'shared/forms';
@import 'shared/footer';
@import 'shared/header';

View File

@@ -16,33 +16,13 @@
padding: ($baseline*2) 0 0 0;
.profile-sidebar {
@include float(right);
@include margin-left(flex-gutter());
width: flex-grid(3);
background: transparent;
.profile {
@include box-sizing(border-box);
border: 1px solid $border-color-2;
border-radius: ($baseline/4) ($baseline/4) 0 0;
width: flex-grid(12);
.username-header {
margin-bottom: 0;
}
.username-label {
@extend %t-title7;
@extend %t-ultrastrong;
@extend %cont-truncated;
text-align: center;
display: block;
margin: 0;
padding: ($baseline*0.75) $baseline;
color: $base-font-color;
text-transform: none;
}
}
@include float(right);
margin-top: ($baseline*2);
width: flex-grid(3);
box-shadow: 0 0 1px $shadow-l1;
border: 1px solid $border-color-2;
border-radius: 3px;
.user-info {
@include clearfix();
@@ -51,12 +31,8 @@
@include box-sizing(border-box);
@include clearfix();
margin: 0;
border: 1px solid $border-color-2;
border-top: none;
border-radius: 0 0 ($baseline/4) ($baseline/4);
padding: $baseline;
width: flex-grid(12);
background: $white;
li {
@include clearfix();
@@ -79,19 +55,22 @@
span {
display: block;
margin-bottom: ($baseline/4);
}
span.title {
color: $gray;
font-family: $sans-serif;
font-size: 13px;
text-transform: uppercase;
@extend %t-copy-sub1;
@extend %t-strong;
a {
text-transform: none;
}
}
span.copy {
@extend %t-copy-sub1;
}
span.data {
color: $base-font-color;
font-weight: 600;
@@ -104,40 +83,6 @@
font-weight: inherit;
}
.auth-provider {
width: flex-grid(12);
display: block;
margin-top: ($baseline/4);
.status {
width: flex-grid(1);
display: inline-block;
color: $gray-l2;
.fa-link {
color: $base-font-color;
}
.copy {
@extend %text-sr;
}
}
.provider {
width: flex-grid(9);
display: inline-block;
}
.control {
width: flex-grid(2);
display: inline-block;
text-align: right;
a:link, a:visited {
@extend %t-copy-sub2;
}
}
}
}
}
@@ -147,6 +92,17 @@
line-height: 20px;
}
}
.heads-up {
.title {
display: inline;
}
.copy {
@extend %t-copy-sub2;
display: inline;
}
}
}
.reverify-status-list {

View File

@@ -0,0 +1,123 @@
// lms - shared - fields
// ====================
.u-field {
padding: $baseline 0;
border-bottom: 1px solid $gray-l5;
border: 1px dashed transparent;
&.mode-placeholder {
border: 2px dashed transparent;
border-radius: 3px;
span {
color: $gray-l1;
}
&:hover {
border: 2px dashed $link-color;
span {
color: $link-color;
}
}
}
&.editable-toggle.mode-display:hover {
background-color: $m-blue-l4;
border-radius: 3px;
.message-can-edit {
display: inline-block;
color: $link-color;
}
}
&.mode-hidden {
display: none;
}
i {
color: $gray-l2;
vertical-align:text-bottom;
@include margin-right(5px);
}
.message-can-edit {
display: none;
}
.message-error {
color: $alert-color;
}
.message-validation-error {
color: $warning-color;
}
.message-in-progress {
color: $gray-d2;
}
.message-success {
color: $success-color;
}
}
.u-field-readonly {
input[type="text"],
input[type="text"]:focus {
background-color: transparent;
padding: 0;
border: none;
box-shadow: none;
}
}
.u-field-icon {
width: $baseline;
color: $gray-l2;
}
.u-field-title {
width: flex-grid(3, 12);
display: inline-block;
color: $gray;
vertical-align: top;
margin-bottom: 0;
-webkit-font-smoothing: antialiased;
label, span {
@include margin-left($baseline/2);
}
}
.u-field-value {
width: flex-grid(3, 12);
display: inline-block;
vertical-align: top;
select, input {
width: 100%;
}
}
.u-field-message {
@extend %t-copy-sub1;
@include padding-left($baseline/2);
width: flex-grid(6, 12);
display: inline-block;
vertical-align: top;
color: $gray-l1;
i {
@include margin-right($baseline/4);
}
.u-field-message-help,
.u-field-message-notification {
color: $gray-l1;
}
}

View File

@@ -0,0 +1,80 @@
// lms - application - account settings
// ====================
// Table of Contents
// * +Container - Account Settings
// * +Main - Header
// * +Settings Section
// +Container - Account Settings
.wrapper-account-settings {
@extend .container;
padding-top: ($baseline*2);
.account-settings-container {
padding: 0;
}
.ui-loading-indicator,
.ui-loading-error {
@extend .ui-loading-base;
// center horizontally
@include margin-left(auto);
@include margin-right(auto);
padding: ($baseline*3);
text-align: center;
.message-error {
color: $alert-color;
}
}
}
// +Main - Header
.wrapper-account-settings {
.wrapper-header {
.header-title {
@extend %t-title4;
margin-bottom: ($baseline/2);
}
.header-subtitle {
color: $gray-l2;
}
}
}
// +Settings Section
.account-settings-sections {
.section-header {
@extend %t-title6;
@extend %t-strong;
padding-bottom: ($baseline/2);
border-bottom: 1px solid $gray-l4;
}
.section {
background-color: $white;
padding: $baseline;
margin-top: $baseline;
border: 1px solid $gray-l4;
box-shadow: 0 0 1px 1px $shadow-l2;
border-radius: 5px;
}
a span {
color: $link-color;
}
a span {
&:hover, &:focus {
color: $pink;
text-decoration: none !important;
}
}
}

View File

@@ -0,0 +1,272 @@
// lms - application - learner profile
// ====================
// Table of Contents
// * +Container - Learner Profile
// * +Main - Header
// * +Settings Section
.view-profile {
$profile-image-dimension: 120px;
.content-wrapper {
background-color: $white;
}
.ui-loading-indicator {
@extend .ui-loading-base;
padding-bottom: $baseline;
// center horizontally
@include margin-left(auto);
@include margin-right(auto);
width: ($baseline*5);
}
.profile-image-field {
@include float(left);
button {
background: transparent !important;
border: none !important;
padding: 0;
}
.u-field-image {
padding-top: 0;
}
.image-wrapper {
width: $profile-image-dimension;
position: relative;
.image-frame {
position: relative;
width: $profile-image-dimension;
height: $profile-image-dimension;
border-radius: ($baseline/4);
}
.u-field-upload-button {
width: $profile-image-dimension;
height: $profile-image-dimension;
position: absolute;
top: 0;
opacity: 0;
@include transition(all $tmg-f1 ease-in-out 0s);
i {
color: $white;
}
}
.upload-button-icon, .upload-button-title {
text-align: center;
transform: translateY(35px);
-webkit-transform: translateY(35px);
display: block;
color: $white;
margin-bottom: ($baseline/4);
line-height: 1.3em;
}
.upload-button-input {
width: $profile-image-dimension;
height: 100%;
position: absolute;
top: 0;
@include left(0);
opacity: 0;
cursor: pointer;
}
.u-field-remove-button {
width: $profile-image-dimension;
height: $baseline;
opacity: 0;
position: relative;
margin-top: 2px;
text-align: center;
&:focus, &:active {
box-shadow: none;
outline: 0;
}
}
&:hover {
.u-field-upload-button, .u-field-remove-button {
opacity: 1;
background-color: $shadow-d2;
border-radius: ($baseline/4);
}
}
}
}
.wrapper-profile {
min-height: 200px;
.ui-loading-indicator {
margin-top: 100px;
}
}
.profile-self {
.wrapper-profile-field-account-privacy {
@include clearfix();
@include box-sizing(border-box);
margin: 0 auto 0;
padding: ($baseline*0.75) 0;
width: 100%;
background-color: $gray-l3;
.u-field-account_privacy {
@extend .container;
border: none;
box-shadow: none;
padding: 0 ($baseline*1.5);
}
.u-field-title {
width: auto;
color: $base-font-color;
font-weight: $font-bold;
cursor: text;
}
.u-field-value {
width: auto;
@include margin-left($baseline/2);
}
.u-field-message {
@include float(left);
width: 100%;
padding: 0;
color: $base-font-color;
}
}
}
.wrapper-profile-sections {
@extend .container;
padding: 0 ($baseline*1.5);
}
.wrapper-profile-section-one {
width: 100%;
display: inline-block;
margin-top: ($baseline*1.5);
}
.profile-section-one-fields {
@include float(left);
width: flex-grid(4, 12);
@include margin-left($baseline*1.5);
.u-field {
margin-bottom: ($baseline/4);
padding-top: 0;
padding-bottom: 0;
@include padding-left(3px);
}
.u-field-username {
margin-bottom: ($baseline/2);
input[type="text"] {
font-weight: 600;
}
.u-field-value {
width: 350px;
@extend %t-title4;
}
}
.u-field-title {
width: 0;
}
.u-field-value {
width: 200px;
}
select {
width: 100%
}
.u-field-message {
@include float(right);
width: 20px;
margin-top: 2px;
}
}
.wrapper-profile-section-two {
width: flex-grid(8, 12);
margin-top: ($baseline*1.5);
}
.profile-section-two-fields {
.u-field-textarea {
margin-bottom: ($baseline/2);
padding: ($baseline/4) ($baseline/2) ($baseline/2);
}
.u-field-title {
font-size: 1.1em;
@extend %t-weight4;
margin-bottom: ($baseline/4);
}
.u-field-value {
width: 100%;
white-space: pre-line;
line-height: 1.5em;
textarea {
width: 100%;
background-color: transparent;
}
}
.u-field-message {
@include float(right);
width: auto;
padding-top: ($baseline/4);
}
.u-field.mode-placeholder {
padding: $baseline;
border: 2px dashed $gray-l3;
i {
font-size: 12px;
@include padding-right(5px);
vertical-align: middle;
color: $gray;
}
.u-field-title {
width: 100%;
text-align: center;
}
.u-field-value {
text-align: center;
line-height: 1.5em;
@extend %t-copy-sub1;
color: $gray;
}
}
.u-field.mode-placeholder:hover {
border: 2px dashed $link-color;
.u-field-title,
i {
color: $link-color;
}
}
}
}

View File

@@ -36,10 +36,7 @@
edx.dashboard.legacy.init({
dashboard: "${reverse('dashboard')}",
signInUser: "${reverse('signin_user')}",
passwordReset: "${reverse('password_reset')}",
changeEmail: "${reverse('change_email')}",
changeEmailSettings: "${reverse('change_email_settings')}",
changeName: "${reverse('change_name')}",
verifyToggleBannerFailedOff: "${reverse('verify_student_toggle_failed_banner_off')}",
});
});
@@ -60,12 +57,6 @@
</section>
%endif
% if duplicate_provider:
<section class="dashboard-banner third-party-auth">
<%include file='dashboard/_dashboard_third_party_error.html' />
</section>
% endif
%if enrollment_message:
<section class="dashboard-banner">
${enrollment_message}
@@ -78,7 +69,8 @@
<header class="wrapper-header-courses">
<h2 class="header-courses">${_("Current Courses")}</h2>
</header>
% if len(course_enrollment_pairs) > 0:
<ul class="listing-courses">
<% share_settings = settings.FEATURES.get('DASHBOARD_SHARE_SETTINGS', {}) %>
@@ -133,75 +125,12 @@
% endif
</section>
<section class="profile-sidebar" role="region" aria-label="User info">
<header class="profile">
<h2 class="username-header"><span class="sr">${_("Username")}: </span><span class="username-label">${ user.username }</span></h2>
</header>
<section class="user-info">
<ul>
<li class="info--username">
<span class="title">${_("Full Name")} (<a href="#apply_name_change" rel="leanModal" class="edit-name">${_("edit")}</a>)</span> <span class="data">${ user.profile.name | h }</span>
<li class="heads-up">
<span class="title">${_("Want to change your account settings?")}</span>
<span class="copy">${_("Click the arrow next to your username above.")}</span>
</li>
<li class="info--email">
<span class="title">${_("Email")}
% if external_auth_map is None or 'shib' not in external_auth_map.external_domain:
(<a href="#change_email" rel="leanModal" class="edit-email">${_("edit")}</a>)
% endif
</span> <span class="data">${ user.email | h }</span>
</li>
%if len(language_options) > 1:
<%include file='dashboard/_dashboard_info_language.html' />
%endif
% if third_party_auth.is_enabled():
<li class="controls--account">
<span class="title">
## Translators: this section lists all the third-party authentication providers (for example, Google and LinkedIn) the user can link with or unlink from their edX account.
${_("Connected Accounts")}
</span>
<span class="data">
<span class="third-party-auth">
% for state in provider_user_states:
<div class="auth-provider">
<div class="status">
% if state.has_account:
<i class="icon fa fa-link"></i> <span class="copy">${_("Linked")}</span>
% else:
<i class="icon fa fa-unlink"></i><span class="copy">${_("Not Linked")}</span>
% endif
</div>
<span class="provider">${state.provider.NAME}</span>
<span class="control">
% if state.has_account:
<form
action="${pipeline.get_disconnect_url(state.provider.NAME)}"
method="post"
name="${state.get_unlink_form_name()}">
<input type="hidden" name="csrfmiddlewaretoken" value="${csrf_token}">
<a href="#" onclick="document.${state.get_unlink_form_name()}.submit()">
## Translators: clicking on this removes the link between a user's edX account and their account with an external authentication provider (like Google or LinkedIn).
${_("Unlink")}
</a>
</form>
% else:
<a href="${pipeline.get_login_url(state.provider.NAME, pipeline.AUTH_ENTRY_DASHBOARD, redirect_url='/')}">
## Translators: clicking on this creates a link between a user's edX account and their account with an external authentication provider (like Google or LinkedIn).
${_("Link")}
</a>
% endif
</span>
</div>
% endfor
</span>
</span>
</li>
% endif
% if len(order_history_list):
<li class="order-history">
@@ -212,16 +141,6 @@
</li>
% endif
% if external_auth_map is None or 'shib' not in external_auth_map.external_domain:
<li class="controls--account">
<span class="title"><a href="#password_reset_complete" rel="leanModal" id="pwd_reset_button">${_("Reset Password")}</a></span>
<form id="password_reset_form" method="post" data-remote="true" action="${reverse('password_reset')}">
<input id="id_email" type="hidden" name="email" maxlength="75" value="${user.email}" />
<!-- <input type="submit" id="pwd_reset_button" value="${_("Reset Password")}" /> -->
</form>
</li>
% endif
<%include file='dashboard/_dashboard_status_verification.html' />
<%include file='dashboard/_dashboard_reverification_sidebar.html' />
@@ -262,123 +181,6 @@
</div>
</section>
<section id="password_reset_complete" class="modal" aria-hidden="true">
<div class="inner-wrapper" role="dialog" aria-labelledby="password-reset-email">
<button class="close-modal">
<i class="icon fa fa-remove"></i>
<span class="sr">
## Translators: this is a control to allow users to exit out of this modal interface (a menu or piece of UI that takes the full focus of the screen)
${_("Close")}
</span>
</button>
<header>
<h2 id="password-reset-email">
${_("Password Reset Email Sent")}
<span class="sr">,
## Translators: this text gives status on if the modal interface (a menu or piece of UI that takes the full focus of the screen) is open or not
${_("window open")}
</span>
</h2>
<hr/>
</header>
<div>
<form> <!-- Here for styling reasons -->
<section>
<p>${_("An email has been sent to {email}. Follow the link in the email to change your password.").format(email=user.email)}</p>
</section>
</form>
</div>
</div>
</section>
<section id="change_email" class="modal" aria-hidden="true">
<div class="inner-wrapper" role="dialog" aria-labelledby="change_email_title">
<button class="close-modal">
<i class="icon fa fa-remove"></i>
<span class="sr">
## Translators: this is a control to allow users to exit out of this modal interface (a menu or piece of UI that takes the full focus of the screen)
${_("Close")}
</span>
</button>
<header>
<h2>
<span id="change_email_title">${_("Change Email")}</span>
<span class="sr">,
## Translators: this text gives status on if the modal interface (a menu or piece of UI that takes the full focus of the screen) is open or not
${_("window open")}
</span>
</h2>
<hr/>
</header>
<div id="change_email_body">
<form id="change_email_form">
<div id="change_email_error" class="modal-form-error"> </div>
<div class="input-group">
<label>${_("Please enter your new email address:")}
<input id="new_email_field" type="email" value="" />
</label>
<label>${_("Please confirm your password:")}
<input id="new_email_password" value="" type="password" />
</label>
</div>
<section>
<p>${_("We will send a confirmation to both {email} and your new email address as part of the process.").format(email=user.email)}</p>
</section>
<div class="submit">
<input type="submit" id="submit_email_change" value="${_("Change Email")}"/>
</div>
</form>
</div>
</div>
</section>
<%include file='modal/_modal-settings-language.html' />
<section id="apply_name_change" class="modal" aria-hidden="true">
<div class="inner-wrapper" role="dialog" aria-labelledby="change-name-title">
<button class="close-modal">
<i class="icon fa fa-remove"></i>
<span class="sr">
## Translators: this is a control to allow users to exit out of this modal interface (a menu or piece of UI that takes the full focus of the screen)
${_("Close")}
</span>
</button>
<header>
<h2 id="change-name-title">
${_("Change your name")}
<span class="sr">,
## Translators: this text gives status on if the modal interface (a menu or piece of UI that takes the full focus of the screen) is open or not
${_("window open")}
</span>
</h2>
<hr/>
</header>
<div id="change_name_body">
<form id="change_name_form">
<div id="change_name_error" class="modal-form-error"> </div>
## Translators: note that {platform} {cert_name_short} will look something like: "edX certificate". Please do not change the order of these placeholders.
<p>${_("To uphold the credibility of your {platform} {cert_name_short}, all name changes will be recorded.").format(platform=settings.PLATFORM_NAME, cert_name_short=cert_name_short)}</p>
<br/>
<div class="input-group">
## Translators: note that {platform} {cert_name_short} will look something like: "edX certificate". Please do not change the order of these placeholders.
<label>${_("Enter your desired full name, as it will appear on your {platform} {cert_name_short}:").format(platform=settings.PLATFORM_NAME, cert_name_short=cert_name_short)}
<input id="new_name_field" value="" type="text" />
</label>
<label>${_("Reason for name change:")}
<textarea id="name_rationale_field" value=""></textarea>
</label>
</div>
<div class="submit">
<input type="submit" id="submit" value="${_("Change My Name")}">
</div>
</form>
</div>
</div>
</section>
<section id="unenroll-modal" class="modal unenroll-modal" aria-hidden="true">
<div class="inner-wrapper" role="dialog" aria-labelledby="unenrollment-modal-title">
<button class="close-modal">

Some files were not shown because too many files have changed in this diff Show More