From 56d49e7390dd8286d5222f4764cb838f46fe04da Mon Sep 17 00:00:00 2001 From: Eric Fischer Date: Wed, 21 Oct 2015 13:34:22 -0400 Subject: [PATCH] CohortMemberships Migrations These are the migrations needed for CohortMembership to function. 0005 establishes the table, 0006 will be used to move existing data into the table as needed. Per product guidance, we can just arbitrarily reassign problem users. Implementing that decision, as well as the remainder of the data migration. Also including a short script to re-run the 0006 migration after code changes are live, to prevent a potential issue where the database become out-of-sync. --- .../migrations/0005_cohort_membership.py | 109 ++++++++++++++ .../0006_cohort_membership_data_migrate.py | 138 ++++++++++++++++++ .../course_groups/migrations/rerun_0006.sh | 8 + .../core/djangoapps/course_groups/models.py | 2 +- 4 files changed, 256 insertions(+), 1 deletion(-) create mode 100644 openedx/core/djangoapps/course_groups/migrations/0005_cohort_membership.py create mode 100644 openedx/core/djangoapps/course_groups/migrations/0006_cohort_membership_data_migrate.py create mode 100644 openedx/core/djangoapps/course_groups/migrations/rerun_0006.sh diff --git a/openedx/core/djangoapps/course_groups/migrations/0005_cohort_membership.py b/openedx/core/djangoapps/course_groups/migrations/0005_cohort_membership.py new file mode 100644 index 0000000000..8993f26e59 --- /dev/null +++ b/openedx/core/djangoapps/course_groups/migrations/0005_cohort_membership.py @@ -0,0 +1,109 @@ +# -*- 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 'CohortMembership' + db.create_table('course_groups_cohortmembership', ( + ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)), + ('course_user_group', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['course_groups.CourseUserGroup'])), + ('user', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['auth.User'])), + ('course_id', self.gf('xmodule_django.models.CourseKeyField')(max_length=255)), + )) + db.send_create_signal('course_groups', ['CohortMembership']) + + # Adding unique constraint on 'CohortMembership', fields ['user', 'course_id'] + db.create_unique('course_groups_cohortmembership', ['user_id', 'course_id']) + + + def backwards(self, orm): + # Removing unique constraint on 'CohortMembership', fields ['user', 'course_id'] + db.delete_unique('course_groups_cohortmembership', ['user_id', 'course_id']) + + # Deleting model 'CohortMembership' + db.delete_table('course_groups_cohortmembership') + + + 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'}) + }, + 'course_groups.cohortmembership': { + 'Meta': {'unique_together': "(('user', 'course_id'),)", 'object_name': 'CohortMembership'}, + 'course_id': ('xmodule_django.models.CourseKeyField', [], {'max_length': '255'}), + 'course_user_group': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['course_groups.CourseUserGroup']"}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}) + }, + 'course_groups.coursecohort': { + 'Meta': {'object_name': 'CourseCohort'}, + 'assignment_type': ('django.db.models.fields.CharField', [], {'default': "'manual'", 'max_length': '20'}), + 'course_user_group': ('django.db.models.fields.related.OneToOneField', [], {'related_name': "'cohort'", 'unique': 'True', 'to': "orm['course_groups.CourseUserGroup']"}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}) + }, + 'course_groups.coursecohortssettings': { + 'Meta': {'object_name': 'CourseCohortsSettings'}, + '_cohorted_discussions': ('django.db.models.fields.TextField', [], {'null': 'True', 'db_column': "'cohorted_discussions'", 'blank': 'True'}), + 'always_cohort_inline_discussions': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'course_id': ('xmodule_django.models.CourseKeyField', [], {'unique': 'True', 'max_length': '255', 'db_index': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'is_cohorted': ('django.db.models.fields.BooleanField', [], {'default': 'False'}) + }, + 'course_groups.courseusergroup': { + 'Meta': {'unique_together': "(('name', 'course_id'),)", 'object_name': 'CourseUserGroup'}, + 'course_id': ('xmodule_django.models.CourseKeyField', [], {'max_length': '255', 'db_index': 'True'}), + 'group_type': ('django.db.models.fields.CharField', [], {'max_length': '20'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}), + 'users': ('django.db.models.fields.related.ManyToManyField', [], {'db_index': 'True', 'related_name': "'course_groups'", 'symmetrical': 'False', 'to': "orm['auth.User']"}) + }, + 'course_groups.courseusergrouppartitiongroup': { + 'Meta': {'object_name': 'CourseUserGroupPartitionGroup'}, + 'course_user_group': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['course_groups.CourseUserGroup']", 'unique': 'True'}), + 'created_at': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}), + 'group_id': ('django.db.models.fields.IntegerField', [], {}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'partition_id': ('django.db.models.fields.IntegerField', [], {}), + 'updated_at': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'blank': 'True'}) + } + } + + complete_apps = ['course_groups'] diff --git a/openedx/core/djangoapps/course_groups/migrations/0006_cohort_membership_data_migrate.py b/openedx/core/djangoapps/course_groups/migrations/0006_cohort_membership_data_migrate.py new file mode 100644 index 0000000000..523d7007b0 --- /dev/null +++ b/openedx/core/djangoapps/course_groups/migrations/0006_cohort_membership_data_migrate.py @@ -0,0 +1,138 @@ +# -*- coding: utf-8 -*- +from south.utils import datetime_utils as datetime +from south.db import db +from south.v2 import DataMigration +from django.db import models, IntegrityError, transaction + + +class Migration(DataMigration): + + def forwards(self, orm): + # Matches CourseUserGroup.COHORT + cohort_type = 'cohort' + + for cohort_group in orm.CourseUserGroup.objects.all(): + for user in cohort_group.users.all(): + current_course_groups = orm.CourseUserGroup.objects.filter( + course_id=cohort_group.course_id, + users__id=user.id, + group_type=cohort_type + ) + current_user_groups = user.course_groups.filter( + course_id=cohort_group.course_id, + group_type=cohort_type + ) + + unioned_set = set(current_course_groups).union(set(current_user_groups)) + + # Per product guidance, fix problem users by arbitrarily choosing a single membership to retain + arbitrary_cohort_to_keep = unioned_set.pop() + + try: + membership = orm.CohortMembership( + course_user_group=arbitrary_cohort_to_keep, + user=user, + course_id=arbitrary_cohort_to_keep.course_id + ) + membership.save() + except IntegrityError: + # It's possible a user already has a conflicting entry in the db. Treat that as correct. + unioned_set.add(arbitrary_cohort_to_keep) + try: + valid_membership = orm.CohortMembership.objects.get( + course_id = cohort_group.course_id, + user__id=user.id + ) + actual_cohort_to_keep = orm.CourseUserGroup.objects.get( + id=valid_membership.course_user_group.id + ) + unioned_set.remove(actual_cohort_to_keep) + except KeyError: + actual_cohort_to_keep.users.add(user) + + for cohort_itr in unioned_set: + cohort_itr.users.remove(user) + user.course_groups.remove(cohort_itr) + + def backwards(self, orm): + # A backwards migration just means dropping the table, which 0005 handles in its backwards() method + pass + + 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'}) + }, + 'course_groups.cohortmembership': { + 'Meta': {'unique_together': "(('user', 'course_id'),)", 'object_name': 'CohortMembership'}, + 'course_id': ('xmodule_django.models.CourseKeyField', [], {'max_length': '255'}), + 'course_user_group': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['course_groups.CourseUserGroup']"}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}) + }, + 'course_groups.coursecohort': { + 'Meta': {'object_name': 'CourseCohort'}, + 'assignment_type': ('django.db.models.fields.CharField', [], {'default': "'manual'", 'max_length': '20'}), + 'course_user_group': ('django.db.models.fields.related.OneToOneField', [], {'related_name': "'cohort'", 'unique': 'True', 'to': "orm['course_groups.CourseUserGroup']"}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}) + }, + 'course_groups.coursecohortssettings': { + 'Meta': {'object_name': 'CourseCohortsSettings'}, + '_cohorted_discussions': ('django.db.models.fields.TextField', [], {'null': 'True', 'db_column': "'cohorted_discussions'", 'blank': 'True'}), + 'always_cohort_inline_discussions': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'course_id': ('xmodule_django.models.CourseKeyField', [], {'unique': 'True', 'max_length': '255', 'db_index': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'is_cohorted': ('django.db.models.fields.BooleanField', [], {'default': 'False'}) + }, + 'course_groups.courseusergroup': { + 'Meta': {'unique_together': "(('name', 'course_id'),)", 'object_name': 'CourseUserGroup'}, + 'course_id': ('xmodule_django.models.CourseKeyField', [], {'max_length': '255', 'db_index': 'True'}), + 'group_type': ('django.db.models.fields.CharField', [], {'max_length': '20'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}), + 'users': ('django.db.models.fields.related.ManyToManyField', [], {'db_index': 'True', 'related_name': "'course_groups'", 'symmetrical': 'False', 'to': "orm['auth.User']"}) + }, + 'course_groups.courseusergrouppartitiongroup': { + 'Meta': {'object_name': 'CourseUserGroupPartitionGroup'}, + 'course_user_group': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['course_groups.CourseUserGroup']", 'unique': 'True'}), + 'created_at': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}), + 'group_id': ('django.db.models.fields.IntegerField', [], {}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'partition_id': ('django.db.models.fields.IntegerField', [], {}), + 'updated_at': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'blank': 'True'}) + } + } + + complete_apps = ['course_groups'] diff --git a/openedx/core/djangoapps/course_groups/migrations/rerun_0006.sh b/openedx/core/djangoapps/course_groups/migrations/rerun_0006.sh new file mode 100644 index 0000000000..d40e60d2b0 --- /dev/null +++ b/openedx/core/djangoapps/course_groups/migrations/rerun_0006.sh @@ -0,0 +1,8 @@ +#!/bin/bash +if [ $# -eq 0 ]; then + echo "$0: usage: rerun_0006.sh . At minimum, '--settings=' is expected." + exit 1 +fi + +./manage.py lms migrate course_groups 0005 --fake "$@" +./manage.py lms migrate course_groups 0006 "$@" diff --git a/openedx/core/djangoapps/course_groups/models.py b/openedx/core/djangoapps/course_groups/models.py index e17c40fd46..4bf76d30e9 100644 --- a/openedx/core/djangoapps/course_groups/models.py +++ b/openedx/core/djangoapps/course_groups/models.py @@ -37,7 +37,7 @@ class CourseUserGroup(models.Model): # For now, only have group type 'cohort', but adding a type field to support # things like 'question_discussion', 'friends', 'off-line-class', etc - COHORT = 'cohort' + COHORT = 'cohort' # If changing this string, update it in migration 0006.forwards() as well GROUP_TYPE_CHOICES = ((COHORT, 'Cohort'),) group_type = models.CharField(max_length=20, choices=GROUP_TYPE_CHOICES)