From 0a962bf991344a72269dd145930e2260d825da50 Mon Sep 17 00:00:00 2001 From: cahrens Date: Thu, 12 Mar 2015 16:39:22 -0400 Subject: [PATCH 01/53] Add field that was missing from re-ordered migration --- ...eexamconfiguration__add_unique_entranceexamconfigurat.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) 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'}, From fad63938e588a67259dd28ef76886221fd18e642 Mon Sep 17 00:00:00 2001 From: cahrens Date: Thu, 12 Mar 2015 16:33:42 -0400 Subject: [PATCH 02/53] New bio field in UserProfile. --- .../student/migrations/0047_add_bio_field.py | 194 ++++++++++++++++++ common/djangoapps/student/models.py | 1 + .../user_api/accounts/serializers.py | 3 +- .../user_api/accounts/tests/test_views.py | 10 +- .../djangoapps/user_api/accounts/views.py | 19 +- 5 files changed, 219 insertions(+), 8 deletions(-) create mode 100644 common/djangoapps/student/migrations/0047_add_bio_field.py 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..ba1bfc1a98 --- /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.TextField')(null=True, blank=True), + 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.TextField', [], {'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/models.py b/common/djangoapps/student/models.py index cc5a3f9fd4..3080181a6d 100644 --- a/common/djangoapps/student/models.py +++ b/common/djangoapps/student/models.py @@ -249,6 +249,7 @@ 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.TextField(blank=True, null=True) def get_meta(self): # pylint: disable=missing-docstring js_str = self.meta diff --git a/openedx/core/djangoapps/user_api/accounts/serializers.py b/openedx/core/djangoapps/user_api/accounts/serializers.py index 8cef22a90a..40b572cc9c 100644 --- a/openedx/core/djangoapps/user_api/accounts/serializers.py +++ b/openedx/core/djangoapps/user_api/accounts/serializers.py @@ -21,7 +21,8 @@ class AccountLegacyProfileSerializer(serializers.HyperlinkedModelSerializer): class Meta: model = UserProfile fields = ( - "name", "gender", "goals", "year_of_birth", "level_of_education", "language", "country", "mailing_address" + "name", "gender", "goals", "year_of_birth", "level_of_education", "language", "country", + "mailing_address", "bio" ) # Currently no read-only field, but keep this so view code doesn't need to know. read_only_fields = () diff --git a/openedx/core/djangoapps/user_api/accounts/tests/test_views.py b/openedx/core/djangoapps/user_api/accounts/tests/test_views.py index 09c406779a..38e62f2577 100644 --- a/openedx/core/djangoapps/user_api/accounts/tests/test_views.py +++ b/openedx/core/djangoapps/user_api/accounts/tests/test_views.py @@ -85,6 +85,7 @@ class UserAPITestCase(APITestCase): legacy_profile.year_of_birth = 1900 legacy_profile.goals = "world peace" legacy_profile.mailing_address = "Park Ave" + legacy_profile.bio = "Tired mother of twins" legacy_profile.save() @@ -110,7 +111,7 @@ class TestAccountAPI(UserAPITestCase): self.assertIsNone(data["profile_image"]) self.assertIsNone(data["time_zone"]) self.assertIsNone(data["languages"]) - self.assertIsNone(data["bio"]) + self.assertEqual("Tired mother of twins", data["bio"]) def _verify_private_account_response(self, response): """ @@ -139,6 +140,7 @@ class TestAccountAPI(UserAPITestCase): self.assertEqual(self.user.email, data["email"]) self.assertTrue(data["is_active"]) self.assertIsNotNone(data["date_joined"]) + self.assertEqual("Tired mother of twins", data["bio"]) def test_anonymous_access(self): """ @@ -243,7 +245,7 @@ class TestAccountAPI(UserAPITestCase): self.assertEqual(12, len(data)) self.assertEqual(self.user.username, data["username"]) self.assertEqual(self.user.first_name + " " + self.user.last_name, data["name"]) - for empty_field in ("year_of_birth", "level_of_education", "mailing_address"): + for empty_field in ("year_of_birth", "level_of_education", "mailing_address", "bio"): self.assertIsNone(data[empty_field]) self.assertIsNone(data["country"]) # TODO: what should the format of this be? @@ -316,6 +318,10 @@ class TestAccountAPI(UserAPITestCase): ("language", "Creole"), ("goals", "Smell the roses"), ("mailing_address", "Sesame Street"), + ("bio", "Lacrosse-playing superhero"), + ("bio", u"壓是進界推日不復女"), + # Note that we store the raw data, so it is up to client to escape the HTML. + ("bio", "fancy text"), # Note that email is tested below, as it is not immediately updated. ) @ddt.unpack diff --git a/openedx/core/djangoapps/user_api/accounts/views.py b/openedx/core/djangoapps/user_api/accounts/views.py index 4bc9f9d57d..7d969127a5 100644 --- a/openedx/core/djangoapps/user_api/accounts/views.py +++ b/openedx/core/djangoapps/user_api/accounts/views.py @@ -38,12 +38,13 @@ class AccountView(APIView): * name: The full name of the user. - * email: The confirmed email address for the user. The request - will not return an unconfirmed email address. + * email: email for the user (the new email address must be + confirmed via a confirmation email, so GET will not reflect + the change until the address has been confirmed). - * date_joined: The date the account was created, in - the string format provided by datetime (for example, - "2014-08-26T17:52:11Z"). + * date_joined: The date the account was created, in the string + format provided by datetime. + For example, "2014-08-26T17:52:11Z". * gender: One of the fullowing values: @@ -77,6 +78,14 @@ class AccountView(APIView): * goals: The textual representation of the user's goals, or null. + * bio: null or textural representation of user biographical + information ("about me") + + For all text fields, clients rendering the values should take care + to HTML escape them to avoid script injections, as the data is + stored exactly as specified. The intention is that plain text is + supported, not HTML. + If a user who does not have "is_staff" access requests account information for a different user, only a subset of these fields is returned. The fields returned depend on the configuration setting From 6f6fdbfb5f24e7b1ef9ddabc3e92b9210c8375d2 Mon Sep 17 00:00:00 2001 From: Daniel Friedman Date: Thu, 12 Mar 2015 18:07:56 -0400 Subject: [PATCH 03/53] Integrate profile images into Accounts API TNL-1545 --- cms/envs/common.py | 7 +- .../0048_add_has_profile_image_boolean.py | 195 ++++++++++++++++++ common/djangoapps/student/models.py | 1 + lms/envs/common.py | 19 +- lms/envs/test.py | 7 + .../core/djangoapps/user_api/accounts/api.py | 2 +- .../djangoapps/user_api/accounts/helpers.py | 57 +++++ .../user_api/accounts/serializers.py | 27 ++- .../user_api/accounts/tests/test_api.py | 6 + .../user_api/accounts/tests/test_helpers.py | 63 ++++++ .../user_api/accounts/tests/test_views.py | 57 ++++- .../djangoapps/user_api/accounts/views.py | 5 + .../core/djangoapps/user_api/serializers.py | 18 ++ .../djangoapps/user_api/tests/test_views.py | 1 - 14 files changed, 452 insertions(+), 13 deletions(-) create mode 100644 common/djangoapps/student/migrations/0048_add_has_profile_image_boolean.py create mode 100644 openedx/core/djangoapps/user_api/accounts/helpers.py create mode 100644 openedx/core/djangoapps/user_api/accounts/tests/test_helpers.py diff --git a/cms/envs/common.py b/cms/envs/common.py index f9c7d84065..6aff9c2546 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, + # 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_DOMAIN, PROFILE_IMAGE_URL_PATH, PROFILE_IMAGE_DEFAULT_FILENAME, + PROFILE_IMAGE_SECRET_KEY ) from path import path from warnings import simplefilter diff --git a/common/djangoapps/student/migrations/0048_add_has_profile_image_boolean.py b/common/djangoapps/student/migrations/0048_add_has_profile_image_boolean.py new file mode 100644 index 0000000000..a5f3ebf4a1 --- /dev/null +++ b/common/djangoapps/student/migrations/0048_add_has_profile_image_boolean.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.has_profile_image' + db.add_column('auth_userprofile', 'has_profile_image', + self.gf('django.db.models.fields.BooleanField')(default=False), + keep_default=False) + + + def backwards(self, orm): + # Deleting field 'UserProfile.has_profile_image' + db.delete_column('auth_userprofile', 'has_profile_image') + + + 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.TextField', [], {'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'] \ No newline at end of file diff --git a/common/djangoapps/student/models.py b/common/djangoapps/student/models.py index 3080181a6d..a42a06fee5 100644 --- a/common/djangoapps/student/models.py +++ b/common/djangoapps/student/models.py @@ -250,6 +250,7 @@ class UserProfile(models.Model): goals = models.TextField(blank=True, null=True) allow_certificate = models.BooleanField(default=1) bio = models.TextField(blank=True, null=True) + has_profile_image = models.BooleanField(default=0) def get_meta(self): # pylint: disable=missing-docstring js_str = self.meta diff --git a/lms/envs/common.py b/lms/envs/common.py index 1930da143c..d399640d35 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -318,7 +318,7 @@ FEATURES = { 'ENABLE_COURSE_SORTING_BY_START_DATE': True, # Flag to enable new user account APIs. - 'ENABLE_USER_REST_API': False, + 'ENABLE_USER_REST_API': True, # Expose Mobile REST API. Note that if you use this, you must also set # ENABLE_OAUTH2_PROVIDER to True @@ -2248,3 +2248,20 @@ CHECKPOINT_PATTERN = r'(?P\w+)' # 'courseware.student_field_overrides.IndividualStudentOverrideProvider' to # this setting. FIELD_OVERRIDE_PROVIDERS = () + +# PROFILE IMAGE CONFIG +# TODO: add these settings to aws.py as well +PROFILE_IMAGE_BACKEND = 'django.core.files.storage.FileSystemStorage' +# PROFILE_IMAGE_DOMAIN points to the domain from which we serve image +# files from. When this is '/', it refers to the same domain as the +# app server. If serving from a different domain, specify that here +# i.e. 'http://www.example-image-server.com/' +PROFILE_IMAGE_DOMAIN = '/' +PROFILE_IMAGE_URL_PATH = 'media/profile_images/' +PROFILE_IMAGE_DEFAULT_FILENAME = 'default_profile_image' # TODO: determine final name +# 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' + diff --git a/lms/envs/test.py b/lms/envs/test.py index a74d84514e..7658867fef 100644 --- a/lms/envs/test.py +++ b/lms/envs/test.py @@ -473,3 +473,10 @@ 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 = 'django.core.files.storage.FileSystemStorage' +PROFILE_IMAGE_DOMAIN = 'http://example-storage.com/' +PROFILE_IMAGE_URL_PATH = 'profile_images/' +PROFILE_IMAGE_DEFAULT_FILENAME = 'default' +PROFILE_IMAGE_SECRET_KEY = 'secret' diff --git a/openedx/core/djangoapps/user_api/accounts/api.py b/openedx/core/djangoapps/user_api/accounts/api.py index a11eab68e8..7913aecd18 100644 --- a/openedx/core/djangoapps/user_api/accounts/api.py +++ b/openedx/core/djangoapps/user_api/accounts/api.py @@ -144,7 +144,7 @@ def update_account_settings(requesting_user, update, username=None): # Check for fields that are not editable. Marking them read-only causes them to be ignored, but we wish to 400. read_only_fields = set(update.keys()).intersection( - AccountUserSerializer.Meta.read_only_fields + AccountLegacyProfileSerializer.Meta.read_only_fields + AccountUserSerializer.get_read_only_fields() + AccountLegacyProfileSerializer.get_read_only_fields() ) # Build up all field errors, whether read-only, validation, or email errors. diff --git a/openedx/core/djangoapps/user_api/accounts/helpers.py b/openedx/core/djangoapps/user_api/accounts/helpers.py new file mode 100644 index 0000000000..836ace7d94 --- /dev/null +++ b/openedx/core/djangoapps/user_api/accounts/helpers.py @@ -0,0 +1,57 @@ +""" +Helper functions for the accounts API. +""" +import hashlib + +from django.conf import settings +from django.core.files.storage import FileSystemStorage, get_storage_class + +PROFILE_IMAGE_SIZES_MAP = { + 'full': 500, + 'large': 120, + 'medium': 50, + 'small': 30 +} +_PROFILE_IMAGE_SIZES = PROFILE_IMAGE_SIZES_MAP.values() +PROFILE_IMAGE_FORMAT = 'jpg' + + +def get_profile_image_url_for_user(user, size): + """Return the URL to a user's profile image for a given size. + Note that based on the value of + django.conf.settings.PROFILE_IMAGE_DOMAIN, the URL may be relative, + and in that case the caller is responsible for constructing the full + URL. + + If the user has not yet uploaded a profile image, return the URL to + the default edX user profile image. + + Arguments: + user (django.auth.User): The user for whom we're generating a + profile image URL. + + Returns: + string: The URL for the user's profile image. + + Raises: + ValueError: The caller asked for an unsupported image size. + """ + if size not in _PROFILE_IMAGE_SIZES: + raise ValueError('Unsupported profile image size: {size}'.format(size=size)) + + if user.profile.has_profile_image: + name = hashlib.md5(settings.PROFILE_IMAGE_SECRET_KEY + user.username).hexdigest() + else: + name = settings.PROFILE_IMAGE_DEFAULT_FILENAME + + filename = '{name}_{size}.{format}'.format(name=name, size=size, format=PROFILE_IMAGE_FORMAT) + + # Note that, for now, the backend will be FileSystemStorage. When + # we eventually support s3 storage, we'll need to pass a parameter + # to the storage class indicating the s3 bucket which we're using + # for profile picture uploads. + storage_class = get_storage_class(settings.PROFILE_IMAGE_BACKEND) + if storage_class == FileSystemStorage: + kwargs = {'base_url': (settings.PROFILE_IMAGE_DOMAIN + settings.PROFILE_IMAGE_URL_PATH)} + storage = storage_class(**kwargs) + return storage.url(filename) diff --git a/openedx/core/djangoapps/user_api/accounts/serializers.py b/openedx/core/djangoapps/user_api/accounts/serializers.py index 40b572cc9c..2de8ac0319 100644 --- a/openedx/core/djangoapps/user_api/accounts/serializers.py +++ b/openedx/core/djangoapps/user_api/accounts/serializers.py @@ -1,10 +1,15 @@ from rest_framework import serializers from django.contrib.auth.models import User -from student.models import UserProfile from openedx.core.djangoapps.user_api.accounts import NAME_MIN_LENGTH +from openedx.core.djangoapps.user_api.serializers import ReadOnlyFieldsSerializerMixin + +from student.models import UserProfile +from .helpers import get_profile_image_url_for_user, PROFILE_IMAGE_SIZES_MAP + +PROFILE_IMAGE_KEY_PREFIX = 'image_url' -class AccountUserSerializer(serializers.HyperlinkedModelSerializer): +class AccountUserSerializer(serializers.HyperlinkedModelSerializer, ReadOnlyFieldsSerializerMixin): """ Class that serializes the portion of User model needed for account information. """ @@ -12,20 +17,24 @@ class AccountUserSerializer(serializers.HyperlinkedModelSerializer): model = User fields = ("username", "email", "date_joined", "is_active") read_only_fields = ("username", "email", "date_joined", "is_active") + explicit_read_only_fields = () -class AccountLegacyProfileSerializer(serializers.HyperlinkedModelSerializer): +class AccountLegacyProfileSerializer(serializers.HyperlinkedModelSerializer, ReadOnlyFieldsSerializerMixin): """ Class that serializes the portion of UserProfile model needed for account information. """ + profile_image = serializers.SerializerMethodField("get_profile_image") + class Meta: model = UserProfile fields = ( "name", "gender", "goals", "year_of_birth", "level_of_education", "language", "country", - "mailing_address", "bio" + "mailing_address", "bio", "profile_image" ) # Currently no read-only field, but keep this so view code doesn't need to know. read_only_fields = () + explicit_read_only_fields = ("profile_image",) def validate_name(self, attrs, source): """ Enforce minimum length for name. """ @@ -55,3 +64,13 @@ class AccountLegacyProfileSerializer(serializers.HyperlinkedModelSerializer): def convert_empty_to_None(value): """ Helper method to convert empty string to None (other values pass through). """ return None if value == "" else value + + def get_profile_image(self, obj): + """ Returns metadata about a user's profile image. """ + data = {'has_image': obj.has_profile_image} + data.update({ + '{image_key_prefix}_{size}'.format(image_key_prefix=PROFILE_IMAGE_KEY_PREFIX, size=size_display_name): + get_profile_image_url_for_user(obj.user, size_value) + for size_display_name, size_value in PROFILE_IMAGE_SIZES_MAP.items() + }) + return data diff --git a/openedx/core/djangoapps/user_api/accounts/tests/test_api.py b/openedx/core/djangoapps/user_api/accounts/tests/test_api.py index 0fb92da9e9..6f2d709f2f 100644 --- a/openedx/core/djangoapps/user_api/accounts/tests/test_api.py +++ b/openedx/core/djangoapps/user_api/accounts/tests/test_api.py @@ -117,6 +117,12 @@ class TestAccountApi(TestCase): with self.assertRaises(AccountValidationError): update_account_settings(self.user, {"gender": "undecided"}) + with self.assertRaises(AccountValidationError): + update_account_settings( + self.user, + {"profile_image": {"has_image": "not_allowed", "image_url": "not_allowed"}} + ) + def test_update_multiple_validation_errors(self): """Test that all validation errors are built up and returned at once""" # Send a read-only error, serializer error, and email validation error. diff --git a/openedx/core/djangoapps/user_api/accounts/tests/test_helpers.py b/openedx/core/djangoapps/user_api/accounts/tests/test_helpers.py new file mode 100644 index 0000000000..af8e2150e9 --- /dev/null +++ b/openedx/core/djangoapps/user_api/accounts/tests/test_helpers.py @@ -0,0 +1,63 @@ +""" +Tests for helpers.py +""" +from ddt import ddt, data +import hashlib +from mock import patch +from unittest import skipUnless + +from django.conf import settings +from django.test import TestCase + +from openedx.core.djangoapps.user_api.accounts.helpers import get_profile_image_url_for_user +from student.tests.factories import UserFactory + + +@ddt +@patch('openedx.core.djangoapps.user_api.accounts.helpers._PROFILE_IMAGE_SIZES', [50, 10]) +@patch.dict( + 'openedx.core.djangoapps.user_api.accounts.helpers.PROFILE_IMAGE_SIZES_MAP', {'full': 50, 'small': 10}, clear=True +) +@skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms') +class ProfileImageUrlTestCase(TestCase): + """ + Tests for `get_profile_image_url_for_user`. + """ + def setUp(self): + super(ProfileImageUrlTestCase, self).setUp() + self.user = UserFactory() + + def verify_url(self, user, pixels, filename): + """ + Helper method to verify that we're correctly generating profile + image URLs. + """ + self.assertEqual( + get_profile_image_url_for_user(user, pixels), + 'http://example-storage.com/profile_images/{filename}_{pixels}.jpg'.format(filename=filename, pixels=pixels) + ) + + @data(10, 50) + def test_profile_image_urls(self, pixels): + """ + Verify we get the URL to the default image if the user does not + have a profile image. + """ + # By default new users will have no profile image. + self.verify_url(self.user, pixels, 'default') + # A user can add an image, then remove it. We should get the + # default image URL in that case. + self.user.profile.has_profile_image = True + self.user.profile.save() + self.verify_url(self.user, pixels, hashlib.md5('secret' + self.user.username).hexdigest()) + self.user.profile.has_profile_image = False + self.user.profile.save() + self.verify_url(self.user, pixels, 'default') + + @data(1, 5000) + def test_unsupported_sizes(self, image_size): + """ + Verify that we cannot ask for image sizes which are unsupported. + """ + with self.assertRaises(ValueError): + get_profile_image_url_for_user(self.user, image_size) diff --git a/openedx/core/djangoapps/user_api/accounts/tests/test_views.py b/openedx/core/djangoapps/user_api/accounts/tests/test_views.py index 38e62f2577..fa23742aec 100644 --- a/openedx/core/djangoapps/user_api/accounts/tests/test_views.py +++ b/openedx/core/djangoapps/user_api/accounts/tests/test_views.py @@ -1,12 +1,14 @@ # -*- coding: utf-8 -*- -import unittest import ddt +import hashlib import json from mock import patch +import unittest from django.conf import settings from django.core.urlresolvers import reverse from django.test.testcases import TransactionTestCase +from django.test.utils import override_settings from rest_framework.test import APITestCase, APIClient from student.tests.factories import UserFactory @@ -86,11 +88,16 @@ class UserAPITestCase(APITestCase): legacy_profile.goals = "world peace" legacy_profile.mailing_address = "Park Ave" legacy_profile.bio = "Tired mother of twins" + legacy_profile.has_profile_image = True legacy_profile.save() @ddt.ddt @unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Account APIs are only supported in LMS') +@patch('openedx.core.djangoapps.user_api.accounts.helpers._PROFILE_IMAGE_SIZES', [50, 10]) +@patch.dict( + 'openedx.core.djangoapps.user_api.accounts.helpers.PROFILE_IMAGE_SIZES_MAP', {'full': 50, 'small': 10}, clear=True +) class TestAccountAPI(UserAPITestCase): """ Unit tests for the Account API. @@ -100,6 +107,25 @@ class TestAccountAPI(UserAPITestCase): self.url = reverse("accounts_api", kwargs={'username': self.user.username}) + def _verify_profile_image_data(self, data, has_profile_image): + """ + Verify the profile image data in a GET response for self.user + corresponds to whether the user has or hasn't set a profile + image. + """ + if has_profile_image: + filename = hashlib.md5('secret' + self.user.username).hexdigest() + else: + filename = 'default' + self.assertEqual( + data['profile_image'], + { + 'has_image': has_profile_image, + 'image_url_full': 'http://example-storage.com/profile_images/{}_50.jpg'.format(filename), + 'image_url_small': 'http://example-storage.com/profile_images/{}_10.jpg'.format(filename) + } + ) + def _verify_full_shareable_account_response(self, response): """ Verify that the shareable fields from the account are returned @@ -108,7 +134,7 @@ class TestAccountAPI(UserAPITestCase): self.assertEqual(6, len(data)) self.assertEqual(self.user.username, data["username"]) self.assertEqual("US", data["country"]) - self.assertIsNone(data["profile_image"]) + self._verify_profile_image_data(data, True) self.assertIsNone(data["time_zone"]) self.assertIsNone(data["languages"]) self.assertEqual("Tired mother of twins", data["bio"]) @@ -120,14 +146,14 @@ class TestAccountAPI(UserAPITestCase): data = response.data self.assertEqual(2, len(data)) self.assertEqual(self.user.username, data["username"]) - self.assertIsNone(data["profile_image"]) + self._verify_profile_image_data(data, True) def _verify_full_account_response(self, response): """ Verify that all account fields are returned (even those that are not shareable). """ data = response.data - self.assertEqual(12, len(data)) + self.assertEqual(13, len(data)) self.assertEqual(self.user.username, data["username"]) self.assertEqual(self.user.first_name + " " + self.user.last_name, data["name"]) self.assertEqual("US", data["country"]) @@ -141,6 +167,7 @@ class TestAccountAPI(UserAPITestCase): self.assertTrue(data["is_active"]) self.assertIsNotNone(data["date_joined"]) self.assertEqual("Tired mother of twins", data["bio"]) + self._verify_profile_image_data(data, True) def test_anonymous_access(self): """ @@ -242,7 +269,7 @@ class TestAccountAPI(UserAPITestCase): def verify_get_own_information(): response = self.send_get(self.client) data = response.data - self.assertEqual(12, len(data)) + self.assertEqual(13, len(data)) self.assertEqual(self.user.username, data["username"]) self.assertEqual(self.user.first_name + " " + self.user.last_name, data["name"]) for empty_field in ("year_of_birth", "level_of_education", "mailing_address", "bio"): @@ -255,6 +282,7 @@ class TestAccountAPI(UserAPITestCase): self.assertEqual(self.user.email, data["email"]) self.assertIsNotNone(data["date_joined"]) self.assertEqual(self.user.is_active, data["is_active"]) + self._verify_profile_image_data(data, False) self.client.login(username=self.user.username, password=self.test_password) verify_get_own_information() @@ -516,6 +544,25 @@ class TestAccountAPI(UserAPITestCase): error_response.data["developer_message"] ) self.assertIsNone(error_response.data["user_message"]) + + @override_settings(PROFILE_IMAGE_DOMAIN='/') + def test_convert_relative_profile_url(self): + """ + Test that when PROFILE_IMAGE_DOMAIN is set to '/', the API + generates the full URL to profile images based on the URL + of the request. + """ + self.client.login(username=self.user.username, password=self.test_password) + response = self.send_get(self.client) + # pylint: disable=no-member + self.assertEqual( + response.data["profile_image"], + { + "has_image": False, + "image_url_full": "http://testserver/profile_images/default_50.jpg", + "image_url_small": "http://testserver/profile_images/default_10.jpg" + } + ) @unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms') diff --git a/openedx/core/djangoapps/user_api/accounts/views.py b/openedx/core/djangoapps/user_api/accounts/views.py index 7d969127a5..efaab726ff 100644 --- a/openedx/core/djangoapps/user_api/accounts/views.py +++ b/openedx/core/djangoapps/user_api/accounts/views.py @@ -14,6 +14,7 @@ from rest_framework import permissions from ..errors import UserNotFound, UserNotAuthorized, AccountUpdateError, AccountValidationError from openedx.core.lib.api.parsers import MergePatchParser from .api import get_account_settings, update_account_settings +from .serializers import PROFILE_IMAGE_KEY_PREFIX class AccountView(APIView): @@ -131,6 +132,10 @@ class AccountView(APIView): """ try: account_settings = get_account_settings(request.user, username, view=request.QUERY_PARAMS.get('view')) + # Account for possibly relative URLs. + for key, value in account_settings['profile_image'].items(): + if key.startswith(PROFILE_IMAGE_KEY_PREFIX): + account_settings['profile_image'][key] = request.build_absolute_uri(value) except UserNotFound: return Response(status=status.HTTP_404_NOT_FOUND) diff --git a/openedx/core/djangoapps/user_api/serializers.py b/openedx/core/djangoapps/user_api/serializers.py index 12ba2c1d92..fefee20e53 100644 --- a/openedx/core/djangoapps/user_api/serializers.py +++ b/openedx/core/djangoapps/user_api/serializers.py @@ -39,3 +39,21 @@ class RawUserPreferenceSerializer(serializers.ModelSerializer): class Meta: model = UserPreference depth = 1 + + +class ReadOnlyFieldsSerializerMixin(object): + """ + Mixin for use with Serializers that provides a method + `get_read_only_fields`, which returns a tuple of all read-only + fields on the Serializer. + """ + @classmethod + def get_read_only_fields(cls): + """ + Return all fields on this Serializer class which are read-only. + Expects sub-classes implement Meta.explicit_read_only_fields, + which is a tuple declaring read-only fields which were declared + explicitly and thus could not be added to the usual + cls.Meta.read_only_fields tuple. + """ + return getattr(cls.Meta, 'read_only_fields', '') + getattr(cls.Meta, 'explicit_read_only_fields', '') diff --git a/openedx/core/djangoapps/user_api/tests/test_views.py b/openedx/core/djangoapps/user_api/tests/test_views.py index 0337b2cbcf..464b5c9ed7 100644 --- a/openedx/core/djangoapps/user_api/tests/test_views.py +++ b/openedx/core/djangoapps/user_api/tests/test_views.py @@ -1693,7 +1693,6 @@ class TestGoogleRegistrationView( """Tests the User API registration endpoint with Google authentication.""" pass - @ddt.ddt class UpdateEmailOptInTestCase(ApiTestCase, ModuleStoreTestCase): """Tests the UpdateEmailOptInPreference view. """ From 8e08ff52ffb9d58f027c3189bc654654d8bdb76e Mon Sep 17 00:00:00 2001 From: Daniel Friedman Date: Tue, 17 Mar 2015 10:32:12 -0400 Subject: [PATCH 04/53] Rebase cleanup --- .../djangoapps/user_api/accounts/tests/test_api.py | 11 +++++++++++ .../djangoapps/user_api/accounts/tests/test_views.py | 4 ++-- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/openedx/core/djangoapps/user_api/accounts/tests/test_api.py b/openedx/core/djangoapps/user_api/accounts/tests/test_api.py index 6f2d709f2f..63c43cc280 100644 --- a/openedx/core/djangoapps/user_api/accounts/tests/test_api.py +++ b/openedx/core/djangoapps/user_api/accounts/tests/test_api.py @@ -178,6 +178,11 @@ class TestAccountApi(TestCase): self.assertEqual(0, len(pending_change)) +@patch('openedx.core.djangoapps.user_api.accounts.helpers._PROFILE_IMAGE_SIZES', [50, 10]) +@patch.dict( + 'openedx.core.djangoapps.user_api.accounts.helpers.PROFILE_IMAGE_SIZES_MAP', {'full': 50, 'small': 10}, clear=True +) +@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Account APIs are only supported in LMS') class AccountSettingsOnCreationTest(TestCase): USERNAME = u'frank-underwood' @@ -209,6 +214,12 @@ class AccountSettingsOnCreationTest(TestCase): 'mailing_address': None, 'year_of_birth': None, 'country': None, + 'bio': None, + 'profile_image': { + 'has_image': False, + 'image_url_full': 'http://example-storage.com/profile_images/default_50.jpg', + 'image_url_small': 'http://example-storage.com/profile_images/default_10.jpg', + }, }) diff --git a/openedx/core/djangoapps/user_api/accounts/tests/test_views.py b/openedx/core/djangoapps/user_api/accounts/tests/test_views.py index fa23742aec..1e139b599e 100644 --- a/openedx/core/djangoapps/user_api/accounts/tests/test_views.py +++ b/openedx/core/djangoapps/user_api/accounts/tests/test_views.py @@ -153,7 +153,7 @@ class TestAccountAPI(UserAPITestCase): Verify that all account fields are returned (even those that are not shareable). """ data = response.data - self.assertEqual(13, len(data)) + self.assertEqual(14, len(data)) self.assertEqual(self.user.username, data["username"]) self.assertEqual(self.user.first_name + " " + self.user.last_name, data["name"]) self.assertEqual("US", data["country"]) @@ -269,7 +269,7 @@ class TestAccountAPI(UserAPITestCase): def verify_get_own_information(): response = self.send_get(self.client) data = response.data - self.assertEqual(13, len(data)) + self.assertEqual(14, len(data)) self.assertEqual(self.user.username, data["username"]) self.assertEqual(self.user.first_name + " " + self.user.last_name, data["name"]) for empty_field in ("year_of_birth", "level_of_education", "mailing_address", "bio"): From 650a9a9b850ff417b16881d7e3921e694ccdbd01 Mon Sep 17 00:00:00 2001 From: Andy Armstrong Date: Wed, 18 Mar 2015 13:36:15 -0400 Subject: [PATCH 05/53] Implement parental controls for user profiles TNL-1606 --- cms/envs/common.py | 2 +- cms/envs/test.py | 3 + common/djangoapps/student/models.py | 45 ++++++++++ .../student/tests/test_enrollment.py | 2 +- .../student/tests/test_parental_controls.py | 86 +++++++++++++++++++ lms/envs/common.py | 8 +- lms/envs/test.py | 3 + .../core/djangoapps/user_api/accounts/api.py | 8 +- .../user_api/accounts/tests/test_helpers.py | 8 +- openedx/core/djangoapps/user_api/helpers.py | 2 +- openedx/core/djangoapps/user_api/models.py | 2 +- .../djangoapps/user_api/preferences/api.py | 69 +++++++-------- .../user_api/preferences/tests/test_api.py | 7 ++ openedx/core/djangoapps/user_api/views.py | 26 +++--- 14 files changed, 211 insertions(+), 60 deletions(-) create mode 100644 common/djangoapps/student/tests/test_parental_controls.py diff --git a/cms/envs/common.py b/cms/envs/common.py index 6aff9c2546..b3d6bf1687 100644 --- a/cms/envs/common.py +++ b/cms/envs/common.py @@ -36,7 +36,7 @@ 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. 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/common/djangoapps/student/models.py b/common/djangoapps/student/models.py index a42a06fee5..ff8d294d6a 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 from django.dispatch import receiver, Signal from django.core.exceptions import ObjectDoesNotExist from django.utils.translation import ugettext_noop @@ -278,6 +279,50 @@ 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.has_profile_image = False + class UserSignupSource(models.Model): """ 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_parental_controls.py b/common/djangoapps/student/tests/test_parental_controls.py new file mode 100644 index 0000000000..3a3623cf6c --- /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.has_profile_image = True + 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.has_profile_image = True + 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/lms/envs/common.py b/lms/envs/common.py index d399640d35..dbcbceb85e 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -987,6 +987,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 +1550,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 ########################################## diff --git a/lms/envs/test.py b/lms/envs/test.py index 7658867fef..58e1e7fef4 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 diff --git a/openedx/core/djangoapps/user_api/accounts/api.py b/openedx/core/djangoapps/user_api/accounts/api.py index 7913aecd18..009b542489 100644 --- a/openedx/core/djangoapps/user_api/accounts/api.py +++ b/openedx/core/djangoapps/user_api/accounts/api.py @@ -104,7 +104,7 @@ def update_account_settings(requesting_user, update, username=None): requesting_user (User): The user requesting to modify account information. Only the user with username 'username' has permissions to modify account information. update (dict): The updated account field values. - username (string): Optional username specifying which account should be updated. If not specified, + username (str): Optional username specifying which account should be updated. If not specified, `requesting_user.username` is assumed. Raises: @@ -372,9 +372,9 @@ def request_password_change(email, orig_host, is_secure): Users must confirm the password change before we update their information. Args: - email (string): An email address - orig_host (string): An originating host, extracted from a request with get_host - is_secure (Boolean): Whether the request was made with HTTPS + email (str): An email address + orig_host (str): An originating host, extracted from a request with get_host + is_secure (bool): Whether the request was made with HTTPS Returns: None diff --git a/openedx/core/djangoapps/user_api/accounts/tests/test_helpers.py b/openedx/core/djangoapps/user_api/accounts/tests/test_helpers.py index af8e2150e9..92abeaff01 100644 --- a/openedx/core/djangoapps/user_api/accounts/tests/test_helpers.py +++ b/openedx/core/djangoapps/user_api/accounts/tests/test_helpers.py @@ -9,9 +9,11 @@ from unittest import skipUnless from django.conf import settings from django.test import TestCase -from openedx.core.djangoapps.user_api.accounts.helpers import get_profile_image_url_for_user from student.tests.factories import UserFactory +from ...models import UserProfile +from ..helpers import get_profile_image_url_for_user + @ddt @patch('openedx.core.djangoapps.user_api.accounts.helpers._PROFILE_IMAGE_SIZES', [50, 10]) @@ -27,6 +29,10 @@ class ProfileImageUrlTestCase(TestCase): super(ProfileImageUrlTestCase, self).setUp() self.user = UserFactory() + # Ensure that parental controls don't apply to this user + self.user.profile.year_of_birth = 1980 + self.user.profile.save() + def verify_url(self, user, pixels, filename): """ Helper method to verify that we're correctly generating profile diff --git a/openedx/core/djangoapps/user_api/helpers.py b/openedx/core/djangoapps/user_api/helpers.py index b939a41b77..8fc9b6c69b 100644 --- a/openedx/core/djangoapps/user_api/helpers.py +++ b/openedx/core/djangoapps/user_api/helpers.py @@ -308,7 +308,7 @@ class FormDescription(object): Field properties not in `OVERRIDE_FIELD_PROPERTIES` will be ignored. Arguments: - field_name (string): The name of the field to override. + field_name (str): The name of the field to override. Keyword Args: Same as to `add_field()`. diff --git a/openedx/core/djangoapps/user_api/models.py b/openedx/core/djangoapps/user_api/models.py index f8f94648c0..e3642fa2e6 100644 --- a/openedx/core/djangoapps/user_api/models.py +++ b/openedx/core/djangoapps/user_api/models.py @@ -35,7 +35,7 @@ class UserPreference(models.Model): Arguments: user (User): The user whose preference should be set. - preference_key (string): The key for the user preference. + preference_key (str): The key for the user preference. Returns: The user preference value, or None if one is not set. diff --git a/openedx/core/djangoapps/user_api/preferences/api.py b/openedx/core/djangoapps/user_api/preferences/api.py index 447282a4bd..2c40f8515e 100644 --- a/openedx/core/djangoapps/user_api/preferences/api.py +++ b/openedx/core/djangoapps/user_api/preferences/api.py @@ -1,22 +1,17 @@ """ API for managing user preferences. """ -import datetime import logging -import string import analytics from eventtracking import tracker -from pytz import UTC from django.conf import settings -from django.contrib.auth.models import User from django.core.exceptions import ObjectDoesNotExist from django.db import IntegrityError from django.utils.translation import ugettext as _ +from student.models import User, UserProfile from django.utils.translation import ugettext_noop -from student.models import UserProfile - from ..errors import ( UserAPIInternalError, UserAPIRequestError, UserNotFound, UserNotAuthorized, PreferenceValidationError, PreferenceUpdateError @@ -35,7 +30,7 @@ def get_user_preference(requesting_user, preference_key, username=None): Args: requesting_user (User): The user requesting the user preferences. Only the user with username `username` or users with "is_staff" privileges can access the preferences. - preference_key (string): The key for the user preference. + preference_key (str): The key for the user preference. username (str): Optional username for which to look up the preferences. If not specified, `requesting_user.username` is assumed. @@ -92,7 +87,7 @@ def update_user_preferences(requesting_user, update, username=None): Some notes: Values are expected to be strings. Non-string values will be converted to strings. Null values for a preference will be treated as a request to delete the key in question. - username (string): Optional username specifying which account should be updated. If not specified, + username (str): Optional username specifying which account should be updated. If not specified, `requesting_user.username` is assumed. Raises: @@ -148,9 +143,9 @@ def set_user_preference(requesting_user, preference_key, preference_value, usern Arguments: requesting_user (User): The user requesting to modify account information. Only the user with username 'username' has permissions to modify account information. - preference_key (string): The key for the user preference. - preference_value (string): The value to be stored. Non-string values will be converted to strings. - username (string): Optional username specifying which account should be updated. If not specified, + preference_key (str): The key for the user preference. + preference_value (str): The value to be stored. Non-string values will be converted to strings. + username (str): Optional username specifying which account should be updated. If not specified, `requesting_user.username` is assumed. Raises: @@ -182,8 +177,8 @@ def delete_user_preference(requesting_user, preference_key, username=None): Arguments: requesting_user (User): The user requesting to delete the preference. Only the user with username 'username' has permissions to delete their own preference. - preference_key (string): The key for the user preference. - username (string): Optional username specifying which account should be updated. If not specified, + preference_key (str): The key for the user preference. + username (str): Optional username specifying which account should be updated. If not specified, `requesting_user.username` is assumed. Returns: @@ -218,7 +213,7 @@ def delete_user_preference(requesting_user, preference_key, username=None): @intercept_errors(UserAPIInternalError, ignore_errors=[UserAPIRequestError]) -def update_email_opt_in(user, org, optin): +def update_email_opt_in(user, org, opt_in): """Updates a user's preference for receiving org-wide emails. Sets a User Org Tag defining the choice to opt in or opt out of organization-wide @@ -227,48 +222,48 @@ def update_email_opt_in(user, org, optin): Arguments: user (User): The user to set a preference for. org (str): The org is used to determine the organization this setting is related to. - optin (Boolean): True if the user is choosing to receive emails for this organization. If the user is not - the correct age to receive emails, email-optin is set to False regardless. + opt_in (bool): True if the user is choosing to receive emails for this organization. + If the user requires parental consent then email-optin is set to False regardless. Returns: None + Raises: + UserNotFound: no user profile exists for the specified user. """ - # Avoid calling get_account_settings because it introduces circularity for many callers who need both - # preferences and account information. + preference, _ = UserOrgTag.objects.get_or_create( + user=user, org=org, key='email-optin' + ) + + # If the user requires parental consent, then don't allow opt-in try: user_profile = UserProfile.objects.get(user=user) except ObjectDoesNotExist: raise UserNotFound() + if user_profile.requires_parental_consent( + age_limit=getattr(settings, 'EMAIL_OPTIN_MINIMUM_AGE', 13), + default_requires_consent=False, + ): + opt_in = False - year_of_birth = user_profile.year_of_birth - of_age = ( - year_of_birth is None or # If year of birth is not set, we assume user is of age. - datetime.datetime.now(UTC).year - year_of_birth > # pylint: disable=maybe-no-member - getattr(settings, 'EMAIL_OPTIN_MINIMUM_AGE', 13) - ) - + # Update the preference and save it + preference.value = str(opt_in) try: - preference, _ = UserOrgTag.objects.get_or_create( - user=user, org=org, key='email-optin' - ) - preference.value = str(optin and of_age) preference.save() - if settings.FEATURES.get('SEGMENT_IO_LMS') and settings.SEGMENT_IO_LMS_KEY: - _track_update_email_opt_in(user.id, org, optin) - + _track_update_email_opt_in(user.id, org, opt_in) except IntegrityError as err: log.warn(u"Could not update organization wide preference due to IntegrityError: {}".format(err.message)) + def _track_update_email_opt_in(user_id, organization, opt_in): """Track an email opt-in preference change. Arguments: user_id (str): The ID of the user making the preference change. organization (str): The organization whose emails are being opted into or out of by the user. - opt_in (Boolean): Whether the user has chosen to opt-in to emails from the organization. + opt_in (bool): Whether the user has chosen to opt-in to emails from the organization. Returns: None @@ -317,8 +312,8 @@ def create_user_preference_serializer(user, preference_key, preference_value): Arguments: user (User): The user whose preference is being serialized. - preference_key (string): The key for the user preference. - preference_value (string): The value to be stored. Non-string values will be converted to strings. + preference_key (str): The key for the user preference. + preference_value (str): The value to be stored. Non-string values will be converted to strings. Returns: A serializer that can be used to save the user preference. @@ -344,8 +339,8 @@ def validate_user_preference_serializer(serializer, preference_key, preference_v Arguments: serializer (UserPreferenceSerializer): The serializer to be validated. - preference_key (string): The key for the user preference. - preference_value (string): The value to be stored. Non-string values will be converted to strings. + preference_key (str): The key for the user preference. + preference_value (str): The value to be stored. Non-string values will be converted to strings. Raises: PreferenceValidationError: the supplied key and/or value for a user preference are invalid. diff --git a/openedx/core/djangoapps/user_api/preferences/tests/test_api.py b/openedx/core/djangoapps/user_api/preferences/tests/test_api.py index 13d05c6492..3791bc33c4 100644 --- a/openedx/core/djangoapps/user_api/preferences/tests/test_api.py +++ b/openedx/core/djangoapps/user_api/preferences/tests/test_api.py @@ -344,6 +344,13 @@ class UpdateEmailOptInTests(ModuleStoreTestCase): result_obj = UserOrgTag.objects.get(user=user, org=course.id.org, key='email-optin') self.assertEqual(result_obj.value, u"True") + def test_update_email_optin_anonymous_user(self): + """Verify that the API raises an exception for a user with no profile.""" + course = CourseFactory.create() + no_profile_user, __ = User.objects.get_or_create(username="no_profile_user", password=self.PASSWORD) + with self.assertRaises(UserNotFound): + update_email_opt_in(no_profile_user, course.id.org, True) + @ddt.data( # Check that a 27 year old can opt-in, then out. (27, True, False, u"False"), diff --git a/openedx/core/djangoapps/user_api/views.py b/openedx/core/djangoapps/user_api/views.py index 89e52fe34e..d1844b119f 100644 --- a/openedx/core/djangoapps/user_api/views.py +++ b/openedx/core/djangoapps/user_api/views.py @@ -309,7 +309,7 @@ class RegistrationView(APIView): form_desc: A form description Keyword Arguments: - required (Boolean): Whether this field is required; defaults to True + required (bool): Whether this field is required; defaults to True """ # Translators: This label appears above a field on the registration form @@ -339,7 +339,7 @@ class RegistrationView(APIView): form_desc: A form description Keyword Arguments: - required (Boolean): Whether this field is required; defaults to True + required (bool): Whether this field is required; defaults to True """ # Translators: This label appears above a field on the registration form @@ -372,7 +372,7 @@ class RegistrationView(APIView): form_desc: A form description Keyword Arguments: - required (Boolean): Whether this field is required; defaults to True + required (bool): Whether this field is required; defaults to True """ # Translators: This label appears above a field on the registration form @@ -409,7 +409,7 @@ class RegistrationView(APIView): form_desc: A form description Keyword Arguments: - required (Boolean): Whether this field is required; defaults to True + required (bool): Whether this field is required; defaults to True """ # Translators: This label appears above a field on the registration form @@ -434,7 +434,7 @@ class RegistrationView(APIView): form_desc: A form description Keyword Arguments: - required (Boolean): Whether this field is required; defaults to True + required (bool): Whether this field is required; defaults to True """ # Translators: This label appears above a dropdown menu on the registration @@ -457,7 +457,7 @@ class RegistrationView(APIView): form_desc: A form description Keyword Arguments: - required (Boolean): Whether this field is required; defaults to True + required (bool): Whether this field is required; defaults to True """ # Translators: This label appears above a dropdown menu on the registration @@ -480,7 +480,7 @@ class RegistrationView(APIView): form_desc: A form description Keyword Arguments: - required (Boolean): Whether this field is required; defaults to True + required (bool): Whether this field is required; defaults to True """ # Translators: This label appears above a dropdown menu on the registration @@ -504,7 +504,7 @@ class RegistrationView(APIView): form_desc: A form description Keyword Arguments: - required (Boolean): Whether this field is required; defaults to True + required (bool): Whether this field is required; defaults to True """ # Translators: This label appears above a field on the registration form @@ -525,7 +525,7 @@ class RegistrationView(APIView): form_desc: A form description Keyword Arguments: - required (Boolean): Whether this field is required; defaults to True + required (bool): Whether this field is required; defaults to True """ # Translators: This phrase appears above a field on the registration form @@ -548,7 +548,7 @@ class RegistrationView(APIView): form_desc: A form description Keyword Arguments: - required (Boolean): Whether this field is required; defaults to True + required (bool): Whether this field is required; defaults to True """ # Translators: This label appears above a field on the registration form @@ -568,7 +568,7 @@ class RegistrationView(APIView): form_desc: A form description Keyword Arguments: - required (Boolean): Whether this field is required; defaults to True + required (bool): Whether this field is required; defaults to True """ # Translators: This label appears above a dropdown menu on the registration @@ -604,7 +604,7 @@ class RegistrationView(APIView): form_desc: A form description Keyword Arguments: - required (Boolean): Whether this field is required; defaults to True + required (bool): Whether this field is required; defaults to True """ # Separate terms of service and honor code checkboxes @@ -658,7 +658,7 @@ class RegistrationView(APIView): form_desc: A form description Keyword Arguments: - required (Boolean): Whether this field is required; defaults to True + required (bool): Whether this field is required; defaults to True """ # Translators: This is a legal document users must agree to From e710a5b2d0c4bb2eabc903092ddd3d0c931fd29f Mon Sep 17 00:00:00 2001 From: Andy Armstrong Date: Fri, 20 Mar 2015 15:34:11 -0400 Subject: [PATCH 06/53] Implement parental controls for the User API TNL-1739 --- .../core/djangoapps/user_api/accounts/api.py | 21 ++++++++++----- .../user_api/accounts/serializers.py | 21 +++++++++------ .../user_api/accounts/tests/test_api.py | 1 + .../user_api/accounts/tests/test_views.py | 26 +++++++++++-------- .../djangoapps/user_api/accounts/views.py | 19 ++++++++++++-- 5 files changed, 60 insertions(+), 28 deletions(-) diff --git a/openedx/core/djangoapps/user_api/accounts/api.py b/openedx/core/djangoapps/user_api/accounts/api.py index 009b542489..9cf4db14d2 100644 --- a/openedx/core/djangoapps/user_api/accounts/api.py +++ b/openedx/core/djangoapps/user_api/accounts/api.py @@ -19,7 +19,7 @@ from ..helpers import intercept_errors from ..models import UserPreference from . import ( - ACCOUNT_VISIBILITY_PREF_KEY, ALL_USERS_VISIBILITY, + ACCOUNT_VISIBILITY_PREF_KEY, ALL_USERS_VISIBILITY, PRIVATE_VISIBILITY, EMAIL_MIN_LENGTH, EMAIL_MAX_LENGTH, PASSWORD_MIN_LENGTH, PASSWORD_MAX_LENGTH, USERNAME_MIN_LENGTH, USERNAME_MAX_LENGTH ) @@ -76,12 +76,8 @@ def get_account_settings(requesting_user, username=None, configuration=None, vie visible_settings = {} - # Calling UserPreference directly because the requesting user may be different from existing_user - # (and does not have to be is_staff). - profile_privacy = UserPreference.get_value(existing_user, ACCOUNT_VISIBILITY_PREF_KEY) - privacy_setting = profile_privacy if profile_privacy else configuration.get('default_visibility') - - if privacy_setting == ALL_USERS_VISIBILITY: + profile_visibility = _get_profile_visibility(existing_user_profile, configuration) + if profile_visibility == ALL_USERS_VISIBILITY: field_names = configuration.get('shareable_fields') else: field_names = configuration.get('public_fields') @@ -92,6 +88,17 @@ def get_account_settings(requesting_user, username=None, configuration=None, vie return visible_settings +def _get_profile_visibility(user_profile, configuration): + """Returns the visibility level for the specified user profile.""" + if user_profile.requires_parental_consent(): + return PRIVATE_VISIBILITY + + # Calling UserPreference directly because the requesting user may be different from existing_user + # (and does not have to be is_staff). + profile_privacy = UserPreference.get_value(user_profile.user, ACCOUNT_VISIBILITY_PREF_KEY) + return profile_privacy if profile_privacy else configuration.get('default_visibility') + + @intercept_errors(UserAPIInternalError, ignore_errors=[UserAPIRequestError]) def update_account_settings(requesting_user, update, username=None): """Update user account information. diff --git a/openedx/core/djangoapps/user_api/accounts/serializers.py b/openedx/core/djangoapps/user_api/accounts/serializers.py index 2de8ac0319..76d0b6950e 100644 --- a/openedx/core/djangoapps/user_api/accounts/serializers.py +++ b/openedx/core/djangoapps/user_api/accounts/serializers.py @@ -25,16 +25,17 @@ class AccountLegacyProfileSerializer(serializers.HyperlinkedModelSerializer, Rea Class that serializes the portion of UserProfile model needed for account information. """ profile_image = serializers.SerializerMethodField("get_profile_image") + requires_parental_consent = serializers.SerializerMethodField("get_requires_parental_consent") class Meta: model = UserProfile fields = ( "name", "gender", "goals", "year_of_birth", "level_of_education", "language", "country", - "mailing_address", "bio", "profile_image" + "mailing_address", "bio", "profile_image", "requires_parental_consent", ) # Currently no read-only field, but keep this so view code doesn't need to know. read_only_fields = () - explicit_read_only_fields = ("profile_image",) + explicit_read_only_fields = ("profile_image", "requires_parental_consent") def validate_name(self, attrs, source): """ Enforce minimum length for name. """ @@ -48,15 +49,15 @@ class AccountLegacyProfileSerializer(serializers.HyperlinkedModelSerializer, Rea return attrs - def transform_gender(self, obj, value): + def transform_gender(self, user_profile, value): """ Converts empty string to None, to indicate not set. Replaced by to_representation in version 3. """ return AccountLegacyProfileSerializer.convert_empty_to_None(value) - def transform_country(self, obj, value): + def transform_country(self, user_profile, value): """ Converts empty string to None, to indicate not set. Replaced by to_representation in version 3. """ return AccountLegacyProfileSerializer.convert_empty_to_None(value) - def transform_level_of_education(self, obj, value): + def transform_level_of_education(self, user_profile, value): """ Converts empty string to None, to indicate not set. Replaced by to_representation in version 3. """ return AccountLegacyProfileSerializer.convert_empty_to_None(value) @@ -65,12 +66,16 @@ class AccountLegacyProfileSerializer(serializers.HyperlinkedModelSerializer, Rea """ Helper method to convert empty string to None (other values pass through). """ return None if value == "" else value - def get_profile_image(self, obj): + def get_profile_image(self, user_profile): """ Returns metadata about a user's profile image. """ - data = {'has_image': obj.has_profile_image} + data = {'has_image': user_profile.has_profile_image} data.update({ '{image_key_prefix}_{size}'.format(image_key_prefix=PROFILE_IMAGE_KEY_PREFIX, size=size_display_name): - get_profile_image_url_for_user(obj.user, size_value) + get_profile_image_url_for_user(user_profile.user, size_value) for size_display_name, size_value in PROFILE_IMAGE_SIZES_MAP.items() }) return data + + def get_requires_parental_consent(self, user_profile): + """ Returns a boolean representing whether the user requires parental controls. """ + return user_profile.requires_parental_consent() diff --git a/openedx/core/djangoapps/user_api/accounts/tests/test_api.py b/openedx/core/djangoapps/user_api/accounts/tests/test_api.py index 63c43cc280..0e4b484f16 100644 --- a/openedx/core/djangoapps/user_api/accounts/tests/test_api.py +++ b/openedx/core/djangoapps/user_api/accounts/tests/test_api.py @@ -220,6 +220,7 @@ class AccountSettingsOnCreationTest(TestCase): 'image_url_full': 'http://example-storage.com/profile_images/default_50.jpg', 'image_url_small': 'http://example-storage.com/profile_images/default_10.jpg', }, + 'requires_parental_consent': True, }) diff --git a/openedx/core/djangoapps/user_api/accounts/tests/test_views.py b/openedx/core/djangoapps/user_api/accounts/tests/test_views.py index 1e139b599e..1d02b56de8 100644 --- a/openedx/core/djangoapps/user_api/accounts/tests/test_views.py +++ b/openedx/core/djangoapps/user_api/accounts/tests/test_views.py @@ -1,4 +1,5 @@ # -*- coding: utf-8 -*- +import datetime import ddt import hashlib import json @@ -84,9 +85,10 @@ class UserAPITestCase(APITestCase): legacy_profile = UserProfile.objects.get(id=user.id) legacy_profile.country = "US" legacy_profile.level_of_education = "m" - legacy_profile.year_of_birth = 1900 + legacy_profile.year_of_birth = 2000 legacy_profile.goals = "world peace" legacy_profile.mailing_address = "Park Ave" + legacy_profile.gender = "f" legacy_profile.bio = "Tired mother of twins" legacy_profile.has_profile_image = True legacy_profile.save() @@ -139,27 +141,27 @@ class TestAccountAPI(UserAPITestCase): self.assertIsNone(data["languages"]) self.assertEqual("Tired mother of twins", data["bio"]) - def _verify_private_account_response(self, response): + def _verify_private_account_response(self, response, requires_parental_consent=False): """ Verify that only the public fields are returned if a user does not want to share account fields """ data = response.data self.assertEqual(2, len(data)) self.assertEqual(self.user.username, data["username"]) - self._verify_profile_image_data(data, True) + self._verify_profile_image_data(data, not requires_parental_consent) - def _verify_full_account_response(self, response): + def _verify_full_account_response(self, response, requires_parental_consent=False): """ Verify that all account fields are returned (even those that are not shareable). """ data = response.data - self.assertEqual(14, len(data)) + self.assertEqual(15, len(data)) self.assertEqual(self.user.username, data["username"]) self.assertEqual(self.user.first_name + " " + self.user.last_name, data["name"]) self.assertEqual("US", data["country"]) self.assertEqual("", data["language"]) - self.assertEqual("m", data["gender"]) - self.assertEqual(1900, data["year_of_birth"]) + self.assertEqual("f", data["gender"]) + self.assertEqual(2000, data["year_of_birth"]) self.assertEqual("m", data["level_of_education"]) self.assertEqual("world peace", data["goals"]) self.assertEqual("Park Ave", data['mailing_address']) @@ -167,7 +169,8 @@ class TestAccountAPI(UserAPITestCase): self.assertTrue(data["is_active"]) self.assertIsNotNone(data["date_joined"]) self.assertEqual("Tired mother of twins", data["bio"]) - self._verify_profile_image_data(data, True) + self._verify_profile_image_data(data, not requires_parental_consent) + self.assertEquals(requires_parental_consent, data["requires_parental_consent"]) def test_anonymous_access(self): """ @@ -269,7 +272,7 @@ class TestAccountAPI(UserAPITestCase): def verify_get_own_information(): response = self.send_get(self.client) data = response.data - self.assertEqual(14, len(data)) + self.assertEqual(15, len(data)) self.assertEqual(self.user.username, data["username"]) self.assertEqual(self.user.first_name + " " + self.user.last_name, data["name"]) for empty_field in ("year_of_birth", "level_of_education", "mailing_address", "bio"): @@ -283,6 +286,7 @@ class TestAccountAPI(UserAPITestCase): self.assertIsNotNone(data["date_joined"]) self.assertEqual(self.user.is_active, data["is_active"]) self._verify_profile_image_data(data, False) + self.assertTrue(data["requires_parental_consent"]) self.client.login(username=self.user.username, password=self.test_password) verify_get_own_information() @@ -406,8 +410,8 @@ class TestAccountAPI(UserAPITestCase): "Field '{0}' cannot be edited.".format(field_name), data["field_errors"][field_name]["user_message"] ) - for field_name in ["username", "date_joined", "is_active"]: - response = self.send_patch(client, {field_name: "will_error", "gender": "f"}, expected_status=400) + for field_name in ["username", "date_joined", "is_active", "profile_image", "requires_parental_consent"]: + response = self.send_patch(client, {field_name: "will_error", "gender": "o"}, expected_status=400) verify_error_response(field_name, response.data) # Make sure that gender did not change. diff --git a/openedx/core/djangoapps/user_api/accounts/views.py b/openedx/core/djangoapps/user_api/accounts/views.py index efaab726ff..f964062f4c 100644 --- a/openedx/core/djangoapps/user_api/accounts/views.py +++ b/openedx/core/djangoapps/user_api/accounts/views.py @@ -80,9 +80,24 @@ class AccountView(APIView): * goals: The textual representation of the user's goals, or null. * bio: null or textural representation of user biographical - information ("about me") + information ("about me"). - For all text fields, clients rendering the values should take care + * profile_image: a dict with the following keys describing + the user's profile image: + * "has_image": true if the user has a profile image + * "image_url_full": an absolute URL to the user's full + profile image + * "image_url_large": an absolute URL to a large thumbnail + of the profile image + * "image_url_medium": an absolute URL to a medium thumbnail + of the profile image + * "image_url_small": an absolute URL to a small thumbnail + of the profile image + + * requires_parental_consent: true if the user is a minor + requiring parental consent. + +> For all text fields, clients rendering the values should take care to HTML escape them to avoid script injections, as the data is stored exactly as specified. The intention is that plain text is supported, not HTML. From 4125bf96d68431ca62264d853162496b9982d75d Mon Sep 17 00:00:00 2001 From: Daniel Friedman Date: Tue, 17 Mar 2015 11:53:33 -0400 Subject: [PATCH 07/53] Add documentation for is_active and profile_image --- .../djangoapps/user_api/accounts/views.py | 23 ++++++++++--------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/openedx/core/djangoapps/user_api/accounts/views.py b/openedx/core/djangoapps/user_api/accounts/views.py index f964062f4c..598e59186a 100644 --- a/openedx/core/djangoapps/user_api/accounts/views.py +++ b/openedx/core/djangoapps/user_api/accounts/views.py @@ -82,22 +82,23 @@ class AccountView(APIView): * bio: null or textural representation of user biographical information ("about me"). - * profile_image: a dict with the following keys describing + * is_active: boolean representation of whether a user is active. + + * profile_image: JSON representation of a user's profile image + information. The keys are: the user's profile image: - * "has_image": true if the user has a profile image - * "image_url_full": an absolute URL to the user's full - profile image - * "image_url_large": an absolute URL to a large thumbnail - of the profile image - * "image_url_medium": an absolute URL to a medium thumbnail - of the profile image - * "image_url_small": an absolute URL to a small thumbnail - of the profile image + * "has_image": boolean indicating whether the user has + a profile image. + * "image_url_*": absolute URL to various sizes of a user's + profile image, where '*' matches a representation of + the corresponding image size such as 'small', 'medium', + 'large', and 'full'. These are configurable via + PROFILE_IMAGE_SIZES_MAP. * requires_parental_consent: true if the user is a minor requiring parental consent. -> For all text fields, clients rendering the values should take care + For all text fields, clients rendering the values should take care to HTML escape them to avoid script injections, as the data is stored exactly as specified. The intention is that plain text is supported, not HTML. From 97e44ed20f09a188862cc58332a19fd006051051 Mon Sep 17 00:00:00 2001 From: Daniel Friedman Date: Thu, 19 Mar 2015 19:28:39 -0400 Subject: [PATCH 08/53] Implement language proficiencies. TNL-1488 --- ...add_unique_languageproficiency_code_use.py | 210 ++++++++++++++++++ common/djangoapps/student/models.py | 16 ++ lms/envs/common.py | 4 +- .../user_api/accounts/serializers.py | 37 ++- .../user_api/accounts/tests/test_api.py | 16 +- .../user_api/accounts/tests/test_views.py | 92 +++++++- .../djangoapps/user_api/accounts/views.py | 23 +- 7 files changed, 378 insertions(+), 20 deletions(-) create mode 100644 common/djangoapps/student/migrations/0049_auto__add_languageproficiency__add_unique_languageproficiency_code_use.py 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..e2625bda0e --- /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.TextField', [], {'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'] \ No newline at end of file diff --git a/common/djangoapps/student/models.py b/common/djangoapps/student/models.py index ff8d294d6a..3eb82fe028 100644 --- a/common/djangoapps/student/models.py +++ b/common/djangoapps/student/models.py @@ -1608,3 +1608,19 @@ class EntranceExamConfiguration(models.Model): except EntranceExamConfiguration.DoesNotExist: can_skip = False return can_skip + + +class LanguageProficiency(models.Model): + """ + Represents a user's language proficiency. + """ + 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/lms/envs/common.py b/lms/envs/common.py index dbcbceb85e..6a27103021 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -1924,6 +1924,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"], @@ -2230,7 +2232,7 @@ ACCOUNT_VISIBILITY_CONFIGURATION = { 'profile_image', 'country', 'time_zone', - 'languages', + 'language_proficiencies', 'bio', ], diff --git a/openedx/core/djangoapps/user_api/accounts/serializers.py b/openedx/core/djangoapps/user_api/accounts/serializers.py index 76d0b6950e..d8a187e3ee 100644 --- a/openedx/core/djangoapps/user_api/accounts/serializers.py +++ b/openedx/core/djangoapps/user_api/accounts/serializers.py @@ -3,12 +3,34 @@ from django.contrib.auth.models import User from openedx.core.djangoapps.user_api.accounts import NAME_MIN_LENGTH from openedx.core.djangoapps.user_api.serializers import ReadOnlyFieldsSerializerMixin -from student.models import UserProfile +from student.models import UserProfile, LanguageProficiency from .helpers import get_profile_image_url_for_user, PROFILE_IMAGE_SIZES_MAP PROFILE_IMAGE_KEY_PREFIX = 'image_url' +class LanguageProficiencySerializer(serializers.ModelSerializer): + """ + Class that serializes the LanguageProficiency model for account + information. + """ + class Meta: + model = LanguageProficiency + fields = ("code",) + + def get_identity(self, data): + """ + This is used in bulk updates to determine the identity of an object. + The default is to use the id of an object, but we want to override that + and consider the language code to be the canonical identity of a + LanguageProficiency model. + """ + try: + return data.get('code', None) + except AttributeError: + return None + + class AccountUserSerializer(serializers.HyperlinkedModelSerializer, ReadOnlyFieldsSerializerMixin): """ Class that serializes the portion of User model needed for account information. @@ -26,12 +48,13 @@ class AccountLegacyProfileSerializer(serializers.HyperlinkedModelSerializer, Rea """ profile_image = serializers.SerializerMethodField("get_profile_image") requires_parental_consent = serializers.SerializerMethodField("get_requires_parental_consent") + language_proficiencies = LanguageProficiencySerializer(many=True, allow_add_remove=True, required=False) class Meta: model = UserProfile fields = ( - "name", "gender", "goals", "year_of_birth", "level_of_education", "language", "country", - "mailing_address", "bio", "profile_image", "requires_parental_consent", + "name", "gender", "goals", "year_of_birth", "level_of_education", "country", + "mailing_address", "bio", "profile_image", "requires_parental_consent", "language_proficiencies" ) # Currently no read-only field, but keep this so view code doesn't need to know. read_only_fields = () @@ -49,6 +72,14 @@ class AccountLegacyProfileSerializer(serializers.HyperlinkedModelSerializer, Rea return attrs + def validate_language_proficiencies(self, attrs, source): + """ Enforce all languages are unique. """ + language_proficiencies = [language for language in attrs.get(source, [])] + unique_language_proficiencies = set(language.code for language in language_proficiencies) + if len(language_proficiencies) != len(unique_language_proficiencies): + raise serializers.ValidationError("The language_proficiencies field must consist of unique languages") + return attrs + def transform_gender(self, user_profile, value): """ Converts empty string to None, to indicate not set. Replaced by to_representation in version 3. """ return AccountLegacyProfileSerializer.convert_empty_to_None(value) diff --git a/openedx/core/djangoapps/user_api/accounts/tests/test_api.py b/openedx/core/djangoapps/user_api/accounts/tests/test_api.py index 0e4b484f16..29ba9c03f7 100644 --- a/openedx/core/djangoapps/user_api/accounts/tests/test_api.py +++ b/openedx/core/djangoapps/user_api/accounts/tests/test_api.py @@ -123,6 +123,20 @@ class TestAccountApi(TestCase): {"profile_image": {"has_image": "not_allowed", "image_url": "not_allowed"}} ) + # Check the various language_proficiencies validation failures. + # language_proficiencies must be a list of dicts, each containing a + # unique 'code' key representing the language code. + with self.assertRaises(AccountValidationError): + update_account_settings( + self.user, + {"language_proficiencies": "not_a_list"} + ) + with self.assertRaises(AccountValidationError): + update_account_settings( + self.user, + {"language_proficiencies": [{}]} + ) + def test_update_multiple_validation_errors(self): """Test that all validation errors are built up and returned at once""" # Send a read-only error, serializer error, and email validation error. @@ -207,7 +221,6 @@ class AccountSettingsOnCreationTest(TestCase): 'email': self.EMAIL, 'name': u'', 'gender': None, - 'language': u'', 'goals': None, 'is_active': False, 'level_of_education': None, @@ -221,6 +234,7 @@ class AccountSettingsOnCreationTest(TestCase): 'image_url_small': 'http://example-storage.com/profile_images/default_10.jpg', }, 'requires_parental_consent': True, + 'language_proficiencies': [], }) diff --git a/openedx/core/djangoapps/user_api/accounts/tests/test_views.py b/openedx/core/djangoapps/user_api/accounts/tests/test_views.py index 1d02b56de8..6486e95df1 100644 --- a/openedx/core/djangoapps/user_api/accounts/tests/test_views.py +++ b/openedx/core/djangoapps/user_api/accounts/tests/test_views.py @@ -13,7 +13,7 @@ from django.test.utils import override_settings from rest_framework.test import APITestCase, APIClient from student.tests.factories import UserFactory -from student.models import UserProfile, PendingEmailChange +from student.models import UserProfile, LanguageProficiency, PendingEmailChange from openedx.core.djangoapps.user_api.accounts import ACCOUNT_VISIBILITY_PREF_KEY from openedx.core.djangoapps.user_api.preferences.api import set_user_preference from .. import PRIVATE_VISIBILITY, ALL_USERS_VISIBILITY @@ -91,6 +91,7 @@ class UserAPITestCase(APITestCase): legacy_profile.gender = "f" legacy_profile.bio = "Tired mother of twins" legacy_profile.has_profile_image = True + legacy_profile.language_proficiencies.add(LanguageProficiency(code='en')) legacy_profile.save() @@ -138,7 +139,7 @@ class TestAccountAPI(UserAPITestCase): self.assertEqual("US", data["country"]) self._verify_profile_image_data(data, True) self.assertIsNone(data["time_zone"]) - self.assertIsNone(data["languages"]) + self.assertEqual([{"code": "en"}], data["language_proficiencies"]) self.assertEqual("Tired mother of twins", data["bio"]) def _verify_private_account_response(self, response, requires_parental_consent=False): @@ -159,7 +160,6 @@ class TestAccountAPI(UserAPITestCase): self.assertEqual(self.user.username, data["username"]) self.assertEqual(self.user.first_name + " " + self.user.last_name, data["name"]) self.assertEqual("US", data["country"]) - self.assertEqual("", data["language"]) self.assertEqual("f", data["gender"]) self.assertEqual(2000, data["year_of_birth"]) self.assertEqual("m", data["level_of_education"]) @@ -171,6 +171,7 @@ class TestAccountAPI(UserAPITestCase): self.assertEqual("Tired mother of twins", data["bio"]) self._verify_profile_image_data(data, not requires_parental_consent) self.assertEquals(requires_parental_consent, data["requires_parental_consent"]) + self.assertEqual([{"code": "en"}], data["language_proficiencies"]) def test_anonymous_access(self): """ @@ -279,7 +280,6 @@ class TestAccountAPI(UserAPITestCase): self.assertIsNone(data[empty_field]) self.assertIsNone(data["country"]) # TODO: what should the format of this be? - self.assertEqual("", data["language"]) self.assertEqual("m", data["gender"]) self.assertEqual("Learn a lot", data["goals"]) self.assertEqual(self.user.email, data["email"]) @@ -287,6 +287,7 @@ class TestAccountAPI(UserAPITestCase): self.assertEqual(self.user.is_active, data["is_active"]) self._verify_profile_image_data(data, False) self.assertTrue(data["requires_parental_consent"]) + self.assertEqual([], data["language_proficiencies"]) self.client.login(username=self.user.username, password=self.test_password) verify_get_own_information() @@ -347,7 +348,6 @@ class TestAccountAPI(UserAPITestCase): ("year_of_birth", 2009, "not_an_int", u"Enter a whole number."), ("name", "bob", "z" * 256, u"Ensure this value has at most 255 characters (it has 256)."), ("name", u"ȻħȺɍłɇs", "z ", u"The name field must be at least 2 characters long."), - ("language", "Creole"), ("goals", "Smell the roses"), ("mailing_address", "Sesame Street"), ("bio", "Lacrosse-playing superhero"), @@ -355,6 +355,7 @@ class TestAccountAPI(UserAPITestCase): # Note that we store the raw data, so it is up to client to escape the HTML. ("bio", "fancy text"), # Note that email is tested below, as it is not immediately updated. + # Note that language_proficiencies is tested below as there are multiple error and success conditions. ) @ddt.unpack def test_patch_account(self, field, value, fails_validation_value=None, developer_validation_message=None): @@ -535,6 +536,42 @@ class TestAccountAPI(UserAPITestCase): ) self.assertEqual("Valid e-mail address required.", field_errors["email"]["user_message"]) + def test_patch_language_proficiencies(self): + """ + Verify that patching the language_proficiencies field of the user + profile completely overwrites the previous value. + """ + client = self.login_client("client", "user") + + # Patching language_proficiencies exercises the + # `LanguageProficiencySerializer.get_identity` method, which compares + # identifies language proficiencies based on their language code rather + # than django model id. + for proficiencies in ([{"code": "en"}, {"code": "fr"}, {"code": "es"}], [{"code": "fr"}], [{"code": "aa"}], []): + self.send_patch(client, {"language_proficiencies": proficiencies}) + response = self.send_get(client) + self.assertItemsEqual(response.data["language_proficiencies"], proficiencies) + + @ddt.data( + (u"not_a_list", [{u'non_field_errors': [u'Expected a list of items.']}]), + ([u"not_a_JSON_object"], [{u'non_field_errors': [u'Invalid data']}]), + ([{}], [{"code": [u"This field is required."]}]), + ([{u"code": u"invalid_language_code"}], [{'code': [u'Select a valid choice. invalid_language_code is not one of the available choices.']}]), + ([{u"code": u"kw"}, {u"code": u"el"}, {u"code": u"kw"}], [u'The language_proficiencies field must consist of unique languages']), + ) + @ddt.unpack + def test_patch_invalid_language_proficiencies(self, patch_value, expected_error_message): + """ + Verify we handle error cases when patching the language_proficiencies + field. + """ + client = self.login_client("client", "user") + response = self.send_patch(client, {"language_proficiencies": patch_value}, expected_status=400) + self.assertEqual( + response.data["field_errors"]["language_proficiencies"]["developer_message"], + u"Value '{patch_value}' is not valid for field 'language_proficiencies': {error_message}".format(patch_value=patch_value, error_message=expected_error_message) + ) + @patch('openedx.core.djangoapps.user_api.accounts.serializers.AccountUserSerializer.save') def test_patch_serializer_save_fails(self, serializer_save): """ @@ -566,9 +603,52 @@ class TestAccountAPI(UserAPITestCase): "image_url_full": "http://testserver/profile_images/default_50.jpg", "image_url_small": "http://testserver/profile_images/default_10.jpg" } - ) + ) + @ddt.data( + ("client", "user", True), + ("different_client", "different_user", False), + ("staff_client", "staff_user", True), + ) + @ddt.unpack + def test_parental_consent(self, api_client, requesting_username, has_full_access): + """ + Verifies that under thirteens never return a public profile. + """ + client = self.login_client(api_client, requesting_username) + # Set the user to be ten years old with a public profile + legacy_profile = UserProfile.objects.get(id=self.user.id) + current_year = datetime.datetime.now().year + legacy_profile.year_of_birth = current_year - 10 + legacy_profile.save() + set_user_preference(self.user, ACCOUNT_VISIBILITY_PREF_KEY, ALL_USERS_VISIBILITY) + + # Verify that the default view is still private (except for clients with full access) + response = self.send_get(client) + if has_full_access: + data = response.data + self.assertEqual(15, len(data)) + self.assertEqual(self.user.username, data["username"]) + self.assertEqual(self.user.first_name + " " + self.user.last_name, data["name"]) + self.assertEqual(self.user.email, data["email"]) + self.assertEqual(current_year - 10, data["year_of_birth"]) + for empty_field in ("country", "level_of_education", "mailing_address", "bio"): + self.assertIsNone(data[empty_field]) + self.assertEqual("m", data["gender"]) + self.assertEqual("Learn a lot", data["goals"]) + self.assertTrue(data["is_active"]) + self.assertIsNotNone(data["date_joined"]) + self._verify_profile_image_data(data, False) + self.assertTrue(data["requires_parental_consent"]) + else: + self._verify_private_account_response(response, requires_parental_consent=True) + + # Verify that the shared view is still private + response = self.send_get(client, query_parameters='view=shared') + self._verify_private_account_response(response, requires_parental_consent=True) + + @unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms') class TestAccountAPITransactions(TransactionTestCase): """ diff --git a/openedx/core/djangoapps/user_api/accounts/views.py b/openedx/core/djangoapps/user_api/accounts/views.py index 598e59186a..1bd0696f54 100644 --- a/openedx/core/djangoapps/user_api/accounts/views.py +++ b/openedx/core/djangoapps/user_api/accounts/views.py @@ -47,18 +47,16 @@ class AccountView(APIView): format provided by datetime. For example, "2014-08-26T17:52:11Z". - * gender: One of the fullowing values: - - * "m" - * "f" - * "o" - * null + * gender: One of the following values: + * "m" + * "f" + * "o" + * null * year_of_birth: The year the user was born, as an integer, or - null. + null. * level_of_education: One of the following values: - * "p": PhD or Doctorate * "m": Master's or professional degree * "b": Bachelor's degree @@ -72,10 +70,13 @@ class AccountView(APIView): * language: The user's preferred language, or null. + * country: null (not set), or a Country corresponding to one of + the ISO 3166-1 countries. + * country: A ISO 3166 country code or null. * mailing_address: The textual representation of the user's - mailing address, or null. + mailing address, or null. * goals: The textual representation of the user's goals, or null. @@ -98,6 +99,10 @@ class AccountView(APIView): * requires_parental_consent: true if the user is a minor requiring parental consent. + * language_proficiencies: array of language preferences. + Each preference is a JSON object with the following keys: + * "code": string ISO 639-1 language code e.g. "en". + For all text fields, clients rendering the values should take care to HTML escape them to avoid script injections, as the data is stored exactly as specified. The intention is that plain text is From 46b63164a083e26b065d86cb45db4e31cb14392e Mon Sep 17 00:00:00 2001 From: jsa Date: Mon, 9 Mar 2015 11:29:42 -0400 Subject: [PATCH 09/53] Implement profile_image upload and remove endpoints TNL-1537 Co-Authored-By: Andy Armstrong Co-Authored-By: cahrens --- common/djangoapps/enrollment/views.py | 5 +- lms/djangoapps/commerce/views.py | 2 +- lms/djangoapps/mobile_api/utils.py | 8 +- lms/envs/common.py | 11 +- lms/envs/test.py | 2 + lms/urls.py | 4 + .../djangoapps/profile_images/__init__.py | 0 .../core/djangoapps/profile_images/images.py | 132 ++++++++ .../profile_images/tests/__init__.py | 0 .../profile_images/tests/helpers.py | 55 ++++ .../profile_images/tests/test_images.py | 182 +++++++++++ .../profile_images/tests/test_views.py | 304 ++++++++++++++++++ .../core/djangoapps/profile_images/urls.py | 22 ++ .../core/djangoapps/profile_images/views.py | 152 +++++++++ .../djangoapps/user_api/accounts/helpers.py | 57 ---- .../user_api/accounts/image_helpers.py | 115 +++++++ .../user_api/accounts/serializers.py | 9 +- .../user_api/accounts/tests/test_api.py | 5 +- .../user_api/accounts/tests/test_helpers.py | 69 ---- .../accounts/tests/test_image_helpers.py | 60 ++++ .../user_api/accounts/tests/test_views.py | 9 +- .../djangoapps/user_api/accounts/views.py | 17 +- .../djangoapps/user_api/preferences/api.py | 1 - .../user_api/preferences/tests/test_views.py | 10 +- .../djangoapps/user_api/preferences/views.py | 45 ++- .../djangoapps/user_api/tests/test_views.py | 1 + openedx/core/djangoapps/user_api/views.py | 11 +- .../core/lib/api}/authentication.py | 0 openedx/core/lib/api/permissions.py | 21 +- openedx/core/lib/api/tests/__init__.py | 0 .../lib/api}/tests/test_authentication.py | 0 31 files changed, 1135 insertions(+), 174 deletions(-) create mode 100644 openedx/core/djangoapps/profile_images/__init__.py create mode 100644 openedx/core/djangoapps/profile_images/images.py create mode 100644 openedx/core/djangoapps/profile_images/tests/__init__.py create mode 100644 openedx/core/djangoapps/profile_images/tests/helpers.py create mode 100644 openedx/core/djangoapps/profile_images/tests/test_images.py create mode 100644 openedx/core/djangoapps/profile_images/tests/test_views.py create mode 100644 openedx/core/djangoapps/profile_images/urls.py create mode 100644 openedx/core/djangoapps/profile_images/views.py delete mode 100644 openedx/core/djangoapps/user_api/accounts/helpers.py create mode 100644 openedx/core/djangoapps/user_api/accounts/image_helpers.py delete mode 100644 openedx/core/djangoapps/user_api/accounts/tests/test_helpers.py create mode 100644 openedx/core/djangoapps/user_api/accounts/tests/test_image_helpers.py rename {common/djangoapps/util => openedx/core/lib/api}/authentication.py (100%) create mode 100644 openedx/core/lib/api/tests/__init__.py rename {common/djangoapps/util => openedx/core/lib/api}/tests/test_authentication.py (100%) 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/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/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/envs/common.py b/lms/envs/common.py index 6a27103021..14fe067eaf 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -2259,7 +2259,13 @@ FIELD_OVERRIDE_PROVIDERS = () # PROFILE IMAGE CONFIG # TODO: add these settings to aws.py as well -PROFILE_IMAGE_BACKEND = 'django.core.files.storage.FileSystemStorage' +# WARNING: Certain django storage backends do not support atomic +# file overwrites (including the default, specified below) - 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 = 'storages.backends.overwrite.OverwriteStorage' # PROFILE_IMAGE_DOMAIN points to the domain from which we serve image # files from. When this is '/', it refers to the same domain as the # app server. If serving from a different domain, specify that here @@ -2272,4 +2278,5 @@ PROFILE_IMAGE_DEFAULT_FILENAME = 'default_profile_image' # TODO: determine fina # 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 58e1e7fef4..fdcf919dbe 100644 --- a/lms/envs/test.py +++ b/lms/envs/test.py @@ -483,3 +483,5 @@ PROFILE_IMAGE_DOMAIN = 'http://example-storage.com/' PROFILE_IMAGE_URL_PATH = 'profile_images/' PROFILE_IMAGE_DEFAULT_FILENAME = 'default' PROFILE_IMAGE_SECRET_KEY = 'secret' +PROFILE_IMAGE_MAX_BYTES = 1024 * 1024 +PROFILE_IMAGE_MIN_BYTES = 100 diff --git a/lms/urls.py b/lms/urls.py index bb041f9876..7822ea7513 100644 --- a/lms/urls.py +++ b/lms/urls.py @@ -91,6 +91,7 @@ urlpatterns = ( if settings.FEATURES["ENABLE_USER_REST_API"]: urlpatterns += ( url(r'^api/user/', include('openedx.core.djangoapps.user_api.urls')), + url(r'^api/profile_images/', include('openedx.core.djangoapps.profile_images.urls')), ) if settings.FEATURES["ENABLE_COMBINED_LOGIN_REGISTRATION"]: @@ -637,6 +638,9 @@ urlpatterns = patterns(*urlpatterns) if settings.DEBUG: urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT) + urlpatterns += static( + settings.PROFILE_IMAGE_DOMAIN + settings.PROFILE_IMAGE_URL_PATH, document_root=settings.MEDIA_ROOT + ) # in debug mode, allow any template to be rendered (most useful for UX reference templates) urlpatterns += url(r'^template/(?P