diff --git a/cms/envs/common.py b/cms/envs/common.py index f9c7d84065..445df80d30 100644 --- a/cms/envs/common.py +++ b/cms/envs/common.py @@ -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 diff --git a/cms/envs/test.py b/cms/envs/test.py index ddc48208b4..2d10aaad49 100644 --- a/cms/envs/test.py +++ b/cms/envs/test.py @@ -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 diff --git a/cms/static/js/spec/views/group_configuration_spec.js b/cms/static/js/spec/views/group_configuration_spec.js index 400715a858..3283726cb2 100644 --- a/cms/static/js/spec/views/group_configuration_spec.js +++ b/cms/static/js/spec/views/group_configuration_spec.js @@ -123,7 +123,7 @@ define([ patchAndVerifyRequest(requests, url, notificationSpy); - AjaxHelpers.respondToDelete(requests); + AjaxHelpers.respondWithNoContent(requests); ViewHelpers.verifyNotificationHidden(notificationSpy); expect($(SELECTORS.itemView)).not.toExist(); }; diff --git a/cms/static/js/spec/views/pages/course_outline_spec.js b/cms/static/js/spec/views/pages/course_outline_spec.js index 98d55a0dd7..c50e7fb985 100644 --- a/cms/static/js/spec/views/pages/course_outline_spec.js +++ b/cms/static/js/spec/views/pages/course_outline_spec.js @@ -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'); }); }); diff --git a/cms/static/js/spec/views/pages/index_spec.js b/cms/static/js/spec/views/pages/index_spec.js index 3217df1b2c..622cff39db 100644 --- a/cms/static/js/spec/views/pages/index_spec.js +++ b/cms/static/js/spec/views/pages/index_spec.js @@ -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(); }); diff --git a/common/djangoapps/enrollment/views.py b/common/djangoapps/enrollment/views.py index 57301a9ec0..6ec535bae1 100644 --- a/common/djangoapps/enrollment/views.py +++ b/common/djangoapps/enrollment/views.py @@ -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 ( diff --git a/common/djangoapps/student/migrations/0046_auto__add_entranceexamconfiguration__add_unique_entranceexamconfigurat.py b/common/djangoapps/student/migrations/0046_auto__add_entranceexamconfiguration__add_unique_entranceexamconfigurat.py index 233519bc0f..a0373d043e 100644 --- a/common/djangoapps/student/migrations/0046_auto__add_entranceexamconfiguration__add_unique_entranceexamconfigurat.py +++ b/common/djangoapps/student/migrations/0046_auto__add_entranceexamconfiguration__add_unique_entranceexamconfigurat.py @@ -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'}, diff --git a/common/djangoapps/student/migrations/0047_add_bio_field.py b/common/djangoapps/student/migrations/0047_add_bio_field.py new file mode 100644 index 0000000000..33f42d2e65 --- /dev/null +++ b/common/djangoapps/student/migrations/0047_add_bio_field.py @@ -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'] \ No newline at end of file diff --git a/common/djangoapps/student/migrations/0048_add_profile_image_version.py b/common/djangoapps/student/migrations/0048_add_profile_image_version.py new file mode 100644 index 0000000000..171e3ce55a --- /dev/null +++ b/common/djangoapps/student/migrations/0048_add_profile_image_version.py @@ -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'] diff --git a/common/djangoapps/student/migrations/0049_auto__add_languageproficiency__add_unique_languageproficiency_code_use.py b/common/djangoapps/student/migrations/0049_auto__add_languageproficiency__add_unique_languageproficiency_code_use.py new file mode 100644 index 0000000000..0fcaf25211 --- /dev/null +++ b/common/djangoapps/student/migrations/0049_auto__add_languageproficiency__add_unique_languageproficiency_code_use.py @@ -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'] \ No newline at end of file diff --git a/common/djangoapps/student/models.py b/common/djangoapps/student/models.py index cc5a3f9fd4..58ecd581b9 100644 --- a/common/djangoapps/student/models.py +++ b/common/djangoapps/student/models.py @@ -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.") + ) diff --git a/common/djangoapps/student/tests/test_auto_auth.py b/common/djangoapps/student/tests/test_auto_auth.py index d94e89d097..0b5304bb54 100644 --- a/common/djangoapps/student/tests/test_auto_auth.py +++ b/common/djangoapps/student/tests/test_auto_auth.py @@ -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') diff --git a/common/djangoapps/student/tests/test_email.py b/common/djangoapps/student/tests/test_email.py index 6d04d31435..c189521139 100644 --- a/common/djangoapps/student/tests/test_email.py +++ b/common/djangoapps/student/tests/test_email.py @@ -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') diff --git a/common/djangoapps/student/tests/test_enrollment.py b/common/djangoapps/student/tests/test_enrollment.py index 6cf29170fc..2334c99b95 100644 --- a/common/djangoapps/student/tests/test_enrollment.py +++ b/common/djangoapps/student/tests/test_enrollment.py @@ -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 diff --git a/common/djangoapps/student/tests/test_events.py b/common/djangoapps/student/tests/test_events.py new file mode 100644 index 0000000000..9640b50dc1 --- /dev/null +++ b/common/djangoapps/student/tests/test_events.py @@ -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() diff --git a/common/djangoapps/student/tests/test_parental_controls.py b/common/djangoapps/student/tests/test_parental_controls.py new file mode 100644 index 0000000000..d37bd96729 --- /dev/null +++ b/common/djangoapps/student/tests/test_parental_controls.py @@ -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) diff --git a/common/djangoapps/student/tests/test_reset_password.py b/common/djangoapps/student/tests/test_reset_password.py index e3305a3037..d4cfca5d19 100644 --- a/common/djangoapps/student/tests/test_reset_password.py +++ b/common/djangoapps/student/tests/test_reset_password.py @@ -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""" diff --git a/common/djangoapps/student/tests/tests.py b/common/djangoapps/student/tests/tests.py index 65b2b7a61d..12d9b22a7e 100644 --- a/common/djangoapps/student/tests/tests.py +++ b/common/djangoapps/student/tests/tests.py @@ -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""" diff --git a/common/djangoapps/student/views.py b/common/djangoapps/student/views.py index 9aad5d538a..c27356681b 100644 --- a/common/djangoapps/student/views.py +++ b/common/djangoapps/student/views.py @@ -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 diff --git a/common/djangoapps/third_party_auth/pipeline.py b/common/djangoapps/third_party_auth/pipeline.py index 58f415d3d1..2ff7ea67b0 100644 --- a/common/djangoapps/third_party_auth/pipeline.py +++ b/common/djangoapps/third_party_auth/pipeline.py @@ -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) diff --git a/common/djangoapps/third_party_auth/tests/specs/base.py b/common/djangoapps/third_party_auth/tests/specs/base.py index 11b27252a1..902a1beb1d 100644 --- a/common/djangoapps/third_party_auth/tests/specs/base.py +++ b/common/djangoapps/third_party_auth/tests/specs/base.py @@ -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 = '
' - 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'([^<]+)', 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() diff --git a/common/djangoapps/third_party_auth/tests/test_change_enrollment.py b/common/djangoapps/third_party_auth/tests/test_change_enrollment.py index caf71e5da1..3fa4655673 100644 --- a/common/djangoapps/third_party_auth/tests/test_change_enrollment.py +++ b/common/djangoapps/third_party_auth/tests/test_change_enrollment.py @@ -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, {}) diff --git a/common/djangoapps/util/model_utils.py b/common/djangoapps/util/model_utils.py new file mode 100644 index 0000000000..4c9e7b9c77 --- /dev/null +++ b/common/djangoapps/util/model_utils.py @@ -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 diff --git a/common/djangoapps/util/testing.py b/common/djangoapps/util/testing.py index ccfd29e91d..72a94a93cd 100644 --- a/common/djangoapps/util/testing.py +++ b/common/djangoapps/util/testing.py @@ -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() diff --git a/common/static/js/spec_helpers/ajax_helpers.js b/common/static/js/spec_helpers/ajax_helpers.js index f2be1a44fb..6ceda7efcc 100644 --- a/common/static/js/spec_helpers/ajax_helpers.js +++ b/common/static/js/spec_helpers/ajax_helpers.js @@ -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, }; }); diff --git a/common/test/acceptance/pages/lms/account_settings.py b/common/test/acceptance/pages/lms/account_settings.py new file mode 100644 index 0000000000..648d7c5831 --- /dev/null +++ b/common/test/acceptance/pages/lms/account_settings.py @@ -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() diff --git a/common/test/acceptance/pages/lms/dashboard.py b/common/test/acceptance/pages/lms/dashboard.py index e1954d13f2..84bc851b7a 100644 --- a/common/test/acceptance/pages/lms/dashboard.py +++ b/common/test/acceptance/pages/lms/dashboard.py @@ -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() diff --git a/common/test/acceptance/pages/lms/discussion.py b/common/test/acceptance/pages/lms/discussion.py index affe1f83e2..2b2f17d9a8 100644 --- a/common/test/acceptance/pages/lms/discussion.py +++ b/common/test/acceptance/pages/lms/discussion.py @@ -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): diff --git a/common/test/acceptance/pages/lms/fields.py b/common/test/acceptance/pages/lms/fields.py new file mode 100644 index 0000000000..d7e88a417a --- /dev/null +++ b/common/test/acceptance/pages/lms/fields.py @@ -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() diff --git a/common/test/acceptance/pages/lms/learner_profile.py b/common/test/acceptance/pages/lms/learner_profile.py new file mode 100644 index 0000000000..4b2f08f1eb --- /dev/null +++ b/common/test/acceptance/pages/lms/learner_profile.py @@ -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 diff --git a/common/test/acceptance/tests/discussion/test_discussion.py b/common/test/acceptance/tests/discussion/test_discussion.py index 9faa4077c4..597df3ad3f 100644 --- a/common/test/acceptance/tests/discussion/test_discussion.py +++ b/common/test/acceptance/tests/discussion/test_discussion.py @@ -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): diff --git a/common/test/acceptance/tests/helpers.py b/common/test/acceptance/tests/helpers.py index fcf3eefe8f..14bcacf79c 100644 --- a/common/test/acceptance/tests/helpers.py +++ b/common/test/acceptance/tests/helpers.py @@ -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.: 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): diff --git a/common/test/acceptance/tests/lms/test_account_settings.py b/common/test/acceptance/tests/lms/test_account_settings.py new file mode 100644 index 0000000000..d76a746378 --- /dev/null +++ b/common/test/acceptance/tests/lms/test_account_settings.py @@ -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) diff --git a/common/test/acceptance/tests/lms/test_learner_profile.py b/common/test/acceptance/tests/lms/test_learner_profile.py new file mode 100644 index 0000000000..226ddfe87c --- /dev/null +++ b/common/test/acceptance/tests/lms/test_learner_profile.py @@ -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 diff --git a/common/test/acceptance/tests/lms/test_lms.py b/common/test/acceptance/tests/lms/test_lms.py index 488a255195..3537a15e75 100644 --- a/common/test/acceptance/tests/lms/test_lms.py +++ b/common/test/acceptance/tests/lms/test_lms.py @@ -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. diff --git a/common/test/data/uploads/larger_image.jpg b/common/test/data/uploads/larger_image.jpg new file mode 100644 index 0000000000..18a3e1c6f9 Binary files /dev/null and b/common/test/data/uploads/larger_image.jpg differ diff --git a/common/test/data/uploads/list-icon-visited.png b/common/test/data/uploads/list-icon-visited.png new file mode 100644 index 0000000000..a3704f3b98 Binary files /dev/null and b/common/test/data/uploads/list-icon-visited.png differ diff --git a/lms/djangoapps/commerce/tests/test_views.py b/lms/djangoapps/commerce/tests/test_views.py index 3b525689cd..f73110174b 100644 --- a/lms/djangoapps/commerce/tests/test_views.py +++ b/lms/djangoapps/commerce/tests/test_views.py @@ -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. diff --git a/lms/djangoapps/commerce/views.py b/lms/djangoapps/commerce/views.py index 46f0b92574..44b17efaa1 100644 --- a/lms/djangoapps/commerce/views.py +++ b/lms/djangoapps/commerce/views.py @@ -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__) diff --git a/lms/djangoapps/django_comment_client/forum/views.py b/lms/djangoapps/django_comment_client/forum/views.py index 57f61e1de0..1e66a97c9f 100644 --- a/lms/djangoapps/django_comment_client/forum/views.py +++ b/lms/djangoapps/django_comment_client/forum/views.py @@ -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) diff --git a/lms/djangoapps/mobile_api/utils.py b/lms/djangoapps/mobile_api/utils.py index 4d4eed8d26..57dc67c73c 100644 --- a/lms/djangoapps/mobile_api/utils.py +++ b/lms/djangoapps/mobile_api/utils.py @@ -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 diff --git a/lms/djangoapps/student_account/test/test_views.py b/lms/djangoapps/student_account/test/test_views.py index c2c99faf98..880a622b74 100644 --- a/lms/djangoapps/student_account/test/test_views.py +++ b/lms/djangoapps/student_account/test/test_views.py @@ -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) diff --git a/lms/djangoapps/student_account/urls.py b/lms/djangoapps/student_account/urls.py index 4f5eba7c04..a172f25ba4 100644 --- a/lms/djangoapps/student_account/urls.py +++ b/lms/djangoapps/student_account/urls.py @@ -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'), +) diff --git a/lms/djangoapps/student_account/views.py b/lms/djangoapps/student_account/views.py index 47f2a56c47..1838ff2e88 100644 --- a/lms/djangoapps/student_account/views.py +++ b/lms/djangoapps/student_account/views.py @@ -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 diff --git a/lms/djangoapps/student_profile/__init__.py b/lms/djangoapps/student_profile/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/lms/djangoapps/student_profile/test/__init__.py b/lms/djangoapps/student_profile/test/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/lms/djangoapps/student_profile/test/test_views.py b/lms/djangoapps/student_profile/test/test_views.py new file mode 100644 index 0000000000..aa78183108 --- /dev/null +++ b/lms/djangoapps/student_profile/test/test_views.py @@ -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) diff --git a/lms/djangoapps/student_profile/views.py b/lms/djangoapps/student_profile/views.py new file mode 100644 index 0000000000..7ac0b09491 --- /dev/null +++ b/lms/djangoapps/student_profile/views.py @@ -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 diff --git a/lms/envs/aws.py b/lms/envs/aws.py index 4fa99517d0..f095b52f9f 100644 --- a/lms/envs/aws.py +++ b/lms/envs/aws.py @@ -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) diff --git a/lms/envs/bok_choy.auth.json b/lms/envs/bok_choy.auth.json index 4820812192..cd47547ed2 100644 --- a/lms/envs/bok_choy.auth.json +++ b/lms/envs/bok_choy.auth.json @@ -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", diff --git a/lms/envs/bok_choy.py b/lms/envs/bok_choy.py index 8cfd1e48d9..0070b1513f 100644 --- a/lms/envs/bok_choy.py +++ b/lms/envs/bok_choy.py @@ -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: diff --git a/lms/envs/common.py b/lms/envs/common.py index 1930da143c..79b4e9cc57 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -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\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 diff --git a/lms/envs/test.py b/lms/envs/test.py index a74d84514e..c2019fe76e 100644 --- a/lms/envs/test.py +++ b/lms/envs/test.py @@ -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 diff --git a/lms/static/images/default-theme/default-profile_120.png b/lms/static/images/default-theme/default-profile_120.png new file mode 100644 index 0000000000..b3261d08ff Binary files /dev/null and b/lms/static/images/default-theme/default-profile_120.png differ diff --git a/lms/static/images/default-theme/default-profile_30.png b/lms/static/images/default-theme/default-profile_30.png new file mode 100644 index 0000000000..2ce94a348d Binary files /dev/null and b/lms/static/images/default-theme/default-profile_30.png differ diff --git a/lms/static/images/default-theme/default-profile_50.png b/lms/static/images/default-theme/default-profile_50.png new file mode 100644 index 0000000000..cef57edebd Binary files /dev/null and b/lms/static/images/default-theme/default-profile_50.png differ diff --git a/lms/static/images/default-theme/default-profile_500.png b/lms/static/images/default-theme/default-profile_500.png new file mode 100644 index 0000000000..ce3172373a Binary files /dev/null and b/lms/static/images/default-theme/default-profile_500.png differ diff --git a/lms/static/images/edx-theme/default-profile_120.png b/lms/static/images/edx-theme/default-profile_120.png new file mode 100644 index 0000000000..8294f6c97e Binary files /dev/null and b/lms/static/images/edx-theme/default-profile_120.png differ diff --git a/lms/static/images/edx-theme/default-profile_30.png b/lms/static/images/edx-theme/default-profile_30.png new file mode 100644 index 0000000000..8e7fb135c1 Binary files /dev/null and b/lms/static/images/edx-theme/default-profile_30.png differ diff --git a/lms/static/images/edx-theme/default-profile_50.png b/lms/static/images/edx-theme/default-profile_50.png new file mode 100644 index 0000000000..b921c482bf Binary files /dev/null and b/lms/static/images/edx-theme/default-profile_50.png differ diff --git a/lms/static/images/edx-theme/default-profile_500.png b/lms/static/images/edx-theme/default-profile_500.png new file mode 100644 index 0000000000..69dcb956c7 Binary files /dev/null and b/lms/static/images/edx-theme/default-profile_500.png differ diff --git a/lms/static/js/dashboard/legacy.js b/lms/static/js/dashboard/legacy.js index 9abdad1a31..fa0c5eedf1 100644 --- a/lms/static/js/dashboard/legacy.js +++ b/lms/static/js/dashboard/legacy.js @@ -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( - "

