diff --git a/lms/envs/common.py b/lms/envs/common.py index b06f9fd7e4..0dfd8abc02 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -2512,6 +2512,9 @@ INSTALLED_APPS = [ # signal handlers to capture course dates into edx-when 'openedx.core.djangoapps.course_date_signals', + + # Management of external user ids + 'openedx.core.djangoapps.external_user_ids', ] ######################### CSRF ######################################### diff --git a/openedx/core/djangoapps/external_user_ids/README.rst b/openedx/core/djangoapps/external_user_ids/README.rst new file mode 100644 index 0000000000..450277386c --- /dev/null +++ b/openedx/core/djangoapps/external_user_ids/README.rst @@ -0,0 +1,19 @@ +Status: Active + +Responsibilities +================ +The external_user_ids app links ids to a user. The internal database id +associated with a user may not be appropriate to send outside of Open edX, so +this app contains external ids for users. + +This app can link an internal user id to an external user id of a particular +type, and can also link an external user id to an internal id (and thus to a +particular user). + +Intended responsibility: Management of ids associated with a user. + +Glossary +======== + +More Documentation +================== diff --git a/openedx/core/djangoapps/external_user_ids/__init__.py b/openedx/core/djangoapps/external_user_ids/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/openedx/core/djangoapps/external_user_ids/migrations/0001_initial.py b/openedx/core/djangoapps/external_user_ids/migrations/0001_initial.py new file mode 100644 index 0000000000..dce07f4b9a --- /dev/null +++ b/openedx/core/djangoapps/external_user_ids/migrations/0001_initial.py @@ -0,0 +1,101 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.28 on 2020-02-10 17:53 +from __future__ import unicode_literals + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import django.utils.timezone +import model_utils.fields +import simple_history.models +import uuid + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='ExternalId', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')), + ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')), + ('external_user_id', models.UUIDField(default=uuid.uuid4, editable=False, unique=True)), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='ExternalIdType', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')), + ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')), + ('name', models.CharField(db_index=True, max_length=32, unique=True)), + ('description', models.TextField()), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='HistoricalExternalId', + fields=[ + ('id', models.IntegerField(auto_created=True, blank=True, db_index=True, verbose_name='ID')), + ('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')), + ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')), + ('external_user_id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False)), + ('history_id', models.AutoField(primary_key=True, serialize=False)), + ('history_date', models.DateTimeField()), + ('history_change_reason', models.CharField(max_length=100, null=True)), + ('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)), + ('external_id_type', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='external_user_ids.ExternalIdType')), + ('history_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), + ('user', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ('-history_date', '-history_id'), + 'get_latest_by': 'history_date', + 'verbose_name': 'historical external id', + }, + bases=(simple_history.models.HistoricalChanges, models.Model), + ), + migrations.CreateModel( + name='HistoricalExternalIdType', + fields=[ + ('id', models.IntegerField(auto_created=True, blank=True, db_index=True, verbose_name='ID')), + ('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')), + ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')), + ('name', models.CharField(db_index=True, max_length=32)), + ('description', models.TextField()), + ('history_id', models.AutoField(primary_key=True, serialize=False)), + ('history_date', models.DateTimeField()), + ('history_change_reason', models.CharField(max_length=100, null=True)), + ('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)), + ('history_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ('-history_date', '-history_id'), + 'get_latest_by': 'history_date', + 'verbose_name': 'historical external id type', + }, + bases=(simple_history.models.HistoricalChanges, models.Model), + ), + migrations.AddField( + model_name='externalid', + name='external_id_type', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='external_user_ids.ExternalIdType'), + ), + migrations.AddField( + model_name='externalid', + name='user', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL), + ), + ] diff --git a/openedx/core/djangoapps/external_user_ids/migrations/0002_mb_coaching_20200210_1754.py b/openedx/core/djangoapps/external_user_ids/migrations/0002_mb_coaching_20200210_1754.py new file mode 100644 index 0000000000..e7f92d2bf2 --- /dev/null +++ b/openedx/core/djangoapps/external_user_ids/migrations/0002_mb_coaching_20200210_1754.py @@ -0,0 +1,33 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.28 on 2020-02-10 17:54 +from __future__ import unicode_literals + +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ('external_user_ids', '0001_initial'), + ] + + coaching_name = 'mb_coaching' + + def create_mb_coaching_type(apps, schema_editor): + """ + Add a MicroBachelors (MB) coaching type + """ + ExternalIdType = apps.get_model('external_user_ids', 'ExternalIdType') + ExternalIdType.objects.update_or_create(name=Migration.coaching_name, description='MicroBachelors Coaching') + + def delete_mb_coaching_type(apps, schema_editor): + """ + Delete the MicroBachelors (MB) coaching type + """ + ExternalIdType = apps.get_model('external_user_ids', 'ExternalIdType') + ExternalIdType.objects.filter( + name=Migration.coaching_name + ).delete() + + operations = [ + migrations.RunPython(create_mb_coaching_type, reverse_code=delete_mb_coaching_type), + ] diff --git a/openedx/core/djangoapps/external_user_ids/migrations/__init__.py b/openedx/core/djangoapps/external_user_ids/migrations/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/openedx/core/djangoapps/external_user_ids/models.py b/openedx/core/djangoapps/external_user_ids/models.py new file mode 100644 index 0000000000..101143a07a --- /dev/null +++ b/openedx/core/djangoapps/external_user_ids/models.py @@ -0,0 +1,38 @@ +""" +Models for External User Ids that are sent out of Open edX +""" + +import uuid as uuid_tools + +from django.contrib.auth.models import User +from django.db import models +from model_utils.models import TimeStampedModel +from simple_history.models import HistoricalRecords + + +class ExternalIdType(TimeStampedModel): + """ + ExternalIdType defines the type (purpose, or expected use) of an external id. A user may have one id that is sent + to Company A and another that is sent to Company B. + + .. no_pii: + """ + name = models.CharField(max_length=32, blank=False, unique=True, db_index=True) + description = models.TextField() + history = HistoricalRecords() + + +class ExternalId(TimeStampedModel): + """ + External ids are sent to systems or companies outside of Open edX. This allows us to limit the exposure of any + given id. + + An external id is linked to an internal id, so that users may be re-identified if the external id is sent + back to Open edX. + + .. no_pii: We store external_user_id here, but do not consider that PII under OEP-30. + """ + external_user_id = models.UUIDField(default=uuid_tools.uuid4, editable=False, unique=True) + external_id_type = models.ForeignKey(ExternalIdType, db_index=True, on_delete=models.CASCADE) + user = models.ForeignKey(User, db_index=True, on_delete=models.CASCADE) + history = HistoricalRecords()