diff --git a/common/djangoapps/student/migrations/0034_auto__add_courseaccessrole.py b/common/djangoapps/student/migrations/0034_auto__add_courseaccessrole.py new file mode 100644 index 0000000000..d6267ecb01 --- /dev/null +++ b/common/djangoapps/student/migrations/0034_auto__add_courseaccessrole.py @@ -0,0 +1,189 @@ +# -*- coding: utf-8 -*- +import datetime +from south.db import db +from south.v2 import SchemaMigration +from django.db import models + + +class Migration(SchemaMigration): + + def forwards(self, orm): + # Adding model 'CourseAccessRole' + db.create_table('student_courseaccessrole', ( + ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)), + ('user', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['auth.User'])), + ('org', self.gf('django.db.models.fields.CharField')(db_index=True, max_length=64, blank=True)), + ('course_id', self.gf('xmodule_django.models.CourseKeyField')(db_index=True, max_length=255, blank=True)), + ('role', self.gf('django.db.models.fields.CharField')(max_length=64, db_index=True)), + )) + db.send_create_signal('student', ['CourseAccessRole']) + + # Adding unique constraint on 'CourseAccessRole', fields ['user', 'org', 'course_id', 'role'] + db.create_unique('student_courseaccessrole', ['user_id', 'org', 'course_id', 'role']) + + + # Changing field 'AnonymousUserId.course_id' + db.alter_column('student_anonymoususerid', 'course_id', self.gf('xmodule_django.models.CourseKeyField')(max_length=255)) + + # Changing field 'CourseEnrollment.course_id' + db.alter_column('student_courseenrollment', 'course_id', self.gf('xmodule_django.models.CourseKeyField')(max_length=255)) + + # Changing field 'CourseEnrollmentAllowed.course_id' + db.alter_column('student_courseenrollmentallowed', 'course_id', self.gf('xmodule_django.models.CourseKeyField')(max_length=255)) + + def backwards(self, orm): + # Removing unique constraint on 'CourseAccessRole', fields ['user', 'org', 'course_id', 'role'] + db.delete_unique('student_courseaccessrole', ['user_id', 'org', 'course_id', 'role']) + + # Deleting model 'CourseAccessRole' + db.delete_table('student_courseaccessrole') + + + # Changing field 'AnonymousUserId.course_id' + db.alter_column('student_anonymoususerid', 'course_id', self.gf('django.db.models.fields.CharField')(max_length=255)) + + # Changing field 'CourseEnrollment.course_id' + db.alter_column('student_courseenrollment', 'course_id', self.gf('django.db.models.fields.CharField')(max_length=255)) + + # Changing field 'CourseEnrollmentAllowed.course_id' + db.alter_column('student_courseenrollmentallowed', 'course_id', self.gf('django.db.models.fields.CharField')(max_length=255)) + + 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.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'}), + '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.userstanding': { + 'Meta': {'object_name': 'UserStanding'}, + 'account_status': ('django.db.models.fields.CharField', [], {'max_length': '31', 'blank': 'True'}), + 'changed_by': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']", 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'standing_last_changed_at': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'blank': 'True'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'standing'", 'unique': 'True', 'to': "orm['auth.User']"}) + }, + 'student.usertestgroup': { + 'Meta': {'object_name': 'UserTestGroup'}, + 'description': ('django.db.models.fields.TextField', [], {'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '32', 'db_index': 'True'}), + 'users': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.User']", 'db_index': 'True', 'symmetrical': 'False'}) + } + } + + complete_apps = ['student'] \ No newline at end of file diff --git a/common/djangoapps/student/migrations/0035_access_roles.py b/common/djangoapps/student/migrations/0035_access_roles.py new file mode 100644 index 0000000000..799665250f --- /dev/null +++ b/common/djangoapps/student/migrations/0035_access_roles.py @@ -0,0 +1,274 @@ +# -*- coding: utf-8 -*- +from south.db import db +from south.v2 import DataMigration +from django.db import models +from xmodule.modulestore.django import loc_mapper +import re +from xmodule.modulestore.locations import SlashSeparatedCourseKey +from opaque_keys import InvalidKeyError +import bson.son +import logging +from django.db.models.query_utils import Q + +log = logging.getLogger(__name__) + + +class Migration(DataMigration): + """ + Converts course_creator, instructor_, staff_, and betatestuser_ to new table + """ + + GROUP_ENTRY_RE = re.compile(r'(?Pstaff|instructor|beta_testers|course_creator_group)_?(?P.*)') + + def forwards(self, orm): + """ + Converts group table entries for write access and beta_test roles to course access roles table. + """ + # Note: Remember to use orm['appname.ModelName'] rather than "from appname.models..." + loc_map_collection = loc_mapper().location_map + # b/c the Groups table had several entries for each course, we need to ensure we process each unique + # course only once. The below datastructures help ensure that. + hold = {} + done = set() + orgs = {} + query = Q(name__startswith='course_creator_group') + for role in ['staff', 'instructor', 'beta_testers', ]: + query = query | Q(name__startswith=role) + for group in orm['auth.Group'].objects.filter(query).all(): + def _migrate_users(correct_course_key, lower_org): + """ + Get all the users from the old group and migrate to this course key in the new table + """ + for user in orm['auth.user'].objects.filter(groups=group).all(): + entry = orm['student.courseaccessrole']( + role=parsed_entry.group('role_id'), user=user, + org=correct_course_key.org, course_id=correct_course_key.to_deprecated_string() + ) + entry.save() + orgs[lower_org] = correct_course_key.org + done.add(correct_course_key) + + # should this actually loop through all groups and log any which are not compliant? That is, + # remove the above filter + parsed_entry = self.GROUP_ENTRY_RE.match(group.name) + if parsed_entry.group('role_id') == 'course_creator_group': + for user in orm['auth.user'].objects.filter(groups=group).all(): + entry = orm['student.courseaccessrole'](role=parsed_entry.group('role_id'), user=user) + entry.save() + else: + course_id_string = parsed_entry.group('course_id_string') + try: + course_key = SlashSeparatedCourseKey.from_deprecated_string(course_id_string) + if course_key not in done: + # is the downcased version, get the normal cased one. loc_mapper() has no + # methods taking downcased SSCK; so, need to do it manually here + correct_course_key = self._map_downcased_ssck(course_key, loc_map_collection, done) + _migrate_users(correct_course_key, course_key.org) + done.add(course_key) + except InvalidKeyError: + entry = loc_map_collection.find_one({ + 'course_id': re.compile(r'^{}$'.format(course_id_string), re.IGNORECASE) + }) + if entry is None: + # not a course_id as far as we can tell + if course_id_string not in done: + hold[course_id_string] = group + else: + correct_course_key = self._cache_done_return_ssck(entry, done) + _migrate_users(correct_course_key, entry['lower_id']['org']) + + # see if any in hold ere missed above + for not_ssck, group in hold.iteritems(): + if not_ssck not in done: + if not_ssck in orgs: + # they have org permission + for user in orm['auth.user'].objects.filter(groups=group).all(): + entry = orm['student.courseaccessrole']( + role=parsed_entry.group('role_id'), user=user, + org=orgs[not_ssck], + ) + entry.save() + else: + # should this just log or really make an effort to do the conversion? + log.warn("Didn't convert role %s", group.name) + + def backwards(self, orm): + "Write your backwards methods here." + # Since this migration is non-destructive (monotonically adds information), I'm not sure what + # the semantic of backwards should be other than perhaps clearing the table. + orm['student.courseaccessrole'].objects.all().delete() + + def _map_downcased_ssck(self, downcased_ssck, loc_map_collection, done): + """ + Get the normal cased version of this downcased slash sep course key and add + the lowercased locator form to done map + """ + # given the regex, the son may be an overkill + course_son = bson.son.SON([ + ('_id.org', re.compile(r'^{}$'.format(downcased_ssck.org), re.IGNORECASE)), + ('_id.course', re.compile(r'^{}$'.format(downcased_ssck.course), re.IGNORECASE)), + ('_id.name', re.compile(r'^{}$'.format(downcased_ssck.run), re.IGNORECASE)), + ]) + entry = loc_map_collection.find_one(course_son) + if entry: + return self._cache_done_return_ssck(entry, done) + else: + return None + + def _cache_done_return_ssck(self, entry, done): + """ + Add all the various formats which auth may use to the done set and return the ssck for the entry + """ + # cache that the dotted form is done too + if 'lower_course_id' in entry: + done.add(entry['lower_course_id']) + elif 'course_id' in entry: + done.add(entry['course_id'].lower()) + elif 'lower_org' in entry: + done.add('{}.{}'.format(entry['lower_org'], entry['lower_offering'])) + else: + done.add('{}.{}'.format(entry['org'].lower(), entry['offering'].lower())) + return SlashSeparatedCourseKey(*entry['_id'].values()) + + + 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.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'}), + '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.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'] + symmetrical = True diff --git a/common/djangoapps/student/models.py b/common/djangoapps/student/models.py index fc6ea6e344..757b33f2a1 100644 --- a/common/djangoapps/student/models.py +++ b/common/djangoapps/student/models.py @@ -27,9 +27,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 post_save from django.dispatch import receiver, Signal -import django.dispatch from django.core.exceptions import ObjectDoesNotExist from django.utils.translation import ugettext_noop from django_countries import CountryField @@ -38,11 +36,14 @@ from track.views import server_track from eventtracking import tracker from importlib import import_module -from xmodule.modulestore import Location +from xmodule.modulestore.locations import SlashSeparatedCourseKey from course_modes.models import CourseMode import lms.lib.comment_client as cc from util.query import use_read_replica_if_available +from xmodule_django.models import CourseKeyField, NoneToEmptyManager +from xmodule.modulestore.keys import CourseKey +from functools import total_ordering unenroll_done = Signal(providing_args=["course_enrollment"]) log = logging.getLogger(__name__) @@ -59,9 +60,12 @@ class AnonymousUserId(models.Model): We generate anonymous_user_id using md5 algorithm, and use result in hex form, so its length is equal to 32 bytes. """ + + objects = NoneToEmptyManager() + user = models.ForeignKey(User, db_index=True) anonymous_user_id = models.CharField(unique=True, max_length=32) - course_id = models.CharField(db_index=True, max_length=255) + course_id = CourseKeyField(db_index=True, max_length=255, blank=True) unique_together = (user, course_id) @@ -84,11 +88,12 @@ def anonymous_id_for_user(user, course_id): hasher = hashlib.md5() hasher.update(settings.SECRET_KEY) hasher.update(unicode(user.id)) - hasher.update(course_id.encode('utf-8')) + if course_id: + hasher.update(course_id.to_deprecated_string()) digest = hasher.hexdigest() try: - anonymous_user_id, created = AnonymousUserId.objects.get_or_create( + anonymous_user_id, __ = AnonymousUserId.objects.get_or_create( defaults={'anonymous_user_id': digest}, user=user, course_id=course_id @@ -267,7 +272,7 @@ def unique_id_for_user(user): """ # Setting course_id to '' makes it not affect the generated hash, # and thus produce the old per-student anonymous id - return anonymous_id_for_user(user, '') + return anonymous_id_for_user(user, None) # TODO: Should be renamed to generic UserGroup, and possibly @@ -572,7 +577,7 @@ class CourseEnrollment(models.Model): MODEL_TAGS = ['course_id', 'is_active', 'mode'] user = models.ForeignKey(User) - course_id = models.CharField(max_length=255, db_index=True) + course_id = CourseKeyField(max_length=255, db_index=True) created = models.DateTimeField(auto_now_add=True, null=True, db_index=True) # If is_active is False, then the student is not considered to be enrolled @@ -593,7 +598,7 @@ class CourseEnrollment(models.Model): ).format(self.user, self.course_id, self.created, self.is_active) @classmethod - def get_or_create_enrollment(cls, user, course_id): + def get_or_create_enrollment(cls, user, course_key): """ Create an enrollment for a user in a class. By default *this enrollment is not active*. This is useful for when an enrollment needs to go @@ -615,12 +620,14 @@ class CourseEnrollment(models.Model): # save it to the database so that it can have an ID that we can throw # into our CourseEnrollment object. Otherwise, we'll get an # IntegrityError for having a null user_id. + assert(isinstance(course_key, CourseKey)) + if user.id is None: user.save() enrollment, created = CourseEnrollment.objects.get_or_create( user=user, - course_id=course_id, + course_id=course_key, ) # If we *did* just create a new enrollment, set some defaults @@ -650,7 +657,7 @@ class CourseEnrollment(models.Model): """ is_course_full = False if course.max_student_enrollments_allowed is not None: - is_course_full = cls.num_enrolled_in(course.location.course_id) >= course.max_student_enrollments_allowed + is_course_full = cls.num_enrolled_in(course.id) >= course.max_student_enrollments_allowed return is_course_full def update_enrollment(self, mode=None, is_active=None): @@ -679,15 +686,13 @@ class CourseEnrollment(models.Model): if activation_changed or mode_changed: self.save() if activation_changed: - course_id_dict = Location.parse_course_id(self.course_id) if self.is_active: self.emit_event(EVENT_NAME_ENROLLMENT_ACTIVATED) dog_stats_api.increment( "common.student.enrollment", - tags=[u"org:{org}".format(**course_id_dict), - u"course:{course}".format(**course_id_dict), - u"run:{name}".format(**course_id_dict), + tags=[u"org:{}".format(self.course_id.org), + u"offering:{}".format(self.course_id.offering), u"mode:{}".format(self.mode)] ) @@ -698,9 +703,8 @@ class CourseEnrollment(models.Model): dog_stats_api.increment( "common.student.unenrollment", - tags=[u"org:{org}".format(**course_id_dict), - u"course:{course}".format(**course_id_dict), - u"run:{name}".format(**course_id_dict), + tags=[u"org:{}".format(self.course_id.org), + u"offering:{}".format(self.course_id.offering), u"mode:{}".format(self.mode)] ) @@ -711,9 +715,10 @@ class CourseEnrollment(models.Model): try: context = contexts.course_context_from_course_id(self.course_id) + assert(isinstance(self.course_id, SlashSeparatedCourseKey)) data = { 'user_id': self.user.id, - 'course_id': self.course_id, + 'course_id': self.course_id.to_deprecated_string(), 'mode': self.mode, } @@ -724,7 +729,7 @@ class CourseEnrollment(models.Model): log.exception('Unable to emit event %s for user %s and course %s', event_name, self.user.username, self.course_id) @classmethod - def enroll(cls, user, course_id, mode="honor"): + def enroll(cls, user, course_key, mode="honor"): """ Enroll a user in a course. This saves immediately. @@ -744,7 +749,7 @@ class CourseEnrollment(models.Model): It is expected that this method is called from a method which has already verified the user authentication and access. """ - enrollment = cls.get_or_create_enrollment(user, course_id) + enrollment = cls.get_or_create_enrollment(user, course_key) enrollment.update_enrollment(is_active=True, mode=mode) return enrollment @@ -824,7 +829,7 @@ class CourseEnrollment(models.Model): log.error(err_msg.format(email, course_id)) @classmethod - def is_enrolled(cls, user, course_id): + def is_enrolled(cls, user, course_key): """ Returns True if the user is enrolled in the course (the entry must exist and it must have `is_active=True`). Otherwise, returns False. @@ -836,7 +841,7 @@ class CourseEnrollment(models.Model): `course_id` is our usual course_id string (e.g. "edX/Test101/2013_Fall) """ try: - record = CourseEnrollment.objects.get(user=user, course_id=course_id) + record = CourseEnrollment.objects.get(user=user, course_id=course_key) return record.is_active except cls.DoesNotExist: return False @@ -854,13 +859,16 @@ class CourseEnrollment(models.Model): attribute), this method will automatically save it before adding an enrollment for it. - `course_id_partial` is a starting substring for a fully qualified - course_id (e.g. "edX/Test101/"). + `course_id_partial` (CourseKey) is missing the run component """ + assert isinstance(course_id_partial, SlashSeparatedCourseKey) + assert not course_id_partial.run # None or empty string + course_key = SlashSeparatedCourseKey(course_id_partial.org, course_id_partial.course, '') + querystring = unicode(course_key.to_deprecated_string()) try: return CourseEnrollment.objects.filter( user=user, - course_id__startswith=course_id_partial, + course_id__startswith=querystring, is_active=1 ).exists() except cls.DoesNotExist: @@ -944,7 +952,7 @@ class CourseEnrollmentAllowed(models.Model): even if the enrollment time window is past. """ email = models.CharField(max_length=255, db_index=True) - course_id = models.CharField(max_length=255, db_index=True) + course_id = CourseKeyField(max_length=255, db_index=True) auto_enroll = models.BooleanField(default=0) created = models.DateTimeField(auto_now_add=True, null=True, db_index=True) @@ -955,7 +963,51 @@ class CourseEnrollmentAllowed(models.Model): def __unicode__(self): return "[CourseEnrollmentAllowed] %s: %s (%s)" % (self.email, self.course_id, self.created) -# cache_relation(User.profile) + +@total_ordering +class CourseAccessRole(models.Model): + """ + Maps users to org, courses, and roles. Used by student.roles.CourseRole and OrgRole. + To establish a user as having a specific role over all courses in the org, create an entry + without a course_id. + """ + + objects = NoneToEmptyManager() + + user = models.ForeignKey(User) + # blank org is for global group based roles such as course creator (may be deprecated) + org = models.CharField(max_length=64, db_index=True, blank=True) + # blank course_id implies org wide role + course_id = CourseKeyField(max_length=255, db_index=True, blank=True) + role = models.CharField(max_length=64, db_index=True) + + class Meta: + unique_together = ('user', 'org', 'course_id', 'role') + + @property + def _key(self): + """ + convenience function to make eq overrides easier and clearer. arbitrary decision + that role is primary, followed by org, course, and then user + """ + return (self.role, self.org, self.course_id, self.user) + + def __eq__(self, other): + """ + Overriding eq b/c the django impl relies on the primary key which requires fetch. sometimes we + just want to compare roles w/o doing another fetch. + """ + return type(self) == type(other) and self._key == other._key + + def __hash__(self): + return hash(self._key) + + def __lt__(self, other): + """ + Lexigraphic sort + """ + return self._key < other._key + #### Helper methods for use from python manage.py shell and other classes. diff --git a/common/djangoapps/student/roles.py b/common/djangoapps/student/roles.py index e50e7290b3..26bb5941d1 100644 --- a/common/djangoapps/student/roles.py +++ b/common/djangoapps/student/roles.py @@ -5,19 +5,9 @@ adding users, removing users, and listing members from abc import ABCMeta, abstractmethod -from django.contrib.auth.models import User, Group - -from xmodule.modulestore import Location -from xmodule.modulestore.exceptions import InvalidLocationError, ItemNotFoundError -from xmodule.modulestore.django import loc_mapper -from xmodule.modulestore.locator import CourseLocator, Locator - - -class CourseContextRequired(Exception): - """ - Raised when a course_context is required to determine permissions - """ - pass +from django.contrib.auth.models import User +from student.models import CourseAccessRole +from xmodule_django.models import CourseKeyField class AccessRole(object): @@ -78,21 +68,25 @@ class GlobalStaff(AccessRole): raise Exception("This operation is un-indexed, and shouldn't be used") -class GroupBasedRole(AccessRole): +class RoleBase(AccessRole): """ - A role based on membership to any of a set of groups. + Roles by type (e.g., instructor, beta_user) and optionally org, course_key """ - def __init__(self, group_names): + def __init__(self, role_name, org='', course_key=CourseKeyField.Empty): """ - Create a GroupBasedRole from a list of group names - - The first element of `group_names` will be the preferred group - to use when adding a user to this Role. - - If a user is a member of any of the groups in the list, then - they will be consider a member of the Role + Create role from required role_name w/ optional org and course_key. You may just provide a role + name if it's a global role (not constrained to an org or course). Provide org if constrained to + an org. Provide org and course if constrained to a course. Although, you should use the subclasses + for all of these. """ - self._group_names = [name.lower() for name in group_names] + super(RoleBase, self).__init__() + + if course_key is None: + raise TypeError('course_key must be CourseKeyField.Empty or a valid CourseKey') + + self.org = org + self.course_key = course_key + self._role_name = role_name def has_user(self, user): """ @@ -102,10 +96,13 @@ class GroupBasedRole(AccessRole): return False # pylint: disable=protected-access - if not hasattr(user, '_groups'): - user._groups = set(name.lower() for name in user.groups.values_list('name', flat=True)) + if not hasattr(user, '_roles'): + user._roles = set( + CourseAccessRole.objects.filter(user=user).all() + ) - return len(user._groups.intersection(self._group_names)) > 0 + role = CourseAccessRole(user=user, role=self._role_name, course_id=self.course_key, org=self.org) + return role in user._roles def add_users(self, *users): """ @@ -113,87 +110,59 @@ class GroupBasedRole(AccessRole): """ # silently ignores anonymous and inactive users so that any that are # legit get updated. - users = [user for user in users if user.is_authenticated() and user.is_active] - group, _ = Group.objects.get_or_create(name=self._group_names[0]) - group.user_set.add(*users) - # remove cache for user in users: - if hasattr(user, '_groups'): - del user._groups + if user.is_authenticated and user.is_active and not self.has_user(user): + entry = CourseAccessRole(user=user, role=self._role_name, course_id=self.course_key, org=self.org) + entry.save() + if hasattr(user, '_roles'): + del user._roles def remove_users(self, *users): """ Remove the supplied django users from this role. """ - groups = Group.objects.filter(name__in=self._group_names) - for group in groups: - group.user_set.remove(*users) - # remove cache + entries = CourseAccessRole.objects.filter( + user__in=users, role=self._role_name, org=self.org, course_id=self.course_key + ) + entries.delete() for user in users: - if hasattr(user, '_groups'): - del user._groups + if hasattr(user, '_roles'): + del user._roles def users_with_role(self): """ Return a django QuerySet for all of the users with this role """ - return User.objects.filter(groups__name__in=self._group_names) + entries = User.objects.filter( + courseaccessrole__role=self._role_name, + courseaccessrole__org=self.org, + courseaccessrole__course_id=self.course_key + ) + return entries -class CourseRole(GroupBasedRole): +class CourseRole(RoleBase): """ A named role in a particular course """ - def __init__(self, role, location, course_context=None): + def __init__(self, role, course_key): """ - Location may be either a Location, a string, dict, or tuple which Location will accept - in its constructor, or a CourseLocator. Handle all these giving some preference to - the preferred naming. + Args: + course_key (CourseKey) """ - # TODO: figure out how to make the group name generation lazy so it doesn't force the - # loc mapping? - self.location = Locator.to_locator_or_location(location) - self.role = role - # direct copy from auth.authz.get_all_course_role_groupnames will refactor to one impl asap - groupnames = [] + super(CourseRole, self).__init__(role, course_key.org, course_key) - if isinstance(self.location, Location): - try: - groupnames.append(u'{0}_{1}'.format(role, self.location.course_id)) - course_context = self.location.course_id # course_id is valid for translation - except InvalidLocationError: # will occur on old locations where location is not of category course - if course_context is None: - raise CourseContextRequired() - else: - groupnames.append(u'{0}_{1}'.format(role, course_context)) - try: - locator = loc_mapper().translate_location_to_course_locator(course_context, self.location) - groupnames.append(u'{0}_{1}'.format(role, locator.package_id)) - except (InvalidLocationError, ItemNotFoundError): - # if it's never been mapped, the auth won't be via the Locator syntax - pass - # least preferred legacy role_course format - groupnames.append(u'{0}_{1}'.format(role, self.location.course)) # pylint: disable=E1101, E1103 - elif isinstance(self.location, CourseLocator): - groupnames.append(u'{0}_{1}'.format(role, self.location.package_id)) - # handle old Location syntax - old_location = loc_mapper().translate_locator_to_location(self.location, get_course=True) - if old_location: - # the slashified version of the course_id (myu/mycourse/myrun) - groupnames.append(u'{0}_{1}'.format(role, old_location.course_id)) - # add the least desirable but sometimes occurring format. - groupnames.append(u'{0}_{1}'.format(role, old_location.course)) # pylint: disable=E1101, E1103 - - super(CourseRole, self).__init__(groupnames) + @classmethod + def course_group_already_exists(self, course_key): + return CourseAccessRole.objects.filter(org=course_key.org, course_id=course_key).exists() -class OrgRole(GroupBasedRole): +class OrgRole(RoleBase): """ - A named role in a particular org + A named role in a particular org independent of course """ - def __init__(self, role, location): - location = Location(location) - super(OrgRole, self).__init__([u'{}_{}'.format(role, location.org)]) + def __init__(self, role, org): + super(OrgRole, self).__init__(role, org) class CourseStaffRole(CourseRole): @@ -207,6 +176,7 @@ class CourseStaffRole(CourseRole): class CourseInstructorRole(CourseRole): """A course Instructor""" ROLE = 'instructor' + def __init__(self, *args, **kwargs): super(CourseInstructorRole, self).__init__(self.ROLE, *args, **kwargs) @@ -214,6 +184,7 @@ class CourseInstructorRole(CourseRole): class CourseBetaTesterRole(CourseRole): """A course Beta Tester""" ROLE = 'beta_testers' + def __init__(self, *args, **kwargs): super(CourseBetaTesterRole, self).__init__(self.ROLE, *args, **kwargs) @@ -230,11 +201,73 @@ class OrgInstructorRole(OrgRole): super(OrgInstructorRole, self).__init__('instructor', *args, **kwargs) -class CourseCreatorRole(GroupBasedRole): +class CourseCreatorRole(RoleBase): """ This is the group of people who have permission to create new courses (we may want to eventually make this an org based role). """ ROLE = "course_creator_group" + def __init__(self, *args, **kwargs): - super(CourseCreatorRole, self).__init__([self.ROLE], *args, **kwargs) + super(CourseCreatorRole, self).__init__(self.ROLE, *args, **kwargs) + + +class UserBasedRole(object): + """ + Backward mapping: given a user, manipulate the courses and roles + """ + def __init__(self, user, role): + """ + Create a UserBasedRole accessor: for a given user and role (e.g., "instructor") + """ + self.user = user + self.role = role + + def has_course(self, course_key): + """ + Return whether the role's user has the configured role access to the passed course + """ + if not (self.user.is_authenticated() and self.user.is_active): + return False + + # pylint: disable=protected-access + if not hasattr(self.user, '_roles'): + self.user._roles = list( + CourseAccessRole.objects.filter(user=self.user).all() + ) + + role = CourseAccessRole(user=self.user, role=self.role, course_id=course_key, org=course_key.org) + return role in self.user._roles + + def add_course(self, *course_keys): + """ + Grant this object's user the object's role for the supplied courses + """ + if self.user.is_authenticated and self.user.is_active: + for course_key in course_keys: + entry = CourseAccessRole(user=self.user, role=self.role, course_id=course_key, org=course_key.org) + entry.save() + if hasattr(self.user, '_roles'): + del self.user._roles + else: + raise ValueError("user is not active. Cannot grant access to courses") + + def remove_courses(self, *course_keys): + """ + Remove the supplied courses from this user's configured role. + """ + entries = CourseAccessRole.objects.filter(user=self.user, role=self.role, course_id__in=course_keys) + entries.delete() + if hasattr(self.user, '_roles'): + del self.user._roles + + def courses_with_role(self): + """ + Return a django QuerySet for all of the courses with this user x role. You can access + any of these properties on each result record: + * user (will be self.user--thus uninteresting) + * org + * course_id + * role (will be self.role--thus uninteresting) + """ + return CourseAccessRole.objects.filter(role=self.role, user=self.user) diff --git a/common/djangoapps/student/tests/test_roles.py b/common/djangoapps/student/tests/test_roles.py index cd20d16cf6..14b62673fa 100644 --- a/common/djangoapps/student/tests/test_roles.py +++ b/common/djangoapps/student/tests/test_roles.py @@ -4,13 +4,12 @@ Tests of student.roles from django.test import TestCase -from xmodule.modulestore import Location from courseware.tests.factories import UserFactory, StaffFactory, InstructorFactory from student.tests.factories import AnonymousUserFactory -from student.roles import GlobalStaff, CourseRole, CourseStaffRole -from xmodule.modulestore.django import loc_mapper -from xmodule.modulestore.locator import BlockUsageLocator +from student.roles import GlobalStaff, CourseRole, CourseStaffRole, OrgStaffRole, OrgInstructorRole, \ + CourseInstructorRole +from xmodule.modulestore.locations import SlashSeparatedCourseKey class RolesTestCase(TestCase): @@ -19,12 +18,13 @@ class RolesTestCase(TestCase): """ def setUp(self): - self.course = Location('i4x://edX/toy/course/2012_Fall') + self.course_id = SlashSeparatedCourseKey('edX', 'toy', '2012_Fall') + self.course_loc = self.course_id.make_usage_key('course', '2012_Fall') self.anonymous_user = AnonymousUserFactory() self.student = UserFactory() self.global_staff = UserFactory(is_staff=True) - self.course_staff = StaffFactory(course=self.course) - self.course_instructor = InstructorFactory(course=self.course) + self.course_staff = StaffFactory(course=self.course_id) + self.course_instructor = InstructorFactory(course=self.course_id) def test_global_staff(self): self.assertFalse(GlobalStaff().has_user(self.student)) @@ -32,55 +32,124 @@ class RolesTestCase(TestCase): self.assertFalse(GlobalStaff().has_user(self.course_instructor)) self.assertTrue(GlobalStaff().has_user(self.global_staff)) - def test_group_name_case_insensitive(self): - uppercase_loc = "i4x://ORG/COURSE/course/NAME" - lowercase_loc = uppercase_loc.lower() + def test_group_name_case_sensitive(self): + uppercase_course_id = "ORG/COURSE/NAME" + lowercase_course_id = uppercase_course_id.lower() + uppercase_course_key = SlashSeparatedCourseKey.from_deprecated_string(uppercase_course_id) + lowercase_course_key = SlashSeparatedCourseKey.from_deprecated_string(lowercase_course_id) - lowercase_group = "role_org/course/name" - uppercase_group = lowercase_group.upper() + role = "role" - lowercase_user = UserFactory(groups=lowercase_group) - uppercase_user = UserFactory(groups=uppercase_group) + lowercase_user = UserFactory() + CourseRole(role, lowercase_course_key).add_users(lowercase_user) + uppercase_user = UserFactory() + CourseRole(role, uppercase_course_key).add_users(uppercase_user) - self.assertTrue(CourseRole("role", lowercase_loc).has_user(lowercase_user)) - self.assertTrue(CourseRole("role", uppercase_loc).has_user(lowercase_user)) - self.assertTrue(CourseRole("role", lowercase_loc).has_user(uppercase_user)) - self.assertTrue(CourseRole("role", uppercase_loc).has_user(uppercase_user)) + self.assertTrue(CourseRole(role, lowercase_course_key).has_user(lowercase_user)) + self.assertFalse(CourseRole(role, uppercase_course_key).has_user(lowercase_user)) + self.assertFalse(CourseRole(role, lowercase_course_key).has_user(uppercase_user)) + self.assertTrue(CourseRole(role, uppercase_course_key).has_user(uppercase_user)) def test_course_role(self): """ Test that giving a user a course role enables access appropriately """ - course_locator = loc_mapper().translate_location( - self.course.course_id, self.course, add_entry_if_missing=True + self.assertFalse( + CourseStaffRole(self.course_id).has_user(self.student), + "Student has premature access to {}".format(self.course_id) + ) + CourseStaffRole(self.course_id).add_users(self.student) + self.assertTrue( + CourseStaffRole(self.course_id).has_user(self.student), + "Student doesn't have access to {}".format(unicode(self.course_id)) + ) + + # remove access and confirm + CourseStaffRole(self.course_id).remove_users(self.student) + self.assertFalse( + CourseStaffRole(self.course_id).has_user(self.student), + "Student still has access to {}".format(self.course_id) + ) + + def test_org_role(self): + """ + Test that giving a user an org role enables access appropriately + """ + self.assertFalse( + OrgStaffRole(self.course_id.org).has_user(self.student), + "Student has premature access to {}".format(self.course_id.org) + ) + OrgStaffRole(self.course_id.org).add_users(self.student) + self.assertTrue( + OrgStaffRole(self.course_id.org).has_user(self.student), + "Student doesn't have access to {}".format(unicode(self.course_id.org)) + ) + + # remove access and confirm + OrgStaffRole(self.course_id.org).remove_users(self.student) + if hasattr(self.student, '_roles'): + del self.student._roles + self.assertFalse( + OrgStaffRole(self.course_id.org).has_user(self.student), + "Student still has access to {}".format(self.course_id.org) + ) + + def test_org_and_course_roles(self): + """ + Test that Org and course roles don't interfere with course roles or vice versa + """ + OrgInstructorRole(self.course_id.org).add_users(self.student) + CourseInstructorRole(self.course_id).add_users(self.student) + self.assertTrue( + OrgInstructorRole(self.course_id.org).has_user(self.student), + "Student doesn't have access to {}".format(unicode(self.course_id.org)) + ) + self.assertTrue( + CourseInstructorRole(self.course_id).has_user(self.student), + "Student doesn't have access to {}".format(unicode(self.course_id)) + ) + + # remove access and confirm + OrgInstructorRole(self.course_id.org).remove_users(self.student) + self.assertFalse( + OrgInstructorRole(self.course_id.org).has_user(self.student), + "Student still has access to {}".format(self.course_id.org) + ) + self.assertTrue( + CourseInstructorRole(self.course_id).has_user(self.student), + "Student doesn't have access to {}".format(unicode(self.course_id)) + ) + + # ok now keep org role and get rid of course one + OrgInstructorRole(self.course_id.org).add_users(self.student) + CourseInstructorRole(self.course_id).remove_users(self.student) + self.assertTrue( + OrgInstructorRole(self.course_id.org).has_user(self.student), + "Student lost has access to {}".format(self.course_id.org) ) self.assertFalse( - CourseStaffRole(course_locator).has_user(self.student), - "Student has premature access to {}".format(unicode(course_locator)) - ) - self.assertFalse( - CourseStaffRole(self.course).has_user(self.student), - "Student has premature access to {}".format(self.course.url()) - ) - CourseStaffRole(course_locator).add_users(self.student) - self.assertTrue( - CourseStaffRole(course_locator).has_user(self.student), - "Student doesn't have access to {}".format(unicode(course_locator)) - ) - self.assertTrue( - CourseStaffRole(self.course).has_user(self.student), - "Student doesn't have access to {}".format(unicode(self.course.url())) - ) - # now try accessing something internal to the course - vertical_locator = BlockUsageLocator( - package_id=course_locator.package_id, branch='published', block_id='madeup' - ) - vertical_location = self.course.replace(category='vertical', name='madeuptoo') - self.assertTrue( - CourseStaffRole(vertical_locator).has_user(self.student), - "Student doesn't have access to {}".format(unicode(vertical_locator)) - ) - self.assertTrue( - CourseStaffRole(vertical_location, course_context=self.course.course_id).has_user(self.student), - "Student doesn't have access to {}".format(unicode(vertical_location.url())) + CourseInstructorRole(self.course_id).has_user(self.student), + "Student doesn't have access to {}".format(unicode(self.course_id)) ) + + + def test_get_user_for_role(self): + """ + test users_for_role + """ + role = CourseStaffRole(self.course_id) + role.add_users(self.student) + self.assertGreater(len(role.users_with_role()), 0) + + def test_add_users_doesnt_add_duplicate_entry(self): + """ + Tests that calling add_users multiple times before a single call + to remove_users does not result in the user remaining in the group. + """ + role = CourseStaffRole(self.course_id) + role.add_users(self.student) + self.assertTrue(role.has_user(self.student)) + # Call add_users a second time, then remove just once. + role.add_users(self.student) + role.remove_users(self.student) + self.assertFalse(role.has_user(self.student)) diff --git a/common/djangoapps/student/tests/tests.py b/common/djangoapps/student/tests/tests.py index 48d8bb642e..fe56da5242 100644 --- a/common/djangoapps/student/tests/tests.py +++ b/common/djangoapps/student/tests/tests.py @@ -20,6 +20,7 @@ from django.http import HttpResponse from xmodule.modulestore.tests.factories import CourseFactory from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from courseware.tests.tests import TEST_DATA_MIXED_MODULESTORE +from xmodule.modulestore.locations import SlashSeparatedCourseKey from mock import Mock, patch, sentinel @@ -30,9 +31,6 @@ from student.tests.factories import UserFactory, CourseModeFactory import shoppingcart -COURSE_1 = 'edX/toy/2012_Fall' -COURSE_2 = 'edx/full/6.002_Spring_2012' - log = logging.getLogger(__name__) @@ -203,38 +201,33 @@ class EnrollInCourseTest(TestCase): def test_enrollment(self): user = User.objects.create_user("joe", "joe@joe.com", "password") - course_id = "edX/Test101/2013" - course_id_partial = "edX/Test101" + course_id = SlashSeparatedCourseKey("edX", "Test101", "2013") + course_id_partial = SlashSeparatedCourseKey("edX", "Test101", None) # Test basic enrollment self.assertFalse(CourseEnrollment.is_enrolled(user, course_id)) - self.assertFalse(CourseEnrollment.is_enrolled_by_partial(user, - course_id_partial)) + self.assertFalse(CourseEnrollment.is_enrolled_by_partial(user, course_id_partial)) CourseEnrollment.enroll(user, course_id) self.assertTrue(CourseEnrollment.is_enrolled(user, course_id)) - self.assertTrue(CourseEnrollment.is_enrolled_by_partial(user, - course_id_partial)) + self.assertTrue(CourseEnrollment.is_enrolled_by_partial(user, course_id_partial)) self.assert_enrollment_event_was_emitted(user, course_id) # Enrolling them again should be harmless CourseEnrollment.enroll(user, course_id) self.assertTrue(CourseEnrollment.is_enrolled(user, course_id)) - self.assertTrue(CourseEnrollment.is_enrolled_by_partial(user, - course_id_partial)) + self.assertTrue(CourseEnrollment.is_enrolled_by_partial(user, course_id_partial)) self.assert_no_events_were_emitted() # Now unenroll the user CourseEnrollment.unenroll(user, course_id) self.assertFalse(CourseEnrollment.is_enrolled(user, course_id)) - self.assertFalse(CourseEnrollment.is_enrolled_by_partial(user, - course_id_partial)) + self.assertFalse(CourseEnrollment.is_enrolled_by_partial(user, course_id_partial)) self.assert_unenrollment_event_was_emitted(user, course_id) # Unenrolling them again should also be harmless CourseEnrollment.unenroll(user, course_id) self.assertFalse(CourseEnrollment.is_enrolled(user, course_id)) - self.assertFalse(CourseEnrollment.is_enrolled_by_partial(user, - course_id_partial)) + self.assertFalse(CourseEnrollment.is_enrolled_by_partial(user, course_id_partial)) self.assert_no_events_were_emitted() # The enrollment record should still exist, just be inactive @@ -257,26 +250,26 @@ class EnrollInCourseTest(TestCase): self.assertFalse(self.mock_server_track.called) self.mock_server_track.reset_mock() - def assert_enrollment_event_was_emitted(self, user, course_id): + def assert_enrollment_event_was_emitted(self, user, course_key): """Ensures an enrollment event was emitted since the last event related assertion""" self.mock_server_track.assert_called_once_with( sentinel.request, 'edx.course.enrollment.activated', { - 'course_id': course_id, + 'course_id': course_key.to_deprecated_string(), 'user_id': user.pk, 'mode': 'honor' } ) self.mock_server_track.reset_mock() - def assert_unenrollment_event_was_emitted(self, user, course_id): + def assert_unenrollment_event_was_emitted(self, user, course_key): """Ensures an unenrollment event was emitted since the last event related assertion""" self.mock_server_track.assert_called_once_with( sentinel.request, 'edx.course.enrollment.deactivated', { - 'course_id': course_id, + 'course_id': course_key.to_deprecated_string(), 'user_id': user.pk, 'mode': 'honor' } @@ -286,7 +279,7 @@ class EnrollInCourseTest(TestCase): def test_enrollment_non_existent_user(self): # Testing enrollment of newly unsaved user (i.e. no database entry) user = User(username="rusty", email="rusty@fake.edx.org") - course_id = "edX/Test101/2013" + course_id = SlashSeparatedCourseKey("edX", "Test101", "2013") self.assertFalse(CourseEnrollment.is_enrolled(user, course_id)) @@ -302,7 +295,7 @@ class EnrollInCourseTest(TestCase): def test_enrollment_by_email(self): user = User.objects.create(username="jack", email="jack@fake.edx.org") - course_id = "edX/Test101/2013" + course_id = SlashSeparatedCourseKey("edX", "Test101", "2013") CourseEnrollment.enroll_by_email("jack@fake.edx.org", course_id) self.assertTrue(CourseEnrollment.is_enrolled(user, course_id)) @@ -339,8 +332,8 @@ class EnrollInCourseTest(TestCase): def test_enrollment_multiple_classes(self): user = User(username="rusty", email="rusty@fake.edx.org") - course_id1 = "edX/Test101/2013" - course_id2 = "MITx/6.003z/2012" + course_id1 = SlashSeparatedCourseKey("edX", "Test101", "2013") + course_id2 = SlashSeparatedCourseKey("MITx", "6.003z", "2012") CourseEnrollment.enroll(user, course_id1) self.assert_enrollment_event_was_emitted(user, course_id1) @@ -361,7 +354,7 @@ class EnrollInCourseTest(TestCase): def test_activation(self): user = User.objects.create(username="jack", email="jack@fake.edx.org") - course_id = "edX/Test101/2013" + course_id = SlashSeparatedCourseKey("edX", "Test101", "2013") self.assertFalse(CourseEnrollment.is_enrolled(user, course_id)) # Creating an enrollment doesn't actually enroll a student @@ -416,7 +409,7 @@ class PaidRegistrationTest(ModuleStoreTestCase): @unittest.skipUnless(settings.FEATURES.get('ENABLE_SHOPPING_CART'), "Shopping Cart not enabled in settings") def test_change_enrollment_add_to_cart(self): - request = self.req_factory.post(reverse('change_enrollment'), {'course_id': self.course.id, + request = self.req_factory.post(reverse('change_enrollment'), {'course_id': self.course.id.to_deprecated_string(), 'enrollment_action': 'add_to_cart'}) request.user = self.user response = change_enrollment(request)