" + - gettext("You'll receive a confirmation in your inbox. Please follow the link in the email to confirm your email address change.") + - "

" - ); - } 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); diff --git a/lms/static/js/fixtures/student_profile/student_profile.html b/lms/static/js/fixtures/student_profile/student_profile.html new file mode 100644 index 0000000000..40ae15e5ba --- /dev/null +++ b/lms/static/js/fixtures/student_profile/student_profile.html @@ -0,0 +1,20 @@ +
+
+
+

+ + + + + + Loading + +

+
+ +
diff --git a/lms/static/js/spec/main.js b/lms/static/js/spec/main.js index d3932fc64b..05b9357134 100644 --- a/lms/static/js/spec/main.js +++ b/lms/static/js/spec/main.js @@ -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', diff --git a/lms/static/js/spec/student_account/account_settings_factory_spec.js b/lms/static/js/spec/student_account/account_settings_factory_spec.js new file mode 100644 index 0000000000..6442e400f6 --- /dev/null +++ b/lms/static/js/spec/student_account/account_settings_factory_spec.js @@ -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(''); + 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); + } + }); + }); + }); diff --git a/lms/static/js/spec/student_account/account_settings_fields_helpers.js b/lms/static/js/spec/student_account/account_settings_fields_helpers.js new file mode 100644 index 0000000000..0ee2fa9749 --- /dev/null +++ b/lms/static/js/spec/student_account/account_settings_fields_helpers.js @@ -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 + }; + }); diff --git a/lms/static/js/spec/student_account/account_settings_fields_spec.js b/lms/static/js/spec/student_account/account_settings_fields_spec.js new file mode 100644 index 0000000000..1970e9ebcd --- /dev/null +++ b/lms/static/js/spec/student_account/account_settings_fields_spec.js @@ -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); + }); + }); + }); diff --git a/lms/static/js/spec/student_account/account_settings_view_spec.js b/lms/static/js/spec/student_account/account_settings_view_spec.js new file mode 100644 index 0000000000..c8d0aaa5ff --- /dev/null +++ b/lms/static/js/spec/student_account/account_settings_view_spec.js @@ -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(''); + 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); + }); + + }); + }); diff --git a/lms/static/js/spec/student_account/helpers.js b/lms/static/js/spec/student_account/helpers.js new file mode 100644 index 0000000000..8d0266e515 --- /dev/null +++ b/lms/static/js/spec/student_account/helpers.js @@ -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, + }; +}); diff --git a/lms/static/js/spec/student_account/register_spec.js b/lms/static/js/spec/student_account/register_spec.js index eed77a687d..7b1708bf6b 100644 --- a/lms/static/js/spec/student_account/register_spec.js +++ b/lms/static/js/spec/student_account/register_spec.js @@ -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.', diff --git a/lms/static/js/spec/student_profile/helpers.js b/lms/static/js/spec/student_profile/helpers.js new file mode 100644 index 0000000000..594d9520f2 --- /dev/null +++ b/lms/static/js/spec/student_profile/helpers.js @@ -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 + }; +}); diff --git a/lms/static/js/spec/student_profile/learner_profile_factory_spec.js b/lms/static/js/spec/student_profile/learner_profile_factory_spec.js new file mode 100644 index 0000000000..7d3bd8e1bc --- /dev/null +++ b/lms/static/js/spec/student_profile/learner_profile_factory_spec.js @@ -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); + }); + }); + }); diff --git a/lms/static/js/spec/student_profile/learner_profile_fields_spec.js b/lms/static/js/spec/student_profile/learner_profile_fields_spec.js new file mode 100644 index 0000000000..0d307a2282 --- /dev/null +++ b/lms/static/js/spec/student_profile/learner_profile_fields_spec.js @@ -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); + }); + }); + }); + }); diff --git a/lms/static/js/spec/student_profile/learner_profile_view_spec.js b/lms/static/js/spec/student_profile/learner_profile_view_spec.js new file mode 100644 index 0000000000..2ee04608ee --- /dev/null +++ b/lms/static/js/spec/student_profile/learner_profile_view_spec.js @@ -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); + }); + }); + }); diff --git a/lms/static/js/spec/views/fields_helpers.js b/lms/static/js/spec/views/fields_helpers.js new file mode 100644 index 0000000000..6898ffa067 --- /dev/null +++ b/lms/static/js/spec/views/fields_helpers.js @@ -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 + }; + }); diff --git a/lms/static/js/spec/views/fields_spec.js b/lms/static/js/spec/views/fields_spec.js new file mode 100644 index 0000000000..06a3e6bb74 --- /dev/null +++ b/lms/static/js/spec/views/fields_spec.js @@ -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 you’re 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 you’re 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); + }); + }); + }); diff --git a/lms/static/js/spec/views/message_banner_spec.js b/lms/static/js/spec/views/message_banner_spec.js new file mode 100644 index 0000000000..b5f01cd3ac --- /dev/null +++ b/lms/static/js/spec/views/message_banner_spec.js @@ -0,0 +1,27 @@ +define(['backbone', 'jquery', 'underscore', 'js/views/message_banner' + ], + function (Backbone, $, _, MessageBannerView) { + 'use strict'; + + describe("MessageBannerView", function () { + + beforeEach(function () { + setFixtures('
'); + 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(''); + }); + }); + }); diff --git a/lms/static/js/student_account/models/user_account_model.js b/lms/static/js/student_account/models/user_account_model.js new file mode 100644 index 0000000000..e847d0ba88 --- /dev/null +++ b/lms/static/js/student_account/models/user_account_model.js @@ -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); diff --git a/lms/static/js/student_account/models/user_preferences_model.js b/lms/static/js/student_account/models/user_preferences_model.js new file mode 100644 index 0000000000..af421013b3 --- /dev/null +++ b/lms/static/js/student_account/models/user_preferences_model.js @@ -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); diff --git a/lms/static/js/student_account/views/account_settings_factory.js b/lms/static/js/student_account/views/account_settings_factory.js new file mode 100644 index 0000000000..af46599688 --- /dev/null +++ b/lms/static/js/student_account/views/account_settings_factory.js @@ -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); diff --git a/lms/static/js/student_account/views/account_settings_fields.js b/lms/static/js/student_account/views/account_settings_fields.js new file mode 100644 index 0000000000..159ad4059b --- /dev/null +++ b/lms/static/js/student_account/views/account_settings_fields.js @@ -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); diff --git a/lms/static/js/student_account/views/account_settings_view.js b/lms/static/js/student_account/views/account_settings_view.js new file mode 100644 index 0000000000..15f577f05f --- /dev/null +++ b/lms/static/js/student_account/views/account_settings_view.js @@ -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); diff --git a/lms/static/js/student_profile/views/learner_profile_factory.js b/lms/static/js/student_profile/views/learner_profile_factory.js new file mode 100644 index 0000000000..4b88bc7672 --- /dev/null +++ b/lms/static/js/student_profile/views/learner_profile_factory.js @@ -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); diff --git a/lms/static/js/student_profile/views/learner_profile_fields.js b/lms/static/js/student_profile/views/learner_profile_fields.js new file mode 100644 index 0000000000..243585fc36 --- /dev/null +++ b/lms/static/js/student_profile/views/learner_profile_fields.js @@ -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 = '' + gettext('Account Settings page.') + ''; + 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); diff --git a/lms/static/js/student_profile/views/learner_profile_view.js b/lms/static/js/student_profile/views/learner_profile_view.js new file mode 100644 index 0000000000..9aa6d0e441 --- /dev/null +++ b/lms/static/js/student_profile/views/learner_profile_view.js @@ -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); diff --git a/lms/static/js/vendor/backbone-super.js b/lms/static/js/vendor/backbone-super.js new file mode 100644 index 0000000000..49f95842a5 --- /dev/null +++ b/lms/static/js/vendor/backbone-super.js @@ -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; +})); + diff --git a/lms/static/js/views/fields.js b/lms/static/js/views/fields.js new file mode 100644 index 0000000000..1303b9bfc5 --- /dev/null +++ b/lms/static/js/views/fields.js @@ -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': '' + gettext("Editable") + '', + 'error': '' + gettext("Error") + '', + 'validationError': '' + gettext("Validation Error") + '', + 'inProgress': '' + gettext("In Progress") + '', + 'success': '' + gettext("Success") + '', + 'plus': '' + gettext("Placeholder")+ '' + }, + + 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: '', + iconRemove: '', + iconProgress: '', + + 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); diff --git a/lms/static/js/views/message_banner.js b/lms/static/js/views/message_banner.js new file mode 100644 index 0000000000..87e36fafa0 --- /dev/null +++ b/lms/static/js/views/message_banner.js @@ -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); diff --git a/lms/static/js_test.yml b/lms/static/js_test.yml index 054e828e05..53badbe193 100644 --- a/lms/static/js_test.yml +++ b/lms/static/js_test.yml @@ -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 diff --git a/lms/static/require-config-lms.js b/lms/static/require-config-lms.js index 3530432f09..0e4b9ae82c 100644 --- a/lms/static/require-config-lms.js +++ b/lms/static/require-config-lms.js @@ -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" }, diff --git a/lms/static/sass/_developer.scss b/lms/static/sass/_developer.scss index b6c643b8d5..434add48ae 100644 --- a/lms/static/sass/_developer.scss +++ b/lms/static/sass/_developer.scss @@ -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; +} diff --git a/lms/static/sass/application-extend2-rtl.scss.mako b/lms/static/sass/application-extend2-rtl.scss.mako index 89ba77d76c..a2dd1f71bc 100644 --- a/lms/static/sass/application-extend2-rtl.scss.mako +++ b/lms/static/sass/application-extend2-rtl.scss.mako @@ -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'; diff --git a/lms/static/sass/application-extend2.scss.mako b/lms/static/sass/application-extend2.scss.mako index e290603a4a..989232e8aa 100644 --- a/lms/static/sass/application-extend2.scss.mako +++ b/lms/static/sass/application-extend2.scss.mako @@ -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'; diff --git a/lms/static/sass/application-rtl.scss.mako b/lms/static/sass/application-rtl.scss.mako index 2336fe85c0..256ffb595d 100644 --- a/lms/static/sass/application-rtl.scss.mako +++ b/lms/static/sass/application-rtl.scss.mako @@ -43,6 +43,7 @@ @import 'elements/controls'; // shared - course +@import 'shared/fields'; @import 'shared/forms'; @import 'shared/footer'; @import 'shared/header'; diff --git a/lms/static/sass/application.scss.mako b/lms/static/sass/application.scss.mako index 80a8e95a83..43113471c8 100644 --- a/lms/static/sass/application.scss.mako +++ b/lms/static/sass/application.scss.mako @@ -43,6 +43,7 @@ @import 'elements/controls'; // shared - course +@import 'shared/fields'; @import 'shared/forms'; @import 'shared/footer'; @import 'shared/header'; diff --git a/lms/static/sass/multicourse/_dashboard.scss b/lms/static/sass/multicourse/_dashboard.scss index 10727a9771..950c9fb7cf 100644 --- a/lms/static/sass/multicourse/_dashboard.scss +++ b/lms/static/sass/multicourse/_dashboard.scss @@ -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 { diff --git a/lms/static/sass/shared/_fields.scss b/lms/static/sass/shared/_fields.scss new file mode 100644 index 0000000000..f277e96114 --- /dev/null +++ b/lms/static/sass/shared/_fields.scss @@ -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; + } +} diff --git a/lms/static/sass/views/_account-settings.scss b/lms/static/sass/views/_account-settings.scss new file mode 100644 index 0000000000..a5d2f7b2f0 --- /dev/null +++ b/lms/static/sass/views/_account-settings.scss @@ -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; + } + } +} diff --git a/lms/static/sass/views/_learner-profile.scss b/lms/static/sass/views/_learner-profile.scss new file mode 100644 index 0000000000..9d7e5469c7 --- /dev/null +++ b/lms/static/sass/views/_learner-profile.scss @@ -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; + } + } + } +} diff --git a/lms/templates/dashboard.html b/lms/templates/dashboard.html index 04f208c489..1c10c822fc 100644 --- a/lms/templates/dashboard.html +++ b/lms/templates/dashboard.html @@ -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 @@
%endif - % if duplicate_provider: -
- <%include file='dashboard/_dashboard_third_party_error.html' /> -
- % endif - %if enrollment_message:
${enrollment_message} @@ -78,7 +69,8 @@

${_("Current Courses")}

- + + % if len(course_enrollment_pairs) > 0:
-
-

${_("Username")}: ${ user.username }

-
- - - - -<%include file='modal/_modal-settings-language.html' /> - - -