From 4af3d58b39b45fcc28b5a0c90ddf3be1a5d447e2 Mon Sep 17 00:00:00 2001 From: ansabgillani Date: Tue, 15 Mar 2022 16:45:53 +0500 Subject: [PATCH] feat: Add SSO History for Support --- cms/envs/common.py | 1 + cms/envs/test.py | 1 - .../0009_historicalusersocialauth.py | 41 +++++++++++++++++++ common/djangoapps/third_party_auth/models.py | 7 ++++ lms/djangoapps/support/serializers.py | 38 ++++++++++++----- lms/djangoapps/support/tests/test_views.py | 17 ++++++++ lms/djangoapps/support/urls.py | 4 +- lms/djangoapps/support/views/sso_records.py | 38 ++++++++++++++++- 8 files changed, 133 insertions(+), 14 deletions(-) create mode 100644 common/djangoapps/third_party_auth/migrations/0009_historicalusersocialauth.py diff --git a/cms/envs/common.py b/cms/envs/common.py index d05ef07b6f..1e55de90b9 100644 --- a/cms/envs/common.py +++ b/cms/envs/common.py @@ -1673,6 +1673,7 @@ INSTALLED_APPS = [ # These are apps that aren't strictly needed by Studio, but are imported by # other apps that are. Django 1.8 wants to have imported models supported # by installed apps. + 'common.djangoapps.third_party_auth', 'openedx.core.djangoapps.oauth_dispatch.apps.OAuthDispatchAppConfig', 'lms.djangoapps.courseware', 'lms.djangoapps.coursewarehistoryextended', diff --git a/cms/envs/test.py b/cms/envs/test.py index a42b73336a..09cb504f7e 100644 --- a/cms/envs/test.py +++ b/cms/envs/test.py @@ -295,7 +295,6 @@ SECRET_KEY = '85920908f28904ed733fe576320db18cabd7b6cd' ######### custom courses ######### INSTALLED_APPS += [ 'openedx.core.djangoapps.ccxcon.apps.CCXConnectorConfig', - 'common.djangoapps.third_party_auth.apps.ThirdPartyAuthConfig', ] FEATURES['CUSTOM_COURSES_EDX'] = True diff --git a/common/djangoapps/third_party_auth/migrations/0009_historicalusersocialauth.py b/common/djangoapps/third_party_auth/migrations/0009_historicalusersocialauth.py new file mode 100644 index 0000000000..5857822d1d --- /dev/null +++ b/common/djangoapps/third_party_auth/migrations/0009_historicalusersocialauth.py @@ -0,0 +1,41 @@ +# Generated by Django 3.2.12 on 2022-04-15 01:04 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import simple_history.models +import social_django.fields + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('third_party_auth', '0008_auto_20220324_1422'), + ] + + operations = [ + migrations.CreateModel( + name='HistoricalUserSocialAuth', + fields=[ + ('id', models.IntegerField(auto_created=True, blank=True, db_index=True, verbose_name='ID')), + ('provider', models.CharField(max_length=32)), + ('uid', models.CharField(db_index=True, max_length=255)), + ('extra_data', social_django.fields.JSONField(default=dict)), + ('created', models.DateTimeField(blank=True, editable=False)), + ('modified', models.DateTimeField(blank=True, 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)), + ('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={ + 'verbose_name': 'historical user social auth', + 'ordering': ('-history_date', '-history_id'), + 'get_latest_by': 'history_date', + }, + bases=(simple_history.models.HistoricalChanges, models.Model), + ), + ] diff --git a/common/djangoapps/third_party_auth/models.py b/common/djangoapps/third_party_auth/models.py index f0a37616a3..4cd636dedb 100644 --- a/common/djangoapps/third_party_auth/models.py +++ b/common/djangoapps/third_party_auth/models.py @@ -9,6 +9,8 @@ import logging import re from config_models.models import ConfigurationModel, cache +from simple_history import register +from social_django.models import UserSocialAuth from django.conf import settings from django.contrib.sites.models import Site from django.core.exceptions import ValidationError @@ -37,6 +39,11 @@ REGISTRATION_FORM_FIELD_BLACKLIST = [ 'username' ] +# Registers UserSocialAuth with simple-django-history. +# This registration makes third_party_auth a required app for Studio, +# even when it is supposed to be for LMS only. +register(UserSocialAuth, app=__package__) + # A dictionary of {name: class} entries for each python-social-auth backend available. # Because this setting can specify arbitrary code to load and execute, it is set via diff --git a/lms/djangoapps/support/serializers.py b/lms/djangoapps/support/serializers.py index 0d4daa83e9..f1a28640ca 100644 --- a/lms/djangoapps/support/serializers.py +++ b/lms/djangoapps/support/serializers.py @@ -84,17 +84,35 @@ def serialize_user_info(user, user_social_auths=None): return user_info -def serialize_sso_records(user_social_auths): +def serialize_sso_records(user_social_auth, user_social_auths_history): """ Serialize user social auth model object """ - sso_records = [] - for user_social_auth in user_social_auths: - sso_records.append({ - 'provider': user_social_auth.provider, - 'uid': user_social_auth.uid, - 'created': user_social_auth.created, - 'modified': user_social_auth.modified, - 'extraData': json.dumps(user_social_auth.extra_data), - }) + sso_records = { + 'provider': user_social_auth.provider, + 'uid': user_social_auth.uid, + 'created': user_social_auth.created, + 'modified': user_social_auth.modified, + 'history': serialize_sso_history( + user_social_auths_history + ), + 'extraData': json.dumps(user_social_auth.extra_data), + } return sso_records + + +def serialize_sso_history(user_social_auths_history): + """ + Serialize history for user social auth model object + """ + history = [] + for sso_history in user_social_auths_history: + history.append({ + 'uid': sso_history.uid, + 'provider': sso_history.provider, + 'created': sso_history.created, + 'modified': sso_history.modified, + 'extraData': json.dumps(sso_history.extra_data), + 'history_date': sso_history.history_date + }) + return history diff --git a/lms/djangoapps/support/tests/test_views.py b/lms/djangoapps/support/tests/test_views.py index a6b8e43947..57c36ffb11 100644 --- a/lms/djangoapps/support/tests/test_views.py +++ b/lms/djangoapps/support/tests/test_views.py @@ -1542,6 +1542,23 @@ class SsoRecordsTests(SupportViewTestCase): # lint-amnesty, pylint: disable=mis assert len(data) == 1 self.assertContains(response, '"uid": "test@example.com"') + def test_history_response(self): + user_social_auth = UserSocialAuth.objects.create( # lint-amnesty, pylint: disable=unused-variable + user=self.student, + uid=self.student.email, + provider='tpa-saml' + ) + sso = UserSocialAuth.objects.get(user=self.student) + sso.uid = self.student.email + ':' + sso.provider + sso.save() + response = self.client.get(self.url) + data = json.loads(response.content.decode('utf-8')) + assert response.status_code == 200 + assert len(data) == 1 + assert len(data[0].get('history')) == 2 + assert data[0].get('history')[0].get('uid') == "test@example.com:tpa-saml" + assert data[0].get('history')[1].get('uid') == "test@example.com" + class FeatureBasedEnrollmentSupportApiViewTests(SupportViewTestCase): """ diff --git a/lms/djangoapps/support/urls.py b/lms/djangoapps/support/urls.py index 94feadfdc0..6cce0afbf9 100644 --- a/lms/djangoapps/support/urls.py +++ b/lms/djangoapps/support/urls.py @@ -19,7 +19,9 @@ from .views.program_enrollments import ( SAMLProvidersWithOrg, ProgramEnrollmentsInspectorAPIView, ) -from .views.sso_records import SsoView +from .views.sso_records import ( + SsoView, +) from .views.onboarding_status import OnboardingView COURSE_ENTITLEMENTS_VIEW = EntitlementSupportView.as_view() diff --git a/lms/djangoapps/support/views/sso_records.py b/lms/djangoapps/support/views/sso_records.py index b86984aac7..3649575320 100644 --- a/lms/djangoapps/support/views/sso_records.py +++ b/lms/djangoapps/support/views/sso_records.py @@ -10,12 +10,43 @@ from social_django.models import UserSocialAuth from common.djangoapps.util.json_request import JsonResponse from lms.djangoapps.support.decorators import require_support_permission -from lms.djangoapps.support.serializers import serialize_sso_records +from lms.djangoapps.support.serializers import ( + serialize_sso_records, +) class SsoView(GenericAPIView): """ Returns a list of SSO records for a given user. + Sample response: + [ + { + "provider": "tpa-saml", + "uid": "new-channel:testuser", + "created": "2022-03-02T04:41:33.145Z", + "modified": "2022-03-15T11:28:17.809Z", + "extraData": "{}", + "history": + [ + { + "uid": "new-channel:testuser", + "provider": "tpa-saml", + "created": "2022-03-02T04:41:33.145Z", + "modified": "2022-03-15T11:28:17.809Z", + "extraData": "{}", + "history_date": "2022-03-15T11:28:17.832Z" + }, + { + "uid": "default-channel:testuser", + "provider": "tpa-saml", + "created": "2022-03-02T04:41:33.145Z", + "modified": "2022-03-10T12:28:32.720Z", + "extraData": "{}", + "history_date": "2022-03-15T11:12:02.420Z" + } + ] + } + ] """ @method_decorator(require_support_permission) def get(self, request, username_or_email): # lint-amnesty, pylint: disable=missing-function-docstring @@ -24,5 +55,8 @@ class SsoView(GenericAPIView): except User.DoesNotExist: return JsonResponse([]) user_social_auths = UserSocialAuth.objects.filter(user=user) - sso_records = serialize_sso_records(user_social_auths) + sso_records = [] + for user_social_auth in user_social_auths: + user_social_auths_history = UserSocialAuth.history.filter(id=user_social_auth.id) + sso_records.append(serialize_sso_records(user_social_auth, user_social_auths_history)) return JsonResponse(sso_records)