From 3dff8d0bf67f317d0d55802ad82c59d966db6936 Mon Sep 17 00:00:00 2001 From: Alexander J Sheehan Date: Tue, 3 Oct 2023 20:27:21 +0000 Subject: [PATCH 001/164] chore: sso orchestrator configs should start inactive and be activated upon successful configuration --- CHANGELOG.rst | 4 ++++ enterprise/__init__.py | 2 +- .../api/v1/views/enterprise_customer_sso_configuration.py | 4 ++++ enterprise/models.py | 8 +++++++- enterprise/tpa_pipeline.py | 2 +- 5 files changed, 17 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 4938f434d0..271b765b04 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -15,6 +15,10 @@ Change Log Unreleased ---------- +[4.5.5] +------- +chore: sso orchestrator configs should start inactive and be activated upon successful configuration + [4.5.4] ------- feat: inactive moodle course instead of true delete diff --git a/enterprise/__init__.py b/enterprise/__init__.py index 7896301f45..f628a72b5e 100644 --- a/enterprise/__init__.py +++ b/enterprise/__init__.py @@ -2,4 +2,4 @@ Your project description goes here. """ -__version__ = "4.5.4" +__version__ = "4.5.5" diff --git a/enterprise/api/v1/views/enterprise_customer_sso_configuration.py b/enterprise/api/v1/views/enterprise_customer_sso_configuration.py index 50df4e7606..9a9a5e8e7a 100644 --- a/enterprise/api/v1/views/enterprise_customer_sso_configuration.py +++ b/enterprise/api/v1/views/enterprise_customer_sso_configuration.py @@ -146,6 +146,10 @@ def oauth_orchestration_complete(self, request, configuration_uuid, *args, **kwa ' not been marked as submitted.' ) + # Mark the configuration record as active IFF this the record has never been configured. + if not sso_configuration_record.configured_at: + sso_configuration_record.active = True + sso_configuration_record.configured_at = localized_utcnow() # Completing the orchestration process for the first time means the configuration record is now configured and # can be considered active. However, subsequent configurations to update the record should not be reactivated, diff --git a/enterprise/models.py b/enterprise/models.py index 69c2900a95..2c759fb108 100644 --- a/enterprise/models.py +++ b/enterprise/models.py @@ -4055,7 +4055,13 @@ def submit_for_configuration(self, updating_existing_record=False): is_sap = True else: for field in self.base_saml_config_fields: - config_data[utils.camelCase(field)] = getattr(self, field) + if field == "active": + if not updating_existing_record: + config_data['enable'] = True + else: + config_data['enable'] = getattr(self, field) + else: + config_data[utils.camelCase(field)] = getattr(self, field) EnterpriseSSOOrchestratorApiClient().configure_sso_orchestration_record( config_data=config_data, diff --git a/enterprise/tpa_pipeline.py b/enterprise/tpa_pipeline.py index ff7dcf53fb..74f65815d0 100644 --- a/enterprise/tpa_pipeline.py +++ b/enterprise/tpa_pipeline.py @@ -65,7 +65,7 @@ def validate_provider_config(enterprise_customer, sso_provider_id): enterprise_orchestration_config = enterprise_customer.sso_orchestration_records.filter( active=True ) - if enterprise_orchestration_config.exists(): + if enterprise_orchestration_config.exists() and not enterprise_orchestration_config.first().validated_at: enterprise_orchestration_config.update(validated_at=datetime.now()) # With a successful SSO login, validate the enterprise customer's IDP config if it hasn't already been validated From d676fcd972c68d8ae6f1211c2ca887884154c6d3 Mon Sep 17 00:00:00 2001 From: Zaman Afzal Date: Fri, 6 Oct 2023 12:05:14 +0500 Subject: [PATCH 002/164] feat: added logs with post params for moodle (#1899) --- CHANGELOG.rst | 4 ++++ enterprise/__init__.py | 2 +- integrated_channels/moodle/client.py | 13 ++++++++++++- 3 files changed, 17 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 271b765b04..2035ef475a 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -15,6 +15,10 @@ Change Log Unreleased ---------- +[4.5.6] +------- +feat: Added logs for learner completion data post request[moodle] + [4.5.5] ------- chore: sso orchestrator configs should start inactive and be activated upon successful configuration diff --git a/enterprise/__init__.py b/enterprise/__init__.py index f628a72b5e..e8a6b0f838 100644 --- a/enterprise/__init__.py +++ b/enterprise/__init__.py @@ -2,4 +2,4 @@ Your project description goes here. """ -__version__ = "4.5.5" +__version__ = "4.5.6" diff --git a/integrated_channels/moodle/client.py b/integrated_channels/moodle/client.py index ef62edcb9e..bac667291c 100644 --- a/integrated_channels/moodle/client.py +++ b/integrated_channels/moodle/client.py @@ -13,7 +13,7 @@ from integrated_channels.exceptions import ClientError from integrated_channels.integrated_channel.client import IntegratedChannelApiClient -from integrated_channels.utils import generate_formatted_log +from integrated_channels.utils import encode_data_for_logging, generate_formatted_log LOGGER = logging.getLogger(__name__) @@ -333,6 +333,17 @@ def _wrapped_create_course_completion(self, user_id, payload): # The grade is exported as a decimal between [0-1] 'grades[0][grade]': completion_data['grade'] * self.enterprise_configuration.grade_scale } + + encoded_params = encode_data_for_logging(params) + LOGGER.info(generate_formatted_log( + self.enterprise_configuration.channel_code(), + self.enterprise_configuration.enterprise_customer.uuid, + user_id, + course_id, + 'posting learner data to integrated channel ' + f'integrated_channel_params_base64={encoded_params}' + )) + return self._post(params) def create_content_metadata(self, serialized_data): From 16f97ded7ca00810496f6b005a6a77086ab88673 Mon Sep 17 00:00:00 2001 From: Saleem Latif Date: Fri, 6 Oct 2023 14:37:24 +0500 Subject: [PATCH 003/164] fix: Fixed ChatGPT prompt and a few model modifications for better readability for admins. --- CHANGELOG.rst | 4 ++++ enterprise/__init__.py | 2 +- enterprise/admin/__init__.py | 5 +++-- enterprise/api/utils.py | 2 +- enterprise/api/v1/views/analytics_summary.py | 8 +++++-- .../migrations/0191_auto_20231006_0948.py | 22 +++++++++++++++++++ enterprise/models.py | 16 +++++++++++++- 7 files changed, 52 insertions(+), 7 deletions(-) create mode 100644 enterprise/migrations/0191_auto_20231006_0948.py diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 2035ef475a..81f58359af 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -15,6 +15,10 @@ Change Log Unreleased ---------- +[4.5.7] +------- +fix: Fixed ChatGPT prompt and a few model modifications for better readability for admins. + [4.5.6] ------- feat: Added logs for learner completion data post request[moodle] diff --git a/enterprise/__init__.py b/enterprise/__init__.py index e8a6b0f838..70c76b0597 100644 --- a/enterprise/__init__.py +++ b/enterprise/__init__.py @@ -2,4 +2,4 @@ Your project description goes here. """ -__version__ = "4.5.6" +__version__ = "4.5.7" diff --git a/enterprise/admin/__init__.py b/enterprise/admin/__init__.py index 77dc482107..3fe27f7d50 100644 --- a/enterprise/admin/__init__.py +++ b/enterprise/admin/__init__.py @@ -1079,8 +1079,9 @@ class ChatGPTResponseAdmin(admin.ModelAdmin): """ model = models.ChatGPTResponse - list_display = ('uuid', 'enterprise_customer', 'prompt_hash', ) - readonly_fields = ('prompt', 'response', 'prompt_hash', ) + list_display = ('uuid', 'prompt_type', 'enterprise_customer', 'prompt_hash', 'created', ) + readonly_fields = ('prompt_type', 'prompt', 'response', 'prompt_hash', 'created', 'modified', ) + list_filter = ('prompt_type', ) @admin.register(models.EnterpriseCustomerSsoConfiguration) diff --git a/enterprise/api/utils.py b/enterprise/api/utils.py index 0735359e5e..63ccce6ea0 100644 --- a/enterprise/api/utils.py +++ b/enterprise/api/utils.py @@ -155,7 +155,7 @@ def generate_prompt_for_learner_engagement_summary(engagement_data): 'hours': engagement_data['hours'], 'hours_delta': delta_format(current=engagement_data['hours'], prior=engagement_data['hours_prior']), 'passed': engagement_data['passed'], - 'passed_delta': delta_format(current=engagement_data['hours'], prior=engagement_data['passed_prior']), + 'passed_delta': delta_format(current=engagement_data['passed'], prior=engagement_data['passed_prior']), } # If active contract (or unknown). diff --git a/enterprise/api/v1/views/analytics_summary.py b/enterprise/api/v1/views/analytics_summary.py index f7888325ca..c63f6d2c87 100644 --- a/enterprise/api/v1/views/analytics_summary.py +++ b/enterprise/api/v1/views/analytics_summary.py @@ -53,6 +53,10 @@ def post(self, request, enterprise_uuid): learner_engagement_prompt = generate_prompt_for_learner_engagement_summary(prompt_data['learner_engagement']) return Response(data={ - 'learner_progress': ChatGPTResponse.get_or_create(learner_progress_prompt, role, enterprise_customer), - 'learner_engagement': ChatGPTResponse.get_or_create(learner_engagement_prompt, role, enterprise_customer), + 'learner_progress': ChatGPTResponse.get_or_create( + learner_progress_prompt, role, enterprise_customer, ChatGPTResponse.LEARNER_PROGRESS, + ), + 'learner_engagement': ChatGPTResponse.get_or_create( + learner_engagement_prompt, role, enterprise_customer, ChatGPTResponse.LEARNER_ENGAGEMENT, + ), }) diff --git a/enterprise/migrations/0191_auto_20231006_0948.py b/enterprise/migrations/0191_auto_20231006_0948.py new file mode 100644 index 0000000000..41c8476c37 --- /dev/null +++ b/enterprise/migrations/0191_auto_20231006_0948.py @@ -0,0 +1,22 @@ +# Generated by Django 3.2.21 on 2023-10-06 09:48 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('enterprise', '0190_auto_20231003_0719'), + ] + + operations = [ + migrations.AlterModelOptions( + name='chatgptresponse', + options={'verbose_name': 'ChatGPT Response', 'verbose_name_plural': 'ChatGPT Responses'}, + ), + migrations.AddField( + model_name='chatgptresponse', + name='prompt_type', + field=models.CharField(choices=[('learner_progress', 'Learner progress'), ('learner_engagement', 'Learner engagement')], help_text='Prompt type.', max_length=32, null=True), + ), + ] diff --git a/enterprise/models.py b/enterprise/models.py index 2c759fb108..5499428f6a 100644 --- a/enterprise/models.py +++ b/enterprise/models.py @@ -3655,6 +3655,12 @@ class ChatGPTResponse(TimeStampedModel): .. no_pii: """ + LEARNER_PROGRESS = 'learner_progress' + LEARNER_ENGAGEMENT = 'learner_engagement' + PROMPT_TYPES = [ + (LEARNER_PROGRESS, 'Learner progress'), + (LEARNER_ENGAGEMENT, 'Learner engagement'), + ] uuid = models.UUIDField(primary_key=True, default=uuid4, editable=False) enterprise_customer = models.ForeignKey( @@ -3671,6 +3677,12 @@ class ChatGPTResponse(TimeStampedModel): prompt = models.TextField(help_text=_('ChatGPT prompt.')) prompt_hash = models.CharField(max_length=32, editable=False) response = models.TextField(help_text=_('ChatGPT response.')) + prompt_type = models.CharField(choices=PROMPT_TYPES, help_text=_('Prompt type.'), max_length=32, null=True) + + class Meta: + app_label = 'enterprise' + verbose_name = _('ChatGPT Response') + verbose_name_plural = _('ChatGPT Responses') def save(self, *args, **kwargs): """ @@ -3680,7 +3692,7 @@ def save(self, *args, **kwargs): super().save(*args, **kwargs) @classmethod - def get_or_create(cls, prompt, role, enterprise_customer): + def get_or_create(cls, prompt, role, enterprise_customer, prompt_type): """ Get or create ChatGPT response against given prompt. @@ -3691,6 +3703,7 @@ def get_or_create(cls, prompt, role, enterprise_customer): prompt (str): OpenAI prompt. role (str): ChatGPT role to assume for the prompt. enterprise_customer (EnterpriseCustomer): Enterprise customer UUId making the request. + prompt_type (str): Prompt type, e.g. learner_progress or learner_engagement etc. Returns: (str): Response against the given prompt. @@ -3702,6 +3715,7 @@ def get_or_create(cls, prompt, role, enterprise_customer): enterprise_customer=enterprise_customer, prompt=prompt, response=response, + prompt_type=prompt_type, ) return response else: From 6f378349173e75ebfd0bb957395b90536a1ee277 Mon Sep 17 00:00:00 2001 From: jajjibhai008 Date: Thu, 5 Oct 2023 16:40:55 +0500 Subject: [PATCH 004/164] feat: Added enable_source_demo_data_for_analytics_and_lpr field to EnterpriseCustomer --- CHANGELOG.rst | 4 ++++ enterprise/__init__.py | 2 +- enterprise/admin/__init__.py | 3 ++- enterprise/admin/forms.py | 1 + enterprise/api/v1/serializers.py | 2 +- .../migrations/0192_auto_20231009_1302.py | 23 +++++++++++++++++++ enterprise/models.py | 6 +++++ tests/test_enterprise/api/test_views.py | 5 ++++ tests/test_utilities.py | 1 + 9 files changed, 44 insertions(+), 3 deletions(-) create mode 100644 enterprise/migrations/0192_auto_20231009_1302.py diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 81f58359af..74b6247217 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -15,6 +15,10 @@ Change Log Unreleased ---------- +[4.6.0] +------- +feat: Added enable_source_demo_data_for_analytics_and_lpr field to EnterpriseCustomer. + [4.5.7] ------- fix: Fixed ChatGPT prompt and a few model modifications for better readability for admins. diff --git a/enterprise/__init__.py b/enterprise/__init__.py index 70c76b0597..c629ee4f9b 100644 --- a/enterprise/__init__.py +++ b/enterprise/__init__.py @@ -2,4 +2,4 @@ Your project description goes here. """ -__version__ = "4.5.7" +__version__ = "4.6.0" diff --git a/enterprise/admin/__init__.py b/enterprise/admin/__init__.py index 3fe27f7d50..665745c1d0 100644 --- a/enterprise/admin/__init__.py +++ b/enterprise/admin/__init__.py @@ -211,7 +211,8 @@ class EnterpriseCustomerAdmin(DjangoObjectActions, SimpleHistoryAdmin): 'enable_audit_data_reporting', 'enable_learner_portal_offers', 'enable_executive_education_2U_fulfillment', 'enable_career_engagement_network_on_learner_portal', - 'career_engagement_network_message', 'enable_pathways', 'enable_programs'), + 'career_engagement_network_message', 'enable_pathways', 'enable_programs', + 'enable_demo_data_for_analytics_and_lpr'), 'description': ('The following default settings should be the same for ' 'the majority of enterprise customers, ' 'and are either rarely used, unlikely to be sold, ' diff --git a/enterprise/admin/forms.py b/enterprise/admin/forms.py index 419600b578..fd076aa4d4 100644 --- a/enterprise/admin/forms.py +++ b/enterprise/admin/forms.py @@ -407,6 +407,7 @@ class Meta: "career_engagement_network_message", "enable_pathways", "enable_programs", + "enable_demo_data_for_analytics_and_lpr", "enable_analytics_screen", "enable_portal_reporting_config_screen", "enable_portal_saml_configuration_screen", diff --git a/enterprise/api/v1/serializers.py b/enterprise/api/v1/serializers.py index 9a36b86ce2..d44ce837cd 100644 --- a/enterprise/api/v1/serializers.py +++ b/enterprise/api/v1/serializers.py @@ -222,7 +222,7 @@ class Meta: 'enterprise_customer_catalogs', 'reply_to', 'enterprise_notification_banner', 'hide_labor_market_data', 'modified', 'enable_universal_link', 'enable_browse_and_request', 'admin_users', 'enable_career_engagement_network_on_learner_portal', 'career_engagement_network_message', - 'enable_pathways', 'enable_programs', + 'enable_pathways', 'enable_programs', 'enable_demo_data_for_analytics_and_lpr', ) identity_providers = EnterpriseCustomerIdentityProviderSerializer(many=True, read_only=True) diff --git a/enterprise/migrations/0192_auto_20231009_1302.py b/enterprise/migrations/0192_auto_20231009_1302.py new file mode 100644 index 0000000000..c6bee31e05 --- /dev/null +++ b/enterprise/migrations/0192_auto_20231009_1302.py @@ -0,0 +1,23 @@ +# Generated by Django 3.2.20 on 2023-10-09 13:02 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('enterprise', '0191_auto_20231006_0948'), + ] + + operations = [ + migrations.AddField( + model_name='enterprisecustomer', + name='enable_demo_data_for_analytics_and_lpr', + field=models.BooleanField(default=False, help_text='Display Demo data from analyitcs and learner progress report for demo customer.', verbose_name='Enable demo data from analytics and lpr'), + ), + migrations.AddField( + model_name='historicalenterprisecustomer', + name='enable_demo_data_for_analytics_and_lpr', + field=models.BooleanField(default=False, help_text='Display Demo data from analyitcs and learner progress report for demo customer.', verbose_name='Enable demo data from analytics and lpr'), + ), + ] diff --git a/enterprise/models.py b/enterprise/models.py index 5499428f6a..a976cf40b2 100644 --- a/enterprise/models.py +++ b/enterprise/models.py @@ -416,6 +416,12 @@ class Meta: help_text=_("Specifies whether the organization should have access to executive education 2U content.") ) + enable_demo_data_for_analytics_and_lpr = models.BooleanField( + verbose_name="Enable demo data from analytics and lpr", + default=False, + help_text=_("Display Demo data from analyitcs and learner progress report for demo customer.") + ) + contact_email = models.EmailField( verbose_name="Customer admin contact email:", null=True, diff --git a/tests/test_enterprise/api/test_views.py b/tests/test_enterprise/api/test_views.py index 14209cbae0..0c35a915b5 100644 --- a/tests/test_enterprise/api/test_views.py +++ b/tests/test_enterprise/api/test_views.py @@ -1196,6 +1196,7 @@ class TestEnterpriseCustomerViewSet(BaseTestEnterpriseAPIViews): 'career_engagement_network_message': 'Test message', 'enable_pathways': True, 'enable_programs': True, + 'enable_demo_data_for_analytics_and_lpr': False, }], ), ( @@ -1253,6 +1254,7 @@ class TestEnterpriseCustomerViewSet(BaseTestEnterpriseAPIViews): 'career_engagement_network_message': 'Test message', 'enable_pathways': True, 'enable_programs': True, + 'enable_demo_data_for_analytics_and_lpr': False, }, 'active': True, 'user_id': 0, 'user': None, 'data_sharing_consent_records': [], 'groups': [], @@ -1342,6 +1344,7 @@ class TestEnterpriseCustomerViewSet(BaseTestEnterpriseAPIViews): 'career_engagement_network_message': 'Test message', 'enable_pathways': True, 'enable_programs': True, + 'enable_demo_data_for_analytics_and_lpr': False, }], ), ( @@ -1407,6 +1410,7 @@ class TestEnterpriseCustomerViewSet(BaseTestEnterpriseAPIViews): 'career_engagement_network_message': 'Test message', 'enable_pathways': True, 'enable_programs': True, + 'enable_demo_data_for_analytics_and_lpr': False, }], ), ( @@ -1643,6 +1647,7 @@ def test_enterprise_customer_with_access_to( 'career_engagement_network_message': 'Test message', 'enable_pathways': True, 'enable_programs': True, + 'enable_demo_data_for_analytics_and_lpr': False, } else: mock_empty_200_success_response = { diff --git a/tests/test_utilities.py b/tests/test_utilities.py index afd74b2f5f..070a1e817a 100644 --- a/tests/test_utilities.py +++ b/tests/test_utilities.py @@ -175,6 +175,7 @@ def setUp(self): "sso_orchestration_records", "enable_pathways", "enable_programs", + "enable_demo_data_for_analytics_and_lpr", ] ), ( From 0d90da2dfab8417d1425065bf6ce50e43437bd6c Mon Sep 17 00:00:00 2001 From: Ehmad Saeed Date: Tue, 10 Oct 2023 23:48:35 +0500 Subject: [PATCH 005/164] feat: implement a subject metadata transmission flag for cornerstone customer config --- .../cornerstone/exporters/content_metadata.py | 2 ++ .../migrations/0030_auto_20231010_1654.py | 23 +++++++++++++++++++ integrated_channels/cornerstone/models.py | 8 +++++++ 3 files changed, 33 insertions(+) create mode 100644 integrated_channels/cornerstone/migrations/0030_auto_20231010_1654.py diff --git a/integrated_channels/cornerstone/exporters/content_metadata.py b/integrated_channels/cornerstone/exporters/content_metadata.py index bf30865064..ce77308167 100644 --- a/integrated_channels/cornerstone/exporters/content_metadata.py +++ b/integrated_channels/cornerstone/exporters/content_metadata.py @@ -178,6 +178,8 @@ def transform_subjects(self, content_metadata_item): """ Return the transformed version of the course subject list or default value if no subject found. """ + if self.enterprise_configuration.disable_subject_metadata_transmission: + return None subjects = [] course_subjects = get_subjects_from_content_metadata(content_metadata_item) CornerstoneGlobalConfiguration = apps.get_model( diff --git a/integrated_channels/cornerstone/migrations/0030_auto_20231010_1654.py b/integrated_channels/cornerstone/migrations/0030_auto_20231010_1654.py new file mode 100644 index 0000000000..0a2d2d2e98 --- /dev/null +++ b/integrated_channels/cornerstone/migrations/0030_auto_20231010_1654.py @@ -0,0 +1,23 @@ +# Generated by Django 3.2.21 on 2023-10-10 16:54 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('cornerstone', '0029_alter_historicalcornerstoneenterprisecustomerconfiguration_options'), + ] + + operations = [ + migrations.AddField( + model_name='cornerstoneenterprisecustomerconfiguration', + name='disable_subject_metadata_transmission', + field=models.BooleanField(default=False, help_text='If checked, subjects will not be sent to Cornerstone', verbose_name='Disable Subject Content Metadata Transmission'), + ), + migrations.AddField( + model_name='historicalcornerstoneenterprisecustomerconfiguration', + name='disable_subject_metadata_transmission', + field=models.BooleanField(default=False, help_text='If checked, subjects will not be sent to Cornerstone', verbose_name='Disable Subject Content Metadata Transmission'), + ), + ] diff --git a/integrated_channels/cornerstone/models.py b/integrated_channels/cornerstone/models.py index 99ae1897a7..88b0f24e93 100644 --- a/integrated_channels/cornerstone/models.py +++ b/integrated_channels/cornerstone/models.py @@ -118,6 +118,14 @@ class CornerstoneEnterpriseCustomerConfiguration(EnterpriseCustomerPluginConfigu ) ) + disable_subject_metadata_transmission = models.BooleanField( + default=False, + verbose_name="Disable Subject Content Metadata Transmission", + help_text=_( + "If checked, subjects will not be sent to Cornerstone" + ) + ) + history = HistoricalRecords() class Meta: From cfd554490e3ebe2bca563de9c42f0fa87c21b271 Mon Sep 17 00:00:00 2001 From: Ehmad Saeed Date: Wed, 11 Oct 2023 00:37:49 +0500 Subject: [PATCH 006/164] chore: update changelog and version bump --- CHANGELOG.rst | 4 ++++ enterprise/__init__.py | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 74b6247217..b30bd5eb5f 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -15,6 +15,10 @@ Change Log Unreleased ---------- +[4.6.1] +------- +feat: Added the disable_subject_metadata_transmission flag to CornerstoneEnterpriseCustomerConfiguration. + [4.6.0] ------- feat: Added enable_source_demo_data_for_analytics_and_lpr field to EnterpriseCustomer. diff --git a/enterprise/__init__.py b/enterprise/__init__.py index c629ee4f9b..2364a6257c 100644 --- a/enterprise/__init__.py +++ b/enterprise/__init__.py @@ -2,4 +2,4 @@ Your project description goes here. """ -__version__ = "4.6.0" +__version__ = "4.6.1" From 0f10e23eadc43d7bebb1f1c0e1e51b064b1e1a4e Mon Sep 17 00:00:00 2001 From: Kira Miller <31229189+kiram15@users.noreply.github.com> Date: Wed, 11 Oct 2023 13:08:50 -0600 Subject: [PATCH 007/164] fix: clarify contact email helper text (#1900) * fix: clarify contact email helper text * chore: version bump * fix: fix migrations --- CHANGELOG.rst | 5 ++++ enterprise/__init__.py | 2 +- .../migrations/0193_auto_20231005_1708.py | 23 +++++++++++++++++++ enterprise/models.py | 11 +++++---- 4 files changed, 35 insertions(+), 6 deletions(-) create mode 100644 enterprise/migrations/0193_auto_20231005_1708.py diff --git a/CHANGELOG.rst b/CHANGELOG.rst index b30bd5eb5f..a413e95f5b 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -15,6 +15,11 @@ Change Log Unreleased ---------- + +[4.6.2] +------- +fix: clarify contact email helper text for enterprise customer + [4.6.1] ------- feat: Added the disable_subject_metadata_transmission flag to CornerstoneEnterpriseCustomerConfiguration. diff --git a/enterprise/__init__.py b/enterprise/__init__.py index 2364a6257c..edcecf2419 100644 --- a/enterprise/__init__.py +++ b/enterprise/__init__.py @@ -2,4 +2,4 @@ Your project description goes here. """ -__version__ = "4.6.1" +__version__ = "4.6.2" diff --git a/enterprise/migrations/0193_auto_20231005_1708.py b/enterprise/migrations/0193_auto_20231005_1708.py new file mode 100644 index 0000000000..00898cfba6 --- /dev/null +++ b/enterprise/migrations/0193_auto_20231005_1708.py @@ -0,0 +1,23 @@ +# Generated by Django 3.2.21 on 2023-10-05 17:08 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('enterprise', '0192_auto_20231009_1302'), + ] + + operations = [ + migrations.AlterField( + model_name='enterprisecustomer', + name='contact_email', + field=models.EmailField(blank=True, help_text='Email linked on learner portal as public point of contact, will default to all admin users associated with this customer if left blank.', max_length=254, null=True, verbose_name='Customer admin contact email:'), + ), + migrations.AlterField( + model_name='historicalenterprisecustomer', + name='contact_email', + field=models.EmailField(blank=True, help_text='Email linked on learner portal as public point of contact, will default to all admin users associated with this customer if left blank.', max_length=254, null=True, verbose_name='Customer admin contact email:'), + ), + ] diff --git a/enterprise/models.py b/enterprise/models.py index a976cf40b2..0d5d60a7ec 100644 --- a/enterprise/models.py +++ b/enterprise/models.py @@ -426,7 +426,8 @@ class Meta: verbose_name="Customer admin contact email:", null=True, blank=True, - help_text=_("Email address presented on learner portal as public point of contact from customer organization.") + help_text=_("Email linked on learner portal as public point of contact, will default to all " + "admin users associated with this customer if left blank.") ) default_contract_discount = models.DecimalField( @@ -1067,7 +1068,7 @@ def save(self, *args, **kwargs): linked=False, ) self.linked = True - # An existing record has been found so update auto primary key with primay key of existing record + # An existing record has been found so update auto primary key with primary key of existing record self.pk = existing.pk # Update the kwargs so that Django will update the existing record instead of creating a new one kwargs = dict(kwargs, **{'force_insert': False, 'force_update': True}) @@ -1771,7 +1772,7 @@ def provider_name(self): @property def sync_learner_profile_data(self): """ - Return bool indicating if data received from the identity provider shoudl be synced to the edX profile. + Return bool indicating if data received from the identity provider should be synced to the edX profile. """ identity_provider = self.identity_provider return identity_provider is not None and identity_provider.sync_learner_profile_data @@ -2245,7 +2246,7 @@ class EnterpriseCatalogQuery(TimeStampedModel): Stores a re-usable catalog query. This stored catalog query used in `EnterpriseCustomerCatalog` objects to build catalog's content_filter field. - This is a saved instance of `content_filter` that can be re-used accross different catalogs. + This is a saved instance of `content_filter` that can be re-used across different catalogs. .. no_pii: """ @@ -3015,7 +3016,7 @@ def validate_compression(cls, enable_compression, data_type, delivery_method): Check enable_compression flag is set as expected Arguments: - enable_compression (bool): file copression flag + enable_compression (bool): file compression flag data_type (str): report type delivery_method (str): delivery method for sending files From e3497896bf04f62c86fb411002d7f02b526e9b00 Mon Sep 17 00:00:00 2001 From: muhammad-ammar Date: Thu, 12 Oct 2023 12:45:27 +0500 Subject: [PATCH 008/164] fix: remvoe not required fields from serializer --- CHANGELOG.rst | 4 ++++ enterprise/__init__.py | 2 +- enterprise/api/v1/serializers.py | 3 --- tests/test_enterprise/api/test_views.py | 3 --- 4 files changed, 5 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index a413e95f5b..b554a2738b 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -16,6 +16,10 @@ Change Log Unreleased ---------- +[4.6.3] +------- +fix: Remove not required fields + [4.6.2] ------- fix: clarify contact email helper text for enterprise customer diff --git a/enterprise/__init__.py b/enterprise/__init__.py index edcecf2419..cce4ecb055 100644 --- a/enterprise/__init__.py +++ b/enterprise/__init__.py @@ -2,4 +2,4 @@ Your project description goes here. """ -__version__ = "4.6.2" +__version__ = "4.6.3" diff --git a/enterprise/api/v1/serializers.py b/enterprise/api/v1/serializers.py index d44ce837cd..fbacd153e8 100644 --- a/enterprise/api/v1/serializers.py +++ b/enterprise/api/v1/serializers.py @@ -1526,7 +1526,6 @@ class LearnerProgressSerializer(serializers.Serializer): at_risk_enrollment_less_than_one_hour = serializers.IntegerField(required=True) at_risk_enrollment_end_date_soon = serializers.IntegerField(required=True) at_risk_enrollment_dormant = serializers.IntegerField(required=True) - created_at = serializers.DateTimeField(required=True) class LearnerEngagementSerializer(serializers.Serializer): """ @@ -1543,8 +1542,6 @@ class LearnerEngagementSerializer(serializers.Serializer): hours = serializers.IntegerField(required=True) hours_prior = serializers.IntegerField(required=True) active_contract = serializers.BooleanField(required=True) - contract_end_date = serializers.DateTimeField(required=True) - created_at = serializers.DateTimeField(required=True) learner_progress = LearnerProgressSerializer() learner_engagement = LearnerEngagementSerializer() diff --git a/tests/test_enterprise/api/test_views.py b/tests/test_enterprise/api/test_views.py index 0c35a915b5..a3b27adf9a 100644 --- a/tests/test_enterprise/api/test_views.py +++ b/tests/test_enterprise/api/test_views.py @@ -6795,7 +6795,6 @@ def setUp(self): 'at_risk_enrollment_less_than_one_hour': 3, 'at_risk_enrollment_end_date_soon': 2, 'at_risk_enrollment_dormant': 2, - 'created_at': '2023-08-10T12:39:35.388936Z' } self.learner_engagement = { @@ -6809,9 +6808,7 @@ def setUp(self): 'engage_prior': 50, 'hours': 2000, 'hours_prior': 3000, - 'contract_end_date': '2023-12-10T12:39:28.792421Z', 'active_contract': True, - 'created_at': '2023-08-11T13:25:40.197061Z' } self.payload = { From da5ddc217d6c66dd91893a3ea91234013c29a8ee Mon Sep 17 00:00:00 2001 From: Alexander J Sheehan Date: Fri, 13 Oct 2023 14:00:02 +0000 Subject: [PATCH 009/164] chore: updating sso orchestrator self service api endpoints --- CHANGELOG.rst | 3 + enterprise/__init__.py | 2 +- .../enterprise_customer_sso_configuration.py | 23 +++++--- .../migrations/0194_auto_20231013_1359.py | 23 ++++++++ enterprise/models.py | 10 ++++ tests/test_enterprise/api/test_views.py | 59 ++++++++++++++++++- 6 files changed, 109 insertions(+), 11 deletions(-) create mode 100644 enterprise/migrations/0194_auto_20231013_1359.py diff --git a/CHANGELOG.rst b/CHANGELOG.rst index b554a2738b..eafe471096 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -15,6 +15,9 @@ Change Log Unreleased ---------- +[4.6.4] +------- +chore: updating sso orchestrator self service api endpoints [4.6.3] ------- diff --git a/enterprise/__init__.py b/enterprise/__init__.py index cce4ecb055..89ac717eca 100644 --- a/enterprise/__init__.py +++ b/enterprise/__init__.py @@ -2,4 +2,4 @@ Your project description goes here. """ -__version__ = "4.6.3" +__version__ = "4.6.4" diff --git a/enterprise/api/v1/views/enterprise_customer_sso_configuration.py b/enterprise/api/v1/views/enterprise_customer_sso_configuration.py index 9a9a5e8e7a..a93156a57c 100644 --- a/enterprise/api/v1/views/enterprise_customer_sso_configuration.py +++ b/enterprise/api/v1/views/enterprise_customer_sso_configuration.py @@ -24,6 +24,7 @@ from enterprise import models from enterprise.api.utils import get_enterprise_customer_from_user_id from enterprise.api.v1 import serializers +from enterprise.api_client.sso_orchestrator import SsoOrchestratorClientError from enterprise.logging import getEnterpriseLogger from enterprise.models import EnterpriseCustomer, EnterpriseCustomerSsoConfiguration, EnterpriseCustomerUser from enterprise.tasks import send_sso_configured_email @@ -146,6 +147,14 @@ def oauth_orchestration_complete(self, request, configuration_uuid, *args, **kwa ' not been marked as submitted.' ) + if error_msg := request.POST.get('error'): + LOGGER.error( + f'SSO configuration record {sso_configuration_record.pk} has failed to configure due to {error_msg}.' + ) + sso_configuration_record.errored_at = localized_utcnow() + sso_configuration_record.save() + return Response(status=HTTP_200_OK) + # Mark the configuration record as active IFF this the record has never been configured. if not sso_configuration_record.configured_at: sso_configuration_record.active = True @@ -248,14 +257,12 @@ def create(self, request, *args, **kwargs): request_data['entity_id'] = entity_id try: - new_record = EnterpriseCustomerSsoConfiguration.objects.create(**request_data) - except TypeError as e: - LOGGER.error(f'{CONFIG_CREATE_ERROR}{e}') - return Response({'error': f'{CONFIG_CREATE_ERROR}{e}'}, status=HTTP_400_BAD_REQUEST) - - # Wondering what to do here with error handling - # If we fail to submit for configuration (ie get a network error) should we rollback the created record? - new_record.submit_for_configuration() + with transaction.atomic(): + new_record = EnterpriseCustomerSsoConfiguration.objects.create(**request_data) + new_record.submit_for_configuration() + except (TypeError, SsoOrchestratorClientError) as e: + LOGGER.error(f'{CONFIG_CREATE_ERROR} {e}') + return Response({'error': f'{CONFIG_CREATE_ERROR} {e}'}, status=HTTP_400_BAD_REQUEST) return Response({'data': new_record.pk}, status=HTTP_201_CREATED) diff --git a/enterprise/migrations/0194_auto_20231013_1359.py b/enterprise/migrations/0194_auto_20231013_1359.py new file mode 100644 index 0000000000..c46f657dda --- /dev/null +++ b/enterprise/migrations/0194_auto_20231013_1359.py @@ -0,0 +1,23 @@ +# Generated by Django 3.2.22 on 2023-10-13 13:59 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('enterprise', '0193_auto_20231005_1708'), + ] + + operations = [ + migrations.AddField( + model_name='enterprisecustomerssoconfiguration', + name='errored_at', + field=models.DateTimeField(blank=True, help_text='The date and time when the orchestrator encountered an error during configuration.', null=True), + ), + migrations.AddField( + model_name='historicalenterprisecustomerssoconfiguration', + name='errored_at', + field=models.DateTimeField(blank=True, help_text='The date and time when the orchestrator encountered an error during configuration.', null=True), + ), + ] diff --git a/enterprise/models.py b/enterprise/models.py index 0d5d60a7ec..c883dac36a 100644 --- a/enterprise/models.py +++ b/enterprise/models.py @@ -3947,6 +3947,14 @@ class Meta: ) ) + errored_at = models.DateTimeField( + blank=True, + null=True, + help_text=_( + "The date and time when the orchestrator encountered an error during configuration." + ) + ) + # ---------------------------- SAP Success Factors attribute mappings ---------------------------- # odata_api_timeout_interval = models.PositiveIntegerField( @@ -4051,6 +4059,8 @@ def is_pending_configuration(self): if self.submitted_at: if not self.configured_at: return True + if self.errored_at and self.errored_at > self.submitted_at: + return False if self.submitted_at > self.configured_at: return True return False diff --git a/tests/test_enterprise/api/test_views.py b/tests/test_enterprise/api/test_views.py index a3b27adf9a..375298d904 100644 --- a/tests/test_enterprise/api/test_views.py +++ b/tests/test_enterprise/api/test_views.py @@ -7228,13 +7228,13 @@ def post_new_sso_configuration(self, data): ) return self.client.post(url, data=data) - def post_sso_configuration_complete(self, config_pk): + def post_sso_configuration_complete(self, config_pk, data=None): """Helper method to hit the configuration complete endpoint for sso configurations.""" url = settings.TEST_SERVER + reverse( self.SSO_CONFIGURATION_COMPLETE_ENDPOINT, kwargs={'configuration_uuid': config_pk} ) - return self.client.post(url) + return self.client.post(url, data=data) def _get_existing_sso_record_url(self, config_pk): """Helper method to get the url for an existing sso configuration endpoint.""" @@ -7291,6 +7291,27 @@ def test_sso_configuration_oauth_orchestration_complete_not_found(self): response = self.post_sso_configuration_complete(config_pk) assert response.status_code == 404 + @mock.patch("enterprise.api_client.braze.BrazeAPIClient.get_braze_client") + def test_sso_configuration_oauth_orchestration_complete_error(self, mock_braze_client): + """ + Verify that the endpoint is able to mark an sso config as errored. + """ + mock_braze_client.return_value.get_braze_client.return_value = mock.MagicMock() + self.set_jwt_cookie(ENTERPRISE_OPERATOR_ROLE, "*") + config_pk = uuid.uuid4() + enterprise_sso_orchestration_config = EnterpriseCustomerSsoConfigurationFactory( + uuid=config_pk, + enterprise_customer=self.enterprise_customer, + configured_at=None, + submitted_at=localized_utcnow(), + ) + assert enterprise_sso_orchestration_config.is_pending_configuration() + response = self.post_sso_configuration_complete(config_pk, data={'error': 'test error'}) + enterprise_sso_orchestration_config.refresh_from_db() + assert enterprise_sso_orchestration_config.configured_at is None + assert enterprise_sso_orchestration_config.errored_at is not None + assert response.status_code == status.HTTP_200_OK + @mock.patch("enterprise.api_client.braze.BrazeAPIClient.get_braze_client") def test_sso_configuration_oauth_orchestration_complete(self, mock_braze_client): """ @@ -7561,6 +7582,40 @@ def test_sso_configuration_create_bad_data_format(self): response = self.post_new_sso_configuration(data) assert "somewhackyvalue" in response.json()['error'] + @responses.activate + def test_sso_configuration_create_error_from_orchestrator(self): + """ + Test that the sso orchestration create endpoint will rollback a created object if the submission for + configuration fails. + """ + xml_metadata = """ + + + """ + responses.add( + responses.GET, + "https://examples.com/metadata.xml", + body=xml_metadata, + ) + responses.add( + responses.POST, + urljoin(get_sso_orchestrator_api_base_url(), get_sso_orchestrator_configure_path()), + json={'error': 'some error'}, + status=400, + ) + data = { + "metadata_url": "https://examples.com/metadata.xml", + "active": False, + "enterprise_customer": str(self.enterprise_customer.uuid), + "identity_provider": "cornerstone" + } + self.set_jwt_cookie(ENTERPRISE_ADMIN_ROLE, self.enterprise_customer.uuid) + + response = self.post_new_sso_configuration(data) + + assert response.status_code == 400 + assert EnterpriseCustomerSsoConfiguration.objects.all().count() == 0 + def test_sso_configuration_create_bad_xml_url(self): """ Test expected response when creating a new sso configuration with a bad xml url. From 34b89900c2e4863350756b440af45e340487d76c Mon Sep 17 00:00:00 2001 From: hamzawaleed01 Date: Fri, 13 Oct 2023 16:57:45 +0500 Subject: [PATCH 010/164] feat: ENT-7725 add logs to debug --- integrated_channels/degreed2/client.py | 32 +++++++++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/integrated_channels/degreed2/client.py b/integrated_channels/degreed2/client.py index 91a2dcae8f..2eeb1ad4bc 100644 --- a/integrated_channels/degreed2/client.py +++ b/integrated_channels/degreed2/client.py @@ -114,6 +114,16 @@ def create_course_completion(self, user_id, payload): Returns: status_code, response_text """ json_payload = json.loads(payload) + LOGGER.error( + generate_formatted_log( + self.enterprise_configuration.channel_code(), + self.enterprise_configuration.enterprise_customer.uuid, + user_id, + None, + '[Degreed2Client] - Attempting degreed2 create_course_completion,' + f'payload:{json_payload}' + ) + ) LOGGER.info(self.make_log_msg( json_payload.get('data').get('attributes').get('content-id'), f'Attempting find course via url: {self.get_completions_url()}'), @@ -378,13 +388,33 @@ def _post(self, url, data, scope): self.enterprise_configuration.enterprise_customer.uuid, None, None, - f'429 detected from {url}, backing-off before retrying, ' + f'[Degreed2Client] 429 detected from {url}, backing-off before retrying, ' f'sleeping {sleep_seconds} seconds...' ) ) time.sleep(sleep_seconds) else: + LOGGER.error( + generate_formatted_log( + self.enterprise_configuration.channel_code(), + self.enterprise_configuration.enterprise_customer.uuid, + None, + None, + '[Degreed2Client] - Exceeded retry attempts:' + f'URL:{url}, DATA:{data}' + ) + ) break + LOGGER.error( + generate_formatted_log( + self.enterprise_configuration.channel_code(), + self.enterprise_configuration.enterprise_customer.uuid, + None, + None, + '[Degreed2Client] - Successfuly called:' + f'RESPONSE:{response}' + ) + ) return response.status_code, response.text def _patch(self, url, data, scope): From 5e9c8f028d55ab45d3fddc105e90806080523be1 Mon Sep 17 00:00:00 2001 From: Alexander J Sheehan Date: Mon, 16 Oct 2023 20:09:58 +0000 Subject: [PATCH 011/164] chore: orchestrator exception handling and submission refinements --- CHANGELOG.rst | 4 ++++ enterprise/__init__.py | 2 +- .../api/v1/views/enterprise_customer_sso_configuration.py | 2 +- enterprise/models.py | 7 ++++--- 4 files changed, 10 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 8c1d55d531..9576a20b7d 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -15,6 +15,10 @@ Change Log Unreleased ---------- +[4.6.6] +------- +chore: orchestrator exception handling and submission refinements + [4.6.5] ------- feat: Added logs for Degreed2 client diff --git a/enterprise/__init__.py b/enterprise/__init__.py index 1f15cde935..4a7b6943fd 100644 --- a/enterprise/__init__.py +++ b/enterprise/__init__.py @@ -2,4 +2,4 @@ Your project description goes here. """ -__version__ = "4.6.5" +__version__ = "4.6.6" diff --git a/enterprise/api/v1/views/enterprise_customer_sso_configuration.py b/enterprise/api/v1/views/enterprise_customer_sso_configuration.py index a93156a57c..252f7aed1f 100644 --- a/enterprise/api/v1/views/enterprise_customer_sso_configuration.py +++ b/enterprise/api/v1/views/enterprise_customer_sso_configuration.py @@ -322,7 +322,7 @@ def update(self, request, *args, **kwargs): with transaction.atomic(): sso_configuration_record.update(**request_data) sso_configuration_record.first().submit_for_configuration(updating_existing_record=True) - except (TypeError, FieldDoesNotExist, ValidationError) as e: + except (TypeError, FieldDoesNotExist, ValidationError, SsoOrchestratorClientError) as e: LOGGER.error(f'{CONFIG_UPDATE_ERROR}{e}') return Response({'error': f'{CONFIG_UPDATE_ERROR}{e}'}, status=HTTP_400_BAD_REQUEST) serializer = self.serializer_class(sso_configuration_record.first()) diff --git a/enterprise/models.py b/enterprise/models.py index c883dac36a..4cd6fba832 100644 --- a/enterprise/models.py +++ b/enterprise/models.py @@ -4082,7 +4082,8 @@ def submit_for_configuration(self, updating_existing_record=False): config_data = {} if self.identity_provider == self.SAP_SUCCESS_FACTORS: for field in self.sap_config_fields: - sap_data[utils.camelCase(field)] = getattr(self, field) + if field_value := getattr(self, field): + sap_data[utils.camelCase(field)] = field_value is_sap = True else: for field in self.base_saml_config_fields: @@ -4091,8 +4092,8 @@ def submit_for_configuration(self, updating_existing_record=False): config_data['enable'] = True else: config_data['enable'] = getattr(self, field) - else: - config_data[utils.camelCase(field)] = getattr(self, field) + elif field_value := getattr(self, field): + config_data[utils.camelCase(field)] = field_value EnterpriseSSOOrchestratorApiClient().configure_sso_orchestration_record( config_data=config_data, From 9933465694ca2d02f65cc5093097bec6dccfa609 Mon Sep 17 00:00:00 2001 From: Ehmad Saeed Date: Tue, 17 Oct 2023 01:42:01 +0500 Subject: [PATCH 012/164] feat: parse API Response body of SAPSF to log relevant courses only --- .../transmitters/content_metadata.py | 14 +++++++-- .../transmitters/content_metadata.py | 20 +++++++++++++ .../test_content_metadata.py | 29 +++++++++++++++++++ 3 files changed, 60 insertions(+), 3 deletions(-) diff --git a/integrated_channels/integrated_channel/transmitters/content_metadata.py b/integrated_channels/integrated_channel/transmitters/content_metadata.py index 297fe13e07..ece8d95057 100644 --- a/integrated_channels/integrated_channel/transmitters/content_metadata.py +++ b/integrated_channels/integrated_channel/transmitters/content_metadata.py @@ -125,6 +125,14 @@ def _serialize_items(self, channel_metadata_items): sort_keys=True ).encode('utf-8') + def _filter_api_response(self, response, content_id): # pylint: disable=unused-argument + """ + Filter the response from the integrated channel API client. + This can be overridden by subclasses to parse the response + expected by the integrated channel. + """ + return response + def _transmit_action(self, content_metadata_item_map, client_method, action_name): # pylint: disable=too-many-statements """ Do the work of calling the appropriate client method, saving the results, and updating @@ -219,9 +227,9 @@ def _transmit_action(self, content_metadata_item_map, client_method, action_name ) transmission.api_response_status_code = response_status_code was_successful = response_status_code < 300 - + api_content_response = self._filter_api_response(response_body, content_id) if transmission.api_record: - transmission.api_record.body = response_body + transmission.api_record.body = api_content_response transmission.api_record.status_code = response_status_code transmission.api_record.save() else: @@ -230,7 +238,7 @@ def _transmit_action(self, content_metadata_item_map, client_method, action_name 'ApiResponseRecord' ) transmission.api_record = ApiResponseRecord.objects.create( - body=response_body, status_code=response_status_code + body=api_content_response, status_code=response_status_code ) if action_name == 'create': transmission.remote_created_at = action_happened_at diff --git a/integrated_channels/sap_success_factors/transmitters/content_metadata.py b/integrated_channels/sap_success_factors/transmitters/content_metadata.py index e54f7e26b5..916a8e878b 100644 --- a/integrated_channels/sap_success_factors/transmitters/content_metadata.py +++ b/integrated_channels/sap_success_factors/transmitters/content_metadata.py @@ -1,9 +1,14 @@ """ Class for transmitting content metadata to SuccessFactors. """ +import json +import logging + from integrated_channels.integrated_channel.transmitters.content_metadata import ContentMetadataTransmitter from integrated_channels.sap_success_factors.client import SAPSuccessFactorsAPIClient +LOGGER = logging.getLogger(__name__) + class SapSuccessFactorsContentMetadataTransmitter(ContentMetadataTransmitter): """ @@ -19,6 +24,21 @@ def __init__(self, enterprise_configuration, client=SAPSuccessFactorsAPIClient): client=client ) + def _filter_api_response(self, response, content_id): + """ + Filter the response from SAPSF to only include the content + based on the content_id + """ + try: + parsed_response = json.loads(response) + parsed_response["ocnCourses"] = [item for item in parsed_response["ocnCourses"] + if item["courseID"] == content_id] + filtered_response = json.dumps(parsed_response) + return filtered_response + except Exception as exc: # pylint: disable=broad-except + LOGGER.error("Error filtering response from SAPSF: %s", exc) + return response + def transmit(self, create_payload, update_payload, delete_payload): """ Transmit method overriding base transmissions. Due to rate limiting on SAP diff --git a/tests/test_integrated_channels/test_sap_success_factors/test_transmitters/test_content_metadata.py b/tests/test_integrated_channels/test_sap_success_factors/test_transmitters/test_content_metadata.py index 1f0ccf790e..4ae99c6abf 100644 --- a/tests/test_integrated_channels/test_sap_success_factors/test_transmitters/test_content_metadata.py +++ b/tests/test_integrated_channels/test_sap_success_factors/test_transmitters/test_content_metadata.py @@ -5,6 +5,7 @@ import unittest from datetime import datetime from unittest import mock +import json import responses from pytest import mark @@ -326,3 +327,31 @@ def test_transmit_api_usage_limit_disabled(self, create_content_metadata_mock): plugin_configuration_id=self.enterprise_config.id, integrated_channel_code=self.enterprise_config.channel_code(), ).count() == 2 + + @mock.patch('integrated_channels.sap_success_factors.transmitters.content_metadata.LOGGER') + def test_fiter_api_response_successful(self, logger_mock): + """ + Test that the api response is successfully filtered + """ + response = '{"ocnCourses": [{"courseID": "course:DemoX"}, {"courseID": "course:DemoX2"}]}' + content_id = 'course:DemoX' + + transmitter = SapSuccessFactorsContentMetadataTransmitter(self.enterprise_config) + filtered_response = transmitter._filter_api_response(response, content_id) + + assert json.loads(filtered_response) == {"ocnCourses": [{"courseID": "course:DemoX2"}]} + assert logger_mock.error.call_count == 0 + + @mock.patch('integrated_channels.sap_success_factors.transmitters.content_metadata.LOGGER') + def test_filter_api_response_exception(self, logger_mock): + """ + Test that the api response is not filtered if an exception occurs + """ + response = 'Invalid JSON response' + content_id = 'course:DemoX' + + transmitter = SapSuccessFactorsContentMetadataTransmitter(self.enterprise_config) + filtered_response = transmitter._filter_api_response(response, content_id) + + assert filtered_response == response + logger_mock.error.assert_called_once() From a539c1734309d69024ee5fe6486e8868a5b3ec68 Mon Sep 17 00:00:00 2001 From: hamzawaleed01 Date: Fri, 20 Oct 2023 12:32:07 +0500 Subject: [PATCH 013/164] refactor: add try catch block for enhanced debugging --- .../degreed2/exporters/learner_data.py | 130 ++++++++++-------- 1 file changed, 70 insertions(+), 60 deletions(-) diff --git a/integrated_channels/degreed2/exporters/learner_data.py b/integrated_channels/degreed2/exporters/learner_data.py index 7286f5eebf..5f3e3ddd74 100644 --- a/integrated_channels/degreed2/exporters/learner_data.py +++ b/integrated_channels/degreed2/exporters/learner_data.py @@ -32,72 +32,82 @@ def get_learner_data_records( If no remote ID can be found, return None. """ - percent_grade = kwargs.get('grade_percent') * 100 if kwargs.get('grade_percent') else None - # Degreed expects completion dates of the form 'yyyy-mm-ddTHH:MM:SS'. - degreed_completed_timestamp = completed_date.strftime('%Y-%m-%dT%H:%M:%S') if isinstance( - completed_date, datetime - ) else None - LOGGER.info(generate_formatted_log( - self.enterprise_configuration.channel_code(), - enterprise_enrollment.enterprise_customer_user.enterprise_customer.uuid, - enterprise_enrollment.enterprise_customer_user.user_id, - enterprise_enrollment.course_id, - '[Degreed2Client] - Attempting get_learner_data_records:' - f'percent_grade={percent_grade}, degreed_completed_timestamp={degreed_completed_timestamp}' - f'completed_date={completed_date}, course_completed={course_completed}' - )) - if enterprise_enrollment.enterprise_customer_user.get_remote_id( - self.enterprise_configuration.idp_id - ) is not None: + try: + percent_grade = kwargs.get('grade_percent') * 100 if kwargs.get('grade_percent') else None + # Degreed expects completion dates of the form 'yyyy-mm-ddTHH:MM:SS'. + degreed_completed_timestamp = completed_date.strftime('%Y-%m-%dT%H:%M:%S') if isinstance( + completed_date, datetime + ) else None LOGGER.info(generate_formatted_log( self.enterprise_configuration.channel_code(), enterprise_enrollment.enterprise_customer_user.enterprise_customer.uuid, enterprise_enrollment.enterprise_customer_user.user_id, enterprise_enrollment.course_id, - '[Degreed2Client] - Found remote id:' + '[Degreed2Client] - Attempting get_learner_data_records:' f'percent_grade={percent_grade}, degreed_completed_timestamp={degreed_completed_timestamp}' f'completed_date={completed_date}, course_completed={course_completed}' - f'course_id={get_course_id_for_enrollment(enterprise_enrollment)}' )) - Degreed2LearnerDataTransmissionAudit = apps.get_model( - 'degreed2', - 'Degreed2LearnerDataTransmissionAudit' - ) - # We return two records here, one with the course key and one with the course run id, to account for - # uncertainty about the type of content (course vs. course run) that was sent to the integrated channel. - return [ - Degreed2LearnerDataTransmissionAudit( - enterprise_course_enrollment_id=enterprise_enrollment.id, - degreed_user_email=enterprise_enrollment.enterprise_customer_user.user_email, - user_email=enterprise_enrollment.enterprise_customer_user.user_email, - course_id=get_course_id_for_enrollment(enterprise_enrollment), - completed_timestamp=completed_date, - degreed_completed_timestamp=degreed_completed_timestamp, - course_completed=course_completed, - grade=percent_grade, - enterprise_customer_uuid=enterprise_enrollment.enterprise_customer_user.enterprise_customer.uuid, - plugin_configuration_id=self.enterprise_configuration.id, - ), - Degreed2LearnerDataTransmissionAudit( - enterprise_course_enrollment_id=enterprise_enrollment.id, - degreed_user_email=enterprise_enrollment.enterprise_customer_user.user_email, - user_email=enterprise_enrollment.enterprise_customer_user.user_email, - course_id=enterprise_enrollment.course_id, - completed_timestamp=completed_date, - degreed_completed_timestamp=degreed_completed_timestamp, - course_completed=course_completed, - grade=percent_grade, - enterprise_customer_uuid=enterprise_enrollment.enterprise_customer_user.enterprise_customer.uuid, - plugin_configuration_id=self.enterprise_configuration.id, + if enterprise_enrollment.enterprise_customer_user.get_remote_id( + self.enterprise_configuration.idp_id + ) is not None: + LOGGER.info(generate_formatted_log( + self.enterprise_configuration.channel_code(), + enterprise_enrollment.enterprise_customer_user.enterprise_customer.uuid, + enterprise_enrollment.enterprise_customer_user.user_id, + enterprise_enrollment.course_id, + '[Degreed2Client] - Found remote id:' + f'percent_grade={percent_grade}, degreed_completed_timestamp={degreed_completed_timestamp}' + f'completed_date={completed_date}, course_completed={course_completed}' + f'course_id={get_course_id_for_enrollment(enterprise_enrollment)}' + )) + Degreed2LearnerDataTransmissionAudit = apps.get_model( + 'degreed2', + 'Degreed2LearnerDataTransmissionAudit' ) - ] - LOGGER.info(generate_formatted_log( - self.enterprise_configuration.channel_code(), - enterprise_enrollment.enterprise_customer_user.enterprise_customer.uuid, - enterprise_enrollment.enterprise_customer_user.user_id, - enterprise_enrollment.course_id, - ('get_learner_data_records finished. No learner data was sent for this LMS User Id because ' - 'Degreed2 User ID not found for [{name}]'.format( - name=enterprise_enrollment.enterprise_customer_user.enterprise_customer.name - )))) - return None + # We return two records here, one with the course key and one with the course run id, to account for + # uncertainty about the type of content (course vs. course run) that was sent to the integrated channel. + return [ + Degreed2LearnerDataTransmissionAudit( + enterprise_course_enrollment_id=enterprise_enrollment.id, + degreed_user_email=enterprise_enrollment.enterprise_customer_user.user_email, + user_email=enterprise_enrollment.enterprise_customer_user.user_email, + course_id=get_course_id_for_enrollment(enterprise_enrollment), + completed_timestamp=completed_date, + degreed_completed_timestamp=degreed_completed_timestamp, + course_completed=course_completed, + grade=percent_grade, + enterprise_customer_uuid=enterprise_enrollment.enterprise_customer_user.enterprise_customer.uuid, + plugin_configuration_id=self.enterprise_configuration.id, + ), + Degreed2LearnerDataTransmissionAudit( + enterprise_course_enrollment_id=enterprise_enrollment.id, + degreed_user_email=enterprise_enrollment.enterprise_customer_user.user_email, + user_email=enterprise_enrollment.enterprise_customer_user.user_email, + course_id=enterprise_enrollment.course_id, + completed_timestamp=completed_date, + degreed_completed_timestamp=degreed_completed_timestamp, + course_completed=course_completed, + grade=percent_grade, + enterprise_customer_uuid=enterprise_enrollment.enterprise_customer_user.enterprise_customer.uuid, + plugin_configuration_id=self.enterprise_configuration.id, + ) + ] + LOGGER.info(generate_formatted_log( + self.enterprise_configuration.channel_code(), + enterprise_enrollment.enterprise_customer_user.enterprise_customer.uuid, + enterprise_enrollment.enterprise_customer_user.user_id, + enterprise_enrollment.course_id, + ('get_learner_data_records finished. No learner data was sent for this LMS User Id because ' + 'Degreed2 User ID not found for [{name}]'.format( + name=enterprise_enrollment.enterprise_customer_user.enterprise_customer.name + )))) + return None + except Exception as e: + LOGGER.error(generate_formatted_log( + self.enterprise_configuration.channel_code(), + enterprise_enrollment.enterprise_customer_user.enterprise_customer.uuid, + enterprise_enrollment.enterprise_customer_user.user_id, + enterprise_enrollment.course_id, + 'Degreed2 get_learner_data_records failed, possibly due to an invalid customer configuration. ' + f'Error: {e}' + )) From 1bb718f7024d429b26929829edad44ecb884bc1e Mon Sep 17 00:00:00 2001 From: hamzawaleed01 Date: Fri, 20 Oct 2023 15:13:28 +0500 Subject: [PATCH 014/164] chore: fix linter complaints --- .../degreed2/exporters/learner_data.py | 131 +++++++++--------- 1 file changed, 62 insertions(+), 69 deletions(-) diff --git a/integrated_channels/degreed2/exporters/learner_data.py b/integrated_channels/degreed2/exporters/learner_data.py index 34fdaa5e48..787be279d1 100644 --- a/integrated_channels/degreed2/exporters/learner_data.py +++ b/integrated_channels/degreed2/exporters/learner_data.py @@ -32,77 +32,25 @@ def get_learner_data_records( If no remote ID can be found, return None. """ + percent_grade = kwargs.get('grade_percent') * 100 if kwargs.get('grade_percent') else None + # Degreed expects completion dates of the form 'yyyy-mm-ddTHH:MM:SS'. + degreed_completed_timestamp = completed_date.strftime('%Y-%m-%dT%H:%M:%S') if isinstance( + completed_date, datetime + ) else None + LOGGER.info(generate_formatted_log( + self.enterprise_configuration.channel_code(), + enterprise_enrollment.enterprise_customer_user.enterprise_customer.uuid, + enterprise_enrollment.enterprise_customer_user.user_id, + enterprise_enrollment.course_id, + '[Degreed2Client] - Attempting get_learner_data_records:' + f'percent_grade={percent_grade}, degreed_completed_timestamp={degreed_completed_timestamp}' + f'completed_date={completed_date}, course_completed={course_completed}' + )) try: - percent_grade = kwargs.get('grade_percent') * 100 if kwargs.get('grade_percent') else None - # Degreed expects completion dates of the form 'yyyy-mm-ddTHH:MM:SS'. - degreed_completed_timestamp = completed_date.strftime('%Y-%m-%dT%H:%M:%S') if isinstance( - completed_date, datetime - ) else None - LOGGER.info(generate_formatted_log( - self.enterprise_configuration.channel_code(), - enterprise_enrollment.enterprise_customer_user.enterprise_customer.uuid, - enterprise_enrollment.enterprise_customer_user.user_id, - enterprise_enrollment.course_id, - '[Degreed2Client] - Attempting get_learner_data_records:' - f'percent_grade={percent_grade}, degreed_completed_timestamp={degreed_completed_timestamp}' - f'completed_date={completed_date}, course_completed={course_completed}' - )) - if enterprise_enrollment.enterprise_customer_user.get_remote_id( + remote_id = enterprise_enrollment.enterprise_customer_user.get_remote_id( self.enterprise_configuration.idp_id - ) is not None: - LOGGER.info(generate_formatted_log( - self.enterprise_configuration.channel_code(), - enterprise_enrollment.enterprise_customer_user.enterprise_customer.uuid, - enterprise_enrollment.enterprise_customer_user.user_id, - enterprise_enrollment.course_id, - '[Degreed2Client] - Found remote id:' - f'percent_grade={percent_grade}, degreed_completed_timestamp={degreed_completed_timestamp}' - f'completed_date={completed_date}, course_completed={course_completed}' - f'course_id={get_course_id_for_enrollment(enterprise_enrollment)}' - )) - Degreed2LearnerDataTransmissionAudit = apps.get_model( - 'degreed2', - 'Degreed2LearnerDataTransmissionAudit' - ) - # We return two records here, one with the course key and one with the course run id, to account for - # uncertainty about the type of content (course vs. course run) that was sent to the integrated channel. - return [ - Degreed2LearnerDataTransmissionAudit( - enterprise_course_enrollment_id=enterprise_enrollment.id, - degreed_user_email=enterprise_enrollment.enterprise_customer_user.user_email, - user_email=enterprise_enrollment.enterprise_customer_user.user_email, - course_id=get_course_id_for_enrollment(enterprise_enrollment), - completed_timestamp=completed_date, - degreed_completed_timestamp=degreed_completed_timestamp, - course_completed=course_completed, - grade=percent_grade, - enterprise_customer_uuid=enterprise_enrollment.enterprise_customer_user.enterprise_customer.uuid, - plugin_configuration_id=self.enterprise_configuration.id, - ), - Degreed2LearnerDataTransmissionAudit( - enterprise_course_enrollment_id=enterprise_enrollment.id, - degreed_user_email=enterprise_enrollment.enterprise_customer_user.user_email, - user_email=enterprise_enrollment.enterprise_customer_user.user_email, - course_id=enterprise_enrollment.course_id, - completed_timestamp=completed_date, - degreed_completed_timestamp=degreed_completed_timestamp, - course_completed=course_completed, - grade=percent_grade, - enterprise_customer_uuid=enterprise_enrollment.enterprise_customer_user.enterprise_customer.uuid, - plugin_configuration_id=self.enterprise_configuration.id, - ) - ] - LOGGER.info(generate_formatted_log( - self.enterprise_configuration.channel_code(), - enterprise_enrollment.enterprise_customer_user.enterprise_customer.uuid, - enterprise_enrollment.enterprise_customer_user.user_id, - enterprise_enrollment.course_id, - ('get_learner_data_records finished. No learner data was sent for this LMS User Id because ' - 'Degreed2 User ID not found for [{name}]'.format( - name=enterprise_enrollment.enterprise_customer_user.enterprise_customer.name - )))) - return None - except Exception as e: + ) + except Exception as e: # pylint: disable=broad-except LOGGER.error(generate_formatted_log( self.enterprise_configuration.channel_code(), enterprise_enrollment.enterprise_customer_user.enterprise_customer.uuid, @@ -111,3 +59,48 @@ def get_learner_data_records( '[Degreed2Client] get_learner_data_records failed, possibly due to an invalid customer configuration. ' f'Error: {e}' )) + return None + + if remote_id is not None: + Degreed2LearnerDataTransmissionAudit = apps.get_model( + 'degreed2', + 'Degreed2LearnerDataTransmissionAudit' + ) + # We return two records here, one with the course key and one with the course run id, to account for + # uncertainty about the type of content (course vs. course run) that was sent to the integrated channel. + return [ + Degreed2LearnerDataTransmissionAudit( + enterprise_course_enrollment_id=enterprise_enrollment.id, + degreed_user_email=enterprise_enrollment.enterprise_customer_user.user_email, + user_email=enterprise_enrollment.enterprise_customer_user.user_email, + course_id=get_course_id_for_enrollment(enterprise_enrollment), + completed_timestamp=completed_date, + degreed_completed_timestamp=degreed_completed_timestamp, + course_completed=course_completed, + grade=percent_grade, + enterprise_customer_uuid=enterprise_enrollment.enterprise_customer_user.enterprise_customer.uuid, + plugin_configuration_id=self.enterprise_configuration.id, + ), + Degreed2LearnerDataTransmissionAudit( + enterprise_course_enrollment_id=enterprise_enrollment.id, + degreed_user_email=enterprise_enrollment.enterprise_customer_user.user_email, + user_email=enterprise_enrollment.enterprise_customer_user.user_email, + course_id=enterprise_enrollment.course_id, + completed_timestamp=completed_date, + degreed_completed_timestamp=degreed_completed_timestamp, + course_completed=course_completed, + grade=percent_grade, + enterprise_customer_uuid=enterprise_enrollment.enterprise_customer_user.enterprise_customer.uuid, + plugin_configuration_id=self.enterprise_configuration.id, + ) + ] + LOGGER.info(generate_formatted_log( + self.enterprise_configuration.channel_code(), + enterprise_enrollment.enterprise_customer_user.enterprise_customer.uuid, + enterprise_enrollment.enterprise_customer_user.user_id, + enterprise_enrollment.course_id, + ('get_learner_data_records finished. No learner data was sent for this LMS User Id because ' + 'Degreed2 User ID not found for [{name}]'.format( + name=enterprise_enrollment.enterprise_customer_user.enterprise_customer.name + )))) + return None From f96cfd6edf2d8e9355e15dc80048a03dd36713d1 Mon Sep 17 00:00:00 2001 From: Ehmad Saeed Date: Mon, 23 Oct 2023 15:19:56 +0500 Subject: [PATCH 015/164] feat: create log_exception utility for integrated channels --- .../transmitters/content_metadata.py | 6 +++++- integrated_channels/utils.py | 12 ++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/integrated_channels/sap_success_factors/transmitters/content_metadata.py b/integrated_channels/sap_success_factors/transmitters/content_metadata.py index 4cf1e4c012..888511dcf4 100644 --- a/integrated_channels/sap_success_factors/transmitters/content_metadata.py +++ b/integrated_channels/sap_success_factors/transmitters/content_metadata.py @@ -6,6 +6,7 @@ from integrated_channels.integrated_channel.transmitters.content_metadata import ContentMetadataTransmitter from integrated_channels.sap_success_factors.client import SAPSuccessFactorsAPIClient +from integrated_channels.utils import log_exception LOGGER = logging.getLogger(__name__) @@ -36,7 +37,10 @@ def _filter_api_response(self, response, content_id): filtered_response = json.dumps(parsed_response) return filtered_response except Exception as exc: # pylint: disable=broad-except - LOGGER.exception("Error filtering response from SAPSF for Course: %s, %s", content_id, exc) + log_exception( + self.enterprise_configuration, + f'Error filtering API response: {exc}' + ) return response def transmit(self, create_payload, update_payload, delete_payload): diff --git a/integrated_channels/utils.py b/integrated_channels/utils.py index b41c4b55f9..9f88552e32 100644 --- a/integrated_channels/utils.py +++ b/integrated_channels/utils.py @@ -316,6 +316,18 @@ def generate_formatted_log( f'integrated_channel_plugin_configuration_id={plugin_configuration_id}, {message}' +def log_exception(enterprise_configuration, msg, course_or_course_run_key=None): + LOGGER.exception( + generate_formatted_log( + channel_name=enterprise_configuration.channel_code(), + enterprise_customer_uuid=enterprise_configuration.enterprise_customer.uuid, + course_or_course_run_key=course_or_course_run_key, + plugin_configuration_id=enterprise_configuration.id, + message=msg + ) + ) + + def refresh_session_if_expired( oauth_access_token_function, session=None, From 1b8bc9ab0a6261391c236ab45c57af44d5fc647e Mon Sep 17 00:00:00 2001 From: Ehmad Saeed Date: Mon, 23 Oct 2023 17:32:53 +0500 Subject: [PATCH 016/164] feat: truncate_string utility to limit the maximum text body size --- enterprise/constants.py | 3 +++ enterprise/utils.py | 11 +++++++++++ tests/test_enterprise/test_utils.py | 14 ++++++++++++++ 3 files changed, 28 insertions(+) diff --git a/enterprise/constants.py b/enterprise/constants.py index 97b48dec7b..91aa7b6c87 100644 --- a/enterprise/constants.py +++ b/enterprise/constants.py @@ -219,3 +219,6 @@ class FulfillmentTypes: SSO_BRAZE_CAMPAIGN_ID = 'a5f10d46-8093-4ce1-bab7-6df018d03660' + +# The maximum length of a text field in the database. +MAX_ALLOWED_TEXT_LENGTH = 16_000_000 diff --git a/enterprise/utils.py b/enterprise/utils.py index 28725480c9..2798a75ad7 100644 --- a/enterprise/utils.py +++ b/enterprise/utils.py @@ -42,6 +42,7 @@ PATHWAY_CUSTOMER_ADMIN_ENROLLMENT, PROGRAM_TYPE_DESCRIPTION, CourseModes, + MAX_ALLOWED_TEXT_LENGTH, ) from enterprise.logging import getEnterpriseLogger @@ -2365,3 +2366,13 @@ def camelCase(string): """ output = ''.join(x for x in string.title() if x.isalnum()) return output[0].lower() + output[1:] + + +def truncate_string(string, max_length=MAX_ALLOWED_TEXT_LENGTH): + """ + Truncate a string to the specified max length. + If max length is not specified, it will be set to MAX_ALLOWED_TEXT_LENGTH. + """ + if len(string) > max_length: + return string[:max_length] + return string diff --git a/tests/test_enterprise/test_utils.py b/tests/test_enterprise/test_utils.py index d98921e6c0..060c064bc0 100644 --- a/tests/test_enterprise/test_utils.py +++ b/tests/test_enterprise/test_utils.py @@ -22,7 +22,9 @@ localized_utcnow, parse_lms_api_datetime, serialize_notification_content, + truncate_string, ) +from enterprise.constants import MAX_ALLOWED_TEXT_LENGTH from test_utils import FAKE_UUIDS, TEST_PASSWORD, TEST_USERNAME, factories LMS_BASE_URL = 'https://lms.base.url' @@ -516,3 +518,15 @@ def test_get_default_invite_key_expiration_date(self): expiration_date = get_default_invite_key_expiration_date() expected_expiration_date = current_time + timedelta(days=365) self.assertEqual(expiration_date.date(), expected_expiration_date.date()) + + def test_truncate_string(self): + """ + Test that `truncate_string` returns the expected string. + """ + test_string_1 = 'This is a test string' + self.assertEqual('This is a ', truncate_string(test_string_1, 10)) + self.assertEqual('This is a test string', truncate_string(test_string_1, 100)) + + test_string_2 = ''.rjust(MAX_ALLOWED_TEXT_LENGTH + 10, 'x') + truncated_string = truncate_string(test_string_2) + self.assertEqual(len(truncated_string), MAX_ALLOWED_TEXT_LENGTH) From 70cc77d18eb40e373dc5e6f1ecc5c8e0041e7b06 Mon Sep 17 00:00:00 2001 From: Ehmad Saeed Date: Mon, 23 Oct 2023 17:33:37 +0500 Subject: [PATCH 017/164] feat: truncate API Response before writing to DB --- enterprise/utils.py | 12 +++++++++--- .../transmitters/content_metadata.py | 9 ++++++++- tests/test_enterprise/test_utils.py | 14 ++++++++++---- 3 files changed, 27 insertions(+), 8 deletions(-) diff --git a/enterprise/utils.py b/enterprise/utils.py index 2798a75ad7..291f7d139b 100644 --- a/enterprise/utils.py +++ b/enterprise/utils.py @@ -39,10 +39,10 @@ DEFAULT_CATALOG_CONTENT_FILTER, LMS_API_DATETIME_FORMAT, LMS_API_DATETIME_FORMAT_WITHOUT_TIMEZONE, + MAX_ALLOWED_TEXT_LENGTH, PATHWAY_CUSTOMER_ADMIN_ENROLLMENT, PROGRAM_TYPE_DESCRIPTION, CourseModes, - MAX_ALLOWED_TEXT_LENGTH, ) from enterprise.logging import getEnterpriseLogger @@ -2372,7 +2372,13 @@ def truncate_string(string, max_length=MAX_ALLOWED_TEXT_LENGTH): """ Truncate a string to the specified max length. If max length is not specified, it will be set to MAX_ALLOWED_TEXT_LENGTH. + + Returns: + (tuple): (truncated_string, was_truncated) """ + was_truncated = False if len(string) > max_length: - return string[:max_length] - return string + truncated_string = string[:max_length] + was_truncated = True + return (truncated_string, was_truncated) + return (string, was_truncated) diff --git a/integrated_channels/integrated_channel/transmitters/content_metadata.py b/integrated_channels/integrated_channel/transmitters/content_metadata.py index ece8d95057..33ad60d6c0 100644 --- a/integrated_channels/integrated_channel/transmitters/content_metadata.py +++ b/integrated_channels/integrated_channel/transmitters/content_metadata.py @@ -12,7 +12,7 @@ from django.apps import apps from django.conf import settings -from enterprise.utils import localized_utcnow +from enterprise.utils import localized_utcnow, truncate_string from integrated_channels.exceptions import ClientError from integrated_channels.integrated_channel.client import IntegratedChannelApiClient from integrated_channels.integrated_channel.transmitters import Transmitter @@ -228,6 +228,13 @@ def _transmit_action(self, content_metadata_item_map, client_method, action_name transmission.api_response_status_code = response_status_code was_successful = response_status_code < 300 api_content_response = self._filter_api_response(response_body, content_id) + (api_content_response, was_truncated) = truncate_string(api_content_response) + if was_truncated: + self._log_info( + f'integrated_channel_content_transmission_id={transmission.id}, ' + f'api response truncated', + course_or_course_run_key=content_id + ) if transmission.api_record: transmission.api_record.body = api_content_response transmission.api_record.status_code = response_status_code diff --git a/tests/test_enterprise/test_utils.py b/tests/test_enterprise/test_utils.py index 060c064bc0..9dc3cac117 100644 --- a/tests/test_enterprise/test_utils.py +++ b/tests/test_enterprise/test_utils.py @@ -12,6 +12,7 @@ from django.conf import settings from django.forms.models import model_to_dict +from enterprise.constants import MAX_ALLOWED_TEXT_LENGTH from enterprise.models import EnterpriseCourseEnrollment, LicensedEnterpriseCourseEnrollment from enterprise.utils import ( enroll_subsidy_users_in_courses, @@ -24,7 +25,6 @@ serialize_notification_content, truncate_string, ) -from enterprise.constants import MAX_ALLOWED_TEXT_LENGTH from test_utils import FAKE_UUIDS, TEST_PASSWORD, TEST_USERNAME, factories LMS_BASE_URL = 'https://lms.base.url' @@ -524,9 +524,15 @@ def test_truncate_string(self): Test that `truncate_string` returns the expected string. """ test_string_1 = 'This is a test string' - self.assertEqual('This is a ', truncate_string(test_string_1, 10)) - self.assertEqual('This is a test string', truncate_string(test_string_1, 100)) + (truncated_string_1, was_truncated_1) = truncate_string(test_string_1, 10) + self.assertTrue(was_truncated_1) + self.assertEqual('This is a ', truncated_string_1) + + (truncated_string_2, was_truncated_2) = truncate_string(test_string_1, 100) + self.assertFalse(was_truncated_2) + self.assertEqual('This is a test string', truncated_string_2) test_string_2 = ''.rjust(MAX_ALLOWED_TEXT_LENGTH + 10, 'x') - truncated_string = truncate_string(test_string_2) + (truncated_string, was_truncated) = truncate_string(test_string_2) + self.assertTrue(was_truncated) self.assertEqual(len(truncated_string), MAX_ALLOWED_TEXT_LENGTH) From cc961938a78b2e093b16e32050f2ec422c6893fa Mon Sep 17 00:00:00 2001 From: Ehmad Saeed Date: Mon, 23 Oct 2023 15:38:25 +0500 Subject: [PATCH 018/164] fix: filter the response only after a successful response status --- .../integrated_channel/transmitters/content_metadata.py | 4 +++- .../sap_success_factors/transmitters/content_metadata.py | 4 +++- .../test_transmitters/test_content_metadata.py | 4 ++-- 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/integrated_channels/integrated_channel/transmitters/content_metadata.py b/integrated_channels/integrated_channel/transmitters/content_metadata.py index ece8d95057..dc87a33a40 100644 --- a/integrated_channels/integrated_channel/transmitters/content_metadata.py +++ b/integrated_channels/integrated_channel/transmitters/content_metadata.py @@ -227,7 +227,9 @@ def _transmit_action(self, content_metadata_item_map, client_method, action_name ) transmission.api_response_status_code = response_status_code was_successful = response_status_code < 300 - api_content_response = self._filter_api_response(response_body, content_id) + api_content_response = response_body + if was_successful: + api_content_response = self._filter_api_response(api_content_response, content_id) if transmission.api_record: transmission.api_record.body = api_content_response transmission.api_record.status_code = response_status_code diff --git a/integrated_channels/sap_success_factors/transmitters/content_metadata.py b/integrated_channels/sap_success_factors/transmitters/content_metadata.py index 888511dcf4..c0656d4a68 100644 --- a/integrated_channels/sap_success_factors/transmitters/content_metadata.py +++ b/integrated_channels/sap_success_factors/transmitters/content_metadata.py @@ -39,7 +39,9 @@ def _filter_api_response(self, response, content_id): except Exception as exc: # pylint: disable=broad-except log_exception( self.enterprise_configuration, - f'Error filtering API response: {exc}' + f'Failed to filter the API response: ' + f'{exc}', + content_id ) return response diff --git a/tests/test_integrated_channels/test_sap_success_factors/test_transmitters/test_content_metadata.py b/tests/test_integrated_channels/test_sap_success_factors/test_transmitters/test_content_metadata.py index 7897bc4e17..9c5260545b 100644 --- a/tests/test_integrated_channels/test_sap_success_factors/test_transmitters/test_content_metadata.py +++ b/tests/test_integrated_channels/test_sap_success_factors/test_transmitters/test_content_metadata.py @@ -328,7 +328,7 @@ def test_transmit_api_usage_limit_disabled(self, create_content_metadata_mock): integrated_channel_code=self.enterprise_config.channel_code(), ).count() == 2 - @mock.patch('integrated_channels.sap_success_factors.transmitters.content_metadata.LOGGER') + @mock.patch('integrated_channels.utils.LOGGER') def test_filter_api_response_successful(self, logger_mock): """ Test that the api response is successfully filtered @@ -343,7 +343,7 @@ def test_filter_api_response_successful(self, logger_mock): assert json.loads(filtered_response) == {"ocnCourses": [{"courseID": "course:DemoX"}]} assert logger_mock.exception.call_count == 0 - @mock.patch('integrated_channels.sap_success_factors.transmitters.content_metadata.LOGGER') + @mock.patch('integrated_channels.utils.LOGGER') def test_filter_api_response_exception(self, logger_mock): """ Test that the api response is not filtered if an exception occurs From 2f7c2e8342fb9133f7debeae120f10f6f853a6d5 Mon Sep 17 00:00:00 2001 From: Ehmad Saeed Date: Tue, 24 Oct 2023 14:29:00 +0500 Subject: [PATCH 019/164] chore: version bump for ENT 7715 and 7716 --- CHANGELOG.rst | 5 +++++ enterprise/__init__.py | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 3062a5f40e..82839a0534 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -15,6 +15,11 @@ Change Log Unreleased ---------- +[4.6.8] +------- +feat: truncate API Response before writing to the APIResponseRecord +fix: initiate filtering the API Response only when a successful response is received + [4.6.7] ------- feat: filter courses from API Response of SAPSF to store in the APIResponseRecord table diff --git a/enterprise/__init__.py b/enterprise/__init__.py index 262a2eabd5..9f9aa82975 100644 --- a/enterprise/__init__.py +++ b/enterprise/__init__.py @@ -2,4 +2,4 @@ Your project description goes here. """ -__version__ = "4.6.7" +__version__ = "4.6.8" From 78ffebcf40348503a8685f3766c937f2dc3881f0 Mon Sep 17 00:00:00 2001 From: Alexander J Sheehan Date: Sun, 22 Oct 2023 15:23:50 +0000 Subject: [PATCH 020/164] chore: returning SP metadata url from the sso orchestrator to the API caller --- CHANGELOG.rst | 4 ++++ enterprise/__init__.py | 2 +- .../views/enterprise_customer_sso_configuration.py | 14 ++++++++------ enterprise/api_client/sso_orchestrator.py | 5 +++-- enterprise/models.py | 3 ++- tests/test_enterprise/api/test_views.py | 10 +++++----- .../api_client/test_sso_orchestrator.py | 4 ++-- 7 files changed, 25 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 82839a0534..624c9800be 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -15,6 +15,10 @@ Change Log Unreleased ---------- +[4.6.9] +------- +chore: returning SP metadata url from the sso orchestrator to the API caller + [4.6.8] ------- feat: truncate API Response before writing to the APIResponseRecord diff --git a/enterprise/__init__.py b/enterprise/__init__.py index 9f9aa82975..d878e19306 100644 --- a/enterprise/__init__.py +++ b/enterprise/__init__.py @@ -2,4 +2,4 @@ Your project description goes here. """ -__version__ = "4.6.8" +__version__ = "4.6.9" diff --git a/enterprise/api/v1/views/enterprise_customer_sso_configuration.py b/enterprise/api/v1/views/enterprise_customer_sso_configuration.py index 252f7aed1f..f123e136e9 100644 --- a/enterprise/api/v1/views/enterprise_customer_sso_configuration.py +++ b/enterprise/api/v1/views/enterprise_customer_sso_configuration.py @@ -259,12 +259,12 @@ def create(self, request, *args, **kwargs): try: with transaction.atomic(): new_record = EnterpriseCustomerSsoConfiguration.objects.create(**request_data) - new_record.submit_for_configuration() + sp_metadata_url = new_record.submit_for_configuration() except (TypeError, SsoOrchestratorClientError) as e: LOGGER.error(f'{CONFIG_CREATE_ERROR} {e}') return Response({'error': f'{CONFIG_CREATE_ERROR} {e}'}, status=HTTP_400_BAD_REQUEST) - return Response({'data': new_record.pk}, status=HTTP_201_CREATED) + return Response({'record': new_record.pk, 'sp_metadata_url': sp_metadata_url}, status=HTTP_201_CREATED) @permission_required( 'enterprise.can_access_admin_dashboard', @@ -321,12 +321,14 @@ def update(self, request, *args, **kwargs): try: with transaction.atomic(): sso_configuration_record.update(**request_data) - sso_configuration_record.first().submit_for_configuration(updating_existing_record=True) + sp_metadata_url = sso_configuration_record.first().submit_for_configuration( + updating_existing_record=True + ) except (TypeError, FieldDoesNotExist, ValidationError, SsoOrchestratorClientError) as e: - LOGGER.error(f'{CONFIG_UPDATE_ERROR}{e}') - return Response({'error': f'{CONFIG_UPDATE_ERROR}{e}'}, status=HTTP_400_BAD_REQUEST) + LOGGER.error(f'{CONFIG_UPDATE_ERROR} {e}') + return Response({'error': f'{CONFIG_UPDATE_ERROR} {e}'}, status=HTTP_400_BAD_REQUEST) serializer = self.serializer_class(sso_configuration_record.first()) - return Response(serializer.data, status=HTTP_200_OK) + return Response({'record': serializer.data, 'sp_metadata_url': sp_metadata_url}, status=HTTP_200_OK) @permission_required( 'enterprise.can_access_admin_dashboard', diff --git a/enterprise/api_client/sso_orchestrator.py b/enterprise/api_client/sso_orchestrator.py index 38df893dd7..693987aa2c 100644 --- a/enterprise/api_client/sso_orchestrator.py +++ b/enterprise/api_client/sso_orchestrator.py @@ -102,7 +102,7 @@ def _post(self, url, data=None): f"Failed to make SSO Orchestrator API request: {response.status_code}", response=response, ) - return response.status_code + return response.json() def configure_sso_orchestration_record( self, @@ -131,4 +131,5 @@ def configure_sso_orchestration_record( if is_sap or sap_config_data: request_data['sapsfConfiguration'] = sap_config_data - return self._post(self._get_orchestrator_configure_url(), data=request_data) + response = self._post(self._get_orchestrator_configure_url(), data=request_data) + return response.get('samlServiceProviderInformation', {}).get('spMetadataUrl', {}) diff --git a/enterprise/models.py b/enterprise/models.py index 4cd6fba832..9ab6485395 100644 --- a/enterprise/models.py +++ b/enterprise/models.py @@ -4095,7 +4095,7 @@ def submit_for_configuration(self, updating_existing_record=False): elif field_value := getattr(self, field): config_data[utils.camelCase(field)] = field_value - EnterpriseSSOOrchestratorApiClient().configure_sso_orchestration_record( + sp_metadata_url = EnterpriseSSOOrchestratorApiClient().configure_sso_orchestration_record( config_data=config_data, config_pk=self.pk, enterprise_data={ @@ -4109,3 +4109,4 @@ def submit_for_configuration(self, updating_existing_record=False): ) self.submitted_at = localized_utcnow() self.save() + return sp_metadata_url diff --git a/tests/test_enterprise/api/test_views.py b/tests/test_enterprise/api/test_views.py index 375298d904..95b77d5007 100644 --- a/tests/test_enterprise/api/test_views.py +++ b/tests/test_enterprise/api/test_views.py @@ -7529,8 +7529,8 @@ def test_sso_configuration_create(self): response = self.post_new_sso_configuration(data) assert response.status_code == status.HTTP_201_CREATED assert len(EnterpriseCustomerSsoConfiguration.objects.all()) == 1 - created_record = EnterpriseCustomerSsoConfiguration.objects.all().first().uuid - assert response.data['data'] == created_record + created_record_uuid = EnterpriseCustomerSsoConfiguration.objects.all().first().uuid + assert response.data['record'] == created_record_uuid def test_sso_configuration_create_permissioning(self): """ @@ -7766,7 +7766,7 @@ def test_sso_configurations_update_submitted_config(self): assert sent_body_params['requestIdentifier'] == str(config_pk) @responses.activate - def test_sso_configuration_update_x(self): + def test_sso_configuration_update_success(self): """ Test expected response when successfully updating an existing sso configuration. """ @@ -7797,8 +7797,8 @@ def test_sso_configuration_update_x(self): } response = self.update_sso_configuration(config_pk, data) assert response.status_code == status.HTTP_200_OK - assert response.json()['uuid'] == str(enterprise_sso_orchestration_config.uuid) - assert response.json()['metadata_url'] == "https://example.com/metadata_update.xml" + assert response.json()['record']['uuid'] == str(enterprise_sso_orchestration_config.uuid) + assert response.json()['record']['metadata_url'] == "https://example.com/metadata_update.xml" enterprise_sso_orchestration_config.refresh_from_db() assert enterprise_sso_orchestration_config.metadata_url == "https://example.com/metadata_update.xml" diff --git a/tests/test_enterprise/api_client/test_sso_orchestrator.py b/tests/test_enterprise/api_client/test_sso_orchestrator.py index 88894451b5..683fe2fec6 100644 --- a/tests/test_enterprise/api_client/test_sso_orchestrator.py +++ b/tests/test_enterprise/api_client/test_sso_orchestrator.py @@ -28,7 +28,7 @@ def test_post_sso_configuration(): responses.add( responses.POST, SSO_ORCHESTRATOR_CONFIGURE_URL, - json={}, + json={'samlServiceProviderInformation': {'spMetadataUrl': 'https://example.com'}}, ) client = sso_orchestrator.EnterpriseSSOOrchestratorApiClient() actual_response = client.configure_sso_orchestration_record( @@ -36,7 +36,7 @@ def test_post_sso_configuration(): config_pk=TEST_ENTERPRISE_SSO_CONFIG_UUID, enterprise_data={'uuid': TEST_ENTERPRISE_ID, 'name': TEST_ENTERPRISE_NAME, 'slug': TEST_ENTERPRISE_NAME}, ) - assert actual_response == 200 + assert actual_response == 'https://example.com' responses.assert_call_count(count=1, url=SSO_ORCHESTRATOR_CONFIGURE_URL) sent_body_params = json.loads(responses.calls[0].request.body) From 3cffc904138391632fcf609ae495676aa2680509 Mon Sep 17 00:00:00 2001 From: zubairshakoorarbisoft Date: Tue, 31 Oct 2023 17:07:37 +0500 Subject: [PATCH 021/164] fix: Replaced whitelist_externals with allowlist_externals in tox and removed tox-battery --- requirements/ci.in | 1 - requirements/ci.txt | 3 --- requirements/dev.in | 1 - requirements/dev.txt | 3 --- tox.ini | 9 ++++----- 5 files changed, 4 insertions(+), 13 deletions(-) diff --git a/requirements/ci.in b/requirements/ci.in index d97da5dcbb..ffbaa2043b 100644 --- a/requirements/ci.in +++ b/requirements/ci.in @@ -3,4 +3,3 @@ -c constraints.txt tox # Virtualenv management for tests -tox-battery==0.6.1 # Makes tox aware of requirements file changes diff --git a/requirements/ci.txt b/requirements/ci.txt index cca733262c..aab2d5880b 100644 --- a/requirements/ci.txt +++ b/requirements/ci.txt @@ -27,8 +27,5 @@ tox==3.28.0 # -c requirements/common_constraints.txt # -c requirements/constraints.txt # -r requirements/ci.in - # tox-battery -tox-battery==0.6.1 - # via -r requirements/ci.in virtualenv==20.24.5 # via tox diff --git a/requirements/dev.in b/requirements/dev.in index 158cf5c88f..8ab9d5d0cd 100644 --- a/requirements/dev.in +++ b/requirements/dev.in @@ -14,6 +14,5 @@ pycodestyle # PEP 8 compliance validation pydocstyle # PEP 257 compliance validation testfixtures # Mock objects for unit tests and doc tests tox # virtualenv management for tests -tox-battery==0.6.1 # Makes tox aware of requirements file changes (it's experimental, so keep it pinned) twine==1.11.0 # Utility for PyPI package uploads wheel # For generation of wheels for PyPI diff --git a/requirements/dev.txt b/requirements/dev.txt index f1c86e6f3a..0b72a4d7d6 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -877,9 +877,6 @@ tox==3.28.0 # -c requirements/common_constraints.txt # -c requirements/constraints.txt # -r requirements/dev.in - # tox-battery -tox-battery==0.6.1 - # via -r requirements/dev.in tqdm==4.66.1 # via # -r requirements/doc.txt diff --git a/tox.ini b/tox.ini index 7b1e7eee0b..8654c28c22 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist = py38-django{32,42}-celery{53}, django{32}-pii-annotations +envlist = py38-django{32}-celery{50}, django{32}-pii-annotations [doc8] max-line-length = 120 @@ -27,8 +27,7 @@ setenv = TOXENV={envname} deps = django32: Django>=3.2,<4.0 - django42: Django>=4.2,<4.3 - celery53: -r{toxinidir}/requirements/celery53.txt + celery50: -r{toxinidir}/requirements/celery50.txt -r{toxinidir}/requirements/test.txt commands = py.test -Wd {posargs} @@ -37,7 +36,7 @@ commands = setenv = DJANGO_SETTINGS_MODULE = enterprise.settings.test PYTHONPATH = {toxinidir} -whitelist_externals = +allowlist_externals = make rm deps = @@ -65,7 +64,7 @@ commands = [testenv:quality] setenv = DJANGO_SETTINGS_MODULE = enterprise.settings.test -whitelist_externals = +allowlist_externals = make rm touch From c51d1622843552ab75f9b8bb94f624ae82628a8c Mon Sep 17 00:00:00 2001 From: zubairshakoorarbisoft Date: Tue, 31 Oct 2023 17:09:51 +0500 Subject: [PATCH 022/164] fix: tox.ini changes fixed --- tox.ini | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tox.ini b/tox.ini index 8654c28c22..bf280c69f4 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist = py38-django{32}-celery{50}, django{32}-pii-annotations +envlist = py38-django{32,42}-celery{53}, django{32}-pii-annotations [doc8] max-line-length = 120 @@ -27,7 +27,8 @@ setenv = TOXENV={envname} deps = django32: Django>=3.2,<4.0 - celery50: -r{toxinidir}/requirements/celery50.txt + django42: Django>=4.2,<4.3 + celery53: -r{toxinidir}/requirements/celery53.txt -r{toxinidir}/requirements/test.txt commands = py.test -Wd {posargs} From e58ee418a7df0895d95fe84065f87733d5ae3c7a Mon Sep 17 00:00:00 2001 From: Feanil Patel Date: Mon, 23 Oct 2023 09:46:49 -0400 Subject: [PATCH 023/164] docs: Update the security e-mail address. This repository is now managed by the Axim Collaborative and security issues with it should be reported to security@openedx.org instead of security@edx.org This work is being done as a part of https://github.com/openedx/wg-security/issues/16 --- README.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.rst b/README.rst index c18f0b972b..d12038d60f 100644 --- a/README.rst +++ b/README.rst @@ -58,7 +58,7 @@ relevant reviewers and track review process. Reporting Security Issues ------------------------- -Please do not report security issues in public. Please email security@edx.org. +Please do not report security issues in public. Please email security@openedx.org. Getting Help ------------ From 653ebdd21cbea6434ad563bd647e648a724821ce Mon Sep 17 00:00:00 2001 From: hamzawaleed01 Date: Wed, 1 Nov 2023 13:10:24 +0500 Subject: [PATCH 024/164] feat: handle exception for deleted user more gracefully and add new test --- integrated_channels/degreed2/client.py | 11 +++- .../test_degreed2/test_client.py | 52 +++++++++++++++++++ 2 files changed, 62 insertions(+), 1 deletion(-) diff --git a/integrated_channels/degreed2/client.py b/integrated_channels/degreed2/client.py index 46a8cb0873..4dd1eacc4e 100644 --- a/integrated_channels/degreed2/client.py +++ b/integrated_channels/degreed2/client.py @@ -6,6 +6,7 @@ import json import logging import time +from http import HTTPStatus import requests from six.moves.urllib.parse import urljoin @@ -129,11 +130,19 @@ def create_course_completion(self, user_id, payload): f'Attempting find course via url: {self.get_completions_url()}'), user_id ) - return self._post( + code, body = self._post( self.get_completions_url(), json_payload, self.ALL_DESIRED_SCOPES ) + if code == HTTPStatus.BAD_REQUEST.value: + error_response = json.loads(body) + for error in error_response['errors']: + if 'detail' in error and 'Invalid user identifier' in error['detail']: + raise ClientError(f'Degreed2 create_course_completion failed due to' + f'deleted user: {body}, code:{code}' + ) + return code, body def delete_course_completion(self, user_id, payload): """ diff --git a/tests/test_integrated_channels/test_degreed2/test_client.py b/tests/test_integrated_channels/test_degreed2/test_client.py index d35193e5ee..5a006e7824 100644 --- a/tests/test_integrated_channels/test_degreed2/test_client.py +++ b/tests/test_integrated_channels/test_degreed2/test_client.py @@ -79,6 +79,18 @@ def setUp(self): } ] } + self.user_deleted_response = { + "errors": [ + { + "id": "c2e2f849-ed0a-4ed8-833c-f9008113948c", + "code": "bad-request", + "status": 400, + "title": "Bad Request", + "detail": "Invalid user identifier: test-learner@example.com", + "source": "test-learner@example.com" + } + ] + } def test_calculate_backoff(self): """ @@ -142,6 +154,46 @@ def test_create_course_completion(self): assert responses.calls[0].request.url == degreed_api_client.get_oauth_url() assert responses.calls[1].request.url == degreed_api_client.get_completions_url() + @responses.activate + def test_create_course_completion_for_deleted_user(self): + """ + ``create_course_completion`` should handle exception for deleted users gracefully + """ + degreed_api_client = Degreed2APIClient(self.enterprise_config) + responses.add( + responses.POST, + degreed_api_client.get_oauth_url(), + json=self.expected_token_response_body, + status=200 + ) + responses.add( + responses.POST, + degreed_api_client.get_completions_url(), + json=self.user_deleted_response, + status=400 + ) + + payload = { + "data": { + "attributes": { + "user-id": 'test-learner@example.com', + "user-identifier-type": "Email", + "content-id": 'DemoX', + "content-id-type": "externalId", + "content-type": "course", + "completed-at": NOW_TIMESTAMP_FORMATTED, + "percentile": 80, + } + } + } + + with pytest.raises(ClientError): + output = degreed_api_client.create_course_completion('test-learner@example.com', json.dumps(payload)) + assert output == (400, json.dumps(self.too_fast_response)) + assert len(responses.calls) == 2 + assert responses.calls[0].request.url == degreed_api_client.get_oauth_url() + assert responses.calls[1].request.url == degreed_api_client.get_completions_url() + @responses.activate def test_delete_course_completion(self): """ From 3cc3214877145439d05a2de84fa3a9e5c054baad Mon Sep 17 00:00:00 2001 From: hamzawaleed01 Date: Wed, 1 Nov 2023 17:23:35 +0500 Subject: [PATCH 025/164] test: update assertion --- integrated_channels/degreed2/client.py | 2 +- tests/test_integrated_channels/test_degreed2/test_client.py | 5 ++--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/integrated_channels/degreed2/client.py b/integrated_channels/degreed2/client.py index 4dd1eacc4e..7b7448f0ee 100644 --- a/integrated_channels/degreed2/client.py +++ b/integrated_channels/degreed2/client.py @@ -139,7 +139,7 @@ def create_course_completion(self, user_id, payload): error_response = json.loads(body) for error in error_response['errors']: if 'detail' in error and 'Invalid user identifier' in error['detail']: - raise ClientError(f'Degreed2 create_course_completion failed due to' + raise ClientError(f'Degreed2 create_course_completion failed due to ' f'deleted user: {body}, code:{code}' ) return code, body diff --git a/tests/test_integrated_channels/test_degreed2/test_client.py b/tests/test_integrated_channels/test_degreed2/test_client.py index 5a006e7824..766afb1512 100644 --- a/tests/test_integrated_channels/test_degreed2/test_client.py +++ b/tests/test_integrated_channels/test_degreed2/test_client.py @@ -187,9 +187,8 @@ def test_create_course_completion_for_deleted_user(self): } } - with pytest.raises(ClientError): - output = degreed_api_client.create_course_completion('test-learner@example.com', json.dumps(payload)) - assert output == (400, json.dumps(self.too_fast_response)) + with pytest.raises(ClientError, match="Degreed2 create_course_completion failed due to deleted user:"): + degreed_api_client.create_course_completion('test-learner@example.com', json.dumps(payload)) assert len(responses.calls) == 2 assert responses.calls[0].request.url == degreed_api_client.get_oauth_url() assert responses.calls[1].request.url == degreed_api_client.get_completions_url() From a9b7593a742fb0343091d39199fc1b897723ef5b Mon Sep 17 00:00:00 2001 From: Brian Beggs Date: Wed, 1 Nov 2023 08:20:50 -0400 Subject: [PATCH 026/164] chore: Update version to 4.6.10 --- CHANGELOG.rst | 4 ++++ enterprise/__init__.py | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 624c9800be..c8325fb37b 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -15,6 +15,10 @@ Change Log Unreleased ---------- +[4.6.10] +-------- +chore: Update requirements + [4.6.9] ------- chore: returning SP metadata url from the sso orchestrator to the API caller diff --git a/enterprise/__init__.py b/enterprise/__init__.py index d878e19306..a5cad61c34 100644 --- a/enterprise/__init__.py +++ b/enterprise/__init__.py @@ -2,4 +2,4 @@ Your project description goes here. """ -__version__ = "4.6.9" +__version__ = "4.6.10" From b60b1c7cb556e49e474cfdffe62a43e1de14c61d Mon Sep 17 00:00:00 2001 From: edX requirements bot Date: Wed, 1 Nov 2023 09:13:58 -0400 Subject: [PATCH 027/164] chore: Updating Python Requirements --- requirements/ci.txt | 8 +- requirements/common_constraints.txt | 3 - requirements/dev.txt | 87 ++++++------ requirements/django.txt | 2 +- requirements/doc.txt | 61 +++++---- requirements/edx-platform-constraints.txt | 156 +++++++++++----------- requirements/js_test.txt | 22 +-- requirements/test-master.txt | 51 ++++--- requirements/test.txt | 61 ++++----- 9 files changed, 226 insertions(+), 225 deletions(-) diff --git a/requirements/ci.txt b/requirements/ci.txt index aab2d5880b..a055e8ef41 100644 --- a/requirements/ci.txt +++ b/requirements/ci.txt @@ -6,13 +6,13 @@ # distlib==0.3.7 # via virtualenv -filelock==3.12.4 +filelock==3.13.1 # via # tox # virtualenv -packaging==23.1 +packaging==23.2 # via tox -platformdirs==3.10.0 +platformdirs==3.11.0 # via virtualenv pluggy==1.3.0 # via tox @@ -27,5 +27,5 @@ tox==3.28.0 # -c requirements/common_constraints.txt # -c requirements/constraints.txt # -r requirements/ci.in -virtualenv==20.24.5 +virtualenv==20.24.6 # via tox diff --git a/requirements/common_constraints.txt b/requirements/common_constraints.txt index 601b0ae550..08e94f34dd 100644 --- a/requirements/common_constraints.txt +++ b/requirements/common_constraints.txt @@ -1,7 +1,4 @@ - - - # A central location for most common version constraints # (across edx repos) for pip-installation. # diff --git a/requirements/dev.txt b/requirements/dev.txt index 0b72a4d7d6..e3ac980034 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -8,7 +8,7 @@ accessible-pygments==0.0.4 # via # -r requirements/doc.txt # pydata-sphinx-theme -aiohttp==3.8.5 +aiohttp==3.8.6 # via # -r requirements/doc.txt # -r requirements/test-master.txt @@ -50,7 +50,7 @@ asn1crypto==1.5.1 # -r requirements/test.txt # oscrypto # snowflake-connector-python -astroid==2.15.6 +astroid==3.0.1 # via # pylint # pylint-celery @@ -67,7 +67,7 @@ attrs==23.1.0 # -r requirements/test.txt # aiohttp # pytest -babel==2.12.1 +babel==2.13.1 # via # -r requirements/doc.txt # pydata-sphinx-theme @@ -77,6 +77,7 @@ backports-zoneinfo[tzdata]==0.2.1 # -r requirements/doc.txt # -r requirements/test-master.txt # -r requirements/test.txt + # backports-zoneinfo # celery # kombu beautifulsoup4==4.12.2 @@ -89,7 +90,7 @@ billiard==4.1.0 # -r requirements/test-master.txt # -r requirements/test.txt # celery -bleach==6.0.0 +bleach==6.1.0 # via # -r requirements/doc.txt # -r requirements/test-master.txt @@ -109,7 +110,7 @@ certifi==2023.7.22 # -r requirements/test.txt # requests # snowflake-connector-python -cffi==1.15.1 +cffi==1.16.0 # via # -r requirements/doc.txt # -r requirements/test-master.txt @@ -170,9 +171,10 @@ code-annotations==1.5.0 # -r requirements/test.txt # edx-lint # edx-toggles -coverage[toml]==7.3.1 +coverage[toml]==7.3.2 # via # -r requirements/test.txt + # coverage # pytest-cov cryptography==38.0.4 # via @@ -199,13 +201,13 @@ deprecated==1.2.14 # -r requirements/test-master.txt # -r requirements/test.txt # jwcrypto -diff-cover==7.7.0 +diff-cover==8.0.0 # via -r requirements/test.txt dill==0.3.7 # via pylint distlib==0.3.7 # via virtualenv -django==3.2.21 +django==3.2.22 # via # -c requirements/common_constraints.txt # -r requirements/doc.txt @@ -227,12 +229,12 @@ django==3.2.21 # edx-rbac # edx-toggles # jsonfield -django-cache-memoize==0.1.10 +django-cache-memoize==0.2.0 # via # -r requirements/doc.txt # -r requirements/test-master.txt # -r requirements/test.txt -django-config-models==2.5.0 +django-config-models==2.5.1 # via # -r requirements/doc.txt # -r requirements/test-master.txt @@ -255,12 +257,12 @@ django-fernet-fields-v2==0.9 # -r requirements/doc.txt # -r requirements/test-master.txt # -r requirements/test.txt -django-filter==23.2 +django-filter==23.3 # via # -r requirements/doc.txt # -r requirements/test-master.txt # -r requirements/test.txt -django-ipware==5.0.0 +django-ipware==5.0.1 # via # -r requirements/doc.txt # -r requirements/test-master.txt @@ -276,7 +278,7 @@ django-multi-email-field==0.7.0 # -r requirements/doc.txt # -r requirements/test-master.txt # -r requirements/test.txt -django-oauth-toolkit==1.5.0 +django-oauth-toolkit==1.7.1 # via # -r requirements/doc.txt # -r requirements/test-master.txt @@ -329,7 +331,7 @@ drf-jwt==1.19.2 # -r requirements/test-master.txt # -r requirements/test.txt # edx-drf-extensions -edx-braze-client==0.1.7 +edx-braze-client==0.1.8 # via # -r requirements/doc.txt # -r requirements/test-master.txt @@ -343,22 +345,23 @@ edx-django-utils==5.7.0 # edx-drf-extensions # edx-rest-api-client # edx-toggles -edx-drf-extensions==8.9.2 +edx-drf-extensions==8.12.0 # via # -r requirements/doc.txt # -r requirements/test-master.txt # -r requirements/test.txt # edx-rbac -edx-i18n-tools==1.2.0 +edx-i18n-tools==1.3.0 # via -r requirements/dev.in -edx-lint==5.3.4 +edx-lint==5.3.6 # via -r requirements/dev.in -edx-opaque-keys[django]==2.5.0 +edx-opaque-keys[django]==2.5.1 # via # -r requirements/doc.txt # -r requirements/test-master.txt # -r requirements/test.txt # edx-drf-extensions + # edx-opaque-keys edx-rbac==1.8.0 # via # -r requirements/doc.txt @@ -384,12 +387,12 @@ factory-boy==3.3.0 # -c requirements/constraints.txt # -r requirements/doc.txt # -r requirements/test.txt -faker==19.6.1 +faker==19.12.1 # via # -r requirements/doc.txt # -r requirements/test.txt # factory-boy -filelock==3.12.3 +filelock==3.12.4 # via # -r requirements/doc.txt # -r requirements/test-master.txt @@ -464,8 +467,8 @@ kombu==5.3.2 # -r requirements/test-master.txt # -r requirements/test.txt # celery -lazy-object-proxy==1.9.0 - # via astroid +lxml==4.9.3 + # via edx-i18n-tools markupsafe==2.1.3 # via # -r requirements/doc.txt @@ -485,7 +488,7 @@ multidict==6.0.4 # -r requirements/test.txt # aiohttp # yarl -newrelic==9.0.0 +newrelic==9.1.0 # via # -r requirements/doc.txt # -r requirements/test-master.txt @@ -501,7 +504,7 @@ oauthlib==3.2.2 # -r requirements/test-master.txt # -r requirements/test.txt # django-oauth-toolkit -openai==0.28.0 +openai==0.28.1 # via # -r requirements/doc.txt # -r requirements/test-master.txt @@ -512,7 +515,7 @@ oscrypto==1.3.0 # -r requirements/test-master.txt # -r requirements/test.txt # snowflake-connector-python -packaging==23.1 +packaging==23.2 # via # -r requirements/doc.txt # -r requirements/test-master.txt @@ -555,7 +558,7 @@ pip-tools==7.3.0 # via -r requirements/dev.in pkginfo==1.9.6 # via twine -platformdirs==3.8.1 +platformdirs==3.11.0 # via # -r requirements/doc.txt # -r requirements/test-master.txt @@ -596,7 +599,7 @@ pyasn1==0.5.0 # -r requirements/test-master.txt # -r requirements/test.txt # pgpy -pycodestyle==2.11.0 +pycodestyle==2.11.1 # via -r requirements/dev.in pycparser==2.21 # via @@ -604,13 +607,13 @@ pycparser==2.21 # -r requirements/test-master.txt # -r requirements/test.txt # cffi -pycryptodomex==3.18.0 +pycryptodomex==3.19.0 # via # -r requirements/doc.txt # -r requirements/test-master.txt # -r requirements/test.txt # snowflake-connector-python -pydata-sphinx-theme==0.14.0 +pydata-sphinx-theme==0.14.3 # via # -r requirements/doc.txt # sphinx-book-theme @@ -634,8 +637,9 @@ pyjwt[crypto]==2.8.0 # drf-jwt # edx-drf-extensions # edx-rest-api-client + # pyjwt # snowflake-connector-python -pylint==2.17.5 +pylint==3.0.2 # via # edx-lint # pylint-celery @@ -643,7 +647,7 @@ pylint==2.17.5 # pylint-plugin-utils pylint-celery==0.3 # via edx-lint -pylint-django==2.5.3 +pylint-django==2.5.5 # via edx-lint pylint-plugin-utils==0.8.2 # via @@ -686,7 +690,6 @@ python-dateutil==2.8.2 # -r requirements/test-master.txt # -r requirements/test.txt # celery - # edx-drf-extensions # faker # freezegun python-slugify==8.0.1 @@ -756,8 +759,6 @@ six==1.16.0 # -r requirements/test-master.txt # -r requirements/test.txt # bleach - # django-oauth-toolkit - # edx-drf-extensions # edx-lint # edx-rbac # freezegun @@ -776,7 +777,7 @@ snowballstemmer==2.2.0 # -r requirements/doc.txt # pydocstyle # sphinx -snowflake-connector-python==3.2.0 +snowflake-connector-python==3.2.1 # via # -r requirements/doc.txt # -r requirements/test-master.txt @@ -837,7 +838,7 @@ stevedore==5.1.0 # doc8 # edx-django-utils # edx-opaque-keys -testfixtures==7.1.0 +testfixtures==7.2.0 # via # -r requirements/dev.in # -r requirements/doc.txt @@ -886,7 +887,7 @@ tqdm==4.66.1 # twine twine==1.11.0 # via -r requirements/dev.in -typing-extensions==4.7.1 +typing-extensions==4.8.0 # via # -r requirements/doc.txt # -r requirements/test-master.txt @@ -896,7 +897,6 @@ typing-extensions==4.7.1 # django-countries # edx-opaque-keys # faker - # filelock # kombu # pydata-sphinx-theme # pylint @@ -913,7 +913,7 @@ unicodecsv==0.14.1 # -r requirements/doc.txt # -r requirements/test-master.txt # -r requirements/test.txt -urllib3==1.26.16 +urllib3==1.26.17 # via # -r requirements/doc.txt # -r requirements/test-master.txt @@ -928,9 +928,9 @@ vine==5.0.0 # amqp # celery # kombu -virtualenv==20.24.1 +virtualenv==20.24.6 # via tox -wcwidth==0.2.6 +wcwidth==0.2.8 # via # -r requirements/doc.txt # -r requirements/test-master.txt @@ -942,7 +942,7 @@ webencodings==0.5.1 # -r requirements/test-master.txt # -r requirements/test.txt # bleach -wheel==0.41.2 +wheel==0.41.3 # via # -r requirements/dev.in # pip-tools @@ -951,7 +951,6 @@ wrapt==1.15.0 # -r requirements/doc.txt # -r requirements/test-master.txt # -r requirements/test.txt - # astroid # deprecated yarl==1.9.2 # via @@ -959,7 +958,7 @@ yarl==1.9.2 # -r requirements/test-master.txt # -r requirements/test.txt # aiohttp -zipp==3.16.2 +zipp==3.17.0 # via # -r requirements/doc.txt # importlib-metadata diff --git a/requirements/django.txt b/requirements/django.txt index 62b5cb851f..5a28da341d 100644 --- a/requirements/django.txt +++ b/requirements/django.txt @@ -1 +1 @@ -django==3.2.21 +django==3.2.22 diff --git a/requirements/doc.txt b/requirements/doc.txt index 79578958ea..92cec3dee1 100644 --- a/requirements/doc.txt +++ b/requirements/doc.txt @@ -6,7 +6,7 @@ # accessible-pygments==0.0.4 # via pydata-sphinx-theme -aiohttp==3.8.5 +aiohttp==3.8.6 # via # -r requirements/test-master.txt # openai @@ -43,13 +43,14 @@ attrs==23.1.0 # -r requirements/test-master.txt # aiohttp # pytest -babel==2.12.1 +babel==2.13.1 # via # pydata-sphinx-theme # sphinx backports-zoneinfo[tzdata]==0.2.1 # via # -r requirements/test-master.txt + # backports-zoneinfo # celery # kombu beautifulsoup4==4.12.2 @@ -58,7 +59,7 @@ billiard==4.1.0 # via # -r requirements/test-master.txt # celery -bleach==6.0.0 +bleach==6.1.0 # via -r requirements/test-master.txt celery==5.3.4 # via @@ -69,7 +70,7 @@ certifi==2023.7.22 # -r requirements/test-master.txt # requests # snowflake-connector-python -cffi==1.15.1 +cffi==1.16.0 # via # -r requirements/test-master.txt # cryptography @@ -123,7 +124,7 @@ deprecated==1.2.14 # via # -r requirements/test-master.txt # jwcrypto -django==3.2.21 +django==3.2.22 # via # -c requirements/common_constraints.txt # -r requirements/test-master.txt @@ -142,9 +143,9 @@ django==3.2.21 # edx-rbac # edx-toggles # jsonfield -django-cache-memoize==0.1.10 +django-cache-memoize==0.2.0 # via -r requirements/test-master.txt -django-config-models==2.5.0 +django-config-models==2.5.1 # via -r requirements/test-master.txt django-countries==7.5.1 # via -r requirements/test-master.txt @@ -156,9 +157,9 @@ django-crum==0.7.9 # edx-toggles django-fernet-fields-v2==0.9 # via -r requirements/test-master.txt -django-filter==23.2 +django-filter==23.3 # via -r requirements/test-master.txt -django-ipware==5.0.0 +django-ipware==5.0.1 # via -r requirements/test-master.txt django-model-utils==4.3.1 # via @@ -166,7 +167,7 @@ django-model-utils==4.3.1 # edx-rbac django-multi-email-field==0.7.0 # via -r requirements/test-master.txt -django-oauth-toolkit==1.5.0 +django-oauth-toolkit==1.7.1 # via -r requirements/test-master.txt django-object-actions==4.2.0 # via -r requirements/test-master.txt @@ -202,7 +203,7 @@ drf-jwt==1.19.2 # via # -r requirements/test-master.txt # edx-drf-extensions -edx-braze-client==0.1.7 +edx-braze-client==0.1.8 # via -r requirements/test-master.txt edx-django-utils==5.7.0 # via @@ -211,14 +212,15 @@ edx-django-utils==5.7.0 # edx-drf-extensions # edx-rest-api-client # edx-toggles -edx-drf-extensions==8.9.2 +edx-drf-extensions==8.12.0 # via # -r requirements/test-master.txt # edx-rbac -edx-opaque-keys[django]==2.5.0 +edx-opaque-keys[django]==2.5.1 # via # -r requirements/test-master.txt # edx-drf-extensions + # edx-opaque-keys edx-rbac==1.8.0 # via -r requirements/test-master.txt edx-rest-api-client==5.6.0 @@ -231,9 +233,9 @@ factory-boy==3.3.0 # via # -c requirements/constraints.txt # -r requirements/doc.in -faker==19.6.1 +faker==19.12.1 # via factory-boy -filelock==3.12.3 +filelock==3.12.4 # via # -r requirements/test-master.txt # snowflake-connector-python @@ -280,7 +282,7 @@ multidict==6.0.4 # -r requirements/test-master.txt # aiohttp # yarl -newrelic==9.0.0 +newrelic==9.1.0 # via # -r requirements/test-master.txt # edx-django-utils @@ -290,13 +292,13 @@ oauthlib==3.2.2 # via # -r requirements/test-master.txt # django-oauth-toolkit -openai==0.28.0 +openai==0.28.1 # via -r requirements/test-master.txt oscrypto==1.3.0 # via # -r requirements/test-master.txt # snowflake-connector-python -packaging==23.1 +packaging==23.2 # via # -r requirements/test-master.txt # pydata-sphinx-theme @@ -317,7 +319,7 @@ pgpy==0.6.0 # via -r requirements/test-master.txt pillow==9.5.0 # via -r requirements/test-master.txt -platformdirs==3.8.1 +platformdirs==3.11.0 # via # -r requirements/test-master.txt # snowflake-connector-python @@ -341,11 +343,11 @@ pycparser==2.21 # via # -r requirements/test-master.txt # cffi -pycryptodomex==3.18.0 +pycryptodomex==3.19.0 # via # -r requirements/test-master.txt # snowflake-connector-python -pydata-sphinx-theme==0.14.0 +pydata-sphinx-theme==0.14.3 # via sphinx-book-theme pygments==2.16.1 # via @@ -360,6 +362,7 @@ pyjwt[crypto]==2.8.0 # drf-jwt # edx-drf-extensions # edx-rest-api-client + # pyjwt # snowflake-connector-python pymongo==3.13.0 # via @@ -381,7 +384,6 @@ python-dateutil==2.8.2 # via # -r requirements/test-master.txt # celery - # edx-drf-extensions # faker python-slugify==8.0.1 # via @@ -423,8 +425,6 @@ six==1.16.0 # via # -r requirements/test-master.txt # bleach - # django-oauth-toolkit - # edx-drf-extensions # edx-rbac # python-dateutil slumber==0.7.1 @@ -433,7 +433,7 @@ slumber==0.7.1 # edx-rest-api-client snowballstemmer==2.2.0 # via sphinx -snowflake-connector-python==3.2.0 +snowflake-connector-python==3.2.1 # via -r requirements/test-master.txt sortedcontainers==2.4.0 # via @@ -471,7 +471,7 @@ stevedore==5.1.0 # doc8 # edx-django-utils # edx-opaque-keys -testfixtures==7.1.0 +testfixtures==7.2.0 # via -r requirements/test-master.txt text-unidecode==1.3 # via @@ -489,14 +489,13 @@ tqdm==4.66.1 # via # -r requirements/test-master.txt # openai -typing-extensions==4.7.1 +typing-extensions==4.8.0 # via # -r requirements/test-master.txt # asgiref # django-countries # edx-opaque-keys # faker - # filelock # kombu # pydata-sphinx-theme # snowflake-connector-python @@ -507,7 +506,7 @@ tzdata==2023.3 # celery unicodecsv==0.14.1 # via -r requirements/test-master.txt -urllib3==1.26.16 +urllib3==1.26.17 # via # -r requirements/test-master.txt # requests @@ -518,7 +517,7 @@ vine==5.0.0 # amqp # celery # kombu -wcwidth==0.2.6 +wcwidth==0.2.8 # via # -r requirements/test-master.txt # prompt-toolkit @@ -534,5 +533,5 @@ yarl==1.9.2 # via # -r requirements/test-master.txt # aiohttp -zipp==3.16.2 +zipp==3.17.0 # via importlib-metadata diff --git a/requirements/edx-platform-constraints.txt b/requirements/edx-platform-constraints.txt index 3ac9c705d4..e04e39f51f 100644 --- a/requirements/edx-platform-constraints.txt +++ b/requirements/edx-platform-constraints.txt @@ -5,9 +5,10 @@ # # make upgrade # + # via -r requirements/edx/github.in acid-xblock==0.2.1 # via -r requirements/edx/kernel.in -aiohttp==3.8.5 +aiohttp==3.8.6 # via # geoip2 # openai @@ -63,7 +64,7 @@ backports-zoneinfo[tzdata]==0.2.1 beautifulsoup4==4.12.2 # via pynliner # via celery -bleach[css]==6.0.0 +bleach[css]==6.1.0 # via # -r requirements/edx/kernel.in # edx-enterprise @@ -76,16 +77,14 @@ boto==2.39.0 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/kernel.in -boto3==1.7.0 +boto3==1.28.62 # via - # -c requirements/edx/../constraints.txt # -r requirements/edx/kernel.in # django-ses # fs-s3fs # ora2 -botocore==1.10.84 +botocore==1.31.62 # via - # -c requirements/edx/../constraints.txt # -r requirements/edx/kernel.in # boto3 # s3transfer @@ -107,7 +106,7 @@ certifi==2023.7.22 # py2neo # requests # snowflake-connector-python -cffi==1.15.1 +cffi==1.16.0 # via # cryptography # pynacl @@ -175,7 +174,7 @@ defusedxml==0.7.1 # social-auth-core deprecated==1.2.14 # via jwcrypto -django==3.2.21 +django==3.2.22 # via # -c requirements/edx/../common_constraints.txt # -r requirements/edx/kernel.in @@ -204,6 +203,7 @@ django==3.2.21 # done-xblock # drf-jwt # drf-nested-routers + # drf-spectacular # drf-yasg # edx-ace # edx-api-doc-tools @@ -247,12 +247,12 @@ django==3.2.21 # xss-utils django-appconf==1.0.5 # via django-statici18n -django-cache-memoize==0.1.10 +django-cache-memoize==0.2.0 django-celery-results==2.5.1 # via -r requirements/edx/kernel.in django-classy-tags==4.1.0 # via django-sekizai -django-config-models==2.5.0 +django-config-models==2.5.1 # via # -r requirements/edx/kernel.in # edx-enterprise @@ -276,13 +276,13 @@ django-crum==0.7.9 django-environ==0.11.2 # via openedx-blockstore django-fernet-fields-v2==0.9 -django-filter==23.2 +django-filter==23.3 # via # -r requirements/edx/kernel.in # edx-enterprise # lti-consumer-xblock # openedx-blockstore -django-ipware==5.0.0 +django-ipware==5.0.1 # via # -r requirements/edx/kernel.in # edx-enterprise @@ -316,7 +316,7 @@ django-mptt==0.14.0 django-multi-email-field==0.7.0 django-mysql==4.11.0 # via -r requirements/edx/kernel.in -django-oauth-toolkit==1.5.0 +django-oauth-toolkit==1.7.1 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/kernel.in @@ -346,7 +346,7 @@ django-statici18n==2.4.0 # -r requirements/edx/kernel.in # lti-consumer-xblock # xblock-drag-and-drop-v2 -django-storages==1.11.1 +django-storages==1.14 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/kernel.in @@ -375,6 +375,7 @@ djangorestframework==3.14.0 # django-user-tasks # drf-jwt # drf-nested-routers + # drf-spectacular # drf-yasg # edx-api-doc-tools # edx-completion @@ -389,16 +390,14 @@ djangorestframework==3.14.0 # ora2 # super-csv djangorestframework-xml==2.0.0 -docutils==0.19 - # via - # -c requirements/edx/../constraints.txt - # botocore done-xblock==2.1.0 # via -r requirements/edx/bundled.in drf-jwt==1.19.2 # via edx-drf-extensions drf-nested-routers==0.93.4 # via openedx-blockstore +drf-spectacular==0.26.5 + # via -r requirements/edx/kernel.in drf-yasg==1.21.5 # via # -c requirements/edx/../constraints.txt @@ -415,7 +414,7 @@ edx-auth-backends==4.2.0 # via # -r requirements/edx/kernel.in # openedx-blockstore -edx-braze-client==0.1.7 +edx-braze-client==0.1.8 # via # -r requirements/edx/bundled.in # edx-enterprise @@ -459,7 +458,7 @@ edx-django-utils==5.7.0 # openedx-blockstore # ora2 # super-csv -edx-drf-extensions==8.9.2 +edx-drf-extensions==8.12.0 # via # -r requirements/edx/kernel.in # edx-completion @@ -471,21 +470,21 @@ edx-drf-extensions==8.9.2 # edx-when # edxval # openedx-learning -edx-enterprise==4.1.14 +edx-enterprise==4.6.9 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/kernel.in -edx-event-bus-kafka==5.4.0 +edx-event-bus-kafka==5.5.0 # via -r requirements/edx/kernel.in -edx-event-bus-redis==0.3.1 +edx-event-bus-redis==0.3.2 # via -r requirements/edx/kernel.in -edx-i18n-tools==1.2.0 +edx-i18n-tools==1.3.0 # via ora2 edx-milestones==0.5.0 # via -r requirements/edx/kernel.in -edx-name-affirmation==2.3.6 +edx-name-affirmation==2.3.7 # via -r requirements/edx/kernel.in -edx-opaque-keys[django]==2.5.0 +edx-opaque-keys[django]==2.5.1 # via # -r requirements/edx/kernel.in # -r requirements/edx/paver.txt @@ -507,8 +506,6 @@ edx-proctoring==4.16.1 # via # -r requirements/edx/kernel.in # edx-proctoring-proctortrack -edx-proctoring-proctortrack==1.0.5 - # via -r requirements/edx/kernel.in edx-rbac==1.8.0 edx-rest-api-client==5.6.0 # via @@ -528,6 +525,7 @@ edx-toggles==5.1.0 # via # -r requirements/edx/kernel.in # edx-completion + # edx-enterprise # edx-event-bus-kafka # edx-event-bus-redis # edx-name-affirmation @@ -540,7 +538,7 @@ edx-when==2.4.0 # via # -r requirements/edx/kernel.in # edx-proctoring -edxval==2.4.2 +edxval==2.4.4 # via -r requirements/edx/kernel.in elasticsearch==7.13.4 # via @@ -555,9 +553,9 @@ event-tracking==2.2.0 # -r requirements/edx/kernel.in # edx-proctoring # edx-search -fastavro==1.8.3 +fastavro==1.8.4 # via openedx-events -filelock==3.12.3 +filelock==3.12.4 # via snowflake-connector-python frozenlist==1.4.0 # via @@ -587,7 +585,7 @@ html5lib==1.1 # via # -r requirements/edx/kernel.in # ora2 -icalendar==5.0.7 +icalendar==5.0.10 # via -r requirements/edx/kernel.in idna==3.4 # via @@ -598,12 +596,14 @@ idna==3.4 # yarl importlib-metadata==6.8.0 # via markdown -importlib-resources==6.0.1 +importlib-resources==6.1.0 # via # jsonschema # jsonschema-specifications inflection==0.5.1 - # via drf-yasg + # via + # drf-spectacular + # drf-yasg interchange==2021.0.4 # via py2neo ipaddress==1.0.23 @@ -616,7 +616,7 @@ jinja2==3.1.2 # via # code-annotations # coreschema -jmespath==0.10.0 +jmespath==1.0.1 # via # boto3 # botocore @@ -632,8 +632,10 @@ jsonfield==3.1.0 # edx-submissions # lti-consumer-xblock # ora2 -jsonschema==4.19.0 - # via optimizely-sdk +jsonschema==4.19.1 + # via + # drf-spectacular + # optimizely-sdk jsonschema-specifications==2023.7.1 # via jsonschema jwcrypto==1.5.0 @@ -643,7 +645,7 @@ jwcrypto==1.5.0 # via celery laboratory==1.0.2 # via -r requirements/edx/kernel.in -lazy==1.5 +lazy==1.6 # via # -r requirements/edx/paver.txt # acid-xblock @@ -656,11 +658,14 @@ libsass==0.10.0 # -r requirements/edx/paver.txt loremipsum==1.0.5 # via ora2 -lti-consumer-xblock==9.6.2 - # via -r requirements/edx/kernel.in +lti-consumer-xblock==9.6.1 + # via + # -c requirements/edx/../constraints.txt + # -r requirements/edx/kernel.in lxml==4.9.3 # via # -r requirements/edx/kernel.in + # edx-i18n-tools # edxval # lti-consumer-xblock # olxcleaner @@ -676,6 +681,7 @@ mako==1.2.4 # -r requirements/edx/kernel.in # acid-xblock # lti-consumer-xblock + # xblock # xblock-google-drive # xblock-utils markdown==3.3.7 @@ -715,7 +721,7 @@ mysqlclient==2.2.0 # via # -r requirements/edx/kernel.in # openedx-blockstore -newrelic==9.0.0 +newrelic==9.1.0 # via # -r requirements/edx/bundled.in # edx-django-utils @@ -738,7 +744,7 @@ oauthlib==3.2.2 # social-auth-core olxcleaner==0.2.1 # via -r requirements/edx/kernel.in -openai==0.28.0 +openai==0.28.1 openedx-atlas==0.5.0 # via -r requirements/edx/kernel.in openedx-blockstore==1.4.0 @@ -751,11 +757,10 @@ openedx-django-pyfs==3.4.0 # xblock openedx-django-require==2.1.0 # via -r requirements/edx/kernel.in -openedx-django-wiki==2.0.1 +openedx-django-wiki==2.0.3 # via -r requirements/edx/kernel.in -openedx-events==8.5.0 +openedx-events==9.0.0 # via - # -c requirements/edx/../constraints.txt # -r requirements/edx/kernel.in # edx-event-bus-kafka # edx-event-bus-redis @@ -763,17 +768,19 @@ openedx-filters==1.6.0 # via # -r requirements/edx/kernel.in # lti-consumer-xblock -openedx-learning==0.1.5 - # via -r requirements/edx/kernel.in +openedx-learning==0.3.0 + # via + # -c requirements/edx/../constraints.txt + # -r requirements/edx/kernel.in openedx-mongodbproxy==0.2.0 # via -r requirements/edx/kernel.in optimizely-sdk==4.1.1 # via -r requirements/edx/bundled.in -ora2==5.4.0 +ora2==5.5.5 # via -r requirements/edx/bundled.in oscrypto==1.3.0 # via snowflake-connector-python -packaging==23.1 +packaging==23.2 # via # drf-yasg # gunicorn @@ -810,7 +817,7 @@ pillow==9.5.0 # edxval pkgutil-resolve-name==1.3.10 # via jsonschema -platformdirs==3.8.1 +platformdirs==3.11.0 # via snowflake-connector-python polib==1.2.0 # via edx-i18n-tools @@ -819,7 +826,7 @@ psutil==5.9.5 # via # -r requirements/edx/paver.txt # edx-django-utils -py2neo==2021.2.3 +py2neo @ https://github.com/overhangio/py2neo/releases/download/2021.2.3/py2neo-2021.2.3.tar.gz # via # -c requirements/edx/../constraints.txt # -r requirements/edx/bundled.in @@ -829,7 +836,7 @@ pycountry==22.3.5 # via -r requirements/edx/kernel.in pycparser==2.21 # via cffi -pycryptodomex==3.18.0 +pycryptodomex==3.19.0 # via # -r requirements/edx/kernel.in # edx-proctoring @@ -897,7 +904,6 @@ python-dateutil==2.8.2 # botocore # celery # edx-ace - # edx-drf-extensions # edx-enterprise # edx-proctoring # icalendar @@ -914,7 +920,7 @@ python3-openid==3.2.0 ; python_version >= "3" # via # -r requirements/edx/kernel.in # social-auth-core -python3-saml==1.15.0 +python3-saml==1.16.0 # via -r requirements/edx/kernel.in pytz==2022.7.1 # via @@ -945,6 +951,7 @@ pyyaml==6.0.1 # via # -r requirements/edx/kernel.in # code-annotations + # drf-spectacular # edx-django-release-util # edx-i18n-tools # xblock @@ -952,7 +959,7 @@ random2==1.0.1 # via -r requirements/edx/kernel.in recommender-xblock==2.0.1 # via -r requirements/edx/bundled.in -redis==5.0.0 +redis==5.0.1 # via # -r requirements/edx/kernel.in # walrus @@ -960,7 +967,7 @@ referencing==0.30.2 # via # jsonschema # jsonschema-specifications -regex==2023.8.8 +regex==2023.10.3 # via nltk requests==2.31.0 # via @@ -989,13 +996,13 @@ requests-oauthlib==1.3.1 # via # -r requirements/edx/kernel.in # social-auth-core -rpds-py==0.10.2 +rpds-py==0.10.4 # via # jsonschema # referencing -ruamel-yaml==0.17.32 +ruamel-yaml==0.17.35 # via drf-yasg -ruamel-yaml-clib==0.2.7 +ruamel-yaml-clib==0.2.8 # via ruamel-yaml rules==3.3 # via @@ -1003,7 +1010,7 @@ rules==3.3 # edx-enterprise # edx-proctoring # openedx-learning -s3transfer==0.1.13 +s3transfer==0.7.0 # via boto3 sailthru-client==2.2.3 # via edx-ace @@ -1016,11 +1023,12 @@ semantic-version==2.10.0 # via edx-drf-extensions shapely==2.0.1 # via -r requirements/edx/kernel.in -simplejson==3.19.1 +simplejson==3.19.2 # via # -r requirements/edx/kernel.in # sailthru-client # super-csv + # xblock # xblock-utils six==1.16.0 # via @@ -1031,13 +1039,11 @@ six==1.16.0 # chem # codejail-includes # crowdsourcehinter-xblock - # django-oauth-toolkit # edx-ace # edx-auth-backends # edx-ccx-keys # edx-codejail # edx-django-release-util - # edx-drf-extensions # edx-milestones # edx-rbac # event-tracking @@ -1059,7 +1065,7 @@ slumber==0.7.1 # edx-bulk-grades # edx-enterprise # edx-rest-api-client -snowflake-connector-python==3.2.0 +snowflake-connector-python==3.2.1 social-auth-app-django==5.0.0 # via # -c requirements/edx/../constraints.txt @@ -1071,7 +1077,7 @@ social-auth-core==4.3.0 # -r requirements/edx/kernel.in # edx-auth-backends # social-auth-app-django -sorl-thumbnail==12.9.0 +sorl-thumbnail==12.10.0 # via # -r requirements/edx/kernel.in # openedx-django-wiki @@ -1101,10 +1107,10 @@ super-csv==3.1.0 # via edx-bulk-grades sympy==1.12 # via openedx-calc -testfixtures==7.1.0 +testfixtures==7.2.0 text-unidecode==1.3 # via python-slugify -tinycss2==1.1.1 +tinycss2==1.2.1 # via bleach tomlkit==0.12.1 # via snowflake-connector-python @@ -1112,13 +1118,12 @@ tqdm==4.66.1 # via # nltk # openai -typing-extensions==4.7.1 +typing-extensions==4.8.0 # via # -r requirements/edx/paver.txt # asgiref # django-countries # edx-opaque-keys - # filelock # kombu # pylti1p3 # snowflake-connector-python @@ -1133,11 +1138,13 @@ unicodecsv==0.14.1 uritemplate==4.1.1 # via # coreapi + # drf-spectacular # drf-yasg -urllib3==1.26.16 +urllib3==1.26.17 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/paver.txt + # botocore # elasticsearch # py2neo # requests @@ -1154,7 +1161,7 @@ walrus==0.9.3 # via edx-event-bus-redis watchdog==3.0.0 # via -r requirements/edx/paver.txt -wcwidth==0.2.6 +wcwidth==0.2.8 # via prompt-toolkit web-fragments==2.1.0 # via @@ -1177,7 +1184,7 @@ wrapt==1.15.0 # via # -r requirements/edx/paver.txt # deprecated -xblock[django]==1.7.0 +xblock[django]==1.8.1 # via # -r requirements/edx/kernel.in # acid-xblock @@ -1198,9 +1205,8 @@ xblock-google-drive==0.4.0 # via -r requirements/edx/bundled.in xblock-poll==1.13.0 # via -r requirements/edx/bundled.in -xblock-utils==3.4.1 +xblock-utils==4.0.0 # via - # -r requirements/edx/kernel.in # done-xblock # edx-sga # lti-consumer-xblock @@ -1213,7 +1219,7 @@ xss-utils==0.5.0 # via -r requirements/edx/kernel.in yarl==1.9.2 # via aiohttp -zipp==3.16.2 +zipp==3.17.0 # via # importlib-metadata # importlib-resources diff --git a/requirements/js_test.txt b/requirements/js_test.txt index ec64f8fc82..ce3f06c88c 100644 --- a/requirements/js_test.txt +++ b/requirements/js_test.txt @@ -4,7 +4,7 @@ # # make upgrade # -annotated-types==0.5.0 +annotated-types==0.6.0 # via pydantic attrs==23.1.0 # via @@ -28,7 +28,7 @@ h11==0.14.0 # via wsproto idna==3.4 # via trio -importlib-resources==6.0.1 +importlib-resources==6.1.0 # via jaraco-text inflect==7.0.0 # via jaraco-text @@ -65,13 +65,13 @@ more-itertools==10.1.0 # jaraco-text ordereddict==1.1 # via jasmine-core -outcome==1.2.0 +outcome==1.3.0.post0 # via trio portend==3.2.0 # via cherrypy -pydantic==2.3.0 +pydantic==2.4.2 # via inflect -pydantic-core==2.6.3 +pydantic-core==2.10.1 # via pydantic pysocks==1.7.1 # via urllib3 @@ -79,7 +79,7 @@ pytz==2023.3.post1 # via tempora pyyaml==6.0.1 # via jasmine -selenium==4.12.0 +selenium==4.14.0 # via jasmine sniffio==1.3.0 # via trio @@ -93,7 +93,7 @@ trio==0.22.2 # via # selenium # trio-websocket -trio-websocket==0.10.4 +trio-websocket==0.11.1 # via selenium typing-extensions==4.8.0 # via @@ -102,13 +102,15 @@ typing-extensions==4.8.0 # jaraco-functools # pydantic # pydantic-core -urllib3[socks]==2.0.4 - # via selenium +urllib3[socks]==2.0.7 + # via + # selenium + # urllib3 wsproto==1.2.0 # via trio-websocket zc-lockfile==3.0.post1 # via cherrypy -zipp==3.16.2 +zipp==3.17.0 # via importlib-resources # The following packages are considered to be unsafe in a requirements file: diff --git a/requirements/test-master.txt b/requirements/test-master.txt index 64a5d89599..eee79bb308 100644 --- a/requirements/test-master.txt +++ b/requirements/test-master.txt @@ -4,7 +4,7 @@ # # make upgrade # -aiohttp==3.8.5 +aiohttp==3.8.6 # via # -c requirements/edx-platform-constraints.txt # openai @@ -43,7 +43,7 @@ backports-zoneinfo[tzdata]==0.2.1 # kombu billiard==4.1.0 # via celery -bleach==6.0.0 +bleach==6.1.0 # via # -c requirements/edx-platform-constraints.txt # -r requirements/base.in @@ -56,7 +56,7 @@ certifi==2023.7.22 # -c requirements/edx-platform-constraints.txt # requests # snowflake-connector-python -cffi==1.15.1 +cffi==1.16.0 # via # -c requirements/edx-platform-constraints.txt # cryptography @@ -107,7 +107,7 @@ deprecated==1.2.14 # via # -c requirements/edx-platform-constraints.txt # jwcrypto -django==3.2.21 +django==3.2.22 # via # -c requirements/common_constraints.txt # -c requirements/edx-platform-constraints.txt @@ -127,11 +127,11 @@ django==3.2.21 # edx-rbac # edx-toggles # jsonfield -django-cache-memoize==0.1.10 +django-cache-memoize==0.2.0 # via # -c requirements/edx-platform-constraints.txt # -r requirements/base.in -django-config-models==2.5.0 +django-config-models==2.5.1 # via # -c requirements/edx-platform-constraints.txt # -r requirements/base.in @@ -150,11 +150,11 @@ django-fernet-fields-v2==0.9 # via # -c requirements/edx-platform-constraints.txt # -r requirements/base.in -django-filter==23.2 +django-filter==23.3 # via # -c requirements/edx-platform-constraints.txt # -r requirements/base.in -django-ipware==5.0.0 +django-ipware==5.0.1 # via # -c requirements/edx-platform-constraints.txt # -r requirements/base.in @@ -167,7 +167,7 @@ django-multi-email-field==0.7.0 # via # -c requirements/edx-platform-constraints.txt # -r requirements/base.in -django-oauth-toolkit==1.5.0 +django-oauth-toolkit==1.7.1 # via # -c requirements/edx-platform-constraints.txt # -r requirements/base.in @@ -201,7 +201,7 @@ drf-jwt==1.19.2 # via # -c requirements/edx-platform-constraints.txt # edx-drf-extensions -edx-braze-client==0.1.7 +edx-braze-client==0.1.8 # via # -c requirements/edx-platform-constraints.txt # -r requirements/base.in @@ -213,12 +213,12 @@ edx-django-utils==5.7.0 # edx-drf-extensions # edx-rest-api-client # edx-toggles -edx-drf-extensions==8.9.2 +edx-drf-extensions==8.12.0 # via # -c requirements/edx-platform-constraints.txt # -r requirements/base.in # edx-rbac -edx-opaque-keys[django]==2.5.0 +edx-opaque-keys[django]==2.5.1 # via # -c requirements/edx-platform-constraints.txt # -r requirements/base.in @@ -239,7 +239,7 @@ edx-toggles==5.1.0 # via # -c requirements/edx-platform-constraints.txt # -r requirements/base.in -filelock==3.12.3 +filelock==3.12.4 # via # -c requirements/edx-platform-constraints.txt # snowflake-connector-python @@ -281,7 +281,7 @@ multidict==6.0.4 # -c requirements/edx-platform-constraints.txt # aiohttp # yarl -newrelic==9.0.0 +newrelic==9.1.0 # via # -c requirements/edx-platform-constraints.txt # edx-django-utils @@ -289,7 +289,7 @@ oauthlib==3.2.2 # via # -c requirements/edx-platform-constraints.txt # django-oauth-toolkit -openai==0.28.0 +openai==0.28.1 # via # -c requirements/edx-platform-constraints.txt # -r requirements/base.in @@ -297,7 +297,7 @@ oscrypto==1.3.0 # via # -c requirements/edx-platform-constraints.txt # snowflake-connector-python -packaging==23.1 +packaging==23.2 # via # -c requirements/edx-platform-constraints.txt # snowflake-connector-python @@ -321,7 +321,7 @@ pillow==9.5.0 # via # -c requirements/edx-platform-constraints.txt # -r requirements/base.in -platformdirs==3.8.1 +platformdirs==3.11.0 # via # -c requirements/edx-platform-constraints.txt # snowflake-connector-python @@ -339,7 +339,7 @@ pycparser==2.21 # via # -c requirements/edx-platform-constraints.txt # cffi -pycryptodomex==3.18.0 +pycryptodomex==3.19.0 # via # -c requirements/edx-platform-constraints.txt # snowflake-connector-python @@ -349,6 +349,7 @@ pyjwt[crypto]==2.8.0 # drf-jwt # edx-drf-extensions # edx-rest-api-client + # pyjwt # snowflake-connector-python pymongo==3.13.0 # via @@ -367,7 +368,6 @@ python-dateutil==2.8.2 # -c requirements/edx-platform-constraints.txt # -r requirements/base.in # celery - # edx-drf-extensions python-slugify==8.0.1 # via # -c requirements/edx-platform-constraints.txt @@ -406,8 +406,6 @@ six==1.16.0 # via # -c requirements/edx-platform-constraints.txt # bleach - # django-oauth-toolkit - # edx-drf-extensions # edx-rbac # python-dateutil slumber==0.7.1 @@ -415,7 +413,7 @@ slumber==0.7.1 # -c requirements/edx-platform-constraints.txt # -r requirements/base.in # edx-rest-api-client -snowflake-connector-python==3.2.0 +snowflake-connector-python==3.2.1 # via # -c requirements/edx-platform-constraints.txt # -r requirements/base.in @@ -434,7 +432,7 @@ stevedore==5.1.0 # code-annotations # edx-django-utils # edx-opaque-keys -testfixtures==7.1.0 +testfixtures==7.2.0 # via # -c requirements/edx-platform-constraints.txt # -r requirements/base.in @@ -450,13 +448,12 @@ tqdm==4.66.1 # via # -c requirements/edx-platform-constraints.txt # openai -typing-extensions==4.7.1 +typing-extensions==4.8.0 # via # -c requirements/edx-platform-constraints.txt # asgiref # django-countries # edx-opaque-keys - # filelock # kombu # snowflake-connector-python tzdata==2023.3 @@ -468,7 +465,7 @@ unicodecsv==0.14.1 # via # -c requirements/edx-platform-constraints.txt # -r requirements/base.in -urllib3==1.26.16 +urllib3==1.26.17 # via # -c requirements/edx-platform-constraints.txt # requests @@ -478,7 +475,7 @@ vine==5.0.0 # amqp # celery # kombu -wcwidth==0.2.6 +wcwidth==0.2.8 # via # -c requirements/edx-platform-constraints.txt # prompt-toolkit diff --git a/requirements/test.txt b/requirements/test.txt index 1d80e77236..4cfc39a60c 100644 --- a/requirements/test.txt +++ b/requirements/test.txt @@ -4,7 +4,7 @@ # # make upgrade # -aiohttp==3.8.5 +aiohttp==3.8.6 # via # -r requirements/test-master.txt # openai @@ -42,12 +42,13 @@ backports-zoneinfo[tzdata]==0.2.1 # via # -r requirements/test-master.txt # -r requirements/test.in + # backports-zoneinfo # celery # kombu # via # -r requirements/test-master.txt # celery -bleach==6.0.0 +bleach==6.1.0 # via -r requirements/test-master.txt # via # -c requirements/constraints.txt @@ -57,7 +58,7 @@ certifi==2023.7.22 # -r requirements/test-master.txt # requests # snowflake-connector-python -cffi==1.15.1 +cffi==1.16.0 # via # -r requirements/test-master.txt # cryptography @@ -93,8 +94,10 @@ code-annotations==1.5.0 # via # -r requirements/test-master.txt # edx-toggles -coverage[toml]==7.3.1 - # via pytest-cov +coverage[toml]==7.3.2 + # via + # coverage + # pytest-cov cryptography==38.0.4 # via # -r requirements/test-master.txt @@ -114,7 +117,7 @@ deprecated==1.2.14 # via # -r requirements/test-master.txt # jwcrypto -diff-cover==7.7.0 +diff-cover==8.0.0 # via -r requirements/test.in # via # -c requirements/common_constraints.txt @@ -134,9 +137,9 @@ diff-cover==7.7.0 # edx-rbac # edx-toggles # jsonfield -django-cache-memoize==0.1.10 +django-cache-memoize==0.2.0 # via -r requirements/test-master.txt -django-config-models==2.5.0 +django-config-models==2.5.1 # via -r requirements/test-master.txt django-countries==7.5.1 # via -r requirements/test-master.txt @@ -148,9 +151,9 @@ django-crum==0.7.9 # edx-toggles django-fernet-fields-v2==0.9 # via -r requirements/test-master.txt -django-filter==23.2 +django-filter==23.3 # via -r requirements/test-master.txt -django-ipware==5.0.0 +django-ipware==5.0.1 # via -r requirements/test-master.txt django-model-utils==4.3.1 # via @@ -159,7 +162,7 @@ django-model-utils==4.3.1 # edx-rbac django-multi-email-field==0.7.0 # via -r requirements/test-master.txt -django-oauth-toolkit==1.5.0 +django-oauth-toolkit==1.7.1 # via -r requirements/test-master.txt django-object-actions==4.2.0 # via -r requirements/test-master.txt @@ -185,7 +188,7 @@ drf-jwt==1.19.2 # via # -r requirements/test-master.txt # edx-drf-extensions -edx-braze-client==0.1.7 +edx-braze-client==0.1.8 # via -r requirements/test-master.txt edx-django-utils==5.7.0 # via @@ -194,14 +197,15 @@ edx-django-utils==5.7.0 # edx-drf-extensions # edx-rest-api-client # edx-toggles -edx-drf-extensions==8.9.2 +edx-drf-extensions==8.12.0 # via # -r requirements/test-master.txt # edx-rbac -edx-opaque-keys[django]==2.5.0 +edx-opaque-keys[django]==2.5.1 # via # -r requirements/test-master.txt # edx-drf-extensions + # edx-opaque-keys edx-rbac==1.8.0 # via -r requirements/test-master.txt edx-rest-api-client==5.6.0 @@ -214,9 +218,9 @@ factory-boy==3.3.0 # via # -c requirements/constraints.txt # -r requirements/test.in -faker==19.6.1 +faker==19.12.1 # via factory-boy -filelock==3.12.3 +filelock==3.12.4 # via # -r requirements/test-master.txt # snowflake-connector-python @@ -266,7 +270,7 @@ multidict==6.0.4 # -r requirements/test-master.txt # aiohttp # yarl -newrelic==9.0.0 +newrelic==9.1.0 # via # -r requirements/test-master.txt # edx-django-utils @@ -274,13 +278,13 @@ oauthlib==3.2.2 # via # -r requirements/test-master.txt # django-oauth-toolkit -openai==0.28.0 +openai==0.28.1 # via -r requirements/test-master.txt oscrypto==1.3.0 # via # -r requirements/test-master.txt # snowflake-connector-python -packaging==23.1 +packaging==23.2 # via # -r requirements/test-master.txt # pytest @@ -299,7 +303,7 @@ pgpy==0.6.0 # via -r requirements/test-master.txt pillow==9.5.0 # via -r requirements/test-master.txt -platformdirs==3.8.1 +platformdirs==3.11.0 # via # -r requirements/test-master.txt # snowflake-connector-python @@ -324,7 +328,7 @@ pycparser==2.21 # via # -r requirements/test-master.txt # cffi -pycryptodomex==3.18.0 +pycryptodomex==3.19.0 # via # -r requirements/test-master.txt # snowflake-connector-python @@ -336,6 +340,7 @@ pyjwt[crypto]==2.8.0 # drf-jwt # edx-drf-extensions # edx-rest-api-client + # pyjwt # snowflake-connector-python pymongo==3.13.0 # via @@ -362,7 +367,6 @@ python-dateutil==2.8.2 # via # -r requirements/test-master.txt # celery - # edx-drf-extensions # faker # freezegun python-slugify==8.0.1 @@ -404,8 +408,6 @@ six==1.16.0 # via # -r requirements/test-master.txt # bleach - # django-oauth-toolkit - # edx-drf-extensions # edx-rbac # freezegun # mock @@ -415,7 +417,7 @@ slumber==0.7.1 # via # -r requirements/test-master.txt # edx-rest-api-client -snowflake-connector-python==3.2.0 +snowflake-connector-python==3.2.1 # via -r requirements/test-master.txt sortedcontainers==2.4.0 # via @@ -431,7 +433,7 @@ stevedore==5.1.0 # code-annotations # edx-django-utils # edx-opaque-keys -testfixtures==7.1.0 +testfixtures==7.2.0 # via # -r requirements/test-master.txt # -r requirements/test.in @@ -451,14 +453,13 @@ tqdm==4.66.1 # via # -r requirements/test-master.txt # openai -typing-extensions==4.7.1 +typing-extensions==4.8.0 # via # -r requirements/test-master.txt # asgiref # django-countries # edx-opaque-keys # faker - # filelock # kombu # snowflake-connector-python tzdata==2023.3 @@ -468,7 +469,7 @@ tzdata==2023.3 # celery unicodecsv==0.14.1 # via -r requirements/test-master.txt -urllib3==1.26.16 +urllib3==1.26.17 # via # -r requirements/test-master.txt # requests @@ -478,7 +479,7 @@ urllib3==1.26.16 # amqp # celery # kombu -wcwidth==0.2.6 +wcwidth==0.2.8 # via # -r requirements/test-master.txt # prompt-toolkit From 2da94026d89656dd67c1950935f5dea050163b65 Mon Sep 17 00:00:00 2001 From: Brian Beggs Date: Wed, 1 Nov 2023 09:58:45 -0400 Subject: [PATCH 028/164] chore: quality fixes --- enterprise/api/v1/serializers.py | 2 +- enterprise/api_client/client.py | 2 +- integrated_channels/canvas/client.py | 7 +- integrated_channels/moodle/client.py | 1 + pylintrc_backup | 381 +++++++++++++++++++++++++++ pylintrc_tweaks | 2 +- requirements/check_pins.py | 2 +- 7 files changed, 391 insertions(+), 6 deletions(-) create mode 100644 pylintrc_backup diff --git a/enterprise/api/v1/serializers.py b/enterprise/api/v1/serializers.py index fbacd153e8..e7aaf33c19 100644 --- a/enterprise/api/v1/serializers.py +++ b/enterprise/api/v1/serializers.py @@ -19,7 +19,7 @@ from django.core import exceptions as django_exceptions from django.utils.translation import gettext_lazy as _ -from enterprise import models, utils +from enterprise import models, utils # pylint: disable=cyclic-import from enterprise.api.v1.fields import Base64EmailCSVField from enterprise.api_client.lms import ThirdPartyAuthApiClient from enterprise.constants import ENTERPRISE_ADMIN_ROLE, ENTERPRISE_PERMISSION_GROUPS, DefaultColors diff --git a/enterprise/api_client/client.py b/enterprise/api_client/client.py index 4d395a0e0d..2e2855c7c5 100644 --- a/enterprise/api_client/client.py +++ b/enterprise/api_client/client.py @@ -13,7 +13,7 @@ from django.apps import apps from django.conf import settings -from enterprise.utils import NotConnectedToOpenEdX +from enterprise.utils import NotConnectedToOpenEdX # pylint: disable=cyclic-import try: from openedx.core.djangoapps.oauth_dispatch import jwt as JwtBuilder diff --git a/integrated_channels/canvas/client.py b/integrated_channels/canvas/client.py index 998eb01a5e..666b948450 100644 --- a/integrated_channels/canvas/client.py +++ b/integrated_channels/canvas/client.py @@ -11,10 +11,13 @@ from django.apps import apps -from integrated_channels.canvas.utils import CanvasUtil +from integrated_channels.canvas.utils import CanvasUtil # pylint: disable=cyclic-import from integrated_channels.exceptions import ClientError from integrated_channels.integrated_channel.client import IntegratedChannelApiClient, IntegratedChannelHealthStatus -from integrated_channels.utils import generate_formatted_log, refresh_session_if_expired +from integrated_channels.utils import ( # pylint: disable=cyclic-import + generate_formatted_log, + refresh_session_if_expired, +) LOGGER = logging.getLogger(__name__) diff --git a/integrated_channels/moodle/client.py b/integrated_channels/moodle/client.py index bac667291c..496800bda4 100644 --- a/integrated_channels/moodle/client.py +++ b/integrated_channels/moodle/client.py @@ -250,6 +250,7 @@ def get_course_final_grade_module(self, course_id): """ response = self._get_course_contents(course_id) course_module_id = None + module_name = None if isinstance(response.json(), list): for course in response.json(): if course.get('name') == 'General': diff --git a/pylintrc_backup b/pylintrc_backup new file mode 100644 index 0000000000..47db11f663 --- /dev/null +++ b/pylintrc_backup @@ -0,0 +1,381 @@ +# *************************** +# ** DO NOT EDIT THIS FILE ** +# *************************** +# +# This file was generated by edx-lint: https://github.com/openedx/edx-lint +# +# If you want to change this file, you have two choices, depending on whether +# you want to make a local change that applies only to this repo, or whether +# you want to make a central change that applies to all repos using edx-lint. +# +# Note: If your pylintrc file is simply out-of-date relative to the latest +# pylintrc in edx-lint, ensure you have the latest edx-lint installed +# and then follow the steps for a "LOCAL CHANGE". +# +# LOCAL CHANGE: +# +# 1. Edit the local pylintrc_tweaks file to add changes just to this +# repo's file. +# +# 2. Run: +# +# $ edx_lint write pylintrc +# +# 3. This will modify the local file. Submit a pull request to get it +# checked in so that others will benefit. +# +# +# CENTRAL CHANGE: +# +# 1. Edit the pylintrc file in the edx-lint repo at +# https://github.com/openedx/edx-lint/blob/master/edx_lint/files/pylintrc +# +# 2. install the updated version of edx-lint (in edx-lint): +# +# $ pip install . +# +# 3. Run (in edx-lint): +# +# $ edx_lint write pylintrc +# +# 4. Make a new version of edx_lint, submit and review a pull request with the +# pylintrc update, and after merging, update the edx-lint version and +# publish the new version. +# +# 5. In your local repo, install the newer version of edx-lint. +# +# 6. Run: +# +# $ edx_lint write pylintrc +# +# 7. This will modify the local file. Submit a pull request to get it +# checked in so that others will benefit. +# +# +# +# +# +# STAY AWAY FROM THIS FILE! +# +# +# +# +# +# SERIOUSLY. +# +# ------------------------------ +# Generated by edx-lint version: 5.3.4 +# ------------------------------ +[MASTER] +ignore = migrations +persistent = yes +load-plugins = edx_lint.pylint,pylint_django,pylint_celery +no-docstring-rgx = (__.*__)|Meta + +[MESSAGES CONTROL] +enable = + blacklisted-name, + line-too-long, + + abstract-class-instantiated, + abstract-method, + access-member-before-definition, + anomalous-backslash-in-string, + anomalous-unicode-escape-in-string, + arguments-differ, + assert-on-tuple, + assigning-non-slot, + assignment-from-no-return, + assignment-from-none, + attribute-defined-outside-init, + bad-except-order, + bad-format-character, + bad-format-string-key, + bad-format-string, + bad-open-mode, + bad-reversed-sequence, + bad-staticmethod-argument, + bad-str-strip-call, + bad-super-call, + binary-op-exception, + boolean-datetime, + catching-non-exception, + cell-var-from-loop, + confusing-with-statement, + continue-in-finally, + dangerous-default-value, + duplicate-argument-name, + duplicate-bases, + duplicate-except, + duplicate-key, + expression-not-assigned, + format-combined-specification, + format-needs-mapping, + function-redefined, + global-variable-undefined, + import-error, + import-self, + inconsistent-mro, + inherit-non-class, + init-is-generator, + invalid-all-object, + invalid-format-index, + invalid-length-returned, + invalid-sequence-index, + invalid-slice-index, + invalid-slots-object, + invalid-slots, + invalid-unary-operand-type, + logging-too-few-args, + logging-too-many-args, + logging-unsupported-format, + lost-exception, + method-hidden, + misplaced-bare-raise, + misplaced-future, + missing-format-argument-key, + missing-format-attribute, + missing-format-string-key, + no-member, + no-method-argument, + no-name-in-module, + no-self-argument, + no-value-for-parameter, + non-iterator-returned, + nonexistent-operator, + not-a-mapping, + not-an-iterable, + not-callable, + not-context-manager, + not-in-loop, + pointless-statement, + pointless-string-statement, + raising-bad-type, + raising-non-exception, + redefined-builtin, + redefined-outer-name, + redundant-keyword-arg, + repeated-keyword, + return-arg-in-generator, + return-in-init, + return-outside-function, + signature-differs, + super-init-not-called, + syntax-error, + too-few-format-args, + too-many-format-args, + too-many-function-args, + truncated-format-string, + undefined-all-variable, + undefined-loop-variable, + undefined-variable, + unexpected-keyword-arg, + unexpected-special-method-signature, + unpacking-non-sequence, + unreachable, + unsubscriptable-object, + unsupported-binary-operation, + unsupported-membership-test, + unused-format-string-argument, + unused-format-string-key, + used-before-assignment, + using-constant-test, + yield-outside-function, + + astroid-error, + fatal, + method-check-failed, + parse-error, + raw-checker-failed, + + empty-docstring, + invalid-characters-in-docstring, + missing-docstring, + wrong-spelling-in-comment, + wrong-spelling-in-docstring, + + unused-argument, + unused-import, + unused-variable, + + eval-used, + exec-used, + + bad-classmethod-argument, + bad-mcs-classmethod-argument, + bad-mcs-method-argument, + bare-except, + broad-except, + consider-iterating-dictionary, + consider-using-enumerate, + global-at-module-level, + global-variable-not-assigned, + logging-format-interpolation, + logging-not-lazy, + multiple-imports, + multiple-statements, + no-classmethod-decorator, + no-staticmethod-decorator, + protected-access, + redundant-unittest-assert, + reimported, + simplifiable-if-statement, + singleton-comparison, + superfluous-parens, + unidiomatic-typecheck, + unnecessary-lambda, + unnecessary-pass, + unnecessary-semicolon, + unneeded-not, + useless-else-on-loop, + + deprecated-method, + deprecated-module, + + too-many-boolean-expressions, + too-many-nested-blocks, + too-many-statements, + + wildcard-import, + wrong-import-order, + wrong-import-position, + + missing-final-newline, + mixed-line-endings, + trailing-newlines, + trailing-whitespace, + unexpected-line-ending-format, + + bad-inline-option, + bad-option-value, + deprecated-pragma, + unrecognized-inline-option, + useless-suppression, +disable = + bad-indentation, + broad-exception-raised, + consider-using-f-string, + duplicate-code, + file-ignored, + fixme, + global-statement, + invalid-name, + locally-disabled, + no-else-return, + suppressed-message, + too-few-public-methods, + too-many-ancestors, + too-many-arguments, + too-many-branches, + too-many-instance-attributes, + too-many-lines, + too-many-locals, + too-many-public-methods, + too-many-return-statements, + ungrouped-imports, + unspecified-encoding, + unused-wildcard-import, + use-maxsplit-arg, + + + logging-fstring-interpolation, + logging-format-interpolation, + no-member, + missing-timeout, + +[REPORTS] +output-format = text +reports = no +score = no + +[BASIC] +module-rgx = (([a-z_][a-z0-9_]*)|([A-Z][a-zA-Z0-9]+))$ +const-rgx = (([A-Z_][A-Z0-9_]*)|(__.*__)|log|urlpatterns)$ +class-rgx = [A-Z_][a-zA-Z0-9]+$ +function-rgx = ([a-z_][a-z0-9_]{2,40}|test_[a-z0-9_]+)$ +method-rgx = ([a-z_][a-z0-9_]{2,40}|setUp|set[Uu]pClass|tearDown|tear[Dd]ownClass|assert[A-Z]\w*|maxDiff|test_[a-z0-9_]+)$ +attr-rgx = [a-z_][a-z0-9_]{2,30}$ +argument-rgx = [a-z_][a-z0-9_]{2,30}$ +variable-rgx = [a-z_][a-z0-9_]{2,30}$ +class-attribute-rgx = ([A-Za-z_][A-Za-z0-9_]{2,30}|(__.*__))$ +inlinevar-rgx = [A-Za-z_][A-Za-z0-9_]*$ +good-names = f,i,j,k,db,ex,Run,_,__ +bad-names = foo,bar,baz,toto,tutu,tata +no-docstring-rgx = __.*__$|test_.+|setUp$|setUpClass$|tearDown$|tearDownClass$|Meta$ +docstring-min-length = 5 + +[FORMAT] +max-line-length = 120 +ignore-long-lines = ^\s*(# )?((?)|(\.\. \w+: .*))$ +single-line-if-stmt = no +max-module-lines = 1000 +indent-string = ' ' + +[MISCELLANEOUS] +notes = FIXME,XXX,TODO + +[SIMILARITIES] +min-similarity-lines = 4 +ignore-comments = yes +ignore-docstrings = yes +ignore-imports = no + +[TYPECHECK] +ignore-mixin-members = yes +ignored-classes = SQLObject +unsafe-load-any-extension = yes +generated-members = + REQUEST, + acl_users, + aq_parent, + objects, + DoesNotExist, + can_read, + can_write, + get_url, + size, + content, + status_code, + create, + build, + fields, + tag, + org, + course, + category, + name, + revision, + _meta, + +[VARIABLES] +init-import = no +dummy-variables-rgx = _|dummy|unused|.*_unused +additional-builtins = + +[CLASSES] +defining-attr-methods = __init__,__new__,setUp +valid-classmethod-first-arg = cls +valid-metaclass-classmethod-first-arg = mcs + +[DESIGN] +max-args = 5 +ignored-argument-names = _.* +max-locals = 15 +max-returns = 6 +max-branches = 12 +max-statements = 50 +max-parents = 7 +max-attributes = 7 +min-public-methods = 2 +max-public-methods = 20 + +[IMPORTS] +deprecated-modules = regsub,TERMIOS,Bastion,rexec +import-graph = +ext-import-graph = +int-import-graph = + +[EXCEPTIONS] +overgeneral-exceptions = builtins.Exception + +# 852b6fff3b5db38083c340a20de6cc6024cc0c00 diff --git a/pylintrc_tweaks b/pylintrc_tweaks index 75edf76c04..0ae79afb45 100644 --- a/pylintrc_tweaks +++ b/pylintrc_tweaks @@ -8,4 +8,4 @@ no-docstring-rgx=(__.*__)|Meta disable+ = logging-format-interpolation, no-member, - missing-timeout, + missing-timeout, \ No newline at end of file diff --git a/requirements/check_pins.py b/requirements/check_pins.py index f2fee68448..4f64232eee 100644 --- a/requirements/check_pins.py +++ b/requirements/check_pins.py @@ -53,4 +53,4 @@ def check_pins(our_file, their_file): print(their_pkg) print("") -check_pins(*sys.argv[1:]) # pylint: disable=too-many-function-args +check_pins(*sys.argv[1:]) From 2a3ea7c5791959cfcb51b3c399e480481f486998 Mon Sep 17 00:00:00 2001 From: Alexander J Sheehan Date: Fri, 3 Nov 2023 19:17:25 +0000 Subject: [PATCH 029/164] chore: Aligning SAP naming conventions --- CHANGELOG.rst | 4 ++++ enterprise/__init__.py | 2 +- enterprise/models.py | 2 +- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index c8325fb37b..c329c7625a 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -15,6 +15,10 @@ Change Log Unreleased ---------- +[4.6.11] +-------- +chore: Aligning SAP naming conventions + [4.6.10] -------- chore: Update requirements diff --git a/enterprise/__init__.py b/enterprise/__init__.py index a5cad61c34..096b01f8e2 100644 --- a/enterprise/__init__.py +++ b/enterprise/__init__.py @@ -2,4 +2,4 @@ Your project description goes here. """ -__version__ = "4.6.10" +__version__ = "4.6.11" diff --git a/enterprise/models.py b/enterprise/models.py index 9ab6485395..7eb2f2256a 100644 --- a/enterprise/models.py +++ b/enterprise/models.py @@ -3744,7 +3744,7 @@ class EnterpriseCustomerSsoConfiguration(TimeStampedModel, SoftDeletableModel): """ all_objects = models.Manager() - SAP_SUCCESS_FACTORS = 'SAP_SUCCESS_FACTORS' + SAP_SUCCESS_FACTORS = 'sap_success_factors' fields_locked_while_configuring = ( 'metadata_url', From 0a3bbbfc5a78af82edf049fdc5e2fbf7059f0e14 Mon Sep 17 00:00:00 2001 From: hamzawaleed01 Date: Mon, 6 Nov 2023 20:36:10 +0500 Subject: [PATCH 030/164] feat: unlink degreed2 inactive user --- integrated_channels/degreed2/client.py | 32 +++++++++++++++++-- .../test_degreed2/test_client.py | 17 ++++++---- 2 files changed, 40 insertions(+), 9 deletions(-) diff --git a/integrated_channels/degreed2/client.py b/integrated_channels/degreed2/client.py index 7b7448f0ee..ed135efd30 100644 --- a/integrated_channels/degreed2/client.py +++ b/integrated_channels/degreed2/client.py @@ -18,6 +18,7 @@ from integrated_channels.exceptions import ClientError from integrated_channels.integrated_channel.client import IntegratedChannelApiClient from integrated_channels.utils import generate_formatted_log, refresh_session_if_expired +from enterprise.models import EnterpriseCustomerUser LOGGER = logging.getLogger(__name__) @@ -139,9 +140,34 @@ def create_course_completion(self, user_id, payload): error_response = json.loads(body) for error in error_response['errors']: if 'detail' in error and 'Invalid user identifier' in error['detail']: - raise ClientError(f'Degreed2 create_course_completion failed due to ' - f'deleted user: {body}, code:{code}' - ) + try: + enterprise_customer = self.enterprise_configuration.enterprise_customer + LOGGER.info( + generate_formatted_log( + self.enterprise_configuration.channel_code(), + self.enterprise_configuration.enterprise_customer.uuid, + None, + None, + f'User {user_id} was deleted on degreed side,' + f"so marking it as inactive and unlinking from enterprise" + ) + ) + # Unlink user from related Enterprise Customer + EnterpriseCustomerUser.objects.unlink_user( + enterprise_customer=enterprise_customer, + user_email=json_payload.get('data').get('attributes').get('user-id'), + ) + except Exception as e: # pylint: disable=broad-except + LOGGER.error( + generate_formatted_log( + self.enterprise_configuration.channel_code(), + self.enterprise_configuration.enterprise_customer.uuid, + None, + None, + f'Error occurred while unlinking a degreed2 learner: {user_id}. ' + f'Payload: {json_payload}, Error: {e}' + ) + ) return code, body def delete_course_completion(self, user_id, payload): diff --git a/tests/test_integrated_channels/test_degreed2/test_client.py b/tests/test_integrated_channels/test_degreed2/test_client.py index 766afb1512..7fa373c52b 100644 --- a/tests/test_integrated_channels/test_degreed2/test_client.py +++ b/tests/test_integrated_channels/test_degreed2/test_client.py @@ -19,6 +19,9 @@ from integrated_channels.degreed2.client import Degreed2APIClient from integrated_channels.exceptions import ClientError from test_utils import factories +from enterprise.models import ( + EnterpriseCustomerUser, +) NOW = datetime.datetime(2017, 1, 2, 3, 4, 5, tzinfo=datetime.timezone.utc) NOW_TIMESTAMP_FORMATTED = NOW.strftime('%F') @@ -158,6 +161,7 @@ def test_create_course_completion(self): def test_create_course_completion_for_deleted_user(self): """ ``create_course_completion`` should handle exception for deleted users gracefully + by unlinking that user from enterprise """ degreed_api_client = Degreed2APIClient(self.enterprise_config) responses.add( @@ -186,12 +190,13 @@ def test_create_course_completion_for_deleted_user(self): } } } - - with pytest.raises(ClientError, match="Degreed2 create_course_completion failed due to deleted user:"): - degreed_api_client.create_course_completion('test-learner@example.com', json.dumps(payload)) - assert len(responses.calls) == 2 - assert responses.calls[0].request.url == degreed_api_client.get_oauth_url() - assert responses.calls[1].request.url == degreed_api_client.get_completions_url() + email = payload.get("data").get("attributes").get("user-id") + with mock.patch.object(EnterpriseCustomerUser.objects, 'unlink_user') as unlink_user_mock: + degreed_api_client.create_course_completion(email, json.dumps(payload)) + unlink_user_mock.assert_called_once() + assert len(responses.calls) == 2 + assert responses.calls[0].request.url == degreed_api_client.get_oauth_url() + assert responses.calls[1].request.url == degreed_api_client.get_completions_url() @responses.activate def test_delete_course_completion(self): From 3a5c67df46696b2712e783ae0466863876edffd3 Mon Sep 17 00:00:00 2001 From: hamzawaleed01 Date: Mon, 6 Nov 2023 20:58:55 +0500 Subject: [PATCH 031/164] chore: sort imports to make linter happy --- integrated_channels/degreed2/client.py | 12 ++++++------ .../test_degreed2/test_client.py | 10 +++------- 2 files changed, 9 insertions(+), 13 deletions(-) diff --git a/integrated_channels/degreed2/client.py b/integrated_channels/degreed2/client.py index ed135efd30..0effc0a6a4 100644 --- a/integrated_channels/degreed2/client.py +++ b/integrated_channels/degreed2/client.py @@ -9,16 +9,16 @@ from http import HTTPStatus import requests -from six.moves.urllib.parse import urljoin - from django.apps import apps from django.conf import settings from django.http.request import QueryDict - -from integrated_channels.exceptions import ClientError -from integrated_channels.integrated_channel.client import IntegratedChannelApiClient -from integrated_channels.utils import generate_formatted_log, refresh_session_if_expired from enterprise.models import EnterpriseCustomerUser +from integrated_channels.exceptions import ClientError +from integrated_channels.integrated_channel.client import \ + IntegratedChannelApiClient +from integrated_channels.utils import (generate_formatted_log, + refresh_session_if_expired) +from six.moves.urllib.parse import urljoin LOGGER = logging.getLogger(__name__) diff --git a/tests/test_integrated_channels/test_degreed2/test_client.py b/tests/test_integrated_channels/test_degreed2/test_client.py index 7fa373c52b..a1557c836f 100644 --- a/tests/test_integrated_channels/test_degreed2/test_client.py +++ b/tests/test_integrated_channels/test_degreed2/test_client.py @@ -11,17 +11,13 @@ import pytest import requests import responses -from freezegun import freeze_time -from six.moves.urllib.parse import urljoin - from django.apps.registry import apps - +from enterprise.models import EnterpriseCustomerUser +from freezegun import freeze_time from integrated_channels.degreed2.client import Degreed2APIClient from integrated_channels.exceptions import ClientError +from six.moves.urllib.parse import urljoin from test_utils import factories -from enterprise.models import ( - EnterpriseCustomerUser, -) NOW = datetime.datetime(2017, 1, 2, 3, 4, 5, tzinfo=datetime.timezone.utc) NOW_TIMESTAMP_FORMATTED = NOW.strftime('%F') From 8c7015743fa00c513c2a3f2c5c68075f6613e7a7 Mon Sep 17 00:00:00 2001 From: Ehmad Saeed Date: Tue, 7 Nov 2023 16:19:58 +0500 Subject: [PATCH 032/164] chore: remove degreed v1 from the set of channels --- integrated_channels/degreed/admin/__init__.py | 3 --- .../management/commands/__init__.py | 2 -- tests/test_management.py | 27 ++++--------------- 3 files changed, 5 insertions(+), 27 deletions(-) diff --git a/integrated_channels/degreed/admin/__init__.py b/integrated_channels/degreed/admin/__init__.py index 003c5f049f..78897bf087 100644 --- a/integrated_channels/degreed/admin/__init__.py +++ b/integrated_channels/degreed/admin/__init__.py @@ -17,7 +17,6 @@ from integrated_channels.integrated_channel.admin import BaseLearnerDataTransmissionAuditAdmin -@admin.register(DegreedGlobalConfiguration) class DegreedGlobalConfigurationAdmin(ConfigurationModelAdmin): """ Django admin model for DegreedGlobalConfiguration. @@ -33,7 +32,6 @@ class Meta: model = DegreedGlobalConfiguration -@admin.register(DegreedEnterpriseCustomerConfiguration) class DegreedEnterpriseCustomerConfigurationAdmin(DjangoObjectActions, admin.ModelAdmin): """ Django admin model for DegreedEnterpriseCustomerConfiguration. @@ -106,7 +104,6 @@ def force_content_metadata_transmission(self, request, obj): force_content_metadata_transmission.label = "Force content metadata transmission" -@admin.register(DegreedLearnerDataTransmissionAudit) class DegreedLearnerDataTransmissionAuditAdmin(BaseLearnerDataTransmissionAuditAdmin): """ Django admin model for DegreedLearnerDataTransmissionAudit. diff --git a/integrated_channels/integrated_channel/management/commands/__init__.py b/integrated_channels/integrated_channel/management/commands/__init__.py index e82943c4a7..b113426029 100644 --- a/integrated_channels/integrated_channel/management/commands/__init__.py +++ b/integrated_channels/integrated_channel/management/commands/__init__.py @@ -23,7 +23,6 @@ BlackboardEnterpriseCustomerConfiguration, CanvasEnterpriseCustomerConfiguration, CornerstoneEnterpriseCustomerConfiguration, - DegreedEnterpriseCustomerConfiguration, Degreed2EnterpriseCustomerConfiguration, MoodleEnterpriseCustomerConfiguration, SAPSuccessFactorsEnterpriseCustomerConfiguration, @@ -44,7 +43,6 @@ BlackboardEnterpriseCustomerConfiguration, CanvasEnterpriseCustomerConfiguration, CornerstoneEnterpriseCustomerConfiguration, - DegreedEnterpriseCustomerConfiguration, Degreed2EnterpriseCustomerConfiguration, MoodleEnterpriseCustomerConfiguration, SAPSuccessFactorsEnterpriseCustomerConfiguration, diff --git a/tests/test_management.py b/tests/test_management.py index 3e90a666bd..208d5a53ed 100644 --- a/tests/test_management.py +++ b/tests/test_management.py @@ -46,7 +46,6 @@ CornerstoneEnterpriseCustomerConfiguration, CornerstoneLearnerDataTransmissionAudit, ) -from integrated_channels.degreed.models import DegreedEnterpriseCustomerConfiguration from integrated_channels.integrated_channel.exporters.learner_data import LearnerExporter from integrated_channels.integrated_channel.management.commands import ( ASSESSMENT_LEVEL_REPORTING_INTEGRATED_CHANNEL_CHOICES, @@ -87,7 +86,7 @@ class TestIntegratedChannelCommandMixin(unittest.TestCase): Tests for the ``IntegratedChannelCommandMixin`` class. """ - @ddt.data('SAP', 'DEGREED') + @ddt.data('SAP') def test_transmit_content_metadata_specific_channel(self, channel_code): """ Only the channel we input is what we get out. @@ -193,7 +192,6 @@ def test_override_user(self): @responses.activate @freeze_time(NOW) - @mock.patch('integrated_channels.degreed.client.DegreedAPIClient.create_content_metadata') @mock.patch('integrated_channels.sap_success_factors.client.SAPSuccessFactorsAPIClient.get_oauth_access_token') @mock.patch('integrated_channels.sap_success_factors.client.SAPSuccessFactorsAPIClient.update_content_metadata') @mock.patch('integrated_channels.integrated_channel.management.commands.transmit_content_metadata.transmit_content_metadata.delay') @@ -202,7 +200,6 @@ def test_transmit_content_metadata_task_with_error( transmit_content_metadata_mock, sapsf_update_content_metadata_mock, sapsf_get_oauth_access_token_mock, - degreed_create_content_metadata_mock, ): """ Verify the data transmission task for integrated channels with error. @@ -213,7 +210,6 @@ def test_transmit_content_metadata_task_with_error( """ sapsf_get_oauth_access_token_mock.return_value = "token", datetime.utcnow() sapsf_update_content_metadata_mock.return_value = 200, '{}' - degreed_create_content_metadata_mock.return_value = 200, '{}' content_filter = { 'key': ['course-v1:edX+DemoX+Demo_Course_1'] @@ -237,14 +233,6 @@ def test_transmit_content_metadata_task_with_error( content_filter=content_filter ) self.mock_enterprise_customer_catalogs(str(enterprise_catalog.uuid)) - dummy_degreed = factories.DegreedEnterpriseCustomerConfigurationFactory( - enterprise_customer=dummy_enterprise_customer, - key='key', - secret='secret', - degreed_company_id='Degreed Company', - degreed_base_url='https://www.degreed.com/', - active=True, - ) dummy_sapsf = factories.SAPSuccessFactorsEnterpriseCustomerConfigurationFactory( enterprise_customer=dummy_enterprise_customer, sapsf_base_url='http://enterprise.successfactors.com/', @@ -255,7 +243,6 @@ def test_transmit_content_metadata_task_with_error( expected_calls = [ mock.call(username='C-3PO', channel_code='SAP', channel_pk=1), - mock.call(username='C-3PO', channel_code='DEGREED', channel_pk=1) ] call_command('transmit_content_metadata', '--catalog_user', 'C-3PO') @@ -264,7 +251,6 @@ def test_transmit_content_metadata_task_with_error( @responses.activate @freeze_time(NOW) - @mock.patch('integrated_channels.degreed.client.DegreedAPIClient.create_content_metadata') @mock.patch('integrated_channels.sap_success_factors.client.SAPSuccessFactorsAPIClient.get_oauth_access_token') @mock.patch('integrated_channels.sap_success_factors.client.SAPSuccessFactorsAPIClient.update_content_metadata') @mock.patch('integrated_channels.integrated_channel.management.commands.transmit_content_metadata.transmit_content_metadata.delay') @@ -273,14 +259,12 @@ def test_transmit_content_metadata_task_success( transmit_content_metadata_mock, sapsf_update_content_metadata_mock, sapsf_get_oauth_access_token_mock, - degreed_create_content_metadata_mock, ): """ Test the data transmission task. """ sapsf_get_oauth_access_token_mock.return_value = "token", datetime.utcnow() sapsf_update_content_metadata_mock.return_value = 200, '{}' - degreed_create_content_metadata_mock.return_value = 200, '{}' factories.EnterpriseCustomerCatalogFactory(enterprise_customer=self.enterprise_customer) enterprise_catalog_uuid = str(self.enterprise_customer.enterprise_customer_catalogs.first().uuid) @@ -288,7 +272,6 @@ def test_transmit_content_metadata_task_success( expected_calls = [ mock.call(username='C-3PO', channel_code='SAP', channel_pk=1), - mock.call(username='C-3PO', channel_code='DEGREED', channel_pk=1), ] call_command('transmit_content_metadata', '--catalog_user', 'C-3PO') @@ -307,7 +290,6 @@ def test_transmit_content_metadata_task_no_channel(self): # Remove all integrated channels SAPSuccessFactorsEnterpriseCustomerConfiguration.objects.all().delete() - DegreedEnterpriseCustomerConfiguration.objects.all().delete() with LogCapture(level=logging.INFO) as log_capture: call_command('transmit_content_metadata', '--catalog_user', user.username) @@ -1907,12 +1889,12 @@ def setUp(self): ) self.enterprise_customer.enterprise_customer_catalogs.set([enterprise_catalog]) self.enterprise_customer.save() - self.customer_config = factories.DegreedEnterpriseCustomerConfigurationFactory( + self.customer_config = factories.SAPSuccessFactorsEnterpriseCustomerConfigurationFactory( enterprise_customer=self.enterprise_customer, + sapsf_base_url='http://enterprise.successfactors.com/', key='key', secret='secret', - degreed_company_id='Degreed Company', - degreed_base_url='https://www.degreed.com/', + active=True, ) self.orphaned_content = factories.ContentMetadataItemTransmissionFactory( content_id='DemoX', @@ -2020,6 +2002,7 @@ class TestRemoveNullCatalogTransmissionAuditsManagementCommand(unittest.TestCase """ Test the ``remove_null_catalog_transmission_audits`` management command. """ + def setUp(self): self.enterprise_customer_1 = factories.EnterpriseCustomerFactory( name='Wonka Factory', From e7736fc409a37320d8cc6fe68acf0e923b5560cc Mon Sep 17 00:00:00 2001 From: Ehmad Saeed Date: Wed, 8 Nov 2023 18:22:55 +0500 Subject: [PATCH 033/164] chore: replace failing Degreed1 unit tests with Degreed2 --- tests/test_management.py | 24 ++++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/tests/test_management.py b/tests/test_management.py index 208d5a53ed..62eaf07c16 100644 --- a/tests/test_management.py +++ b/tests/test_management.py @@ -46,6 +46,7 @@ CornerstoneEnterpriseCustomerConfiguration, CornerstoneLearnerDataTransmissionAudit, ) +from integrated_channels.degreed2.models import Degreed2EnterpriseCustomerConfiguration from integrated_channels.integrated_channel.exporters.learner_data import LearnerExporter from integrated_channels.integrated_channel.management.commands import ( ASSESSMENT_LEVEL_REPORTING_INTEGRATED_CHANNEL_CHOICES, @@ -86,7 +87,7 @@ class TestIntegratedChannelCommandMixin(unittest.TestCase): Tests for the ``IntegratedChannelCommandMixin`` class. """ - @ddt.data('SAP') + @ddt.data('SAP', 'DEGREED2') def test_transmit_content_metadata_specific_channel(self, channel_code): """ Only the channel we input is what we get out. @@ -142,12 +143,9 @@ def setUp(self): self.enterprise_customer = factories.EnterpriseCustomerFactory( name='Veridian Dynamics', ) - self.degreed = factories.DegreedEnterpriseCustomerConfigurationFactory( + self.degreed2 = factories.Degreed2EnterpriseCustomerConfigurationFactory( enterprise_customer=self.enterprise_customer, - key='key', - secret='secret', - degreed_company_id='Degreed Company', - degreed_base_url='https://www.degreed.com/', + degreed_base_url='http://betatest.degreed.com/', ) self.sapsf = factories.SAPSuccessFactorsEnterpriseCustomerConfigurationFactory( enterprise_customer=self.enterprise_customer, @@ -192,6 +190,7 @@ def test_override_user(self): @responses.activate @freeze_time(NOW) + @mock.patch('integrated_channels.degreed2.client.Degreed2APIClient.create_content_metadata') @mock.patch('integrated_channels.sap_success_factors.client.SAPSuccessFactorsAPIClient.get_oauth_access_token') @mock.patch('integrated_channels.sap_success_factors.client.SAPSuccessFactorsAPIClient.update_content_metadata') @mock.patch('integrated_channels.integrated_channel.management.commands.transmit_content_metadata.transmit_content_metadata.delay') @@ -200,6 +199,7 @@ def test_transmit_content_metadata_task_with_error( transmit_content_metadata_mock, sapsf_update_content_metadata_mock, sapsf_get_oauth_access_token_mock, + degreed2_create_content_metadata_mock, ): """ Verify the data transmission task for integrated channels with error. @@ -210,6 +210,7 @@ def test_transmit_content_metadata_task_with_error( """ sapsf_get_oauth_access_token_mock.return_value = "token", datetime.utcnow() sapsf_update_content_metadata_mock.return_value = 200, '{}' + degreed2_create_content_metadata_mock.return_value = 200, '{}' content_filter = { 'key': ['course-v1:edX+DemoX+Demo_Course_1'] @@ -233,6 +234,11 @@ def test_transmit_content_metadata_task_with_error( content_filter=content_filter ) self.mock_enterprise_customer_catalogs(str(enterprise_catalog.uuid)) + dummy_degreed2 = factories.Degreed2EnterpriseCustomerConfigurationFactory( + enterprise_customer=dummy_enterprise_customer, + degreed_base_url='http://betatest.degreed.com/', + active=True, + ) dummy_sapsf = factories.SAPSuccessFactorsEnterpriseCustomerConfigurationFactory( enterprise_customer=dummy_enterprise_customer, sapsf_base_url='http://enterprise.successfactors.com/', @@ -243,6 +249,7 @@ def test_transmit_content_metadata_task_with_error( expected_calls = [ mock.call(username='C-3PO', channel_code='SAP', channel_pk=1), + mock.call(username='C-3PO', channel_code='DEGREED2', channel_pk=1), ] call_command('transmit_content_metadata', '--catalog_user', 'C-3PO') @@ -251,6 +258,7 @@ def test_transmit_content_metadata_task_with_error( @responses.activate @freeze_time(NOW) + @mock.patch('integrated_channels.degreed2.client.Degreed2APIClient.create_content_metadata') @mock.patch('integrated_channels.sap_success_factors.client.SAPSuccessFactorsAPIClient.get_oauth_access_token') @mock.patch('integrated_channels.sap_success_factors.client.SAPSuccessFactorsAPIClient.update_content_metadata') @mock.patch('integrated_channels.integrated_channel.management.commands.transmit_content_metadata.transmit_content_metadata.delay') @@ -259,12 +267,14 @@ def test_transmit_content_metadata_task_success( transmit_content_metadata_mock, sapsf_update_content_metadata_mock, sapsf_get_oauth_access_token_mock, + degreed2_create_content_metadata_mock, ): """ Test the data transmission task. """ sapsf_get_oauth_access_token_mock.return_value = "token", datetime.utcnow() sapsf_update_content_metadata_mock.return_value = 200, '{}' + degreed2_create_content_metadata_mock.return_value = 200, '{}' factories.EnterpriseCustomerCatalogFactory(enterprise_customer=self.enterprise_customer) enterprise_catalog_uuid = str(self.enterprise_customer.enterprise_customer_catalogs.first().uuid) @@ -272,6 +282,7 @@ def test_transmit_content_metadata_task_success( expected_calls = [ mock.call(username='C-3PO', channel_code='SAP', channel_pk=1), + mock.call(username='C-3PO', channel_code='DEGREED2', channel_pk=1), ] call_command('transmit_content_metadata', '--catalog_user', 'C-3PO') @@ -290,6 +301,7 @@ def test_transmit_content_metadata_task_no_channel(self): # Remove all integrated channels SAPSuccessFactorsEnterpriseCustomerConfiguration.objects.all().delete() + Degreed2EnterpriseCustomerConfiguration.objects.all().delete() with LogCapture(level=logging.INFO) as log_capture: call_command('transmit_content_metadata', '--catalog_user', user.username) From 40e01dc30403f0e87faa089faa70c2c134485ed7 Mon Sep 17 00:00:00 2001 From: Alex Dusenbery Date: Thu, 9 Nov 2023 10:30:38 -0500 Subject: [PATCH 034/164] feat: Add an api-docs page that lives at [LMS_ROOT_URL]/enterprise/api-docs/ Also installs edx-api-doc-tools and its dependencies into our test requirements files. --- CHANGELOG.rst | 6 ++ enterprise/__init__.py | 2 +- enterprise/urls.py | 29 +++++++-- requirements/celery53.txt | 8 +-- requirements/dev.txt | 77 ++++++++++++++++++++--- requirements/doc.txt | 57 ++++++++++++++--- requirements/edx-platform-constraints.txt | 8 +-- requirements/js_test.txt | 13 ++-- requirements/test-master.in | 2 + requirements/test-master.txt | 55 ++++++++++++++-- requirements/test.txt | 53 +++++++++++++--- 11 files changed, 258 insertions(+), 52 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 346dc650b5..0eb77332f1 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -15,6 +15,12 @@ Change Log Unreleased ---------- +Nothing unreleased. + +[4.7.0] +-------- +feat: Add an ``api-docs`` page that lives at ``[LMS_ROOT_URL]/enterprise/api-docs/`` + [4.6.12] -------- feat: unlink degreed2 inactive user diff --git a/enterprise/__init__.py b/enterprise/__init__.py index 1395d932e9..9205bf506e 100644 --- a/enterprise/__init__.py +++ b/enterprise/__init__.py @@ -2,4 +2,4 @@ Your project description goes here. """ -__version__ = "4.6.12" +__version__ = "4.7.0" diff --git a/enterprise/urls.py b/enterprise/urls.py index ff8035537e..fdb751a56b 100644 --- a/enterprise/urls.py +++ b/enterprise/urls.py @@ -2,8 +2,10 @@ URLs for enterprise. """ +from edx_api_doc_tools import make_api_info, make_docs_urls + from django.conf import settings -from django.urls import include, re_path +from django.urls import include, path, re_path from enterprise.constants import COURSE_KEY_URL_PATTERN from enterprise.heartbeat.views import heartbeat @@ -17,6 +19,12 @@ ENTERPRISE_ROUTER = RouterView.as_view() +enterprise_rest_api_urls = re_path( + r'^enterprise/api/', + include('enterprise.api.urls'), + name='enterprise_api' +) + urlpatterns = [ re_path(r'^enterprise/grant_data_sharing_permissions', GrantDataSharingPermissions.as_view(), name='grant_data_sharing_permissions' @@ -52,15 +60,11 @@ ENTERPRISE_ROUTER, name='enterprise_program_enrollment_page' ), - re_path( - r'^enterprise/api/', - include('enterprise.api.urls'), - name='enterprise_api' - ), re_path( r'^enterprise/heartbeat/', heartbeat, name='enterprise_heartbeat', ), + enterprise_rest_api_urls, ] # Because ROOT_URLCONF points here, we are including the urls from the other apps here for now. @@ -95,3 +99,16 @@ include('integrated_channels.api.urls') ), ] + +api_docs_urlpatterns = make_docs_urls( + make_api_info( + title='Enterprise API', + version='v1', + description='Docs for the edx-enterprise `/enterprise/api/v1` REST API.', + ), + api_url_patterns=[enterprise_rest_api_urls], +) + +urlpatterns.append( + path('enterprise/', include(api_docs_urlpatterns)), +) diff --git a/requirements/celery53.txt b/requirements/celery53.txt index e853ef4665..f5e6e328aa 100644 --- a/requirements/celery53.txt +++ b/requirements/celery53.txt @@ -1,9 +1,9 @@ -amqp==5.1.1 -billiard==4.1.0 +amqp==5.2.0 +billiard==4.2.0 celery==5.3.4 click==8.1.7 click-didyoumean==0.3.0 click-repl==0.3.0 -kombu==5.3.2 +kombu==5.3.3 prompt-toolkit==3.0.39 -vine==5.0.0 +vine==5.1.0 diff --git a/requirements/dev.txt b/requirements/dev.txt index e3ac980034..fbe87b0ec8 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -24,7 +24,7 @@ alabaster==0.7.13 # via # -r requirements/doc.txt # sphinx -amqp==5.1.1 +amqp==5.2.0 # via # -r requirements/doc.txt # -r requirements/test-master.txt @@ -77,14 +77,13 @@ backports-zoneinfo[tzdata]==0.2.1 # -r requirements/doc.txt # -r requirements/test-master.txt # -r requirements/test.txt - # backports-zoneinfo # celery # kombu beautifulsoup4==4.12.2 # via # -r requirements/doc.txt # pydata-sphinx-theme -billiard==4.1.0 +billiard==4.2.0 # via # -r requirements/doc.txt # -r requirements/test-master.txt @@ -171,10 +170,22 @@ code-annotations==1.5.0 # -r requirements/test.txt # edx-lint # edx-toggles +coreapi==2.3.3 + # via + # -r requirements/doc.txt + # -r requirements/test-master.txt + # -r requirements/test.txt + # drf-yasg +coreschema==0.0.4 + # via + # -r requirements/doc.txt + # -r requirements/test-master.txt + # -r requirements/test.txt + # coreapi + # drf-yasg coverage[toml]==7.3.2 # via # -r requirements/test.txt - # coverage # pytest-cov cryptography==38.0.4 # via @@ -223,6 +234,8 @@ django==3.2.22 # django-waffle # djangorestframework # drf-jwt + # drf-yasg + # edx-api-doc-tools # edx-django-utils # edx-drf-extensions # edx-i18n-tools @@ -309,6 +322,8 @@ djangorestframework==3.14.0 # -r requirements/test.txt # django-config-models # drf-jwt + # drf-yasg + # edx-api-doc-tools # edx-drf-extensions djangorestframework-xml==2.0.0 # via @@ -331,6 +346,17 @@ drf-jwt==1.19.2 # -r requirements/test-master.txt # -r requirements/test.txt # edx-drf-extensions +drf-yasg==1.21.5 + # via + # -r requirements/doc.txt + # -r requirements/test-master.txt + # -r requirements/test.txt + # edx-api-doc-tools +edx-api-doc-tools==1.7.0 + # via + # -r requirements/doc.txt + # -r requirements/test-master.txt + # -r requirements/test.txt edx-braze-client==0.1.8 # via # -r requirements/doc.txt @@ -361,7 +387,6 @@ edx-opaque-keys[django]==2.5.1 # -r requirements/test-master.txt # -r requirements/test.txt # edx-drf-extensions - # edx-opaque-keys edx-rbac==1.8.0 # via # -r requirements/doc.txt @@ -387,7 +412,7 @@ factory-boy==3.3.0 # -c requirements/constraints.txt # -r requirements/doc.txt # -r requirements/test.txt -faker==19.12.1 +faker==19.13.0 # via # -r requirements/doc.txt # -r requirements/test.txt @@ -428,6 +453,12 @@ importlib-metadata==6.8.0 # -r requirements/doc.txt # build # sphinx +inflection==0.5.1 + # via + # -r requirements/doc.txt + # -r requirements/test-master.txt + # -r requirements/test.txt + # drf-yasg iniconfig==2.0.0 # via # -r requirements/doc.txt @@ -437,12 +468,19 @@ isort==5.12.0 # via # -r requirements/dev.in # pylint +itypes==1.2.0 + # via + # -r requirements/doc.txt + # -r requirements/test-master.txt + # -r requirements/test.txt + # coreapi jinja2==3.1.2 # via # -r requirements/doc.txt # -r requirements/test-master.txt # -r requirements/test.txt # code-annotations + # coreschema # diff-cover # sphinx jsondiff==2.0.0 @@ -461,7 +499,7 @@ jwcrypto==1.5.0 # -r requirements/test-master.txt # -r requirements/test.txt # django-oauth-toolkit -kombu==5.3.2 +kombu==5.3.3 # via # -r requirements/doc.txt # -r requirements/test-master.txt @@ -521,6 +559,7 @@ packaging==23.2 # -r requirements/test-master.txt # -r requirements/test.txt # build + # drf-yasg # pydata-sphinx-theme # pytest # snowflake-connector-python @@ -637,7 +676,6 @@ pyjwt[crypto]==2.8.0 # drf-jwt # edx-drf-extensions # edx-rest-api-client - # pyjwt # snowflake-connector-python pylint==3.0.2 # via @@ -706,6 +744,7 @@ pytz==2022.7.1 # babel # django # djangorestframework + # drf-yasg # edx-tincan-py35 # snowflake-connector-python pyyaml==6.0.1 @@ -722,6 +761,7 @@ requests==2.31.0 # -r requirements/doc.txt # -r requirements/test-master.txt # -r requirements/test.txt + # coreapi # django-oauth-toolkit # edx-drf-extensions # edx-rest-api-client @@ -742,6 +782,18 @@ restructuredtext-lint==1.4.0 # via # -r requirements/doc.txt # doc8 +ruamel-yaml==0.17.35 + # via + # -r requirements/doc.txt + # -r requirements/test-master.txt + # -r requirements/test.txt + # drf-yasg +ruamel-yaml-clib==0.2.8 + # via + # -r requirements/doc.txt + # -r requirements/test-master.txt + # -r requirements/test.txt + # ruamel-yaml rules==3.3 # via # -r requirements/doc.txt @@ -913,6 +965,13 @@ unicodecsv==0.14.1 # -r requirements/doc.txt # -r requirements/test-master.txt # -r requirements/test.txt +uritemplate==4.1.1 + # via + # -r requirements/doc.txt + # -r requirements/test-master.txt + # -r requirements/test.txt + # coreapi + # drf-yasg urllib3==1.26.17 # via # -r requirements/doc.txt @@ -920,7 +979,7 @@ urllib3==1.26.17 # -r requirements/test.txt # requests # snowflake-connector-python -vine==5.0.0 +vine==5.1.0 # via # -r requirements/doc.txt # -r requirements/test-master.txt diff --git a/requirements/doc.txt b/requirements/doc.txt index 92cec3dee1..62eae90148 100644 --- a/requirements/doc.txt +++ b/requirements/doc.txt @@ -16,7 +16,7 @@ aiosignal==1.3.1 # aiohttp alabaster==0.7.13 # via sphinx -amqp==5.1.1 +amqp==5.2.0 # via # -r requirements/test-master.txt # kombu @@ -50,12 +50,11 @@ babel==2.13.1 backports-zoneinfo[tzdata]==0.2.1 # via # -r requirements/test-master.txt - # backports-zoneinfo # celery # kombu beautifulsoup4==4.12.2 # via pydata-sphinx-theme -billiard==4.1.0 +billiard==4.2.0 # via # -r requirements/test-master.txt # celery @@ -107,6 +106,15 @@ code-annotations==1.5.0 # via # -r requirements/test-master.txt # edx-toggles +coreapi==2.3.3 + # via + # -r requirements/test-master.txt + # drf-yasg +coreschema==0.0.4 + # via + # -r requirements/test-master.txt + # coreapi + # drf-yasg cryptography==38.0.4 # via # -r requirements/test-master.txt @@ -138,6 +146,8 @@ django==3.2.22 # django-waffle # djangorestframework # drf-jwt + # drf-yasg + # edx-api-doc-tools # edx-django-utils # edx-drf-extensions # edx-rbac @@ -186,6 +196,8 @@ djangorestframework==3.14.0 # -r requirements/test-master.txt # django-config-models # drf-jwt + # drf-yasg + # edx-api-doc-tools # edx-drf-extensions djangorestframework-xml==2.0.0 # via -r requirements/test-master.txt @@ -203,6 +215,12 @@ drf-jwt==1.19.2 # via # -r requirements/test-master.txt # edx-drf-extensions +drf-yasg==1.21.5 + # via + # -r requirements/test-master.txt + # edx-api-doc-tools +edx-api-doc-tools==1.7.0 + # via -r requirements/test-master.txt edx-braze-client==0.1.8 # via -r requirements/test-master.txt edx-django-utils==5.7.0 @@ -220,7 +238,6 @@ edx-opaque-keys[django]==2.5.1 # via # -r requirements/test-master.txt # edx-drf-extensions - # edx-opaque-keys edx-rbac==1.8.0 # via -r requirements/test-master.txt edx-rest-api-client==5.6.0 @@ -233,7 +250,7 @@ factory-boy==3.3.0 # via # -c requirements/constraints.txt # -r requirements/doc.in -faker==19.12.1 +faker==19.13.0 # via factory-boy filelock==3.12.4 # via @@ -254,12 +271,21 @@ imagesize==1.4.1 # via sphinx importlib-metadata==6.8.0 # via sphinx +inflection==0.5.1 + # via + # -r requirements/test-master.txt + # drf-yasg iniconfig==2.0.0 # via pytest +itypes==1.2.0 + # via + # -r requirements/test-master.txt + # coreapi jinja2==3.1.2 # via # -r requirements/test-master.txt # code-annotations + # coreschema # sphinx jsondiff==2.0.0 # via -r requirements/test-master.txt @@ -269,7 +295,7 @@ jwcrypto==1.5.0 # via # -r requirements/test-master.txt # django-oauth-toolkit -kombu==5.3.2 +kombu==5.3.3 # via # -r requirements/test-master.txt # celery @@ -301,6 +327,7 @@ oscrypto==1.3.0 packaging==23.2 # via # -r requirements/test-master.txt + # drf-yasg # pydata-sphinx-theme # pytest # snowflake-connector-python @@ -362,7 +389,6 @@ pyjwt[crypto]==2.8.0 # drf-jwt # edx-drf-extensions # edx-rest-api-client - # pyjwt # snowflake-connector-python pymongo==3.13.0 # via @@ -395,6 +421,7 @@ pytz==2022.7.1 # babel # django # djangorestframework + # drf-yasg # edx-tincan-py35 # snowflake-connector-python pyyaml==6.0.1 @@ -406,6 +433,7 @@ readme-renderer==42.0 requests==2.31.0 # via # -r requirements/test-master.txt + # coreapi # django-oauth-toolkit # edx-drf-extensions # edx-rest-api-client @@ -415,6 +443,14 @@ requests==2.31.0 # sphinx restructuredtext-lint==1.4.0 # via doc8 +ruamel-yaml==0.17.35 + # via + # -r requirements/test-master.txt + # drf-yasg +ruamel-yaml-clib==0.2.8 + # via + # -r requirements/test-master.txt + # ruamel-yaml rules==3.3 # via -r requirements/test-master.txt semantic-version==2.10.0 @@ -506,12 +542,17 @@ tzdata==2023.3 # celery unicodecsv==0.14.1 # via -r requirements/test-master.txt +uritemplate==4.1.1 + # via + # -r requirements/test-master.txt + # coreapi + # drf-yasg urllib3==1.26.17 # via # -r requirements/test-master.txt # requests # snowflake-connector-python -vine==5.0.0 +vine==5.1.0 # via # -r requirements/test-master.txt # amqp diff --git a/requirements/edx-platform-constraints.txt b/requirements/edx-platform-constraints.txt index e04e39f51f..4fe802bf37 100644 --- a/requirements/edx-platform-constraints.txt +++ b/requirements/edx-platform-constraints.txt @@ -470,7 +470,7 @@ edx-drf-extensions==8.12.0 # edx-when # edxval # openedx-learning -edx-enterprise==4.6.9 +edx-enterprise==4.6.12 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/kernel.in @@ -759,7 +759,7 @@ openedx-django-require==2.1.0 # via -r requirements/edx/kernel.in openedx-django-wiki==2.0.3 # via -r requirements/edx/kernel.in -openedx-events==9.0.0 +openedx-events==9.0.1 # via # -r requirements/edx/kernel.in # edx-event-bus-kafka @@ -768,7 +768,7 @@ openedx-filters==1.6.0 # via # -r requirements/edx/kernel.in # lti-consumer-xblock -openedx-learning==0.3.0 +openedx-learning==0.3.2 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/kernel.in @@ -776,7 +776,7 @@ openedx-mongodbproxy==0.2.0 # via -r requirements/edx/kernel.in optimizely-sdk==4.1.1 # via -r requirements/edx/bundled.in -ora2==5.5.5 +ora2==6.0.0 # via -r requirements/edx/bundled.in oscrypto==1.3.0 # via snowflake-connector-python diff --git a/requirements/js_test.txt b/requirements/js_test.txt index ce3f06c88c..49ff64e80f 100644 --- a/requirements/js_test.txt +++ b/requirements/js_test.txt @@ -28,7 +28,7 @@ h11==0.14.0 # via wsproto idna==3.4 # via trio -importlib-resources==6.1.0 +importlib-resources==6.1.1 # via jaraco-text inflect==7.0.0 # via jaraco-text @@ -40,7 +40,7 @@ jaraco-collections==4.3.0 # cherrypy jaraco-context==4.3.0 # via jaraco-text -jaraco-functools==3.9.0 +jaraco-functools==4.0.0 # via # -r requirements/js_test.in # cheroot @@ -79,7 +79,7 @@ pytz==2023.3.post1 # via tempora pyyaml==6.0.1 # via jasmine -selenium==4.14.0 +selenium==4.15.2 # via jasmine sniffio==1.3.0 # via trio @@ -89,7 +89,7 @@ tempora==5.5.0 # via # -r requirements/js_test.in # portend -trio==0.22.2 +trio==0.23.1 # via # selenium # trio-websocket @@ -99,13 +99,10 @@ typing-extensions==4.8.0 # via # annotated-types # inflect - # jaraco-functools # pydantic # pydantic-core urllib3[socks]==2.0.7 - # via - # selenium - # urllib3 + # via selenium wsproto==1.2.0 # via trio-websocket zc-lockfile==3.0.post1 diff --git a/requirements/test-master.in b/requirements/test-master.in index 319dc3b60d..ba60c38154 100644 --- a/requirements/test-master.in +++ b/requirements/test-master.in @@ -5,3 +5,5 @@ -c edx-platform-constraints.txt -c constraints.txt -r base.in + +edx-api-doc-tools # for installing an api-docs page for enterprise diff --git a/requirements/test-master.txt b/requirements/test-master.txt index eee79bb308..6f7c3fcff4 100644 --- a/requirements/test-master.txt +++ b/requirements/test-master.txt @@ -12,7 +12,7 @@ aiosignal==1.3.1 # via # -c requirements/edx-platform-constraints.txt # aiohttp -amqp==5.1.1 +amqp==5.2.0 # via kombu aniso8601==9.0.1 # via @@ -41,7 +41,7 @@ backports-zoneinfo[tzdata]==0.2.1 # -c requirements/edx-platform-constraints.txt # celery # kombu -billiard==4.1.0 +billiard==4.2.0 # via celery bleach==6.1.0 # via @@ -89,6 +89,15 @@ code-annotations==1.5.0 # -c requirements/edx-platform-constraints.txt # -r requirements/base.in # edx-toggles +coreapi==2.3.3 + # via + # -c requirements/edx-platform-constraints.txt + # drf-yasg +coreschema==0.0.4 + # via + # -c requirements/edx-platform-constraints.txt + # coreapi + # drf-yasg cryptography==38.0.4 # via # -c requirements/edx-platform-constraints.txt @@ -122,6 +131,8 @@ django==3.2.22 # django-waffle # djangorestframework # drf-jwt + # drf-yasg + # edx-api-doc-tools # edx-django-utils # edx-drf-extensions # edx-rbac @@ -192,6 +203,8 @@ djangorestframework==3.14.0 # -r requirements/base.in # django-config-models # drf-jwt + # drf-yasg + # edx-api-doc-tools # edx-drf-extensions djangorestframework-xml==2.0.0 # via @@ -201,6 +214,14 @@ drf-jwt==1.19.2 # via # -c requirements/edx-platform-constraints.txt # edx-drf-extensions +drf-yasg==1.21.5 + # via + # -c requirements/edx-platform-constraints.txt + # edx-api-doc-tools +edx-api-doc-tools==1.7.0 + # via + # -c requirements/edx-platform-constraints.txt + # -r requirements/test-master.in edx-braze-client==0.1.8 # via # -c requirements/edx-platform-constraints.txt @@ -254,10 +275,19 @@ idna==3.4 # requests # snowflake-connector-python # yarl +inflection==0.5.1 + # via + # -c requirements/edx-platform-constraints.txt + # drf-yasg +itypes==1.2.0 + # via + # -c requirements/edx-platform-constraints.txt + # coreapi jinja2==3.1.2 # via # -c requirements/edx-platform-constraints.txt # code-annotations + # coreschema jsondiff==2.0.0 # via # -c requirements/edx-platform-constraints.txt @@ -270,7 +300,7 @@ jwcrypto==1.5.0 # via # -c requirements/edx-platform-constraints.txt # django-oauth-toolkit -kombu==5.3.2 +kombu==5.3.3 # via celery markupsafe==2.1.3 # via @@ -300,6 +330,7 @@ oscrypto==1.3.0 packaging==23.2 # via # -c requirements/edx-platform-constraints.txt + # drf-yasg # snowflake-connector-python path==16.7.1 # via @@ -349,7 +380,6 @@ pyjwt[crypto]==2.8.0 # drf-jwt # edx-drf-extensions # edx-rest-api-client - # pyjwt # snowflake-connector-python pymongo==3.13.0 # via @@ -378,6 +408,7 @@ pytz==2022.7.1 # -r requirements/base.in # django # djangorestframework + # drf-yasg # edx-tincan-py35 # snowflake-connector-python pyyaml==6.0.1 @@ -388,12 +419,21 @@ requests==2.31.0 # via # -c requirements/edx-platform-constraints.txt # -r requirements/base.in + # coreapi # django-oauth-toolkit # edx-drf-extensions # edx-rest-api-client # openai # slumber # snowflake-connector-python +ruamel-yaml==0.17.35 + # via + # -c requirements/edx-platform-constraints.txt + # drf-yasg +ruamel-yaml-clib==0.2.8 + # via + # -c requirements/edx-platform-constraints.txt + # ruamel-yaml rules==3.3 # via # -c requirements/edx-platform-constraints.txt @@ -465,12 +505,17 @@ unicodecsv==0.14.1 # via # -c requirements/edx-platform-constraints.txt # -r requirements/base.in +uritemplate==4.1.1 + # via + # -c requirements/edx-platform-constraints.txt + # coreapi + # drf-yasg urllib3==1.26.17 # via # -c requirements/edx-platform-constraints.txt # requests # snowflake-connector-python -vine==5.0.0 +vine==5.1.0 # via # amqp # celery diff --git a/requirements/test.txt b/requirements/test.txt index 4cfc39a60c..6880b62747 100644 --- a/requirements/test.txt +++ b/requirements/test.txt @@ -42,7 +42,6 @@ backports-zoneinfo[tzdata]==0.2.1 # via # -r requirements/test-master.txt # -r requirements/test.in - # backports-zoneinfo # celery # kombu # via @@ -94,10 +93,17 @@ code-annotations==1.5.0 # via # -r requirements/test-master.txt # edx-toggles -coverage[toml]==7.3.2 +coreapi==2.3.3 # via - # coverage - # pytest-cov + # -r requirements/test-master.txt + # drf-yasg +coreschema==0.0.4 + # via + # -r requirements/test-master.txt + # coreapi + # drf-yasg +coverage[toml]==7.3.2 + # via pytest-cov cryptography==38.0.4 # via # -r requirements/test-master.txt @@ -132,6 +138,8 @@ diff-cover==8.0.0 # django-waffle # djangorestframework # drf-jwt + # drf-yasg + # edx-api-doc-tools # edx-django-utils # edx-drf-extensions # edx-rbac @@ -181,6 +189,8 @@ djangorestframework==3.14.0 # -r requirements/test-master.txt # django-config-models # drf-jwt + # drf-yasg + # edx-api-doc-tools # edx-drf-extensions djangorestframework-xml==2.0.0 # via -r requirements/test-master.txt @@ -188,6 +198,12 @@ drf-jwt==1.19.2 # via # -r requirements/test-master.txt # edx-drf-extensions +drf-yasg==1.21.5 + # via + # -r requirements/test-master.txt + # edx-api-doc-tools +edx-api-doc-tools==1.7.0 + # via -r requirements/test-master.txt edx-braze-client==0.1.8 # via -r requirements/test-master.txt edx-django-utils==5.7.0 @@ -205,7 +221,6 @@ edx-opaque-keys[django]==2.5.1 # via # -r requirements/test-master.txt # edx-drf-extensions - # edx-opaque-keys edx-rbac==1.8.0 # via -r requirements/test-master.txt edx-rest-api-client==5.6.0 @@ -218,7 +233,7 @@ factory-boy==3.3.0 # via # -c requirements/constraints.txt # -r requirements/test.in -faker==19.12.1 +faker==19.13.0 # via factory-boy filelock==3.12.4 # via @@ -239,12 +254,21 @@ idna==3.4 # requests # snowflake-connector-python # yarl +inflection==0.5.1 + # via + # -r requirements/test-master.txt + # drf-yasg iniconfig==2.0.0 # via pytest +itypes==1.2.0 + # via + # -r requirements/test-master.txt + # coreapi jinja2==3.1.2 # via # -r requirements/test-master.txt # code-annotations + # coreschema # diff-cover jsondiff==2.0.0 # via -r requirements/test-master.txt @@ -287,6 +311,7 @@ oscrypto==1.3.0 packaging==23.2 # via # -r requirements/test-master.txt + # drf-yasg # pytest # snowflake-connector-python path==16.7.1 @@ -340,7 +365,6 @@ pyjwt[crypto]==2.8.0 # drf-jwt # edx-drf-extensions # edx-rest-api-client - # pyjwt # snowflake-connector-python pymongo==3.13.0 # via @@ -378,6 +402,7 @@ pytz==2022.7.1 # -r requirements/test-master.txt # django # djangorestframework + # drf-yasg # edx-tincan-py35 # snowflake-connector-python pyyaml==6.0.1 @@ -387,6 +412,7 @@ pyyaml==6.0.1 requests==2.31.0 # via # -r requirements/test-master.txt + # coreapi # django-oauth-toolkit # edx-drf-extensions # edx-rest-api-client @@ -398,6 +424,14 @@ responses==0.10.15 # via # -c requirements/constraints.txt # -r requirements/test.in +ruamel-yaml==0.17.35 + # via + # -r requirements/test-master.txt + # drf-yasg +ruamel-yaml-clib==0.2.8 + # via + # -r requirements/test-master.txt + # ruamel-yaml rules==3.3 # via -r requirements/test-master.txt semantic-version==2.10.0 @@ -469,6 +503,11 @@ tzdata==2023.3 # celery unicodecsv==0.14.1 # via -r requirements/test-master.txt +uritemplate==4.1.1 + # via + # -r requirements/test-master.txt + # coreapi + # drf-yasg urllib3==1.26.17 # via # -r requirements/test-master.txt From 22d94b1859859961619c880f145a3ca64d89f65f Mon Sep 17 00:00:00 2001 From: Ehmad Saeed Date: Mon, 13 Nov 2023 16:11:21 +0500 Subject: [PATCH 035/164] chore: bump the version --- CHANGELOG.rst | 4 ++++ enterprise/__init__.py | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 0eb77332f1..6a1feafd52 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -17,6 +17,10 @@ Unreleased ---------- Nothing unreleased. +[4.7.1] +-------- +chore: retire Degreed v1 code from the set of channels + [4.7.0] -------- feat: Add an ``api-docs`` page that lives at ``[LMS_ROOT_URL]/enterprise/api-docs/`` diff --git a/enterprise/__init__.py b/enterprise/__init__.py index 9205bf506e..361e96f798 100644 --- a/enterprise/__init__.py +++ b/enterprise/__init__.py @@ -2,4 +2,4 @@ Your project description goes here. """ -__version__ = "4.7.0" +__version__ = "4.7.1" From 17b0857472d80f4d7add3807261efad043d8a584 Mon Sep 17 00:00:00 2001 From: Shadi Naif Date: Fri, 17 Nov 2023 19:21:52 +0300 Subject: [PATCH 036/164] feat: standardize make extract_translations (#1751) Refs: FC-0012 OEP-58 --- .tx/config | 8 +- Makefile | 3 +- enterprise/{ => conf}/locale/config.yaml | 3 + .../conf/locale/en/LC_MESSAGES/django.po | 1599 +++++++++++++++-- .../locale/en/LC_MESSAGES/djangojs.po | 0 .../locale/eo/LC_MESSAGES/django.po | 0 .../locale/eo/LC_MESSAGES/djangojs.po | 0 .../locale/es_419/LC_MESSAGES/django.po | 0 enterprise/locale | 1 + enterprise/locale/en/LC_MESSAGES/django.po | 1593 ---------------- .../locale/es_419/LC_MESSAGES/django.mo | Bin 7727 -> 0 bytes locale | 1 + 12 files changed, 1412 insertions(+), 1796 deletions(-) rename enterprise/{ => conf}/locale/config.yaml (98%) rename enterprise/{ => conf}/locale/en/LC_MESSAGES/djangojs.po (100%) rename enterprise/{ => conf}/locale/eo/LC_MESSAGES/django.po (100%) rename enterprise/{ => conf}/locale/eo/LC_MESSAGES/djangojs.po (100%) rename enterprise/{ => conf}/locale/es_419/LC_MESSAGES/django.po (100%) create mode 120000 enterprise/locale delete mode 100644 enterprise/locale/en/LC_MESSAGES/django.po delete mode 100644 enterprise/locale/es_419/LC_MESSAGES/django.mo create mode 120000 locale diff --git a/.tx/config b/.tx/config index 692555bb18..cae16ae218 100644 --- a/.tx/config +++ b/.tx/config @@ -2,14 +2,14 @@ host = https://www.transifex.com [o:open-edx:p:edx-platform:r:edx-enterprise] -file_filter = enterprise/locale//LC_MESSAGES/django.po -source_file = enterprise/locale/en/LC_MESSAGES/django.po +file_filter = enterprise/conf/locale//LC_MESSAGES/django.po +source_file = enterprise/conf/locale/en/LC_MESSAGES/django.po source_lang = en type = PO [o:open-edx:p:edx-platform:r:edx-enterprise-js] -file_filter = enterprise/locale//LC_MESSAGES/djangojs.po -source_file = enterprise/locale/en/LC_MESSAGES/djangojs.po +file_filter = enterprise/conf/locale//LC_MESSAGES/djangojs.po +source_file = enterprise/conf/locale/en/LC_MESSAGES/djangojs.po source_lang = en type = PO diff --git a/Makefile b/Makefile index bbe87de9e4..6d11600142 100644 --- a/Makefile +++ b/Makefile @@ -52,8 +52,7 @@ dummy_translations: ## generate dummy translation (.po) files extract_translations: ## extract strings to be translated, outputting .mo files rm -rf docs/_build - ./manage.py makemessages -l en -v1 -d django - ./manage.py makemessages -l en -v1 -d djangojs -i "node_modules/*" + i18n_tool extract --no-segment fake_translations: extract_translations dummy_translations compile_translations ## generate and compile dummy translation files diff --git a/enterprise/locale/config.yaml b/enterprise/conf/locale/config.yaml similarity index 98% rename from enterprise/locale/config.yaml rename to enterprise/conf/locale/config.yaml index d1c618e885..a3c141259a 100644 --- a/enterprise/locale/config.yaml +++ b/enterprise/conf/locale/config.yaml @@ -83,3 +83,6 @@ locales: # The locales used for fake-accented English, for testing. dummy_locales: - eo + +ignore_dirs: + - node_modules diff --git a/enterprise/conf/locale/en/LC_MESSAGES/django.po b/enterprise/conf/locale/en/LC_MESSAGES/django.po index 3142161461..fb95540da9 100644 --- a/enterprise/conf/locale/en/LC_MESSAGES/django.po +++ b/enterprise/conf/locale/en/LC_MESSAGES/django.po @@ -18,371 +18,1576 @@ msgstr "" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" -#: consent/admin/__init__.py:110 +#: enterprise/admin/__init__.py:558 msgid "Preview (course)" msgstr "" -#: consent/admin/__init__.py:112 +#: enterprise/admin/__init__.py:560 msgid "" -"Preview the data sharing consent page rendered in the context of a course " -"enrollment." +"Preview the HTML template rendered in the context of a course enrollment." msgstr "" -#: consent/admin/__init__.py:114 +#: enterprise/admin/__init__.py:569 msgid "Preview (program)" msgstr "" -#: consent/admin/__init__.py:116 +#: enterprise/admin/__init__.py:571 msgid "" -"Preview the data sharing consent page rendered in the context of a program " -"enrollment." +"Preview the HTML template rendered in the context of a program enrollment." +msgstr "" + +#: enterprise/admin/forms.py:48 +msgid "To add a single learner, enter an email address or username." +msgstr "" + +#: enterprise/admin/forms.py:52 +msgid "" +"To add multiple learners, upload a .csv file that contains a column of email " +"addresses." +msgstr "" + +#: enterprise/admin/forms.py:57 +msgid "" +"The .csv file must have a column of email addresses, indicated by the " +"heading 'email' in the first row." +msgstr "" + +#: enterprise/admin/forms.py:62 +msgid "Enroll these learners in this course" +msgstr "" + +#: enterprise/admin/forms.py:63 +msgid "To enroll learners in a course, enter a course ID." +msgstr "" + +#: enterprise/admin/forms.py:66 +msgid "Course enrollment track" +msgstr "" + +#: enterprise/admin/forms.py:68 enterprise/api/v1/serializers.py:930 +msgid "Audit" +msgstr "" + +#: enterprise/admin/forms.py:69 enterprise/api/v1/serializers.py:931 +msgid "Verified" +msgstr "" + +#: enterprise/admin/forms.py:70 enterprise/api/v1/serializers.py:932 +msgid "Professional Education" +msgstr "" + +#: enterprise/admin/forms.py:71 enterprise/api/v1/serializers.py:933 +msgid "Professional Education (no ID)" +msgstr "" + +#: enterprise/admin/forms.py:72 enterprise/api/v1/serializers.py:934 +msgid "Credit" +msgstr "" + +#: enterprise/admin/forms.py:73 enterprise/api/v1/serializers.py:935 +msgid "Honor" +msgstr "" + +#: enterprise/admin/forms.py:76 +msgid "Reason for manual enrollment" +msgstr "" + +#: enterprise/admin/forms.py:77 +msgid "Salesforce Opportunity ID" msgstr "" -#: consent/models.py:222 -msgid "Name of the user whose consent state is stored." +#: enterprise/admin/forms.py:79 +msgid "Discount percentage for manual enrollment" msgstr "" -#: consent/models.py:224 -msgid "Whether consent is granted." +#: enterprise/admin/forms.py:80 +msgid "Discount percentage should be from 0 to 100" msgstr "" -#: consent/models.py:252 -msgid "Data Sharing Consent Record" +#: enterprise/admin/forms.py:95 +msgid "Notify learners of enrollment" msgstr "" -#: consent/models.py:253 -msgid "Data Sharing Consent Records" +#: enterprise/admin/forms.py:97 +msgid "Send email" msgstr "" -#: consent/models.py:261 -msgid "Course key for which data sharing consent is granted." +#: enterprise/admin/forms.py:98 +msgid "Do not notify" msgstr "" -#: consent/models.py:276 -msgid "Data sharing consent text overrides" +#: enterprise/admin/forms.py:284 +msgid "Email/Username" msgstr "" -#: consent/models.py:279 +#: enterprise/admin/forms.py:285 +msgid "Enter an email address or username." +msgstr "" + +#: enterprise/admin/forms.py:289 +msgid "Course" +msgstr "" + +#: enterprise/admin/forms.py:290 +msgid "Enter the course run key." +msgstr "" + +#: enterprise/admin/forms.py:397 +msgid "Preview" +msgstr "" + +#: enterprise/admin/forms.py:398 +msgid "Hold Ctrl when clicking on button to open Preview in new tab" +msgstr "" + +#: enterprise/admin/forms.py:461 +msgid "Identity Provider" +msgstr "" + +#: enterprise/admin/forms.py:485 +msgid "" +"The specified Identity Provider does not exist. For more information, " +"contact a system administrator." +msgstr "" + +#: enterprise/admin/forms.py:496 +#, python-brace-format msgid "" -"Fill in a text for first paragraph of page. The following variables may be " -"available:
  • enterprise_customer_name: A name of enterprise " -"customer.
  • platform_name: Name of platform.
  • item: A string " -"which is \"course\" or \"program\" depending on the type of consent.
  • course_title: Title of course. Available when type of consent is " -"course.
  • course_start_date: Course start date. Available when type of " -"consent is course.
" +"The site for the selected identity provider ({identity_provider_site}) does " +"not match the site for this enterprise customer " +"({enterprise_customer_site}). To correct this problem, select a site that " +"has a domain of '{identity_provider_site}', or update the identity provider " +"to '{enterprise_customer_site}'." msgstr "" -#: consent/models.py:289 +#: enterprise/admin/forms.py:565 +#, python-brace-format msgid "" -"Fill in a text for policy paragraph at the bottom of page. The following " -"variables may be available:
  • enterprise_customer_name: A name of " -"enterprise customer.
  • platform_name: Name of platform.
  • item: " -"A string which is \"course\" or \"program\" depending on the type of consent." -"
  • course_title: Title of course. Available when type of consent is " -"course.
  • course_start_date: Course start date. Available when type of " -"consent is course.
" +"These catalogs for reporting do not match enterprisecustomer " +"{enterprise_customer}: {invalid_catalogs}" +msgstr "" + +#: enterprise/admin/forms.py:579 +msgid "Enter enterprise channel worker username." +msgstr "" + +#: enterprise/admin/views.py:203 +msgid "Successfully requested the Data Sharing consent from learner." +msgstr "" + +#: enterprise/admin/views.py:421 +#, python-brace-format +msgid "Error at line {line}: {message}\n" msgstr "" -#: consent/models.py:299 +#: enterprise/admin/views.py:448 +#, python-brace-format +msgid "{count} new learner was added to {enterprise_customer_name}." +msgid_plural "{count} new learners were added to {enterprise_customer_name}." +msgstr[0] "" +msgstr[1] "" + +#: enterprise/admin/views.py:462 +#, python-brace-format msgid "" -"Fill in a text for left sidebar paragraph. The following variables may be " -"available:
  • enterprise_customer_name: A name of enterprise " -"customer.
  • platform_name: Name of platform.
  • item: A string " -"which is \"course\" or \"program\" depending on the type of consent.
  • course_title: Title of course. Available when type of consent is " -"course.
  • course_start_date: Course start date. Available when type of " -"consent is course.
" +"The following learners were already associated with this Enterprise " +"Customer: {list_of_emails}" msgstr "" -#: consent/models.py:309 +#: enterprise/admin/views.py:472 +#, python-brace-format msgid "" -"Fill in a text for dialog which appears when user decline to provide " -"consent. The following variables may be available:
  • enterprise_customer_name: A name of enterprise customer.
  • item: A string which is \"course\" or \"program\" depending on the " -"type of consent.
  • course_title: Title of course. Available when type " -"of consent is course.
  • course_start_date: Course start date. " -"Available when type of consent is course.
" +"The following learners are already associated with another Enterprise " +"Customer. These learners were not added to {enterprise_customer_name}: " +"{list_of_emails}" msgstr "" -#: consent/models.py:319 +#: enterprise/admin/views.py:484 +#, python-brace-format msgid "" -"Fill in a text for title of the notification which appears on dashboard when " -"user decline to provide consent. The following variables may be available:" -"
  • enterprise_customer_name: A name of enterprise customer.
  • course_title: Title of course. Available when type of consent is " -"course.
" +"The following duplicate email addresses were not added: {list_of_emails}" msgstr "" -#: consent/models.py:328 +#: enterprise/admin/views.py:527 +#, python-brace-format +msgid "{enrolled_count} learner was enrolled in {enrolled_in}." +msgid_plural "{enrolled_count} learners were enrolled in {enrolled_in}." +msgstr[0] "" +msgstr[1] "" + +#: enterprise/admin/views.py:552 +#, python-brace-format msgid "" -"Fill in a text for message of the notification which appears on dashboard " -"when user decline to provide consent. The following variables may be " -"available:
  • enterprise_customer_name: A name of enterprise " -"customer.
  • course_title: Title of course. Available when type of " -"consent is course.
" +"The following learners could not be enrolled in {enrolled_in}: {user_list}" msgstr "" -#: consent/models.py:339 -msgid "Title of page" +#: enterprise/admin/views.py:575 +#, python-brace-format +msgid "" +"The following learners do not have an account on {platform_name}. They have " +"not been enrolled in {enrolled_in}. When these learners create an account, " +"they will be enrolled automatically: {pending_email_list}" msgstr "" -#: consent/models.py:354 -msgid "Text next to agreement check mark" +#: enterprise/admin/views.py:788 +#, python-brace-format +msgid "Email {email} is not associated with Enterprise Customer {ec_name}" msgstr "" -#: consent/models.py:358 -msgid "Text of agree button" +#: enterprise/api/utils.py:66 +#, python-brace-format +msgid "" +"{token_email} from {token_enterprise_name} has requested " +"{token_number_codes} additional codes. Please reach out to them.\n" +"Additional Notes:\n" +"{token_notes}." msgstr "" -#: consent/models.py:362 -msgid "Text of decline link" +#: enterprise/api/utils.py:73 +#, python-brace-format +msgid "" +"{token_email} from {token_enterprise_name} has requested " +"{token_number_codes} additional codes. Please reach out to them." msgstr "" -#: consent/models.py:368 -msgid "Text of policy drop down" +#: enterprise/api/utils.py:79 +#, python-brace-format +msgid "" +"{token_email} from {token_enterprise_name} has requested additional codes. " +"Please reach out to them.\n" +"Additional Notes:\n" +"{token_notes}." msgstr "" -#: consent/models.py:377 +#: enterprise/api/utils.py:85 +#, python-brace-format msgid "" -"Heading text of dialog box which appears when user decline to provide consent" +"{token_email} from {token_enterprise_name} has requested additional codes. " +"Please reach out to them." +msgstr "" + +#: enterprise/api/v1/serializers.py:52 +msgid "Total count of items." msgstr "" -#: consent/models.py:384 -msgid "Text of decline button on confirmation dialog box" +#: enterprise/api/v1/serializers.py:53 +msgid "URL to fetch next page of items." msgstr "" -#: consent/models.py:388 -msgid "Text of abort decline link on confirmation dialog box" +#: enterprise/api/v1/serializers.py:54 +msgid "URL to fetch previous page of items." msgstr "" -#: consent/models.py:403 -msgid "Specifies whether data sharing consent page is published." +#: enterprise/api/v1/serializers.py:55 +msgid "List of items." msgstr "" -#: integrated_channels/cornerstone/models.py:41 -msgid "The API path for making completion POST requests to Cornerstone." +#: enterprise/api/v1/views.py:555 enterprise_learner_portal/api/v1/views.py:39 +#: enterprise_learner_portal/api/v1/views.py:76 +msgid "" +"To use this endpoint, this package must be installed in an Open edX " +"environment." +msgstr "" + +#: enterprise/api/v1/views.py:750 +#, python-brace-format +msgid "" +"[Enterprise API] CourseKey not found in the Catalog. Course: {course_key}, " +"Catalog: {catalog_id}" msgstr "" -#: integrated_channels/cornerstone/models.py:48 +#: enterprise/api/v1/views.py:778 +#, python-brace-format msgid "" -"The API path for making OAuth-related POST requests to Cornerstone. This " -"will be used to gain the OAuth access token which is required for other API " -"calls." +"[Enterprise API] CourseRun not found in the Catalog. CourseRun: {course_id}, " +"Catalog: {catalog_id}" msgstr "" -#: integrated_channels/cornerstone/models.py:57 -msgid "Basic auth username for sending user completion status to cornerstone." +#: enterprise/api/v1/views.py:806 +#, python-brace-format +msgid "" +"[Enterprise API] Program not found in the Catalog. Program: {program_uuid}, " +"Catalog: {catalog_id}" msgstr "" -#: integrated_channels/cornerstone/models.py:63 -msgid "Basic auth password for sending user completion status to cornerstone." +#: enterprise/api/v1/views.py:976 +#, python-brace-format +msgid "Code Management - Request for Codes by {token_enterprise_name}" msgstr "" -#: integrated_channels/cornerstone/models.py:68 -msgid "Key/value mapping cornerstone subjects to edX subjects list" +#: enterprise/api/v1/views.py:1001 +#, python-brace-format +msgid "" +"[Enterprise API] Failure in sending e-mail to support. SupportEmail: " +"{token_cs_email}, UserEmail: {token_email}, EnterpriseName: " +"{token_enterprise_name}" msgstr "" -#: integrated_channels/cornerstone/models.py:73 -msgid "List of IETF language tags supported by cornerstone" +#: enterprise/api_client/discovery.py:52 +msgid "" +"To get a Catalog API client, this package must be installed in an Open edX " +"environment." msgstr "" -#: integrated_channels/cornerstone/models.py:103 +#: enterprise/api_client/discovery.py:84 enterprise/api_client/discovery.py:419 +#: enterprise/utils.py:1172 msgid "" -"The base URL used for API requests to Cornerstone, i.e. https://portalName." -"csod.com" +"To get a CatalogIntegration object, this package must be installed in an " +"Open edX environment." msgstr "" -#: integrated_channels/cornerstone/models.py:187 +#: enterprise/api_client/discovery.py:89 msgid "" -"The course run's key which is used to uniquely identify the course for " -"Cornerstone." +"To parse a Catalog API response, this package must be installed in an Open " +"edX environment." msgstr "" -#: integrated_channels/cornerstone/models.py:196 -msgid "The learner's course completion status transmitted to Cornerstone." +#: enterprise/api_client/discovery.py:429 +msgid "The configured CatalogIntegration service user does not exist." msgstr "" -#: integrated_channels/cornerstone/models.py:203 -msgid "Date time when user completed course" +#: enterprise/api_client/discovery.py:431 +msgid "There is no active CatalogIntegration." msgstr "" -#: integrated_channels/integrated_channel/management/commands/__init__.py:47 +#: enterprise/api_client/ecommerce.py:40 msgid "" -"Transmit data for only this EnterpriseCustomer. Omit this option to transmit " -"to all EnterpriseCustomers with active integrated channels." +"To get a ecommerce_api_client, this package must be installed in an Open edX " +"environment." msgstr "" -#: integrated_channels/integrated_channel/management/commands/__init__.py:55 +#: enterprise/constants.py:17 +#, python-brace-format +msgid "" +"To log in using this SSO identity provider and access special course offers, " +"you must first consent to share your learning achievements with " +"{enterprise_customer_name}." +msgstr "" + +#: enterprise/constants.py:21 +#, python-brace-format msgid "" -"Transmit data to this IntegrateChannel. Omit this option to transmit to all " -"configured, active integrated channels." +"In order to sign in and access special offers, you must consent to share " +"your course data with {enterprise_customer_name}." msgstr "" -#: integrated_channels/integrated_channel/management/commands/__init__.py:95 +#: enterprise/constants.py:25 #, python-brace-format -msgid "Enterprise customer {uuid} not found, or not active" +msgid "" +"If you do not consent to share your course data, that information may be " +"shared with {enterprise_customer_name}." msgstr "" -#: integrated_channels/integrated_channel/management/commands/__init__.py:111 +#: enterprise/constants.py:28 #, python-brace-format -msgid "Invalid integrated channel: {channel}" +msgid "Welcome to {platform_name}." msgstr "" -#: integrated_channels/integrated_channel/management/commands/reset_sapsf_learner_transmissions.py:20 +#: enterprise/constants.py:30 +#, python-brace-format msgid "" -"\n" -" Reset SAPSF learner transmissions for the given EnterpriseCustomer and " -"Channel between two dates.\n" -" " +"You have left the {strong_start}{enterprise_customer_name}{strong_end} " +"website and are now on the {platform_name} site. {enterprise_customer_name} " +"has partnered with {platform_name} to offer you high-quality, always " +"available learning programs to help you advance your knowledge and career. " +"{line_break}Please note that {platform_name} has a different " +"{privacy_policy_link_start}Privacy Policy {privacy_policy_link_end} from " +"{enterprise_customer_name}." msgstr "" -#: integrated_channels/integrated_channel/management/commands/reset_sapsf_learner_transmissions.py:32 -msgid "Start date and time in YYYY-MM-DDTHH:MM:SSZ format." +#: enterprise/constants.py:80 +msgid "" +"A series of Master’s-level courses to advance your career, created by top " +"universities and recognized by companies. MicroMasters Programs are credit-" +"eligible, provide in-demand knowledge and may be applied to accelerate a " +"Master’s Degree." msgstr "" -#: integrated_channels/integrated_channel/management/commands/reset_sapsf_learner_transmissions.py:39 -msgid "End date and time in YYYY-MM-DDTHH:MM:SSZ format." +#: enterprise/constants.py:86 +msgid "" +"Designed by industry leaders and top universities to enhance professional " +"skills, Professional Certificates develop the proficiency and expertise that " +"employers are looking for with specialized training and professional " +"education." msgstr "" -#: integrated_channels/integrated_channel/management/commands/transmit_learner_data.py:22 +#: enterprise/constants.py:92 msgid "" -"\n" -" Transmit Enterprise learner course completion data for the given " -"EnterpriseCustomer.\n" -" " +"Created by world-renowned experts and top universities, XSeries are designed " +"to provide a deep understanding of key subjects through a series of courses. " +"Complete the series to earn a valuable XSeries Certificate that illustrates " +"your achievement." +msgstr "" + +#: enterprise/forms.py:13 +msgid "" +"You have access to multiple organizations. Select the organization that you " +"will use to sign up for courses. If you want to change organizations, sign " +"out and sign back in." +msgstr "" + +#: enterprise/forms.py:16 +msgid "Enter the organization name" +msgstr "" + +#: enterprise/forms.py:18 +msgid "" +"Have an account through your company, school, or organization? Enter your " +"organization’s name below to sign in." +msgstr "" + +#: enterprise/forms.py:21 +msgid "" +"The attempt to login with this organization name was not successful. Please " +"try again, or contact our support." +msgstr "" + +#: enterprise/forms.py:53 +msgid "Enterprise not found" msgstr "" -#: integrated_channels/integrated_channel/management/commands/transmit_learner_data.py:36 -msgid "Username of a user authorized to fetch grades from the LMS API." +#: enterprise/forms.py:57 +msgid "Wrong Enterprise" msgstr "" -#: integrated_channels/integrated_channel/management/commands/transmit_learner_data.py:49 +#: enterprise/management/commands/migrate_enterprise_catalogs.py:30 +msgid "Username of a user authorized to access the Enterprise Catalog API." +msgstr "" + +#: enterprise/management/commands/migrate_enterprise_catalogs.py:36 +msgid "Comma separated list of uuids of enterprise catalogs to migrate." +msgstr "" + +#: enterprise/management/commands/migrate_enterprise_catalogs.py:45 #, python-brace-format msgid "A user with the username {username} was not found." msgstr "" -#: integrated_channels/integrated_channel/models.py:44 -#: integrated_channels/xapi/models.py:31 -msgid "Enterprise Customer associated with the configuration." +#: enterprise/messages.py:25 +#, python-brace-format +msgid "" +"{strong_start}We could not enroll you in {em_start}{item}{em_end}." +"{strong_end} {span_start}If you have questions or concerns about sharing " +"your data, please contact your learning manager at " +"{enterprise_customer_name}, or contact {link_start}{platform_name} " +"support{link_end}.{span_end}" msgstr "" -#: integrated_channels/integrated_channel/models.py:51 -#: integrated_channels/xapi/models.py:41 -msgid "Is this configuration active?" +#: enterprise/messages.py:56 +#, python-brace-format +msgid "" +"{strong_start}We could not gather price information for {em_start}{item}" +"{em_end}.{strong_end} {span_start}If you continue to have these issues, " +"please contact {link_start}{platform_name} support{link_end}.{span_end}" msgstr "" -#: integrated_channels/integrated_channel/models.py:56 -#: integrated_channels/moodle/models.py:88 +#: enterprise/messages.py:86 +#, python-brace-format msgid "" -"The maximum number of data items to transmit to the integrated channel with " -"each request." +"{strong_start}Something happened.{strong_end} {span_start}This {item} is not " +"currently open to new learners. Please start over and select a different " +"{item}.{span_end}" msgstr "" -#: integrated_channels/integrated_channel/models.py:63 +#: enterprise/messages.py:112 +#, python-brace-format msgid "" -"Enterprise channel worker username to get JWT tokens for authenticating LMS " -"APIs." +"{strong_start}Something happened.{strong_end} {span_start}Please reach out " +"to your learning administrator with the following error code and they will " +"be able to help you out.{span_end}{span_start}Error code: {error_code}" +"{span_end}{span_start}Username: {username}{span_end}" msgstr "" -#: integrated_channels/integrated_channel/models.py:68 -msgid "A comma-separated list of catalog UUIDs to transmit." +#: enterprise/models.py:112 +msgid "Enterprise Customer Type" msgstr "" -#: integrated_channels/moodle/admin/__init__.py:28 -msgid "Cannot set both a Username/Password and Token" +#: enterprise/models.py:113 +msgid "Enterprise Customer Types" msgstr "" -#: integrated_channels/moodle/admin/__init__.py:30 -msgid "Must set both a Username and Password, not just one" +#: enterprise/models.py:120 enterprise/models.py:264 +msgid "Specifies enterprise customer type." msgstr "" -#: integrated_channels/moodle/models.py:36 -msgid "The base URL used for API requests to Moodle" +#: enterprise/models.py:169 enterprise/models.py:2071 +msgid "Enterprise Customer" msgstr "" -#: integrated_channels/moodle/models.py:43 -msgid "The short name for the Moodle webservice." +#: enterprise/models.py:170 +msgid "Enterprise Customers" msgstr "" -#: integrated_channels/moodle/models.py:52 -msgid "The category ID for what edX courses should be associated with." +#: enterprise/models.py:177 +msgid "Enterprise Customer name." msgstr "" -#: integrated_channels/moodle/models.py:62 -msgid "The API user's username used to obtain new tokens." +#: enterprise/models.py:190 +msgid "" +"Specify whether display the course original price on enterprise course " +"landing page or not." msgstr "" -#: integrated_channels/moodle/models.py:72 -msgid "The API user's password used to obtain new tokens." +#: enterprise/models.py:210 +msgid "" +"Specifies whether data sharing consent is enabled or disabled for learners " +"signing in through this enterprise customer. If disabled, consent will not " +"be requested, and eligible data will not be shared." msgstr "" -#: integrated_channels/moodle/models.py:82 -msgid "The user's token for the Moodle webservice." +#: enterprise/models.py:223 +msgid "" +"Specifies whether data sharing consent is optional, is required at login, or " +"is required at enrollment." msgstr "" -#: integrated_channels/sap_success_factors/exporters/content_metadata.py:171 -#: integrated_channels/sap_success_factors/exporters/content_metadata.py:200 -msgid "Starts" +#: enterprise/models.py:231 +msgid "" +"Specifies whether the audit track enrollment option will be displayed in the " +"course enrollment view." +msgstr "" + +#: enterprise/models.py:238 +msgid "" +"Specifies whether to pass-back audit track enrollment data through an " +"integrated channel." +msgstr "" + +#: enterprise/models.py:245 +msgid "" +"Specifies whether to replace the display of potentially sensitive SSO " +"usernames with a more generic name, e.g. EnterpriseLearner." +msgstr "" + +#: enterprise/models.py:256 +msgid "" +"Specifies whether the customer is able to assign learners to cohorts upon " +"enrollment." +msgstr "" + +#: enterprise/models.py:261 +msgid "Customer Type" +msgstr "" + +#: enterprise/models.py:270 +msgid "" +"Specifies whether to allow access to the code management screen in the admin " +"portal." +msgstr "" + +#: enterprise/models.py:275 +msgid "" +"Specifies whether to allow access to the reporting configurations screen in " +"the admin portal." +msgstr "" + +#: enterprise/models.py:280 +msgid "" +"Specifies whether to allow access to the subscription management screen in " +"the admin portal." +msgstr "" + +#: enterprise/models.py:285 +msgid "" +"Specifies whether to allow access to the saml configuration screen in the " +"admin portal" +msgstr "" + +#: enterprise/models.py:290 +msgid "" +"Specifies whether the enterprise learner portal site should be made known to " +"the learner." +msgstr "" + +#: enterprise/models.py:296 +msgid "" +"Specifies whether a learner for an integrated channel customer can navigate " +"the enterprise learner portal site using the main menu." +msgstr "" + +#: enterprise/models.py:303 +msgid "" +"Specifies whether to allow access to the analytics screen in the admin " +"portal." +msgstr "" + +#: enterprise/models.py:308 +msgid "" +"Specifies whether the learner should be able to login through enterprise's " +"slug login" +msgstr "" + +#: enterprise/models.py:314 +msgid "Email to be displayed as public point of contact for enterprise." +msgstr "" + +#: enterprise/models.py:323 +msgid "" +"Specifies the discount percent used for enrollments from the enrollment API " +"where capturing the discount per order is not possible. This is passed to " +"ecommerce when creating orders for financial data reporting." +msgstr "" + +#: enterprise/models.py:335 +msgid "" +"Specifies the default language for all the learners of this enterprise " +"customer." +msgstr "" + +#: enterprise/models.py:573 +msgid "" +"Course details were not found for course key {} - Course Catalog API " +"returned nothing. Proceeding with enrollment, but notifications won't be sent" +msgstr "" + +#: enterprise/models.py:767 +msgid "Enterprise Customer Learner" +msgstr "" + +#: enterprise/models.py:768 +msgid "Enterprise Customer Learners" +msgstr "" + +#: enterprise/models.py:1208 +msgid "Logo images must be in .png format." +msgstr "" + +#: enterprise/models.py:1235 +msgid "Branding Configuration" +msgstr "" + +#: enterprise/models.py:1236 +msgid "Branding Configurations" +msgstr "" + +#: enterprise/models.py:1391 +msgid "The enterprise learner to which this enrollment is attached." +msgstr "" + +#: enterprise/models.py:1398 +msgid "The ID of the course in which the learner was enrolled." +msgstr "" + +#: enterprise/models.py:1405 +msgid "" +"Specifies whether a user marked this course as saved for later in the " +"learner portal." +msgstr "" + +#: enterprise/models.py:1521 +msgid "The course enrollment the associated license is for." +msgstr "" + +#: enterprise/models.py:1528 +msgid "" +"Whether the licensed enterprise course enrollment is revoked, e.g., when a " +"user's license is revoked." +msgstr "" + +#: enterprise/models.py:1583 +msgid "" +"Query parameters which will be used to filter the discovery service's search/" +"all endpoint results, specified as a JSON object. An empty JSON object means " +"that all available content items will be included in the catalog." +msgstr "" + +#: enterprise/models.py:1591 +msgid "Enterprise Catalog Query" +msgstr "" + +#: enterprise/models.py:1592 +msgid "Enterprise Catalog Queries" +msgstr "" + +#: enterprise/models.py:1650 +msgid "" +"Query parameters which will be used to filter the discovery service's search/" +"all endpoint results, specified as a Json object. An empty Json object means " +"that all available content items will be included in the catalog." +msgstr "" + +#: enterprise/models.py:1658 +msgid "" +"Ordered list of enrollment modes which can be displayed to learners for " +"course runs in this catalog." +msgstr "" + +#: enterprise/models.py:1664 +msgid "" +"Specifies whether courses should be published with direct-to-audit " +"enrollment URLs." +msgstr "" + +#: enterprise/models.py:1671 +msgid "Enterprise Customer Catalog" +msgstr "" + +#: enterprise/models.py:1672 enterprise/models.py:2185 +msgid "Enterprise Customer Catalogs" +msgstr "" + +#: enterprise/models.py:1940 +msgid "" +"Fill in a standard Django template that, when rendered, produces the email " +"you want sent to newly-enrolled Enterprise Customer learners. The following " +"variables may be available:\n" +"
  • user_name: A human-readable name for the person being emailed. Be " +"sure to handle the case where this is not defined, as it may be missing in " +"some cases. It may also be a username, if the learner hasn't configured " +"their \"real\" name in the system.
  • organization_name: The name " +"of the organization sponsoring the enrollment.
  • enrolled_in: " +"Details of the course or program that was enrolled in. It may contain: " +"
    • name: The name of the enrollable item (e.g., \"Demo Course\").
    • url: A link to the homepage of the enrolled-in item.
    • branding: A custom branding name for the enrolled-in item. " +"For example, the branding of a MicroMasters program would be \"MicroMasters" +"\".
    • start: The date the enrolled-in item becomes available. " +"Render this to text using the Django `date` template filter (see the " +"Django documentation).
    • type: Whether the enrolled-in item is a " +"course, a program, or something else.
" +msgstr "" + +#: enterprise/models.py:1958 +#, python-brace-format +msgid "" +"Enter a string that can be used to generate a dynamic subject line for " +"notification emails. The placeholder {course_name} will be replaced with the " +"name of the course or program that was enrolled in." +msgstr "" + +#: enterprise/models.py:2073 +msgid "Active" +msgstr "" + +#: enterprise/models.py:2074 +msgid "Include Date" +msgstr "" + +#: enterprise/models.py:2075 +msgid "Include date in the report file name" +msgstr "" + +#: enterprise/models.py:2081 +msgid "Delivery Method" +msgstr "" + +#: enterprise/models.py:2082 +msgid "The method in which the data should be sent." +msgstr "" + +#: enterprise/models.py:2087 +msgid "PGP Encryption Key" +msgstr "" + +#: enterprise/models.py:2088 +msgid "The key for encryption, if PGP encrypted file is required." +msgstr "" + +#: enterprise/models.py:2095 +msgid "Data Type" +msgstr "" + +#: enterprise/models.py:2096 +msgid "The type of data this report should contain." +msgstr "" + +#: enterprise/models.py:2103 +msgid "Report Type" +msgstr "" + +#: enterprise/models.py:2104 +msgid "The type this report should be sent as, e.g. CSV." +msgstr "" + +#: enterprise/models.py:2108 +msgid "Email" +msgstr "" + +#: enterprise/models.py:2109 +msgid "The email(s), one per line, where the report should be sent." +msgstr "" + +#: enterprise/models.py:2116 +msgid "Frequency" msgstr "" -#: integrated_channels/sap_success_factors/exporters/content_metadata.py:176 -msgid "Enrollment Closed" +#: enterprise/models.py:2117 +msgid "" +"The frequency interval (daily, weekly, or monthly) that the report should be " +"sent." msgstr "" -#: integrated_channels/sap_success_factors/exporters/content_metadata.py:209 -msgid "Ends" +#: enterprise/models.py:2122 +msgid "Day of Month" msgstr "" -#: integrated_channels/sap_success_factors/models.py:81 -msgid "OAuth client identifier." +#: enterprise/models.py:2123 +msgid "" +"The day of the month to send the report. This field is required and only " +"valid when the frequency is monthly." msgstr "" -#: integrated_channels/sap_success_factors/models.py:86 -msgid "Base URL of success factors API." +#: enterprise/models.py:2131 +msgid "Day of Week" msgstr "" -#: integrated_channels/sap_success_factors/models.py:89 -msgid "Success factors company identifier." +#: enterprise/models.py:2132 +msgid "" +"The day of the week to send the report. This field is required and only " +"valid when the frequency is weekly." msgstr "" -#: integrated_channels/sap_success_factors/models.py:94 -msgid "Success factors user identifier." +#: enterprise/models.py:2136 +msgid "Hour of Day" msgstr "" -#: integrated_channels/sap_success_factors/models.py:99 -msgid "OAuth client secret." +#: enterprise/models.py:2137 +msgid "" +"The hour of the day to send the report, in Eastern Standard Time (EST). This " +"is required for all frequency settings." msgstr "" -#: integrated_channels/sap_success_factors/models.py:106 -msgid "Type of SAP User (admin or user)." +#: enterprise/models.py:2145 +msgid "" +"This password will be used to secure the zip file. It will be encrypted when " +"stored in the database." msgstr "" -#: integrated_channels/sap_success_factors/models.py:112 -msgid "A comma-separated list of additional locales." +#: enterprise/models.py:2152 +msgid "SFTP Host name" msgstr "" -#: integrated_channels/sap_success_factors/models.py:117 -msgid "Transmit Total Hours" +#: enterprise/models.py:2153 +msgid "If the delivery method is sftp, the host to deliver the report to." msgstr "" -#: integrated_channels/sap_success_factors/models.py:118 -msgid "Include totalHours in the transmitted completion data" +#: enterprise/models.py:2158 +msgid "SFTP Port" msgstr "" -#: integrated_channels/xapi/models.py:34 -msgid "Version of xAPI." +#: enterprise/models.py:2159 +msgid "If the delivery method is sftp, the port on the host to connect to." msgstr "" -#: integrated_channels/xapi/models.py:35 -msgid "URL of the LRS." +#: enterprise/models.py:2166 +msgid "SFTP username" msgstr "" -#: integrated_channels/xapi/models.py:36 -msgid "Key of xAPI LRS." +#: enterprise/models.py:2167 +msgid "" +"If the delivery method is sftp, the username to use to securely access the " +"host." msgstr "" -#: integrated_channels/xapi/models.py:37 -msgid "secret of xAPI LRS." +#: enterprise/models.py:2173 +msgid "" +"If the delivery method is sftp, the password to use to securely access the " +"host. The password will be encrypted when stored in the database." +msgstr "" + +#: enterprise/models.py:2180 +msgid "SFTP file path" +msgstr "" + +#: enterprise/models.py:2181 +msgid "" +"If the delivery method is sftp, the path on the host to deliver the report " +"to." +msgstr "" + +#: enterprise/models.py:2264 +msgid "Day of week must be set if the frequency is weekly." +msgstr "" + +#: enterprise/models.py:2268 +msgid "Day of month must be set if the frequency is monthly." +msgstr "" + +#: enterprise/models.py:2271 +msgid "Frequency must be set to either daily, weekly, or monthly." +msgstr "" + +#: enterprise/models.py:2277 +msgid "Email(s) must be set if the delivery method is email." +msgstr "" + +#: enterprise/models.py:2281 +msgid "Decrypted password must be set if the delivery method is email." +msgstr "" + +#: enterprise/models.py:2285 +msgid "SFTP Hostname must be set if the delivery method is sftp." +msgstr "" + +#: enterprise/models.py:2287 +msgid "SFTP username must be set if the delivery method is sftp." +msgstr "" + +#: enterprise/models.py:2289 +msgid "SFTP File Path must be set if the delivery method is sftp." +msgstr "" + +#: enterprise/models.py:2292 +msgid "Decrypted SFTP password must be set if the delivery method is SFTP." +msgstr "" + +#: enterprise/models.py:2529 +msgid "User id in the third party analytics system." +msgstr "" + +#: enterprise/templates/enterprise/_data_sharing_decline_modal.html:7 +#: enterprise/views.py:1607 enterprise/views.py:2079 +msgid "Close" +msgstr "" + +#: enterprise/templates/enterprise/admin/clear_learners_data_sharing_consent.html:18 +#: enterprise/templates/enterprise/admin/manage_learners.html:47 +#: enterprise/templates/enterprise/admin/transmit_courses_metadata.html:18 +msgid "Home" +msgstr "" + +#: enterprise/templates/enterprise/admin/clear_learners_data_sharing_consent.html:33 +#: enterprise/templates/enterprise/admin/clear_learners_data_sharing_consent.html:40 +msgid "Clear Data Sharing Consent" +msgstr "" + +#: enterprise/templates/enterprise/admin/manage_learners.html:65 +msgid "Manage Learners" +msgstr "" + +#: enterprise/templates/enterprise/admin/manage_learners.html:68 +msgid "Search Term: " +msgstr "" + +#: enterprise/templates/enterprise/admin/manage_learners.html:87 +msgid "Search email address or username" +msgstr "" + +#: enterprise/templates/enterprise/admin/manage_learners.html:98 +msgid "User Email" +msgstr "" + +#: enterprise/templates/enterprise/admin/manage_learners.html:99 +msgid "Username" +msgstr "" + +#: enterprise/templates/enterprise/admin/manage_learners.html:100 +msgid "Linked Date" +msgstr "" + +#: enterprise/templates/enterprise/admin/manage_learners.html:101 +#: enterprise/templates/enterprise/admin/manage_learners.html:141 +msgid "Enroll" +msgstr "" + +#: enterprise/templates/enterprise/admin/manage_learners.html:139 +msgid "Learner Email" +msgstr "" + +#: enterprise/templates/enterprise/admin/manage_learners.html:140 +msgid "Date Added" +msgstr "" + +#: enterprise/templates/enterprise/admin/manage_learners.html:162 +msgid "Link learners" +msgstr "" + +#: enterprise/templates/enterprise/admin/transmit_courses_metadata.html:33 +#: enterprise/templates/enterprise/admin/transmit_courses_metadata.html:40 +msgid "Transmit Courses Metadata" +msgstr "" + +#: enterprise/templates/enterprise/emails/user_notification.html:3 +#: enterprise/templates/enterprise/emails/user_notification.txt:1 +#, python-format +msgid "Dear %(user_name)s," +msgstr "" + +#: enterprise/templates/enterprise/emails/user_notification.html:3 +#: enterprise/templates/enterprise/emails/user_notification.txt:1 +msgid "Hi!" +msgstr "" + +#: enterprise/templates/enterprise/emails/user_notification.html:5 +#, python-format +msgid "" +"You have been enrolled in %(program_name)s, " +"a %(program_branding)s program offered by %(organization_name)s. This " +"program begins %(start_date)s. For more information, see %(program_name)s." +msgstr "" + +#: enterprise/templates/enterprise/emails/user_notification.html:6 +#, python-format +msgid "" +"You have been enrolled in %(course_name)s, a " +"course offered by %(organization_name)s. This course begins %(start_date)s. " +"For more information, see %(course_name)s." +msgstr "" + +#: enterprise/templates/enterprise/emails/user_notification.html:8 +#, python-format +msgid "" +"

\n" +"Thanks,\n" +"

\n" +"

\n" +"The %(enrolled_in_name)s team\n" +"

" +msgstr "" + +#: enterprise/templates/enterprise/emails/user_notification.txt:3 +#, python-format +msgid "" +"You have been enrolled in %(program_name)s, a %(program_branding)s program " +"offered by %(organization_name)s. This program begins %(start_date)s. For " +"more information, see the following link:\n" +"\n" +"%(program_url)s" +msgstr "" + +#: enterprise/templates/enterprise/emails/user_notification.txt:6 +#, python-format +msgid "" +"You have been enrolled in %(course_name)s, a course offered by " +"%(organization_name)s. This course begins %(start_date)s. For more " +"information, see the following link:\n" +"\n" +"%(course_url)s" +msgstr "" + +#: enterprise/templates/enterprise/emails/user_notification.txt:9 +#, python-format +msgid "" +"\n" +"Thanks,\n" +"\n" +"The %(enrolled_in_name)s team" +msgstr "" + +#: enterprise/templates/enterprise/enterprise_customer_login_page.html:43 +msgid "Login" +msgstr "" + +#: enterprise/templates/enterprise/enterprise_customer_select_form.html:50 +#: enterprise/views.py:1603 +msgid "Continue" +msgstr "" + +#: enterprise/utils.py:107 +msgid "" +"Either \"Email or Username\" or \"CSV bulk upload\" must be specified, but " +"both were." +msgstr "" + +#: enterprise/utils.py:110 +msgid "Error: Learners could not be added. Correct the following errors." +msgstr "" + +#: enterprise/utils.py:112 +#, python-brace-format +msgid "Enrollment track {course_mode} is not available for course {course_id}." +msgstr "" + +#: enterprise/utils.py:114 +msgid "Select a course enrollment track for the given course." +msgstr "" + +#: enterprise/utils.py:116 +#, python-brace-format +msgid "" +"Could not retrieve details for the course ID {course_id}. Specify a valid ID." +msgstr "" + +#: enterprise/utils.py:119 +#, python-brace-format +msgid "{argument} does not appear to be a valid email address." +msgstr "" + +#: enterprise/utils.py:121 +#, python-brace-format +msgid "" +"{argument} does not appear to be a valid email address or known username" +msgstr "" + +#: enterprise/utils.py:124 +#, python-brace-format +msgid "" +"Expected a CSV file with [{expected_columns}] columns, but found " +"[{actual_columns}] columns instead." +msgstr "" + +#: enterprise/utils.py:128 +msgid "Reason field is required but was not filled." +msgstr "" + +#: enterprise/utils.py:131 +msgid "" +"Either \"Email or Username\" or \"CSV bulk upload\" must be specified, but " +"neither were." +msgstr "" + +#: enterprise/utils.py:134 +#, python-brace-format +msgid "" +"Pending user with email address {user_email} is already linked with another " +"Enterprise {ec_name}, you will be able to add the learner once the user " +"creates account or other enterprise deletes the pending user" +msgstr "" + +#: enterprise/utils.py:138 +#, python-brace-format +msgid "" +"User with email address {email} is already registered with Enterprise " +"Customer {ec_name}" +msgstr "" + +#: enterprise/utils.py:140 +msgid "User is not linked with Enterprise Customer" +msgstr "" + +#: enterprise/utils.py:141 +#, python-brace-format +msgid "User with email address {email} doesn't exist." +msgstr "" + +#: enterprise/utils.py:142 +msgid "Course doesn't exist in Enterprise Customer's Catalog" +msgstr "" + +#: enterprise/utils.py:144 +#, python-brace-format +msgid "" +"Enterprise channel worker user with the username " +"\"{channel_worker_username}\" was not found." +msgstr "" + +#: enterprise/utils.py:147 +msgid "" +"Unable to parse CSV file. Please make sure it is a CSV 'utf-8' encoded file." +msgstr "" + +#: enterprise/utils.py:150 +msgid "Discount percentage should be from 0 to 100." +msgstr "" + +#: enterprise/utils.py:358 +#, python-brace-format +msgid "You've been enrolled in {course_name}!" +msgstr "" + +#: enterprise/utils.py:415 +msgid "`user` must have one of either `email` or `user_email`." +msgstr "" + +#: enterprise/validators.py:29 +msgid "Value entered is not a valid hex color code." +msgstr "" + +#: enterprise/validators.py:40 +msgid "Unsupported file extension." +msgstr "" + +#: enterprise/validators.py:51 +#, python-format +msgid "The logo image file size must be less than or equal to %s KB." +msgstr "" + +#: enterprise/views.py:120 +msgid "" +"The following method from the Open edX platform is necessary for this view " +"but isn't available." +msgstr "" + +#: enterprise/views.py:142 +#, python-brace-format +msgid "{platform_name} home page" +msgstr "" + +#: enterprise/views.py:287 +msgid "Data sharing consent required" +msgstr "" + +#: enterprise/views.py:288 +msgid "Consent to share your data" +msgstr "" + +#: enterprise/views.py:290 +#, python-brace-format +msgid "" +"Per the {start_link}Data Sharing Policy{end_link}, {bold_start}" +"{enterprise_customer_name}{bold_end} would like to know about:" +msgstr "" + +#: enterprise/views.py:301 +#, python-brace-format +msgid "" +"I agree to allow {platform_name} to share data about my enrollment, " +"completion and performance in all {platform_name} courses and programs where " +"my enrollment is sponsored by {enterprise_customer_name}." +msgstr "" + +#: enterprise/views.py:307 +msgid "Yes, continue" +msgstr "" + +#: enterprise/views.py:308 +msgid "No, take me back." +msgstr "" + +#: enterprise/views.py:309 +msgid "Data Sharing Policy" +msgstr "" + +#: enterprise/views.py:311 +#, python-brace-format +msgid "" +"Enrollment, completion, and performance data that may be shared with " +"{enterprise_customer_name} (or its designee) for these courses and programs " +"are limited to the following:" +msgstr "" + +#: enterprise/views.py:318 +#, python-brace-format +msgid "" +"My email address for my {platform_name} account, and the date when I created " +"my {platform_name} account" +msgstr "" + +#: enterprise/views.py:324 +#, python-brace-format +msgid "" +"My {platform_name} ID, and if I log in via single sign-on, my " +"{enterprise_customer_name} SSO user-ID" +msgstr "" + +#: enterprise/views.py:330 +#, python-brace-format +msgid "My {platform_name} username" +msgstr "" + +#: enterprise/views.py:331 +msgid "My country or region of residence" +msgstr "" + +#: enterprise/views.py:333 +msgid "" +"What courses and/or programs I've enrolled in or unenrolled from, what track " +"I enrolled in (audit or verified) and the date when I enrolled in each " +"course or program" +msgstr "" + +#: enterprise/views.py:337 +msgid "" +"Information about each course or program I've enrolled in, including its " +"duration and level of effort required" +msgstr "" + +#: enterprise/views.py:341 +msgid "" +"Whether I completed specific parts of each course or program (for example, " +"whether I watched a given video or attempted or completed a given homework " +"assignment)" +msgstr "" + +#: enterprise/views.py:345 +msgid "" +"My overall percentage completion of each course or program on a periodic " +"basis, including the total time spent in each course or program, the date " +"when I last logged in to each course or program and how much of the course " +"or program content I have consumed" +msgstr "" + +#: enterprise/views.py:349 +msgid "" +"My performance in each course or program, including, for example, my score " +"on each assignment and current average of correct answers out of total " +"attempted answers" +msgstr "" + +#: enterprise/views.py:351 +msgid "" +"My final grade in each course or program, and the date when I completed each " +"course or program" +msgstr "" + +#: enterprise/views.py:352 +msgid "Whether I received a certificate in each course or program" +msgstr "" + +#: enterprise/views.py:355 +#, python-brace-format +msgid "" +"My permission applies only to data from courses or programs that are " +"sponsored by {enterprise_customer_name}, and not to data from any " +"{platform_name} courses or programs that I take on my own. I understand that " +"I may withdraw my permission only by fully unenrolling from any courses or " +"programs that are sponsored by {enterprise_customer_name}." +msgstr "" + +#: enterprise/views.py:363 +msgid "Please note" +msgstr "" + +#: enterprise/views.py:365 +#, python-brace-format +msgid "" +"If you decline to consent, that fact may be shared with " +"{enterprise_customer_name}." +msgstr "" + +#: enterprise/views.py:369 +msgid "" +"Any version of this Data Sharing Policy in a language other than English is " +"provided for convenience and you understand and agree that the English " +"language version will control if there is any conflict." +msgstr "" + +#: enterprise/views.py:374 +msgid "Are you aware..." +msgstr "" + +#: enterprise/views.py:375 +msgid "I decline" +msgstr "" + +#: enterprise/views.py:376 +msgid "View the data sharing policy" +msgstr "" + +#: enterprise/views.py:377 +#, python-brace-format +msgid "View the {start_link}data sharing policy{end_link}." +msgstr "" + +#: enterprise/views.py:382 +msgid "Return to Top" +msgstr "" + +#: enterprise/views.py:538 +#, python-brace-format +msgid "" +"To access this {item}, you must first consent to share your learning " +"achievements with {bold_start}{enterprise_customer_name}{bold_end}." +msgstr "" + +#: enterprise/views.py:547 +#, python-brace-format +msgid "" +"In order to start this {item} and use your discount, {bold_start}you " +"must{bold_end} consent to share your {item} data with " +"{enterprise_customer_name}." +msgstr "" + +#: enterprise/views.py:560 +#, python-brace-format +msgid "your enrollment in this {item}" +msgstr "" + +#: enterprise/views.py:561 +msgid "your learning progress" +msgstr "" + +#: enterprise/views.py:562 +msgid "course completion" +msgstr "" + +#: enterprise/views.py:1008 +msgid "Enterprise Slug Login" +msgstr "" + +#: enterprise/views.py:1121 +msgid "Select Organization" +msgstr "" + +#: enterprise/views.py:1122 +msgid "Select an organization" +msgstr "" + +#: enterprise/views.py:1275 +msgid "Instructor-Paced" +msgstr "" + +#: enterprise/views.py:1276 +msgid "Self-Paced" +msgstr "" + +#: enterprise/views.py:1462 +msgid "FREE" +msgstr "" + +#: enterprise/views.py:1464 +msgid "Not eligible for a certificate." +msgstr "" + +#: enterprise/views.py:1466 +msgid "Earn a verified certificate!" +msgstr "" + +#: enterprise/views.py:1525 +#, python-brace-format +msgid "{num_weeks}, starting on {start_date} and ending at {end_date}" +msgstr "" + +#: enterprise/views.py:1527 enterprise/views.py:1900 +msgid "{} week" +msgid_plural "{} weeks" +msgstr[0] "" +msgstr[1] "" + +#: enterprise/views.py:1587 +#, python-brace-format +msgid "" +"Discount provided by {strong_start}{enterprise_customer_name}{strong_end}" +msgstr "" + +#: enterprise/views.py:1597 enterprise/views.py:1598 +msgid "Confirm your course" +msgstr "" + +#: enterprise/views.py:1599 +msgid "Starts" +msgstr "" + +#: enterprise/views.py:1600 +msgid "View Course Details" +msgstr "" + +#: enterprise/views.py:1601 +msgid "Please select one:" +msgstr "" + +#: enterprise/views.py:1602 enterprise/views.py:2073 +msgid "Price" +msgstr "" + +#: enterprise/views.py:1604 enterprise/views.py:2076 +msgid "Level" +msgstr "" + +#: enterprise/views.py:1605 enterprise/views.py:2075 +msgid "Effort" +msgstr "" + +#: enterprise/views.py:1606 +msgid "Duration" +msgstr "" + +#: enterprise/views.py:1608 enterprise/views.py:2065 +msgid "What you'll learn" +msgstr "" + +#: enterprise/views.py:1609 enterprise/views.py:2077 +msgid "About This Course" +msgstr "" + +#: enterprise/views.py:1610 enterprise/views.py:2078 +msgid "Course Staff" +msgstr "" + +#: enterprise/views.py:2017 +#, python-brace-format +msgid "{count} Course" +msgid_plural "{count} Courses" +msgstr[0] "" +msgstr[1] "" + +#: enterprise/views.py:2024 +msgid "{}-{} hours per week, per course" +msgstr "" + +#: enterprise/views.py:2031 +msgid "{}-{} weeks per course" +msgstr "" + +#: enterprise/views.py:2038 +msgid "Purchase all unenrolled courses" +msgstr "" + +#: enterprise/views.py:2039 +msgid "enrollment" +msgstr "" + +#: enterprise/views.py:2041 +msgid "Pursue the program" +msgstr "" + +#: enterprise/views.py:2042 +msgid "program enrollment" +msgstr "" + +#: enterprise/views.py:2063 +msgid "enrolled" +msgstr "" + +#: enterprise/views.py:2064 +msgid "already enrolled, must pay for certificate" +msgstr "" + +#: enterprise/views.py:2067 +msgid "Real Career Impact" +msgstr "" + +#: enterprise/views.py:2069 +msgid "See More" +msgstr "" + +#: enterprise/views.py:2070 +msgid "See Less" +msgstr "" + +#: enterprise/views.py:2071 +msgid "Confirm Program" +msgstr "" + +#: enterprise/views.py:2072 +msgid "Program Summary" +msgstr "" + +#: enterprise/views.py:2074 +msgid "Length" +msgstr "" + +#: enterprise/views.py:2080 +msgid "Program not eligible for one-click purchase." +msgstr "" + +#: enterprise/views.py:2081 +#, python-brace-format +msgid "What is an {platform_name} {program_type}?" +msgstr "" + +#: enterprise/views.py:2085 +#, python-brace-format +msgid "What is {platform_name}?" +msgstr "" + +#: enterprise/views.py:2090 +#, python-brace-format +msgid "Presented by {organization}" +msgstr "" + +#: enterprise/views.py:2091 +#, python-brace-format +msgid "Confirm your {item}" +msgstr "" + +#: enterprise/views.py:2103 +msgid "Credit- and Certificate-eligible" +msgstr "" + +#: enterprise/views.py:2104 +msgid "Self-paced; courses can be taken in any order" +msgstr "" + +#: enterprise/views.py:2106 +#, python-brace-format +msgid "{purchase_action} for" +msgstr "" + +#: enterprise_learner_portal/api/v1/serializers.py:34 +msgid "" +"To use this EnterpriseCourseEnrollmentSerializer, this package must be " +"installed in an Open edX environment." msgstr "" diff --git a/enterprise/locale/en/LC_MESSAGES/djangojs.po b/enterprise/conf/locale/en/LC_MESSAGES/djangojs.po similarity index 100% rename from enterprise/locale/en/LC_MESSAGES/djangojs.po rename to enterprise/conf/locale/en/LC_MESSAGES/djangojs.po diff --git a/enterprise/locale/eo/LC_MESSAGES/django.po b/enterprise/conf/locale/eo/LC_MESSAGES/django.po similarity index 100% rename from enterprise/locale/eo/LC_MESSAGES/django.po rename to enterprise/conf/locale/eo/LC_MESSAGES/django.po diff --git a/enterprise/locale/eo/LC_MESSAGES/djangojs.po b/enterprise/conf/locale/eo/LC_MESSAGES/djangojs.po similarity index 100% rename from enterprise/locale/eo/LC_MESSAGES/djangojs.po rename to enterprise/conf/locale/eo/LC_MESSAGES/djangojs.po diff --git a/enterprise/locale/es_419/LC_MESSAGES/django.po b/enterprise/conf/locale/es_419/LC_MESSAGES/django.po similarity index 100% rename from enterprise/locale/es_419/LC_MESSAGES/django.po rename to enterprise/conf/locale/es_419/LC_MESSAGES/django.po diff --git a/enterprise/locale b/enterprise/locale new file mode 120000 index 0000000000..618b7e29a8 --- /dev/null +++ b/enterprise/locale @@ -0,0 +1 @@ +conf/locale \ No newline at end of file diff --git a/enterprise/locale/en/LC_MESSAGES/django.po b/enterprise/locale/en/LC_MESSAGES/django.po deleted file mode 100644 index fb95540da9..0000000000 --- a/enterprise/locale/en/LC_MESSAGES/django.po +++ /dev/null @@ -1,1593 +0,0 @@ -# SOME DESCRIPTIVE TITLE. -# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER -# This file is distributed under the same license as the PACKAGE package. -# FIRST AUTHOR , YEAR. -# -#, fuzzy -msgid "" -msgstr "" -"Project-Id-Version: PACKAGE VERSION\n" -"Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2020-11-10 03:45-0600\n" -"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" -"Last-Translator: FULL NAME \n" -"Language-Team: LANGUAGE \n" -"Language: \n" -"MIME-Version: 1.0\n" -"Content-Type: text/plain; charset=UTF-8\n" -"Content-Transfer-Encoding: 8bit\n" -"Plural-Forms: nplurals=2; plural=(n != 1);\n" - -#: enterprise/admin/__init__.py:558 -msgid "Preview (course)" -msgstr "" - -#: enterprise/admin/__init__.py:560 -msgid "" -"Preview the HTML template rendered in the context of a course enrollment." -msgstr "" - -#: enterprise/admin/__init__.py:569 -msgid "Preview (program)" -msgstr "" - -#: enterprise/admin/__init__.py:571 -msgid "" -"Preview the HTML template rendered in the context of a program enrollment." -msgstr "" - -#: enterprise/admin/forms.py:48 -msgid "To add a single learner, enter an email address or username." -msgstr "" - -#: enterprise/admin/forms.py:52 -msgid "" -"To add multiple learners, upload a .csv file that contains a column of email " -"addresses." -msgstr "" - -#: enterprise/admin/forms.py:57 -msgid "" -"The .csv file must have a column of email addresses, indicated by the " -"heading 'email' in the first row." -msgstr "" - -#: enterprise/admin/forms.py:62 -msgid "Enroll these learners in this course" -msgstr "" - -#: enterprise/admin/forms.py:63 -msgid "To enroll learners in a course, enter a course ID." -msgstr "" - -#: enterprise/admin/forms.py:66 -msgid "Course enrollment track" -msgstr "" - -#: enterprise/admin/forms.py:68 enterprise/api/v1/serializers.py:930 -msgid "Audit" -msgstr "" - -#: enterprise/admin/forms.py:69 enterprise/api/v1/serializers.py:931 -msgid "Verified" -msgstr "" - -#: enterprise/admin/forms.py:70 enterprise/api/v1/serializers.py:932 -msgid "Professional Education" -msgstr "" - -#: enterprise/admin/forms.py:71 enterprise/api/v1/serializers.py:933 -msgid "Professional Education (no ID)" -msgstr "" - -#: enterprise/admin/forms.py:72 enterprise/api/v1/serializers.py:934 -msgid "Credit" -msgstr "" - -#: enterprise/admin/forms.py:73 enterprise/api/v1/serializers.py:935 -msgid "Honor" -msgstr "" - -#: enterprise/admin/forms.py:76 -msgid "Reason for manual enrollment" -msgstr "" - -#: enterprise/admin/forms.py:77 -msgid "Salesforce Opportunity ID" -msgstr "" - -#: enterprise/admin/forms.py:79 -msgid "Discount percentage for manual enrollment" -msgstr "" - -#: enterprise/admin/forms.py:80 -msgid "Discount percentage should be from 0 to 100" -msgstr "" - -#: enterprise/admin/forms.py:95 -msgid "Notify learners of enrollment" -msgstr "" - -#: enterprise/admin/forms.py:97 -msgid "Send email" -msgstr "" - -#: enterprise/admin/forms.py:98 -msgid "Do not notify" -msgstr "" - -#: enterprise/admin/forms.py:284 -msgid "Email/Username" -msgstr "" - -#: enterprise/admin/forms.py:285 -msgid "Enter an email address or username." -msgstr "" - -#: enterprise/admin/forms.py:289 -msgid "Course" -msgstr "" - -#: enterprise/admin/forms.py:290 -msgid "Enter the course run key." -msgstr "" - -#: enterprise/admin/forms.py:397 -msgid "Preview" -msgstr "" - -#: enterprise/admin/forms.py:398 -msgid "Hold Ctrl when clicking on button to open Preview in new tab" -msgstr "" - -#: enterprise/admin/forms.py:461 -msgid "Identity Provider" -msgstr "" - -#: enterprise/admin/forms.py:485 -msgid "" -"The specified Identity Provider does not exist. For more information, " -"contact a system administrator." -msgstr "" - -#: enterprise/admin/forms.py:496 -#, python-brace-format -msgid "" -"The site for the selected identity provider ({identity_provider_site}) does " -"not match the site for this enterprise customer " -"({enterprise_customer_site}). To correct this problem, select a site that " -"has a domain of '{identity_provider_site}', or update the identity provider " -"to '{enterprise_customer_site}'." -msgstr "" - -#: enterprise/admin/forms.py:565 -#, python-brace-format -msgid "" -"These catalogs for reporting do not match enterprisecustomer " -"{enterprise_customer}: {invalid_catalogs}" -msgstr "" - -#: enterprise/admin/forms.py:579 -msgid "Enter enterprise channel worker username." -msgstr "" - -#: enterprise/admin/views.py:203 -msgid "Successfully requested the Data Sharing consent from learner." -msgstr "" - -#: enterprise/admin/views.py:421 -#, python-brace-format -msgid "Error at line {line}: {message}\n" -msgstr "" - -#: enterprise/admin/views.py:448 -#, python-brace-format -msgid "{count} new learner was added to {enterprise_customer_name}." -msgid_plural "{count} new learners were added to {enterprise_customer_name}." -msgstr[0] "" -msgstr[1] "" - -#: enterprise/admin/views.py:462 -#, python-brace-format -msgid "" -"The following learners were already associated with this Enterprise " -"Customer: {list_of_emails}" -msgstr "" - -#: enterprise/admin/views.py:472 -#, python-brace-format -msgid "" -"The following learners are already associated with another Enterprise " -"Customer. These learners were not added to {enterprise_customer_name}: " -"{list_of_emails}" -msgstr "" - -#: enterprise/admin/views.py:484 -#, python-brace-format -msgid "" -"The following duplicate email addresses were not added: {list_of_emails}" -msgstr "" - -#: enterprise/admin/views.py:527 -#, python-brace-format -msgid "{enrolled_count} learner was enrolled in {enrolled_in}." -msgid_plural "{enrolled_count} learners were enrolled in {enrolled_in}." -msgstr[0] "" -msgstr[1] "" - -#: enterprise/admin/views.py:552 -#, python-brace-format -msgid "" -"The following learners could not be enrolled in {enrolled_in}: {user_list}" -msgstr "" - -#: enterprise/admin/views.py:575 -#, python-brace-format -msgid "" -"The following learners do not have an account on {platform_name}. They have " -"not been enrolled in {enrolled_in}. When these learners create an account, " -"they will be enrolled automatically: {pending_email_list}" -msgstr "" - -#: enterprise/admin/views.py:788 -#, python-brace-format -msgid "Email {email} is not associated with Enterprise Customer {ec_name}" -msgstr "" - -#: enterprise/api/utils.py:66 -#, python-brace-format -msgid "" -"{token_email} from {token_enterprise_name} has requested " -"{token_number_codes} additional codes. Please reach out to them.\n" -"Additional Notes:\n" -"{token_notes}." -msgstr "" - -#: enterprise/api/utils.py:73 -#, python-brace-format -msgid "" -"{token_email} from {token_enterprise_name} has requested " -"{token_number_codes} additional codes. Please reach out to them." -msgstr "" - -#: enterprise/api/utils.py:79 -#, python-brace-format -msgid "" -"{token_email} from {token_enterprise_name} has requested additional codes. " -"Please reach out to them.\n" -"Additional Notes:\n" -"{token_notes}." -msgstr "" - -#: enterprise/api/utils.py:85 -#, python-brace-format -msgid "" -"{token_email} from {token_enterprise_name} has requested additional codes. " -"Please reach out to them." -msgstr "" - -#: enterprise/api/v1/serializers.py:52 -msgid "Total count of items." -msgstr "" - -#: enterprise/api/v1/serializers.py:53 -msgid "URL to fetch next page of items." -msgstr "" - -#: enterprise/api/v1/serializers.py:54 -msgid "URL to fetch previous page of items." -msgstr "" - -#: enterprise/api/v1/serializers.py:55 -msgid "List of items." -msgstr "" - -#: enterprise/api/v1/views.py:555 enterprise_learner_portal/api/v1/views.py:39 -#: enterprise_learner_portal/api/v1/views.py:76 -msgid "" -"To use this endpoint, this package must be installed in an Open edX " -"environment." -msgstr "" - -#: enterprise/api/v1/views.py:750 -#, python-brace-format -msgid "" -"[Enterprise API] CourseKey not found in the Catalog. Course: {course_key}, " -"Catalog: {catalog_id}" -msgstr "" - -#: enterprise/api/v1/views.py:778 -#, python-brace-format -msgid "" -"[Enterprise API] CourseRun not found in the Catalog. CourseRun: {course_id}, " -"Catalog: {catalog_id}" -msgstr "" - -#: enterprise/api/v1/views.py:806 -#, python-brace-format -msgid "" -"[Enterprise API] Program not found in the Catalog. Program: {program_uuid}, " -"Catalog: {catalog_id}" -msgstr "" - -#: enterprise/api/v1/views.py:976 -#, python-brace-format -msgid "Code Management - Request for Codes by {token_enterprise_name}" -msgstr "" - -#: enterprise/api/v1/views.py:1001 -#, python-brace-format -msgid "" -"[Enterprise API] Failure in sending e-mail to support. SupportEmail: " -"{token_cs_email}, UserEmail: {token_email}, EnterpriseName: " -"{token_enterprise_name}" -msgstr "" - -#: enterprise/api_client/discovery.py:52 -msgid "" -"To get a Catalog API client, this package must be installed in an Open edX " -"environment." -msgstr "" - -#: enterprise/api_client/discovery.py:84 enterprise/api_client/discovery.py:419 -#: enterprise/utils.py:1172 -msgid "" -"To get a CatalogIntegration object, this package must be installed in an " -"Open edX environment." -msgstr "" - -#: enterprise/api_client/discovery.py:89 -msgid "" -"To parse a Catalog API response, this package must be installed in an Open " -"edX environment." -msgstr "" - -#: enterprise/api_client/discovery.py:429 -msgid "The configured CatalogIntegration service user does not exist." -msgstr "" - -#: enterprise/api_client/discovery.py:431 -msgid "There is no active CatalogIntegration." -msgstr "" - -#: enterprise/api_client/ecommerce.py:40 -msgid "" -"To get a ecommerce_api_client, this package must be installed in an Open edX " -"environment." -msgstr "" - -#: enterprise/constants.py:17 -#, python-brace-format -msgid "" -"To log in using this SSO identity provider and access special course offers, " -"you must first consent to share your learning achievements with " -"{enterprise_customer_name}." -msgstr "" - -#: enterprise/constants.py:21 -#, python-brace-format -msgid "" -"In order to sign in and access special offers, you must consent to share " -"your course data with {enterprise_customer_name}." -msgstr "" - -#: enterprise/constants.py:25 -#, python-brace-format -msgid "" -"If you do not consent to share your course data, that information may be " -"shared with {enterprise_customer_name}." -msgstr "" - -#: enterprise/constants.py:28 -#, python-brace-format -msgid "Welcome to {platform_name}." -msgstr "" - -#: enterprise/constants.py:30 -#, python-brace-format -msgid "" -"You have left the {strong_start}{enterprise_customer_name}{strong_end} " -"website and are now on the {platform_name} site. {enterprise_customer_name} " -"has partnered with {platform_name} to offer you high-quality, always " -"available learning programs to help you advance your knowledge and career. " -"{line_break}Please note that {platform_name} has a different " -"{privacy_policy_link_start}Privacy Policy {privacy_policy_link_end} from " -"{enterprise_customer_name}." -msgstr "" - -#: enterprise/constants.py:80 -msgid "" -"A series of Master’s-level courses to advance your career, created by top " -"universities and recognized by companies. MicroMasters Programs are credit-" -"eligible, provide in-demand knowledge and may be applied to accelerate a " -"Master’s Degree." -msgstr "" - -#: enterprise/constants.py:86 -msgid "" -"Designed by industry leaders and top universities to enhance professional " -"skills, Professional Certificates develop the proficiency and expertise that " -"employers are looking for with specialized training and professional " -"education." -msgstr "" - -#: enterprise/constants.py:92 -msgid "" -"Created by world-renowned experts and top universities, XSeries are designed " -"to provide a deep understanding of key subjects through a series of courses. " -"Complete the series to earn a valuable XSeries Certificate that illustrates " -"your achievement." -msgstr "" - -#: enterprise/forms.py:13 -msgid "" -"You have access to multiple organizations. Select the organization that you " -"will use to sign up for courses. If you want to change organizations, sign " -"out and sign back in." -msgstr "" - -#: enterprise/forms.py:16 -msgid "Enter the organization name" -msgstr "" - -#: enterprise/forms.py:18 -msgid "" -"Have an account through your company, school, or organization? Enter your " -"organization’s name below to sign in." -msgstr "" - -#: enterprise/forms.py:21 -msgid "" -"The attempt to login with this organization name was not successful. Please " -"try again, or contact our support." -msgstr "" - -#: enterprise/forms.py:53 -msgid "Enterprise not found" -msgstr "" - -#: enterprise/forms.py:57 -msgid "Wrong Enterprise" -msgstr "" - -#: enterprise/management/commands/migrate_enterprise_catalogs.py:30 -msgid "Username of a user authorized to access the Enterprise Catalog API." -msgstr "" - -#: enterprise/management/commands/migrate_enterprise_catalogs.py:36 -msgid "Comma separated list of uuids of enterprise catalogs to migrate." -msgstr "" - -#: enterprise/management/commands/migrate_enterprise_catalogs.py:45 -#, python-brace-format -msgid "A user with the username {username} was not found." -msgstr "" - -#: enterprise/messages.py:25 -#, python-brace-format -msgid "" -"{strong_start}We could not enroll you in {em_start}{item}{em_end}." -"{strong_end} {span_start}If you have questions or concerns about sharing " -"your data, please contact your learning manager at " -"{enterprise_customer_name}, or contact {link_start}{platform_name} " -"support{link_end}.{span_end}" -msgstr "" - -#: enterprise/messages.py:56 -#, python-brace-format -msgid "" -"{strong_start}We could not gather price information for {em_start}{item}" -"{em_end}.{strong_end} {span_start}If you continue to have these issues, " -"please contact {link_start}{platform_name} support{link_end}.{span_end}" -msgstr "" - -#: enterprise/messages.py:86 -#, python-brace-format -msgid "" -"{strong_start}Something happened.{strong_end} {span_start}This {item} is not " -"currently open to new learners. Please start over and select a different " -"{item}.{span_end}" -msgstr "" - -#: enterprise/messages.py:112 -#, python-brace-format -msgid "" -"{strong_start}Something happened.{strong_end} {span_start}Please reach out " -"to your learning administrator with the following error code and they will " -"be able to help you out.{span_end}{span_start}Error code: {error_code}" -"{span_end}{span_start}Username: {username}{span_end}" -msgstr "" - -#: enterprise/models.py:112 -msgid "Enterprise Customer Type" -msgstr "" - -#: enterprise/models.py:113 -msgid "Enterprise Customer Types" -msgstr "" - -#: enterprise/models.py:120 enterprise/models.py:264 -msgid "Specifies enterprise customer type." -msgstr "" - -#: enterprise/models.py:169 enterprise/models.py:2071 -msgid "Enterprise Customer" -msgstr "" - -#: enterprise/models.py:170 -msgid "Enterprise Customers" -msgstr "" - -#: enterprise/models.py:177 -msgid "Enterprise Customer name." -msgstr "" - -#: enterprise/models.py:190 -msgid "" -"Specify whether display the course original price on enterprise course " -"landing page or not." -msgstr "" - -#: enterprise/models.py:210 -msgid "" -"Specifies whether data sharing consent is enabled or disabled for learners " -"signing in through this enterprise customer. If disabled, consent will not " -"be requested, and eligible data will not be shared." -msgstr "" - -#: enterprise/models.py:223 -msgid "" -"Specifies whether data sharing consent is optional, is required at login, or " -"is required at enrollment." -msgstr "" - -#: enterprise/models.py:231 -msgid "" -"Specifies whether the audit track enrollment option will be displayed in the " -"course enrollment view." -msgstr "" - -#: enterprise/models.py:238 -msgid "" -"Specifies whether to pass-back audit track enrollment data through an " -"integrated channel." -msgstr "" - -#: enterprise/models.py:245 -msgid "" -"Specifies whether to replace the display of potentially sensitive SSO " -"usernames with a more generic name, e.g. EnterpriseLearner." -msgstr "" - -#: enterprise/models.py:256 -msgid "" -"Specifies whether the customer is able to assign learners to cohorts upon " -"enrollment." -msgstr "" - -#: enterprise/models.py:261 -msgid "Customer Type" -msgstr "" - -#: enterprise/models.py:270 -msgid "" -"Specifies whether to allow access to the code management screen in the admin " -"portal." -msgstr "" - -#: enterprise/models.py:275 -msgid "" -"Specifies whether to allow access to the reporting configurations screen in " -"the admin portal." -msgstr "" - -#: enterprise/models.py:280 -msgid "" -"Specifies whether to allow access to the subscription management screen in " -"the admin portal." -msgstr "" - -#: enterprise/models.py:285 -msgid "" -"Specifies whether to allow access to the saml configuration screen in the " -"admin portal" -msgstr "" - -#: enterprise/models.py:290 -msgid "" -"Specifies whether the enterprise learner portal site should be made known to " -"the learner." -msgstr "" - -#: enterprise/models.py:296 -msgid "" -"Specifies whether a learner for an integrated channel customer can navigate " -"the enterprise learner portal site using the main menu." -msgstr "" - -#: enterprise/models.py:303 -msgid "" -"Specifies whether to allow access to the analytics screen in the admin " -"portal." -msgstr "" - -#: enterprise/models.py:308 -msgid "" -"Specifies whether the learner should be able to login through enterprise's " -"slug login" -msgstr "" - -#: enterprise/models.py:314 -msgid "Email to be displayed as public point of contact for enterprise." -msgstr "" - -#: enterprise/models.py:323 -msgid "" -"Specifies the discount percent used for enrollments from the enrollment API " -"where capturing the discount per order is not possible. This is passed to " -"ecommerce when creating orders for financial data reporting." -msgstr "" - -#: enterprise/models.py:335 -msgid "" -"Specifies the default language for all the learners of this enterprise " -"customer." -msgstr "" - -#: enterprise/models.py:573 -msgid "" -"Course details were not found for course key {} - Course Catalog API " -"returned nothing. Proceeding with enrollment, but notifications won't be sent" -msgstr "" - -#: enterprise/models.py:767 -msgid "Enterprise Customer Learner" -msgstr "" - -#: enterprise/models.py:768 -msgid "Enterprise Customer Learners" -msgstr "" - -#: enterprise/models.py:1208 -msgid "Logo images must be in .png format." -msgstr "" - -#: enterprise/models.py:1235 -msgid "Branding Configuration" -msgstr "" - -#: enterprise/models.py:1236 -msgid "Branding Configurations" -msgstr "" - -#: enterprise/models.py:1391 -msgid "The enterprise learner to which this enrollment is attached." -msgstr "" - -#: enterprise/models.py:1398 -msgid "The ID of the course in which the learner was enrolled." -msgstr "" - -#: enterprise/models.py:1405 -msgid "" -"Specifies whether a user marked this course as saved for later in the " -"learner portal." -msgstr "" - -#: enterprise/models.py:1521 -msgid "The course enrollment the associated license is for." -msgstr "" - -#: enterprise/models.py:1528 -msgid "" -"Whether the licensed enterprise course enrollment is revoked, e.g., when a " -"user's license is revoked." -msgstr "" - -#: enterprise/models.py:1583 -msgid "" -"Query parameters which will be used to filter the discovery service's search/" -"all endpoint results, specified as a JSON object. An empty JSON object means " -"that all available content items will be included in the catalog." -msgstr "" - -#: enterprise/models.py:1591 -msgid "Enterprise Catalog Query" -msgstr "" - -#: enterprise/models.py:1592 -msgid "Enterprise Catalog Queries" -msgstr "" - -#: enterprise/models.py:1650 -msgid "" -"Query parameters which will be used to filter the discovery service's search/" -"all endpoint results, specified as a Json object. An empty Json object means " -"that all available content items will be included in the catalog." -msgstr "" - -#: enterprise/models.py:1658 -msgid "" -"Ordered list of enrollment modes which can be displayed to learners for " -"course runs in this catalog." -msgstr "" - -#: enterprise/models.py:1664 -msgid "" -"Specifies whether courses should be published with direct-to-audit " -"enrollment URLs." -msgstr "" - -#: enterprise/models.py:1671 -msgid "Enterprise Customer Catalog" -msgstr "" - -#: enterprise/models.py:1672 enterprise/models.py:2185 -msgid "Enterprise Customer Catalogs" -msgstr "" - -#: enterprise/models.py:1940 -msgid "" -"Fill in a standard Django template that, when rendered, produces the email " -"you want sent to newly-enrolled Enterprise Customer learners. The following " -"variables may be available:\n" -"
  • user_name: A human-readable name for the person being emailed. Be " -"sure to handle the case where this is not defined, as it may be missing in " -"some cases. It may also be a username, if the learner hasn't configured " -"their \"real\" name in the system.
  • organization_name: The name " -"of the organization sponsoring the enrollment.
  • enrolled_in: " -"Details of the course or program that was enrolled in. It may contain: " -"
    • name: The name of the enrollable item (e.g., \"Demo Course\").
    • url: A link to the homepage of the enrolled-in item.
    • branding: A custom branding name for the enrolled-in item. " -"For example, the branding of a MicroMasters program would be \"MicroMasters" -"\".
    • start: The date the enrolled-in item becomes available. " -"Render this to text using the Django `date` template filter (see the " -"Django documentation).
    • type: Whether the enrolled-in item is a " -"course, a program, or something else.
" -msgstr "" - -#: enterprise/models.py:1958 -#, python-brace-format -msgid "" -"Enter a string that can be used to generate a dynamic subject line for " -"notification emails. The placeholder {course_name} will be replaced with the " -"name of the course or program that was enrolled in." -msgstr "" - -#: enterprise/models.py:2073 -msgid "Active" -msgstr "" - -#: enterprise/models.py:2074 -msgid "Include Date" -msgstr "" - -#: enterprise/models.py:2075 -msgid "Include date in the report file name" -msgstr "" - -#: enterprise/models.py:2081 -msgid "Delivery Method" -msgstr "" - -#: enterprise/models.py:2082 -msgid "The method in which the data should be sent." -msgstr "" - -#: enterprise/models.py:2087 -msgid "PGP Encryption Key" -msgstr "" - -#: enterprise/models.py:2088 -msgid "The key for encryption, if PGP encrypted file is required." -msgstr "" - -#: enterprise/models.py:2095 -msgid "Data Type" -msgstr "" - -#: enterprise/models.py:2096 -msgid "The type of data this report should contain." -msgstr "" - -#: enterprise/models.py:2103 -msgid "Report Type" -msgstr "" - -#: enterprise/models.py:2104 -msgid "The type this report should be sent as, e.g. CSV." -msgstr "" - -#: enterprise/models.py:2108 -msgid "Email" -msgstr "" - -#: enterprise/models.py:2109 -msgid "The email(s), one per line, where the report should be sent." -msgstr "" - -#: enterprise/models.py:2116 -msgid "Frequency" -msgstr "" - -#: enterprise/models.py:2117 -msgid "" -"The frequency interval (daily, weekly, or monthly) that the report should be " -"sent." -msgstr "" - -#: enterprise/models.py:2122 -msgid "Day of Month" -msgstr "" - -#: enterprise/models.py:2123 -msgid "" -"The day of the month to send the report. This field is required and only " -"valid when the frequency is monthly." -msgstr "" - -#: enterprise/models.py:2131 -msgid "Day of Week" -msgstr "" - -#: enterprise/models.py:2132 -msgid "" -"The day of the week to send the report. This field is required and only " -"valid when the frequency is weekly." -msgstr "" - -#: enterprise/models.py:2136 -msgid "Hour of Day" -msgstr "" - -#: enterprise/models.py:2137 -msgid "" -"The hour of the day to send the report, in Eastern Standard Time (EST). This " -"is required for all frequency settings." -msgstr "" - -#: enterprise/models.py:2145 -msgid "" -"This password will be used to secure the zip file. It will be encrypted when " -"stored in the database." -msgstr "" - -#: enterprise/models.py:2152 -msgid "SFTP Host name" -msgstr "" - -#: enterprise/models.py:2153 -msgid "If the delivery method is sftp, the host to deliver the report to." -msgstr "" - -#: enterprise/models.py:2158 -msgid "SFTP Port" -msgstr "" - -#: enterprise/models.py:2159 -msgid "If the delivery method is sftp, the port on the host to connect to." -msgstr "" - -#: enterprise/models.py:2166 -msgid "SFTP username" -msgstr "" - -#: enterprise/models.py:2167 -msgid "" -"If the delivery method is sftp, the username to use to securely access the " -"host." -msgstr "" - -#: enterprise/models.py:2173 -msgid "" -"If the delivery method is sftp, the password to use to securely access the " -"host. The password will be encrypted when stored in the database." -msgstr "" - -#: enterprise/models.py:2180 -msgid "SFTP file path" -msgstr "" - -#: enterprise/models.py:2181 -msgid "" -"If the delivery method is sftp, the path on the host to deliver the report " -"to." -msgstr "" - -#: enterprise/models.py:2264 -msgid "Day of week must be set if the frequency is weekly." -msgstr "" - -#: enterprise/models.py:2268 -msgid "Day of month must be set if the frequency is monthly." -msgstr "" - -#: enterprise/models.py:2271 -msgid "Frequency must be set to either daily, weekly, or monthly." -msgstr "" - -#: enterprise/models.py:2277 -msgid "Email(s) must be set if the delivery method is email." -msgstr "" - -#: enterprise/models.py:2281 -msgid "Decrypted password must be set if the delivery method is email." -msgstr "" - -#: enterprise/models.py:2285 -msgid "SFTP Hostname must be set if the delivery method is sftp." -msgstr "" - -#: enterprise/models.py:2287 -msgid "SFTP username must be set if the delivery method is sftp." -msgstr "" - -#: enterprise/models.py:2289 -msgid "SFTP File Path must be set if the delivery method is sftp." -msgstr "" - -#: enterprise/models.py:2292 -msgid "Decrypted SFTP password must be set if the delivery method is SFTP." -msgstr "" - -#: enterprise/models.py:2529 -msgid "User id in the third party analytics system." -msgstr "" - -#: enterprise/templates/enterprise/_data_sharing_decline_modal.html:7 -#: enterprise/views.py:1607 enterprise/views.py:2079 -msgid "Close" -msgstr "" - -#: enterprise/templates/enterprise/admin/clear_learners_data_sharing_consent.html:18 -#: enterprise/templates/enterprise/admin/manage_learners.html:47 -#: enterprise/templates/enterprise/admin/transmit_courses_metadata.html:18 -msgid "Home" -msgstr "" - -#: enterprise/templates/enterprise/admin/clear_learners_data_sharing_consent.html:33 -#: enterprise/templates/enterprise/admin/clear_learners_data_sharing_consent.html:40 -msgid "Clear Data Sharing Consent" -msgstr "" - -#: enterprise/templates/enterprise/admin/manage_learners.html:65 -msgid "Manage Learners" -msgstr "" - -#: enterprise/templates/enterprise/admin/manage_learners.html:68 -msgid "Search Term: " -msgstr "" - -#: enterprise/templates/enterprise/admin/manage_learners.html:87 -msgid "Search email address or username" -msgstr "" - -#: enterprise/templates/enterprise/admin/manage_learners.html:98 -msgid "User Email" -msgstr "" - -#: enterprise/templates/enterprise/admin/manage_learners.html:99 -msgid "Username" -msgstr "" - -#: enterprise/templates/enterprise/admin/manage_learners.html:100 -msgid "Linked Date" -msgstr "" - -#: enterprise/templates/enterprise/admin/manage_learners.html:101 -#: enterprise/templates/enterprise/admin/manage_learners.html:141 -msgid "Enroll" -msgstr "" - -#: enterprise/templates/enterprise/admin/manage_learners.html:139 -msgid "Learner Email" -msgstr "" - -#: enterprise/templates/enterprise/admin/manage_learners.html:140 -msgid "Date Added" -msgstr "" - -#: enterprise/templates/enterprise/admin/manage_learners.html:162 -msgid "Link learners" -msgstr "" - -#: enterprise/templates/enterprise/admin/transmit_courses_metadata.html:33 -#: enterprise/templates/enterprise/admin/transmit_courses_metadata.html:40 -msgid "Transmit Courses Metadata" -msgstr "" - -#: enterprise/templates/enterprise/emails/user_notification.html:3 -#: enterprise/templates/enterprise/emails/user_notification.txt:1 -#, python-format -msgid "Dear %(user_name)s," -msgstr "" - -#: enterprise/templates/enterprise/emails/user_notification.html:3 -#: enterprise/templates/enterprise/emails/user_notification.txt:1 -msgid "Hi!" -msgstr "" - -#: enterprise/templates/enterprise/emails/user_notification.html:5 -#, python-format -msgid "" -"You have been enrolled in %(program_name)s, " -"a %(program_branding)s program offered by %(organization_name)s. This " -"program begins %(start_date)s. For more information, see %(program_name)s." -msgstr "" - -#: enterprise/templates/enterprise/emails/user_notification.html:6 -#, python-format -msgid "" -"You have been enrolled in %(course_name)s, a " -"course offered by %(organization_name)s. This course begins %(start_date)s. " -"For more information, see %(course_name)s." -msgstr "" - -#: enterprise/templates/enterprise/emails/user_notification.html:8 -#, python-format -msgid "" -"

\n" -"Thanks,\n" -"

\n" -"

\n" -"The %(enrolled_in_name)s team\n" -"

" -msgstr "" - -#: enterprise/templates/enterprise/emails/user_notification.txt:3 -#, python-format -msgid "" -"You have been enrolled in %(program_name)s, a %(program_branding)s program " -"offered by %(organization_name)s. This program begins %(start_date)s. For " -"more information, see the following link:\n" -"\n" -"%(program_url)s" -msgstr "" - -#: enterprise/templates/enterprise/emails/user_notification.txt:6 -#, python-format -msgid "" -"You have been enrolled in %(course_name)s, a course offered by " -"%(organization_name)s. This course begins %(start_date)s. For more " -"information, see the following link:\n" -"\n" -"%(course_url)s" -msgstr "" - -#: enterprise/templates/enterprise/emails/user_notification.txt:9 -#, python-format -msgid "" -"\n" -"Thanks,\n" -"\n" -"The %(enrolled_in_name)s team" -msgstr "" - -#: enterprise/templates/enterprise/enterprise_customer_login_page.html:43 -msgid "Login" -msgstr "" - -#: enterprise/templates/enterprise/enterprise_customer_select_form.html:50 -#: enterprise/views.py:1603 -msgid "Continue" -msgstr "" - -#: enterprise/utils.py:107 -msgid "" -"Either \"Email or Username\" or \"CSV bulk upload\" must be specified, but " -"both were." -msgstr "" - -#: enterprise/utils.py:110 -msgid "Error: Learners could not be added. Correct the following errors." -msgstr "" - -#: enterprise/utils.py:112 -#, python-brace-format -msgid "Enrollment track {course_mode} is not available for course {course_id}." -msgstr "" - -#: enterprise/utils.py:114 -msgid "Select a course enrollment track for the given course." -msgstr "" - -#: enterprise/utils.py:116 -#, python-brace-format -msgid "" -"Could not retrieve details for the course ID {course_id}. Specify a valid ID." -msgstr "" - -#: enterprise/utils.py:119 -#, python-brace-format -msgid "{argument} does not appear to be a valid email address." -msgstr "" - -#: enterprise/utils.py:121 -#, python-brace-format -msgid "" -"{argument} does not appear to be a valid email address or known username" -msgstr "" - -#: enterprise/utils.py:124 -#, python-brace-format -msgid "" -"Expected a CSV file with [{expected_columns}] columns, but found " -"[{actual_columns}] columns instead." -msgstr "" - -#: enterprise/utils.py:128 -msgid "Reason field is required but was not filled." -msgstr "" - -#: enterprise/utils.py:131 -msgid "" -"Either \"Email or Username\" or \"CSV bulk upload\" must be specified, but " -"neither were." -msgstr "" - -#: enterprise/utils.py:134 -#, python-brace-format -msgid "" -"Pending user with email address {user_email} is already linked with another " -"Enterprise {ec_name}, you will be able to add the learner once the user " -"creates account or other enterprise deletes the pending user" -msgstr "" - -#: enterprise/utils.py:138 -#, python-brace-format -msgid "" -"User with email address {email} is already registered with Enterprise " -"Customer {ec_name}" -msgstr "" - -#: enterprise/utils.py:140 -msgid "User is not linked with Enterprise Customer" -msgstr "" - -#: enterprise/utils.py:141 -#, python-brace-format -msgid "User with email address {email} doesn't exist." -msgstr "" - -#: enterprise/utils.py:142 -msgid "Course doesn't exist in Enterprise Customer's Catalog" -msgstr "" - -#: enterprise/utils.py:144 -#, python-brace-format -msgid "" -"Enterprise channel worker user with the username " -"\"{channel_worker_username}\" was not found." -msgstr "" - -#: enterprise/utils.py:147 -msgid "" -"Unable to parse CSV file. Please make sure it is a CSV 'utf-8' encoded file." -msgstr "" - -#: enterprise/utils.py:150 -msgid "Discount percentage should be from 0 to 100." -msgstr "" - -#: enterprise/utils.py:358 -#, python-brace-format -msgid "You've been enrolled in {course_name}!" -msgstr "" - -#: enterprise/utils.py:415 -msgid "`user` must have one of either `email` or `user_email`." -msgstr "" - -#: enterprise/validators.py:29 -msgid "Value entered is not a valid hex color code." -msgstr "" - -#: enterprise/validators.py:40 -msgid "Unsupported file extension." -msgstr "" - -#: enterprise/validators.py:51 -#, python-format -msgid "The logo image file size must be less than or equal to %s KB." -msgstr "" - -#: enterprise/views.py:120 -msgid "" -"The following method from the Open edX platform is necessary for this view " -"but isn't available." -msgstr "" - -#: enterprise/views.py:142 -#, python-brace-format -msgid "{platform_name} home page" -msgstr "" - -#: enterprise/views.py:287 -msgid "Data sharing consent required" -msgstr "" - -#: enterprise/views.py:288 -msgid "Consent to share your data" -msgstr "" - -#: enterprise/views.py:290 -#, python-brace-format -msgid "" -"Per the {start_link}Data Sharing Policy{end_link}, {bold_start}" -"{enterprise_customer_name}{bold_end} would like to know about:" -msgstr "" - -#: enterprise/views.py:301 -#, python-brace-format -msgid "" -"I agree to allow {platform_name} to share data about my enrollment, " -"completion and performance in all {platform_name} courses and programs where " -"my enrollment is sponsored by {enterprise_customer_name}." -msgstr "" - -#: enterprise/views.py:307 -msgid "Yes, continue" -msgstr "" - -#: enterprise/views.py:308 -msgid "No, take me back." -msgstr "" - -#: enterprise/views.py:309 -msgid "Data Sharing Policy" -msgstr "" - -#: enterprise/views.py:311 -#, python-brace-format -msgid "" -"Enrollment, completion, and performance data that may be shared with " -"{enterprise_customer_name} (or its designee) for these courses and programs " -"are limited to the following:" -msgstr "" - -#: enterprise/views.py:318 -#, python-brace-format -msgid "" -"My email address for my {platform_name} account, and the date when I created " -"my {platform_name} account" -msgstr "" - -#: enterprise/views.py:324 -#, python-brace-format -msgid "" -"My {platform_name} ID, and if I log in via single sign-on, my " -"{enterprise_customer_name} SSO user-ID" -msgstr "" - -#: enterprise/views.py:330 -#, python-brace-format -msgid "My {platform_name} username" -msgstr "" - -#: enterprise/views.py:331 -msgid "My country or region of residence" -msgstr "" - -#: enterprise/views.py:333 -msgid "" -"What courses and/or programs I've enrolled in or unenrolled from, what track " -"I enrolled in (audit or verified) and the date when I enrolled in each " -"course or program" -msgstr "" - -#: enterprise/views.py:337 -msgid "" -"Information about each course or program I've enrolled in, including its " -"duration and level of effort required" -msgstr "" - -#: enterprise/views.py:341 -msgid "" -"Whether I completed specific parts of each course or program (for example, " -"whether I watched a given video or attempted or completed a given homework " -"assignment)" -msgstr "" - -#: enterprise/views.py:345 -msgid "" -"My overall percentage completion of each course or program on a periodic " -"basis, including the total time spent in each course or program, the date " -"when I last logged in to each course or program and how much of the course " -"or program content I have consumed" -msgstr "" - -#: enterprise/views.py:349 -msgid "" -"My performance in each course or program, including, for example, my score " -"on each assignment and current average of correct answers out of total " -"attempted answers" -msgstr "" - -#: enterprise/views.py:351 -msgid "" -"My final grade in each course or program, and the date when I completed each " -"course or program" -msgstr "" - -#: enterprise/views.py:352 -msgid "Whether I received a certificate in each course or program" -msgstr "" - -#: enterprise/views.py:355 -#, python-brace-format -msgid "" -"My permission applies only to data from courses or programs that are " -"sponsored by {enterprise_customer_name}, and not to data from any " -"{platform_name} courses or programs that I take on my own. I understand that " -"I may withdraw my permission only by fully unenrolling from any courses or " -"programs that are sponsored by {enterprise_customer_name}." -msgstr "" - -#: enterprise/views.py:363 -msgid "Please note" -msgstr "" - -#: enterprise/views.py:365 -#, python-brace-format -msgid "" -"If you decline to consent, that fact may be shared with " -"{enterprise_customer_name}." -msgstr "" - -#: enterprise/views.py:369 -msgid "" -"Any version of this Data Sharing Policy in a language other than English is " -"provided for convenience and you understand and agree that the English " -"language version will control if there is any conflict." -msgstr "" - -#: enterprise/views.py:374 -msgid "Are you aware..." -msgstr "" - -#: enterprise/views.py:375 -msgid "I decline" -msgstr "" - -#: enterprise/views.py:376 -msgid "View the data sharing policy" -msgstr "" - -#: enterprise/views.py:377 -#, python-brace-format -msgid "View the {start_link}data sharing policy{end_link}." -msgstr "" - -#: enterprise/views.py:382 -msgid "Return to Top" -msgstr "" - -#: enterprise/views.py:538 -#, python-brace-format -msgid "" -"To access this {item}, you must first consent to share your learning " -"achievements with {bold_start}{enterprise_customer_name}{bold_end}." -msgstr "" - -#: enterprise/views.py:547 -#, python-brace-format -msgid "" -"In order to start this {item} and use your discount, {bold_start}you " -"must{bold_end} consent to share your {item} data with " -"{enterprise_customer_name}." -msgstr "" - -#: enterprise/views.py:560 -#, python-brace-format -msgid "your enrollment in this {item}" -msgstr "" - -#: enterprise/views.py:561 -msgid "your learning progress" -msgstr "" - -#: enterprise/views.py:562 -msgid "course completion" -msgstr "" - -#: enterprise/views.py:1008 -msgid "Enterprise Slug Login" -msgstr "" - -#: enterprise/views.py:1121 -msgid "Select Organization" -msgstr "" - -#: enterprise/views.py:1122 -msgid "Select an organization" -msgstr "" - -#: enterprise/views.py:1275 -msgid "Instructor-Paced" -msgstr "" - -#: enterprise/views.py:1276 -msgid "Self-Paced" -msgstr "" - -#: enterprise/views.py:1462 -msgid "FREE" -msgstr "" - -#: enterprise/views.py:1464 -msgid "Not eligible for a certificate." -msgstr "" - -#: enterprise/views.py:1466 -msgid "Earn a verified certificate!" -msgstr "" - -#: enterprise/views.py:1525 -#, python-brace-format -msgid "{num_weeks}, starting on {start_date} and ending at {end_date}" -msgstr "" - -#: enterprise/views.py:1527 enterprise/views.py:1900 -msgid "{} week" -msgid_plural "{} weeks" -msgstr[0] "" -msgstr[1] "" - -#: enterprise/views.py:1587 -#, python-brace-format -msgid "" -"Discount provided by {strong_start}{enterprise_customer_name}{strong_end}" -msgstr "" - -#: enterprise/views.py:1597 enterprise/views.py:1598 -msgid "Confirm your course" -msgstr "" - -#: enterprise/views.py:1599 -msgid "Starts" -msgstr "" - -#: enterprise/views.py:1600 -msgid "View Course Details" -msgstr "" - -#: enterprise/views.py:1601 -msgid "Please select one:" -msgstr "" - -#: enterprise/views.py:1602 enterprise/views.py:2073 -msgid "Price" -msgstr "" - -#: enterprise/views.py:1604 enterprise/views.py:2076 -msgid "Level" -msgstr "" - -#: enterprise/views.py:1605 enterprise/views.py:2075 -msgid "Effort" -msgstr "" - -#: enterprise/views.py:1606 -msgid "Duration" -msgstr "" - -#: enterprise/views.py:1608 enterprise/views.py:2065 -msgid "What you'll learn" -msgstr "" - -#: enterprise/views.py:1609 enterprise/views.py:2077 -msgid "About This Course" -msgstr "" - -#: enterprise/views.py:1610 enterprise/views.py:2078 -msgid "Course Staff" -msgstr "" - -#: enterprise/views.py:2017 -#, python-brace-format -msgid "{count} Course" -msgid_plural "{count} Courses" -msgstr[0] "" -msgstr[1] "" - -#: enterprise/views.py:2024 -msgid "{}-{} hours per week, per course" -msgstr "" - -#: enterprise/views.py:2031 -msgid "{}-{} weeks per course" -msgstr "" - -#: enterprise/views.py:2038 -msgid "Purchase all unenrolled courses" -msgstr "" - -#: enterprise/views.py:2039 -msgid "enrollment" -msgstr "" - -#: enterprise/views.py:2041 -msgid "Pursue the program" -msgstr "" - -#: enterprise/views.py:2042 -msgid "program enrollment" -msgstr "" - -#: enterprise/views.py:2063 -msgid "enrolled" -msgstr "" - -#: enterprise/views.py:2064 -msgid "already enrolled, must pay for certificate" -msgstr "" - -#: enterprise/views.py:2067 -msgid "Real Career Impact" -msgstr "" - -#: enterprise/views.py:2069 -msgid "See More" -msgstr "" - -#: enterprise/views.py:2070 -msgid "See Less" -msgstr "" - -#: enterprise/views.py:2071 -msgid "Confirm Program" -msgstr "" - -#: enterprise/views.py:2072 -msgid "Program Summary" -msgstr "" - -#: enterprise/views.py:2074 -msgid "Length" -msgstr "" - -#: enterprise/views.py:2080 -msgid "Program not eligible for one-click purchase." -msgstr "" - -#: enterprise/views.py:2081 -#, python-brace-format -msgid "What is an {platform_name} {program_type}?" -msgstr "" - -#: enterprise/views.py:2085 -#, python-brace-format -msgid "What is {platform_name}?" -msgstr "" - -#: enterprise/views.py:2090 -#, python-brace-format -msgid "Presented by {organization}" -msgstr "" - -#: enterprise/views.py:2091 -#, python-brace-format -msgid "Confirm your {item}" -msgstr "" - -#: enterprise/views.py:2103 -msgid "Credit- and Certificate-eligible" -msgstr "" - -#: enterprise/views.py:2104 -msgid "Self-paced; courses can be taken in any order" -msgstr "" - -#: enterprise/views.py:2106 -#, python-brace-format -msgid "{purchase_action} for" -msgstr "" - -#: enterprise_learner_portal/api/v1/serializers.py:34 -msgid "" -"To use this EnterpriseCourseEnrollmentSerializer, this package must be " -"installed in an Open edX environment." -msgstr "" diff --git a/enterprise/locale/es_419/LC_MESSAGES/django.mo b/enterprise/locale/es_419/LC_MESSAGES/django.mo deleted file mode 100644 index 50366818069b3fedbb399e89ea70e402758890ee..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 7727 zcmbuEO>87b6~_xmNMgS7Mfir=Tw)8)ECB(*$wHj9H;k}%E!$ZX4ypR#9j~#aNq#qz!46J10W$l0*N3+;`ge%J?-(>abUFe zb5B=Sy^sIPMk-tUW zh5S46J;%I*OC80#>l5%uhc1|x<#oz;$GxukzYb8 zboev!JN)^VTa|j8>o2oe$n~G*lzJC({S8Xpfpo}g$g4>Hsh9ZmCgja;RO*Y!yO3hn zv&au2e}w!V@~_C}kY8o-67s`uE}t7AW#7*s-;4YT@*d<5kdL6pFOV;DeH+f;Pjw56 zTgZEm^T?IzdWifL*WX0S`pb8g{uto2O|Dbqw~;?bik`i@mD0$+A-{mU{jEw_DIKT|#~r86n?-yaQ*6KTaW^MxI7We1C#` z6!{P24dlmglKA_d$d4euK~UwnUn4n6{SGPq_!p9`)!kg6hT?cdDE-+QDKU_b_(s+d zF2B#)k*D|(oiN2?1L0%Imi{9c1Rfq&@&(Wmc=!)6Q19bMVj=OYKe*NFB|6GSV2jHS zd<3_^asPvhm0FJX^o~t4m&7{h>3rZay<&2sw+1G4abIsFkqh>;i?z{_iTj1=Tb<+s zo3hx%dNuAxE*of`8>PvP3vH--NveY+-m$T>abUHHL%o+2x`;!L%H`7k^iykPmz-aA zf2aLZYQ1({6v=*hnnc=(k~Xz+ph0=Q@1bGdR?B=TrVB8hBf|f zsza<(GfXVMlZ9e7+xlly`$XYV8>-cq)1x8#Epl@>ifoS07QG=On_}|Nh^?}HY17c` z>8>r04)w0f2l^_H+jNw=%w7tLEKi0uy%d|Fy{6~!naeXB+RXK1YZts1F*UQCSEQL0 zdqZ(@KQ%)xFeq|Em*a3O6c6^$Fxf@@CDqZU^lqY!e5tRFB9n`j)p`3)@d}%6QsjEL zr|nEL4_PU2ASlzD&Qx^QhwH%J61;4LixXU{yL-ntrj03~4I&p?)#>>FSC?XS6{f|~ z<2@5##H(>TyMy}_SbCJ?CKW8A-BtWLyyopLhyb_(IWEIM0l?L65`~vQa++Tg$PI~Q z`JIi!YqeHWz^k_O{(c2Ch*};|f-zkN$C_YJApwnR9O%w{JGP1%u5=*M#X(er0%DJ+ zA~oeMfG4s$Hj+HIJ+{yFKDyT>kuWXYlf6^hulOJZjiKbQVuc+V7iklQe4BZA64Yr3 zn4t2JfK@<=y;g&PSa)=gTCPF?+7ZvmIXxGfNQ3f_ERXeU!^_M&bU>Mz95MST**<|+ z1iWNzKt2keO%mEWXSSy$67rBsLKo<+$z0ZSwy2UPIhyBgNZOBtdX7b4^I@n>^rk_By&}TKA}(22QrO#B*17-R*0y+d z+N#b<=>iICNAM?@-El@|93SCELfeu;hsMX0udS^M8lp^Fot0UK$StXJ>U;uMi?c|P zt_iNR)rRjRtiI~$^b$mW<=V>O4urGA^5#MMJzA#*A@3$d1p7J81wdC~vd5F^lG=!@ zf!~S0Rhu?1(pXk(CnL2@}i_p3BY$@>rGbLC{)|xj|9&$UCrqcDC4HrBoOp{&nnb|almX@bd4yf3f6Cl-q zT?0L$^1=udve{fcPkGb(j79aSw%Rp$Fc7BEeMfr=&_kPux)T!OVsc=;Za}BDo1|Ct zgzFX>8bWTC{bb+3rn#snRqsf0RgYV0hfuVO3#-al)W^bk%J&8X4>I2p z0#vn;CYPbvRwry-EPMJTy|H}uQ_GL8>WizJTb&E%=Qiz#%HLYg`YvoeQuMRdcCw`B zHZHU_?T!<|ZIOL;Nk6{2yxCg6&{|p6pS-ZN-g*CLE}UPTTcb*~wo{mzb}~tq^ts2@ z*7W)1^;P}Q>iTkL?cwE>mCe< z4{R{tVJC&kb)i~7&FD}FE~?Q98m8opZ;)d-qZG3N*Cr@z8k)@S9GQut99*4QA0&FG zslUy0sZ08B33GG!rwJ>8y~gD1;_V7D_kZ*(B4nIvIjmUQT6( ztmY0L_mi1nccy0XdLK*ynI*N#gQ8g#M!LLM8ZzcMTxt$gXF*S;>y#g|q)V4GbSL)# zC*?_qzR^t8MXBlLz(cZJZX(b(bsWZu>BA&|H^f@Fyu_T2+^ubO-@+`DqztK39`bl7F zz#PK8cUw6v9*?B9IvUD>IM1U=lV=r}1R71>!y3Xg9`T~QZ_xLkg%Y8VTn&7NyqJ0v ziEcLQ!52>@e%PZZhY4+<1I6R(R8T+bRV-Hzd(Oy$kk=kTxgopw4j!;Zut>^!7gT%z zBD^zwMoz&;98Z$MJkXsOQ?lV+d1{uI_^lr2{?y}M&wz}Ua$0Iqj)p9q>98ZtlhE3;R2awVXgxYc_<5gVw7P{q%velR0a~~;L8KWqa@7+|4p*( z0F!ad@l+;I6aG4Mf`Fo|3eBzkw{>SR1#@4UcooQcg1etjZC;4_qF)xTA}#|i1tFpH z>LbP3mF=FWCmh9{19l(JOp9fMBAvo0$$T%8T50bIUE>hu#3GjSYkr0e`c$nzUVKL7 z(q=&xaMtZqS+FN{r%^Rt(bt{_Ix754OGM8{m8)AT=3=1OGduidG)C!*Nkl`; zOXt*eO<8GWI+howPY^Vy7eV-!-Kp$!k!S3e}92%cmr zex)AWJ5Mnm9rFB1AD(!=&3_Aco|WFHZU#>`6Q9={&7uwQR@nj_dva>_uP~;h*N`Sn z-j6gNT-5Wt5Y2from8W>6p@A51(s*aJnW9I@6+%!@kgFjo>^p4hN{|JKE(9nYXz@X zrPnqKm5wwbt}6wsz}xkF&1GF-W|+1=U4z}}s$HF~`~WlYzt?PnWV!7-F-vK|m Date: Fri, 17 Nov 2023 22:46:46 +0500 Subject: [PATCH 037/164] feat: stop learner data transmissions for course runs --- CHANGELOG.rst | 4 + enterprise/__init__.py | 2 +- .../degreed/exporters/learner_data.py | 11 -- .../degreed2/exporters/learner_data.py | 12 -- .../exporters/learner_data.py | 12 -- .../moodle/exporters/learner_data.py | 12 -- .../exporters/learner_data.py | 14 --- .../test_exporters/test_learner_data.py | 3 +- .../test_exporters/test_learner_data.py | 3 +- .../test_exporters/test_learner_data.py | 112 ++++++++---------- 10 files changed, 54 insertions(+), 131 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 6a1feafd52..57157ef161 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -17,6 +17,10 @@ Unreleased ---------- Nothing unreleased. +[4.7.2] +-------- +feat: stop learner data transmissions for course runs + [4.7.1] -------- chore: retire Degreed v1 code from the set of channels diff --git a/enterprise/__init__.py b/enterprise/__init__.py index 361e96f798..90cefe02c9 100644 --- a/enterprise/__init__.py +++ b/enterprise/__init__.py @@ -2,4 +2,4 @@ Your project description goes here. """ -__version__ = "4.7.1" +__version__ = "4.7.2" diff --git a/integrated_channels/degreed/exporters/learner_data.py b/integrated_channels/degreed/exporters/learner_data.py index 65ed9fd51e..47cf816537 100644 --- a/integrated_channels/degreed/exporters/learner_data.py +++ b/integrated_channels/degreed/exporters/learner_data.py @@ -54,17 +54,6 @@ def get_learner_data_records( enterprise_customer_uuid=enterprise_enrollment.enterprise_customer_user.enterprise_customer.uuid, plugin_configuration_id=self.enterprise_configuration.id, ), - DegreedLearnerDataTransmissionAudit( - enterprise_course_enrollment_id=enterprise_enrollment.id, - degreed_user_email=enterprise_enrollment.enterprise_customer_user.user_email, - user_email=enterprise_enrollment.enterprise_customer_user.user_email, - course_id=enterprise_enrollment.course_id, - course_completed=course_completed, - completed_timestamp=completed_date, - degreed_completed_timestamp=degreed_completed_timestamp, - enterprise_customer_uuid=enterprise_enrollment.enterprise_customer_user.enterprise_customer.uuid, - plugin_configuration_id=self.enterprise_configuration.id, - ) ] LOGGER.info(generate_formatted_log( self.enterprise_configuration.channel_code(), diff --git a/integrated_channels/degreed2/exporters/learner_data.py b/integrated_channels/degreed2/exporters/learner_data.py index 787be279d1..8c8ba96cdc 100644 --- a/integrated_channels/degreed2/exporters/learner_data.py +++ b/integrated_channels/degreed2/exporters/learner_data.py @@ -81,18 +81,6 @@ def get_learner_data_records( enterprise_customer_uuid=enterprise_enrollment.enterprise_customer_user.enterprise_customer.uuid, plugin_configuration_id=self.enterprise_configuration.id, ), - Degreed2LearnerDataTransmissionAudit( - enterprise_course_enrollment_id=enterprise_enrollment.id, - degreed_user_email=enterprise_enrollment.enterprise_customer_user.user_email, - user_email=enterprise_enrollment.enterprise_customer_user.user_email, - course_id=enterprise_enrollment.course_id, - completed_timestamp=completed_date, - degreed_completed_timestamp=degreed_completed_timestamp, - course_completed=course_completed, - grade=percent_grade, - enterprise_customer_uuid=enterprise_enrollment.enterprise_customer_user.enterprise_customer.uuid, - plugin_configuration_id=self.enterprise_configuration.id, - ) ] LOGGER.info(generate_formatted_log( self.enterprise_configuration.channel_code(), diff --git a/integrated_channels/integrated_channel/exporters/learner_data.py b/integrated_channels/integrated_channel/exporters/learner_data.py index 8a4eecc05d..3918e4f521 100644 --- a/integrated_channels/integrated_channel/exporters/learner_data.py +++ b/integrated_channels/integrated_channel/exporters/learner_data.py @@ -627,18 +627,6 @@ def get_learner_data_records( content_title=content_title, progress_status=progress_status, ), - TransmissionAudit( - plugin_configuration_id=self.enterprise_configuration.id, - enterprise_customer_uuid=self.enterprise_configuration.enterprise_customer.uuid, - enterprise_course_enrollment_id=enterprise_enrollment.id, - course_id=enterprise_enrollment.course_id, - course_completed=course_completed, - completed_timestamp=completed_timestamp, - grade=grade, - user_email=user_email, - content_title=content_title, - progress_status=progress_status, - ) ] def collect_certificate_data(self, enterprise_enrollment, channel_name): diff --git a/integrated_channels/moodle/exporters/learner_data.py b/integrated_channels/moodle/exporters/learner_data.py index 7a699eaacd..10f2fee354 100644 --- a/integrated_channels/moodle/exporters/learner_data.py +++ b/integrated_channels/moodle/exporters/learner_data.py @@ -68,16 +68,4 @@ def get_learner_data_records( enterprise_customer_uuid=enterprise_customer_user.enterprise_customer.uuid, plugin_configuration_id=self.enterprise_configuration.id, ), - MoodleLearnerDataTransmissionAudit( - enterprise_course_enrollment_id=enterprise_enrollment.id, - moodle_user_email=enterprise_customer_user.user_email, - user_email=enterprise_customer_user.user_email, - course_id=enterprise_enrollment.course_id, - course_completed=course_completed, - grade=percent_grade, - completed_timestamp=completed_date, - moodle_completed_timestamp=moodle_completed_timestamp, - enterprise_customer_uuid=enterprise_customer_user.enterprise_customer.uuid, - plugin_configuration_id=self.enterprise_configuration.id, - ) ] diff --git a/integrated_channels/sap_success_factors/exporters/learner_data.py b/integrated_channels/sap_success_factors/exporters/learner_data.py index 99cf390cd6..f0baea0891 100644 --- a/integrated_channels/sap_success_factors/exporters/learner_data.py +++ b/integrated_channels/sap_success_factors/exporters/learner_data.py @@ -73,20 +73,6 @@ def get_learner_data_records( enterprise_customer_uuid=self.enterprise_configuration.enterprise_customer.uuid, plugin_configuration_id=self.enterprise_configuration.id ), - SapSuccessFactorsLearnerDataTransmissionAudit( - enterprise_course_enrollment_id=enterprise_enrollment.id, - sapsf_user_id=sapsf_user_id, - user_email=enterprise_enrollment.enterprise_customer_user.user_email, - course_id=enterprise_enrollment.course_id, - course_completed=course_completed, - completed_timestamp=completed_date, - sap_completed_timestamp=sap_completed_timestamp, - grade=grade, - total_hours=total_hours, - credit_hours=total_hours, - enterprise_customer_uuid=self.enterprise_configuration.enterprise_customer.uuid, - plugin_configuration_id=self.enterprise_configuration.id - ), ] LOGGER.info( generate_formatted_log( diff --git a/tests/test_integrated_channels/test_degreed/test_exporters/test_learner_data.py b/tests/test_integrated_channels/test_degreed/test_exporters/test_learner_data.py index b70466f4f8..82c92797e8 100644 --- a/tests/test_integrated_channels/test_degreed/test_exporters/test_learner_data.py +++ b/tests/test_integrated_channels/test_degreed/test_exporters/test_learner_data.py @@ -81,9 +81,8 @@ def test_get_learner_data_record(self, completed_date, course_completed): completed_date=completed_date, course_completed=course_completed, ) - assert len(learner_data_records) == 2 + assert len(learner_data_records) == 1 assert learner_data_records[0].course_id == self.course_key - assert learner_data_records[1].course_id == self.course_id for learner_data_record in learner_data_records: assert learner_data_record.enterprise_course_enrollment_id == enterprise_course_enrollment.id diff --git a/tests/test_integrated_channels/test_degreed2/test_exporters/test_learner_data.py b/tests/test_integrated_channels/test_degreed2/test_exporters/test_learner_data.py index e251ba21ee..35fa6427f1 100644 --- a/tests/test_integrated_channels/test_degreed2/test_exporters/test_learner_data.py +++ b/tests/test_integrated_channels/test_degreed2/test_exporters/test_learner_data.py @@ -80,9 +80,8 @@ def test_get_learner_data_record(self, completed_date, grade_percent): completed_date=completed_date, grade_percent=grade_percent, ) - assert len(learner_data_records) == 2 + assert len(learner_data_records) == 1 assert learner_data_records[0].course_id == self.course_key - assert learner_data_records[1].course_id == self.course_id for learner_data_record in learner_data_records: assert learner_data_record.enterprise_course_enrollment_id == enterprise_course_enrollment.id diff --git a/tests/test_integrated_channels/test_integrated_channel/test_exporters/test_learner_data.py b/tests/test_integrated_channels/test_integrated_channel/test_exporters/test_learner_data.py index 05ea465134..9eb1200689 100644 --- a/tests/test_integrated_channels/test_integrated_channel/test_exporters/test_learner_data.py +++ b/tests/test_integrated_channels/test_integrated_channel/test_exporters/test_learner_data.py @@ -124,9 +124,9 @@ def test_get_learner_data_record(self, completed_date, mock_course_catalog_api): course_completed=expected_course_completed, ) - learner_data_record = learner_data_records[1] + learner_data_record = learner_data_records[0] assert learner_data_record.enterprise_course_enrollment_id == enterprise_course_enrollment.id - assert learner_data_record.course_id == enterprise_course_enrollment.course_id + assert learner_data_record.course_id == self.course_key assert learner_data_record.course_completed == expected_course_completed assert learner_data_record.completed_timestamp == (self.NOW_TIMESTAMP if completed_date is not None else None) assert learner_data_record.grade == 'A+' @@ -247,16 +247,14 @@ def test_learner_data_instructor_paced_no_certificate( } learner_data = list(self.exporter.export()) - assert len(learner_data) == 2 + assert len(learner_data) == 1 assert learner_data[0].course_id == self.course_key - assert learner_data[1].course_id == self.course_id - for report in learner_data: - assert report.user_email == self.user.email - assert report.enterprise_course_enrollment_id == enrollment.id - assert not report.course_completed - assert report.completed_timestamp is None - assert report.grade == LearnerExporter.GRADE_INCOMPLETE + assert learner_data[0].user_email == self.user.email + assert learner_data[0].enterprise_course_enrollment_id == enrollment.id + assert not learner_data[0].course_completed + assert learner_data[0].completed_timestamp is None + assert learner_data[0].grade == LearnerExporter.GRADE_INCOMPLETE @mock.patch('enterprise.models.EnrollmentApiClient') @mock.patch('integrated_channels.integrated_channel.exporters.learner_data.get_single_user_grade') @@ -359,17 +357,14 @@ def test_learner_data_instructor_paced_with_certificate_created_date( } learner_data = list(self.exporter.export()) - assert len(learner_data) == 2 + assert len(learner_data) == 1 assert learner_data[0].course_id == self.course_key - assert learner_data[1].course_id == self.course_id - - for report in learner_data: - assert report.enterprise_course_enrollment_id == enrollment.id - assert report.course_completed - assert report.completed_timestamp == self.NOW_TIMESTAMP - assert report.grade == LearnerExporter.GRADE_PASSING - assert report.progress_status == 'Passed' - assert report.content_title == 'Dogs and Cats: Star Crossed Lovers or Fated Foes' + assert learner_data[0].enterprise_course_enrollment_id == enrollment.id + assert learner_data[0].course_completed + assert learner_data[0].completed_timestamp == self.NOW_TIMESTAMP + assert learner_data[0].grade == LearnerExporter.GRADE_PASSING + assert learner_data[0].progress_status == 'Passed' + assert learner_data[0].content_title == 'Dogs and Cats: Star Crossed Lovers or Fated Foes' @mock.patch('enterprise.models.EnrollmentApiClient') @mock.patch('integrated_channels.integrated_channel.exporters.learner_data.get_course_details') @@ -423,15 +418,12 @@ def test_learner_data_instructor_paced_with_certificate_created( } learner_data = list(self.exporter.export()) - assert len(learner_data) == 2 + assert len(learner_data) == 1 assert learner_data[0].course_id == self.course_key - assert learner_data[1].course_id == self.course_id - - for report in learner_data: - assert report.enterprise_course_enrollment_id == enrollment.id - assert report.course_completed - assert report.completed_timestamp == self.NOW_TIMESTAMP - assert report.grade == LearnerExporter.GRADE_PASSING + assert learner_data[0].enterprise_course_enrollment_id == enrollment.id + assert learner_data[0].course_completed + assert learner_data[0].completed_timestamp == self.NOW_TIMESTAMP + assert learner_data[0].grade == LearnerExporter.GRADE_PASSING @mock.patch('enterprise.models.EnrollmentApiClient') @mock.patch('integrated_channels.integrated_channel.exporters.learner_data.get_course_certificate') @@ -468,9 +460,8 @@ def test_learner_data_self_paced_no_grades( } learner_data = list(self.exporter.export()) - assert len(learner_data) == 2 + assert len(learner_data) == 1 assert learner_data[0].course_id == self.course_key - assert learner_data[1].course_id == self.course_id for report in learner_data: assert report.enterprise_course_enrollment_id == enrollment.id @@ -556,9 +547,8 @@ def test_learner_data_self_paced_course( with freeze_time(self.NOW): learner_data = list(self.exporter.export()) - assert len(learner_data) == 2 + assert len(learner_data) == 1 assert learner_data[0].course_id == self.course_key - assert learner_data[1].course_id == self.course_id for report in learner_data: assert report.enterprise_course_enrollment_id == enrollment.id @@ -647,9 +637,8 @@ def test_learner_data_self_paced_course_with_funky_certificate( with freeze_time(self.NOW): learner_data = list(self.exporter.export()) - assert len(learner_data) == 2 + assert len(learner_data) == 1 assert learner_data[0].course_id == self.course_key - assert learner_data[1].course_id == self.course_id for report in learner_data: assert report.enterprise_course_enrollment_id == enrollment.id @@ -866,41 +855,36 @@ def get_course_grade(course_id, username): # pylint: disable=unused-argument with freeze_time(self.NOW): learner_data = list(self.exporter.export()) - assert len(learner_data) == 6 + assert len(learner_data) == 3 assert learner_data[0].course_id == self.course_key - assert learner_data[1].course_id == self.course_id # note: the course_completed is a function of the mock_is_course_completed mock - for report1 in learner_data[0:1]: - assert report1.enterprise_course_enrollment_id == enrollment1.id - assert report1.course_completed - assert report1.completed_timestamp is None - assert report1.grade == LearnerExporter.GRADE_INCOMPLETE + assert learner_data[0].enterprise_course_enrollment_id == enrollment1.id + assert learner_data[0].course_completed + assert learner_data[0].completed_timestamp is None + assert learner_data[0].grade == LearnerExporter.GRADE_INCOMPLETE + + assert learner_data[1].course_id == self.course_key + assert learner_data[1].enterprise_course_enrollment_id == enrollment2.id + assert learner_data[1].course_completed + assert learner_data[1].completed_timestamp == self.NOW_TIMESTAMP + assert learner_data[1].grade == grade assert learner_data[2].course_id == self.course_key - assert learner_data[3].course_id == course_id2 - for report2 in learner_data[2:3]: - assert report2.enterprise_course_enrollment_id == enrollment2.id - assert report2.course_completed - assert report2.completed_timestamp == self.NOW_TIMESTAMP - assert report2.grade == grade - assert learner_data[4].course_id == self.course_key - assert learner_data[5].course_id == self.course_id - for report3 in learner_data[4:5]: - assert report3.enterprise_course_enrollment_id == enrollment3.id - assert report3.course_completed - assert report3.completed_timestamp is None - assert report3.grade == LearnerExporter.GRADE_INCOMPLETE + assert learner_data[2].enterprise_course_enrollment_id == enrollment3.id + assert learner_data[2].course_completed + assert learner_data[2].completed_timestamp is None + assert learner_data[2].grade == LearnerExporter.GRADE_INCOMPLETE @ddt.data( - (True, True, 'audit', 2), + (True, True, 'audit', 1), (True, False, 'audit', 0), (False, True, 'audit', 0), (False, False, 'audit', 0), - (True, True, 'verified', 2), - (True, False, 'verified', 2), - (False, True, 'verified', 2), - (False, False, 'verified', 2), + (True, True, 'verified', 1), + (True, False, 'verified', 1), + (False, True, 'verified', 1), + (False, False, 'verified', 1), ) @ddt.unpack @mock.patch('enterprise.models.CourseEnrollment') @@ -968,13 +952,11 @@ def test_learner_data_audit_data_reporting( assert len(learner_data) == expected_data_len - if expected_data_len == 2: + if expected_data_len == 1: assert learner_data[0].course_id == self.course_key - assert learner_data[1].course_id == self.course_id - for report in learner_data: - assert report.enterprise_course_enrollment_id == enrollment.id - assert report.course_completed - assert report.grade == LearnerExporter.GRADE_PASSING + assert learner_data[0].enterprise_course_enrollment_id == enrollment.id + assert learner_data[0].course_completed + assert learner_data[0].grade == LearnerExporter.GRADE_PASSING @mock.patch('enterprise.models.CourseEnrollment') @mock.patch('integrated_channels.integrated_channel.exporters.learner_data.get_single_user_grade') From 15063601488c9b6e41a827dc7c23092415b2bdfa Mon Sep 17 00:00:00 2001 From: hamzawaleed01 Date: Mon, 20 Nov 2023 18:29:53 +0500 Subject: [PATCH 038/164] feat: add management command to reencrypt enterprise remoting configs --- ...ise_customer_reporting_config_passwords.py | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 enterprise/management/commands/reencrypt_enterprise_customer_reporting_config_passwords.py diff --git a/enterprise/management/commands/reencrypt_enterprise_customer_reporting_config_passwords.py b/enterprise/management/commands/reencrypt_enterprise_customer_reporting_config_passwords.py new file mode 100644 index 0000000000..12c59759a1 --- /dev/null +++ b/enterprise/management/commands/reencrypt_enterprise_customer_reporting_config_passwords.py @@ -0,0 +1,31 @@ +""" +Django management command to reencrypt passwords in enterprise custom reporting configs. +""" +import logging +from django.core.management import BaseCommand + +from enterprise.models import EnterpriseCustomerReportingConfiguration + +LOGGER = logging.getLogger(__name__) + + +class Command(BaseCommand): + """ + Django management command to reencrypt passwords in enterprise custom reporting configs + It's useful when following encryption keys are rotated + - FERNET_KEYS + - LMS_FERNET_KEY + + Example usage: + ./manage.py lms reencrypt_enterprise_customer_reporting_config_passwords + + """ + + + def handle(self, *args, **options): + try: + for config in EnterpriseCustomerReportingConfiguration.objects.all(): + config.save() # resaving reencrypts all the encrypted columns + LOGGER.info('Enterprise customer reporting configuration passwords reencrypted succesfully!') + except Exception as e: # pylint: disable=broad-except + LOGGER.exception(f'Failed to reencrypt customer reporting configuration passwords. Error: {e}') From c5dc54546b98730518c8626f893c1c3bc41b195f Mon Sep 17 00:00:00 2001 From: Ehmad Saeed Date: Tue, 21 Nov 2023 18:05:18 +0500 Subject: [PATCH 039/164] chore: remove unnecessary logs based on ENT-7719 --- integrated_channels/blackboard/client.py | 24 ----------------- integrated_channels/canvas/client.py | 9 ------- integrated_channels/degreed2/client.py | 26 ------------------- .../degreed2/exporters/learner_data.py | 9 ------- .../exporters/content_metadata.py | 24 ----------------- .../exporters/learner_data.py | 23 ++-------------- .../transmitters/content_metadata.py | 8 ------ .../transmitters/learner_data.py | 24 ----------------- integrated_channels/moodle/client.py | 12 +-------- .../sap_success_factors/client.py | 11 -------- .../exporters/learner_data.py | 11 -------- 11 files changed, 3 insertions(+), 178 deletions(-) diff --git a/integrated_channels/blackboard/client.py b/integrated_channels/blackboard/client.py index 12755cb7dd..3927030556 100644 --- a/integrated_channels/blackboard/client.py +++ b/integrated_channels/blackboard/client.py @@ -76,13 +76,6 @@ def create_content_metadata(self, serialized_data): copy_of_channel_metadata = copy.deepcopy(channel_metadata_item) copy_of_channel_metadata['course_metadata']['courseId'] = course_id_generated - LOGGER.info(generate_formatted_log( - self.enterprise_configuration.channel_code(), - self.enterprise_configuration.enterprise_customer.uuid, - None, - external_id, - f"Creating course with courseId: {external_id}, and generated course_id: {course_id_generated}" - )) self._create_session() create_url = self.generate_course_create_url() try: @@ -111,14 +104,6 @@ def create_content_metadata(self, serialized_data): HTTPStatus.NOT_FOUND.value ) - LOGGER.info(generate_formatted_log( - self.enterprise_configuration.channel_code(), - self.enterprise_configuration.enterprise_customer.uuid, - None, - external_id, - (f"Creating content page for Blackboard course with course ID={external_id}," - f" and generated course_id: {course_id_generated}") - )) course_created_response = self.create_integration_content_for_course(bb_course_id, copy_of_channel_metadata) success_body = 'Successfully created Blackboard integration course={bb_course_id} with integration ' \ @@ -139,15 +124,6 @@ def update_content_metadata(self, serialized_data): course_id = self._resolve_blackboard_course_id(external_id) BlackboardAPIClient._validate_course_id(course_id, external_id) - LOGGER.info( - generate_formatted_log( - self.enterprise_configuration.channel_code(), - self.enterprise_configuration.enterprise_customer.uuid, - None, - course_id, - f'Updating course with courseId: {course_id}' - ) - ) update_url = self.generate_course_update_url(course_id) response = self._patch(update_url, channel_metadata_item.get('course_metadata')) diff --git a/integrated_channels/canvas/client.py b/integrated_channels/canvas/client.py index 666b948450..d93199716c 100644 --- a/integrated_channels/canvas/client.py +++ b/integrated_channels/canvas/client.py @@ -90,15 +90,6 @@ def create_content_metadata(self, serialized_data): # Do one of 3 things with the fetched canvas course info # If no course was found, create it if not located_course: - LOGGER.info( - generate_formatted_log( - self.enterprise_configuration.channel_code(), - self.enterprise_configuration.enterprise_customer.uuid, - None, - edx_course_id, - f'Creating new course with payload {desired_payload}', - ) - ) # Course does not exist: Create the course status_code, response_text = self._post( self.course_create_url, diff --git a/integrated_channels/degreed2/client.py b/integrated_channels/degreed2/client.py index dfd13733b1..472794fa3a 100644 --- a/integrated_channels/degreed2/client.py +++ b/integrated_channels/degreed2/client.py @@ -116,21 +116,6 @@ def create_course_completion(self, user_id, payload): Returns: status_code, response_text """ json_payload = json.loads(payload) - LOGGER.error( - generate_formatted_log( - self.enterprise_configuration.channel_code(), - self.enterprise_configuration.enterprise_customer.uuid, - user_id, - None, - '[Degreed2Client] - Attempting degreed2 create_course_completion,' - f'payload:{json_payload}' - ) - ) - LOGGER.info(self.make_log_msg( - json_payload.get('data').get('attributes').get('content-id'), - f'Attempting find course via url: {self.get_completions_url()}'), - user_id - ) code, body = self._post( self.get_completions_url(), json_payload, @@ -345,7 +330,6 @@ def _sync_content_metadata(self, course_attributes, http_method, override_url, d if degreed_course_id: json_to_send['data']['id'] = degreed_course_id - LOGGER.info(self.make_log_msg('', f'About to post payload: {json_to_send}')) try: status_code, response_body = getattr(self, '_' + http_method)( override_url, @@ -450,16 +434,6 @@ def _post(self, url, data, scope): ) ) break - LOGGER.error( - generate_formatted_log( - self.enterprise_configuration.channel_code(), - self.enterprise_configuration.enterprise_customer.uuid, - None, - None, - '[Degreed2Client] - Successfuly called:' - f'RESPONSE:{response}' - ) - ) return response.status_code, response.text def _patch(self, url, data, scope): diff --git a/integrated_channels/degreed2/exporters/learner_data.py b/integrated_channels/degreed2/exporters/learner_data.py index 8c8ba96cdc..84914dfa07 100644 --- a/integrated_channels/degreed2/exporters/learner_data.py +++ b/integrated_channels/degreed2/exporters/learner_data.py @@ -37,15 +37,6 @@ def get_learner_data_records( degreed_completed_timestamp = completed_date.strftime('%Y-%m-%dT%H:%M:%S') if isinstance( completed_date, datetime ) else None - LOGGER.info(generate_formatted_log( - self.enterprise_configuration.channel_code(), - enterprise_enrollment.enterprise_customer_user.enterprise_customer.uuid, - enterprise_enrollment.enterprise_customer_user.user_id, - enterprise_enrollment.course_id, - '[Degreed2Client] - Attempting get_learner_data_records:' - f'percent_grade={percent_grade}, degreed_completed_timestamp={degreed_completed_timestamp}' - f'completed_date={completed_date}, course_completed={course_completed}' - )) try: remote_id = enterprise_enrollment.enterprise_customer_user.get_remote_id( self.enterprise_configuration.idp_id diff --git a/integrated_channels/integrated_channel/exporters/content_metadata.py b/integrated_channels/integrated_channel/exporters/content_metadata.py index 8302a5f11c..70d20c4fd5 100644 --- a/integrated_channels/integrated_channel/exporters/content_metadata.py +++ b/integrated_channels/integrated_channel/exporters/content_metadata.py @@ -264,11 +264,6 @@ def _check_matched_content_to_create( content_id=content_id, ).first() if incomplete_transmission: - self._log_info( - 'Found an unsent content create record while creating record. ' - 'Including record.', - course_or_course_run_key=content_id - ) incomplete_transmission.mark_for_create() items_to_create[content_id] = incomplete_transmission else: @@ -347,12 +342,6 @@ def _get_catalog_diff( # if the item to create doesn't exist as an orphaned piece of content, do all the normal checks elif content_key not in existing_content_keys: unique_new_items_to_create.append(item) - else: - self._log_info( - 'Found an previous content record in another catalog while creating. ' - 'Skipping record.', - course_or_course_run_key=content_key - ) content_to_create = self._check_matched_content_to_create( enterprise_catalog, @@ -443,11 +432,6 @@ def _get_customer_config_orphaned_content(self, max_set_count, content_key=None) # Grab orphaned content metadata items for the customer, ordered by oldest to newest orphaned_content = OrphanedContentTransmissions.objects.filter(base_query) - num_records = len(orphaned_content) - self._log_info( - f'Found {num_records} orphaned content records for customer: ' - f'{self.enterprise_customer.uuid}. Returning {min(max_set_count, num_records)} records.' - ) ordered_and_chunked_orphaned_content = orphaned_content.order_by('created')[:max_set_count] return ordered_and_chunked_orphaned_content @@ -463,10 +447,6 @@ def _sanitize_and_set_item_metadata(self, item, metadata, action): item.content_title = metadata.get('title') item.content_last_changed = metadata.get('content_last_modified') item.save() - self._log_info( - f'_sanitize_and_set_item_metadata method updated item: {item} `content_last_changed`: ' - f'{metadata.get("content_last_modified")}' - ) def export(self, **kwargs): """ @@ -510,10 +490,6 @@ def export(self, **kwargs): max_payload_count ) - self._log_info(f'diff items_to_create: {items_to_create}') - self._log_info(f'diff items_to_update: {items_to_update}') - self._log_info(f'diff items_to_delete: {items_to_delete}') - content_keys_filter = list(items_to_create.keys()) + list(items_to_update.keys()) if content_keys_filter: content_metadata_items = self.enterprise_catalog_api.get_content_metadata( diff --git a/integrated_channels/integrated_channel/exporters/learner_data.py b/integrated_channels/integrated_channel/exporters/learner_data.py index 3918e4f521..75ecbcca4d 100644 --- a/integrated_channels/integrated_channel/exporters/learner_data.py +++ b/integrated_channels/integrated_channel/exporters/learner_data.py @@ -312,25 +312,12 @@ def get_grades_summary( else: completed_date_from_api, grade_from_api, is_passing_from_api, grade_percent, passed_timestamp = \ self.collect_certificate_data(enterprise_enrollment, channel_name) - LOGGER.info(generate_formatted_log( - channel_name, enterprise_customer_uuid, lms_user_id, course_id, - f'collect_certificate_data finished with CompletedDate: {completed_date_from_api},' - f' Grade: {grade_from_api}, IsPassing: {is_passing_from_api},' - f' Passed timestamp: {passed_timestamp}' - )) if completed_date_from_api is None: # means we cannot find a cert for this learner # we will try getting grades info using the alternative api in this case # if that also does not exist then we have nothing to report completed_date_from_api, grade_from_api, is_passing_from_api, grade_percent, passed_timestamp = \ self.collect_grades_data(enterprise_enrollment, course_details, channel_name) - LOGGER.info(generate_formatted_log( - channel_name, enterprise_customer_uuid, lms_user_id, course_id, - f'No certificate found, obtained grading data from grades api.' - f' CompletedDate: {completed_date_from_api},' - f' Grade: {grade_from_api}, IsPassing: {is_passing_from_api},' - f' Passed timestamp: {passed_timestamp}' - )) # In the past we have been inconsistent about the format/source/typing of the grade_percent value. # Initial investigations have lead us to believe that grade percents from the source are seemingly more @@ -349,7 +336,7 @@ def get_grades_summary( return completed_date_from_api, grade_from_api, is_passing_from_api, grade_percent, passed_timestamp - def get_incomplete_content_count(self, enterprise_enrollment, channel_name): + def get_incomplete_content_count(self, enterprise_enrollment): ''' Fetch incomplete content count using completion blocks LMS api Will return None for non audit enrollment (but this does not have to be the case necessarily) @@ -363,17 +350,11 @@ def get_incomplete_content_count(self, enterprise_enrollment, channel_name): if not is_audit_enrollment: return incomplete_count lms_user_id = enterprise_enrollment.enterprise_customer_user.user_id - enterprise_customer_uuid = enterprise_enrollment.enterprise_customer_user.enterprise_customer.uuid course_id = enterprise_enrollment.course_id user = User.objects.get(pk=lms_user_id) completion_summary = get_completion_summary(course_id, user) incomplete_count = completion_summary.get('incomplete_count') - LOGGER.info( - generate_formatted_log( - channel_name, enterprise_customer_uuid, lms_user_id, course_id, - f'Incomplete count for audit enrollment is {incomplete_count}' - )) return incomplete_count @@ -435,7 +416,7 @@ def export(self, **kwargs): # For audit courses, check if 100% completed # which we define as: no non-gated content is remaining - incomplete_count = self.get_incomplete_content_count(enterprise_enrollment, channel_name) + incomplete_count = self.get_incomplete_content_count(enterprise_enrollment) ( completed_date_from_api, grade_from_api, diff --git a/integrated_channels/integrated_channel/transmitters/content_metadata.py b/integrated_channels/integrated_channel/transmitters/content_metadata.py index b53fcbfa5f..25fe8ca8b0 100644 --- a/integrated_channels/integrated_channel/transmitters/content_metadata.py +++ b/integrated_channels/integrated_channel/transmitters/content_metadata.py @@ -88,13 +88,10 @@ def transmit(self, create_payload, update_payload, delete_payload): Transmit content metadata items to the integrated channel. Save or update content metadata records according to the type of transmission. """ - self._log_info_for_each_item_map(delete_payload, 'transmitting delete') delete_payload_results = self._transmit_delete(delete_payload) - self._log_info_for_each_item_map(create_payload, 'transmitting create') create_payload_results = self._transmit_create(create_payload) - self._log_info_for_each_item_map(update_payload, 'transmitting update') update_payload_results = self._transmit_update(update_payload) return create_payload_results, update_payload_results, delete_payload_results @@ -220,11 +217,6 @@ def _transmit_action(self, content_metadata_item_map, client_method, action_name finally: action_happened_at = localized_utcnow() for content_id, transmission in chunk.items(): - self._log_info( - f'integrated_channel_content_transmission_id={transmission.id}, ' - f'saving {action_name} transmission', - course_or_course_run_key=content_id - ) transmission.api_response_status_code = response_status_code was_successful = response_status_code < 300 api_content_response = response_body diff --git a/integrated_channels/integrated_channel/transmitters/learner_data.py b/integrated_channels/integrated_channel/transmitters/learner_data.py index a1c8fd9c34..0c3c093664 100644 --- a/integrated_channels/integrated_channel/transmitters/learner_data.py +++ b/integrated_channels/integrated_channel/transmitters/learner_data.py @@ -293,13 +293,6 @@ def transmit(self, payload, **kwargs): # pylint: disable=arguments-differ # one by course key and one by course run id. # If the transmission with the course key succeeds, the next one will get skipped. # If it fails, the one with the course run id will be attempted and (presumably) succeed. - LOGGER.info(generate_formatted_log( - self.enterprise_configuration.channel_code(), - enterprise_customer_uuid, - None, - None, - f"Looping through learner data = {payload.export(**kwargs)}" - )) for learner_data in payload.export(**kwargs): serialized_payload = learner_data.serialize(enterprise_configuration=self.enterprise_configuration) @@ -313,15 +306,6 @@ def transmit(self, payload, **kwargs): # pylint: disable=arguments-differ # The user has not completed the course, so we shouldn't send a completion status call remote_id = getattr(learner_data, kwargs.get('remote_user_id')) encoded_serialized_payload = encode_data_for_logging(serialized_payload) - LOGGER.info(generate_formatted_log( - self.enterprise_configuration.channel_code(), - enterprise_customer_uuid, - lms_user_id, - learner_data.course_id, - 'Skipping in-progress enterprise enrollment record ' - f'integrated_channel_remote_user_id={remote_id}, ' - f'integrated_channel_serialized_payload_base64={encoded_serialized_payload}' - )) continue grade = getattr(learner_data, 'grade', None) @@ -333,14 +317,6 @@ def transmit(self, payload, **kwargs): # pylint: disable=arguments-differ detect_grade_updated=self.INCLUDE_GRADE_FOR_COMPLETION_AUDIT_CHECK, ): # We've already sent a completion status for this enrollment - LOGGER.info(generate_formatted_log( - self.enterprise_configuration.channel_code(), - enterprise_customer_uuid, - lms_user_id, - learner_data.course_id, - 'Skipping previously sent enterprise enrollment ' - f'integrated_channel_enterprise_enrollment_id={enterprise_enrollment_id}' - )) continue if self.enterprise_configuration.dry_run_mode_enabled: diff --git a/integrated_channels/moodle/client.py b/integrated_channels/moodle/client.py index 496800bda4..e444750789 100644 --- a/integrated_channels/moodle/client.py +++ b/integrated_channels/moodle/client.py @@ -13,7 +13,7 @@ from integrated_channels.exceptions import ClientError from integrated_channels.integrated_channel.client import IntegratedChannelApiClient -from integrated_channels.utils import encode_data_for_logging, generate_formatted_log +from integrated_channels.utils import generate_formatted_log LOGGER = logging.getLogger(__name__) @@ -335,16 +335,6 @@ def _wrapped_create_course_completion(self, user_id, payload): 'grades[0][grade]': completion_data['grade'] * self.enterprise_configuration.grade_scale } - encoded_params = encode_data_for_logging(params) - LOGGER.info(generate_formatted_log( - self.enterprise_configuration.channel_code(), - self.enterprise_configuration.enterprise_customer.uuid, - user_id, - course_id, - 'posting learner data to integrated channel ' - f'integrated_channel_params_base64={encoded_params}' - )) - return self._post(params) def create_content_metadata(self, serialized_data): diff --git a/integrated_channels/sap_success_factors/client.py b/integrated_channels/sap_success_factors/client.py index 853dca2c0c..ec1f6a63b7 100644 --- a/integrated_channels/sap_success_factors/client.py +++ b/integrated_channels/sap_success_factors/client.py @@ -405,17 +405,6 @@ def _call_search_students_recursively(self, sap_search_student_url, all_inactive new_page_start_at = page_size + start_at total_inactive_learners = sap_inactive_learners['@odata.count'] inactive_learners_on_page = sap_inactive_learners['value'] - LOGGER.info( - generate_formatted_log( - self.enterprise_configuration.channel_code(), - self.enterprise_configuration.enterprise_customer.uuid, - None, - None, - f"SAP SF searchStudent API returned {len(inactive_learners_on_page)} " - f"inactive learners of total {total_inactive_learners} starting from {start_at} for " - f"enterprise customer {self.enterprise_configuration.enterprise_customer.name}" - ) - ) all_inactive_learners += inactive_learners_on_page if total_inactive_learners > new_page_start_at: diff --git a/integrated_channels/sap_success_factors/exporters/learner_data.py b/integrated_channels/sap_success_factors/exporters/learner_data.py index f0baea0891..43f3239da6 100644 --- a/integrated_channels/sap_success_factors/exporters/learner_data.py +++ b/integrated_channels/sap_success_factors/exporters/learner_data.py @@ -167,17 +167,6 @@ def unlink_learners(self): sap_student_id = sap_inactive_learner['studentID'] social_auth_user = get_user_from_social_auth(providers, sap_student_id, enterprise_customer) if not social_auth_user: - LOGGER.info( - generate_formatted_log( - self.enterprise_configuration.channel_code(), - self.enterprise_configuration.enterprise_customer.uuid, - None, - None, - f"No social auth data found for inactive user with SAP student id {sap_student_id} " - f"of enterprise customer {enterprise_customer.name} with identity providers " - f"{', '.join(map(lambda provider: provider.provider_id, providers))}" - ) - ) continue try: From 0858964105a2fc2165b10b341fb7f87dd082b0ea Mon Sep 17 00:00:00 2001 From: MueezKhan246 <93375917+MueezKhan246@users.noreply.github.com> Date: Wed, 22 Nov 2023 02:07:44 +0500 Subject: [PATCH 040/164] Encryption fields added to moodle config (#1889) * feat: added fields for holding encrypted data in database (ENT 5613) * refactor: removing "null=true" constraint from encrypted fields * refactor: changing version for pull request merge * fix: used null=True to avoid decryption of empty values * refactor: removed unused import * refactor: D000 Title underline too short * refactor: added more meaningful help text for encryption fields * fix: added missing updated migration * fix: corrected change log file --- CHANGELOG.rst | 5 +++ .../migrations/0028_auto_20230928_1530.py | 44 +++++++++++++++++++ .../migrations/0029_auto_20231106_1233.py | 27 ++++++++++++ integrated_channels/moodle/models.py | 34 ++++++++++++++ 4 files changed, 110 insertions(+) create mode 100644 integrated_channels/moodle/migrations/0028_auto_20230928_1530.py create mode 100644 integrated_channels/moodle/migrations/0029_auto_20231106_1233.py diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 1b463dc00c..b456a20cda 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -15,6 +15,11 @@ Change Log Unreleased ---------- + +[4.7.4] +-------- +feat: added fields for holding encrypted data in database + [4.7.3] -------- feat: added management command to re-encrypt enterprise customer reporting configs diff --git a/integrated_channels/moodle/migrations/0028_auto_20230928_1530.py b/integrated_channels/moodle/migrations/0028_auto_20230928_1530.py new file mode 100644 index 0000000000..8e8839840b --- /dev/null +++ b/integrated_channels/moodle/migrations/0028_auto_20230928_1530.py @@ -0,0 +1,44 @@ +# Generated by Django 3.2.20 on 2023-09-28 15:30 + +from django.db import migrations +import fernet_fields.fields + + +class Migration(migrations.Migration): + + dependencies = [ + ('moodle', '0027_alter_historicalmoodleenterprisecustomerconfiguration_options'), + ] + + operations = [ + migrations.AddField( + model_name='historicalmoodleenterprisecustomerconfiguration', + name='decrypted_password', + field=fernet_fields.fields.EncryptedCharField(blank=True, help_text="The encrypted API user's password used to obtain new tokens. It will be encrypted when stored in the database.", max_length=255, null=True, verbose_name='Encrypted Webservice Password'), + ), + migrations.AddField( + model_name='historicalmoodleenterprisecustomerconfiguration', + name='decrypted_token', + field=fernet_fields.fields.EncryptedCharField(blank=True, help_text="The encrypted API user's token used to obtain new tokens. It will be encrypted when stored in the database.", max_length=255, null=True, verbose_name='Encrypted Webservice Token'), + ), + migrations.AddField( + model_name='historicalmoodleenterprisecustomerconfiguration', + name='decrypted_username', + field=fernet_fields.fields.EncryptedCharField(blank=True, help_text="The encrypted API user's username used to obtain new tokens. It will be encrypted when stored in the database.", max_length=255, null=True, verbose_name='Encrypted Webservice Username'), + ), + migrations.AddField( + model_name='moodleenterprisecustomerconfiguration', + name='decrypted_password', + field=fernet_fields.fields.EncryptedCharField(blank=True, help_text="The encrypted API user's password used to obtain new tokens. It will be encrypted when stored in the database.", max_length=255, null=True, verbose_name='Encrypted Webservice Password'), + ), + migrations.AddField( + model_name='moodleenterprisecustomerconfiguration', + name='decrypted_token', + field=fernet_fields.fields.EncryptedCharField(blank=True, help_text="The encrypted API user's token used to obtain new tokens. It will be encrypted when stored in the database.", max_length=255, null=True, verbose_name='Encrypted Webservice Token'), + ), + migrations.AddField( + model_name='moodleenterprisecustomerconfiguration', + name='decrypted_username', + field=fernet_fields.fields.EncryptedCharField(blank=True, help_text="The encrypted API user's username used to obtain new tokens. It will be encrypted when stored in the database.", max_length=255, null=True, verbose_name='Encrypted Webservice Username'), + ), + ] diff --git a/integrated_channels/moodle/migrations/0029_auto_20231106_1233.py b/integrated_channels/moodle/migrations/0029_auto_20231106_1233.py new file mode 100644 index 0000000000..ea32466125 --- /dev/null +++ b/integrated_channels/moodle/migrations/0029_auto_20231106_1233.py @@ -0,0 +1,27 @@ +# Generated by Django 3.2.20 on 2023-11-06 12:33 + +from django.db import migrations + + +def populate_decrypted_fields(apps, schema_editor): + """ + Populates the encryption fields with the data previously stored in database. + """ + MoodleEnterpriseCustomerConfiguration = apps.get_model('moodle', 'MoodleEnterpriseCustomerConfiguration') + + for moodle_enterprise_configuration in MoodleEnterpriseCustomerConfiguration.objects.all(): + moodle_enterprise_configuration.decrypted_username = moodle_enterprise_configuration.username + moodle_enterprise_configuration.decrypted_password = moodle_enterprise_configuration.password + moodle_enterprise_configuration.decrypted_token = moodle_enterprise_configuration.token + moodle_enterprise_configuration.save() + + +class Migration(migrations.Migration): + + dependencies = [ + ('moodle', '0028_auto_20230928_1530'), + ] + + operations = [ + migrations.RunPython(populate_decrypted_fields, reverse_code=migrations.RunPython.noop), + ] diff --git a/integrated_channels/moodle/models.py b/integrated_channels/moodle/models.py index 7e96a6ca8d..5e1a4112c9 100644 --- a/integrated_channels/moodle/models.py +++ b/integrated_channels/moodle/models.py @@ -5,6 +5,7 @@ import json from logging import getLogger +from fernet_fields import EncryptedCharField from simple_history.models import HistoricalRecords from django.db import models @@ -64,6 +65,17 @@ class MoodleEnterpriseCustomerConfiguration(EnterpriseCustomerPluginConfiguratio ) ) + decrypted_username = EncryptedCharField( + max_length=255, + verbose_name="Encrypted Webservice Username", + blank=True, + help_text=_( + "The encrypted API user's username used to obtain new tokens." + " It will be encrypted when stored in the database." + ), + null=True, + ) + password = models.CharField( max_length=255, blank=True, @@ -73,6 +85,17 @@ class MoodleEnterpriseCustomerConfiguration(EnterpriseCustomerPluginConfiguratio ) ) + decrypted_password = EncryptedCharField( + max_length=255, + verbose_name="Encrypted Webservice Password", + blank=True, + help_text=_( + "The encrypted API user's password used to obtain new tokens." + " It will be encrypted when stored in the database." + ), + null=True, + ) + token = models.CharField( max_length=255, blank=True, @@ -82,6 +105,17 @@ class MoodleEnterpriseCustomerConfiguration(EnterpriseCustomerPluginConfiguratio ) ) + decrypted_token = EncryptedCharField( + max_length=255, + verbose_name="Encrypted Webservice Token", + blank=True, + help_text=_( + "The encrypted API user's token used to obtain new tokens." + " It will be encrypted when stored in the database." + ), + null=True, + ) + transmission_chunk_size = models.IntegerField( default=1, help_text=_("The maximum number of data items to transmit to the integrated channel with each request.") From b0eca94e29d7a313ab02251de84e1440b9011e4a Mon Sep 17 00:00:00 2001 From: MueezKhan246 <93375917+MueezKhan246@users.noreply.github.com> Date: Wed, 22 Nov 2023 17:52:15 +0500 Subject: [PATCH 041/164] Enable incomplete course learner transmission (#1940) * feat: added flag to allow in progress course learner data transmission * test: added test case for testing enable_incomplete_progress_transmission flag * refactor: code formating to pass pipeline * fix: test_migrations test fail * refactor: restructuring enable_incomplete_progress_transmission to moodle config level * refactor: fixing line length format issue * refactor: made the comment explaining check, more clear * refactor: merging migrations to remove conflict * refactor: updated if condition with better structure * refactor: fixing continuation line with same indent as next logical line --- CHANGELOG.rst | 4 ++ .../transmitters/learner_data.py | 6 +- .../migrations/0028_auto_20231116_1826.py | 23 ++++++++ ...o_20231116_1826_0029_auto_20231106_1233.py | 14 +++++ integrated_channels/moodle/models.py | 6 ++ .../test_transmitters/test_learner_data.py | 1 - .../test_transmitters/test_learner_data.py | 56 +++++++++++++++++++ 7 files changed, 107 insertions(+), 3 deletions(-) create mode 100644 integrated_channels/moodle/migrations/0028_auto_20231116_1826.py create mode 100644 integrated_channels/moodle/migrations/0030_merge_0028_auto_20231116_1826_0029_auto_20231106_1233.py diff --git a/CHANGELOG.rst b/CHANGELOG.rst index b456a20cda..57b5b73c92 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -16,6 +16,10 @@ Change Log Unreleased ---------- +[4.7.5] +-------- +feat: added flag to allow in progress course learner data transmission + [4.7.4] -------- feat: added fields for holding encrypted data in database diff --git a/integrated_channels/integrated_channel/transmitters/learner_data.py b/integrated_channels/integrated_channel/transmitters/learner_data.py index a1c8fd9c34..6e510fcc42 100644 --- a/integrated_channels/integrated_channel/transmitters/learner_data.py +++ b/integrated_channels/integrated_channel/transmitters/learner_data.py @@ -309,8 +309,10 @@ def transmit(self, payload, **kwargs): # pylint: disable=arguments-differ enterprise_enrollment_id ) - if not learner_data.course_completed: - # The user has not completed the course, so we shouldn't send a completion status call + if (not learner_data.course_completed and + not getattr(self.enterprise_configuration, 'enable_incomplete_progress_transmission', False)): + # The user has not completed the course and enable_incomplete_progress_transmission is not set, + # so we shouldn't send a completion status call remote_id = getattr(learner_data, kwargs.get('remote_user_id')) encoded_serialized_payload = encode_data_for_logging(serialized_payload) LOGGER.info(generate_formatted_log( diff --git a/integrated_channels/moodle/migrations/0028_auto_20231116_1826.py b/integrated_channels/moodle/migrations/0028_auto_20231116_1826.py new file mode 100644 index 0000000000..a6709602eb --- /dev/null +++ b/integrated_channels/moodle/migrations/0028_auto_20231116_1826.py @@ -0,0 +1,23 @@ +# Generated by Django 3.2.20 on 2023-11-16 18:26 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('moodle', '0027_alter_historicalmoodleenterprisecustomerconfiguration_options'), + ] + + operations = [ + migrations.AddField( + model_name='historicalmoodleenterprisecustomerconfiguration', + name='enable_incomplete_progress_transmission', + field=models.BooleanField(default=False, help_text='When set to True, the configured customer will receive learner data transmissions, for incomplete courses as well'), + ), + migrations.AddField( + model_name='moodleenterprisecustomerconfiguration', + name='enable_incomplete_progress_transmission', + field=models.BooleanField(default=False, help_text='When set to True, the configured customer will receive learner data transmissions, for incomplete courses as well'), + ), + ] diff --git a/integrated_channels/moodle/migrations/0030_merge_0028_auto_20231116_1826_0029_auto_20231106_1233.py b/integrated_channels/moodle/migrations/0030_merge_0028_auto_20231116_1826_0029_auto_20231106_1233.py new file mode 100644 index 0000000000..21679e40a6 --- /dev/null +++ b/integrated_channels/moodle/migrations/0030_merge_0028_auto_20231116_1826_0029_auto_20231106_1233.py @@ -0,0 +1,14 @@ +# Generated by Django 3.2.20 on 2023-11-22 08:24 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('moodle', '0028_auto_20231116_1826'), + ('moodle', '0029_auto_20231106_1233'), + ] + + operations = [ + ] diff --git a/integrated_channels/moodle/models.py b/integrated_channels/moodle/models.py index 5e1a4112c9..6ee487cbef 100644 --- a/integrated_channels/moodle/models.py +++ b/integrated_channels/moodle/models.py @@ -136,6 +136,12 @@ class MoodleEnterpriseCustomerConfiguration(EnterpriseCustomerPluginConfiguratio ) ) + enable_incomplete_progress_transmission = models.BooleanField( + help_text=_("When set to True, the configured customer will receive learner data transmissions, for incomplete" + " courses as well"), + default=False, + ) + history = HistoricalRecords() class Meta: diff --git a/tests/test_integrated_channels/test_integrated_channel/test_transmitters/test_learner_data.py b/tests/test_integrated_channels/test_integrated_channel/test_transmitters/test_learner_data.py index 763870da62..0d2b7345a2 100644 --- a/tests/test_integrated_channels/test_integrated_channel/test_transmitters/test_learner_data.py +++ b/tests/test_integrated_channels/test_integrated_channel/test_transmitters/test_learner_data.py @@ -9,7 +9,6 @@ import ddt from pytest import mark -from integrated_channels.exceptions import ClientError from integrated_channels.integrated_channel.exporters.learner_data import LearnerExporter from integrated_channels.integrated_channel.tasks import transmit_single_learner_data from integrated_channels.integrated_channel.transmitters.learner_data import LearnerTransmitter diff --git a/tests/test_integrated_channels/test_moodle/test_transmitters/test_learner_data.py b/tests/test_integrated_channels/test_moodle/test_transmitters/test_learner_data.py index f94498fcc5..db3c014dfd 100644 --- a/tests/test_integrated_channels/test_moodle/test_transmitters/test_learner_data.py +++ b/tests/test_integrated_channels/test_moodle/test_transmitters/test_learner_data.py @@ -4,11 +4,15 @@ import datetime import unittest from unittest import mock +from unittest.mock import Mock from pytest import mark +from integrated_channels.integrated_channel.exporters.learner_data import LearnerExporter, LearnerExporterUtility +from integrated_channels.integrated_channel.transmitters.learner_data import LearnerTransmitter from integrated_channels.moodle.models import MoodleLearnerDataTransmissionAudit from integrated_channels.moodle.transmitters import learner_data +from integrated_channels.utils import encode_data_for_logging, generate_formatted_log from test_utils import factories @@ -58,6 +62,8 @@ def setUp(self): self.create_course_completion_mock = create_course_completion_mock.start() self.addCleanup(create_course_completion_mock.stop) + self.learner_transmitter = LearnerTransmitter(self.enterprise_config) + def test_transmit_success(self): """ Learner data transmission is successful and the payload is saved with the appropriate data. @@ -70,3 +76,53 @@ def test_transmit_success(self): self.create_course_completion_mock.assert_called_with(self.payload.moodle_user_email, self.payload.serialize()) assert self.payload.status == '200' assert self.payload.error_message == '' + + @mock.patch("integrated_channels.integrated_channel.models.LearnerDataTransmissionAudit") + @mock.patch('integrated_channels.integrated_channel.transmitters.' + 'learner_data.LOGGER', autospec=True) + def test_incomplete_progress_learner_data_transmission(self, mock_logger, learner_data_transmission_audit_mock): + """ + Test that a customer's configuration can run in enable incomplete progress transmission mode + """ + # Set boolean flag to true + self.enterprise_config.enable_incomplete_progress_transmission = True + + self.learner_transmitter.client.create_course_completion = Mock(return_value=(200, 'success')) + + LearnerExporterMock = LearnerExporter + + learner_data_transmission_audit_mock.serialize = Mock(return_value='serialized data') + learner_data_transmission_audit_mock.user_id = 1 + learner_data_transmission_audit_mock.enterprise_course_enrollment_id = 1 + learner_data_transmission_audit_mock.course_completed = False + learner_data_transmission_audit_mock.course_id = 'course_id' + LearnerExporterMock.export = Mock(return_value=[learner_data_transmission_audit_mock]) + lms_user_id = LearnerExporterUtility.lms_user_id_for_ent_course_enrollment_id( + learner_data_transmission_audit_mock.enterprise_course_enrollment_id + ) + serialized_payload = learner_data_transmission_audit_mock.serialize( + enterprise_configuration=self.enterprise_config + ) + encoded_serialized_payload = encode_data_for_logging(serialized_payload) + self.learner_transmitter.transmit( + LearnerExporterMock, + remote_user_id='user_id' + ) + # with enable_incomplete_progress_transmission = True we should be able to call this method + assert self.learner_transmitter.client.create_course_completion.called + + # Set boolean flag to false + self.enterprise_config.enable_incomplete_progress_transmission = False + self.learner_transmitter.transmit( + LearnerExporterMock, + remote_user_id='user_id' + ) + mock_logger.info.assert_called_with(generate_formatted_log( + self.enterprise_config.channel_code(), + self.enterprise_config.enterprise_customer.uuid or None, + lms_user_id, + learner_data_transmission_audit_mock.course_id, + 'Skipping in-progress enterprise enrollment record ' + f'integrated_channel_remote_user_id={learner_data_transmission_audit_mock.user_id}, ' + f'integrated_channel_serialized_payload_base64={encoded_serialized_payload}' + )) From 60401a2cec3c19a7aac516ea9337a54cb2b6f77e Mon Sep 17 00:00:00 2001 From: Troy Sankey Date: Mon, 27 Nov 2023 17:35:01 -0800 Subject: [PATCH 042/164] feat: add enterprise_catalog_query to output of catalog list endpoint This is needed to support robust provisioning, editing, and displaying of Learner Credit Plans linked to "predefined" catalogs. The previous behavior was to rely on reading the catalog title and use string manipulation (fragile) to infer the catalog query type. Exposing the query ID allows the LC provisioning tool to more directly understand the query used. ENT-7922 --- enterprise/api/v1/views/enterprise_customer_catalog.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/enterprise/api/v1/views/enterprise_customer_catalog.py b/enterprise/api/v1/views/enterprise_customer_catalog.py index 4f288e2be8..0860a0e2f4 100644 --- a/enterprise/api/v1/views/enterprise_customer_catalog.py +++ b/enterprise/api/v1/views/enterprise_customer_catalog.py @@ -122,7 +122,7 @@ class EnterpriseCustomerCatalogViewSet(EnterpriseReadOnlyModelViewSet): USER_ID_FILTER = 'enterprise_customer__enterprise_customer_users__user_id' FIELDS = ( - 'uuid', 'enterprise_customer', + 'uuid', 'title', 'enterprise_customer', 'enterprise_catalog_query', ) filterset_fields = FIELDS ordering_fields = FIELDS From 2582587b940eee46526b433e4dfa819e8c150e5d Mon Sep 17 00:00:00 2001 From: mahamakifdar19 Date: Tue, 28 Nov 2023 14:15:47 +0500 Subject: [PATCH 043/164] feat: integrated resumeCourseRunUrl into enrollments API --- .../api/v1/serializers.py | 1 + enterprise_learner_portal/api/v1/views.py | 18 ++++++++- .../api/test_serializers.py | 10 ++++- .../api/test_views.py | 37 +++++++++++++++++-- 4 files changed, 60 insertions(+), 6 deletions(-) diff --git a/enterprise_learner_portal/api/v1/serializers.py b/enterprise_learner_portal/api/v1/serializers.py index 925d47d641..0f91e59d39 100644 --- a/enterprise_learner_portal/api/v1/serializers.py +++ b/enterprise_learner_portal/api/v1/serializers.py @@ -78,6 +78,7 @@ def to_representation(self, instance): representation['is_revoked'] = instance.license.is_revoked if instance.license else False representation['is_enrollment_active'] = instance.is_active representation['mode'] = instance.mode + representation['resume_course_run_url'] = self.context['course_enrollments_resume_urls'].get(course_run_id) if CourseDetails: course_details = CourseDetails.objects.filter(id=course_run_id).first() diff --git a/enterprise_learner_portal/api/v1/views.py b/enterprise_learner_portal/api/v1/views.py index f9b4d4d85d..7319253e38 100644 --- a/enterprise_learner_portal/api/v1/views.py +++ b/enterprise_learner_portal/api/v1/views.py @@ -20,6 +20,10 @@ from openedx.core.djangoapps.content.course_overviews.api import get_course_overviews except ImportError: get_course_overviews = None +try: + from lms.djangoapps.learner_home.views import get_resume_urls_for_course_enrollments +except ImportError: + get_resume_urls_for_course_enrollments = None class EnterpriseCourseEnrollmentView(APIView): @@ -86,10 +90,22 @@ def get(self, request): course_overviews = get_course_overviews([record.course_id for record in filtered_enterprise_enrollments]) + if get_resume_urls_for_course_enrollments: + course_enrollments_resume_urls = get_resume_urls_for_course_enrollments( + user, + list(filtered_enterprise_enrollments) + ) + data = EnterpriseCourseEnrollmentSerializer( filtered_enterprise_enrollments, many=True, - context={'request': request, 'course_overviews': course_overviews}, + context={ + 'request': request, + 'course_overviews': course_overviews, + 'course_enrollments_resume_urls': ( + course_enrollments_resume_urls if course_enrollments_resume_urls else None + ) + }, ).data if request.query_params.get('is_active'): diff --git a/tests/test_enterprise_learner_portal/api/test_serializers.py b/tests/test_enterprise_learner_portal/api/test_serializers.py index fc04ccbbd0..42e1854c5f 100644 --- a/tests/test_enterprise_learner_portal/api/test_serializers.py +++ b/tests/test_enterprise_learner_portal/api/test_serializers.py @@ -64,6 +64,9 @@ def test_serializer_representation( 'pacing': 'instructor', 'display_org_with_default': 'my university', }] + course_enrollments_resume_urls = { + course_run_id: "http://example.com/resume_url", + } mock_get_cert.return_value = { 'download_url': 'example.com', @@ -93,7 +96,11 @@ def test_serializer_representation( serializer = EnterpriseCourseEnrollmentSerializer( [enterprise_enrollment], many=True, - context={'request': request, 'course_overviews': course_overviews}, + context={ + 'request': request, + 'course_overviews': course_overviews, + 'course_enrollments_resume_urls': course_enrollments_resume_urls + }, ) expected = OrderedDict([ @@ -112,6 +119,7 @@ def test_serializer_representation( ('is_revoked', False), ('is_enrollment_active', True), ('mode', 'verified'), + ('resume_course_run_url', 'http://example.com/resume_url'), ]) actual = serializer.data[0] self.assertDictEqual(actual, expected) diff --git a/tests/test_enterprise_learner_portal/api/test_views.py b/tests/test_enterprise_learner_portal/api/test_views.py index 92fad451c8..36bfe2f1e3 100644 --- a/tests/test_enterprise_learner_portal/api/test_views.py +++ b/tests/test_enterprise_learner_portal/api/test_views.py @@ -69,15 +69,19 @@ def setUp(self): self.client = Client() self.client.login(username=self.user.username, password="QWERTY") + @mock.patch('enterprise_learner_portal.api.v1.views.get_resume_urls_for_course_enrollments') @mock.patch('enterprise_learner_portal.api.v1.views.EnterpriseCourseEnrollmentSerializer') @mock.patch('enterprise_learner_portal.api.v1.views.get_course_overviews') - def test_view_returns_information(self, mock_get_overviews, mock_serializer): + def test_view_returns_information(self, mock_get_overviews, mock_serializer, mock_course_resume_urls): """ View should return data created by EnterpriseCourseEnrollmentSerializer (which we mock in this case) """ mock_get_overviews.return_value = {'overview_info': 'this would be a larger dict'} mock_serializer.return_value = self.MockSerializer() + mock_course_resume_urls.return_value = { + 'course-v1:edX+DemoX+Demo_Course': 'http://example.com/resume_url' + } resp = self.client.get( '{host}{path}?enterprise_id={enterprise_id}'.format( @@ -92,6 +96,7 @@ def test_view_returns_information(self, mock_get_overviews, mock_serializer): SERIALIZED_MOCK_INACTIVE_ENROLLMENT, ] + @mock.patch('enterprise_learner_portal.api.v1.views.get_resume_urls_for_course_enrollments') @mock.patch('enterprise_learner_portal.api.v1.views.EnterpriseCourseEnrollmentSerializer') @mock.patch('enterprise_learner_portal.api.v1.views.get_course_overviews') @ddt.data('true', 'false') @@ -100,6 +105,7 @@ def test_view_get_filters_active_enrollments( active_filter_value, mock_get_overviews, mock_serializer, + mock_course_resume_urls, ): """ View should return data created by EnterpriseCourseEnrollmentSerializer @@ -107,6 +113,9 @@ def test_view_get_filters_active_enrollments( """ mock_get_overviews.return_value = {'overview_info': 'this would be a larger dict'} mock_serializer.return_value = self.MockSerializer() + mock_course_resume_urls.return_value = { + 'course-v1:edX+DemoX+Demo_Course': 'http://example.com/resume_url' + } resp = self.client.get( '{host}{path}?enterprise_id={enterprise_id}&is_active={active_filter_value}'.format( @@ -123,14 +132,23 @@ def test_view_get_filters_active_enrollments( expected_result = [SERIALIZED_MOCK_INACTIVE_ENROLLMENT] assert resp.json() == expected_result + @mock.patch('enterprise_learner_portal.api.v1.views.get_resume_urls_for_course_enrollments') @mock.patch('enterprise_learner_portal.api.v1.views.EnterpriseCourseEnrollmentSerializer') @mock.patch('enterprise_learner_portal.api.v1.views.get_course_overviews') - def test_view_returns_bad_request_without_enterprise(self, mock_get_overviews, mock_serializer): + def test_view_returns_bad_request_without_enterprise( + self, + mock_get_overviews, + mock_serializer, + mock_course_resume_urls + ): """ View should return a 400 because of the missing enterprise_id parameter. """ mock_get_overviews.return_value = {'overview_info': 'this would be a larger dict'} mock_serializer.return_value = self.MockSerializer() + mock_course_resume_urls.return_value = { + 'course-v1:edX+DemoX+Demo_Course': 'http://example.com/resume_url' + } resp = self.client.get( '{host}{path}'.format( @@ -141,14 +159,23 @@ def test_view_returns_bad_request_without_enterprise(self, mock_get_overviews, m assert resp.status_code == 400 assert resp.json() == {'error': 'enterprise_id must be provided as a query parameter'} + @mock.patch('enterprise_learner_portal.api.v1.views.get_resume_urls_for_course_enrollments') @mock.patch('enterprise_learner_portal.api.v1.views.EnterpriseCourseEnrollmentSerializer') @mock.patch('enterprise_learner_portal.api.v1.views.get_course_overviews') - def test_view_returns_not_found_unlinked_enterprise(self, mock_get_overviews, mock_serializer): + def test_view_returns_not_found_unlinked_enterprise( + self, + mock_get_overviews, + mock_serializer, + mock_course_resume_urls + ): """ View should return a 404 because the user is not linked to the enterprise. """ mock_get_overviews.return_value = {'overview_info': 'this would be a larger dict'} mock_serializer.return_value = self.MockSerializer() + mock_course_resume_urls.return_value = { + 'course-v1:edX+DemoX+Demo_Course': 'http://example.com/resume_url' + } resp = self.client.get( '{host}{path}?enterprise_id={enterprise_id}'.format( @@ -160,14 +187,16 @@ def test_view_returns_not_found_unlinked_enterprise(self, mock_get_overviews, mo assert resp.status_code == 404 assert resp.json() == {'detail': 'Not found.'} + @mock.patch('enterprise_learner_portal.api.v1.views.get_resume_urls_for_course_enrollments') @mock.patch('enterprise_learner_portal.api.v1.serializers.get_certificate_for_user', mock.MagicMock()) @mock.patch('enterprise_learner_portal.api.v1.views.get_course_overviews') - def test_view_filters_out_invalid_enterprise_enrollments(self, mock_get_overviews): + def test_view_filters_out_invalid_enterprise_enrollments(self, mock_get_overviews, mock_course_resume_urls): """ View does not fail, and view filters out all enrollments whose course_enrollment field is None """ mock_get_overviews.return_value = {} + mock_course_resume_urls.return_value = {} resp = self.client.get( '{host}{path}?enterprise_id={enterprise_id}&is_active=true'.format( From 7746a6170e8fe0a6570512041a262c9c86196aae Mon Sep 17 00:00:00 2001 From: mahamakifdar19 Date: Thu, 30 Nov 2023 12:48:30 +0500 Subject: [PATCH 044/164] fix: ent-7993 version bump --- CHANGELOG.rst | 4 ++++ enterprise/__init__.py | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 589fb1d373..a320801bca 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -16,6 +16,10 @@ Change Log Unreleased ---------- +[4.8.1] +-------- +feat: integrated resumeCourseRunUrl into enrollments API + [4.7.6] -------- chore: remove unnecessary logs from the integrated channels diff --git a/enterprise/__init__.py b/enterprise/__init__.py index 5f2ce9640b..9751b8fa4a 100644 --- a/enterprise/__init__.py +++ b/enterprise/__init__.py @@ -2,4 +2,4 @@ Your project description goes here. """ -__version__ = "4.7.6" +__version__ = "4.8.1" From 31dd3aba5aa42ef816cf52f7d34e4ca97b521e7f Mon Sep 17 00:00:00 2001 From: Troy Sankey Date: Fri, 1 Dec 2023 00:07:26 -0800 Subject: [PATCH 045/164] feat: add created+modified to output of catalog list endpoint This is needed to support sorting of catalog options during provisioning and editing of Learner Credit Plans. ENT-7922 --- enterprise/api/v1/serializers.py | 2 +- .../v1/views/enterprise_customer_catalog.py | 2 +- tests/test_enterprise/api/test_views.py | 61 ++++++++++++------- 3 files changed, 42 insertions(+), 23 deletions(-) diff --git a/enterprise/api/v1/serializers.py b/enterprise/api/v1/serializers.py index e7aaf33c19..3010e52805 100644 --- a/enterprise/api/v1/serializers.py +++ b/enterprise/api/v1/serializers.py @@ -446,7 +446,7 @@ class EnterpriseCustomerCatalogSerializer(serializers.ModelSerializer): class Meta: model = models.EnterpriseCustomerCatalog fields = ( - 'uuid', 'title', 'enterprise_customer', 'enterprise_catalog_query', + 'uuid', 'title', 'enterprise_customer', 'enterprise_catalog_query', 'created', 'modified', ) diff --git a/enterprise/api/v1/views/enterprise_customer_catalog.py b/enterprise/api/v1/views/enterprise_customer_catalog.py index 0860a0e2f4..b724ac2669 100644 --- a/enterprise/api/v1/views/enterprise_customer_catalog.py +++ b/enterprise/api/v1/views/enterprise_customer_catalog.py @@ -122,7 +122,7 @@ class EnterpriseCustomerCatalogViewSet(EnterpriseReadOnlyModelViewSet): USER_ID_FILTER = 'enterprise_customer__enterprise_customer_users__user_id' FIELDS = ( - 'uuid', 'title', 'enterprise_customer', 'enterprise_catalog_query', + 'uuid', 'title', 'enterprise_customer', 'enterprise_catalog_query', 'created', 'modified', ) filterset_fields = FIELDS ordering_fields = FIELDS diff --git a/tests/test_enterprise/api/test_views.py b/tests/test_enterprise/api/test_views.py index 95b77d5007..0b0c68a216 100644 --- a/tests/test_enterprise/api/test_views.py +++ b/tests/test_enterprise/api/test_views.py @@ -2054,55 +2054,60 @@ def test_enterprise_customer_catalogs_list(self, is_staff, is_linked_to_enterpri response = self.client.get(ENTERPRISE_CATALOGS_LIST_ENDPOINT) response = self.load_json(response.content) + # Assert they exist, but don't test `created` and `modified` response keys because their values are + # non-deterministic. + if response['results']: + del response['results'][0]['created'] + del response['results'][0]['modified'] assert response == expected_results @ddt.data( - ( - False, - False, - {'detail': 'Not found.'}, - ), - ( - False, - True, - fake_enterprise_api.build_fake_enterprise_catalog_detail( + { + 'is_staff': False, + 'is_linked_to_enterprise': False, + 'expected_result': {'detail': 'Not found.'}, + }, + { + 'is_staff': False, + 'is_linked_to_enterprise': True, + 'expected_result': fake_enterprise_api.build_fake_enterprise_catalog_detail( paginated_content=fake_catalog_api.FAKE_SEARCH_ALL_RESULTS, include_enterprise_context=True, add_utm_info=False, count=3, ), - ), - ( - True, - False, - fake_enterprise_api.build_fake_enterprise_catalog_detail( + }, + { + 'is_staff': True, + 'is_linked_to_enterprise': False, + 'expected_result': fake_enterprise_api.build_fake_enterprise_catalog_detail( paginated_content=fake_catalog_api.FAKE_SEARCH_ALL_RESULTS, include_enterprise_context=True, add_utm_info=False, count=3, ), - ), - ( - True, - True, - fake_enterprise_api.build_fake_enterprise_catalog_detail( + }, + { + 'is_staff': True, + 'is_linked_to_enterprise': True, + 'expected_result': fake_enterprise_api.build_fake_enterprise_catalog_detail( paginated_content=fake_catalog_api.FAKE_SEARCH_ALL_RESULTS, include_enterprise_context=True, add_utm_info=False, count=3, ), - ), + }, ) @ddt.unpack @mock.patch('enterprise.api_client.discovery.CourseCatalogApiServiceClient') @mock.patch("enterprise.utils.update_query_parameters", mock.MagicMock(side_effect=side_effect)) def test_enterprise_customer_catalogs_detail( self, + mock_catalog_api_client, is_staff, is_linked_to_enterprise, expected_result, - mock_catalog_api_client, ): """ Make sure the Enterprise Customer's Catalog view correctly returns details about specific catalogs based on @@ -2137,6 +2142,12 @@ def test_enterprise_customer_catalogs_detail( ) response = self.client.get(ENTERPRISE_CATALOGS_DETAIL_ENDPOINT) response = self.load_json(response.content) + # Assert they exist, but don't test `created` and `modified` response keys because their values are + # non-deterministic. + if is_staff or is_linked_to_enterprise: + del response['created'] + del response['modified'] + self.assertDictEqual(response, expected_result) @mock.patch('enterprise.api_client.discovery.CourseCatalogApiServiceClient') @@ -2167,6 +2178,10 @@ def test_enterprise_customer_catalogs_detail_pagination(self, mock_catalog_api_c response = self.client.get(ENTERPRISE_CATALOGS_DETAIL_ENDPOINT + '?page=2') response = self.load_json(response.content) + # Assert they exist, but don't test `created` and `modified` response keys because their values are + # non-deterministic. + del response['created'] + del response['modified'] expected_result = fake_enterprise_api.build_fake_enterprise_catalog_detail( paginated_content=fake_catalog_api.FAKE_SEARCH_ALL_RESULTS_2, @@ -2205,6 +2220,10 @@ def test_enterprise_customer_catalogs_detail_pagination_filtering(self, mock_cat ) response = self.client.get(ENTERPRISE_CATALOGS_DETAIL_ENDPOINT + '?page=2') response = self.load_json(response.content) + # Assert they exist, but don't test `created` and `modified` response keys because their values are + # non-deterministic. + del response['created'] + del response['modified'] expected_result = fake_enterprise_api.build_fake_enterprise_catalog_detail( paginated_content=fake_catalog_api.FAKE_SEARCH_ALL_RESULTS_3, From a04e650a788aa5d32157621494364d271c05a227 Mon Sep 17 00:00:00 2001 From: MueezKhan246 <93375917+MueezKhan246@users.noreply.github.com> Date: Sat, 2 Dec 2023 15:24:25 +0500 Subject: [PATCH 046/164] Added log for learner data transmission (#1953) * refactor: adding log for learner data transmission --- CHANGELOG.rst | 4 ++++ enterprise/__init__.py | 2 +- integrated_channels/moodle/client.py | 32 +++++++++++++++++++++++++++- 3 files changed, 36 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index a320801bca..e2a06efa4b 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -16,6 +16,10 @@ Change Log Unreleased ---------- +[4.8.2] +-------- +refactor: adding log for learner data transmission + [4.8.1] -------- feat: integrated resumeCourseRunUrl into enrollments API diff --git a/enterprise/__init__.py b/enterprise/__init__.py index 9751b8fa4a..ad531fb9c8 100644 --- a/enterprise/__init__.py +++ b/enterprise/__init__.py @@ -2,4 +2,4 @@ Your project description goes here. """ -__version__ = "4.8.1" +__version__ = "4.8.2" diff --git a/integrated_channels/moodle/client.py b/integrated_channels/moodle/client.py index e444750789..9ab8233686 100644 --- a/integrated_channels/moodle/client.py +++ b/integrated_channels/moodle/client.py @@ -335,7 +335,37 @@ def _wrapped_create_course_completion(self, user_id, payload): 'grades[0][grade]': completion_data['grade'] * self.enterprise_configuration.grade_scale } - return self._post(params) + response = self._post(params) + + if hasattr(response, 'status_code'): + status_code = response.status_code + else: + status_code = None + + if hasattr(response, 'text'): + text = response.text + else: + text = None + + if hasattr(response, 'headers'): + headers = response.headers + else: + headers = None + + LOGGER.info( + 'Learner Data Transmission' + f'for course={completion_data["courseID"]} with data ' + f'source: {module_name}, ' + f'activityid: {course_module_id}, ' + f'grades[0][studentid]: {moodle_user_id}, ' + f'grades[0][grade]: {completion_data["grade"] * self.enterprise_configuration.grade_scale} ' + f' with response: {response} ' + f'Status Code: {status_code}, ' + f'Text: {text}, ' + f'Headers: {headers}, ' + ) + + return response def create_content_metadata(self, serialized_data): """ From 0b1ad8334970211bf1056dd4e8211560505745c2 Mon Sep 17 00:00:00 2001 From: MueezKhan246 <93375917+MueezKhan246@users.noreply.github.com> Date: Mon, 4 Dec 2023 12:48:47 +0500 Subject: [PATCH 047/164] refactor: updating build version (#1957) --- CHANGELOG.rst | 2 +- enterprise/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index e2a06efa4b..050402e7fd 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -16,7 +16,7 @@ Change Log Unreleased ---------- -[4.8.2] +[4.8.3] -------- refactor: adding log for learner data transmission diff --git a/enterprise/__init__.py b/enterprise/__init__.py index ad531fb9c8..2a3d377d89 100644 --- a/enterprise/__init__.py +++ b/enterprise/__init__.py @@ -2,4 +2,4 @@ Your project description goes here. """ -__version__ = "4.8.2" +__version__ = "4.8.3" From db7c42a9932a85a011d754556155dc52c3064538 Mon Sep 17 00:00:00 2001 From: mahamakifdar19 Date: Fri, 1 Dec 2023 13:30:42 +0500 Subject: [PATCH 048/164] fix: changed relative resumeCourseRunUrl to an absolute URL --- CHANGELOG.rst | 4 ++++ enterprise/__init__.py | 2 +- enterprise_learner_portal/api/v1/serializers.py | 11 ++++++++++- enterprise_learner_portal/api/v1/views.py | 1 + .../api/test_serializers.py | 10 +++++++--- .../test_enterprise_learner_portal/api/test_views.py | 8 ++++---- 6 files changed, 27 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 050402e7fd..45b9e1e204 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -16,6 +16,10 @@ Change Log Unreleased ---------- +[4.8.4] +-------- +fix: changed relative resumeCourseRunUrl to an absolute URL + [4.8.3] -------- refactor: adding log for learner data transmission diff --git a/enterprise/__init__.py b/enterprise/__init__.py index 2a3d377d89..aff8b6df91 100644 --- a/enterprise/__init__.py +++ b/enterprise/__init__.py @@ -2,4 +2,4 @@ Your project description goes here. """ -__version__ = "4.8.3" +__version__ = "4.8.4" diff --git a/enterprise_learner_portal/api/v1/serializers.py b/enterprise_learner_portal/api/v1/serializers.py index 0f91e59d39..cbbb6dfcd6 100644 --- a/enterprise_learner_portal/api/v1/serializers.py +++ b/enterprise_learner_portal/api/v1/serializers.py @@ -78,7 +78,7 @@ def to_representation(self, instance): representation['is_revoked'] = instance.license.is_revoked if instance.license else False representation['is_enrollment_active'] = instance.is_active representation['mode'] = instance.mode - representation['resume_course_run_url'] = self.context['course_enrollments_resume_urls'].get(course_run_id) + representation['resume_course_run_url'] = self._get_resume_course_run_url(course_run_id, request) if CourseDetails: course_details = CourseDetails.objects.filter(id=course_run_id).first() @@ -122,3 +122,12 @@ def _get_course_run_url(self, request, course_run_id): course_run_url = '{}?{}'.format(exec_ed_base_url, urlencode(params)) return course_run_url + + def _get_resume_course_run_url(self, course_run_id, request): + """ + Converts a relative resume course run URL to an absolute URL. + """ + resume_course_run_url = self.context['course_enrollments_resume_urls'].get(course_run_id) + if resume_course_run_url: + return request.build_absolute_uri(resume_course_run_url) + return None diff --git a/enterprise_learner_portal/api/v1/views.py b/enterprise_learner_portal/api/v1/views.py index 7319253e38..b962fa4733 100644 --- a/enterprise_learner_portal/api/v1/views.py +++ b/enterprise_learner_portal/api/v1/views.py @@ -53,6 +53,7 @@ def get(self, request): "org_name": "edX", "is_revoked": false, "is_enrollment_active": true + "resume_course_run_url": "http://localhost:18000/courses/course-v1:MITx+6.86x+2T2024" } ] diff --git a/tests/test_enterprise_learner_portal/api/test_serializers.py b/tests/test_enterprise_learner_portal/api/test_serializers.py index 42e1854c5f..5bca4083e2 100644 --- a/tests/test_enterprise_learner_portal/api/test_serializers.py +++ b/tests/test_enterprise_learner_portal/api/test_serializers.py @@ -4,12 +4,14 @@ from collections import OrderedDict from unittest import mock +from unittest.mock import patch from urllib.parse import urlencode import ddt from pytest import mark from django.conf import settings +from django.http import HttpRequest from django.test import RequestFactory, TestCase from enterprise.utils import NotConnectedToOpenEdX @@ -36,6 +38,7 @@ def setUp(self): (settings.EXEC_ED_LANDING_PAGE, True), ) @ddt.unpack + @patch.object(HttpRequest, 'get_host', return_value='courses.edx.org') @mock.patch('enterprise.models.CourseEnrollment') @mock.patch('enterprise_learner_portal.api.v1.serializers.get_course_run_status') @mock.patch('enterprise_learner_portal.api.v1.serializers.get_emails_enabled') @@ -50,6 +53,7 @@ def test_serializer_representation( mock_get_emails_enabled, mock_get_course_run_status, mock_course_enrollment_class, + _, ): """ EnterpriseCourseEnrollmentSerializer should create proper representation @@ -65,7 +69,7 @@ def test_serializer_representation( 'display_org_with_default': 'my university', }] course_enrollments_resume_urls = { - course_run_id: "http://example.com/resume_url", + course_run_id: '/courses/course-v1:MITx+6.86x+2T2024' } mock_get_cert.return_value = { @@ -90,7 +94,7 @@ def test_serializer_representation( mock_course_enrollment_class.objects.get.return_value.is_active = True mock_course_enrollment_class.objects.get.return_value.mode = 'verified' - request = self.factory.get('/') + request = HttpRequest() request.user = self.user serializer = EnterpriseCourseEnrollmentSerializer( @@ -119,7 +123,7 @@ def test_serializer_representation( ('is_revoked', False), ('is_enrollment_active', True), ('mode', 'verified'), - ('resume_course_run_url', 'http://example.com/resume_url'), + ('resume_course_run_url', 'http://courses.edx.org/courses/course-v1:MITx+6.86x+2T2024'), ]) actual = serializer.data[0] self.assertDictEqual(actual, expected) diff --git a/tests/test_enterprise_learner_portal/api/test_views.py b/tests/test_enterprise_learner_portal/api/test_views.py index 36bfe2f1e3..f43a297fd5 100644 --- a/tests/test_enterprise_learner_portal/api/test_views.py +++ b/tests/test_enterprise_learner_portal/api/test_views.py @@ -80,7 +80,7 @@ def test_view_returns_information(self, mock_get_overviews, mock_serializer, moc mock_get_overviews.return_value = {'overview_info': 'this would be a larger dict'} mock_serializer.return_value = self.MockSerializer() mock_course_resume_urls.return_value = { - 'course-v1:edX+DemoX+Demo_Course': 'http://example.com/resume_url' + 'course-v1:edX+DemoX+Demo_Course': '/courses/course-v1:MITx+6.86x+2T2024' } resp = self.client.get( @@ -114,7 +114,7 @@ def test_view_get_filters_active_enrollments( mock_get_overviews.return_value = {'overview_info': 'this would be a larger dict'} mock_serializer.return_value = self.MockSerializer() mock_course_resume_urls.return_value = { - 'course-v1:edX+DemoX+Demo_Course': 'http://example.com/resume_url' + 'course-v1:edX+DemoX+Demo_Course': '/courses/course-v1:MITx+6.86x+2T2024' } resp = self.client.get( @@ -147,7 +147,7 @@ def test_view_returns_bad_request_without_enterprise( mock_get_overviews.return_value = {'overview_info': 'this would be a larger dict'} mock_serializer.return_value = self.MockSerializer() mock_course_resume_urls.return_value = { - 'course-v1:edX+DemoX+Demo_Course': 'http://example.com/resume_url' + 'course-v1:edX+DemoX+Demo_Course': '/courses/course-v1:MITx+6.86x+2T2024' } resp = self.client.get( @@ -174,7 +174,7 @@ def test_view_returns_not_found_unlinked_enterprise( mock_get_overviews.return_value = {'overview_info': 'this would be a larger dict'} mock_serializer.return_value = self.MockSerializer() mock_course_resume_urls.return_value = { - 'course-v1:edX+DemoX+Demo_Course': 'http://example.com/resume_url' + 'course-v1:edX+DemoX+Demo_Course': '/courses/course-v1:MITx+6.86x+2T2024' } resp = self.client.get( From bae4c2571db07747817d3be37d288d6922f28b6f Mon Sep 17 00:00:00 2001 From: Sameen Fatima <55431213+sameenfatima78@users.noreply.github.com> Date: Tue, 5 Dec 2023 18:39:15 +0500 Subject: [PATCH 049/164] feat: job to assign skills to Degreed courses (#1958) --- CHANGELOG.rst | 4 + enterprise/__init__.py | 2 +- integrated_channels/degreed2/apps.py | 1 + integrated_channels/degreed2/client.py | 47 ++++++++ .../assign_skills_to_degreed_courses.py | 113 ++++++++++++++++++ 5 files changed, 166 insertions(+), 1 deletion(-) create mode 100644 integrated_channels/integrated_channel/management/commands/assign_skills_to_degreed_courses.py diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 45b9e1e204..fc80796996 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -16,6 +16,10 @@ Change Log Unreleased ---------- +[4.8.5] +-------- +feat: Added a management command to assign skills to Degreed courses + [4.8.4] -------- fix: changed relative resumeCourseRunUrl to an absolute URL diff --git a/enterprise/__init__.py b/enterprise/__init__.py index aff8b6df91..eba5c72fbe 100644 --- a/enterprise/__init__.py +++ b/enterprise/__init__.py @@ -2,4 +2,4 @@ Your project description goes here. """ -__version__ = "4.8.4" +__version__ = "4.8.5" diff --git a/integrated_channels/degreed2/apps.py b/integrated_channels/degreed2/apps.py index ee5ed03794..962c688233 100644 --- a/integrated_channels/degreed2/apps.py +++ b/integrated_channels/degreed2/apps.py @@ -15,3 +15,4 @@ class Degreed2Config(AppConfig): oauth_api_path = "/oauth/token" courses_api_path = "/api/v2/content/courses" completions_api_path = "/api/v2/completions" + skill_api_path = "api/v2/content/{contentId}/relationships/skills" diff --git a/integrated_channels/degreed2/client.py b/integrated_channels/degreed2/client.py index 472794fa3a..14cc9d61b3 100644 --- a/integrated_channels/degreed2/client.py +++ b/integrated_channels/degreed2/client.py @@ -52,6 +52,7 @@ def __init__(self, enterprise_configuration): self.oauth_api_path = app_config.oauth_api_path self.courses_api_path = app_config.courses_api_path self.completions_api_path = app_config.completions_api_path + self.skill_api_path = app_config.skill_api_path # to log without having to pass channel_name, ent_customer_uuid each time self.make_log_msg = lambda course_key, message, lms_user_id=None: generate_formatted_log( self.enterprise_configuration.channel_code(), @@ -72,6 +73,12 @@ def get_courses_url(self): def get_completions_url(self): return urljoin(self.enterprise_configuration.degreed_base_url, self.completions_api_path) + def get_course_skills_url(self, course_key): + return urljoin( + self.enterprise_configuration.degreed_base_url, + self.skill_api_path.format(contentId=course_key) + ) + def create_assessment_reporting(self, user_id, payload): """ Not implemented yet. @@ -197,6 +204,46 @@ def fetch_degreed_course_id(self, external_id): f'Degreed2: Attempted to find degreed course id but failed, external id was {external_id}' f', Response from Degreed was {response_body}') + def assign_course_skills(self, course_id, serialized_data): + """ + Assign skills to a course. + + Args: + serialized_data: JSON-encoded object containing skills metadata. + + Raises: + ClientError: + If degreed course id doesn't exist. + If Degreed course skills API request fails. + """ + + degreed_course_id = self.fetch_degreed_course_id(course_id) + if not degreed_course_id: + raise ClientError(f'Degreed2: Cannot find course via external-id {course_id}') + + course_skills_url = self.get_course_skills_url(degreed_course_id) + LOGGER.info(self.make_log_msg(course_id, f'Attempting to assign course skills {course_skills_url}')) + try: + status_code, response_body = self._patch(course_skills_url, serialized_data, self.CONTENT_WRITE_SCOPE) + if status_code == 201: + LOGGER.info( + self.make_log_msg( + course_id, + f'Succesfully assigned skills to course {course_id}') + ) + elif status_code >= 400: + raise ClientError( + f'Degreed2APIClient failed to assign skills to course {course_id}.' + f'Failed with status_code={status_code} and response={response_body}', + ) + except requests.exceptions.RequestException as exc: + raise ClientError( + 'Degreed2APIClient request to assign skills failed: {error} {message}'.format( + error=exc.__class__.__name__, + message=str(exc) + ) + ) from exc + def create_content_metadata(self, serialized_data): """ Create content metadata using the Degreed course content API. diff --git a/integrated_channels/integrated_channel/management/commands/assign_skills_to_degreed_courses.py b/integrated_channels/integrated_channel/management/commands/assign_skills_to_degreed_courses.py new file mode 100644 index 0000000000..a9ed23904c --- /dev/null +++ b/integrated_channels/integrated_channel/management/commands/assign_skills_to_degreed_courses.py @@ -0,0 +1,113 @@ +""" +Assign skills to degreed courses +""" +from logging import getLogger + +from requests.exceptions import ConnectionError, RequestException, Timeout # pylint: disable=redefined-builtin + +from django.contrib import auth +from django.core.management.base import BaseCommand, CommandError + +from enterprise.api_client.enterprise_catalog import EnterpriseCatalogApiClient +from integrated_channels.degreed2.client import Degreed2APIClient +from integrated_channels.exceptions import ClientError +from integrated_channels.integrated_channel.management.commands import IntegratedChannelCommandMixin +from integrated_channels.utils import generate_formatted_log + +User = auth.get_user_model() +LOGGER = getLogger(__name__) + + +class Command(IntegratedChannelCommandMixin, BaseCommand): + """ + Add skill metadata to existing Degreed courses. + + ./manage.py lms assign_skills_to_degreed_courses + """ + + def add_arguments(self, parser): + """ + Add required arguments to the parser. + """ + parser.add_argument( + '--catalog_user', + dest='catalog_user', + required=True, + metavar='ENTERPRISE_CATALOG_API_USERNAME', + help='Use this user to access the Enterprise Catalog API.' + ) + super().add_arguments(parser) + + def _prepare_json_payload_for_skills_endpoint(self, course_skills): + """ + Prepares a json payload for skills in the Degreed expected format. + """ + course_skills_json = [] + for skill in course_skills: + skill_data = {"type": "skills", "id": skill} + course_skills_json.append(skill_data) + return { + "data": course_skills_json + } + + def handle(self, *args, **options): + """ + Update all existing Degreed courses to assign skills metadata. + """ + options['channel'] = 'DEGREED2' + username = options['catalog_user'] + + try: + user = User.objects.get(username=username) + except User.DoesNotExist as no_user_error: + raise CommandError('A user with the username {} was not found.'.format(username)) from no_user_error + + enterprise_catalog_client = EnterpriseCatalogApiClient(user) + + for degreed_channel_config in self.get_integrated_channels(options): + enterprise_customer = degreed_channel_config.enterprise_customer + enterprise_customer_catalogs = degreed_channel_config.customer_catalogs_to_transmit or \ + enterprise_customer.enterprise_customer_catalogs.all() + try: + content_metadata_in_catalogs = enterprise_catalog_client.get_content_metadata( + enterprise_customer, + enterprise_customer_catalogs + ) + except (RequestException, ConnectionError, Timeout) as exc: + LOGGER.exception( + 'Failed to retrieve enterprise catalogs content metadata due to: [%s]', str(exc) + ) + continue + + degreed_client = Degreed2APIClient(degreed_channel_config) + + for content_item in content_metadata_in_catalogs: + + course_id = content_item.get('key', []) + course_skills = content_item.get('skill_names', []) + json_payload = self._prepare_json_payload_for_skills_endpoint(course_skills) + + # assign skills metadata to degreed course by first fetching degreed course id + try: + degreed_client.assign_course_skills(course_id, json_payload) + except ClientError as error: + LOGGER.error( + generate_formatted_log( + degreed_channel_config.channel_code(), + degreed_channel_config.enterprise_customer.uuid, + None, + None, + f'Degreed2APIClient assign_course_skills failed for course {course_id} ' + f'with message: {error.message}' + ) + ) + except RequestException as error: + LOGGER.error( + generate_formatted_log( + degreed_channel_config.channel_code(), + degreed_channel_config.enterprise_customer.uuid, + None, + None, + f'Degreed2APIClient request to assign skills failed with message: {error.message}' + ) + ) From 97c9128aa0993d03a68365b4c075634e9f3c6b80 Mon Sep 17 00:00:00 2001 From: Marlon Keating Date: Mon, 4 Dec 2023 21:33:21 +0000 Subject: [PATCH 050/164] feat: add marked_authorized flag to SSO config chore: bump version to 4.8.6 --- CHANGELOG.rst | 4 +++ enterprise/__init__.py | 2 +- .../migrations/0195_auto_20231130_1837.py | 23 +++++++++++++++ .../0196_backfill_sso_marked_authorized.py | 29 +++++++++++++++++++ .../migrations/0197_auto_20231130_2239.py | 23 +++++++++++++++ enterprise/models.py | 9 ++++++ 6 files changed, 89 insertions(+), 1 deletion(-) create mode 100644 enterprise/migrations/0195_auto_20231130_1837.py create mode 100644 enterprise/migrations/0196_backfill_sso_marked_authorized.py create mode 100644 enterprise/migrations/0197_auto_20231130_2239.py diff --git a/CHANGELOG.rst b/CHANGELOG.rst index fc80796996..f5ed4ff4ef 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -16,6 +16,10 @@ Change Log Unreleased ---------- +[4.8.6] +-------- +feat: add marked_authorized flag to SSO config + [4.8.5] -------- feat: Added a management command to assign skills to Degreed courses diff --git a/enterprise/__init__.py b/enterprise/__init__.py index eba5c72fbe..e61935bbee 100644 --- a/enterprise/__init__.py +++ b/enterprise/__init__.py @@ -2,4 +2,4 @@ Your project description goes here. """ -__version__ = "4.8.5" +__version__ = "4.8.6" diff --git a/enterprise/migrations/0195_auto_20231130_1837.py b/enterprise/migrations/0195_auto_20231130_1837.py new file mode 100644 index 0000000000..aa9895e0d6 --- /dev/null +++ b/enterprise/migrations/0195_auto_20231130_1837.py @@ -0,0 +1,23 @@ +# Generated by Django 3.2.23 on 2023-11-30 18:37 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('enterprise', '0194_auto_20231013_1359'), + ] + + operations = [ + migrations.AddField( + model_name='enterprisecustomerssoconfiguration', + name='marked_authorized', + field=models.BooleanField(blank=True, default=False, help_text='Whether admin has indicated the service provider metadata was uploaded.', null=True), + ), + migrations.AddField( + model_name='historicalenterprisecustomerssoconfiguration', + name='marked_authorized', + field=models.BooleanField(blank=True, default=False, help_text='Whether admin has indicated the service provider metadata was uploaded.', null=True), + ), + ] diff --git a/enterprise/migrations/0196_backfill_sso_marked_authorized.py b/enterprise/migrations/0196_backfill_sso_marked_authorized.py new file mode 100644 index 0000000000..27b6925623 --- /dev/null +++ b/enterprise/migrations/0196_backfill_sso_marked_authorized.py @@ -0,0 +1,29 @@ +# Generated by Django 3.2.23 on 2023-11-30 19:01 + +from django.db import migrations, models + + +def backfill_sso_marked_authorized(apps, app_schema): + """ + Sets all Enterprise Customer SSO Configuration records 'marked_authorized' to False, in anticipation + of making it the default value + """ + EnterpriseCustomerSsoConfiguration = apps.get_model('enterprise', 'EnterpriseCustomerSsoConfiguration') + queryset = EnterpriseCustomerSsoConfiguration.all_objects.all() + for sso_config in queryset: + sso_config.marked_authorized = False + EnterpriseCustomerSsoConfiguration.all_objects.bulk_update(queryset, ['marked_authorized']) + + +class Migration(migrations.Migration): + + dependencies = [ + ('enterprise', '0195_auto_20231130_1837'), + ] + + operations = [ + migrations.RunPython( + code=backfill_sso_marked_authorized, + reverse_code=migrations.RunPython.noop, + ), + ] diff --git a/enterprise/migrations/0197_auto_20231130_2239.py b/enterprise/migrations/0197_auto_20231130_2239.py new file mode 100644 index 0000000000..75936a92b7 --- /dev/null +++ b/enterprise/migrations/0197_auto_20231130_2239.py @@ -0,0 +1,23 @@ +# Generated by Django 3.2.23 on 2023-11-30 22:39 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('enterprise', '0196_backfill_sso_marked_authorized'), + ] + + operations = [ + migrations.AlterField( + model_name='enterprisecustomerssoconfiguration', + name='marked_authorized', + field=models.BooleanField(default=False, help_text='Whether admin has indicated the service provider metadata was uploaded.'), + ), + migrations.AlterField( + model_name='historicalenterprisecustomerssoconfiguration', + name='marked_authorized', + field=models.BooleanField(default=False, help_text='Whether admin has indicated the service provider metadata was uploaded.'), + ), + ] diff --git a/enterprise/models.py b/enterprise/models.py index 7eb2f2256a..2d2378fb2a 100644 --- a/enterprise/models.py +++ b/enterprise/models.py @@ -3955,6 +3955,15 @@ class Meta: ) ) + marked_authorized = models.BooleanField( + blank=False, + null=False, + default=False, + help_text=_( + "Whether admin has indicated the service provider metadata was uploaded." + ) + ) + # ---------------------------- SAP Success Factors attribute mappings ---------------------------- # odata_api_timeout_interval = models.PositiveIntegerField( From 6a59a2c7b728c646381942303b2e9df033b23186 Mon Sep 17 00:00:00 2001 From: Zaman Afzal Date: Wed, 6 Dec 2023 15:00:32 +0500 Subject: [PATCH 051/164] Override the default save method of ``EnterpriseCustomerPluginConfiguration`` to update only changed fields (#1960) * refactor: change save method of EnterpriseCustomerPluginConfiguration to update only changed fields --- CHANGELOG.rst | 3 +++ enterprise/__init__.py | 2 +- .../commands/update_config_last_errored_at.py | 4 +++- .../integrated_channel/models.py | 20 +++++++++++++++++-- 4 files changed, 25 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index f5ed4ff4ef..557ebc7f2c 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -15,6 +15,9 @@ Change Log Unreleased ---------- +[4.8.7] +-------- +refactor: Override the default save method of ``EnterpriseCustomerPluginConfiguration`` to update only changed fields [4.8.6] -------- diff --git a/enterprise/__init__.py b/enterprise/__init__.py index e61935bbee..f5de6fc19a 100644 --- a/enterprise/__init__.py +++ b/enterprise/__init__.py @@ -2,4 +2,4 @@ Your project description goes here. """ -__version__ = "4.8.6" +__version__ = "4.8.7" diff --git a/enterprise/management/commands/update_config_last_errored_at.py b/enterprise/management/commands/update_config_last_errored_at.py index 898377beb5..e006e6d483 100644 --- a/enterprise/management/commands/update_config_last_errored_at.py +++ b/enterprise/management/commands/update_config_last_errored_at.py @@ -111,7 +111,9 @@ def update_config_last_errored_at(self): config.id, channel_code, config.enterprise_customer.uuid ) ) - config.save() + config.save(update_fields=["last_learner_sync_errored_at", "last_content_sync_errored_at", + "last_sync_errored_at"]) + except Exception as exc: LOGGER.exception('update_config_last_errored_at', exc_info=exc) raise exc diff --git a/integrated_channels/integrated_channel/models.py b/integrated_channels/integrated_channel/models.py index e81dee4de8..4e20d2a43f 100644 --- a/integrated_channels/integrated_channel/models.py +++ b/integrated_channels/integrated_channel/models.py @@ -273,31 +273,47 @@ def update_content_synced_at(self, action_happened_at, was_successful): """ Given the last time a Content record sync was attempted and status update the appropriate timestamps. """ + update_fields = [] if self.last_sync_attempted_at is None or action_happened_at > self.last_sync_attempted_at: self.last_sync_attempted_at = action_happened_at + update_fields.append('last_sync_attempted_at') if self.last_content_sync_attempted_at is None or action_happened_at > self.last_content_sync_attempted_at: self.last_content_sync_attempted_at = action_happened_at + update_fields.append('last_content_sync_attempted_at') if not was_successful: if self.last_sync_errored_at is None or action_happened_at > self.last_sync_errored_at: self.last_sync_errored_at = action_happened_at + update_fields.append('last_sync_errored_at') if self.last_content_sync_errored_at is None or action_happened_at > self.last_content_sync_errored_at: self.last_content_sync_errored_at = action_happened_at - return self.save() + update_fields.append('last_content_sync_errored_at') + if update_fields: + return self.save(update_fields=update_fields) + else: + return self def update_learner_synced_at(self, action_happened_at, was_successful): """ Given the last time a Learner record sync was attempted and status update the appropriate timestamps. """ + update_fields = [] if self.last_sync_attempted_at is None or action_happened_at > self.last_sync_attempted_at: self.last_sync_attempted_at = action_happened_at + update_fields.append('last_sync_attempted_at') if self.last_learner_sync_attempted_at is None or action_happened_at > self.last_learner_sync_attempted_at: self.last_learner_sync_attempted_at = action_happened_at + update_fields.append('last_learner_sync_attempted_at') if not was_successful: if self.last_sync_errored_at is None or action_happened_at > self.last_sync_errored_at: self.last_sync_errored_at = action_happened_at + update_fields.append('last_sync_errored_at') if self.last_learner_sync_errored_at is None or action_happened_at > self.last_learner_sync_errored_at: self.last_learner_sync_errored_at = action_happened_at - return self.save() + update_fields.append('last_learner_sync_errored_at') + if update_fields: + return self.save(update_fields=update_fields) + else: + return self @property def is_valid(self): From c3f16469ca18351af35b0af9f5853231059b038f Mon Sep 17 00:00:00 2001 From: Sameen Fatima <55431213+sameenfatima78@users.noreply.github.com> Date: Wed, 6 Dec 2023 18:00:41 +0500 Subject: [PATCH 052/164] fix: added logs and handled edge cases (#1963) --- CHANGELOG.rst | 4 + enterprise/__init__.py | 2 +- integrated_channels/degreed2/client.py | 6 +- .../assign_skills_to_degreed_courses.py | 27 ++++- test_utils/fake_catalog_api.py | 6 +- .../test_degreed2/test_client.py | 111 ++++++++++++++++++ tests/test_management.py | 62 +++++++++- 7 files changed, 206 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 557ebc7f2c..5ad7c28a64 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -15,6 +15,10 @@ Change Log Unreleased ---------- +[4.8.8] +-------- +fix: added more logs and handled edge cases in Degreed assign skills job + [4.8.7] -------- refactor: Override the default save method of ``EnterpriseCustomerPluginConfiguration`` to update only changed fields diff --git a/enterprise/__init__.py b/enterprise/__init__.py index f5de6fc19a..0d14033546 100644 --- a/enterprise/__init__.py +++ b/enterprise/__init__.py @@ -2,4 +2,4 @@ Your project description goes here. """ -__version__ = "4.8.7" +__version__ = "4.8.8" diff --git a/integrated_channels/degreed2/client.py b/integrated_channels/degreed2/client.py index 14cc9d61b3..00070bda1e 100644 --- a/integrated_channels/degreed2/client.py +++ b/integrated_channels/degreed2/client.py @@ -204,16 +204,17 @@ def fetch_degreed_course_id(self, external_id): f'Degreed2: Attempted to find degreed course id but failed, external id was {external_id}' f', Response from Degreed was {response_body}') - def assign_course_skills(self, course_id, serialized_data): + def assign_course_skills(self, course_id, serialized_data): # pylint: disable=inconsistent-return-statements """ Assign skills to a course. Args: + course_id: Course key serialized_data: JSON-encoded object containing skills metadata. Raises: ClientError: - If degreed course id doesn't exist. + If Degreed course id doesn't exist. If Degreed course skills API request fails. """ @@ -231,6 +232,7 @@ def assign_course_skills(self, course_id, serialized_data): course_id, f'Succesfully assigned skills to course {course_id}') ) + return status_code, response_body elif status_code >= 400: raise ClientError( f'Degreed2APIClient failed to assign skills to course {course_id}.' diff --git a/integrated_channels/integrated_channel/management/commands/assign_skills_to_degreed_courses.py b/integrated_channels/integrated_channel/management/commands/assign_skills_to_degreed_courses.py index a9ed23904c..715093f2e0 100644 --- a/integrated_channels/integrated_channel/management/commands/assign_skills_to_degreed_courses.py +++ b/integrated_channels/integrated_channel/management/commands/assign_skills_to_degreed_courses.py @@ -63,8 +63,8 @@ def handle(self, *args, **options): raise CommandError('A user with the username {} was not found.'.format(username)) from no_user_error enterprise_catalog_client = EnterpriseCatalogApiClient(user) - - for degreed_channel_config in self.get_integrated_channels(options): + integrated_channels = self.get_integrated_channels(options) + for degreed_channel_config in integrated_channels: enterprise_customer = degreed_channel_config.enterprise_customer enterprise_customer_catalogs = degreed_channel_config.customer_catalogs_to_transmit or \ enterprise_customer.enterprise_customer_catalogs.all() @@ -80,11 +80,24 @@ def handle(self, *args, **options): continue degreed_client = Degreed2APIClient(degreed_channel_config) + LOGGER.info( + generate_formatted_log( + degreed_channel_config.channel_code(), + enterprise_customer.uuid, + None, + None, + f'[Degreed Skills] Attempting to assign skills for customer {enterprise_customer.slug}' + ) + ) for content_item in content_metadata_in_catalogs: - - course_id = content_item.get('key', []) + course_id = content_item.get('key', None) course_skills = content_item.get('skill_names', []) + + # if we get empty list of skills, there's no point making API call to Degreed. + if not course_skills: + continue + json_payload = self._prepare_json_payload_for_skills_endpoint(course_skills) # assign skills metadata to degreed course by first fetching degreed course id @@ -94,20 +107,22 @@ def handle(self, *args, **options): LOGGER.error( generate_formatted_log( degreed_channel_config.channel_code(), - degreed_channel_config.enterprise_customer.uuid, + enterprise_customer.uuid, None, None, f'Degreed2APIClient assign_course_skills failed for course {course_id} ' f'with message: {error.message}' ) ) + continue except RequestException as error: LOGGER.error( generate_formatted_log( degreed_channel_config.channel_code(), - degreed_channel_config.enterprise_customer.uuid, + enterprise_customer.uuid, None, None, f'Degreed2APIClient request to assign skills failed with message: {error.message}' ) ) + continue diff --git a/test_utils/fake_catalog_api.py b/test_utils/fake_catalog_api.py index 0778a13003..44d3f33433 100644 --- a/test_utils/fake_catalog_api.py +++ b/test_utils/fake_catalog_api.py @@ -220,7 +220,8 @@ 'content_type': 'course', 'enrollment_url': FAKE_URL, 'programs': [], - 'content_last_modified': '2020-08-18T00:32:33.754662Z' + 'content_last_modified': '2020-08-18T00:32:33.754662Z', + 'skill_names': ['Information Technology'] } FAKE_COURSE_TO_CREATE = { @@ -258,7 +259,8 @@ 'content_type': 'course', 'enrollment_url': FAKE_URL, 'programs': [], - 'content_last_modified': '2020-08-18T00:32:33.754662Z' + 'content_last_modified': '2020-08-18T00:32:33.754662Z', + 'skill_names': ['Machine Learning'] } FAKE_COURSE = { diff --git a/tests/test_integrated_channels/test_degreed2/test_client.py b/tests/test_integrated_channels/test_degreed2/test_client.py index de35c3f92e..454581407f 100644 --- a/tests/test_integrated_channels/test_degreed2/test_client.py +++ b/tests/test_integrated_channels/test_degreed2/test_client.py @@ -381,6 +381,117 @@ def test_update_content_metadata_success(self, mock_fetch_degreed_course_id): assert status_code == 200 assert response_body == '"{}"' + @responses.activate + @mock.patch('integrated_channels.degreed2.client.Degreed2APIClient.fetch_degreed_course_id') + def test_assign_course_skills(self, mock_fetch_degreed_course_id): + """ + ``assign_course_skills`` should use the appropriate URL for making API call. + """ + payload = { + "data": [ + { + "id": "Financial Technology", + "type": "skills" + } + ] + } + test_course_key = 'a_course_id' + mock_fetch_degreed_course_id.return_value = test_course_key + enterprise_config = factories.Degreed2EnterpriseCustomerConfigurationFactory() + degreed_api_client = Degreed2APIClient(enterprise_config) + oauth_url = degreed_api_client.get_oauth_url() + + responses.add( + responses.POST, + oauth_url, + json=self.expected_token_response_body, + status=200 + ) + responses.add( + responses.PATCH, + f'{degreed_api_client.get_course_skills_url(test_course_key)}', + json='{}', + status=201 + ) + + status_code, response_body = degreed_api_client.assign_course_skills('edx_course_key', payload) + assert len(responses.calls) == 2 + assert responses.calls[0].request.url == oauth_url + assert responses.calls[1].request.url == f'{degreed_api_client.get_course_skills_url(test_course_key)}' + assert status_code == 201 + assert response_body == '"{}"' + + @responses.activate + @mock.patch('integrated_channels.degreed2.client.Degreed2APIClient.fetch_degreed_course_id') + def test_assign_skills_api_connection_error(self, mock_fetch_degreed_course_id): + """ + ``assign_course_skills`` should raise ClientError when API request fails with a connection error. + """ + test_course_key = 'a_course_id' + mock_fetch_degreed_course_id.return_value = test_course_key + enterprise_config = factories.Degreed2EnterpriseCustomerConfigurationFactory() + degreed_api_client = Degreed2APIClient(enterprise_config) + oauth_url = degreed_api_client.get_oauth_url() + + responses.add( + responses.POST, + oauth_url, + json=self.expected_token_response_body, + status=200 + ) + responses.add( + responses.PATCH, + f'{degreed_api_client.get_course_skills_url(test_course_key)}', + body=requests.exceptions.RequestException() + ) + + payload = { + "data": [ + { + "id": "Financial Technology", + "type": "skills" + } + ] + } + with pytest.raises(ClientError): + degreed_api_client.assign_course_skills('edx_course_key', payload) + + @responses.activate + @mock.patch('integrated_channels.degreed2.client.Degreed2APIClient.fetch_degreed_course_id') + def test_assign_skills_api_failure_response(self, mock_fetch_degreed_course_id): + """ + ``assign_course_skills`` should raise ClientError when API request fails with a with status code above 400. + """ + test_course_key = 'a_course_id' + mock_fetch_degreed_course_id.return_value = test_course_key + enterprise_config = factories.Degreed2EnterpriseCustomerConfigurationFactory() + degreed_api_client = Degreed2APIClient(enterprise_config) + oauth_url = degreed_api_client.get_oauth_url() + + responses.add( + responses.POST, + oauth_url, + json=self.expected_token_response_body, + status=200 + ) + responses.add( + responses.PATCH, + f'{degreed_api_client.get_course_skills_url(test_course_key)}', + json='{}', + status=400 + ) + + payload = { + "data": [ + { + "id": "Financial Technology", + "type": "skills" + } + ] + } + with pytest.raises(ClientError): + degreed_api_client.assign_course_skills('edx_course_key', payload) + @responses.activate @mock.patch('integrated_channels.degreed2.client.Degreed2APIClient.fetch_degreed_course_id') def test_update_content_metadata_retry_success(self, mock_fetch_degreed_course_id): diff --git a/tests/test_management.py b/tests/test_management.py index 62eaf07c16..f6555edaf7 100644 --- a/tests/test_management.py +++ b/tests/test_management.py @@ -59,7 +59,12 @@ from integrated_channels.sap_success_factors.exporters.learner_data import SapSuccessFactorsLearnerManger from integrated_channels.sap_success_factors.models import SAPSuccessFactorsEnterpriseCustomerConfiguration from test_utils import ReturnValueSpy, factories -from test_utils.fake_catalog_api import CourseDiscoveryApiTestMixin, setup_course_catalog_api_client_mock +from test_utils.fake_catalog_api import ( + FAKE_COURSE_TO_CREATE, + FAKE_COURSE_TO_CREATE_2, + CourseDiscoveryApiTestMixin, + setup_course_catalog_api_client_mock, +) from test_utils.fake_enterprise_api import EnterpriseMockMixin User = auth.get_user_model() @@ -1405,6 +1410,61 @@ def test_unlink_inactive_sap_learners_task_sapsf_error_response( assert len(calls_to_search_url) > 0 +@mark.django_db +@ddt.ddt +class TestAssignSkillstoDegreedCoursesManagementCommand(unittest.TestCase): + """ + Test the ``assign_skills_to_degreed_courses`` management command. + """ + + def setUp(self): + self.user = factories.UserFactory(username='C-3PO') + self.enterprise_customer = factories.EnterpriseCustomerFactory( + active=True, + name='Degreed Customer', + ) + self.enterprise_customer_2 = factories.EnterpriseCustomerFactory( + active=True, + name='Degreed Customer 2', + ) + self.enterprise_catalog = factories.EnterpriseCustomerCatalogFactory( + enterprise_customer=self.enterprise_customer, + ) + self.enterprise_catalog_2 = factories.EnterpriseCustomerCatalogFactory( + enterprise_customer=self.enterprise_customer_2, + ) + self.degreed_config = factories.Degreed2EnterpriseCustomerConfigurationFactory( + + enterprise_customer=self.enterprise_customer, + degreed_base_url='http://betatest.degreed.com/', + ) + self.degreed_config_2 = factories.Degreed2EnterpriseCustomerConfigurationFactory( + enterprise_customer=self.enterprise_customer_2, + degreed_base_url='http://betatest.degreed.com/', + ) + super().setUp() + + @responses.activate + @mock.patch('enterprise.api_client.enterprise_catalog.EnterpriseCatalogApiClient.get_content_metadata') + @mock.patch('integrated_channels.degreed2.client.Degreed2APIClient.assign_course_skills') + def test_assign_skills_command( + self, + mock_degreed2_client, + mock_enterprise_catalog_client + ): + """ + Test the unlink inactive learners task without any SAP integrated channel. + """ + mock_degreed2_client.return_value = 201, '{}' + mock_enterprise_catalog_client.return_value = [FAKE_COURSE_TO_CREATE, FAKE_COURSE_TO_CREATE_2] + with LogCapture(level=logging.INFO) as log: + log_message = '[Degreed Skills] Attempting to assign skills for customer' + call_command('assign_skills_to_degreed_courses', '--catalog_user', self.user.username) + assert log_message in log.records[-1].getMessage() + # should make a call for two courses for both degreed enterprises with active config + self.assertEqual(mock_degreed2_client.call_count, 4) + + @ddt.ddt @mark.django_db class TestUpdateRoleAssignmentsCommand(unittest.TestCase): From 77270a6a21c865bb2ced375c3ebd8e3c8f9bc8bb Mon Sep 17 00:00:00 2001 From: Alexander J Sheehan Date: Tue, 5 Dec 2023 23:00:01 +0000 Subject: [PATCH 053/164] feat: adding timeouts to sso orchestrator configurations and api cleanup --- CHANGELOG.rst | 4 ++++ enterprise/__init__.py | 2 +- .../enterprise_customer_sso_configuration.py | 13 +++++----- enterprise/models.py | 24 ++++++++++++++----- 4 files changed, 30 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 5ad7c28a64..1f0bbddebe 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -15,6 +15,10 @@ Change Log Unreleased ---------- +[4.8.9] +------- +feat: adding timeouts to sso orchestrator configurations and api cleanup + [4.8.8] -------- fix: added more logs and handled edge cases in Degreed assign skills job diff --git a/enterprise/__init__.py b/enterprise/__init__.py index 0d14033546..49f6ee888a 100644 --- a/enterprise/__init__.py +++ b/enterprise/__init__.py @@ -2,4 +2,4 @@ Your project description goes here. """ -__version__ = "4.8.8" +__version__ = "4.8.9" diff --git a/enterprise/api/v1/views/enterprise_customer_sso_configuration.py b/enterprise/api/v1/views/enterprise_customer_sso_configuration.py index f123e136e9..49c03e9ae8 100644 --- a/enterprise/api/v1/views/enterprise_customer_sso_configuration.py +++ b/enterprise/api/v1/views/enterprise_customer_sso_configuration.py @@ -37,6 +37,7 @@ BAD_CUSTOMER_ERROR = 'Must provide valid enterprise customer' CONFIG_UPDATE_ERROR = 'Error updating SSO configuration record' CONFIG_CREATE_ERROR = 'Error creating SSO configuration record' +BAD_IDP_METADATA_URL = 'Must provide valid IDP metadata url' class EnterpriseCustomerInactiveException(Exception): @@ -243,9 +244,9 @@ def create(self, request, *args, **kwargs): # If the metadata url has changed, we need to update the metadata xml try: sso_config_metadata_xml = get_metadata_xml_from_url(request_metadata_url) - except SsoConfigurationApiError as e: - LOGGER.error(f'{CONFIG_UPDATE_ERROR}{e}') - return Response({'error': f'{CONFIG_UPDATE_ERROR} {e}'}, status=HTTP_400_BAD_REQUEST) + except (SsoConfigurationApiError, requests.exceptions.SSLError) as e: + LOGGER.error(f'{BAD_IDP_METADATA_URL}{e}') + return Response({'error': f'{BAD_IDP_METADATA_URL} {e}'}, status=HTTP_400_BAD_REQUEST) request_data['metadata_xml'] = sso_config_metadata_xml if sso_config_metadata_xml or (sso_config_metadata_xml := request_data.get('metadata_xml')): try: @@ -292,9 +293,9 @@ def update(self, request, *args, **kwargs): # If the metadata url has changed, we need to update the metadata xml try: sso_config_metadata_xml = get_metadata_xml_from_url(request_metadata_url) - except SsoConfigurationApiError as e: - LOGGER.error(f'{CONFIG_UPDATE_ERROR} {e}') - return Response({'error': f'{CONFIG_UPDATE_ERROR} {e}'}, status=HTTP_400_BAD_REQUEST) + except (SsoConfigurationApiError, requests.exceptions.SSLError) as e: + LOGGER.error(f'{BAD_IDP_METADATA_URL}{e}') + return Response({'error': f'{BAD_IDP_METADATA_URL} {e}'}, status=HTTP_400_BAD_REQUEST) request_data['metadata_xml'] = sso_config_metadata_xml if request_metadata_xml := request_data.get('metadata_xml'): if request_metadata_xml != sso_configuration_record.first().metadata_xml: diff --git a/enterprise/models.py b/enterprise/models.py index 2d2378fb2a..4ad711cee8 100644 --- a/enterprise/models.py +++ b/enterprise/models.py @@ -3,6 +3,7 @@ """ import collections +import datetime import itertools import json from decimal import Decimal @@ -4066,12 +4067,23 @@ def is_pending_configuration(self): Returns True if the configuration has been submitted but not completed configuration. """ if self.submitted_at: - if not self.configured_at: - return True - if self.errored_at and self.errored_at > self.submitted_at: - return False - if self.submitted_at > self.configured_at: - return True + # The configuration times out after 12 hours. If the configuration has not been submitted in the last 12 + # hours then it can be considered unblocked. + sso_config_timeout_hours = getattr(settings, "ENTERPRISE_SSO_ORCHESTRATOR_TIMEOUT_HOURS", 1) + sso_config_timeout_minutes = getattr(settings, "ENTERPRISE_SSO_ORCHESTRATOR_TIMEOUT_MINUTES", 0) + timeout_timedelta = datetime.timedelta(hours=sso_config_timeout_hours, minutes=sso_config_timeout_minutes) + if (self.submitted_at + timeout_timedelta) > localized_utcnow(): + # if we have received an error from the orchestrator after submitting the configuration, it is + # unblocked + if self.errored_at and self.errored_at > self.submitted_at: + return False + # If we have not gotten a response from the orchestrator, it is still configuring + if not self.configured_at: + return True + # If we have gotten a response from the orchestrator, but it's before the submission time, it is still + # configuring + if self.submitted_at > self.configured_at: + return True return False def submit_for_configuration(self, updating_existing_record=False): From 8179f81538bcaafd0d2167406def4b9a8752d714 Mon Sep 17 00:00:00 2001 From: Alexander J Sheehan Date: Thu, 7 Dec 2023 20:22:34 +0000 Subject: [PATCH 054/164] feat: not submitting sso orchestrator records if no changes occur --- CHANGELOG.rst | 4 ++++ enterprise/__init__.py | 2 +- .../enterprise_customer_sso_configuration.py | 24 +++++++++++++++---- enterprise/models.py | 4 ++-- tests/test_enterprise/api/test_views.py | 3 ++- 5 files changed, 29 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 1f0bbddebe..8c57dac379 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -15,6 +15,10 @@ Change Log Unreleased ---------- +[4.8.10] +-------- +feat: not submitting sso orchestrator records if no changes occur + [4.8.9] ------- feat: adding timeouts to sso orchestrator configurations and api cleanup diff --git a/enterprise/__init__.py b/enterprise/__init__.py index 49f6ee888a..2648d5777c 100644 --- a/enterprise/__init__.py +++ b/enterprise/__init__.py @@ -2,4 +2,4 @@ Your project description goes here. """ -__version__ = "4.8.9" +__version__ = "4.8.10" diff --git a/enterprise/api/v1/views/enterprise_customer_sso_configuration.py b/enterprise/api/v1/views/enterprise_customer_sso_configuration.py index 49c03e9ae8..4d5e5e41b5 100644 --- a/enterprise/api/v1/views/enterprise_customer_sso_configuration.py +++ b/enterprise/api/v1/views/enterprise_customer_sso_configuration.py @@ -321,15 +321,31 @@ def update(self, request, *args, **kwargs): return Response(status=HTTP_403_FORBIDDEN) try: with transaction.atomic(): + needs_submitting = False + for request_key in request_data.keys(): + # If the requested data to update includes a field that is locked while configuring + if request_key in EnterpriseCustomerSsoConfiguration.fields_locked_while_configuring: + # If any of the provided values differ from the existing value + existing_value = getattr( + sso_configuration_record.first(), request_key, request_data[request_key] + ) + if existing_value != request_data[request_key]: + # Indicate that the record needs to be submitted for configuration to the orchestrator + needs_submitting = True sso_configuration_record.update(**request_data) - sp_metadata_url = sso_configuration_record.first().submit_for_configuration( - updating_existing_record=True - ) + sp_metadata_url = '' + if needs_submitting: + sp_metadata_url = sso_configuration_record.first().submit_for_configuration( + updating_existing_record=True + ) except (TypeError, FieldDoesNotExist, ValidationError, SsoOrchestratorClientError) as e: LOGGER.error(f'{CONFIG_UPDATE_ERROR} {e}') return Response({'error': f'{CONFIG_UPDATE_ERROR} {e}'}, status=HTTP_400_BAD_REQUEST) serializer = self.serializer_class(sso_configuration_record.first()) - return Response({'record': serializer.data, 'sp_metadata_url': sp_metadata_url}, status=HTTP_200_OK) + response = {'record': serializer.data} + if sp_metadata_url: + response['sp_metadata_url'] = sp_metadata_url + return Response(response, status=HTTP_200_OK) @permission_required( 'enterprise.can_access_admin_dashboard', diff --git a/enterprise/models.py b/enterprise/models.py index 4ad711cee8..e3c47734cf 100644 --- a/enterprise/models.py +++ b/enterprise/models.py @@ -4110,9 +4110,9 @@ def submit_for_configuration(self, updating_existing_record=False): for field in self.base_saml_config_fields: if field == "active": if not updating_existing_record: - config_data['enable'] = True + config_data['enabled'] = True else: - config_data['enable'] = getattr(self, field) + config_data['enabled'] = getattr(self, field) elif field_value := getattr(self, field): config_data[utils.camelCase(field)] = field_value diff --git a/tests/test_enterprise/api/test_views.py b/tests/test_enterprise/api/test_views.py index 0b0c68a216..aa41038a9a 100644 --- a/tests/test_enterprise/api/test_views.py +++ b/tests/test_enterprise/api/test_views.py @@ -7763,7 +7763,8 @@ def test_sso_configurations_update_submitted_config(self): enterprise_sso_orchestration_config = EnterpriseCustomerSsoConfigurationFactory( uuid=config_pk, enterprise_customer=self.enterprise_customer, - submitted_at=localized_utcnow() + submitted_at=localized_utcnow(), + metadata_url="old_url", ) data = { "metadata_url": "https://example.com/metadata.xml", From cc4a8100fb2d030d745194a9951467283e696109 Mon Sep 17 00:00:00 2001 From: MueezKhan246 <93375917+MueezKhan246@users.noreply.github.com> Date: Mon, 11 Dec 2023 15:14:06 +0500 Subject: [PATCH 055/164] Restructuring moodle course completion request and allowed continuous learner transmission till course completion (#1964) * refactor: adding log inside moodle request wrapper * feat: allow incomplete course learner transmissions till completion --- CHANGELOG.rst | 5 ++++ enterprise/__init__.py | 2 +- integrated_channels/moodle/client.py | 37 ++++++++++++++++++---------- integrated_channels/utils.py | 3 ++- 4 files changed, 32 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 8c57dac379..ff4bec00b4 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -15,6 +15,11 @@ Change Log Unreleased ---------- +[4.8.11] +-------- +feat: allow incomplete course learner transmissions till completion +refactor: adding log inside moodle request wrapper + [4.8.10] -------- feat: not submitting sso orchestrator records if no changes occur diff --git a/enterprise/__init__.py b/enterprise/__init__.py index 2648d5777c..21c8fa1dd8 100644 --- a/enterprise/__init__.py +++ b/enterprise/__init__.py @@ -2,4 +2,4 @@ Your project description goes here. """ -__version__ = "4.8.10" +__version__ = "4.8.11" diff --git a/integrated_channels/moodle/client.py b/integrated_channels/moodle/client.py index 9ab8233686..7f6ac96e0e 100644 --- a/integrated_channels/moodle/client.py +++ b/integrated_channels/moodle/client.py @@ -61,6 +61,17 @@ def inner(self, *args, **kwargs): # This only happens for grades AFAICT. Zero also doesn't necessarily mean success, # but we have nothing else to go on if body == 0: + if method.__name__ == "_wrapped_create_course_completion": + completion_data = kwargs.get('payload') + course_id = completion_data.get('courseID', None) + LOGGER.info( + 'Integer Response for Moodle Course Completion' + f'for course={course_id} ' + f' response: {response} ' + f'Status Code: {response.status_code}, ' + f'Text: {response.text}, ' + f'Headers: {response.headers}, ' + ) return 200, '' raise ClientError('Moodle API Grade Update failed with int code: {code}'.format(code=body), 500) if isinstance(body, str): @@ -351,19 +362,19 @@ def _wrapped_create_course_completion(self, user_id, payload): headers = response.headers else: headers = None - - LOGGER.info( - 'Learner Data Transmission' - f'for course={completion_data["courseID"]} with data ' - f'source: {module_name}, ' - f'activityid: {course_module_id}, ' - f'grades[0][studentid]: {moodle_user_id}, ' - f'grades[0][grade]: {completion_data["grade"] * self.enterprise_configuration.grade_scale} ' - f' with response: {response} ' - f'Status Code: {status_code}, ' - f'Text: {text}, ' - f'Headers: {headers}, ' - ) + if not status_code or not text or not headers: + LOGGER.info( + 'Learner Data Transmission' + f'for course={completion_data["courseID"]} with data ' + f'source: {module_name}, ' + f'activityid: {course_module_id}, ' + f'grades[0][studentid]: {moodle_user_id}, ' + f'grades[0][grade]: {completion_data["grade"] * self.enterprise_configuration.grade_scale} ' + f' with response: {response} ' + f'Status Code: {status_code}, ' + f'Text: {text}, ' + f'Headers: {headers}, ' + ) return response diff --git a/integrated_channels/utils.py b/integrated_channels/utils.py index 9f88552e32..3c58ba14ef 100644 --- a/integrated_channels/utils.py +++ b/integrated_channels/utils.py @@ -217,7 +217,8 @@ def is_already_transmitted( enterprise_course_enrollment_id=enterprise_enrollment_id, plugin_configuration_id=enterprise_configuration_id, error_message='', - status__lt=400 + status__lt=400, + course_completed=True ) if subsection_id: already_transmitted = already_transmitted.filter(subsection_id=subsection_id) From 19cecd389e40f146843469d59f78c6d9273c415f Mon Sep 17 00:00:00 2001 From: Feanil Patel Date: Mon, 30 Oct 2023 17:05:17 -0400 Subject: [PATCH 056/164] chore: Update to the new version of paragon in the new scope. Part of https://github.com/openedx/axim-engineering/issues/23 This replaces the `@edx/paragon` packag to point to the `paragon` package at the `openedx` scope(`@openedx/paragon`). Imports have been updated to use the same locations in the new package. --- enterprise/static/enterprise/sass/main.scss | 2 +- package-lock.json | 2020 ++++++++++--------- package.json | 2 +- 3 files changed, 1080 insertions(+), 944 deletions(-) diff --git a/enterprise/static/enterprise/sass/main.scss b/enterprise/static/enterprise/sass/main.scss index e848301a15..19b8808746 100644 --- a/enterprise/static/enterprise/sass/main.scss +++ b/enterprise/static/enterprise/sass/main.scss @@ -4,7 +4,7 @@ // Bootstrap's modals require Bootstrap variables & mixins. @import "@edx/brand/paragon/fonts"; @import "@edx/brand/paragon/variables"; -@import "@edx/paragon/scss/core/core"; +@import "@openedx/paragon/scss/core/core"; @import "@edx/brand/paragon/overrides"; /* Utilities */ diff --git a/package-lock.json b/package-lock.json index c7aaca9a69..7b021ff86c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,7 @@ "license": "AGPL-3.0", "dependencies": { "@edx/brand": "npm:@edx/brand-edx.org@^1.3.0", - "@edx/paragon": "^12.3.0" + "@openedx/paragon": "^21.5.7" }, "devDependencies": { "css-loader": "^0.28.7", @@ -46,34 +46,98 @@ "resolved": "https://registry.npmjs.org/@edx/brand-edx.org/-/brand-edx.org-1.6.1.tgz", "integrity": "sha512-GTddzcmQwBn30s4dBPKPbuvJAYERF3YW+B0yo9LNPvyRpQjI2dOfOsnzCuqroqDh72lEK+PFtVAEYTH9mu1Zjg==" }, - "node_modules/@edx/paragon": { - "version": "12.8.0", - "resolved": "https://registry.npmjs.org/@edx/paragon/-/paragon-12.8.0.tgz", - "integrity": "sha512-gJVnozu4V1e2PCI0lFICvnrs2yi0ZzcaO5gJB9OjB1Pzf5GXpkKNWLtdTwjgp4fvzL9w4sqvyB30apbQzWW8yw==", - "dependencies": { - "@fortawesome/fontawesome-svg-core": "^1.2.30", - "@fortawesome/free-solid-svg-icons": "^5.14.0", - "@fortawesome/react-fontawesome": "^0.1.11", - "airbnb-prop-types": "^2.12.0", - "bootstrap": "^4.4.1", - "classnames": "^2.2.6", - "email-prop-type": "^3.0.0", - "font-awesome": "^4.7.0", - "mailto-link": "^1.0.0", - "prop-types": "^15.7.2", - "react-bootstrap": "^1.2.2", - "react-focus-on": "^3.5.0", - "react-proptype-conditional-require": "^1.0.4", - "react-responsive": "^6.1.1", - "react-table": "^7.6.1", - "react-transition-group": "^4.0.0", - "sanitize-html": "^1.20.0", - "tabbable": "^4.0.0" + "node_modules/@formatjs/ecma402-abstract": { + "version": "1.17.2", + "resolved": "https://registry.npmjs.org/@formatjs/ecma402-abstract/-/ecma402-abstract-1.17.2.tgz", + "integrity": "sha512-k2mTh0m+IV1HRdU0xXM617tSQTi53tVR2muvYOsBeYcUgEAyxV1FOC7Qj279th3fBVQ+Dj6muvNJZcHSPNdbKg==", + "peer": true, + "dependencies": { + "@formatjs/intl-localematcher": "0.4.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@formatjs/fast-memoize": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@formatjs/fast-memoize/-/fast-memoize-2.2.0.tgz", + "integrity": "sha512-hnk/nY8FyrL5YxwP9e4r9dqeM6cAbo8PeU9UjyXojZMNvVad2Z06FAVHyR3Ecw6fza+0GH7vdJgiKIVXTMbSBA==", + "peer": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@formatjs/icu-messageformat-parser": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/@formatjs/icu-messageformat-parser/-/icu-messageformat-parser-2.7.0.tgz", + "integrity": "sha512-7uqC4C2RqOaBQtcjqXsSpGRYVn+ckjhNga5T/otFh6MgxRrCJQqvjfbrGLpX1Lcbxdm5WH3Z2WZqt1+Tm/cn/Q==", + "peer": true, + "dependencies": { + "@formatjs/ecma402-abstract": "1.17.2", + "@formatjs/icu-skeleton-parser": "1.6.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@formatjs/icu-skeleton-parser": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/@formatjs/icu-skeleton-parser/-/icu-skeleton-parser-1.6.2.tgz", + "integrity": "sha512-VtB9Slo4ZL6QgtDFJ8Injvscf0xiDd4bIV93SOJTBjUF4xe2nAWOoSjLEtqIG+hlIs1sNrVKAaFo3nuTI4r5ZA==", + "peer": true, + "dependencies": { + "@formatjs/ecma402-abstract": "1.17.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@formatjs/intl": { + "version": "2.9.5", + "resolved": "https://registry.npmjs.org/@formatjs/intl/-/intl-2.9.5.tgz", + "integrity": "sha512-WEdEv8Jf2nKBErTK4MJ2xCesUJVHH9iunXzfHzZo4tnn2NSj48g04FNH9w17XDpEbj9KEM39fLkwBz7ay/ErPQ==", + "peer": true, + "dependencies": { + "@formatjs/ecma402-abstract": "1.17.2", + "@formatjs/fast-memoize": "2.2.0", + "@formatjs/icu-messageformat-parser": "2.7.0", + "@formatjs/intl-displaynames": "6.6.1", + "@formatjs/intl-listformat": "7.5.0", + "intl-messageformat": "10.5.4", + "tslib": "^2.4.0" }, "peerDependencies": { - "prop-types": "^15.7.2", - "react": "^16.8.6", - "react-dom": "^16.8.6" + "typescript": "5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@formatjs/intl-displaynames": { + "version": "6.6.1", + "resolved": "https://registry.npmjs.org/@formatjs/intl-displaynames/-/intl-displaynames-6.6.1.tgz", + "integrity": "sha512-TIPaDu0SlwJUXlIyeSL9052jrUC4QviLnvUEJ53Ldc3Q4nZJnT2wD8NHIroTOYX9lgp5m3BeTlhpRcsnuExDkA==", + "peer": true, + "dependencies": { + "@formatjs/ecma402-abstract": "1.17.2", + "@formatjs/intl-localematcher": "0.4.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@formatjs/intl-listformat": { + "version": "7.5.0", + "resolved": "https://registry.npmjs.org/@formatjs/intl-listformat/-/intl-listformat-7.5.0.tgz", + "integrity": "sha512-n9FsXGl1T2ZbX6wSyrzCDJHrbJR0YJ9ZNsAqUvHXfbY3nsOmGnSTf5+bkuIp1Xiywu7m1X1Pfm/Ngp/yK1H84A==", + "peer": true, + "dependencies": { + "@formatjs/ecma402-abstract": "1.17.2", + "@formatjs/intl-localematcher": "0.4.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@formatjs/intl-localematcher": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@formatjs/intl-localematcher/-/intl-localematcher-0.4.2.tgz", + "integrity": "sha512-BGdtJFmaNJy5An/Zan4OId/yR9Ih1OojFjcduX/xOvq798OgWSyDtd6Qd5jqJXwJs1ipe4Fxu9+cshic5Ox2tA==", + "peer": true, + "dependencies": { + "tslib": "^2.4.0" } }, "node_modules/@fortawesome/fontawesome-common-types": { @@ -81,6 +145,7 @@ "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-0.2.36.tgz", "integrity": "sha512-a/7BiSgobHAgBWeN7N0w+lAhInrGxksn13uK7231n2m8EDPE3BMCl9NZLTGrj9ZXfCmC6LM0QLqXidIizVQ6yg==", "hasInstallScript": true, + "peer": true, "engines": { "node": ">=6" } @@ -90,18 +155,7 @@ "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-1.2.36.tgz", "integrity": "sha512-YUcsLQKYb6DmaJjIHdDWpBIGCcyE/W+p/LMGvjQem55Mm2XWVAP5kWTMKWLv9lwpCVjpLxPyOMOyUocP1GxrtA==", "hasInstallScript": true, - "dependencies": { - "@fortawesome/fontawesome-common-types": "^0.2.36" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/@fortawesome/free-solid-svg-icons": { - "version": "5.15.4", - "resolved": "https://registry.npmjs.org/@fortawesome/free-solid-svg-icons/-/free-solid-svg-icons-5.15.4.tgz", - "integrity": "sha512-JLmQfz6tdtwxoihXLg6lT78BorrFyCf59SAwBM6qV/0zXyVeDygJVb3fk+j5Qat+Yvcxp1buLTY5iDh1ZSAQ8w==", - "hasInstallScript": true, + "peer": true, "dependencies": { "@fortawesome/fontawesome-common-types": "^0.2.36" }, @@ -121,6 +175,232 @@ "react": ">=16.x" } }, + "node_modules/@openedx/paragon": { + "version": "21.6.0", + "resolved": "https://registry.npmjs.org/@openedx/paragon/-/paragon-21.6.0.tgz", + "integrity": "sha512-/BfPA4MaZIw99RUvWumFsLXwwPWHiCoxgla9vg4vkBlWugHbImb/7MAE3Iosm9S8Wb8hBUDzADQvhu33yc1QSw==", + "dependencies": { + "@fortawesome/fontawesome-svg-core": "^6.1.1", + "@fortawesome/react-fontawesome": "^0.1.18", + "@popperjs/core": "^2.11.4", + "bootstrap": "^4.6.2", + "chalk": "^4.1.2", + "child_process": "^1.0.2", + "classnames": "^2.3.1", + "email-prop-type": "^3.0.0", + "file-selector": "^0.6.0", + "font-awesome": "^4.7.0", + "glob": "^8.0.3", + "inquirer": "^8.2.5", + "lodash.uniqby": "^4.7.0", + "mailto-link": "^2.0.0", + "prop-types": "^15.8.1", + "react-bootstrap": "^1.6.5", + "react-colorful": "^5.6.1", + "react-dropzone": "^14.2.1", + "react-focus-on": "^3.5.4", + "react-loading-skeleton": "^3.1.0", + "react-popper": "^2.2.5", + "react-proptype-conditional-require": "^1.0.4", + "react-responsive": "^8.2.0", + "react-table": "^7.7.0", + "react-transition-group": "^4.4.2", + "tabbable": "^5.3.3", + "uncontrollable": "^7.2.1", + "uuid": "^9.0.0" + }, + "bin": { + "paragon": "bin/paragon-scripts.js" + }, + "peerDependencies": { + "react": "^16.8.6 || ^17.0.0", + "react-dom": "^16.8.6 || ^17.0.0", + "react-intl": "^5.25.1 || ^6.4.0" + } + }, + "node_modules/@openedx/paragon/node_modules/@fortawesome/fontawesome-common-types": { + "version": "6.4.2", + "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-6.4.2.tgz", + "integrity": "sha512-1DgP7f+XQIJbLFCTX1V2QnxVmpLdKdzzo2k8EmvDOePfchaIGQ9eCHj2up3/jNEbZuBqel5OxiaOJf37TWauRA==", + "hasInstallScript": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/@openedx/paragon/node_modules/@fortawesome/fontawesome-svg-core": { + "version": "6.4.2", + "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-6.4.2.tgz", + "integrity": "sha512-gjYDSKv3TrM2sLTOKBc5rH9ckje8Wrwgx1CxAPbN5N3Fm4prfi7NsJVWd1jklp7i5uSCVwhZS5qlhMXqLrpAIg==", + "hasInstallScript": true, + "dependencies": { + "@fortawesome/fontawesome-common-types": "6.4.2" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@openedx/paragon/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@openedx/paragon/node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@openedx/paragon/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/@openedx/paragon/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/@openedx/paragon/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" + }, + "node_modules/@openedx/paragon/node_modules/glob": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-8.1.0.tgz", + "integrity": "sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^5.0.1", + "once": "^1.3.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@openedx/paragon/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "engines": { + "node": ">=8" + } + }, + "node_modules/@openedx/paragon/node_modules/mailto-link": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mailto-link/-/mailto-link-2.0.0.tgz", + "integrity": "sha512-b5FErkZ4t6mpH1IFZSw7Mm2IQHXQ2R0/5Q4xd7Rv8dVkWvE54mFG/UW7HjfFazXFjXTNsM+dSX2tTeIDrV9K9A==", + "dependencies": { + "assert-ok": "~1.0.0", + "cast-array": "~1.0.1", + "object-filter": "~1.0.2", + "query-string": "~7.0.0" + }, + "engines": { + "node": ">= 12" + } + }, + "node_modules/@openedx/paragon/node_modules/minimatch": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", + "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@openedx/paragon/node_modules/query-string": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/query-string/-/query-string-7.0.1.tgz", + "integrity": "sha512-uIw3iRvHnk9to1blJCG3BTc+Ro56CBowJXKmNNAm3RulvPBzWLRqKSiiDk+IplJhsydwtuNMHi8UGQFcCLVfkA==", + "dependencies": { + "decode-uri-component": "^0.2.0", + "filter-obj": "^1.1.0", + "split-on-first": "^1.0.0", + "strict-uri-encode": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@openedx/paragon/node_modules/react-responsive": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/react-responsive/-/react-responsive-8.2.0.tgz", + "integrity": "sha512-iagCqVrw4QSjhxKp3I/YK6+ODkWY6G+YPElvdYKiUUbywwh9Ds0M7r26Fj2/7dWFFbOpcGnJE6uE7aMck8j5Qg==", + "dependencies": { + "hyphenate-style-name": "^1.0.0", + "matchmediaquery": "^0.3.0", + "prop-types": "^15.6.1", + "shallow-equal": "^1.1.0" + }, + "engines": { + "node": ">= 0.10" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@openedx/paragon/node_modules/strict-uri-encode": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strict-uri-encode/-/strict-uri-encode-2.0.0.tgz", + "integrity": "sha512-QwiXZgpRcKkhTj2Scnn++4PKtWsH0kpzZ62L2R6c/LUVYv7hVnZqcg2+sMuT6R7Jusu1vviK/MFsu6kNJfWlEQ==", + "engines": { + "node": ">=4" + } + }, + "node_modules/@openedx/paragon/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@openedx/paragon/node_modules/tabbable": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-5.3.3.tgz", + "integrity": "sha512-QD9qKY3StfbZqWOPLp0++pOrAVb/HbUi5xCc8cUo4XjP19808oaMiDzn0leBY5mCespIBM0CIZePzZjgzR83kA==" + }, "node_modules/@popperjs/core": { "version": "2.11.7", "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.7.tgz", @@ -149,6 +429,16 @@ "react": ">=16.8.0" } }, + "node_modules/@types/hoist-non-react-statics": { + "version": "3.3.4", + "resolved": "https://registry.npmjs.org/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.4.tgz", + "integrity": "sha512-ZchYkbieA+7tnxwX/SCBySx9WwvWR8TaP5tb2jRAzwvLb/rWchGw3v0w3pqUbUvj0GCwW2Xz/AVPSk6kUGctXQ==", + "peer": true, + "dependencies": { + "@types/react": "*", + "hoist-non-react-statics": "^3.3.0" + } + }, "node_modules/@types/invariant": { "version": "2.2.35", "resolved": "https://registry.npmjs.org/@types/invariant/-/invariant-2.2.35.tgz", @@ -221,28 +511,6 @@ "node": ">=0.4.0" } }, - "node_modules/airbnb-prop-types": { - "version": "2.16.0", - "resolved": "https://registry.npmjs.org/airbnb-prop-types/-/airbnb-prop-types-2.16.0.tgz", - "integrity": "sha512-7WHOFolP/6cS96PhKNrslCLMYAI8yB1Pp6u6XmxozQOiZbsI5ycglZr5cHhBFfuRcQQjzCMith5ZPZdYiJCxUg==", - "dependencies": { - "array.prototype.find": "^2.1.1", - "function.prototype.name": "^1.1.2", - "is-regex": "^1.1.0", - "object-is": "^1.1.2", - "object.assign": "^4.1.0", - "object.entries": "^1.1.2", - "prop-types": "^15.7.2", - "prop-types-exact": "^1.2.0", - "react-is": "^16.13.1" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - }, - "peerDependencies": { - "react": "^0.14 || ^15.0.0 || ^16.0.0-alpha" - } - }, "node_modules/ajv": { "version": "5.5.2", "resolved": "https://registry.npmjs.org/ajv/-/ajv-5.5.2.tgz", @@ -287,6 +555,20 @@ "integrity": "sha512-0FcBfdcmaumGPQ0qPn7Q5qTgz/ooXgIyp1rf8ik5bGX8mpE2YHjC0P/eyQvxu1GURYQgq9ozf2mteQ5ZD9YiyQ==", "dev": true }, + "node_modules/ansi-escapes": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", + "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", + "dependencies": { + "type-fest": "^0.21.3" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/ansi-regex": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz", @@ -368,18 +650,6 @@ "node": ">=0.10.0" } }, - "node_modules/array-buffer-byte-length": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.0.tgz", - "integrity": "sha512-LPuwb2P+NrQw3XhxGc36+XSvuBPopovXYTR9Ew++Du9Yb/bx5AzBfrIsBoj0EZUifjQU+sHL21sseZ3jerWO/A==", - "dependencies": { - "call-bind": "^1.0.2", - "is-array-buffer": "^3.0.1" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/array-unique": { "version": "0.3.2", "resolved": "https://registry.npmjs.org/array-unique/-/array-unique-0.3.2.tgz", @@ -390,20 +660,6 @@ "node": ">=0.10.0" } }, - "node_modules/array.prototype.find": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/array.prototype.find/-/array.prototype.find-2.2.1.tgz", - "integrity": "sha512-I2ri5Z9uMpMvnsNrHre9l3PaX+z9D0/z6F7Yt2u15q7wt0I62g5kX6xUKR1SJiefgG+u2/gJUmM8B47XRvQR6w==", - "dependencies": { - "call-bind": "^1.0.2", - "define-properties": "^1.1.4", - "es-abstract": "^1.20.4", - "es-shim-unscopables": "^1.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/asn1.js": { "version": "5.4.1", "resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz", @@ -497,6 +753,14 @@ "node": ">= 4.5.0" } }, + "node_modules/attr-accept": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/attr-accept/-/attr-accept-2.2.2.tgz", + "integrity": "sha512-7prDjvt9HmqiZ0cl5CRjtS84sEyhsHP2coDkaZKRKVfCDo9s7iw7ChVmar78Gu9pC4SoR/28wFu/G5JJhTnqEg==", + "engines": { + "node": ">=4" + } + }, "node_modules/autoprefixer": { "version": "6.7.7", "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-6.7.7.tgz", @@ -511,17 +775,6 @@ "postcss-value-parser": "^3.2.3" } }, - "node_modules/available-typed-arrays": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.5.tgz", - "integrity": "sha512-DMD0KiN46eipeziST1LPP/STfDU0sufISXmjSgvVsoU2tqxctQeASejWcfNtxYKqETM1UxQ8sp2OrSBWpHY6sw==", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/babel-code-frame": { "version": "6.26.0", "resolved": "https://registry.npmjs.org/babel-code-frame/-/babel-code-frame-6.26.0.tgz", @@ -536,8 +789,7 @@ "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==" }, "node_modules/base": { "version": "0.11.2", @@ -575,7 +827,6 @@ "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", - "dev": true, "funding": [ { "type": "github", @@ -619,6 +870,60 @@ "file-uri-to-path": "1.0.0" } }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/bl/node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/bl/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/bl/node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, "node_modules/bn.js": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-5.2.1.tgz", @@ -832,18 +1137,6 @@ "node": ">=0.10.0" } }, - "node_modules/call-bind": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.2.tgz", - "integrity": "sha512-7O+FbCihrB5WGbFYesctwmTKae6rOiIzmz1icreWJ+0aA7LJfuqhEso2T9ncpcFtzMQtzXf2QGGueWJGTYsqrA==", - "dependencies": { - "function-bind": "^1.1.1", - "get-intrinsic": "^1.0.2" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/camelcase": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-4.1.0.tgz", @@ -908,6 +1201,16 @@ "node": ">=0.10.0" } }, + "node_modules/chardet": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/chardet/-/chardet-0.7.0.tgz", + "integrity": "sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==" + }, + "node_modules/child_process": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/child_process/-/child_process-1.0.2.tgz", + "integrity": "sha512-Wmza/JzL0SiWz7kl6MhIKT5ceIlnFPJX+lwUGj7Clhy5MMldsSoJR0+uvRzOS5Kv45Mq7t1PoE8TsOA9bzvb6g==" + }, "node_modules/chokidar": { "version": "3.5.3", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz", @@ -1081,6 +1384,36 @@ "node": ">=0.2.5" } }, + "node_modules/cli-cursor": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz", + "integrity": "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==", + "dependencies": { + "restore-cursor": "^3.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cli-spinners": { + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.1.tgz", + "integrity": "sha512-jHgecW0pxkonBJdrKsqxgRX9AcG+u/5k0Q7WPDfi8AogLAdwxEkyYYNWwZ5GvVFoFx2uiY1eNcSK00fh+1+FyQ==", + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-width": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-3.0.0.tgz", + "integrity": "sha512-FxqpkPPwu1HjuN93Omfm4h8uIanXofW0RxVEW3k5RKx+mJJYSthzNhp32Kzxxy3YAEZ/Dc/EWN1vZRY0+kOhbw==", + "engines": { + "node": ">= 10" + } + }, "node_modules/cliui": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/cliui/-/cliui-3.2.0.tgz", @@ -1122,7 +1455,6 @@ "version": "1.0.4", "resolved": "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz", "integrity": "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==", - "dev": true, "engines": { "node": ">=0.8" } @@ -1522,25 +1854,19 @@ "version": "0.2.2", "resolved": "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.2.2.tgz", "integrity": "sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ==", - "dev": true, - "optional": true, "engines": { "node": ">=0.10" } }, - "node_modules/define-properties": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.0.tgz", - "integrity": "sha512-xvqAVKGfT1+UAvPwKTVw/njhdQ8ZhXK4lI0bCIuCMrp2up9nPnaDftrLtmpTazqd1o+UY4zgzU+avtMbDP+ldA==", + "node_modules/defaults": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.4.tgz", + "integrity": "sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==", "dependencies": { - "has-property-descriptors": "^1.0.0", - "object-keys": "^1.1.1" - }, - "engines": { - "node": ">= 0.4" + "clone": "^1.0.2" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/define-property": { @@ -1724,6 +2050,11 @@ "node": ">4.0" } }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" + }, "node_modules/emojis-list": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/emojis-list/-/emojis-list-3.0.0.tgz", @@ -1775,90 +2106,6 @@ "is-arrayish": "^0.2.1" } }, - "node_modules/es-abstract": { - "version": "1.21.2", - "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.21.2.tgz", - "integrity": "sha512-y/B5POM2iBnIxCiernH1G7rC9qQoM77lLIMQLuob0zhp8C56Po81+2Nj0WFKnd0pNReDTnkYryc+zhOzpEIROg==", - "dependencies": { - "array-buffer-byte-length": "^1.0.0", - "available-typed-arrays": "^1.0.5", - "call-bind": "^1.0.2", - "es-set-tostringtag": "^2.0.1", - "es-to-primitive": "^1.2.1", - "function.prototype.name": "^1.1.5", - "get-intrinsic": "^1.2.0", - "get-symbol-description": "^1.0.0", - "globalthis": "^1.0.3", - "gopd": "^1.0.1", - "has": "^1.0.3", - "has-property-descriptors": "^1.0.0", - "has-proto": "^1.0.1", - "has-symbols": "^1.0.3", - "internal-slot": "^1.0.5", - "is-array-buffer": "^3.0.2", - "is-callable": "^1.2.7", - "is-negative-zero": "^2.0.2", - "is-regex": "^1.1.4", - "is-shared-array-buffer": "^1.0.2", - "is-string": "^1.0.7", - "is-typed-array": "^1.1.10", - "is-weakref": "^1.0.2", - "object-inspect": "^1.12.3", - "object-keys": "^1.1.1", - "object.assign": "^4.1.4", - "regexp.prototype.flags": "^1.4.3", - "safe-regex-test": "^1.0.0", - "string.prototype.trim": "^1.2.7", - "string.prototype.trimend": "^1.0.6", - "string.prototype.trimstart": "^1.0.6", - "typed-array-length": "^1.0.4", - "unbox-primitive": "^1.0.2", - "which-typed-array": "^1.1.9" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/es-set-tostringtag": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.0.1.tgz", - "integrity": "sha512-g3OMbtlwY3QewlqAiMLI47KywjWZoEytKr8pf6iTC8uJq5bIAH52Z9pnQ8pVL6whrCto53JZDuUIsifGeLorTg==", - "dependencies": { - "get-intrinsic": "^1.1.3", - "has": "^1.0.3", - "has-tostringtag": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-shim-unscopables": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.0.0.tgz", - "integrity": "sha512-Jm6GPcCdC30eMLbZ2x8z2WuRwAws3zTBBKuusffYVUrNj/GVSUAZ+xKMaUpfNDR5IbyNA5LJbaecoUVbmUcB1w==", - "dependencies": { - "has": "^1.0.3" - } - }, - "node_modules/es-to-primitive": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.2.1.tgz", - "integrity": "sha512-QCOllgZJtaUo9miYBcLChTUaHNjJF3PYs1VidD7AwiEj1kYxKeQTctLAezAOH5ZKRH0g2IgPn6KwB4IT8iRpvA==", - "dependencies": { - "is-callable": "^1.1.4", - "is-date-object": "^1.0.1", - "is-symbol": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/es5-ext": { "version": "0.10.62", "resolved": "https://registry.npmjs.org/es5-ext/-/es5-ext-0.10.62.tgz", @@ -1948,7 +2195,6 @@ "version": "1.0.5", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", - "dev": true, "engines": { "node": ">=0.8.0" } @@ -2237,6 +2483,19 @@ "node": ">=0.10.0" } }, + "node_modules/external-editor": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/external-editor/-/external-editor-3.1.0.tgz", + "integrity": "sha512-hMQ4CX1p1izmuLYyZqLMO/qGNw10wSv9QDCPfzXfyFrOaCSSoRfqE1Kf1s5an66J5JZC62NewG+mK49jOCtQew==", + "dependencies": { + "chardet": "^0.7.0", + "iconv-lite": "^0.4.24", + "tmp": "^0.0.33" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/extglob": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/extglob/-/extglob-2.0.4.tgz", @@ -2330,6 +2589,31 @@ "integrity": "sha512-483XLLxTVIwWK3QTrMGRqUfUpoOs/0hbQrl2oz4J0pAcm3A3bu84wxTFqGqkJzewCLdME38xJLJAxBABfQT8sQ==", "dev": true }, + "node_modules/figures": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/figures/-/figures-3.2.0.tgz", + "integrity": "sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==", + "dependencies": { + "escape-string-regexp": "^1.0.5" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/file-selector": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/file-selector/-/file-selector-0.6.0.tgz", + "integrity": "sha512-QlZ5yJC0VxHxQQsQhXvBaC7VRJ2uaxTf+Tfpu4Z/OcVQJVpZO+DGU0rkoVW5ce2SccxugvpBJoMvUs59iILYdw==", + "dependencies": { + "tslib": "^2.4.0" + }, + "engines": { + "node": ">= 12" + } + }, "node_modules/file-uri-to-path": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", @@ -2349,6 +2633,14 @@ "node": ">=8" } }, + "node_modules/filter-obj": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/filter-obj/-/filter-obj-1.1.0.tgz", + "integrity": "sha512-8rXg1ZnX7xzy2NGDVkBVaAy+lSlPNwad13BtgSlLuxfIslyt5Vg64U7tFcCt4WS1R0hvtnQybT/IyCkGZ3DpXQ==", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/find-up": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-2.1.0.tgz", @@ -2387,14 +2679,6 @@ "node": ">=0.10.3" } }, - "node_modules/for-each": { - "version": "0.3.3", - "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.3.tgz", - "integrity": "sha512-jqYfLp7mo9vIyQf8ykW2v7A+2N4QjeCeI5+Dz9XraiO1ign81wjiH7Fb9vSOWvQfNtmSa4H2RoQTrrXivdUZmw==", - "dependencies": { - "is-callable": "^1.1.3" - } - }, "node_modules/for-in": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/for-in/-/for-in-1.0.2.tgz", @@ -2421,8 +2705,7 @@ "node_modules/fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", - "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", - "dev": true + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==" }, "node_modules/fsevents": { "version": "2.3.2", @@ -2441,32 +2724,8 @@ "node_modules/function-bind": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz", - "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==" - }, - "node_modules/function.prototype.name": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.5.tgz", - "integrity": "sha512-uN7m/BzVKQnCUF/iW8jYea67v++2u7m5UgENbHRtdDVclOUP+FMPlCNdmk0h/ysGyo2tavMJEDqJAkJdRa1vMA==", - "dependencies": { - "call-bind": "^1.0.2", - "define-properties": "^1.1.3", - "es-abstract": "^1.19.0", - "functions-have-names": "^1.2.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/functions-have-names": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", - "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } + "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==", + "dev": true }, "node_modules/get-caller-file": { "version": "1.0.3", @@ -2474,19 +2733,6 @@ "integrity": "sha512-3t6rVToeoZfYSGd8YoLFR2DJkiQrIiUrGcjvFX2mDw3bn6k2OtwHN0TNCLbBO+w8qTvimhDkv+LSscbJY1vE6w==", "dev": true }, - "node_modules/get-intrinsic": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.0.tgz", - "integrity": "sha512-L049y6nFOuom5wGyRc3/gdTLO94dySVKRACj1RmJZBQXlbTMhtNIgkWkUHq+jYmZvKf14EW1EoJnnjbmoHij0Q==", - "dependencies": { - "function-bind": "^1.1.1", - "has": "^1.0.3", - "has-symbols": "^1.0.3" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/get-nonce": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", @@ -2504,21 +2750,6 @@ "node": ">=4" } }, - "node_modules/get-symbol-description": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.0.0.tgz", - "integrity": "sha512-2EmdH1YvIQiZpltCNgkuiUnyukzxM/R6NDJX31Ke3BG1Nq5b0S2PhX59UKi9vZpPDQVdqn+1IcaAwnzTT5vCjw==", - "dependencies": { - "call-bind": "^1.0.2", - "get-intrinsic": "^1.1.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/get-value": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/get-value/-/get-value-2.0.6.tgz", @@ -2573,31 +2804,6 @@ "node": "*" } }, - "node_modules/globalthis": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.3.tgz", - "integrity": "sha512-sFdI5LyBiNTHjRd7cGPWapiHWMOXKyuBNX/cWJ3NfzrZQVa8GI/8cofCl74AOVqq9W5kNmguTIzJ/1s2gyI9wA==", - "dependencies": { - "define-properties": "^1.1.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/gopd": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.0.1.tgz", - "integrity": "sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==", - "dependencies": { - "get-intrinsic": "^1.1.3" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", @@ -2608,6 +2814,7 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz", "integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==", + "dev": true, "dependencies": { "function-bind": "^1.1.1" }, @@ -2627,14 +2834,6 @@ "node": ">=0.10.0" } }, - "node_modules/has-bigints": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.0.2.tgz", - "integrity": "sha512-tSvCKtBr9lkF0Ex0aQiP9N+OpV4zi2r/Nee5VkRDbaqv35RLYMzbwQfFSZZH0kR+Rd6302UJZ2p/bJCEoR3VoQ==", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/has-flag": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", @@ -2644,53 +2843,6 @@ "node": ">=4" } }, - "node_modules/has-property-descriptors": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.0.tgz", - "integrity": "sha512-62DVLZGoiEBDHQyqG4w9xCuZ7eJEwNmJRWw2VY84Oedb7WFcA27fiEVe8oUQx9hAUJ4ekurquucTGwsyO1XGdQ==", - "dependencies": { - "get-intrinsic": "^1.1.1" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.0.1.tgz", - "integrity": "sha512-7qE+iP+O+bgF9clE5+UoBFzE65mlBiVj3tKCrlNQ0Ogwm0BjpT/gK4SlLYDMybDh5I3TCTKnPPa0oMG7JDYrhg==", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-symbols": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz", - "integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-tostringtag": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.0.tgz", - "integrity": "sha512-kFjcSNhnlGV1kyoGk7OXKSawH5JOb/LzUc5w9B02hOTO0dfFRjbHQKvg1d6cf3HbeUmtU9VbbV3qzZ2Teh97WQ==", - "dependencies": { - "has-symbols": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/has-value": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/has-value/-/has-value-1.0.0.tgz", @@ -2817,6 +2969,15 @@ "minimalistic-crypto-utils": "^1.0.1" } }, + "node_modules/hoist-non-react-statics": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", + "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==", + "peer": true, + "dependencies": { + "react-is": "^16.7.0" + } + }, "node_modules/hosted-git-info": { "version": "2.8.9", "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.8.9.tgz", @@ -2853,6 +3014,17 @@ "resolved": "https://registry.npmjs.org/hyphenate-style-name/-/hyphenate-style-name-1.0.4.tgz", "integrity": "sha512-ygGZLjmXfPHj+ZWh6LwbC37l43MhfztxetbFCoYTM2VjkIUpeHgSNn7QIyVFj7YQ1Wl9Cbw5sholVJPzWvC2MQ==" }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/icss-replace-symbols": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/icss-replace-symbols/-/icss-replace-symbols-1.1.0.tgz", @@ -2933,7 +3105,6 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", - "dev": true, "funding": [ { "type": "github", @@ -2965,7 +3136,6 @@ "version": "1.0.6", "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", - "dev": true, "dependencies": { "once": "^1.3.0", "wrappy": "1" @@ -2974,20 +3144,148 @@ "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "dev": true + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" + }, + "node_modules/inquirer": { + "version": "8.2.6", + "resolved": "https://registry.npmjs.org/inquirer/-/inquirer-8.2.6.tgz", + "integrity": "sha512-M1WuAmb7pn9zdFRtQYk26ZBoY043Sse0wVDdk4Bppr+JOXyQYybdtvK+l9wUibhtjdjvtoiNy8tk+EgsYIUqKg==", + "dependencies": { + "ansi-escapes": "^4.2.1", + "chalk": "^4.1.1", + "cli-cursor": "^3.1.0", + "cli-width": "^3.0.0", + "external-editor": "^3.0.3", + "figures": "^3.0.0", + "lodash": "^4.17.21", + "mute-stream": "0.0.8", + "ora": "^5.4.1", + "run-async": "^2.4.0", + "rxjs": "^7.5.5", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0", + "through": "^2.3.6", + "wrap-ansi": "^6.0.1" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/inquirer/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "engines": { + "node": ">=8" + } }, - "node_modules/internal-slot": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.0.5.tgz", - "integrity": "sha512-Y+R5hJrzs52QCG2laLn4udYVnxsfny9CpOhNhUvk/SSSVyF6T27FzRbF0sroPidSu3X8oEAkOn2K804mjpt6UQ==", + "node_modules/inquirer/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", "dependencies": { - "get-intrinsic": "^1.2.0", - "has": "^1.0.3", - "side-channel": "^1.0.4" + "color-convert": "^2.0.1" }, "engines": { - "node": ">= 0.4" + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/inquirer/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/inquirer/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/inquirer/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" + }, + "node_modules/inquirer/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "engines": { + "node": ">=8" + } + }, + "node_modules/inquirer/node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "engines": { + "node": ">=8" + } + }, + "node_modules/inquirer/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/inquirer/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/inquirer/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/inquirer/node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" } }, "node_modules/interpret": { @@ -2999,6 +3297,18 @@ "node": ">= 0.10" } }, + "node_modules/intl-messageformat": { + "version": "10.5.4", + "resolved": "https://registry.npmjs.org/intl-messageformat/-/intl-messageformat-10.5.4.tgz", + "integrity": "sha512-z+hrFdiJ/heRYlzegrdFYqU1m/KOMOVMqNilIArj+PbsuU8TNE7v4TWdQgSoxlxbT4AcZH3Op3/Fu15QTp+W1w==", + "peer": true, + "dependencies": { + "@formatjs/ecma402-abstract": "1.17.2", + "@formatjs/fast-memoize": "2.2.0", + "@formatjs/icu-messageformat-parser": "2.7.0", + "tslib": "^2.4.0" + } + }, "node_modules/invariant": { "version": "2.2.4", "resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz", @@ -3038,61 +3348,22 @@ "node": ">=0.10.0" } }, - "node_modules/is-array-buffer": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.2.tgz", - "integrity": "sha512-y+FyyR/w8vfIRq4eQcM1EYgSTnmHXPqaF+IgzgraytCFq5Xh8lllDVmAZolPJiZttZLeFSINPYMaEJ7/vWUa1w==", - "dependencies": { - "call-bind": "^1.0.2", - "get-intrinsic": "^1.2.0", - "is-typed-array": "^1.1.10" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/is-arrayish": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", "dev": true }, - "node_modules/is-bigint": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.0.4.tgz", - "integrity": "sha512-zB9CruMamjym81i2JZ3UMn54PKGsQzsJeo6xvN3HJJ4CAsQNB6iRutp2To77OfCNuoxspsIhzaPoO1zyCEhFOg==", - "dependencies": { - "has-bigints": "^1.0.1" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/is-binary-path": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", "dev": true, "dependencies": { - "binary-extensions": "^2.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/is-boolean-object": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.1.2.tgz", - "integrity": "sha512-gDYaKHJmnj4aWxyj6YHyXVpdQawtVLHU5cb+eztPGczf6cjuTdwve5ZIEfgXqH4e57An1D1AKf8CZ3kYrQRqYA==", - "dependencies": { - "call-bind": "^1.0.2", - "has-tostringtag": "^1.0.0" + "binary-extensions": "^2.0.0" }, "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">=8" } }, "node_modules/is-buffer": { @@ -3101,17 +3372,6 @@ "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==", "dev": true }, - "node_modules/is-callable": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", - "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/is-core-module": { "version": "2.11.0", "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.11.0.tgz", @@ -3137,20 +3397,6 @@ "node": ">=0.10.0" } }, - "node_modules/is-date-object": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.0.5.tgz", - "integrity": "sha512-9YQaSxsAiSwcvS33MBk3wTCVnWK+HhF8VZR2jRxehM16QcVOdHqPn4VPHmRK4lSr38n9JriurInLcP90xsYNfQ==", - "dependencies": { - "has-tostringtag": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/is-descriptor": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/is-descriptor/-/is-descriptor-1.0.2.tgz", @@ -3209,15 +3455,12 @@ "node": ">=0.10.0" } }, - "node_modules/is-negative-zero": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.2.tgz", - "integrity": "sha512-dqJvarLawXsFbNDeJW7zAz8ItJ9cd28YufuuFzh0G8pNHjJMnY08Dv7sYX2uF5UpQOwieAeOExEYAWWfu7ZZUA==", + "node_modules/is-interactive": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-1.0.0.tgz", + "integrity": "sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==", "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">=8" } }, "node_modules/is-number": { @@ -3229,20 +3472,6 @@ "node": ">=0.12.0" } }, - "node_modules/is-number-object": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.0.7.tgz", - "integrity": "sha512-k1U0IRzLMo7ZlYIfzRu23Oh6MiIFasgpb9X76eqfFZAqwH44UI4KTBvBYIZ1dSL9ZzChTB9ShHfLkR4pdW5krQ==", - "dependencies": { - "has-tostringtag": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/is-plain-obj": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-1.1.0.tgz", @@ -3264,32 +3493,6 @@ "node": ">=0.10.0" } }, - "node_modules/is-regex": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.1.4.tgz", - "integrity": "sha512-kvRdxDsxZjhzUX07ZnLydzS1TU/TJlTUHHY4YLL87e37oUA49DfkLqgy+VjFocowy29cKvcSiu+kIv728jTTVg==", - "dependencies": { - "call-bind": "^1.0.2", - "has-tostringtag": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-shared-array-buffer": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.2.tgz", - "integrity": "sha512-sqN2UDu1/0y6uvXyStCOzyhAjCSlHceFoMKJW8W9EU9cvic/QdsZ0kEU93HEy3IUEFZIiH/3w+AH/UQbPHNdhA==", - "dependencies": { - "call-bind": "^1.0.2" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/is-stream": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-1.1.0.tgz", @@ -3299,20 +3502,6 @@ "node": ">=0.10.0" } }, - "node_modules/is-string": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.0.7.tgz", - "integrity": "sha512-tE2UXzivje6ofPW7l23cjDOMa09gb7xlAqG6jG5ej6uPV32TlWP3NKPigtaGeHNu9fohccRYvIiZMfOOnOYUtg==", - "dependencies": { - "has-tostringtag": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/is-svg": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-svg/-/is-svg-2.1.0.tgz", @@ -3325,47 +3514,15 @@ "node": ">=0.10.0" } }, - "node_modules/is-symbol": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.0.4.tgz", - "integrity": "sha512-C/CPBqKWnvdcxqIARxyOh4v1UUEOCHpgDa0WYgpKDFMszcrPcffg5uhwSgPCLD2WWxmq6isisz87tzT01tuGhg==", - "dependencies": { - "has-symbols": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-typed-array": { - "version": "1.1.10", - "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.10.tgz", - "integrity": "sha512-PJqgEHiWZvMpaFZ3uTc8kHPM4+4ADTlDniuQL7cU/UDA0Ql7F70yGfHph3cLNe+c9toaigv+DFzTJKhc2CtO6A==", - "dependencies": { - "available-typed-arrays": "^1.0.5", - "call-bind": "^1.0.2", - "for-each": "^0.3.3", - "gopd": "^1.0.1", - "has-tostringtag": "^1.0.0" - }, + "node_modules/is-unicode-supported": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", + "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==", "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-weakref": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.0.2.tgz", - "integrity": "sha512-qctsuLZmIQ0+vSSMfoVvyFe2+GSEvnmZ2ezTup1SBse9+twCCeial6EEi3Nc2KFcf6+qz2FBPnjXsk8xhKSaPQ==", - "dependencies": { - "call-bind": "^1.0.2" + "node": ">=10" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/is-windows": { @@ -3594,6 +3751,90 @@ "integrity": "sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==", "dev": true }, + "node_modules/lodash.uniqby": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/lodash.uniqby/-/lodash.uniqby-4.7.0.tgz", + "integrity": "sha512-e/zcLx6CSbmaEgFHCA7BnoQKyCtKMxnuWrJygbwPs/AIn+IMKl66L8/s+wBUn5LRw2pZx3bUHibiV1b6aTWIww==" + }, + "node_modules/log-symbols": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", + "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==", + "dependencies": { + "chalk": "^4.1.0", + "is-unicode-supported": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-symbols/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/log-symbols/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/log-symbols/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/log-symbols/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" + }, + "node_modules/log-symbols/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "engines": { + "node": ">=8" + } + }, + "node_modules/log-symbols/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/longest": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/longest/-/longest-1.0.1.tgz", @@ -3624,17 +3865,6 @@ "yallist": "^2.1.2" } }, - "node_modules/mailto-link": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/mailto-link/-/mailto-link-1.0.0.tgz", - "integrity": "sha512-DVRvtkoeXLHAbH+S+9m3ILIdnvQsSc9IvJwfEclQVD8e8FhzwA5Mtw4Q0XXYr/sAziw/HsMc/gpGAI+5w6ohIw==", - "dependencies": { - "assert-ok": "~1.0.0", - "cast-array": "~1.0.0", - "object-filter": "~1.0.2", - "query-string": "~2.4.1" - } - }, "node_modules/map-cache": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/map-cache/-/map-cache-0.2.2.tgz", @@ -3974,6 +4204,11 @@ "dev": true, "optional": true }, + "node_modules/mute-stream": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.8.tgz", + "integrity": "sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==" + }, "node_modules/nan": { "version": "2.17.0", "resolved": "https://registry.npmjs.org/nan/-/nan-2.17.0.tgz", @@ -4291,100 +4526,165 @@ "resolved": "https://registry.npmjs.org/object-filter/-/object-filter-1.0.2.tgz", "integrity": "sha512-NahvP2vZcy1ZiiYah30CEPw0FpDcSkSePJBMpzl5EQgCmISijiGuJm3SPYp7U+Lf2TljyaIw3E5EgkEx/TNEVA==" }, - "node_modules/object-inspect": { - "version": "1.12.3", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.12.3.tgz", - "integrity": "sha512-geUvdk7c+eizMNUDkRpW1wJwgfOiOeHbxBR/hLXK1aT6zmVSO0jsQcs7fj6MGw89jC/cjGfLcNOrtMYtGqm81g==", + "node_modules/object-visit": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/object-visit/-/object-visit-1.0.1.tgz", + "integrity": "sha512-GBaMwwAVK9qbQN3Scdo0OyvgPW7l3lnaVMj84uTOZlswkX0KpF6fyDBJhtTthf7pymztoN36/KEr1DyhF96zEA==", + "dev": true, + "optional": true, + "dependencies": { + "isobject": "^3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object.pick": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/object.pick/-/object.pick-1.3.0.tgz", + "integrity": "sha512-tqa/UMy/CCoYmj+H5qc07qvSL9dqcs/WZENZ1JbtWBlATP+iVOe778gE6MSijnyCnORzDuX6hU+LA4SZ09YjFQ==", + "dev": true, + "optional": true, + "dependencies": { + "isobject": "^3.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/onetime/node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "engines": { + "node": ">=6" } }, - "node_modules/object-is": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/object-is/-/object-is-1.1.5.tgz", - "integrity": "sha512-3cyDsyHgtmi7I7DfSSI2LDp6SK2lwvtbg0p0R1e0RvTqF5ceGx+K2dfSjm1bKDMVCFEDAQvy+o8c6a7VujOddw==", + "node_modules/ora": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/ora/-/ora-5.4.1.tgz", + "integrity": "sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==", "dependencies": { - "call-bind": "^1.0.2", - "define-properties": "^1.1.3" + "bl": "^4.1.0", + "chalk": "^4.1.0", + "cli-cursor": "^3.1.0", + "cli-spinners": "^2.5.0", + "is-interactive": "^1.0.0", + "is-unicode-supported": "^0.1.0", + "log-symbols": "^4.1.0", + "strip-ansi": "^6.0.0", + "wcwidth": "^1.0.1" }, "engines": { - "node": ">= 0.4" + "node": ">=10" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/object-keys": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", - "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "node_modules/ora/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "engines": { - "node": ">= 0.4" + "node": ">=8" } }, - "node_modules/object-visit": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/object-visit/-/object-visit-1.0.1.tgz", - "integrity": "sha512-GBaMwwAVK9qbQN3Scdo0OyvgPW7l3lnaVMj84uTOZlswkX0KpF6fyDBJhtTthf7pymztoN36/KEr1DyhF96zEA==", - "dev": true, - "optional": true, + "node_modules/ora/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", "dependencies": { - "isobject": "^3.0.0" + "color-convert": "^2.0.1" }, "engines": { - "node": ">=0.10.0" + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/object.assign": { - "version": "4.1.4", - "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.4.tgz", - "integrity": "sha512-1mxKf0e58bvyjSCtKYY4sRe9itRk3PJpquJOjeIkz885CczcI4IvJJDLPS72oowuSh+pBxUFROpX+TU++hxhZQ==", + "node_modules/ora/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", "dependencies": { - "call-bind": "^1.0.2", - "define-properties": "^1.1.4", - "has-symbols": "^1.0.3", - "object-keys": "^1.1.1" + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" }, "engines": { - "node": ">= 0.4" + "node": ">=10" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/object.entries": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/object.entries/-/object.entries-1.1.6.tgz", - "integrity": "sha512-leTPzo4Zvg3pmbQ3rDK69Rl8GQvIqMWubrkxONG9/ojtFE2rD9fjMKfSI5BxW3osRH1m6VdzmqK8oAY9aT4x5w==", + "node_modules/ora/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", "dependencies": { - "call-bind": "^1.0.2", - "define-properties": "^1.1.4", - "es-abstract": "^1.20.4" + "color-name": "~1.1.4" }, "engines": { - "node": ">= 0.4" + "node": ">=7.0.0" } }, - "node_modules/object.pick": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/object.pick/-/object.pick-1.3.0.tgz", - "integrity": "sha512-tqa/UMy/CCoYmj+H5qc07qvSL9dqcs/WZENZ1JbtWBlATP+iVOe778gE6MSijnyCnORzDuX6hU+LA4SZ09YjFQ==", - "dev": true, - "optional": true, + "node_modules/ora/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" + }, + "node_modules/ora/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "engines": { + "node": ">=8" + } + }, + "node_modules/ora/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", "dependencies": { - "isobject": "^3.0.1" + "ansi-regex": "^5.0.1" }, "engines": { - "node": ">=0.10.0" + "node": ">=8" } }, - "node_modules/once": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "dev": true, + "node_modules/ora/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", "dependencies": { - "wrappy": "1" + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" } }, "node_modules/os-browserify": { @@ -4407,6 +4707,14 @@ "node": ">=4" } }, + "node_modules/os-tmpdir": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/os-tmpdir/-/os-tmpdir-1.0.2.tgz", + "integrity": "sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g==", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/p-finally": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/p-finally/-/p-finally-1.0.0.tgz", @@ -4480,11 +4788,6 @@ "node": ">=0.10.0" } }, - "node_modules/parse-srcset": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/parse-srcset/-/parse-srcset-1.0.2.tgz", - "integrity": "sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q==" - }, "node_modules/pascalcase": { "version": "0.1.1", "resolved": "https://registry.npmjs.org/pascalcase/-/pascalcase-0.1.1.tgz", @@ -4578,11 +4881,6 @@ "node": ">=0.12" } }, - "node_modules/picocolors": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-0.2.1.tgz", - "integrity": "sha512-cMlDqaLEqfSaW8Z7N5Jw+lyIW869EzT73/F5lhtY9cLGoVxSXznfgfXMO0Z5K0o0Q2TkTXq+0KFsdnSe3jDViA==" - }, "node_modules/picomatch": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", @@ -5261,16 +5559,6 @@ "react-is": "^16.13.1" } }, - "node_modules/prop-types-exact": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/prop-types-exact/-/prop-types-exact-1.2.0.tgz", - "integrity": "sha512-K+Tk3Kd9V0odiXFP9fwDHUYRyvK3Nun3GVyPapSIs5OBkITAm15W0CPFD/YKTkMUAbc0b9CUwRQp2ybiBIq+eA==", - "dependencies": { - "has": "^1.0.3", - "object.assign": "^4.1.0", - "reflect.ownkeys": "^0.2.0" - } - }, "node_modules/prop-types-extra": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/prop-types-extra/-/prop-types-extra-1.1.1.tgz", @@ -5331,17 +5619,6 @@ "teleport": ">=0.2.0" } }, - "node_modules/query-string": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/query-string/-/query-string-2.4.2.tgz", - "integrity": "sha512-Y+OMYUuY7HxznI6WBN822fi/FMvnCTiuqd6KNcidPColOmMWPoV1RGYyyzObve1T/dD1i0ZgCCbO8ytu0ZUrkA==", - "dependencies": { - "strict-uri-encode": "^1.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/querystring": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/querystring/-/querystring-0.2.0.tgz", @@ -5433,6 +5710,15 @@ "react": "^15.3.0 || ^16.0.0 || ^17.0.0 || ^18.0.0" } }, + "node_modules/react-colorful": { + "version": "5.6.1", + "resolved": "https://registry.npmjs.org/react-colorful/-/react-colorful-5.6.1.tgz", + "integrity": "sha512-1exovf0uGTGyq5mXQT0zgQ80uvj2PCwvF8zY1RN9/vbJVSjSo3fsB/4L3ObbF7u70NduSiK4xu4Y6q1MHoUGEw==", + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, "node_modules/react-dom": { "version": "16.14.0", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-16.14.0.tgz", @@ -5448,6 +5734,27 @@ "react": "^16.14.0" } }, + "node_modules/react-dropzone": { + "version": "14.2.3", + "resolved": "https://registry.npmjs.org/react-dropzone/-/react-dropzone-14.2.3.tgz", + "integrity": "sha512-O3om8I+PkFKbxCukfIR3QAGftYXDZfOE2N1mr/7qebQJHs7U+/RSL/9xomJNpRg9kM5h9soQSdf0Gc7OHF5Fug==", + "dependencies": { + "attr-accept": "^2.2.2", + "file-selector": "^0.6.0", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">= 10.13" + }, + "peerDependencies": { + "react": ">= 16.8 || 18.0.0" + } + }, + "node_modules/react-fast-compare": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-3.2.2.tgz", + "integrity": "sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ==" + }, "node_modules/react-focus-lock": { "version": "2.9.4", "resolved": "https://registry.npmjs.org/react-focus-lock/-/react-focus-lock-2.9.4.tgz", @@ -5496,6 +5803,33 @@ } } }, + "node_modules/react-intl": { + "version": "6.5.1", + "resolved": "https://registry.npmjs.org/react-intl/-/react-intl-6.5.1.tgz", + "integrity": "sha512-mKxfH7GV5P4dJcQmbq/xU8FVBl//xRudXgS5r1Gt62NEr+T8pnzQZZ2th1jP5BQ+Ne/3kS3uYpFcynj5KyXVhg==", + "peer": true, + "dependencies": { + "@formatjs/ecma402-abstract": "1.17.2", + "@formatjs/icu-messageformat-parser": "2.7.0", + "@formatjs/intl": "2.9.5", + "@formatjs/intl-displaynames": "6.6.1", + "@formatjs/intl-listformat": "7.5.0", + "@types/hoist-non-react-statics": "^3.3.1", + "@types/react": "16 || 17 || 18", + "hoist-non-react-statics": "^3.3.2", + "intl-messageformat": "10.5.4", + "tslib": "^2.4.0" + }, + "peerDependencies": { + "react": "^16.6.0 || 17 || 18", + "typescript": "5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "node_modules/react-is": { "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", @@ -5506,6 +5840,14 @@ "resolved": "https://registry.npmjs.org/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz", "integrity": "sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA==" }, + "node_modules/react-loading-skeleton": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/react-loading-skeleton/-/react-loading-skeleton-3.3.1.tgz", + "integrity": "sha512-NilqqwMh2v9omN7LteiDloEVpFyMIa0VGqF+ukqp0ncVlYu1sKYbYGX9JEl+GtOT9TKsh04zCHAbavnQ2USldA==", + "peerDependencies": { + "react": ">=16.8.0" + } + }, "node_modules/react-overlays": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/react-overlays/-/react-overlays-5.2.1.tgz", @@ -5525,6 +5867,20 @@ "react-dom": ">=16.3.0" } }, + "node_modules/react-popper": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/react-popper/-/react-popper-2.3.0.tgz", + "integrity": "sha512-e1hj8lL3uM+sgSR4Lxzn5h1GxBlpa4CQz0XLF8kx4MDrDRWY0Ena4c97PUeSX9i5W3UAfDP0z0FXCTQkoXUl3Q==", + "dependencies": { + "react-fast-compare": "^3.0.1", + "warning": "^4.0.2" + }, + "peerDependencies": { + "@popperjs/core": "^2.0.0", + "react": "^16.8.0 || ^17 || ^18", + "react-dom": "^16.8.0 || ^17 || ^18" + } + }, "node_modules/react-proptype-conditional-require": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/react-proptype-conditional-require/-/react-proptype-conditional-require-1.0.4.tgz", @@ -5575,22 +5931,6 @@ } } }, - "node_modules/react-responsive": { - "version": "6.1.2", - "resolved": "https://registry.npmjs.org/react-responsive/-/react-responsive-6.1.2.tgz", - "integrity": "sha512-AXentVC/kN3KED9zhzJv2pu4vZ0i6cSHdTtbCScVV1MT6F5KXaG2qs5D7WLmhdaOvmiMX8UfmS4ZSO+WPwDt4g==", - "dependencies": { - "hyphenate-style-name": "^1.0.0", - "matchmediaquery": "^0.3.0", - "prop-types": "^15.6.1" - }, - "engines": { - "node": ">= 0.10" - }, - "peerDependencies": { - "react": "^16.3.0" - } - }, "node_modules/react-style-singleton": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.1.tgz", @@ -5717,11 +6057,6 @@ "balanced-match": "^1.0.0" } }, - "node_modules/reflect.ownkeys": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/reflect.ownkeys/-/reflect.ownkeys-0.2.0.tgz", - "integrity": "sha512-qOLsBKHCpSOFKK1NUOCGC5VyeufB6lEsFe92AL2bhIJsacZS1qdoOZSbPk3MYKuT2cFlRDnulKXuuElIrMjGUg==" - }, "node_modules/regenerator-runtime": { "version": "0.13.11", "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", @@ -5741,22 +6076,6 @@ "node": ">=0.10.0" } }, - "node_modules/regexp.prototype.flags": { - "version": "1.4.3", - "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.4.3.tgz", - "integrity": "sha512-fjggEOO3slI6Wvgjwflkc4NFRCTZAu5CnNfBd5qOMYhWdn67nJBBu34/TkD++eeFmd8C9r9jfXJ27+nSiRkSUA==", - "dependencies": { - "call-bind": "^1.0.2", - "define-properties": "^1.1.3", - "functions-have-names": "^1.2.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/remove-trailing-separator": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/remove-trailing-separator/-/remove-trailing-separator-1.1.0.tgz", @@ -5823,6 +6142,18 @@ "dev": true, "optional": true }, + "node_modules/restore-cursor": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-3.1.0.tgz", + "integrity": "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==", + "dependencies": { + "onetime": "^5.1.0", + "signal-exit": "^3.0.2" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/ret": { "version": "0.1.15", "resolved": "https://registry.npmjs.org/ret/-/ret-0.1.15.tgz", @@ -5855,11 +6186,26 @@ "inherits": "^2.0.1" } }, + "node_modules/run-async": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/run-async/-/run-async-2.4.1.tgz", + "integrity": "sha512-tvVnVv01b8c1RrA6Ep7JkStj85Guv/YrMcwqYQnwjsAS2cTmmPGBBjAjpCW7RrSodNSoE2/qg9O4bceNvUuDgQ==", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/rxjs": { + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", + "integrity": "sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==", + "dependencies": { + "tslib": "^2.1.0" + } + }, "node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "dev": true, "funding": [ { "type": "github", @@ -5885,157 +6231,10 @@ "ret": "~0.1.10" } }, - "node_modules/safe-regex-test": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.0.0.tgz", - "integrity": "sha512-JBUUzyOgEwXQY1NuPtvcj/qcBDbDmEvWufhlnXZIm75DEHp+afM1r1ujJpJsV/gSM4t59tpDyPi1sd6ZaPFfsA==", - "dependencies": { - "call-bind": "^1.0.2", - "get-intrinsic": "^1.1.3", - "is-regex": "^1.1.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "dev": true - }, - "node_modules/sanitize-html": { - "version": "1.27.5", - "resolved": "https://registry.npmjs.org/sanitize-html/-/sanitize-html-1.27.5.tgz", - "integrity": "sha512-M4M5iXDAUEcZKLXkmk90zSYWEtk5NH3JmojQxKxV371fnMh+x9t1rqdmXaGoyEHw3z/X/8vnFhKjGL5xFGOJ3A==", - "dependencies": { - "htmlparser2": "^4.1.0", - "lodash": "^4.17.15", - "parse-srcset": "^1.0.2", - "postcss": "^7.0.27" - } - }, - "node_modules/sanitize-html/node_modules/dom-serializer": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-1.4.1.tgz", - "integrity": "sha512-VHwB3KfrcOOkelEG2ZOfxqLZdfkil8PtJi4P8N2MMXucZq2yLp75ClViUlOVwyoHEDjYU433Aq+5zWP61+RGag==", - "dependencies": { - "domelementtype": "^2.0.1", - "domhandler": "^4.2.0", - "entities": "^2.0.0" - }, - "funding": { - "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" - } - }, - "node_modules/sanitize-html/node_modules/dom-serializer/node_modules/domhandler": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-4.3.1.tgz", - "integrity": "sha512-GrwoxYN+uWlzO8uhUXRl0P+kHE4GtVPfYzVLcUxPL7KNdHKj66vvlhiweIHqYYXWlw+T8iLMp42Lm67ghw4WMQ==", - "dependencies": { - "domelementtype": "^2.2.0" - }, - "engines": { - "node": ">= 4" - }, - "funding": { - "url": "https://github.com/fb55/domhandler?sponsor=1" - } - }, - "node_modules/sanitize-html/node_modules/domelementtype": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", - "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fb55" - } - ] - }, - "node_modules/sanitize-html/node_modules/domhandler": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-3.3.0.tgz", - "integrity": "sha512-J1C5rIANUbuYK+FuFL98650rihynUOEzRLxW+90bKZRWB6A1X1Tf82GxR1qAWLyfNPRvjqfip3Q5tdYlmAa9lA==", - "dependencies": { - "domelementtype": "^2.0.1" - }, - "engines": { - "node": ">= 4" - }, - "funding": { - "url": "https://github.com/fb55/domhandler?sponsor=1" - } - }, - "node_modules/sanitize-html/node_modules/domutils": { - "version": "2.8.0", - "resolved": "https://registry.npmjs.org/domutils/-/domutils-2.8.0.tgz", - "integrity": "sha512-w96Cjofp72M5IIhpjgobBimYEfoPjx1Vx0BSX9P30WBdZW2WIKU0T1Bd0kz2eNZ9ikjKgHbEyKx8BB6H1L3h3A==", - "dependencies": { - "dom-serializer": "^1.0.1", - "domelementtype": "^2.2.0", - "domhandler": "^4.2.0" - }, - "funding": { - "url": "https://github.com/fb55/domutils?sponsor=1" - } - }, - "node_modules/sanitize-html/node_modules/domutils/node_modules/domhandler": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-4.3.1.tgz", - "integrity": "sha512-GrwoxYN+uWlzO8uhUXRl0P+kHE4GtVPfYzVLcUxPL7KNdHKj66vvlhiweIHqYYXWlw+T8iLMp42Lm67ghw4WMQ==", - "dependencies": { - "domelementtype": "^2.2.0" - }, - "engines": { - "node": ">= 4" - }, - "funding": { - "url": "https://github.com/fb55/domhandler?sponsor=1" - } - }, - "node_modules/sanitize-html/node_modules/entities": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/entities/-/entities-2.2.0.tgz", - "integrity": "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==", - "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" - } - }, - "node_modules/sanitize-html/node_modules/htmlparser2": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-4.1.0.tgz", - "integrity": "sha512-4zDq1a1zhE4gQso/c5LP1OtrhYTncXNSpvJYtWJBtXAETPlMfi3IFNjGuQbYLuVY4ZR0QMqRVvo4Pdy9KLyP8Q==", - "dependencies": { - "domelementtype": "^2.0.1", - "domhandler": "^3.0.0", - "domutils": "^2.0.0", - "entities": "^2.0.0" - } - }, - "node_modules/sanitize-html/node_modules/postcss": { - "version": "7.0.39", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-7.0.39.tgz", - "integrity": "sha512-yioayjNbHn6z1/Bywyb2Y4s3yvDAeXGOyxqD+LnVOinq6Mdmd++SW2wUNVzavyyHxd6+DxzWGIuosg6P1Rj8uA==", - "dependencies": { - "picocolors": "^0.2.1", - "source-map": "^0.6.1" - }, - "engines": { - "node": ">=6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - } - }, - "node_modules/sanitize-html/node_modules/source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "engines": { - "node": ">=0.10.0" - } + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" }, "node_modules/sass": { "version": "1.61.0", @@ -6186,6 +6385,11 @@ "node": ">=8" } }, + "node_modules/shallow-equal": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/shallow-equal/-/shallow-equal-1.2.1.tgz", + "integrity": "sha512-S4vJDjHHMBaiZuT9NPb616CSmLf618jawtv3sufLl6ivK8WocjAo58cXwbRV1cgqxH0Qbv+iUt6m05eqEa2IRA==" + }, "node_modules/shebang-command": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-1.2.0.tgz", @@ -6207,24 +6411,10 @@ "node": ">=0.10.0" } }, - "node_modules/side-channel": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.4.tgz", - "integrity": "sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw==", - "dependencies": { - "call-bind": "^1.0.0", - "get-intrinsic": "^1.0.2", - "object-inspect": "^1.9.0" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/signal-exit": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", - "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", - "dev": true + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==" }, "node_modules/snapdragon": { "version": "0.8.2", @@ -6504,6 +6694,14 @@ "integrity": "sha512-XkD+zwiqXHikFZm4AX/7JSCXA98U5Db4AFd5XUg/+9UNtnH75+Z9KxtpYiJZx36mUDVOwH83pl7yvCer6ewM3w==", "dev": true }, + "node_modules/split-on-first": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/split-on-first/-/split-on-first-1.1.0.tgz", + "integrity": "sha512-43ZssAJaMusuKWL8sKUBQXHWOpq8d6CfN/u1p4gUzfJkM05C8rxTmYrkIPTXapZpORA6LkkzcUulJ8FqA7Uudw==", + "engines": { + "node": ">=6" + } + }, "node_modules/split-string": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/split-string/-/split-string-3.1.0.tgz", @@ -6726,6 +6924,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/strict-uri-encode/-/strict-uri-encode-1.1.0.tgz", "integrity": "sha512-R3f198pcvnB+5IpnBlRkphuE9n46WyVl8I39W/ZUTZLz4nqSP/oLYUrcnJrw462Ds8he4YKMov2efsTIw1BDGQ==", + "dev": true, "engines": { "node": ">=0.10.0" } @@ -6770,48 +6969,6 @@ "node": ">=4" } }, - "node_modules/string.prototype.trim": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.7.tgz", - "integrity": "sha512-p6TmeT1T3411M8Cgg9wBTMRtY2q9+PNy9EV1i2lIXUN/btt763oIfxwN3RR8VU6wHX8j/1CFy0L+YuThm6bgOg==", - "dependencies": { - "call-bind": "^1.0.2", - "define-properties": "^1.1.4", - "es-abstract": "^1.20.4" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/string.prototype.trimend": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.6.tgz", - "integrity": "sha512-JySq+4mrPf9EsDBEDYMOb/lM7XQLulwg5R/m1r0PXEFqrV0qHvl58sdTilSXtKOflCsK2E8jxf+GKC0T07RWwQ==", - "dependencies": { - "call-bind": "^1.0.2", - "define-properties": "^1.1.4", - "es-abstract": "^1.20.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/string.prototype.trimstart": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.6.tgz", - "integrity": "sha512-omqjMDaY92pbn5HOX7f9IccLA+U1tA9GvtU4JrodiXFfYB7jPzzHpRzpglLAjtUV6bB557zwClJezTqnAiYnQA==", - "dependencies": { - "call-bind": "^1.0.2", - "define-properties": "^1.1.4", - "es-abstract": "^1.20.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/strip-ansi": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz", @@ -6910,11 +7067,6 @@ "node": ">=0.10.0" } }, - "node_modules/tabbable": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-4.0.0.tgz", - "integrity": "sha512-H1XoH1URcBOa/rZZWxLxHCtOdVUEev+9vo5YdYhC9tCY4wnybX+VQrCYuy9ubkg69fCBxCONJOSLGfw0DWMffQ==" - }, "node_modules/tapable": { "version": "0.2.9", "resolved": "https://registry.npmjs.org/tapable/-/tapable-0.2.9.tgz", @@ -6924,6 +7076,11 @@ "node": ">=0.6" } }, + "node_modules/through": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", + "integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==" + }, "node_modules/timers-browserify": { "version": "2.0.12", "resolved": "https://registry.npmjs.org/timers-browserify/-/timers-browserify-2.0.12.tgz", @@ -6936,6 +7093,17 @@ "node": ">=0.6.0" } }, + "node_modules/tmp": { + "version": "0.0.33", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz", + "integrity": "sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==", + "dependencies": { + "os-tmpdir": "~1.0.2" + }, + "engines": { + "node": ">=0.6.0" + } + }, "node_modules/to-arraybuffer": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/to-arraybuffer/-/to-arraybuffer-1.0.1.tgz", @@ -7013,17 +7181,15 @@ "integrity": "sha512-+5nt5AAniqsCnu2cEQQdpzCAh33kVx8n0VoFidKpB1dVVLAN/F+bgVOqOJqOnEnrhp222clB5p3vUlD+1QAnfg==", "dev": true }, - "node_modules/typed-array-length": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.4.tgz", - "integrity": "sha512-KjZypGq+I/H7HI5HlOoGHkWUUGq+Q0TPhQurLbyrVrvnKTBgzLhIJ7j6J/XTQOi0d1RjyZ0wdas8bKs2p0x3Ng==", - "dependencies": { - "call-bind": "^1.0.2", - "for-each": "^0.3.3", - "is-typed-array": "^1.1.9" + "node_modules/type-fest": { + "version": "0.21.3", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", + "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", + "engines": { + "node": ">=10" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/uglify-js": { @@ -7102,20 +7268,6 @@ "webpack": "^1.9 || ^2 || ^2.1.0-beta || ^2.2.0-rc || ^3.0.0" } }, - "node_modules/unbox-primitive": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.0.2.tgz", - "integrity": "sha512-61pPlCD9h51VoreyJ0BReideM3MDKMKnh6+V9L08331ipq6Q8OFXZYiqP6n/tbHx4s5I9uRhcye6BrbkizkBDw==", - "dependencies": { - "call-bind": "^1.0.2", - "has-bigints": "^1.0.2", - "has-symbols": "^1.0.3", - "which-boxed-primitive": "^1.0.2" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/uncontrollable": { "version": "7.2.1", "resolved": "https://registry.npmjs.org/uncontrollable/-/uncontrollable-7.2.1.tgz", @@ -7343,8 +7495,7 @@ "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", - "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", - "dev": true + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==" }, "node_modules/util/node_modules/inherits": { "version": "2.0.3", @@ -7352,6 +7503,18 @@ "integrity": "sha512-x00IRNXNy63jwGkJmzPigoySHbaqpNuzKbBOmzK+g2OdZpQ9w+sxCN+VSB3ja7IAge2OP2qpfxTjeNcyjmW1uw==", "dev": true }, + "node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/validate-npm-package-license": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz", @@ -7680,6 +7843,14 @@ "node": ">=0.10.0" } }, + "node_modules/wcwidth": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz", + "integrity": "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==", + "dependencies": { + "defaults": "^1.0.3" + } + }, "node_modules/webpack": { "version": "3.12.0", "resolved": "https://registry.npmjs.org/webpack/-/webpack-3.12.0.tgz", @@ -7823,46 +7994,12 @@ "which": "bin/which" } }, - "node_modules/which-boxed-primitive": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.0.2.tgz", - "integrity": "sha512-bwZdv0AKLpplFY2KZRX6TvyuN7ojjr7lwkg6ml0roIy9YeuSr7JS372qlNW18UQYzgYK9ziGcerWqZOmEn9VNg==", - "dependencies": { - "is-bigint": "^1.0.1", - "is-boolean-object": "^1.1.0", - "is-number-object": "^1.0.4", - "is-string": "^1.0.5", - "is-symbol": "^1.0.3" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/which-module": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.0.tgz", "integrity": "sha512-B+enWhmw6cjfVC7kS8Pj9pCrKSc5txArRyaYGe088shv/FGWH+0Rjx/xPgtsWfsUtS27FkP697E4DDhgrgoc0Q==", "dev": true }, - "node_modules/which-typed-array": { - "version": "1.1.9", - "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.9.tgz", - "integrity": "sha512-w9c4xkx6mPidwp7180ckYWfMmvxpjlZuIudNtDf4N/tTAUB8VJbX25qZoAsrtGuYNnGw3pa0AXgbGKRB8/EceA==", - "dependencies": { - "available-typed-arrays": "^1.0.5", - "call-bind": "^1.0.2", - "for-each": "^0.3.3", - "gopd": "^1.0.1", - "has-tostringtag": "^1.0.0", - "is-typed-array": "^1.1.10" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/window-size": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/window-size/-/window-size-0.1.0.tgz", @@ -7923,8 +8060,7 @@ "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "dev": true + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" }, "node_modules/xtend": { "version": "4.0.2", diff --git a/package.json b/package.json index 4762e7498d..30991b62c6 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ }, "dependencies": { "@edx/brand": "npm:@edx/brand-edx.org@^1.3.0", - "@edx/paragon": "^12.3.0" + "@openedx/paragon": "^21.5.7" }, "devDependencies": { "css-loader": "^0.28.7", From bbbf5e30897aeda7b2ca4704c440ed03835956d6 Mon Sep 17 00:00:00 2001 From: Feanil Patel Date: Wed, 13 Dec 2023 13:53:27 -0500 Subject: [PATCH 057/164] chore: Prep for a release. Update the changelog and bump the version. --- CHANGELOG.rst | 7 +++++++ enterprise/__init__.py | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index ff4bec00b4..81fb3fb64c 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -15,6 +15,13 @@ Change Log Unreleased ---------- + +[4.8.12] +-------- + +* chore: update paragon npm dependency to move to the new @openedx scope. + + [4.8.11] -------- feat: allow incomplete course learner transmissions till completion diff --git a/enterprise/__init__.py b/enterprise/__init__.py index 21c8fa1dd8..9da0570a94 100644 --- a/enterprise/__init__.py +++ b/enterprise/__init__.py @@ -2,4 +2,4 @@ Your project description goes here. """ -__version__ = "4.8.11" +__version__ = "4.8.12" From d86285cd5ee071194636ac898a9d0807640fe3fe Mon Sep 17 00:00:00 2001 From: hamzawaleed01 Date: Mon, 18 Dec 2023 14:44:20 +0500 Subject: [PATCH 058/164] feat: update content transmission job to post skills to degreed2 --- enterprise/api_client/enterprise_catalog.py | 14 ++++ integrated_channels/degreed2/client.py | 20 +++++ .../test_degreed2/test_client.py | 74 ++++++++++++++++++- 3 files changed, 104 insertions(+), 4 deletions(-) diff --git a/enterprise/api_client/enterprise_catalog.py b/enterprise/api_client/enterprise_catalog.py index 3464cfa446..21c4346a68 100644 --- a/enterprise/api_client/enterprise_catalog.py +++ b/enterprise/api_client/enterprise_catalog.py @@ -28,6 +28,8 @@ class EnterpriseCatalogApiClient(UserAPIClient): REFRESH_CATALOG_ENDPOINT = ENTERPRISE_CATALOG_ENDPOINT + '/{}/refresh_metadata' CATALOG_DIFF_ENDPOINT = ENTERPRISE_CATALOG_ENDPOINT + '/{}/generate_diff' ENTERPRISE_CUSTOMER_ENDPOINT = 'enterprise-customer' + CONTENT_METADATA_IDENTIFIER_ENDPOINT = ENTERPRISE_CUSTOMER_ENDPOINT + \ + '/{}/content-metadata/'+'{}' APPEND_SLASH = True GET_CONTENT_METADATA_PAGE_SIZE = getattr(settings, 'ENTERPRISE_CATALOG_GET_CONTENT_METADATA_PAGE_SIZE', 50) @@ -311,6 +313,18 @@ def enterprise_contains_content_items(self, enterprise_uuid, content_ids): response = self.client.get(api_url, params=query_params) response.raise_for_status() return response.json()['contains_content_items'] + + @UserAPIClient.refresh_token + def get_content_metadata_content_identifier(self, enterprise_uuid, content_id): + """ + Return all content metadata contained in the catalogs associated with the the + given EnterpriseCustomer and content_id. + """ + api_url = self.get_api_url( + f"{self.CONTENT_METADATA_IDENTIFIER_ENDPOINT.format(enterprise_uuid, content_id)}") + response = self.client.get(api_url) + response.raise_for_status() + return response.json() class NoAuthEnterpriseCatalogClient(NoAuthAPIClient): diff --git a/integrated_channels/degreed2/client.py b/integrated_channels/degreed2/client.py index 00070bda1e..0649b6740e 100644 --- a/integrated_channels/degreed2/client.py +++ b/integrated_channels/degreed2/client.py @@ -15,6 +15,7 @@ from django.conf import settings from django.http.request import QueryDict +from enterprise.api_client.enterprise_catalog import EnterpriseCatalogApiClient from enterprise.models import EnterpriseCustomerUser from integrated_channels.exceptions import ClientError from integrated_channels.integrated_channel.client import IntegratedChannelApiClient @@ -275,6 +276,25 @@ def create_content_metadata(self, serialized_data): f'Degreed2APIClient create_content_metadata failed with status {status_code}: {response_body}', status_code=status_code ) + # once course is created/updated successfully, we need to do 2 more steps + # 1. Fetch skills from enterprise-catalog + client = EnterpriseCatalogApiClient() + metadata = client.get_content_metadata_content_identifier( + enterprise_uuid=self.enterprise_configuration.enterprise_customer.uuid, + content_id=a_course.get('external-id') + ) + LOGGER.warning( + generate_formatted_log( + self.enterprise_configuration.channel_code(), + self.enterprise_configuration.enterprise_customer.uuid, + None, + None, + f'[Degreed2Client] metadata: {metadata}' + ) + ) + # 2. Transmit to degreed + course_id = a_course.get("external-id") + self.assign_course_skills(course_id, metadata['skill_names']) return status_code, response_body def update_content_metadata(self, serialized_data): diff --git a/tests/test_integrated_channels/test_degreed2/test_client.py b/tests/test_integrated_channels/test_degreed2/test_client.py index 454581407f..d662fe26f8 100644 --- a/tests/test_integrated_channels/test_degreed2/test_client.py +++ b/tests/test_integrated_channels/test_degreed2/test_client.py @@ -16,6 +16,7 @@ from django.apps.registry import apps +from enterprise.api_client.enterprise_catalog import EnterpriseCatalogApiClient from enterprise.models import EnterpriseCustomerUser from integrated_channels.degreed2.client import Degreed2APIClient from integrated_channels.exceptions import ClientError @@ -206,6 +207,8 @@ def test_delete_course_completion(self): degreed_api_client.delete_course_completion(None, None) @responses.activate + @pytest.mark.django_db + @mock.patch('enterprise.api_client.client.JwtBuilder', mock.Mock()) def test_create_content_metadata_success(self): """ ``create_content_metadata`` should use expected URLs and receive correct response. @@ -214,7 +217,7 @@ def test_create_content_metadata_success(self): degreed_api_client = Degreed2APIClient(enterprise_config) oauth_url = degreed_api_client.get_oauth_url() course_url = degreed_api_client.get_courses_url() - + degreed_course_id = 'degreed-id' responses.add( responses.POST, oauth_url, @@ -227,15 +230,36 @@ def test_create_content_metadata_success(self): json='{}', status=200 ) + responses.add( + responses.GET, + EnterpriseCatalogApiClient.API_BASE_URL + + EnterpriseCatalogApiClient.CONTENT_METADATA_IDENTIFIER_ENDPOINT.format( + enterprise_config.enterprise_customer.uuid, "key/"), + json={"skill_names": ["Supply Chain", "Supply Chain Management"]}, + status=200 + ) + responses.add( + responses.GET, + course_url+"?filter%5Bexternal_id%5D=key", + json={'data': [{'id': degreed_course_id}]}, + status=200 + ) + responses.add( + responses.PATCH, + f'{enterprise_config.degreed_base_url}api/v2/content/{degreed_course_id}/relationships/skills', + json='{}', + status=200 + ) status_code, response_body = degreed_api_client.create_content_metadata(create_course_payload()) - assert len(responses.calls) == 2 + assert len(responses.calls) == 5 assert responses.calls[0].request.url == oauth_url assert responses.calls[1].request.url == course_url assert status_code == 200 assert response_body == '"{}"' @responses.activate + @mock.patch('enterprise.api_client.client.JwtBuilder', mock.Mock()) def test_create_content_metadata_retry_success(self): """ ``create_content_metadata`` should hit a 429 and retry and receive correct response. @@ -244,6 +268,7 @@ def test_create_content_metadata_retry_success(self): degreed_api_client = Degreed2APIClient(enterprise_config) oauth_url = degreed_api_client.get_oauth_url() course_url = degreed_api_client.get_courses_url() + degreed_course_id='degreed-id' responses.add( responses.POST, @@ -263,9 +288,28 @@ def test_create_content_metadata_retry_success(self): json='{}', status=200, ) - + responses.add( + responses.GET, + EnterpriseCatalogApiClient.API_BASE_URL + + EnterpriseCatalogApiClient.CONTENT_METADATA_IDENTIFIER_ENDPOINT.format( + enterprise_config.enterprise_customer.uuid, "key/"), + json={"skill_names": ["Supply Chain", "Supply Chain Management"]}, + status=200 + ) + responses.add( + responses.GET, + course_url+"?filter%5Bexternal_id%5D=key", + json={'data': [{'id': degreed_course_id}]}, + status=200 + ) + responses.add( + responses.PATCH, + f'{enterprise_config.degreed_base_url}api/v2/content/{degreed_course_id}/relationships/skills', + json='{}', + status=200 + ) status_code, response_body = degreed_api_client.create_content_metadata(create_course_payload()) - assert len(responses.calls) == 3 + assert len(responses.calls) == 6 assert responses.calls[0].request.url == oauth_url assert responses.calls[1].request.url == course_url assert responses.calls[2].request.url == course_url @@ -325,6 +369,7 @@ def test_create_content_metadata_retry_exhaust(self): assert json.loads(response_body) == self.too_fast_response @responses.activate + @mock.patch('enterprise.api_client.client.JwtBuilder', mock.Mock()) def test_create_content_metadata_course_exists(self): """ ``create_content_metadata`` should return 409 status and not fail @@ -333,6 +378,7 @@ def test_create_content_metadata_course_exists(self): degreed_api_client = Degreed2APIClient(enterprise_config) oauth_url = degreed_api_client.get_oauth_url() course_url = degreed_api_client.get_courses_url() + degreed_course_id='degreed-id' responses.add( responses.POST, @@ -346,6 +392,26 @@ def test_create_content_metadata_course_exists(self): json='{}', status=409 ) + responses.add( + responses.GET, + EnterpriseCatalogApiClient.API_BASE_URL + + EnterpriseCatalogApiClient.CONTENT_METADATA_IDENTIFIER_ENDPOINT.format( + enterprise_config.enterprise_customer.uuid, "key/"), + json={"skill_names": ["Supply Chain", "Supply Chain Management"]}, + status=200 + ) + responses.add( + responses.GET, + course_url+"?filter%5Bexternal_id%5D=key", + json={'data': [{'id': degreed_course_id}]}, + status=200 + ) + responses.add( + responses.PATCH, + f'{enterprise_config.degreed_base_url}api/v2/content/{degreed_course_id}/relationships/skills', + json='{}', + status=200 + ) status_code, _ = degreed_api_client.create_content_metadata(create_course_payload()) # we treat as "course exists" as a success assert status_code == 200 From 3564ea010cb5c2b79960fc4a0c40ef5ee31f8a25 Mon Sep 17 00:00:00 2001 From: hamzawaleed01 Date: Tue, 19 Dec 2023 13:23:48 +0500 Subject: [PATCH 059/164] feat: transmit skills to degreed2 when courses are updated --- enterprise/api_client/enterprise_catalog.py | 6 +- integrated_channels/degreed2/client.py | 51 ++++++++++-- .../test_degreed2/test_client.py | 81 +++++++++++++++++-- 3 files changed, 125 insertions(+), 13 deletions(-) diff --git a/enterprise/api_client/enterprise_catalog.py b/enterprise/api_client/enterprise_catalog.py index e40b073d58..56dd5d1597 100644 --- a/enterprise/api_client/enterprise_catalog.py +++ b/enterprise/api_client/enterprise_catalog.py @@ -28,9 +28,9 @@ class EnterpriseCatalogApiClient(UserAPIClient): REFRESH_CATALOG_ENDPOINT = ENTERPRISE_CATALOG_ENDPOINT + '/{}/refresh_metadata' CATALOG_DIFF_ENDPOINT = ENTERPRISE_CATALOG_ENDPOINT + '/{}/generate_diff' ENTERPRISE_CUSTOMER_ENDPOINT = 'enterprise-customer' - CONTENT_METADATA_IDENTIFIER_ENDPOINT = ( - ENTERPRISE_CUSTOMER_ENDPOINT + "/{}/content-metadata/" + "{}" - ) + CONTENT_METADATA_IDENTIFIER_ENDPOINT = ENTERPRISE_CUSTOMER_ENDPOINT + \ + "/{}/content-metadata/" + "{}" + APPEND_SLASH = True GET_CONTENT_METADATA_PAGE_SIZE = getattr(settings, 'ENTERPRISE_CATALOG_GET_CONTENT_METADATA_PAGE_SIZE', 50) diff --git a/integrated_channels/degreed2/client.py b/integrated_channels/degreed2/client.py index 2b5a2733ec..5203320cb1 100644 --- a/integrated_channels/degreed2/client.py +++ b/integrated_channels/degreed2/client.py @@ -260,13 +260,14 @@ def create_content_metadata(self, serialized_data): channel_metadata_item = json.loads(serialized_data.decode('utf-8')) # only expect one course in this array as of now (chunk size is 1) a_course = channel_metadata_item['courses'][0] + external_id = a_course.get('external-id') status_code, response_body = self._sync_content_metadata(a_course, 'post', self.get_courses_url()) if status_code == 409: # course already exists, don't raise failure, but log and move on LOGGER.warning( self.make_log_msg( - a_course.get('external-id'), - f'Course with integration_id = {a_course.get("external-id")} already exists, ' + external_id, + f'Course with integration_id = {external_id} already exists, ' ) ) # content already exists, we'll treat this as a success @@ -276,14 +277,14 @@ def create_content_metadata(self, serialized_data): f'Degreed2APIClient create_content_metadata failed with status {status_code}: {response_body}', status_code=status_code ) - # once course is created/updated successfully, we need to do 2 more steps + # once course is created successfully, we need to do 2 more steps # 1. Fetch skills from enterprise-catalog client = EnterpriseCatalogApiClient() metadata = client.get_content_metadata_content_identifier( enterprise_uuid=self.enterprise_configuration.enterprise_customer.uuid, - content_id=a_course.get('external-id') + content_id=external_id ) - LOGGER.warning( + LOGGER.info( generate_formatted_log( self.enterprise_configuration.channel_code(), self.enterprise_configuration.enterprise_customer.uuid, @@ -297,6 +298,16 @@ def create_content_metadata(self, serialized_data): if skills: course_id = a_course.get("external-id") self.assign_course_skills(course_id, skills) + LOGGER.info( + generate_formatted_log( + self.enterprise_configuration.channel_code(), + self.enterprise_configuration.enterprise_customer.uuid, + None, + None, + f'[Degreed2Client] transmitted skills: {metadata["skill_names"]},' + f'for course: {course_id}' + ) + ) return status_code, response_body def update_content_metadata(self, serialized_data): @@ -325,6 +336,36 @@ def update_content_metadata(self, serialized_data): patch_url, course_id ) + # once course is updated successfully, we need to do 2 more steps + # 1. Fetch skills from enterprise-catalog + client = EnterpriseCatalogApiClient() + metadata = client.get_content_metadata_content_identifier( + enterprise_uuid=self.enterprise_configuration.enterprise_customer.uuid, + content_id=external_id + ) + LOGGER.info( + generate_formatted_log( + self.enterprise_configuration.channel_code(), + self.enterprise_configuration.enterprise_customer.uuid, + None, + None, + f'[Degreed2Client] metadata: {metadata}' + ) + ) + # 2. Transmit to degreed + skills = metadata['skill_names'] + if skills: + self.assign_course_skills(external_id, skills) + LOGGER.info( + generate_formatted_log( + self.enterprise_configuration.channel_code(), + self.enterprise_configuration.enterprise_customer.uuid, + None, + None, + f'[Degreed2Client] transmitted skills: {metadata["skill_names"]},' + f'for course: {course_id}' + ) + ) return patch_status_code, patch_response_body def delete_content_metadata(self, serialized_data): diff --git a/tests/test_integrated_channels/test_degreed2/test_client.py b/tests/test_integrated_channels/test_degreed2/test_client.py index a2f2d84f1f..52e431f621 100644 --- a/tests/test_integrated_channels/test_degreed2/test_client.py +++ b/tests/test_integrated_channels/test_degreed2/test_client.py @@ -421,6 +421,7 @@ def test_create_content_metadata_course_exists(self): @responses.activate @mock.patch('integrated_channels.degreed2.client.Degreed2APIClient.fetch_degreed_course_id') + @mock.patch('enterprise.api_client.client.JwtBuilder', mock.Mock()) def test_update_content_metadata_success(self, mock_fetch_degreed_course_id): """ ``update_content_metadata`` should use the appropriate URLs for transmission. @@ -429,6 +430,8 @@ def test_update_content_metadata_success(self, mock_fetch_degreed_course_id): enterprise_config = factories.Degreed2EnterpriseCustomerConfigurationFactory() degreed_api_client = Degreed2APIClient(enterprise_config) oauth_url = degreed_api_client.get_oauth_url() + degreed_course_id = "a_course_id" + course_url = degreed_api_client.get_courses_url() responses.add( responses.POST, @@ -442,9 +445,29 @@ def test_update_content_metadata_success(self, mock_fetch_degreed_course_id): json='{}', status=200 ) - + responses.add( + responses.GET, + EnterpriseCatalogApiClient.API_BASE_URL + + EnterpriseCatalogApiClient.CONTENT_METADATA_IDENTIFIER_ENDPOINT.format( + enterprise_config.enterprise_customer.uuid, "key/" + ), + json={"skill_names": ["Supply Chain", "Supply Chain Management"]}, + status=200, + ) + responses.add( + responses.GET, + course_url + "?filter%5Bexternal_id%5D=key", + json={"data": [{"id": degreed_course_id}]}, + status=200, + ) + responses.add( + responses.PATCH, + f'{enterprise_config.degreed_base_url}api/v2/content/{degreed_course_id}/relationships/skills', + json='{}', + status=200 + ) status_code, response_body = degreed_api_client.update_content_metadata(create_course_payload()) - assert len(responses.calls) == 2 + assert len(responses.calls) == 4 assert responses.calls[0].request.url == oauth_url assert responses.calls[1].request.url == f'{degreed_api_client.get_courses_url()}/a_course_id' assert status_code == 200 @@ -452,6 +475,7 @@ def test_update_content_metadata_success(self, mock_fetch_degreed_course_id): @responses.activate @mock.patch('integrated_channels.degreed2.client.Degreed2APIClient.fetch_degreed_course_id') + @mock.patch('enterprise.api_client.client.JwtBuilder', mock.Mock()) def test_assign_course_skills(self, mock_fetch_degreed_course_id): """ ``assign_course_skills`` should use the appropriate URL for making API call. @@ -563,6 +587,7 @@ def test_assign_skills_api_failure_response(self, mock_fetch_degreed_course_id): @responses.activate @mock.patch('integrated_channels.degreed2.client.Degreed2APIClient.fetch_degreed_course_id') + @mock.patch('enterprise.api_client.client.JwtBuilder', mock.Mock()) def test_update_content_metadata_retry_success(self, mock_fetch_degreed_course_id): """ ``update_content_metadata`` should use the appropriate URLs for transmission. @@ -571,6 +596,8 @@ def test_update_content_metadata_retry_success(self, mock_fetch_degreed_course_i enterprise_config = factories.Degreed2EnterpriseCustomerConfigurationFactory() degreed_api_client = Degreed2APIClient(enterprise_config) oauth_url = degreed_api_client.get_oauth_url() + degreed_course_id = 'a_course_id' + course_url = degreed_api_client.get_courses_url() responses.add( responses.POST, @@ -590,9 +617,29 @@ def test_update_content_metadata_retry_success(self, mock_fetch_degreed_course_i json='{}', status=200 ) - + responses.add( + responses.GET, + EnterpriseCatalogApiClient.API_BASE_URL + + EnterpriseCatalogApiClient.CONTENT_METADATA_IDENTIFIER_ENDPOINT.format( + enterprise_config.enterprise_customer.uuid, "key/" + ), + json={"skill_names": ["Supply Chain", "Supply Chain Management"]}, + status=200, + ) + responses.add( + responses.GET, + course_url + "?filter%5Bexternal_id%5D=key", + json={"data": [{"id": degreed_course_id}]}, + status=200, + ) + responses.add( + responses.PATCH, + f'{enterprise_config.degreed_base_url}api/v2/content/{degreed_course_id}/relationships/skills', + json='{}', + status=200 + ) status_code, response_body = degreed_api_client.update_content_metadata(create_course_payload()) - assert len(responses.calls) == 3 + assert len(responses.calls) == 5 assert responses.calls[0].request.url == oauth_url assert responses.calls[1].request.url == f'{degreed_api_client.get_courses_url()}/a_course_id' assert responses.calls[2].request.url == f'{degreed_api_client.get_courses_url()}/a_course_id' @@ -601,6 +648,7 @@ def test_update_content_metadata_retry_success(self, mock_fetch_degreed_course_i @responses.activate @mock.patch('integrated_channels.degreed2.client.Degreed2APIClient.fetch_degreed_course_id') + @mock.patch('enterprise.api_client.client.JwtBuilder', mock.Mock()) def test_update_content_metadata_retry_exhaust(self, mock_fetch_degreed_course_id): """ ``update_content_metadata`` should use the appropriate URLs for transmission. @@ -609,6 +657,8 @@ def test_update_content_metadata_retry_exhaust(self, mock_fetch_degreed_course_i enterprise_config = factories.Degreed2EnterpriseCustomerConfigurationFactory() degreed_api_client = Degreed2APIClient(enterprise_config) oauth_url = degreed_api_client.get_oauth_url() + degreed_course_id = 'a_course_id' + course_url = degreed_api_client.get_courses_url() responses.add( responses.POST, @@ -646,9 +696,30 @@ def test_update_content_metadata_retry_exhaust(self, mock_fetch_degreed_course_i json=self.too_fast_response, status=429 ) + responses.add( + responses.GET, + EnterpriseCatalogApiClient.API_BASE_URL + + EnterpriseCatalogApiClient.CONTENT_METADATA_IDENTIFIER_ENDPOINT.format( + enterprise_config.enterprise_customer.uuid, "key/" + ), + json={"skill_names": ["Supply Chain", "Supply Chain Management"]}, + status=200, + ) + responses.add( + responses.GET, + course_url + "?filter%5Bexternal_id%5D=key", + json={"data": [{"id": degreed_course_id}]}, + status=200, + ) + responses.add( + responses.PATCH, + f'{enterprise_config.degreed_base_url}api/v2/content/{degreed_course_id}/relationships/skills', + json='{}', + status=200 + ) status_code, response_body = degreed_api_client.update_content_metadata(create_course_payload()) - assert len(responses.calls) == 6 + assert len(responses.calls) == 8 assert responses.calls[0].request.url == oauth_url assert responses.calls[1].request.url == f'{degreed_api_client.get_courses_url()}/a_course_id' assert responses.calls[2].request.url == f'{degreed_api_client.get_courses_url()}/a_course_id' From 2b7f603d168cd8ce368c098534e058d624ae0859 Mon Sep 17 00:00:00 2001 From: MueezKhan246 <93375917+MueezKhan246@users.noreply.github.com> Date: Tue, 19 Dec 2023 13:53:10 +0500 Subject: [PATCH 060/164] Fixed create_course_completion request's response handling in case reponse body is '0' (#1968) * fix: fixed create_course_completion request's response handling in case return body is 0 --- integrated_channels/moodle/client.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/integrated_channels/moodle/client.py b/integrated_channels/moodle/client.py index 7f6ac96e0e..6dfb8e5e55 100644 --- a/integrated_channels/moodle/client.py +++ b/integrated_channels/moodle/client.py @@ -61,17 +61,16 @@ def inner(self, *args, **kwargs): # This only happens for grades AFAICT. Zero also doesn't necessarily mean success, # but we have nothing else to go on if body == 0: - if method.__name__ == "_wrapped_create_course_completion": - completion_data = kwargs.get('payload') - course_id = completion_data.get('courseID', None) + if method.__name__ == "_wrapped_create_course_completion" and response.status_code == 200: LOGGER.info( 'Integer Response for Moodle Course Completion' - f'for course={course_id} ' + f'with data kwargs={kwargs} and args={args} ' f' response: {response} ' f'Status Code: {response.status_code}, ' f'Text: {response.text}, ' f'Headers: {response.headers}, ' ) + return {'status_code': 200, 'text': ''} return 200, '' raise ClientError('Moodle API Grade Update failed with int code: {code}'.format(code=body), 500) if isinstance(body, str): From 1ce0a4360fe99f642e7f1c118446e59fe0dd96b6 Mon Sep 17 00:00:00 2001 From: hamzawaleed01 Date: Tue, 19 Dec 2023 15:03:30 +0500 Subject: [PATCH 061/164] feat: more efficient error handling --- enterprise/api_client/enterprise_catalog.py | 29 ++++++++++++++++----- integrated_channels/degreed2/client.py | 5 ++-- 2 files changed, 24 insertions(+), 10 deletions(-) diff --git a/enterprise/api_client/enterprise_catalog.py b/enterprise/api_client/enterprise_catalog.py index 56dd5d1597..cea4a4fa63 100644 --- a/enterprise/api_client/enterprise_catalog.py +++ b/enterprise/api_client/enterprise_catalog.py @@ -8,6 +8,7 @@ from urllib.parse import urljoin from requests.exceptions import ConnectionError, RequestException, Timeout # pylint: disable=redefined-builtin +from rest_framework.exceptions import NotFound from django.conf import settings @@ -30,7 +31,6 @@ class EnterpriseCatalogApiClient(UserAPIClient): ENTERPRISE_CUSTOMER_ENDPOINT = 'enterprise-customer' CONTENT_METADATA_IDENTIFIER_ENDPOINT = ENTERPRISE_CUSTOMER_ENDPOINT + \ "/{}/content-metadata/" + "{}" - APPEND_SLASH = True GET_CONTENT_METADATA_PAGE_SIZE = getattr(settings, 'ENTERPRISE_CATALOG_GET_CONTENT_METADATA_PAGE_SIZE', 50) @@ -316,16 +316,31 @@ def enterprise_contains_content_items(self, enterprise_uuid, content_ids): return response.json()['contains_content_items'] @UserAPIClient.refresh_token - def get_content_metadata_content_identifier(self, enterprise_uuid, content_id): + def get_content_metadata_content_identifier(self, enterprise_uuid, content_id): # pylint: disable=inconsistent-return-statements """ Return all content metadata contained in the catalogs associated with the the given EnterpriseCustomer and content_id. """ - api_url = self.get_api_url( - f"{self.CONTENT_METADATA_IDENTIFIER_ENDPOINT.format(enterprise_uuid, content_id)}") - response = self.client.get(api_url) - response.raise_for_status() - return response.json() + try: + api_url = self.get_api_url( + f"{self.CONTENT_METADATA_IDENTIFIER_ENDPOINT.format(enterprise_uuid, content_id)}" + ) + response = self.client.get(api_url) + response.raise_for_status() + return response.json() + except NotFound as exc: + LOGGER.exception( + "No matching content found in catalog for customer: [%s] or content_id: [%s], Error: %s", + enterprise_uuid, + content_id, + str(exc), + ) + return {} + except (RequestException, ConnectionError, Timeout) as exc: + LOGGER.exception( + "Exception raised in EnterpriseCatalogApiClient::get_content_metadata_content_identifier: [%s]", + str(exc), + ) class NoAuthEnterpriseCatalogClient(NoAuthAPIClient): diff --git a/integrated_channels/degreed2/client.py b/integrated_channels/degreed2/client.py index 5203320cb1..0fcf4e0f7f 100644 --- a/integrated_channels/degreed2/client.py +++ b/integrated_channels/degreed2/client.py @@ -296,8 +296,7 @@ def create_content_metadata(self, serialized_data): # 2. Transmit to degreed skills = metadata['skill_names'] if skills: - course_id = a_course.get("external-id") - self.assign_course_skills(course_id, skills) + self.assign_course_skills(external_id, skills) LOGGER.info( generate_formatted_log( self.enterprise_configuration.channel_code(), @@ -305,7 +304,7 @@ def create_content_metadata(self, serialized_data): None, None, f'[Degreed2Client] transmitted skills: {metadata["skill_names"]},' - f'for course: {course_id}' + f'for course: {external_id}' ) ) return status_code, response_body From 61c36ad0bc4476ef9c9dda9cf2fd951467aff464 Mon Sep 17 00:00:00 2001 From: MueezKhan246 <93375917+MueezKhan246@users.noreply.github.com> Date: Tue, 19 Dec 2023 16:14:27 +0500 Subject: [PATCH 062/164] refactor: update build version (#1971) --- CHANGELOG.rst | 5 +++++ enterprise/__init__.py | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 81fb3fb64c..9d315b79b2 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -16,6 +16,11 @@ Change Log Unreleased ---------- +[4.8.13] +-------- + +fix: fixed create_course_completion request's response handling in case return body is 0 + [4.8.12] -------- diff --git a/enterprise/__init__.py b/enterprise/__init__.py index 9da0570a94..15ce5134e4 100644 --- a/enterprise/__init__.py +++ b/enterprise/__init__.py @@ -2,4 +2,4 @@ Your project description goes here. """ -__version__ = "4.8.12" +__version__ = "4.8.13" From 80225d09ae5d13be4eae3db26a2a43a42fad8446 Mon Sep 17 00:00:00 2001 From: hamzawaleed01 Date: Wed, 20 Dec 2023 11:46:04 +0500 Subject: [PATCH 063/164] chore: did some refactoring --- enterprise/api_client/enterprise_catalog.py | 3 +- integrated_channels/degreed2/client.py | 70 ++++++++------------- 2 files changed, 29 insertions(+), 44 deletions(-) diff --git a/enterprise/api_client/enterprise_catalog.py b/enterprise/api_client/enterprise_catalog.py index cea4a4fa63..a27f69d6c7 100644 --- a/enterprise/api_client/enterprise_catalog.py +++ b/enterprise/api_client/enterprise_catalog.py @@ -318,7 +318,7 @@ def enterprise_contains_content_items(self, enterprise_uuid, content_ids): @UserAPIClient.refresh_token def get_content_metadata_content_identifier(self, enterprise_uuid, content_id): # pylint: disable=inconsistent-return-statements """ - Return all content metadata contained in the catalogs associated with the the + Return all content metadata contained in the catalogs associated with the given EnterpriseCustomer and content_id. """ try: @@ -341,6 +341,7 @@ def get_content_metadata_content_identifier(self, enterprise_uuid, content_id): "Exception raised in EnterpriseCatalogApiClient::get_content_metadata_content_identifier: [%s]", str(exc), ) + return {} class NoAuthEnterpriseCatalogClient(NoAuthAPIClient): diff --git a/integrated_channels/degreed2/client.py b/integrated_channels/degreed2/client.py index 0fcf4e0f7f..5d684e72d1 100644 --- a/integrated_channels/degreed2/client.py +++ b/integrated_channels/degreed2/client.py @@ -62,6 +62,7 @@ def __init__(self, enterprise_configuration): course_key, message, ) + self.enterprise_catalog_api_client = EnterpriseCatalogApiClient() def get_oauth_url(self): config = self.enterprise_configuration @@ -277,36 +278,8 @@ def create_content_metadata(self, serialized_data): f'Degreed2APIClient create_content_metadata failed with status {status_code}: {response_body}', status_code=status_code ) - # once course is created successfully, we need to do 2 more steps - # 1. Fetch skills from enterprise-catalog - client = EnterpriseCatalogApiClient() - metadata = client.get_content_metadata_content_identifier( - enterprise_uuid=self.enterprise_configuration.enterprise_customer.uuid, - content_id=external_id - ) - LOGGER.info( - generate_formatted_log( - self.enterprise_configuration.channel_code(), - self.enterprise_configuration.enterprise_customer.uuid, - None, - None, - f'[Degreed2Client] metadata: {metadata}' - ) - ) - # 2. Transmit to degreed - skills = metadata['skill_names'] - if skills: - self.assign_course_skills(external_id, skills) - LOGGER.info( - generate_formatted_log( - self.enterprise_configuration.channel_code(), - self.enterprise_configuration.enterprise_customer.uuid, - None, - None, - f'[Degreed2Client] transmitted skills: {metadata["skill_names"]},' - f'for course: {external_id}' - ) - ) + self._fetch_and_assign_skills_to_course(external_id) + return status_code, response_body def update_content_metadata(self, serialized_data): @@ -335,37 +308,48 @@ def update_content_metadata(self, serialized_data): patch_url, course_id ) - # once course is updated successfully, we need to do 2 more steps + + self._fetch_and_assign_skills_to_course(external_id) + + return patch_status_code, patch_response_body + + def _fetch_and_assign_skills_to_course(self, external_id): + """ + Fetches content metadata(skills) from enterprise catalog API + and transmits them to Degreed2 against given external_id(course_id) + + Args: + external_id: Course id that is assigned to a course on Degreed side + """ + # We need to do 2 steps here: # 1. Fetch skills from enterprise-catalog - client = EnterpriseCatalogApiClient() - metadata = client.get_content_metadata_content_identifier( + + metadata = self.enterprise_catalog_api_client.get_content_metadata_content_identifier( enterprise_uuid=self.enterprise_configuration.enterprise_customer.uuid, - content_id=external_id - ) + content_id=external_id) LOGGER.info( generate_formatted_log( self.enterprise_configuration.channel_code(), self.enterprise_configuration.enterprise_customer.uuid, None, None, - f'[Degreed2Client] metadata: {metadata}' + f"[Degreed2Client] metadata: {metadata}", ) ) + # 2. Transmit to degreed - skills = metadata['skill_names'] + skills = metadata.get("skill_names", []) if skills: - self.assign_course_skills(external_id, skills) - LOGGER.info( + try: + self.assign_course_skills(external_id, skills) + except ClientError as err: generate_formatted_log( self.enterprise_configuration.channel_code(), self.enterprise_configuration.enterprise_customer.uuid, None, None, - f'[Degreed2Client] transmitted skills: {metadata["skill_names"]},' - f'for course: {course_id}' + f"[Degreed2Client]: {err.message}", ) - ) - return patch_status_code, patch_response_body def delete_content_metadata(self, serialized_data): """ From 2b22668d3cba05ef3a41693632d7ad58189e0834 Mon Sep 17 00:00:00 2001 From: hamzawaleed01 Date: Wed, 20 Dec 2023 12:10:52 +0500 Subject: [PATCH 064/164] chore: bump version and changelog --- CHANGELOG.rst | 5 +++++ enterprise/__init__.py | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 9d315b79b2..f157be875c 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -16,6 +16,11 @@ Change Log Unreleased ---------- +[4.8.14] +-------- + +feat: Modified existing content transmission job to post skills metadata to Degreed2 + [4.8.13] -------- diff --git a/enterprise/__init__.py b/enterprise/__init__.py index 15ce5134e4..acfa2c776e 100644 --- a/enterprise/__init__.py +++ b/enterprise/__init__.py @@ -2,4 +2,4 @@ Your project description goes here. """ -__version__ = "4.8.13" +__version__ = "4.8.14" From bbe649e5133aa7a643b13c1f05271cd3e0ab4b35 Mon Sep 17 00:00:00 2001 From: MueezKhan246 <93375917+MueezKhan246@users.noreply.github.com> Date: Wed, 20 Dec 2023 17:13:12 +0500 Subject: [PATCH 065/164] Replacing Encrypted Fields With Non Encrypted (#1897) * feat: replacing non encrypted fields of moodle config model with encrypted ones (ENT 5613) * feat: adding feature flag to test encrypted user data columns --- integrated_channels/moodle/client.py | 17 ++++- integrated_channels/moodle/models.py | 70 +++++++++++++++++++ test_utils/factories.py | 2 +- .../test_moodle/test_client.py | 6 ++ .../test_content_metadata.py | 3 + .../test_transmitters/test_learner_data.py | 3 + 6 files changed, 97 insertions(+), 4 deletions(-) diff --git a/integrated_channels/moodle/client.py b/integrated_channels/moodle/client.py index 6dfb8e5e55..b371e82299 100644 --- a/integrated_channels/moodle/client.py +++ b/integrated_channels/moodle/client.py @@ -10,6 +10,7 @@ import requests from django.apps import apps +from django.conf import settings from integrated_channels.exceptions import ClientError from integrated_channels.integrated_channel.client import IntegratedChannelApiClient @@ -131,7 +132,12 @@ def __init__(self, enterprise_configuration): """ super().__init__(enterprise_configuration) self.config = apps.get_app_config('moodle') - self.token = enterprise_configuration.token or self._get_access_token() + token = ( + enterprise_configuration.decrypted_token + if getattr(settings, 'FEATURES', {}).get('USE_ENCRYPTED_USER_DATA', False) + else enterprise_configuration.token + ) + self.token = token or self._get_access_token() self.api_url = urljoin(self.enterprise_configuration.moodle_base_url, self.MOODLE_API_PATH) def _post(self, additional_params): @@ -171,6 +177,11 @@ def _get_access_token(self): 'service': self.enterprise_configuration.service_short_name } + decrypted_username = self.enterprise_configuration.decrypted_username + username = self.enterprise_configuration.username + decrypted_password = self.enterprise_configuration.decrypted_password + password = self.enterprise_configuration.password + response = requests.post( urljoin( self.enterprise_configuration.moodle_base_url, @@ -181,8 +192,8 @@ def _get_access_token(self): 'Content-Type': 'application/x-www-form-urlencoded', }, data={ - 'username': self.enterprise_configuration.username, - 'password': self.enterprise_configuration.password, + "username": decrypted_username if settings.FEATURES.get('USE_ENCRYPTED_USER_DATA', False) else username, + "password": decrypted_password if settings.FEATURES.get('USE_ENCRYPTED_USER_DATA', False) else password, }, ) diff --git a/integrated_channels/moodle/models.py b/integrated_channels/moodle/models.py index 6ee487cbef..793dfbbe8a 100644 --- a/integrated_channels/moodle/models.py +++ b/integrated_channels/moodle/models.py @@ -9,6 +9,7 @@ from simple_history.models import HistoricalRecords from django.db import models +from django.utils.encoding import force_bytes, force_str from django.utils.translation import gettext_lazy as _ from integrated_channels.integrated_channel.models import ( @@ -76,6 +77,29 @@ class MoodleEnterpriseCustomerConfiguration(EnterpriseCustomerPluginConfiguratio null=True, ) + @property + def encrypted_username(self): + """ + Return encrypted username as a string. + + The data is encrypted in the DB at rest, but is unencrypted in the app when retrieved through the + decrypted_username field. This method will encrypt the username again before sending. + """ + if self.decrypted_username: + return force_str( + self._meta.get_field('decrypted_username').fernet.encrypt( + force_bytes(self.decrypted_username) + ) + ) + return self.decrypted_username + + @encrypted_username.setter + def encrypted_username(self, value): + """ + Set the encrypted username. + """ + self.decrypted_username = value + password = models.CharField( max_length=255, blank=True, @@ -96,6 +120,29 @@ class MoodleEnterpriseCustomerConfiguration(EnterpriseCustomerPluginConfiguratio null=True, ) + @property + def encrypted_password(self): + """ + Return encrypted password as a string. + + The data is encrypted in the DB at rest, but is unencrypted in the app when retrieved through the + decrypted_password field. This method will encrypt the password again before sending. + """ + if self.decrypted_password: + return force_str( + self._meta.get_field('decrypted_password').fernet.encrypt( + force_bytes(self.decrypted_password) + ) + ) + return self.decrypted_password + + @encrypted_password.setter + def encrypted_password(self, value): + """ + Set the encrypted password. + """ + self.decrypted_password = value + token = models.CharField( max_length=255, blank=True, @@ -116,6 +163,29 @@ class MoodleEnterpriseCustomerConfiguration(EnterpriseCustomerPluginConfiguratio null=True, ) + @property + def encrypted_token(self): + """ + Return encrypted token as a string. + + The data is encrypted in the DB at rest, but is unencrypted in the app when retrieved through the + decrypted_token field. This method will encrypt the token again before sending. + """ + if self.decrypted_token: + return force_str( + self._meta.get_field('decrypted_token').fernet.encrypt( + force_bytes(self.decrypted_token) + ) + ) + return self.decrypted_token + + @encrypted_token.setter + def encrypted_token(self, value): + """ + Set the encrypted token. + """ + self.decrypted_token = value + transmission_chunk_size = models.IntegerField( default=1, help_text=_("The maximum number of data items to transmit to the integrated channel with each request.") diff --git a/test_utils/factories.py b/test_utils/factories.py index 1d6998d92c..3d87059754 100644 --- a/test_utils/factories.py +++ b/test_utils/factories.py @@ -894,7 +894,7 @@ class Meta: enterprise_customer = factory.SubFactory(EnterpriseCustomerFactory) moodle_base_url = factory.LazyAttribute(lambda x: FAKER.url()) service_short_name = factory.LazyAttribute(lambda x: FAKER.slug()) - token = factory.LazyAttribute(lambda x: FAKER.slug()) + decrypted_token = factory.LazyAttribute(lambda x: FAKER.slug()) class AdminNotificationFactory(factory.django.DjangoModelFactory): diff --git a/tests/test_integrated_channels/test_moodle/test_client.py b/tests/test_integrated_channels/test_moodle/test_client.py index c9fd66389e..c0f07a2eda 100644 --- a/tests/test_integrated_channels/test_moodle/test_client.py +++ b/tests/test_integrated_channels/test_moodle/test_client.py @@ -79,12 +79,18 @@ def setUp(self): self.learner_data_payload = '{{"courseID": {}, "grade": {}}}'.format(self.moodle_course_id, self.grade) self.enterprise_config = factories.MoodleEnterpriseCustomerConfigurationFactory( moodle_base_url=self.moodle_base_url, + decrypted_username=self.user, + decrypted_password=self.password, + decrypted_token=self.token, username=self.user, password=self.password, token=self.token, ) self.enterprise_custom_config = factories.MoodleEnterpriseCustomerConfigurationFactory( moodle_base_url=self.custom_moodle_base_url, + decrypted_username=self.user, + decrypted_password=self.password, + decrypted_token=self.token, username=self.user, password=self.password, token=self.token, diff --git a/tests/test_integrated_channels/test_moodle/test_transmitters/test_content_metadata.py b/tests/test_integrated_channels/test_moodle/test_transmitters/test_content_metadata.py index bd3fb0bf7f..46f0a8d21f 100644 --- a/tests/test_integrated_channels/test_moodle/test_transmitters/test_content_metadata.py +++ b/tests/test_integrated_channels/test_moodle/test_transmitters/test_content_metadata.py @@ -33,6 +33,9 @@ def setUp(self): self.enterprise_config = factories.MoodleEnterpriseCustomerConfigurationFactory( moodle_base_url=self.moodle_base_url, enterprise_customer=enterprise_customer, + decrypted_username=self.user, + decrypted_password=self.password, + decrypted_token=self.api_token, username=self.user, password=self.password, token=self.api_token, diff --git a/tests/test_integrated_channels/test_moodle/test_transmitters/test_learner_data.py b/tests/test_integrated_channels/test_moodle/test_transmitters/test_learner_data.py index f2c9ca237a..c55e0b6960 100644 --- a/tests/test_integrated_channels/test_moodle/test_transmitters/test_learner_data.py +++ b/tests/test_integrated_channels/test_moodle/test_transmitters/test_learner_data.py @@ -36,6 +36,9 @@ def setUp(self): moodle_base_url='foobar', service_short_name='shortname', category_id=1, + decrypted_username='username', + decrypted_password='password', + decrypted_token='token', username='username', password='password', token='token', From b14b261ae13c1e6fb7c434551738ec3a6c47b37d Mon Sep 17 00:00:00 2001 From: MueezKhan246 <93375917+MueezKhan246@users.noreply.github.com> Date: Wed, 20 Dec 2023 17:26:59 +0500 Subject: [PATCH 066/164] Restructured response from moodle request wrapper (#1972) * fix: restructured response from moodle request wrapper --- CHANGELOG.rst | 6 ++++++ enterprise/__init__.py | 2 +- integrated_channels/moodle/client.py | 23 ++++++++++++++++++++++- 3 files changed, 29 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index f157be875c..e890e51b80 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -16,6 +16,12 @@ Change Log Unreleased ---------- +[4.8.15] +-------- + +fix: restructured response from moodle request wrapper +feat: replacing non encrypted fields of moodle config model with encrypted ones (ENT 5613) + [4.8.14] -------- diff --git a/enterprise/__init__.py b/enterprise/__init__.py index acfa2c776e..499b72f5a9 100644 --- a/enterprise/__init__.py +++ b/enterprise/__init__.py @@ -2,4 +2,4 @@ Your project description goes here. """ -__version__ = "4.8.14" +__version__ = "4.8.15" diff --git a/integrated_channels/moodle/client.py b/integrated_channels/moodle/client.py index b371e82299..619db70f60 100644 --- a/integrated_channels/moodle/client.py +++ b/integrated_channels/moodle/client.py @@ -32,6 +32,17 @@ def __init__(self, message, status_code=500, moodle_error=None): super().__init__(message, status_code) +class MoodleResponse: + """ + Represents an HTTP response with status code and textual content. + """ + + def __init__(self, status_code, text): + """Save the status code and text of the response.""" + self.status_code = status_code + self.text = text + + def moodle_request_wrapper(method): """ Wraps requests to Moodle's API in a token check. @@ -71,7 +82,7 @@ def inner(self, *args, **kwargs): f'Text: {response.text}, ' f'Headers: {response.headers}, ' ) - return {'status_code': 200, 'text': ''} + return MoodleResponse(status_code=200, text='') return 200, '' raise ClientError('Moodle API Grade Update failed with int code: {code}'.format(code=body), 500) if isinstance(body, str): @@ -485,6 +496,16 @@ def create_course_completion(self, user_id, payload): # The base integrated channels transmitter expects a tuple of (code, body), # but we need to wrap the requests resp = self._wrapped_create_course_completion(user_id, payload) + completion_data = json.loads(payload) + LOGGER.info( + generate_formatted_log( + channel_name=self.enterprise_configuration.channel_code(), + enterprise_customer_uuid=self.enterprise_configuration.enterprise_customer.uuid, + course_or_course_run_key=completion_data['courseID'], + plugin_configuration_id=self.enterprise_configuration.id, + message=f'Response for Moodle Create Course Completion Request response: {resp} ' + ) + ) return resp.status_code, resp.text @moodle_request_wrapper From fe1db640c152c70de8d74eebf388378bd56f13ef Mon Sep 17 00:00:00 2001 From: Ehmad Saeed Date: Thu, 21 Dec 2023 15:51:46 +0500 Subject: [PATCH 067/164] refactor: learner data transmission audit record creation --- .../blackboard/exporters/learner_data.py | 13 +++-- .../canvas/exporters/learner_data.py | 15 ++++-- .../degreed2/exporters/learner_data.py | 17 +++--- .../exporters/learner_data.py | 19 ++++--- .../moodle/exporters/learner_data.py | 19 ++++--- .../exporters/learner_data.py | 14 +++-- .../test_exporters/test_learner_data.py | 20 +++++++ .../test_exporters/test_learner_data.py | 27 ++++++++++ .../test_exporters/test_learner_data.py | 54 +++++++++++++++++++ .../test_exporters/test_learner_data.py | 47 +++++++++++++++- 10 files changed, 211 insertions(+), 34 deletions(-) create mode 100644 tests/test_integrated_channels/test_moodle/test_exporters/test_learner_data.py diff --git a/integrated_channels/blackboard/exporters/learner_data.py b/integrated_channels/blackboard/exporters/learner_data.py index dd31f2aadb..1d636245d2 100644 --- a/integrated_channels/blackboard/exporters/learner_data.py +++ b/integrated_channels/blackboard/exporters/learner_data.py @@ -50,9 +50,14 @@ def get_learner_data_records( 'blackboard', 'BlackboardLearnerDataTransmissionAudit' ) - - return [ - BlackboardLearnerDataTransmissionAudit( + course_id = get_course_id_for_enrollment(enterprise_enrollment) + # We only want to send one record per enrollment and course, so we check if one exists first. + learner_transmission_record = BlackboardLearnerDataTransmissionAudit.objects.filter( + enterprise_course_enrollment_id=enterprise_enrollment.id, + course_id=course_id, + ).first() + if learner_transmission_record is None: + learner_transmission_record = BlackboardLearnerDataTransmissionAudit( enterprise_course_enrollment_id=enterprise_enrollment.id, blackboard_user_email=enterprise_customer_user.user_email, user_email=enterprise_customer_user.user_email, @@ -64,7 +69,7 @@ def get_learner_data_records( enterprise_customer_uuid=enterprise_customer_user.enterprise_customer.uuid, plugin_configuration_id=self.enterprise_configuration.id, ) - ] + return [learner_transmission_record] def get_learner_assessment_data_records( self, diff --git a/integrated_channels/canvas/exporters/learner_data.py b/integrated_channels/canvas/exporters/learner_data.py index 193baf9c83..5c898d6e77 100644 --- a/integrated_channels/canvas/exporters/learner_data.py +++ b/integrated_channels/canvas/exporters/learner_data.py @@ -50,9 +50,13 @@ def get_learner_data_records( 'canvas', 'CanvasLearnerDataTransmissionAudit' ) - # We return two records here, one with the course key and one with the course run id, to account for - # uncertainty about the type of content (course vs. course run) that was sent to the integrated channel. - return [ + course_id = get_course_id_for_enrollment(enterprise_enrollment) + # We only want to send one record per enrollment and course, so we check if one exists first. + learner_transmission_record = CanvasLearnerDataTransmissionAudit.objects.filter( + enterprise_course_enrollment_id=enterprise_enrollment.id, + course_id=course_id, + ).first() + if learner_transmission_record is None: CanvasLearnerDataTransmissionAudit( enterprise_course_enrollment_id=enterprise_enrollment.id, canvas_user_email=enterprise_customer_user.user_email, @@ -64,8 +68,9 @@ def get_learner_data_records( canvas_completed_timestamp=canvas_completed_timestamp, enterprise_customer_uuid=enterprise_customer_user.enterprise_customer.uuid, plugin_configuration_id=self.enterprise_configuration.id, - ), - ] + ) + # We return one record here, with the course key, that was sent to the integrated channel. + return [learner_transmission_record] def get_learner_assessment_data_records( self, diff --git a/integrated_channels/degreed2/exporters/learner_data.py b/integrated_channels/degreed2/exporters/learner_data.py index 84914dfa07..5b3750dff5 100644 --- a/integrated_channels/degreed2/exporters/learner_data.py +++ b/integrated_channels/degreed2/exporters/learner_data.py @@ -57,10 +57,14 @@ def get_learner_data_records( 'degreed2', 'Degreed2LearnerDataTransmissionAudit' ) - # We return two records here, one with the course key and one with the course run id, to account for - # uncertainty about the type of content (course vs. course run) that was sent to the integrated channel. - return [ - Degreed2LearnerDataTransmissionAudit( + course_id = get_course_id_for_enrollment(enterprise_enrollment) + # We only want to send one record per enrollment and course, so we check if one exists first. + learner_transmission_record = Degreed2LearnerDataTransmissionAudit.objects.filter( + enterprise_course_enrollment_id=enterprise_enrollment.id, + course_id=course_id, + ).first() + if learner_transmission_record is None: + learner_transmission_record = Degreed2LearnerDataTransmissionAudit( enterprise_course_enrollment_id=enterprise_enrollment.id, degreed_user_email=enterprise_enrollment.enterprise_customer_user.user_email, user_email=enterprise_enrollment.enterprise_customer_user.user_email, @@ -71,8 +75,9 @@ def get_learner_data_records( grade=percent_grade, enterprise_customer_uuid=enterprise_enrollment.enterprise_customer_user.enterprise_customer.uuid, plugin_configuration_id=self.enterprise_configuration.id, - ), - ] + ) + # We return one record here, with the course key, that was sent to the integrated channel. + return [learner_transmission_record] LOGGER.info(generate_formatted_log( self.enterprise_configuration.channel_code(), enterprise_enrollment.enterprise_customer_user.enterprise_customer.uuid, diff --git a/integrated_channels/integrated_channel/exporters/learner_data.py b/integrated_channels/integrated_channel/exporters/learner_data.py index 75ecbcca4d..01d609fc9e 100644 --- a/integrated_channels/integrated_channel/exporters/learner_data.py +++ b/integrated_channels/integrated_channel/exporters/learner_data.py @@ -593,22 +593,27 @@ def get_learner_data_records( completed_timestamp = None if completed_date is not None: completed_timestamp = parse_datetime_to_epoch_millis(completed_date) - # We return two records here, one with the course key and one with the course run id, to account for - # uncertainty about the type of content (course vs. course run) that was sent to the integrated channel. - return [ - TransmissionAudit( + course_id = get_course_id_for_enrollment(enterprise_enrollment) + # We only want to send one record per enrollment and course, so we check if one exists first. + learner_transmission_record = TransmissionAudit.objects.filter( + enterprise_course_enrollment_id=enterprise_enrollment.id, + course_id=course_id, + ).first() + if learner_transmission_record is None: + learner_transmission_record = TransmissionAudit( plugin_configuration_id=self.enterprise_configuration.id, enterprise_customer_uuid=self.enterprise_configuration.enterprise_customer.uuid, enterprise_course_enrollment_id=enterprise_enrollment.id, - course_id=get_course_id_for_enrollment(enterprise_enrollment), + course_id=course_id, course_completed=course_completed, completed_timestamp=completed_timestamp, grade=grade, user_email=user_email, content_title=content_title, progress_status=progress_status, - ), - ] + ) + # We return one record here, with the course key, that was sent to the integrated channel. + return [learner_transmission_record] def collect_certificate_data(self, enterprise_enrollment, channel_name): """ diff --git a/integrated_channels/moodle/exporters/learner_data.py b/integrated_channels/moodle/exporters/learner_data.py index 10f2fee354..df1ad82bec 100644 --- a/integrated_channels/moodle/exporters/learner_data.py +++ b/integrated_channels/moodle/exporters/learner_data.py @@ -52,11 +52,14 @@ def get_learner_data_records( ) percent_grade = kwargs.get('grade_percent', None) - # We return two records here, one with the course key and one with the course run id, to account for - # uncertainty about the type of content (course vs. course run) that was sent to the integrated channel. - # TODO: this shouldn't be necessary anymore and eventually phased out as part of tech debt - return [ - MoodleLearnerDataTransmissionAudit( + course_id = get_course_id_for_enrollment(enterprise_enrollment) + # We only want to send one record per enrollment and course, so we check if one exists first. + learner_transmission_record = MoodleLearnerDataTransmissionAudit.objects.filter( + enterprise_course_enrollment_id=enterprise_enrollment.id, + course_id=course_id, + ).first() + if learner_transmission_record is None: + learner_transmission_record = MoodleLearnerDataTransmissionAudit( enterprise_course_enrollment_id=enterprise_enrollment.id, moodle_user_email=enterprise_customer_user.user_email, user_email=enterprise_customer_user.user_email, @@ -67,5 +70,7 @@ def get_learner_data_records( moodle_completed_timestamp=moodle_completed_timestamp, enterprise_customer_uuid=enterprise_customer_user.enterprise_customer.uuid, plugin_configuration_id=self.enterprise_configuration.id, - ), - ] + ) + # We return one record here, with the course key, that was sent to the integrated channel. + # TODO: this shouldn't be necessary anymore and eventually phased out as part of tech debt + return [learner_transmission_record] diff --git a/integrated_channels/sap_success_factors/exporters/learner_data.py b/integrated_channels/sap_success_factors/exporters/learner_data.py index 43f3239da6..acc7a52fa1 100644 --- a/integrated_channels/sap_success_factors/exporters/learner_data.py +++ b/integrated_channels/sap_success_factors/exporters/learner_data.py @@ -58,8 +58,14 @@ def get_learner_data_records( total_hours = 0.0 if course_run and self.enterprise_configuration.transmit_total_hours: total_hours = course_run.get("estimated_hours", 0.0) - return [ - SapSuccessFactorsLearnerDataTransmissionAudit( + course_id = get_course_id_for_enrollment(enterprise_enrollment) + # We only want to send one record per enrollment and course, so we check if one exists first. + learner_transmission_record = SapSuccessFactorsLearnerDataTransmissionAudit.objects.filter( + enterprise_course_enrollment_id=enterprise_enrollment.id, + course_id=course_id, + ).first() + if learner_transmission_record is None: + learner_transmission_record = SapSuccessFactorsLearnerDataTransmissionAudit( enterprise_course_enrollment_id=enterprise_enrollment.id, sapsf_user_id=sapsf_user_id, user_email=enterprise_enrollment.enterprise_customer_user.user_email, @@ -72,8 +78,8 @@ def get_learner_data_records( credit_hours=total_hours, enterprise_customer_uuid=self.enterprise_configuration.enterprise_customer.uuid, plugin_configuration_id=self.enterprise_configuration.id - ), - ] + ) + return [learner_transmission_record] LOGGER.info( generate_formatted_log( self.enterprise_configuration.channel_code(), diff --git a/tests/test_integrated_channels/test_degreed2/test_exporters/test_learner_data.py b/tests/test_integrated_channels/test_degreed2/test_exporters/test_learner_data.py index 35fa6427f1..be9d5fb83f 100644 --- a/tests/test_integrated_channels/test_degreed2/test_exporters/test_learner_data.py +++ b/tests/test_integrated_channels/test_degreed2/test_exporters/test_learner_data.py @@ -91,6 +91,26 @@ def test_get_learner_data_record(self, completed_date, grade_percent): ) assert learner_data_record.grade == (grade_percent * 100 if grade_percent else None) + def test_retrieve_same_learner_data_record(self): + """ + If a learner data record already exists for the enrollment, it should be retrieved instead of created. + """ + enterprise_course_enrollment = factories.EnterpriseCourseEnrollmentFactory( + enterprise_customer_user=self.enterprise_customer_user, + course_id=self.course_id, + ) + exporter = Degreed2LearnerExporter('fake-user', self.config) + learner_data_records_1 = exporter.get_learner_data_records( + enterprise_course_enrollment, + )[0] + learner_data_records_1.save() + learner_data_records_2 = exporter.get_learner_data_records( + enterprise_course_enrollment, + )[0] + learner_data_records_2.save() + + assert learner_data_records_1.id == learner_data_records_2.id + def test_no_remote_id(self): """ If the TPA API Client returns no remote user ID, nothing is returned. diff --git a/tests/test_integrated_channels/test_integrated_channel/test_exporters/test_learner_data.py b/tests/test_integrated_channels/test_integrated_channel/test_exporters/test_learner_data.py index 9eb1200689..21558597a4 100644 --- a/tests/test_integrated_channels/test_integrated_channel/test_exporters/test_learner_data.py +++ b/tests/test_integrated_channels/test_integrated_channel/test_exporters/test_learner_data.py @@ -131,6 +131,33 @@ def test_get_learner_data_record(self, completed_date, mock_course_catalog_api): assert learner_data_record.completed_timestamp == (self.NOW_TIMESTAMP if completed_date is not None else None) assert learner_data_record.grade == 'A+' + @mock.patch('enterprise.api_client.discovery.CourseCatalogApiServiceClient') + def test_retrieve_same_learner_data_record(self, mock_course_catalog_api): + """ + If a learner data record already exists for the enrollment, it should be retrieved instead of created. + """ + enterprise_course_enrollment = factories.EnterpriseCourseEnrollmentFactory( + enterprise_customer_user=self.enterprise_customer_user, + course_id=self.course_id, + ) + mock_course_catalog_api.return_value.get_course_id.return_value = self.course_key + expected_course_completed = True + exporter = LearnerExporter('fake-user', self.config) + learner_data_records_1 = exporter.get_learner_data_records( + enterprise_course_enrollment, + course_completed=expected_course_completed, + progress_status='Passed' + )[0] + learner_data_records_1.save() + learner_data_records_2 = exporter.get_learner_data_records( + enterprise_course_enrollment, + course_completed=expected_course_completed, + progress_status='Passed' + )[0] + learner_data_records_2.save() + + assert learner_data_records_1.id == learner_data_records_2.id + def test_get_learner_subsection_data_records(self): """ Test that the base learner subsection data exporter generates appropriate learner records from assessment grade diff --git a/tests/test_integrated_channels/test_moodle/test_exporters/test_learner_data.py b/tests/test_integrated_channels/test_moodle/test_exporters/test_learner_data.py new file mode 100644 index 0000000000..6b27e4cd16 --- /dev/null +++ b/tests/test_integrated_channels/test_moodle/test_exporters/test_learner_data.py @@ -0,0 +1,54 @@ +""" +Tests for Moodle learner data exporters. +""" + +import unittest +from unittest import mock + +from pytest import mark + +from integrated_channels.moodle.exporters.learner_data import MoodleLearnerExporter +from test_utils import factories + + +@mark.django_db +class TestMoodleLearnerDataExporter(unittest.TestCase): + """ + Test MoodleLearnerDataExporter + """ + + def setUp(self): + super().setUp() + self.enterprise_customer = factories.EnterpriseCustomerFactory() + self.enterprise_customer_user = factories.EnterpriseCustomerUserFactory( + enterprise_customer=self.enterprise_customer, + ) + self.course_id = 'course-v1:edX+DemoX+DemoCourse' + self.course_key = 'edX+DemoX' + self.config = factories.MoodleEnterpriseCustomerConfigurationFactory( + enterprise_customer=self.enterprise_customer, + moodle_base_url='foobar', + service_short_name='shortname', + category_id=1, + username='username', + password='password', + token='token', + ) + + @mock.patch('enterprise.api_client.discovery.CourseCatalogApiServiceClient') + def test_retrieve_same_learner_data_record(self, mock_course_catalog_api): + """ + If a learner data record already exists for the enrollment, it should be retrieved instead of created. + """ + enterprise_course_enrollment = factories.EnterpriseCourseEnrollmentFactory( + course_id=self.course_id, + enterprise_customer_user=self.enterprise_customer_user, + ) + mock_course_catalog_api.return_value.get_course_id.return_value = self.course_key + exporter = MoodleLearnerExporter('fake-user', self.config) + learner_data_records_1 = exporter.get_learner_data_records(enterprise_course_enrollment)[0] + learner_data_records_1.save() + learner_data_records_2 = exporter.get_learner_data_records(enterprise_course_enrollment)[0] + learner_data_records_2.save() + + assert learner_data_records_1.id == learner_data_records_2.id diff --git a/tests/test_integrated_channels/test_sap_success_factors/test_exporters/test_learner_data.py b/tests/test_integrated_channels/test_sap_success_factors/test_exporters/test_learner_data.py index 078b6a9ff3..581ab2e914 100644 --- a/tests/test_integrated_channels/test_sap_success_factors/test_exporters/test_learner_data.py +++ b/tests/test_integrated_channels/test_sap_success_factors/test_exporters/test_learner_data.py @@ -4,7 +4,7 @@ import unittest from unittest import mock -from unittest.mock import MagicMock +from unittest.mock import MagicMock, Mock import ddt from pytest import mark @@ -57,6 +57,51 @@ def test_call_get_remote_id(self, mock_get_course_run_for_enrollment, mock_get_c enterprise_configuration.idp_id ) + @mock.patch('integrated_channels.sap_success_factors.exporters.learner_data.get_course_id_for_enrollment') + @mock.patch('integrated_channels.sap_success_factors.exporters.learner_data.get_course_run_for_enrollment') + def test_retrieve_same_learner_data_record( + self, + mock_get_course_run_for_enrollment, + mock_get_course_id_for_enrollment, + ): + """ + If a learner data record already exists for the enrollment, it should be retrieved instead of created. + """ + mock_get_course_run_for_enrollment.return_value = MagicMock() + mock_get_course_id_for_enrollment.return_value = 'test:id' + user = UserFactory() + enterprise_customer = EnterpriseCustomerFactory() + enterprise_configuration = SAPSuccessFactorsEnterpriseCustomerConfigurationFactory( + enterprise_customer=enterprise_customer, + idp_id='test-id' + ) + completed_date = None + grade = 'Pass' + course_completed = False + enterprise_customer_user = EnterpriseCustomerUserFactory() + enterprise_enrollment = EnterpriseCourseEnrollmentFactory( + enterprise_customer_user=enterprise_customer_user + ) + enterprise_enrollment.enterprise_customer_user.get_remote_id = Mock(return_value=99) + exporter = SapSuccessFactorsLearnerExporter(user, enterprise_configuration) + learner_data_records_1 = exporter.get_learner_data_records( + enterprise_enrollment, + completed_date, + grade, + course_completed + )[0] + learner_data_records_1.save() + learner_data_records_2 = exporter.get_learner_data_records( + enterprise_enrollment, + completed_date, + grade, + course_completed + )[0] + learner_data_records_2.save() + + assert enterprise_enrollment.enterprise_customer_user.get_remote_id.call_count == 2 + assert learner_data_records_1.id == learner_data_records_2.id + def test_override_of_default_channel_settings(self): """ If you override any settings to the ChannelSettingsMixin, add a test here for those From 04eb42de93947cabea23d6d900f40053d89a572a Mon Sep 17 00:00:00 2001 From: Ehmad Saeed Date: Thu, 21 Dec 2023 16:16:36 +0500 Subject: [PATCH 068/164] test: unit tests for canvas, blackboard and cornerstone for learner exporter --- .../canvas/exporters/learner_data.py | 2 +- .../test_exporters/test_learner_data.py | 52 +++++++++++++++++++ .../test_exporters/test_learner_data.py | 51 ++++++++++++++++++ .../test_exporters/test_learner_data.py | 14 +++++ 4 files changed, 118 insertions(+), 1 deletion(-) create mode 100644 tests/test_integrated_channels/test_blackboard/test_exporters/test_learner_data.py create mode 100644 tests/test_integrated_channels/test_canvas/test_exporters/test_learner_data.py diff --git a/integrated_channels/canvas/exporters/learner_data.py b/integrated_channels/canvas/exporters/learner_data.py index 5c898d6e77..f3f172b265 100644 --- a/integrated_channels/canvas/exporters/learner_data.py +++ b/integrated_channels/canvas/exporters/learner_data.py @@ -57,7 +57,7 @@ def get_learner_data_records( course_id=course_id, ).first() if learner_transmission_record is None: - CanvasLearnerDataTransmissionAudit( + learner_transmission_record = CanvasLearnerDataTransmissionAudit( enterprise_course_enrollment_id=enterprise_enrollment.id, canvas_user_email=enterprise_customer_user.user_email, user_email=enterprise_customer_user.user_email, diff --git a/tests/test_integrated_channels/test_blackboard/test_exporters/test_learner_data.py b/tests/test_integrated_channels/test_blackboard/test_exporters/test_learner_data.py new file mode 100644 index 0000000000..ce3778347c --- /dev/null +++ b/tests/test_integrated_channels/test_blackboard/test_exporters/test_learner_data.py @@ -0,0 +1,52 @@ +""" +Tests for Blackboard learner data exporters. +""" + +import unittest +from unittest import mock + +from pytest import mark + +from integrated_channels.blackboard.exporters.learner_data import BlackboardLearnerExporter +from test_utils import factories + + +@mark.django_db +class TestBlackboardLearnerDataExporter(unittest.TestCase): + """ + Test BlackboardLearnerDataExporter + """ + + def setUp(self): + super().setUp() + self.enterprise_customer = factories.EnterpriseCustomerFactory() + self.enterprise_customer_user = factories.EnterpriseCustomerUserFactory( + enterprise_customer=self.enterprise_customer, + ) + self.course_id = 'course-v1:edX+DemoX+DemoCourse' + self.course_key = 'edX+DemoX' + self.config = factories.BlackboardEnterpriseCustomerConfigurationFactory( + enterprise_customer=self.enterprise_customer, + blackboard_base_url='foobar', + client_id='client_id', + client_secret='client_secret', + refresh_token='token', + ) + + @mock.patch('enterprise.api_client.discovery.CourseCatalogApiServiceClient') + def test_retrieve_same_learner_data_record(self, mock_course_catalog_api): + """ + If a learner data record already exists for the enrollment, it should be retrieved instead of created. + """ + enterprise_course_enrollment = factories.EnterpriseCourseEnrollmentFactory( + course_id=self.course_id, + enterprise_customer_user=self.enterprise_customer_user, + ) + mock_course_catalog_api.return_value.get_course_id.return_value = self.course_key + exporter = BlackboardLearnerExporter('fake-user', self.config) + learner_data_records_1 = exporter.get_learner_data_records(enterprise_course_enrollment)[0] + learner_data_records_1.save() + learner_data_records_2 = exporter.get_learner_data_records(enterprise_course_enrollment)[0] + learner_data_records_2.save() + + assert learner_data_records_1.id == learner_data_records_2.id diff --git a/tests/test_integrated_channels/test_canvas/test_exporters/test_learner_data.py b/tests/test_integrated_channels/test_canvas/test_exporters/test_learner_data.py new file mode 100644 index 0000000000..c24b3d209b --- /dev/null +++ b/tests/test_integrated_channels/test_canvas/test_exporters/test_learner_data.py @@ -0,0 +1,51 @@ +""" +Tests for Canvas learner data exporters. +""" + +import unittest +from unittest import mock + +from pytest import mark + +from integrated_channels.canvas.exporters.learner_data import CanvasLearnerExporter +from test_utils import factories + + +@mark.django_db +class TestCanvasLearnerDataExporter(unittest.TestCase): + """ + Test CanvasLearnerDataExporter + """ + + def setUp(self): + super().setUp() + self.user = factories.UserFactory(id=1, email='example@email.com') + self.enterprise_customer = factories.EnterpriseCustomerFactory() + self.enterprise_customer_user = factories.EnterpriseCustomerUserFactory( + user_id=self.user.id, + enterprise_customer=self.enterprise_customer, + ) + self.course_id = 'course-v1:edX+DemoX+DemoCourse' + self.course_key = 'edX+DemoX' + self.config = factories.CanvasEnterpriseCustomerConfigurationFactory( + enterprise_customer=self.enterprise_customer, + canvas_base_url='foobar', + ) + + @mock.patch('enterprise.api_client.discovery.CourseCatalogApiServiceClient') + def test_retrieve_same_learner_data_record(self, mock_course_catalog_api): + """ + If a learner data record already exists for the enrollment, it should be retrieved instead of created. + """ + enterprise_course_enrollment = factories.EnterpriseCourseEnrollmentFactory( + course_id=self.course_id, + enterprise_customer_user=self.enterprise_customer_user, + ) + mock_course_catalog_api.return_value.get_course_id.return_value = self.course_key + exporter = CanvasLearnerExporter('fake-user', self.config) + learner_data_records_1 = exporter.get_learner_data_records(enterprise_course_enrollment)[0] + learner_data_records_1.save() + learner_data_records_2 = exporter.get_learner_data_records(enterprise_course_enrollment)[0] + learner_data_records_2.save() + + assert learner_data_records_1.id == learner_data_records_2.id diff --git a/tests/test_integrated_channels/test_cornerstone/test_exporters/test_learner_data.py b/tests/test_integrated_channels/test_cornerstone/test_exporters/test_learner_data.py index 964f9810f7..6323b88677 100644 --- a/tests/test_integrated_channels/test_cornerstone/test_exporters/test_learner_data.py +++ b/tests/test_integrated_channels/test_cornerstone/test_exporters/test_learner_data.py @@ -117,6 +117,20 @@ def test_get_learner_data_record(self, completed_date): self.NOW if completed_date is not None else None ) + @mock.patch('enterprise.api_client.discovery.CourseCatalogApiServiceClient') + def test_retrieve_same_learner_data_record(self, mock_course_catalog_api): + """ + If a learner data record already exists for the enrollment, it should be retrieved instead of created. + """ + mock_course_catalog_api.return_value.get_course_id.return_value = self.course_key + exporter = CornerstoneLearnerExporter('fake-user', self.config) + learner_data_records_1 = exporter.get_learner_data_records(self.enterprise_course_enrollment)[0] + learner_data_records_1.save() + learner_data_records_2 = exporter.get_learner_data_records(self.enterprise_course_enrollment)[0] + learner_data_records_2.save() + + assert learner_data_records_1.id == learner_data_records_2.id + def test_get_learner_data_record_not_exist(self): """ If learner data is not already exist, nothing is returned. From d5b6472446127b2c9dd80930f7bd1f865f9652f4 Mon Sep 17 00:00:00 2001 From: MueezKhan246 <93375917+MueezKhan246@users.noreply.github.com> Date: Tue, 2 Jan 2024 15:47:05 +0500 Subject: [PATCH 069/164] Removed course completion check from is_already_transmitted utility (#1974) * fix: removed course completion check from is_already_transmitted utility --- CHANGELOG.rst | 5 +++++ enterprise/__init__.py | 2 +- integrated_channels/moodle/client.py | 8 -------- integrated_channels/utils.py | 1 - 4 files changed, 6 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index e890e51b80..7f10bcb55e 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -16,6 +16,11 @@ Change Log Unreleased ---------- +[4.8.16] +-------- + +fix: removed course completion check from is_already_transmitted utility (ENT 7837) + [4.8.15] -------- diff --git a/enterprise/__init__.py b/enterprise/__init__.py index 499b72f5a9..934e51f78d 100644 --- a/enterprise/__init__.py +++ b/enterprise/__init__.py @@ -2,4 +2,4 @@ Your project description goes here. """ -__version__ = "4.8.15" +__version__ = "4.8.16" diff --git a/integrated_channels/moodle/client.py b/integrated_channels/moodle/client.py index 619db70f60..d62558ad22 100644 --- a/integrated_channels/moodle/client.py +++ b/integrated_channels/moodle/client.py @@ -74,14 +74,6 @@ def inner(self, *args, **kwargs): # but we have nothing else to go on if body == 0: if method.__name__ == "_wrapped_create_course_completion" and response.status_code == 200: - LOGGER.info( - 'Integer Response for Moodle Course Completion' - f'with data kwargs={kwargs} and args={args} ' - f' response: {response} ' - f'Status Code: {response.status_code}, ' - f'Text: {response.text}, ' - f'Headers: {response.headers}, ' - ) return MoodleResponse(status_code=200, text='') return 200, '' raise ClientError('Moodle API Grade Update failed with int code: {code}'.format(code=body), 500) diff --git a/integrated_channels/utils.py b/integrated_channels/utils.py index 3c58ba14ef..62ba54fa71 100644 --- a/integrated_channels/utils.py +++ b/integrated_channels/utils.py @@ -218,7 +218,6 @@ def is_already_transmitted( plugin_configuration_id=enterprise_configuration_id, error_message='', status__lt=400, - course_completed=True ) if subsection_id: already_transmitted = already_transmitted.filter(subsection_id=subsection_id) From dd8b5cb694aa372fc1a24de15910cd1093e8e8dd Mon Sep 17 00:00:00 2001 From: Sameen Fatima <55431213+sameenfatima78@users.noreply.github.com> Date: Thu, 4 Jan 2024 16:17:15 +0500 Subject: [PATCH 070/164] fix: remove logs from degreed2 client (#1980) --- CHANGELOG.rst | 5 +++++ enterprise/__init__.py | 2 +- integrated_channels/degreed2/client.py | 20 -------------------- 3 files changed, 6 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 7f10bcb55e..6fdedcfce4 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -16,6 +16,11 @@ Change Log Unreleased ---------- +[4.8.17] +-------- + +fix: remove logs from Degreed2 client + [4.8.16] -------- diff --git a/enterprise/__init__.py b/enterprise/__init__.py index 934e51f78d..2874a43c0d 100644 --- a/enterprise/__init__.py +++ b/enterprise/__init__.py @@ -2,4 +2,4 @@ Your project description goes here. """ -__version__ = "4.8.16" +__version__ = "4.8.17" diff --git a/integrated_channels/degreed2/client.py b/integrated_channels/degreed2/client.py index 5d684e72d1..bee591fed7 100644 --- a/integrated_channels/degreed2/client.py +++ b/integrated_channels/degreed2/client.py @@ -475,16 +475,6 @@ def _get(self, url, scope): ) time.sleep(sleep_seconds) else: - LOGGER.error( - generate_formatted_log( - self.enterprise_configuration.channel_code(), - self.enterprise_configuration.enterprise_customer.uuid, - None, - None, - '[Degreed2Client]._get - Exceeded retry attempts in:' - f'URL:{url}' - ) - ) break return response.status_code, response.text @@ -518,16 +508,6 @@ def _post(self, url, data, scope): ) time.sleep(sleep_seconds) else: - LOGGER.error( - generate_formatted_log( - self.enterprise_configuration.channel_code(), - self.enterprise_configuration.enterprise_customer.uuid, - None, - None, - '[Degreed2Client]._post - Exceeded retry attempts in:' - f'URL:{url}, DATA:{data}' - ) - ) break return response.status_code, response.text From af94b4b048c876c870d8d5abbac11eb2dc395682 Mon Sep 17 00:00:00 2001 From: hamzawaleed01 Date: Thu, 4 Jan 2024 20:43:04 +0500 Subject: [PATCH 071/164] feat: add logs to debug ENT-8130 --- .../exporters/learner_data.py | 60 ++++++++++++++++--- 1 file changed, 52 insertions(+), 8 deletions(-) diff --git a/integrated_channels/integrated_channel/exporters/learner_data.py b/integrated_channels/integrated_channel/exporters/learner_data.py index 75ecbcca4d..a61c7c1be6 100644 --- a/integrated_channels/integrated_channel/exporters/learner_data.py +++ b/integrated_channels/integrated_channel/exporters/learner_data.py @@ -397,6 +397,11 @@ def export(self, **kwargs): ) enrollment_ids_to_export = [enrollment.id for enrollment in enrollments_permitted] + LOGGER.info( + f"[Debug-SAP]: course_run_id:{course_run_id}, channel_name: {channel_name}" + f"lms_user_for_filter:{lms_user_for_filter}, grade:{grade} " + f"enrollment_ids_to_export: {enrollment_ids_to_export}" + ) for enterprise_enrollment in enrollments_permitted: lms_user_id = enterprise_enrollment.enterprise_customer_user.user_id user_email = enterprise_enrollment.enterprise_customer_user.user_email @@ -439,6 +444,12 @@ def export(self, **kwargs): # Apply the Source of Truth for Grades # Note: Only completed records are transmitted by the completion transmitter # therefore even non complete grading/cert records are exported here. + _is_course_completed = is_course_completed( + enterprise_enrollment, + is_passing_from_api, + incomplete_count, + passed_timestamp, + ) records = self.get_learner_data_records( enterprise_enrollment=enterprise_enrollment, user_email=user_email, @@ -446,15 +457,21 @@ def export(self, **kwargs): grade=grade_from_api, content_title=course_details.display_name, progress_status=progress_status, - course_completed=is_course_completed( - enterprise_enrollment, - is_passing_from_api, - incomplete_count, - passed_timestamp, - ), + course_completed=_is_course_completed, grade_percent=grade_percent, ) - + LOGGER.info( + generate_formatted_log( + channel_name, + enterprise_customer_uuid, + lms_user_id, + course_id, + f", [Debug-SAP]: _is_course_completed: {_is_course_completed}, progress_status:{progress_status}" + f",completed_date_from_api: {completed_date_from_api}, grade_from_api:{grade_from_api}" + f"is_passing_from_api: {is_passing_from_api}, grade_percent:{grade_percent}" + f"passed_timestamp:{passed_timestamp}, records: {records}", + ) + ) if records: # There are some cases where we won't receive a record from the above # method; right now, that should only happen if we have an Enterprise-linked @@ -474,7 +491,7 @@ def export(self, **kwargs): LOGGER.info(generate_formatted_log( channel_name, None, lms_user_for_filter, course_run_id, - f'export finished. Did not export records for EnterpriseCourseEnrollment objects: ' + f'[Debug-SAP]: export finished. Did not export records for EnterpriseCourseEnrollment objects: ' f' {enrollment_ids_to_export}.' )) @@ -529,15 +546,42 @@ def get_enrollments_to_process(self, lms_user_for_filter, course_run_id, channel enterprise_customer_user__active=True, ) if lms_user_for_filter and course_run_id: + LOGGER.info( + generate_formatted_log( + channel_name, + self.enterprise_customer.uuid, + lms_user_for_filter, + course_run_id, + f"[Debug-SAP]: enrollments to process before filtering: {list(enrollment_queryset)}", + ) + ) enrollment_queryset = enrollment_queryset.filter( course_id=course_run_id, enterprise_customer_user__user_id=lms_user_for_filter.id, ) + LOGGER.info( + generate_formatted_log( + channel_name, + self.enterprise_customer.uuid, + lms_user_for_filter, + course_run_id, + f"[Debug-SAP]: enrollments to process after filtering: {list(enrollment_queryset)}", + ) + ) LOGGER.info(generate_formatted_log( channel_name, self.enterprise_customer.uuid, lms_user_for_filter, course_run_id, 'get_enrollments_to_process run for single learner and course.')) enrollment_queryset = enrollment_queryset.order_by('course_id') # return resolved list instead of queryset + LOGGER.info( + generate_formatted_log( + channel_name, + self.enterprise_customer.uuid, + lms_user_for_filter, + course_run_id, + f"[Debug-SAP]: enrollments to process: {list(enrollment_queryset)}", + ) + ) return list(enrollment_queryset) def get_learner_assessment_data_records( From b792f1d23257ec71919e9fbc0f6e40d53f3c7661 Mon Sep 17 00:00:00 2001 From: hamzawaleed01 Date: Thu, 4 Jan 2024 21:19:51 +0500 Subject: [PATCH 072/164] chore: bump version and update CHANGELOG --- CHANGELOG.rst | 5 +++++ enterprise/__init__.py | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 6fdedcfce4..9f453e6582 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -16,6 +16,11 @@ Change Log Unreleased ---------- +[4.8.18] +-------- + +feat: added logs to debug ENT-8130 + [4.8.17] -------- diff --git a/enterprise/__init__.py b/enterprise/__init__.py index 2874a43c0d..6c2a61ee09 100644 --- a/enterprise/__init__.py +++ b/enterprise/__init__.py @@ -2,4 +2,4 @@ Your project description goes here. """ -__version__ = "4.8.17" +__version__ = "4.8.18" From 5d9da6db3cf8300f5f1071c6d002887e02f5e048 Mon Sep 17 00:00:00 2001 From: Troy Sankey Date: Wed, 3 Jan 2024 22:27:18 -0800 Subject: [PATCH 073/164] feat: add "Setup Auth org id" action for Enterprise Customers ENT-8169 --- .gitignore | 3 + .python-version | 1 - CHANGELOG.rst | 5 ++ enterprise/__init__.py | 2 +- enterprise/admin/__init__.py | 52 +++++++++-- enterprise/admin/utils.py | 1 + enterprise/admin/views.py | 75 +++++++++++++++- enterprise/api_client/sso_orchestrator.py | 32 ++++++- enterprise/settings/test.py | 1 + .../enterprise/admin/setup_auth_org_id.html | 59 ++++++++++++ enterprise/utils.py | 7 ++ tests/test_admin/test_view.py | 90 +++++++++++++++++++ .../api_client/test_sso_orchestrator.py | 42 ++++++++- 13 files changed, 358 insertions(+), 12 deletions(-) delete mode 100644 .python-version create mode 100644 enterprise/templates/enterprise/admin/setup_auth_org_id.html diff --git a/.gitignore b/.gitignore index dd36223bc2..62ccda9979 100644 --- a/.gitignore +++ b/.gitignore @@ -90,5 +90,8 @@ enterprise/static/enterprise/bundles/*.js # Virtual environments venv/ +# pyenv +.python-version + # TODO: When we move to be a service, ignore this too. #enterprise/static/enterprise/bundles/ diff --git a/.python-version b/.python-version deleted file mode 100644 index 8bed2f106e..0000000000 --- a/.python-version +++ /dev/null @@ -1 +0,0 @@ -3.8.12/envs/venv diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 9f453e6582..60b2dd8d3e 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -16,6 +16,11 @@ Change Log Unreleased ---------- +[4.9.0] +-------- + +feat: add "Setup Auth org id" action for Enterprise Customers (ENT-8169) + [4.8.18] -------- diff --git a/enterprise/__init__.py b/enterprise/__init__.py index 6c2a61ee09..901407901d 100644 --- a/enterprise/__init__.py +++ b/enterprise/__init__.py @@ -2,4 +2,4 @@ Your project description goes here. """ -__version__ = "4.8.18" +__version__ = "4.9.0" diff --git a/enterprise/admin/__init__.py b/enterprise/admin/__init__.py index 665745c1d0..3182c91968 100644 --- a/enterprise/admin/__init__.py +++ b/enterprise/admin/__init__.py @@ -36,6 +36,7 @@ CatalogQueryPreviewView, EnterpriseCustomerManageLearnerDataSharingConsentView, EnterpriseCustomerManageLearnersView, + EnterpriseCustomerSetupAuthOrgIDView, EnterpriseCustomerTransmitCoursesView, TemplatePreviewView, ) @@ -45,6 +46,7 @@ discovery_query_url, get_all_field_names, get_default_catalog_content_filter, + get_sso_orchestrator_configure_edx_oauth_path, localized_utcnow, ) @@ -233,7 +235,29 @@ class EnterpriseCustomerAdmin(DjangoObjectActions, SimpleHistoryAdmin): export_as_csv_action('CSV Export', fields=EXPORT_AS_CSV_FIELDS), ] - change_actions = ('manage_learners', 'manage_learners_data_sharing_consent', 'transmit_courses_metadata') + change_actions = ( + 'setup_auth_org_id', + 'manage_learners', + 'manage_learners_data_sharing_consent', + 'transmit_courses_metadata', + ) + + def get_change_actions(self, *args, **kwargs): + """ + Buttons that appear at the top of the "Change Enterprise Customer" page. + + Due to a known deficiency in the upstream django_object_actions library, we must STILL define change_actions + above with all possible values. + """ + change_actions = ( + 'manage_learners', + 'manage_learners_data_sharing_consent', + 'transmit_courses_metadata', + ) + # Add the "Setup Auth org id" button only if it is configured. + if get_sso_orchestrator_configure_edx_oauth_path(): + change_actions = ('setup_auth_org_id',) + change_actions + return change_actions form = EnterpriseCustomerAdminForm @@ -357,6 +381,19 @@ def transmit_courses_metadata(self, request, obj): transmit_courses_metadata.label = 'Transmit Courses Metadata' + @admin.action( + description='Setup auth_org_id for this Enterprise Customer' + ) + def setup_auth_org_id(self, request, obj): + """ + Object tool handler method - redirects to `Setup Auth org id` view. + """ + # url names coming from get_urls are prefixed with 'admin' namespace + setup_auth_org_id_url = reverse('admin:' + UrlNames.SETUP_AUTH_ORG_ID, args=(obj.uuid,)) + return HttpResponseRedirect(setup_auth_org_id_url) + + setup_auth_org_id.label = 'Setup Auth org id' + def get_urls(self): """ Returns the additional urls used by the custom object tools. @@ -365,18 +402,23 @@ def get_urls(self): re_path( r"^([^/]+)/manage_learners$", self.admin_site.admin_view(EnterpriseCustomerManageLearnersView.as_view()), - name=UrlNames.MANAGE_LEARNERS + name=UrlNames.MANAGE_LEARNERS, ), re_path( r"^([^/]+)/clear_learners_data_sharing_consent", self.admin_site.admin_view(EnterpriseCustomerManageLearnerDataSharingConsentView.as_view()), - name=UrlNames.MANAGE_LEARNERS_DSC + name=UrlNames.MANAGE_LEARNERS_DSC, ), re_path( r"^([^/]+)/transmit_courses_metadata", self.admin_site.admin_view(EnterpriseCustomerTransmitCoursesView.as_view()), - name=UrlNames.TRANSMIT_COURSES_METADATA - ) + name=UrlNames.TRANSMIT_COURSES_METADATA, + ), + re_path( + r"^([^/]+)/setup_auth_org_id", + self.admin_site.admin_view(EnterpriseCustomerSetupAuthOrgIDView.as_view()), + name=UrlNames.SETUP_AUTH_ORG_ID, + ), ] return customer_urls + super().get_urls() diff --git a/enterprise/admin/utils.py b/enterprise/admin/utils.py index e8ff946f18..f8d3011c3f 100644 --- a/enterprise/admin/utils.py +++ b/enterprise/admin/utils.py @@ -25,6 +25,7 @@ class UrlNames: MANAGE_LEARNERS = URL_PREFIX + "manage_learners" MANAGE_LEARNERS_DSC = URL_PREFIX + "manage_learners_data_sharing_consent" TRANSMIT_COURSES_METADATA = URL_PREFIX + "transmit_courses_metadata" + SETUP_AUTH_ORG_ID = URL_PREFIX + "setup_auth_org_id" PREVIEW_EMAIL_TEMPLATE = URL_PREFIX + "preview_email_template" PREVIEW_QUERY_RESULT = URL_PREFIX + "preview_query_result" diff --git a/enterprise/admin/views.py b/enterprise/admin/views.py index da997683b5..9addac150f 100644 --- a/enterprise/admin/views.py +++ b/enterprise/admin/views.py @@ -15,7 +15,7 @@ from django.core.management import call_command from django.db import transaction from django.db.models import Q -from django.http import HttpResponse, HttpResponseRedirect, JsonResponse +from django.http import HttpResponse, HttpResponseForbidden, HttpResponseRedirect, HttpResponseServerError, JsonResponse from django.shortcuts import get_object_or_404, render from django.urls import reverse from django.utils.translation import gettext as _ @@ -36,6 +36,7 @@ ) from enterprise.api_client.discovery import get_course_catalog_api_service_client from enterprise.api_client.ecommerce import EcommerceApiClient +from enterprise.api_client.sso_orchestrator import EnterpriseSSOOrchestratorApiClient, SsoOrchestratorClientError from enterprise.constants import PAGE_SIZE from enterprise.errors import LinkUserToEnterpriseError from enterprise.models import ( @@ -50,6 +51,7 @@ delete_data_sharing_consent, enroll_users_in_course, get_ecommerce_worker_user, + get_sso_orchestrator_configure_edx_oauth_path, validate_course_exists_for_enterprise, validate_email_to_link, ) @@ -174,7 +176,8 @@ def get_form_view(self, request, customer_uuid, additional_context=None): render the form with appropriate context. """ context = self._build_context(request, customer_uuid) - context.update(additional_context) + if additional_context: + context.update(additional_context) return render(request, self.template, context) @@ -895,3 +898,71 @@ def delete(self, request, customer_uuid): return HttpResponse(message, content_type="application/json", status=404) return JsonResponse({}) + + +class EnterpriseCustomerSetupAuthOrgIDView(BaseEnterpriseCustomerView): + """ + Setup Auth org id View. + + This action will configure SSO to GetSmarter using edX credentials via Auth0. + """ + template = 'enterprise/admin/setup_auth_org_id.html' + + def get(self, request, customer_uuid): + """ + Handle GET request - render "Setup Auth org id" form. + + Arguments: + request (django.http.request.HttpRequest): Request instance + customer_uuid (str): Enterprise Customer UUID + + Returns: + django.http.response.HttpResponse: HttpResponse + """ + if not get_sso_orchestrator_configure_edx_oauth_path(): + return HttpResponseForbidden( + "The ENTERPRISE_SSO_ORCHESTRATOR_CONFIGURE_EDX_OAUTH_PATH setting was not configured." + ) + return self.get_form_view(request, customer_uuid) + + def post(self, request, customer_uuid): + """ + Handle POST request - handle form submissions. + + Arguments: + request (django.http.request.HttpRequest): Request instance + customer_uuid (str): Enterprise Customer UUID + """ + if not get_sso_orchestrator_configure_edx_oauth_path(): + return HttpResponseForbidden( + "The ENTERPRISE_SSO_ORCHESTRATOR_CONFIGURE_EDX_OAUTH_PATH setting was not configured." + ) + + enterprise_customer = EnterpriseCustomer.objects.get(uuid=customer_uuid) + + # Call the configure-edx-oauth endpoint on the enterprise-sso-orchestrator service to obtain an orgId. + # This will raise SsoOrchestratorClientError if the API request fails. + try: + auth_org_id = EnterpriseSSOOrchestratorApiClient().configure_edx_oauth(enterprise_customer) + except SsoOrchestratorClientError as exc: + error_msg = ( + f"Error configuring edx oauth for enterprise customer {enterprise_customer.name}" + f"<{enterprise_customer.uuid}>: {exc}" + ) + LOG.exception(error_msg) + return HttpResponseServerError(error_msg) + + if auth_org_id: + enterprise_customer.auth_org_id = auth_org_id + enterprise_customer.save() + messages.success(request, _('Successfully written the "Auth org id" field for this enterprise customer.')) + return HttpResponseRedirect(reverse("admin:" + UrlNames.SETUP_AUTH_ORG_ID, args=(customer_uuid,))) + else: + # Annoyingly, there's still the remote possibility that the request succeeded but we failed to retrieve the + # auth_org_id. This might be due to a regression in the API response schema. + error_msg = ( + f"Error configuring edx oauth for enterprise customer {enterprise_customer.name}" + f"<{enterprise_customer.uuid}>: Missing orgId." + ) + LOG.exception(error_msg) + return HttpResponseServerError(error_msg) diff --git a/enterprise/api_client/sso_orchestrator.py b/enterprise/api_client/sso_orchestrator.py index 693987aa2c..28fe70f640 100644 --- a/enterprise/api_client/sso_orchestrator.py +++ b/enterprise/api_client/sso_orchestrator.py @@ -16,6 +16,7 @@ get_sso_orchestrator_api_base_url, get_sso_orchestrator_basic_auth_password, get_sso_orchestrator_basic_auth_username, + get_sso_orchestrator_configure_edx_oauth_path, get_sso_orchestrator_configure_path, ) @@ -67,6 +68,14 @@ def _get_orchestrator_configure_url(self): # probably want config value validated for this return urljoin(self.base_url, get_sso_orchestrator_configure_path()) + def _get_orchestrator_configure_edx_oauth_url(self): + """ + get the configure-edx-oauth url for the SSO Orchestrator API + """ + if path := get_sso_orchestrator_configure_edx_oauth_path(): + return urljoin(self.base_url, path) + return None + def _create_auth_header(self): """ create the basic auth header for requests to the SSO Orchestrator API @@ -93,7 +102,7 @@ def _create_session(self): def _post(self, url, data=None): """ - make a GET request to the SSO Orchestrator API + make a POST request to the SSO Orchestrator API """ self._create_session() response = self.session.post(url, json=data, auth=self._create_auth_header()) @@ -133,3 +142,24 @@ def configure_sso_orchestration_record( response = self._post(self._get_orchestrator_configure_url(), data=request_data) return response.get('samlServiceProviderInformation', {}).get('spMetadataUrl', {}) + + def configure_edx_oauth(self, enterprise_customer): + """ + Configure SSO to GetSmarter using edX credentials via Auth0. + + Args: + enterprise_customer (EnterpriseCustomer): The enterprise customer for which to configure edX OAuth. + + Returns: + str: Auth0 Organization ID. + + Raises: + SsoOrchestratorClientError: If the request to the SSO Orchestrator API failed. + """ + request_data = { + 'enterpriseName': enterprise_customer.name, + 'enterpriseSlug': enterprise_customer.slug, + 'enterpriseUuid': str(enterprise_customer.uuid), + } + response = self._post(self._get_orchestrator_configure_edx_oauth_url(), data=request_data) + return response.get('orgId', None) diff --git a/enterprise/settings/test.py b/enterprise/settings/test.py index 20c1490ccd..e95dcfad49 100644 --- a/enterprise/settings/test.py +++ b/enterprise/settings/test.py @@ -363,3 +363,4 @@ def root(*args): ENTERPRISE_SSO_ORCHESTRATOR_WORKER_PASSWORD = 'password' ENTERPRISE_SSO_ORCHESTRATOR_BASE_URL = 'https://foobar.com' ENTERPRISE_SSO_ORCHESTRATOR_CONFIGURE_PATH = 'configure' +ENTERPRISE_SSO_ORCHESTRATOR_CONFIGURE_EDX_OAUTH_PATH = 'configure-edx-oauth' diff --git a/enterprise/templates/enterprise/admin/setup_auth_org_id.html b/enterprise/templates/enterprise/admin/setup_auth_org_id.html new file mode 100644 index 0000000000..3f9dbadc6a --- /dev/null +++ b/enterprise/templates/enterprise/admin/setup_auth_org_id.html @@ -0,0 +1,59 @@ +{% extends "admin/base_site.html" %} +{% load i18n static admin_urls %} + +{% block breadcrumbs %} + +{% endblock %} + +{% block content %} +
+
+

{% trans "Setup Auth org id" %}

+

+ This action is required for customers who will have learners in executive education courses. Setting up the + Auth org id will enable the enterprise's learners to take Exec Ed or OCM courses using the same set of login + credentials. Clicking the button below will facilitate the necessary steps with our external identity vendor, + Auth0, and will overwrite any value that may already be in the "Auth org id" field. +

+
+ {% csrf_token %} +
+
+ + + +
+
+ +
+
+
+
+
+{% endblock %} + +{% block footer %} + {{ block.super }} +{% endblock %} diff --git a/enterprise/utils.py b/enterprise/utils.py index 291f7d139b..631eeefd48 100644 --- a/enterprise/utils.py +++ b/enterprise/utils.py @@ -1565,6 +1565,13 @@ def get_sso_orchestrator_configure_path(): return settings.ENTERPRISE_SSO_ORCHESTRATOR_CONFIGURE_PATH +def get_sso_orchestrator_configure_edx_oauth_path(): + """ + Return the SSO orchestrator configure-edx-oauth endpoint path, or None if it is not defined. + """ + return getattr(settings, "ENTERPRISE_SSO_ORCHESTRATOR_CONFIGURE_EDX_OAUTH_PATH", None) + + def get_enterprise_worker_user(): """ Return the user object of enterprise worker user. diff --git a/tests/test_admin/test_view.py b/tests/test_admin/test_view.py index 87288b60d3..ca2602881b 100644 --- a/tests/test_admin/test_view.py +++ b/tests/test_admin/test_view.py @@ -33,6 +33,7 @@ TransmitEnterpriseCoursesForm, ) from enterprise.admin.utils import ValidationMessages +from enterprise.api_client.sso_orchestrator import SsoOrchestratorClientError from enterprise.constants import PAGE_SIZE from enterprise.models import ( EnrollmentNotificationEmailTemplate, @@ -2078,3 +2079,92 @@ def test_post_validation_errors(self): ) ] } + + +class BaseTestEnterpriseCustomerSetupAuthOrgIDView(BaseEnterpriseCustomerView): + """ + Common functionality for EnterpriseCustomerTransmitCoursesView tests. + """ + + def setUp(self): + """ + Test set up + """ + super().setUp() + self.enterprise_customer.auth_org_id = None + self.enterprise_customer.save() + self.view_url = reverse( + 'admin:' + enterprise_admin.utils.UrlNames.SETUP_AUTH_ORG_ID, + args=(self.enterprise_customer.uuid,) + ) + + +@ddt.ddt +@mark.django_db +@override_settings(ROOT_URLCONF='test_utils.admin_urls') +class TestEnterpriseCustomerSetupAuthOrgIDViewGet(BaseTestEnterpriseCustomerSetupAuthOrgIDView): + """ + Tests for EnterpriseCustomerSetupAuthOrgIDView GET endpoint. + """ + + def _test_get_response(self, response): + """ + Test view GET response for common parts. + """ + assert response.status_code == 200 + self._test_common_context(response.context) + assert response.context['enterprise_customer'] == self.enterprise_customer + + def test_get_not_logged_in(self): + response = self.client.get(self.view_url) + assert response.status_code == 302 + + def test_get_links(self): + self._login() + + response = self.client.get(self.view_url) + self._test_get_response(response) + + +@ddt.ddt +@mark.django_db +@override_settings(ROOT_URLCONF='test_utils.admin_urls') +class TestEnterpriseCustomerSetupAuthOrgIDViewPost(BaseTestEnterpriseCustomerSetupAuthOrgIDView): + """ + Tests for EnterpriseCustomerSetupAuthOrgIDView POST endpoint. + """ + + def test_post_not_logged_in(self): + response = self.client.post(self.view_url, data={}) + assert response.status_code == 302 + + @mock.patch('enterprise.api_client.sso_orchestrator.EnterpriseSSOOrchestratorApiClient.configure_edx_oauth') + def test_post_happy_path(self, mock_configure_edx_oauth): + fake_org_id = 'foobar' + mock_configure_edx_oauth.return_value = fake_org_id + self._login() + response = self.client.post(self.view_url) + mock_configure_edx_oauth.assert_called_once_with(self.enterprise_customer) + self.enterprise_customer.refresh_from_db() + assert self.enterprise_customer.auth_org_id == fake_org_id + + # Now check that the redirect is correct and that the success message is set. + self.assertRedirects(response, self.view_url, fetch_redirect_response=False) + get_response = self.client.get(self.view_url) + actual_messages = { + (m.level, m.message) for m in get_response.context['messages'] + } + expected_messages = { + (messages.SUCCESS, 'Successfully written the "Auth org id" field for this enterprise customer.'), + } + assert actual_messages == expected_messages + + @mock.patch('enterprise.api_client.sso_orchestrator.EnterpriseSSOOrchestratorApiClient.configure_edx_oauth') + def test_post_api_raises_error(self, mock_configure_edx_oauth): + mock_configure_edx_oauth.side_effect = SsoOrchestratorClientError('foobar') + self._login() + response = self.client.post(self.view_url) + assert response.status_code == 500 + mock_configure_edx_oauth.assert_called_once_with(self.enterprise_customer) + self.enterprise_customer.refresh_from_db() + assert self.enterprise_customer.auth_org_id is None diff --git a/tests/test_enterprise/api_client/test_sso_orchestrator.py b/tests/test_enterprise/api_client/test_sso_orchestrator.py index 683fe2fec6..02dbb39be5 100644 --- a/tests/test_enterprise/api_client/test_sso_orchestrator.py +++ b/tests/test_enterprise/api_client/test_sso_orchestrator.py @@ -11,12 +11,24 @@ from django.conf import settings from enterprise.api_client import sso_orchestrator -from enterprise.utils import get_sso_orchestrator_api_base_url, get_sso_orchestrator_configure_path +from enterprise.utils import ( + get_sso_orchestrator_api_base_url, + get_sso_orchestrator_configure_edx_oauth_path, + get_sso_orchestrator_configure_path, +) +from test_utils.factories import EnterpriseCustomerFactory TEST_ENTERPRISE_ID = '1840e1dc-59cf-4a78-82c5-c5bbc0b5df0f' TEST_ENTERPRISE_SSO_CONFIG_UUID = uuid4() TEST_ENTERPRISE_NAME = 'Test Enterprise' -SSO_ORCHESTRATOR_CONFIGURE_URL = urljoin(get_sso_orchestrator_api_base_url(), get_sso_orchestrator_configure_path()) +SSO_ORCHESTRATOR_CONFIGURE_URL = urljoin( + get_sso_orchestrator_api_base_url(), + get_sso_orchestrator_configure_path() +) +SSO_ORCHESTRATOR_CONFIGURE_EDX_OAUTH_URL = urljoin( + get_sso_orchestrator_api_base_url(), + get_sso_orchestrator_configure_edx_oauth_path() +) @responses.activate @@ -51,3 +63,29 @@ def test_post_sso_configuration(): 'name': TEST_ENTERPRISE_NAME, 'slug': TEST_ENTERPRISE_NAME } + + +@responses.activate +def test_configure_edx_oauth(): + """ + Test the configure_edx_oauth method. + """ + fake_enterprise_customer = EnterpriseCustomerFactory.stub() + fake_org_id = 'foobar' + responses.add( + responses.POST, + SSO_ORCHESTRATOR_CONFIGURE_EDX_OAUTH_URL, + json={'orgId': fake_org_id, 'status': 200}, + ) + client = sso_orchestrator.EnterpriseSSOOrchestratorApiClient() + + # Call the method under test: + actual_response = client.configure_edx_oauth(enterprise_customer=fake_enterprise_customer) + + assert actual_response == fake_org_id + responses.assert_call_count(count=1, url=SSO_ORCHESTRATOR_CONFIGURE_EDX_OAUTH_URL) + + sent_body_params = json.loads(responses.calls[0].request.body) + assert sent_body_params['enterpriseName'] == fake_enterprise_customer.name + assert sent_body_params['enterpriseSlug'] == fake_enterprise_customer.slug + assert sent_body_params['enterpriseUuid'] == str(fake_enterprise_customer.uuid) From 1c6cece4db29ee4770dfb523e43857ab7ad4c15a Mon Sep 17 00:00:00 2001 From: hamzawaleed01 Date: Tue, 9 Jan 2024 15:41:08 +0500 Subject: [PATCH 074/164] feat: create integrated channel API request log table --- .../0030_integratedchannelapirequestlogs.py | 36 ++++++++++++++++ .../migrations/0031_auto_20240109_1048.py | 21 +++++++++ .../integrated_channel/models.py | 43 +++++++++++++++++++ 3 files changed, 100 insertions(+) create mode 100644 integrated_channels/integrated_channel/migrations/0030_integratedchannelapirequestlogs.py create mode 100644 integrated_channels/integrated_channel/migrations/0031_auto_20240109_1048.py diff --git a/integrated_channels/integrated_channel/migrations/0030_integratedchannelapirequestlogs.py b/integrated_channels/integrated_channel/migrations/0030_integratedchannelapirequestlogs.py new file mode 100644 index 0000000000..7b340c8fdc --- /dev/null +++ b/integrated_channels/integrated_channel/migrations/0030_integratedchannelapirequestlogs.py @@ -0,0 +1,36 @@ +# Generated by Django 3.2.23 on 2024-01-09 10:35 + +from django.db import migrations, models +import django.db.models.deletion +import django.utils.timezone +import model_utils.fields + + +class Migration(migrations.Migration): + + dependencies = [ + ('enterprise', '0197_auto_20231130_2239'), + ('integrated_channel', '0029_genericenterprisecustomerpluginconfiguration_show_course_price'), + ] + + operations = [ + migrations.CreateModel( + name='IntegratedChannelAPIRequestLogs', + 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')), + ('enterprise_customer_configuration_uuid', models.UUIDField()), + ('endpoint', models.TextField()), + ('payload', models.TextField()), + ('time_taken', models.DurationField()), + ('user_agent', models.CharField(max_length=255)), + ('user_ip', models.GenericIPAddressField()), + ('api_record', models.OneToOneField(blank=True, help_text='Data pertaining to the transmissions API request response.', null=True, on_delete=django.db.models.deletion.CASCADE, to='integrated_channel.apiresponserecord')), + ('enterprise_customer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='enterprise.enterprisecustomer')), + ], + options={ + 'abstract': False, + }, + ), + ] diff --git a/integrated_channels/integrated_channel/migrations/0031_auto_20240109_1048.py b/integrated_channels/integrated_channel/migrations/0031_auto_20240109_1048.py new file mode 100644 index 0000000000..3188443b1b --- /dev/null +++ b/integrated_channels/integrated_channel/migrations/0031_auto_20240109_1048.py @@ -0,0 +1,21 @@ +# Generated by Django 3.2.23 on 2024-01-09 10:48 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('integrated_channel', '0030_integratedchannelapirequestlogs'), + ] + + operations = [ + migrations.RemoveField( + model_name='integratedchannelapirequestlogs', + name='user_agent', + ), + migrations.RemoveField( + model_name='integratedchannelapirequestlogs', + name='user_ip', + ), + ] diff --git a/integrated_channels/integrated_channel/models.py b/integrated_channels/integrated_channel/models.py index 4e20d2a43f..a3961aee81 100644 --- a/integrated_channels/integrated_channel/models.py +++ b/integrated_channels/integrated_channel/models.py @@ -879,3 +879,46 @@ class Meta: on_delete=models.CASCADE, ) resolved = models.BooleanField(default=False) + +class IntegratedChannelAPIRequestLogs(TimeStampedModel): + """ + A model to track basic information about every API call we make from the integrated channels. + """ + enterprise_customer = models.ForeignKey(EnterpriseCustomer, on_delete=models.CASCADE) + enterprise_customer_configuration_uuid = models.UUIDField(blank=False, null=False) + endpoint = models.TextField(blank=False, null=False) + payload = models.TextField(blank=False, null=False) + time_taken = models.DurationField(blank=False, null=False) + api_record = models.OneToOneField( + ApiResponseRecord, + blank=True, + null=True, + on_delete=models.CASCADE, + help_text=_('Data pertaining to the transmissions API request response.') + ) + + class Meta: + app_label = 'integrated_channel' + + def __str__(self): + """ + Return a human-readable string representation of the object. + """ + return ( + f'' + f', endpoint: {self.endpoint}' + f', payload: {self.payload}' + f', time_taken: {self.time_taken}' + f', user_agent: {self.user_agent}' + f', user_ip: {self.user_ip}' + f', api_record.body: {self.api_record.body}' + f', api_record.status_code: {self.api_record.status_code}' + ) + + def __repr__(self): + """ + Return uniquely identifying string representation. + """ + return self.__str__() \ No newline at end of file From 32421b987811452168ce246b04b257f6a660778c Mon Sep 17 00:00:00 2001 From: Troy Sankey Date: Tue, 9 Jan 2024 13:16:51 -0800 Subject: [PATCH 075/164] fix: enable printing of error messages from the SSO Orchestrator API ENT-8169 --- CHANGELOG.rst | 5 +++++ enterprise/__init__.py | 2 +- enterprise/api_client/sso_orchestrator.py | 5 ++++- 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 60b2dd8d3e..a443ca72f3 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -16,6 +16,11 @@ Change Log Unreleased ---------- +[4.9.1] +-------- + +fix: enable printing of error messages from the SSO Orchestrator API (ENT-8169) + [4.9.0] -------- diff --git a/enterprise/__init__.py b/enterprise/__init__.py index 901407901d..a3dceeccd6 100644 --- a/enterprise/__init__.py +++ b/enterprise/__init__.py @@ -2,4 +2,4 @@ Your project description goes here. """ -__version__ = "4.9.0" +__version__ = "4.9.1" diff --git a/enterprise/api_client/sso_orchestrator.py b/enterprise/api_client/sso_orchestrator.py index 28fe70f640..8f17056922 100644 --- a/enterprise/api_client/sso_orchestrator.py +++ b/enterprise/api_client/sso_orchestrator.py @@ -108,7 +108,10 @@ def _post(self, url, data=None): response = self.session.post(url, json=data, auth=self._create_auth_header()) if response.status_code >= 300: raise SsoOrchestratorClientError( - f"Failed to make SSO Orchestrator API request: {response.status_code}", + ( + f"Failed to make SSO Orchestrator API request: {response.status_code}\n" + f"{response.content}" + ), response=response, ) return response.json() From 2f5244f186a05c7348599ce3dec348ac1d36fdb6 Mon Sep 17 00:00:00 2001 From: hamzawaleed01 Date: Wed, 10 Jan 2024 14:00:36 +0500 Subject: [PATCH 076/164] feat: add api request logs model for CSOD and update generic one --- .../0031_cornerstoneapirequestlogs.py | 24 +++++++++++++ integrated_channels/cornerstone/models.py | 35 +++++++++++++++++++ .../0030_integratedchannelapirequestlogs.py | 7 +--- .../migrations/0031_auto_20240109_1048.py | 21 ----------- .../integrated_channel/models.py | 2 -- 5 files changed, 60 insertions(+), 29 deletions(-) create mode 100644 integrated_channels/cornerstone/migrations/0031_cornerstoneapirequestlogs.py delete mode 100644 integrated_channels/integrated_channel/migrations/0031_auto_20240109_1048.py diff --git a/integrated_channels/cornerstone/migrations/0031_cornerstoneapirequestlogs.py b/integrated_channels/cornerstone/migrations/0031_cornerstoneapirequestlogs.py new file mode 100644 index 0000000000..231f9e11f0 --- /dev/null +++ b/integrated_channels/cornerstone/migrations/0031_cornerstoneapirequestlogs.py @@ -0,0 +1,24 @@ +# Generated by Django 3.2.23 on 2024-01-10 08:12 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('integrated_channel', '0030_integratedchannelapirequestlogs'), + ('cornerstone', '0030_auto_20231010_1654'), + ] + + operations = [ + migrations.CreateModel( + name='CornerstoneAPIRequestLogs', + fields=[ + ('integratedchannelapirequestlogs_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='integrated_channel.integratedchannelapirequestlogs')), + ('user_agent', models.CharField(max_length=255)), + ('user_ip', models.GenericIPAddressField()), + ], + bases=('integrated_channel.integratedchannelapirequestlogs',), + ), + ] diff --git a/integrated_channels/cornerstone/models.py b/integrated_channels/cornerstone/models.py index 88b0f24e93..fc1af52a39 100644 --- a/integrated_channels/cornerstone/models.py +++ b/integrated_channels/cornerstone/models.py @@ -20,6 +20,7 @@ from integrated_channels.integrated_channel.models import ( EnterpriseCustomerPluginConfiguration, LearnerDataTransmissionAudit, + IntegratedChannelAPIRequestLogs ) from integrated_channels.utils import is_valid_url @@ -318,3 +319,37 @@ class CornerstoneCourseKey(models.Model): class Meta: app_label = 'cornerstone' + + +class CornerstoneAPIRequestLogs(IntegratedChannelAPIRequestLogs): + """ + A model to track basic information about every API call we make from the integrated channels. + """ + user_agent = models.CharField(max_length=255) + user_ip = models.GenericIPAddressField(blank=False, null=False) + + class Meta: + app_label = 'cornerstone' + + def __str__(self): + """ + Return a human-readable string representation of the object. + """ + return ( + f'' + f', endpoint: {self.endpoint}' + f', payload: {self.payload}' + f', time_taken: {self.time_taken}' + f', user_agent: {self.user_agent}' + f', user_ip: {self.user_ip}' + f', api_record.body: {self.api_record.body}' + f', api_record.status_code: {self.api_record.status_code}' + ) + + def __repr__(self): + """ + Return uniquely identifying string representation. + """ + return self.__str__() diff --git a/integrated_channels/integrated_channel/migrations/0030_integratedchannelapirequestlogs.py b/integrated_channels/integrated_channel/migrations/0030_integratedchannelapirequestlogs.py index 7b340c8fdc..fef651c8a9 100644 --- a/integrated_channels/integrated_channel/migrations/0030_integratedchannelapirequestlogs.py +++ b/integrated_channels/integrated_channel/migrations/0030_integratedchannelapirequestlogs.py @@ -1,4 +1,4 @@ -# Generated by Django 3.2.23 on 2024-01-09 10:35 +# Generated by Django 3.2.23 on 2024-01-10 08:12 from django.db import migrations, models import django.db.models.deletion @@ -24,13 +24,8 @@ class Migration(migrations.Migration): ('endpoint', models.TextField()), ('payload', models.TextField()), ('time_taken', models.DurationField()), - ('user_agent', models.CharField(max_length=255)), - ('user_ip', models.GenericIPAddressField()), ('api_record', models.OneToOneField(blank=True, help_text='Data pertaining to the transmissions API request response.', null=True, on_delete=django.db.models.deletion.CASCADE, to='integrated_channel.apiresponserecord')), ('enterprise_customer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='enterprise.enterprisecustomer')), ], - options={ - 'abstract': False, - }, ), ] diff --git a/integrated_channels/integrated_channel/migrations/0031_auto_20240109_1048.py b/integrated_channels/integrated_channel/migrations/0031_auto_20240109_1048.py deleted file mode 100644 index 3188443b1b..0000000000 --- a/integrated_channels/integrated_channel/migrations/0031_auto_20240109_1048.py +++ /dev/null @@ -1,21 +0,0 @@ -# Generated by Django 3.2.23 on 2024-01-09 10:48 - -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ('integrated_channel', '0030_integratedchannelapirequestlogs'), - ] - - operations = [ - migrations.RemoveField( - model_name='integratedchannelapirequestlogs', - name='user_agent', - ), - migrations.RemoveField( - model_name='integratedchannelapirequestlogs', - name='user_ip', - ), - ] diff --git a/integrated_channels/integrated_channel/models.py b/integrated_channels/integrated_channel/models.py index a3961aee81..bbccd11c6a 100644 --- a/integrated_channels/integrated_channel/models.py +++ b/integrated_channels/integrated_channel/models.py @@ -911,8 +911,6 @@ def __str__(self): f', endpoint: {self.endpoint}' f', payload: {self.payload}' f', time_taken: {self.time_taken}' - f', user_agent: {self.user_agent}' - f', user_ip: {self.user_ip}' f', api_record.body: {self.api_record.body}' f', api_record.status_code: {self.api_record.status_code}' ) From 64710ca0f243f2e90fe67d17b436526e8c662d63 Mon Sep 17 00:00:00 2001 From: Ehmad Saeed Date: Wed, 10 Jan 2024 14:16:40 +0500 Subject: [PATCH 077/164] chore: bump version to 4.9.2 --- CHANGELOG.rst | 5 +++++ enterprise/__init__.py | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index a443ca72f3..ec5fe92c59 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -16,6 +16,11 @@ Change Log Unreleased ---------- +[4.9.2] +-------- + +refactor: learner data transmission audit record to utilize the existing records (ENT-8005) + [4.9.1] -------- diff --git a/enterprise/__init__.py b/enterprise/__init__.py index a3dceeccd6..18f6b3105d 100644 --- a/enterprise/__init__.py +++ b/enterprise/__init__.py @@ -2,4 +2,4 @@ Your project description goes here. """ -__version__ = "4.9.1" +__version__ = "4.9.2" From 36063f6fb8b9145580866b43cd384c71cd29a543 Mon Sep 17 00:00:00 2001 From: hamzawaleed01 Date: Thu, 11 Jan 2024 12:39:03 +0500 Subject: [PATCH 078/164] fix: remove debugging logs --- .../exporters/learner_data.py | 51 +------------------ 1 file changed, 1 insertion(+), 50 deletions(-) diff --git a/integrated_channels/integrated_channel/exporters/learner_data.py b/integrated_channels/integrated_channel/exporters/learner_data.py index 18c22c3f3d..04385833ea 100644 --- a/integrated_channels/integrated_channel/exporters/learner_data.py +++ b/integrated_channels/integrated_channel/exporters/learner_data.py @@ -397,11 +397,6 @@ def export(self, **kwargs): ) enrollment_ids_to_export = [enrollment.id for enrollment in enrollments_permitted] - LOGGER.info( - f"[Debug-SAP]: course_run_id:{course_run_id}, channel_name: {channel_name}" - f"lms_user_for_filter:{lms_user_for_filter}, grade:{grade} " - f"enrollment_ids_to_export: {enrollment_ids_to_export}" - ) for enterprise_enrollment in enrollments_permitted: lms_user_id = enterprise_enrollment.enterprise_customer_user.user_id user_email = enterprise_enrollment.enterprise_customer_user.user_email @@ -460,18 +455,7 @@ def export(self, **kwargs): course_completed=_is_course_completed, grade_percent=grade_percent, ) - LOGGER.info( - generate_formatted_log( - channel_name, - enterprise_customer_uuid, - lms_user_id, - course_id, - f", [Debug-SAP]: _is_course_completed: {_is_course_completed}, progress_status:{progress_status}" - f",completed_date_from_api: {completed_date_from_api}, grade_from_api:{grade_from_api}" - f"is_passing_from_api: {is_passing_from_api}, grade_percent:{grade_percent}" - f"passed_timestamp:{passed_timestamp}, records: {records}", - ) - ) + if records: # There are some cases where we won't receive a record from the above # method; right now, that should only happen if we have an Enterprise-linked @@ -489,12 +473,6 @@ def export(self, **kwargs): yield record - LOGGER.info(generate_formatted_log( - channel_name, None, lms_user_for_filter, course_run_id, - f'[Debug-SAP]: export finished. Did not export records for EnterpriseCourseEnrollment objects: ' - f' {enrollment_ids_to_export}.' - )) - def _filter_out_pre_transmitted_enrollments( self, enrollments_to_process, @@ -546,42 +524,15 @@ def get_enrollments_to_process(self, lms_user_for_filter, course_run_id, channel enterprise_customer_user__active=True, ) if lms_user_for_filter and course_run_id: - LOGGER.info( - generate_formatted_log( - channel_name, - self.enterprise_customer.uuid, - lms_user_for_filter, - course_run_id, - f"[Debug-SAP]: enrollments to process before filtering: {list(enrollment_queryset)}", - ) - ) enrollment_queryset = enrollment_queryset.filter( course_id=course_run_id, enterprise_customer_user__user_id=lms_user_for_filter.id, ) - LOGGER.info( - generate_formatted_log( - channel_name, - self.enterprise_customer.uuid, - lms_user_for_filter, - course_run_id, - f"[Debug-SAP]: enrollments to process after filtering: {list(enrollment_queryset)}", - ) - ) LOGGER.info(generate_formatted_log( channel_name, self.enterprise_customer.uuid, lms_user_for_filter, course_run_id, 'get_enrollments_to_process run for single learner and course.')) enrollment_queryset = enrollment_queryset.order_by('course_id') # return resolved list instead of queryset - LOGGER.info( - generate_formatted_log( - channel_name, - self.enterprise_customer.uuid, - lms_user_for_filter, - course_run_id, - f"[Debug-SAP]: enrollments to process: {list(enrollment_queryset)}", - ) - ) return list(enrollment_queryset) def get_learner_assessment_data_records( From ae3373abc1753b75cfc036cdf91b0ae504af1e7f Mon Sep 17 00:00:00 2001 From: hamzawaleed01 Date: Thu, 11 Jan 2024 17:45:13 +0500 Subject: [PATCH 079/164] feat: bump version --- CHANGELOG.rst | 5 +++++ enterprise/__init__.py | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index ec5fe92c59..88972f33c1 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -16,6 +16,11 @@ Change Log Unreleased ---------- +[4.9.3] +-------- + +fix: Remove SAP debug logs + [4.9.2] -------- diff --git a/enterprise/__init__.py b/enterprise/__init__.py index 18f6b3105d..eae2d43e45 100644 --- a/enterprise/__init__.py +++ b/enterprise/__init__.py @@ -2,4 +2,4 @@ Your project description goes here. """ -__version__ = "4.9.2" +__version__ = "4.9.3" From 62367cfa930d6a10675bbace1cb237ae81cc2c30 Mon Sep 17 00:00:00 2001 From: hamzawaleed01 Date: Fri, 12 Jan 2024 12:56:02 +0500 Subject: [PATCH 080/164] feat: update models and add tests --- .../0031_cornerstoneapirequestlogs.py | 2 +- integrated_channels/cornerstone/models.py | 4 +- .../0030_integratedchannelapirequestlogs.py | 4 +- .../integrated_channel/models.py | 8 +-- .../test_integrated_channel/test_models.py | 54 ++++++++++++++++++- 5 files changed, 62 insertions(+), 10 deletions(-) diff --git a/integrated_channels/cornerstone/migrations/0031_cornerstoneapirequestlogs.py b/integrated_channels/cornerstone/migrations/0031_cornerstoneapirequestlogs.py index 18116a9cd1..fb01bcca82 100644 --- a/integrated_channels/cornerstone/migrations/0031_cornerstoneapirequestlogs.py +++ b/integrated_channels/cornerstone/migrations/0031_cornerstoneapirequestlogs.py @@ -1,4 +1,4 @@ -# Generated by Django 3.2.23 on 2024-01-10 10:26 +# Generated by Django 3.2.23 on 2024-01-12 07:24 from django.db import migrations, models import django.db.models.deletion diff --git a/integrated_channels/cornerstone/models.py b/integrated_channels/cornerstone/models.py index 97fb87db99..e69f3c68b8 100644 --- a/integrated_channels/cornerstone/models.py +++ b/integrated_channels/cornerstone/models.py @@ -337,8 +337,8 @@ def __str__(self): """ return ( f'' + f' for enterprise customer {self.enterprise_customer}, ' + f', enterprise_customer_configuration_id: {self.enterprise_customer_configuration_id}>' f', endpoint: {self.endpoint}' f', payload: {self.payload}' f', time_taken: {self.time_taken}' diff --git a/integrated_channels/integrated_channel/migrations/0030_integratedchannelapirequestlogs.py b/integrated_channels/integrated_channel/migrations/0030_integratedchannelapirequestlogs.py index 83c97412f3..db8f99c3e5 100644 --- a/integrated_channels/integrated_channel/migrations/0030_integratedchannelapirequestlogs.py +++ b/integrated_channels/integrated_channel/migrations/0030_integratedchannelapirequestlogs.py @@ -1,4 +1,4 @@ -# Generated by Django 3.2.23 on 2024-01-10 10:26 +# Generated by Django 3.2.23 on 2024-01-12 07:24 from django.db import migrations, models import django.db.models.deletion @@ -20,7 +20,7 @@ class Migration(migrations.Migration): ('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')), - ('enterprise_customer_configuration_uuid', models.IntegerField()), + ('enterprise_customer_configuration_id', models.IntegerField(help_text='ID from the EnterpriseCustomerConfiguration model')), ('endpoint', models.TextField()), ('payload', models.TextField()), ('time_taken', models.DurationField()), diff --git a/integrated_channels/integrated_channel/models.py b/integrated_channels/integrated_channel/models.py index 620c42bd0d..9fcced2e99 100644 --- a/integrated_channels/integrated_channel/models.py +++ b/integrated_channels/integrated_channel/models.py @@ -887,8 +887,8 @@ class IntegratedChannelAPIRequestLogs(TimeStampedModel): """ enterprise_customer = models.ForeignKey( EnterpriseCustomer, on_delete=models.CASCADE) - enterprise_customer_configuration_uuid = models.IntegerField( - blank=False, null=False) + enterprise_customer_configuration_id = models.IntegerField( + blank=False, null=False, help_text='ID from the EnterpriseCustomerConfiguration model') endpoint = models.TextField(blank=False, null=False) payload = models.TextField(blank=False, null=False) time_taken = models.DurationField(blank=False, null=False) @@ -910,8 +910,8 @@ def __str__(self): """ return ( f'' + f' for enterprise customer {self.enterprise_customer}, ' + f', enterprise_customer_configuration_id: {self.enterprise_customer_configuration_id}>' f', endpoint: {self.endpoint}' f', payload: {self.payload}' f', time_taken: {self.time_taken}' diff --git a/tests/test_integrated_channels/test_integrated_channel/test_models.py b/tests/test_integrated_channels/test_integrated_channel/test_models.py index e182b1ff7e..8f8c187654 100644 --- a/tests/test_integrated_channels/test_integrated_channel/test_models.py +++ b/tests/test_integrated_channels/test_integrated_channel/test_models.py @@ -9,7 +9,11 @@ from pytest import mark from enterprise.utils import get_content_metadata_item_id, localized_utcnow -from integrated_channels.integrated_channel.models import ApiResponseRecord, ContentMetadataItemTransmission +from integrated_channels.integrated_channel.models import ( + ApiResponseRecord, + ContentMetadataItemTransmission, + IntegratedChannelAPIRequestLogs, +) from test_utils import factories from test_utils.fake_catalog_api import FAKE_COURSE_RUN, get_fake_catalog, get_fake_content_metadata from test_utils.fake_enterprise_api import EnterpriseMockMixin @@ -251,3 +255,51 @@ def test_offset_naive_error(self): first_timestamp = localized_utcnow() self.config.update_content_synced_at(first_timestamp, True) assert self.config.last_sync_attempted_at == first_timestamp + +@mark.django_db +class TestIntegratedChannelAPIRequestLogs(unittest.TestCase, EnterpriseMockMixin): + """ + Tests for the ``IntegratedChannelAPIRequestLogs`` model. + """ + + def setUp(self): + self.enterprise_customer = factories.EnterpriseCustomerFactory() + with mock.patch('enterprise.signals.EnterpriseCatalogApiClient'): + self.enterprise_customer_catalog = factories.EnterpriseCustomerCatalogFactory( + enterprise_customer=self.enterprise_customer, + ) + self.pk = 1 + self.enterprise_customer_configuration_id = 1 + self.endpoint = 'https://example.com/endpoint' + self.payload = "{}" + self.time_taken = 500 + api_record = ApiResponseRecord(status_code=200, body='SUCCESS') + api_record.save() + self.api_record = api_record + super().setUp() + + def test_content_meta_data_string_representation(self): + """ + Test the string representation of the model. + """ + expected_string = ( + f'' + f', endpoint: {self.endpoint}' + f', payload: {self.payload}' + f', time_taken: {self.time_taken}' + f', api_record.body: {self.api_record.body}' + f', api_record.status_code: {self.api_record.status_code}' + ) + + request_log = IntegratedChannelAPIRequestLogs( + id=1, + enterprise_customer=self.enterprise_customer, + enterprise_customer_configuration_id=self.enterprise_customer_configuration_id, + endpoint=self.endpoint, + payload=self.payload, + time_taken=self.time_taken, + api_record=self.api_record + ) + assert expected_string == repr(request_log) From cc843d719f06cd38570ed5d8f91a927b92f139bf Mon Sep 17 00:00:00 2001 From: MueezKhan246 <93375917+MueezKhan246@users.noreply.github.com> Date: Mon, 15 Jan 2024 17:37:45 +0500 Subject: [PATCH 081/164] Removing unencrypted user credentials data (#1966) * feat: replacing non encrypted fields of moodle config model with encrypted ones (ENT 5613) --- CHANGELOG.rst | 5 +++++ enterprise/__init__.py | 2 +- integrated_channels/api/v1/moodle/serializers.py | 12 +++++++++--- integrated_channels/moodle/admin/__init__.py | 6 +++--- integrated_channels/moodle/client.py | 5 +++-- integrated_channels/moodle/models.py | 2 +- .../test_api/test_moodle/test_views.py | 10 ++++++++-- 7 files changed, 30 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index e3ddf6a78b..51e2890bd5 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -16,6 +16,11 @@ Change Log Unreleased ---------- +[4.9.5] +-------- + +feat: replacing non encrypted fields of moodle config model with encrypted ones + [4.9.4] -------- diff --git a/enterprise/__init__.py b/enterprise/__init__.py index 665440b6f8..897c6f5e55 100644 --- a/enterprise/__init__.py +++ b/enterprise/__init__.py @@ -2,4 +2,4 @@ Your project description goes here. """ -__version__ = "4.9.4" +__version__ = "4.9.5" diff --git a/integrated_channels/api/v1/moodle/serializers.py b/integrated_channels/api/v1/moodle/serializers.py index fcef70f5a5..9668dcdb43 100644 --- a/integrated_channels/api/v1/moodle/serializers.py +++ b/integrated_channels/api/v1/moodle/serializers.py @@ -1,6 +1,8 @@ """ Serializer for Moodle configuration. """ +from rest_framework import serializers + from integrated_channels.api.serializers import EnterpriseCustomerPluginConfigSerializer from integrated_channels.moodle.models import MoodleEnterpriseCustomerConfiguration @@ -12,8 +14,12 @@ class Meta: 'moodle_base_url', 'service_short_name', 'category_id', - 'username', - 'password', - 'token', + 'encrypted_username', + 'encrypted_password', + 'encrypted_token', ) fields = EnterpriseCustomerPluginConfigSerializer.Meta.fields + extra_fields + + encrypted_password = serializers.CharField(required=False, allow_blank=False, read_only=False) + encrypted_username = serializers.CharField(required=False, allow_blank=False, read_only=False) + encrypted_token = serializers.CharField(required=False, allow_blank=False, read_only=False) diff --git a/integrated_channels/moodle/admin/__init__.py b/integrated_channels/moodle/admin/__init__.py index 81156462a5..44c11cb02c 100644 --- a/integrated_channels/moodle/admin/__init__.py +++ b/integrated_channels/moodle/admin/__init__.py @@ -24,9 +24,9 @@ class Meta: def clean(self): cleaned_data = super().clean() - cleaned_username = cleaned_data.get('username') - cleaned_password = cleaned_data.get('password') - cleaned_token = cleaned_data.get('token') + cleaned_username = cleaned_data.get('decrypted_username') + cleaned_password = cleaned_data.get('decrypted_password') + cleaned_token = cleaned_data.get('decrypted_token') if cleaned_token and (cleaned_username or cleaned_password): raise ValidationError(_('Cannot set both a Username/Password and Token')) if (cleaned_username and not cleaned_password) or (cleaned_password and not cleaned_username): diff --git a/integrated_channels/moodle/client.py b/integrated_channels/moodle/client.py index d62558ad22..9226c524bf 100644 --- a/integrated_channels/moodle/client.py +++ b/integrated_channels/moodle/client.py @@ -184,6 +184,7 @@ def _get_access_token(self): username = self.enterprise_configuration.username decrypted_password = self.enterprise_configuration.decrypted_password password = self.enterprise_configuration.password + use_encrypted_user_data = getattr(settings, 'FEATURES', {}).get('USE_ENCRYPTED_USER_DATA', False) response = requests.post( urljoin( @@ -195,8 +196,8 @@ def _get_access_token(self): 'Content-Type': 'application/x-www-form-urlencoded', }, data={ - "username": decrypted_username if settings.FEATURES.get('USE_ENCRYPTED_USER_DATA', False) else username, - "password": decrypted_password if settings.FEATURES.get('USE_ENCRYPTED_USER_DATA', False) else password, + "username": decrypted_username if use_encrypted_user_data else username, + "password": decrypted_password if use_encrypted_user_data else password, }, ) diff --git a/integrated_channels/moodle/models.py b/integrated_channels/moodle/models.py index 793dfbbe8a..83442f4d44 100644 --- a/integrated_channels/moodle/models.py +++ b/integrated_channels/moodle/models.py @@ -230,7 +230,7 @@ def is_valid(self): incorrect_items = {'incorrect': []} if not self.moodle_base_url: missing_items.get('missing').append('moodle_base_url') - if not self.token and not (self.username and self.password): + if not self.decrypted_token and not (self.decrypted_username and self.decrypted_password): missing_items.get('missing').append('token OR username and password') if not self.service_short_name: missing_items.get('missing').append('service_short_name') diff --git a/tests/test_integrated_channels/test_api/test_moodle/test_views.py b/tests/test_integrated_channels/test_api/test_moodle/test_views.py index 23fe53db18..ef420739e9 100644 --- a/tests/test_integrated_channels/test_api/test_moodle/test_views.py +++ b/tests/test_integrated_channels/test_api/test_moodle/test_views.py @@ -82,13 +82,13 @@ def test_update(self, mock_current_request): 'moodle_base_url': 'http://testing2', 'service_short_name': 'test', 'enterprise_customer': ENTERPRISE_ID, - 'token': 'testing' + 'encrypted_token': 'testing' } response = self.client.put(url, payload) self.moodle_config.refresh_from_db() self.assertEqual(self.moodle_config.moodle_base_url, 'http://testing2') self.assertEqual(self.moodle_config.service_short_name, 'test') - self.assertEqual(self.moodle_config.token, 'testing') + self.assertEqual(self.moodle_config.decrypted_token, 'testing') self.assertEqual(response.status_code, 200) @mock.patch('enterprise.rules.crum.get_current_request') @@ -139,6 +139,9 @@ def test_is_valid_field(self, mock_current_request): _, incorrect = data[0].get('is_valid') assert incorrect.get('incorrect') == ['moodle_base_url', 'display_name'] + self.moodle_config.decrypted_token = '' + self.moodle_config.decrypted_username = '' + self.moodle_config.decrypted_password = '' self.moodle_config.token = '' self.moodle_config.username = '' self.moodle_config.password = '' @@ -152,6 +155,9 @@ def test_is_valid_field(self, mock_current_request): assert missing.get('missing') == ['moodle_base_url', 'token OR username and password', 'service_short_name'] self.moodle_config.category_id = 10 + self.moodle_config.decrypted_username = 'lmao' + self.moodle_config.decrypted_password = 'foobar' + self.moodle_config.decrypted_token = 'baa' self.moodle_config.username = 'lmao' self.moodle_config.password = 'foobar' self.moodle_config.token = 'baa' From 237e4a1e9b1f7f2de514682e094a73b97f95bc48 Mon Sep 17 00:00:00 2001 From: 0x29a Date: Sun, 5 Feb 2023 19:06:47 +0100 Subject: [PATCH 082/164] feat: enrollment API enhancements - Allows Enrollment API Admin to see all enrollments. - Makes the endpoint return more fields, such as: enrollment_track, enrollment_date, user_email, course_start and course_end. - Changes EnterpriseCourseEnrollment's default ordering from 'created' to 'id', which equivalent, but faster in some cases (due to the existing indes on 'id'). Co-authored-by: Maxim Beder --- CHANGELOG.rst | 12 ++++ enterprise/__init__.py | 2 +- enterprise/api/filters.py | 32 ++++++++- enterprise/api/v1/serializers.py | 26 +++++++ .../v1/views/enterprise_course_enrollment.py | 63 ++++++++++++++++- ...lter_enterprisecourseenrollment_options.py | 17 +++++ enterprise/models.py | 61 +++++++++++++++- test_utils/factories.py | 35 ++++++++++ tests/test_enterprise/api/test_filters.py | 70 ++++++++++++++++++- tests/test_enterprise/api/test_views.py | 5 ++ 10 files changed, 317 insertions(+), 6 deletions(-) create mode 100644 enterprise/migrations/0198_alter_enterprisecourseenrollment_options.py diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 51e2890bd5..ae6419f8eb 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -16,6 +16,18 @@ Change Log Unreleased ---------- +[4.10.0] +-------- + +feat: enrollment API enhancements + +- Allows Enrollment API Admin to see all enrollments. +- Makes the endpoint return more fields, such as: enrollment_track, + enrollment_date, user_email, course_start and course_end. +- Changes EnterpriseCourseEnrollment's default ordering from 'created' + to 'id', which equivalent, but faster in some cases (due to the + existing indes on 'id'). + [4.9.5] -------- diff --git a/enterprise/__init__.py b/enterprise/__init__.py index 897c6f5e55..b3a0ce8824 100644 --- a/enterprise/__init__.py +++ b/enterprise/__init__.py @@ -2,4 +2,4 @@ Your project description goes here. """ -__version__ = "4.9.5" +__version__ = "4.10.0" diff --git a/enterprise/api/filters.py b/enterprise/api/filters.py index 0ea4115085..92c1bb4551 100644 --- a/enterprise/api/filters.py +++ b/enterprise/api/filters.py @@ -7,7 +7,7 @@ from django.contrib import auth -from enterprise.models import EnterpriseCustomerUser, SystemWideEnterpriseUserRoleAssignment +from enterprise.models import EnterpriseCustomer, EnterpriseCustomerUser, SystemWideEnterpriseUserRoleAssignment User = auth.get_user_model() @@ -33,6 +33,36 @@ def filter_queryset(self, request, queryset, view): return queryset +class EnterpriseCourseEnrollmentFilterBackend(filters.BaseFilterBackend): + """ + Filter backend to return enrollments under the user's enterprise(s) only. + + * Staff users will bypass this filter. + * Non-staff users will receive enrollments under their linked enterprises, + only if they have the `enterprise.can_enroll_learners` permission. + * Non-staff users without the `enterprise.can_enroll_learners` permission + will receive only their own enrollments. + """ + + def filter_queryset(self, request, queryset, view): + """ + Filter out enrollments if learner is not linked + """ + + if request.user.is_staff: + return queryset + + if request.user.has_perm('enterprise.can_enroll_learners'): + enterprise_customers = EnterpriseCustomer.objects.filter(enterprise_customer_users__user_id=request.user.id) + return queryset.filter(enterprise_customer_user__enterprise_customer__in=enterprise_customers) + + filter_kwargs = { + view.USER_ID_FILTER: request.user.id, + } + + return queryset.filter(**filter_kwargs) + + class EnterpriseCustomerUserFilterBackend(filters.BaseFilterBackend): """ Allow filtering on the enterprise customer user api endpoint. diff --git a/enterprise/api/v1/serializers.py b/enterprise/api/v1/serializers.py index 3010e52805..d991cc445b 100644 --- a/enterprise/api/v1/serializers.py +++ b/enterprise/api/v1/serializers.py @@ -356,6 +356,32 @@ class Meta: ) +class EnterpriseCourseEnrollmentWithAdditionalFieldsReadOnlySerializer(EnterpriseCourseEnrollmentReadOnlySerializer): + """ + Serializer for EnterpriseCourseEnrollment model with additional fields. + """ + + class Meta: + model = models.EnterpriseCourseEnrollment + fields = ( + 'enterprise_customer_user', + 'course_id', + 'created', + 'unenrolled_at', + 'enrollment_date', + 'enrollment_track', + 'user_email', + 'course_start', + 'course_end', + ) + + enrollment_track = serializers.CharField() + enrollment_date = serializers.DateTimeField() + user_email = serializers.EmailField() + course_start = serializers.DateTimeField() + course_end = serializers.DateTimeField() + + class EnterpriseCourseEnrollmentWriteSerializer(serializers.ModelSerializer): """ Serializer for writing to the EnterpriseCourseEnrollment model. diff --git a/enterprise/api/v1/views/enterprise_course_enrollment.py b/enterprise/api/v1/views/enterprise_course_enrollment.py index c7aef5ffc0..59ebf75c01 100644 --- a/enterprise/api/v1/views/enterprise_course_enrollment.py +++ b/enterprise/api/v1/views/enterprise_course_enrollment.py @@ -1,17 +1,68 @@ """ Views for the ``enterprise-course-enrollment`` API endpoint. """ +from django_filters.rest_framework import DjangoFilterBackend +from edx_rest_framework_extensions.paginators import DefaultPagination +from rest_framework import filters + +from django.core.paginator import Paginator +from django.utils.functional import cached_property + from enterprise import models +from enterprise.api.filters import EnterpriseCourseEnrollmentFilterBackend from enterprise.api.v1 import serializers from enterprise.api.v1.views.base_views import EnterpriseReadWriteModelViewSet +try: + from common.djangoapps.util.query import read_replica_or_default +except ImportError: + def read_replica_or_default(): + return None + + +class PaginatorWithOptimizedCount(Paginator): + """ + Django < 4.2 ORM doesn't strip unused annotations from count queries. + + For example, if we execute this query: + + Book.objects.annotate(Count('chapters')).count() + + it will generate SQL like this: + + SELECT COUNT(*) FROM (SELECT COUNT(...), ... FROM ...) subquery + + This isn't optimal on its own, but it's not a big deal. However, this + becomes problematic when annotations use subqueries, because it's terribly + inefficient to execute the subquery for every row in the outer query. + + This class overrides the count() method of Django's Paginator to strip + unused annotations from the query by requesting only the primary key + instead of all fields. + + This is a temporary workaround until Django is updated to 4.2, which will + include a fix for this issue. + + See https://code.djangoproject.com/ticket/32169 for more details. + + TODO: remove this class once Django is updated to 4.2 or higher. + """ + @cached_property + def count(self): + return self.object_list.values("pk").count() + + +class EnterpriseCourseEnrollmentPagination(DefaultPagination): + django_paginator_class = PaginatorWithOptimizedCount + class EnterpriseCourseEnrollmentViewSet(EnterpriseReadWriteModelViewSet): """ API views for the ``enterprise-course-enrollment`` API endpoint. """ - queryset = models.EnterpriseCourseEnrollment.objects.all() + queryset = models.EnterpriseCourseEnrollment.with_additional_fields.all() + filter_backends = (filters.OrderingFilter, DjangoFilterBackend, EnterpriseCourseEnrollmentFilterBackend) USER_ID_FILTER = 'enterprise_customer_user__user_id' FIELDS = ( @@ -20,10 +71,18 @@ class EnterpriseCourseEnrollmentViewSet(EnterpriseReadWriteModelViewSet): filterset_fields = FIELDS ordering_fields = FIELDS + pagination_class = EnterpriseCourseEnrollmentPagination + + def get_queryset(self): + queryset = super().get_queryset() + if self.request.method == 'GET': + queryset = queryset.using(read_replica_or_default()) + return queryset + def get_serializer_class(self): """ Use a special serializer for any requests that aren't read-only. """ if self.request.method in ('GET',): - return serializers.EnterpriseCourseEnrollmentReadOnlySerializer + return serializers.EnterpriseCourseEnrollmentWithAdditionalFieldsReadOnlySerializer return serializers.EnterpriseCourseEnrollmentWriteSerializer diff --git a/enterprise/migrations/0198_alter_enterprisecourseenrollment_options.py b/enterprise/migrations/0198_alter_enterprisecourseenrollment_options.py new file mode 100644 index 0000000000..1c3982cd8f --- /dev/null +++ b/enterprise/migrations/0198_alter_enterprisecourseenrollment_options.py @@ -0,0 +1,17 @@ +# Generated by Django 4.2 on 2023-12-29 17:03 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('enterprise', '0197_auto_20231130_2239'), + ] + + operations = [ + migrations.AlterModelOptions( + name='enterprisecourseenrollment', + options={'ordering': ['id']}, + ), + ] diff --git a/enterprise/models.py b/enterprise/models.py index e3c47734cf..6c2bfec1ca 100644 --- a/enterprise/models.py +++ b/enterprise/models.py @@ -93,6 +93,11 @@ except ImportError: CourseEntitlement = None +try: + from openedx.core.djangoapps.content.course_overviews.models import CourseOverview +except ImportError: + CourseOverview = None + LOGGER = getEnterpriseLogger(__name__) User = auth.get_user_model() mark_safe_lazy = lazy(mark_safe, str) @@ -1857,11 +1862,61 @@ def get_queryset(self): """ Override to return only those enrollment records for which learner is linked to an enterprise. """ + return super().get_queryset().select_related('enterprise_customer_user').filter( enterprise_customer_user__linked=True ) +class EnterpriseCourseEnrollmentWithAdditionalFieldsManager(models.Manager): + """ + Model manager for `EnterpriseCourseEnrollment`. + """ + + def get_queryset(self): + """ + Override to return only those enrollment records for which learner is linked to an enterprise. + """ + + return super().get_queryset().select_related('enterprise_customer_user').filter( + enterprise_customer_user__linked=True + ).annotate(**self._get_additional_data_annotations()) + + def _get_additional_data_annotations(self): + """ + Return annotations with additional data for the queryset. + Additional fields are None in the test environment, where platform models are not available. + """ + + if not CourseEnrollment or not CourseOverview: + return { + 'enrollment_track': models.Value(None, output_field=models.CharField()), + 'enrollment_date': models.Value(None, output_field=models.DateTimeField()), + 'user_email': models.Value(None, output_field=models.EmailField()), + 'course_start': models.Value(None, output_field=models.DateTimeField()), + 'course_end': models.Value(None, output_field=models.DateTimeField()), + } + + enrollment_subquery = CourseEnrollment.objects.filter( + user=models.OuterRef('enterprise_customer_user__user_id'), + course_id=models.OuterRef('course_id'), + ) + user_subquery = auth.get_user_model().objects.filter( + id=models.OuterRef('enterprise_customer_user__user_id'), + ).values('email')[:1] + course_subquery = CourseOverview.objects.filter( + id=models.OuterRef('course_id'), + ) + + return { + 'enrollment_track': models.Subquery(enrollment_subquery.values('mode')[:1]), + 'enrollment_date': models.Subquery(enrollment_subquery.values('created')[:1]), + 'user_email': models.Subquery(user_subquery), + 'course_start': models.Subquery(course_subquery.values('start')[:1]), + 'course_end': models.Subquery(course_subquery.values('end')[:1]), + } + + class EnterpriseCourseEnrollment(TimeStampedModel): """ Store information about the enrollment of a user in a course. @@ -1881,11 +1936,15 @@ class EnterpriseCourseEnrollment(TimeStampedModel): """ objects = EnterpriseCourseEnrollmentManager() + with_additional_fields = EnterpriseCourseEnrollmentWithAdditionalFieldsManager() class Meta: unique_together = (('enterprise_customer_user', 'course_id',),) app_label = 'enterprise' - ordering = ['created'] + # Originally, we were ordering by 'created', but there was never an index on that column. To avoid creating + # an index on that column, we are ordering by 'id' instead, which is indexed by default and is equivalent to + # ordering by 'created' in this case. + ordering = ['id'] enterprise_customer_user = models.ForeignKey( EnterpriseCustomerUser, diff --git a/test_utils/factories.py b/test_utils/factories.py index 3d87059754..811cb5c27a 100644 --- a/test_utils/factories.py +++ b/test_utils/factories.py @@ -27,6 +27,8 @@ EnterpriseCustomerReportingConfiguration, EnterpriseCustomerSsoConfiguration, EnterpriseCustomerUser, + EnterpriseFeatureRole, + EnterpriseFeatureUserRoleAssignment, LearnerCreditEnterpriseCourseEnrollment, LicensedEnterpriseCourseEnrollment, PendingEnrollment, @@ -272,6 +274,39 @@ class Meta: invite_key = None +class EnterpriseFeatureRoleFactory(factory.django.DjangoModelFactory): + """ + EnterpriseFeatureRole factory. + Creates an instance of EnterpriseFeatureRole with minimal boilerplate. + """ + + class Meta: + """ + Meta for EnterpriseFeatureRoleFactory. + """ + + model = EnterpriseFeatureRole + + name = factory.LazyAttribute(lambda x: FAKER.word()) + + +class EnterpriseFeatureUserRoleAssignmentFactory(factory.django.DjangoModelFactory): + """ + EnterpriseFeatureUserRoleAssignment factory. + Creates an instance of EnterpriseFeatureUserRoleAssignment with minimal boilerplate. + """ + + class Meta: + """ + Meta for EnterpriseFeatureUserRoleAssignmentFactory. + """ + + model = EnterpriseFeatureUserRoleAssignment + + role = factory.SubFactory(EnterpriseFeatureRoleFactory) + user = factory.SubFactory(UserFactory) + + class AnonymousUserFactory(factory.Factory): """ Anonymous User factory. diff --git a/tests/test_enterprise/api/test_filters.py b/tests/test_enterprise/api/test_filters.py index b37b90790c..1076c58f4b 100644 --- a/tests/test_enterprise/api/test_filters.py +++ b/tests/test_enterprise/api/test_filters.py @@ -10,7 +10,8 @@ from django.conf import settings -from enterprise.constants import ENTERPRISE_ADMIN_ROLE +from enterprise.constants import ENTERPRISE_ADMIN_ROLE, ENTERPRISE_ENROLLMENT_API_ADMIN_ROLE +from enterprise.models import EnterpriseFeatureRole from test_utils import FAKE_UUIDS, TEST_EMAIL, TEST_USERNAME, APITest, factories ENTERPRISE_CUSTOMER_LIST_ENDPOINT = reverse('enterprise-customer-list') @@ -80,6 +81,73 @@ def test_filter_for_detail(self, is_staff, is_linked, expected_content_in_respon assert data[key] == value +@ddt.ddt +@mark.django_db +class TestEnterpriseCourseEnrollmentFilterBackend(APITest): + """ + Test suite for the ``EnterpriseCourseEnrollmentFilterBackend`` filter. + """ + + def setUp(self): + super().setUp() + + self._setup_enterprise_customer_and_enrollments( + uuid=FAKE_UUIDS[0], + users=[self.user, factories.UserFactory()] + ) + self._setup_enterprise_customer_and_enrollments( + uuid=FAKE_UUIDS[1], + users=[factories.UserFactory(), factories.UserFactory()] + ) + + self.url = settings.TEST_SERVER + reverse('enterprise-course-enrollment-list') + + def _setup_enterprise_customer_and_enrollments(self, uuid, users): + """ + Creates an enterprise customer with the uuid and enrolls passed users. + """ + enterprise_customer = factories.EnterpriseCustomerFactory(uuid=uuid) + + for user in users: + enterprise_customer_user = factories.EnterpriseCustomerUserFactory( + enterprise_customer=enterprise_customer, + user_id=user.id + ) + factories.EnterpriseCourseEnrollmentFactory( + enterprise_customer_user=enterprise_customer_user + ) + + def _setup_user_privileges_by_role(self, user, role): + """ + Sets up privileges for the passed user based on the role. + """ + if role == "staff": + user.is_staff = True + user.save() + elif role == "enrollment_api_admin": + factories.EnterpriseFeatureUserRoleAssignmentFactory( + user=user, + role=EnterpriseFeatureRole.objects.get(name=ENTERPRISE_ENROLLMENT_API_ADMIN_ROLE) + ) + + @ddt.data( + ("regular", 1), + ("enrollment_api_admin", 2), + ("staff", 4), + ) + @ddt.unpack + def test_filter_for_list(self, user_role, expected_course_enrollment_count): + """ + Filter objects based off whether the user is a staff, enterprise enrollment api admin, or neither. + """ + self._setup_user_privileges_by_role(self.user, user_role) + + response = self.client.get(self.url) + assert response.status_code == status.HTTP_200_OK + data = self.load_json(response.content) + assert len(data['results']) == expected_course_enrollment_count + + @ddt.ddt @mark.django_db class TestEnterpriseCustomerUserFilterBackend(APITest): diff --git a/tests/test_enterprise/api/test_views.py b/tests/test_enterprise/api/test_views.py index aa41038a9a..6383f3dce1 100644 --- a/tests/test_enterprise/api/test_views.py +++ b/tests/test_enterprise/api/test_views.py @@ -1276,6 +1276,11 @@ class TestEnterpriseCustomerViewSet(BaseTestEnterpriseAPIViews): 'course_id': 'course-v1:edX+DemoX+DemoCourse', 'created': '2021-10-20T19:01:31Z', 'unenrolled_at': None, + 'enrollment_date': None, + 'enrollment_track': None, + 'user_email': None, + 'course_start': None, + 'course_end': None, }], ), ( From 098203ac826b6bf7e1e1fbb82afd9283d04fae59 Mon Sep 17 00:00:00 2001 From: MueezKhan246 <93375917+MueezKhan246@users.noreply.github.com> Date: Wed, 17 Jan 2024 14:24:02 +0500 Subject: [PATCH 083/164] Added json field in learner transmission audit to record 3 most response status information (#1986) * feat: added json field in learner transmission audit to record 3 most latest response statuses --- CHANGELOG.rst | 5 ++ enterprise/__init__.py | 2 +- .../migrations/0018_auto_20240110_1301.py | 23 +++++++++ .../migrations/0033_auto_20240110_1301.py | 23 +++++++++ ...tatransmissionaudit_transmission_status.py | 18 +++++++ ...tatransmissionaudit_transmission_status.py | 18 +++++++ ...tatransmissionaudit_transmission_status.py | 18 +++++++ .../integrated_channel/constants.py | 1 + ...tatransmissionaudit_transmission_status.py | 18 +++++++ .../integrated_channel/models.py | 18 +++++++ .../transmitters/learner_data.py | 1 + ...tatransmissionaudit_transmission_status.py | 18 +++++++ ...tatransmissionaudit_transmission_status.py | 18 +++++++ ...tatransmissionaudit_transmission_status.py | 18 +++++++ .../test_transmitters/test_learner_data.py | 51 ++++++++++++++++++- 15 files changed, 248 insertions(+), 2 deletions(-) create mode 100644 integrated_channels/blackboard/migrations/0018_auto_20240110_1301.py create mode 100644 integrated_channels/canvas/migrations/0033_auto_20240110_1301.py create mode 100644 integrated_channels/cornerstone/migrations/0032_cornerstonelearnerdatatransmissionaudit_transmission_status.py create mode 100644 integrated_channels/degreed/migrations/0032_degreedlearnerdatatransmissionaudit_transmission_status.py create mode 100644 integrated_channels/degreed2/migrations/0024_degreed2learnerdatatransmissionaudit_transmission_status.py create mode 100644 integrated_channels/integrated_channel/migrations/0031_genericlearnerdatatransmissionaudit_transmission_status.py create mode 100644 integrated_channels/moodle/migrations/0031_moodlelearnerdatatransmissionaudit_transmission_status.py create mode 100644 integrated_channels/sap_success_factors/migrations/0015_sapsuccessfactorslearnerdatatransmissionaudit_transmission_status.py create mode 100644 integrated_channels/xapi/migrations/0012_xapilearnerdatatransmissionaudit_transmission_status.py diff --git a/CHANGELOG.rst b/CHANGELOG.rst index ae6419f8eb..359824ebb3 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -16,6 +16,11 @@ Change Log Unreleased ---------- +[4.10.1] +-------- + +feat: added json field in learner transmission audit to record 3 most latest response statuses + [4.10.0] -------- diff --git a/enterprise/__init__.py b/enterprise/__init__.py index b3a0ce8824..9fe7d26ac9 100644 --- a/enterprise/__init__.py +++ b/enterprise/__init__.py @@ -2,4 +2,4 @@ Your project description goes here. """ -__version__ = "4.10.0" +__version__ = "4.10.1" diff --git a/integrated_channels/blackboard/migrations/0018_auto_20240110_1301.py b/integrated_channels/blackboard/migrations/0018_auto_20240110_1301.py new file mode 100644 index 0000000000..79c0ef22ba --- /dev/null +++ b/integrated_channels/blackboard/migrations/0018_auto_20240110_1301.py @@ -0,0 +1,23 @@ +# Generated by Django 3.2.23 on 2024-01-10 13:01 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('blackboard', '0017_alter_historicalblackboardenterprisecustomerconfiguration_options'), + ] + + operations = [ + migrations.AddField( + model_name='blackboardlearnerassessmentdatatransmissionaudit', + name='transmission_status', + field=models.JSONField(blank=True, default=list, null=True), + ), + migrations.AddField( + model_name='blackboardlearnerdatatransmissionaudit', + name='transmission_status', + field=models.JSONField(blank=True, default=list, null=True), + ), + ] diff --git a/integrated_channels/canvas/migrations/0033_auto_20240110_1301.py b/integrated_channels/canvas/migrations/0033_auto_20240110_1301.py new file mode 100644 index 0000000000..3c4ab2ff5d --- /dev/null +++ b/integrated_channels/canvas/migrations/0033_auto_20240110_1301.py @@ -0,0 +1,23 @@ +# Generated by Django 3.2.23 on 2024-01-10 13:01 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('canvas', '0032_alter_historicalcanvasenterprisecustomerconfiguration_options'), + ] + + operations = [ + migrations.AddField( + model_name='canvaslearnerassessmentdatatransmissionaudit', + name='transmission_status', + field=models.JSONField(blank=True, default=list, null=True), + ), + migrations.AddField( + model_name='canvaslearnerdatatransmissionaudit', + name='transmission_status', + field=models.JSONField(blank=True, default=list, null=True), + ), + ] diff --git a/integrated_channels/cornerstone/migrations/0032_cornerstonelearnerdatatransmissionaudit_transmission_status.py b/integrated_channels/cornerstone/migrations/0032_cornerstonelearnerdatatransmissionaudit_transmission_status.py new file mode 100644 index 0000000000..b606802498 --- /dev/null +++ b/integrated_channels/cornerstone/migrations/0032_cornerstonelearnerdatatransmissionaudit_transmission_status.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.23 on 2024-01-16 07:30 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('cornerstone', '0031_cornerstoneapirequestlogs'), + ] + + operations = [ + migrations.AddField( + model_name='cornerstonelearnerdatatransmissionaudit', + name='transmission_status', + field=models.JSONField(blank=True, default=list, null=True), + ), + ] diff --git a/integrated_channels/degreed/migrations/0032_degreedlearnerdatatransmissionaudit_transmission_status.py b/integrated_channels/degreed/migrations/0032_degreedlearnerdatatransmissionaudit_transmission_status.py new file mode 100644 index 0000000000..4bfd135b84 --- /dev/null +++ b/integrated_channels/degreed/migrations/0032_degreedlearnerdatatransmissionaudit_transmission_status.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.23 on 2024-01-10 13:01 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('degreed', '0031_alter_historicaldegreedenterprisecustomerconfiguration_options'), + ] + + operations = [ + migrations.AddField( + model_name='degreedlearnerdatatransmissionaudit', + name='transmission_status', + field=models.JSONField(blank=True, default=list, null=True), + ), + ] diff --git a/integrated_channels/degreed2/migrations/0024_degreed2learnerdatatransmissionaudit_transmission_status.py b/integrated_channels/degreed2/migrations/0024_degreed2learnerdatatransmissionaudit_transmission_status.py new file mode 100644 index 0000000000..0f367ac477 --- /dev/null +++ b/integrated_channels/degreed2/migrations/0024_degreed2learnerdatatransmissionaudit_transmission_status.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.23 on 2024-01-10 13:01 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('degreed2', '0023_alter_historicaldegreed2enterprisecustomerconfiguration_options'), + ] + + operations = [ + migrations.AddField( + model_name='degreed2learnerdatatransmissionaudit', + name='transmission_status', + field=models.JSONField(blank=True, default=list, null=True), + ), + ] diff --git a/integrated_channels/integrated_channel/constants.py b/integrated_channels/integrated_channel/constants.py index 8636e653ab..7f0e33189c 100644 --- a/integrated_channels/integrated_channel/constants.py +++ b/integrated_channels/integrated_channel/constants.py @@ -4,3 +4,4 @@ ISO_8601_DATE_FORMAT = '%Y-%m-%dT%H:%M:%S.%fZ' TASK_LOCK_EXPIRY_SECONDS = 60 * 60 * 12 +TRANSMISSION_STATUS_RECORDS_LIMIT = 3 diff --git a/integrated_channels/integrated_channel/migrations/0031_genericlearnerdatatransmissionaudit_transmission_status.py b/integrated_channels/integrated_channel/migrations/0031_genericlearnerdatatransmissionaudit_transmission_status.py new file mode 100644 index 0000000000..843c65d649 --- /dev/null +++ b/integrated_channels/integrated_channel/migrations/0031_genericlearnerdatatransmissionaudit_transmission_status.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.23 on 2024-01-16 07:59 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('integrated_channel', '0030_integratedchannelapirequestlogs'), + ] + + operations = [ + migrations.AddField( + model_name='genericlearnerdatatransmissionaudit', + name='transmission_status', + field=models.JSONField(blank=True, default=list, null=True), + ), + ] diff --git a/integrated_channels/integrated_channel/models.py b/integrated_channels/integrated_channel/models.py index 0d2a26f4e3..cc0d60fc64 100644 --- a/integrated_channels/integrated_channel/models.py +++ b/integrated_channels/integrated_channel/models.py @@ -12,6 +12,7 @@ from django.db import models from django.db.models import Q from django.db.models.query import QuerySet +from django.utils import timezone from django.utils.translation import gettext_lazy as _ from model_utils.models import TimeStampedModel @@ -19,6 +20,7 @@ from enterprise.constants import TRANSMISSION_MARK_CREATE, TRANSMISSION_MARK_DELETE, TRANSMISSION_MARK_UPDATE from enterprise.models import EnterpriseCustomer, EnterpriseCustomerCatalog from enterprise.utils import localized_utcnow +from integrated_channels.integrated_channel.constants import TRANSMISSION_STATUS_RECORDS_LIMIT from integrated_channels.integrated_channel.exporters.content_metadata import ContentMetadataExporter from integrated_channels.integrated_channel.exporters.learner_data import LearnerExporter from integrated_channels.integrated_channel.transmitters.content_metadata import ContentMetadataTransmitter @@ -531,6 +533,8 @@ class LearnerDataTransmissionAudit(TimeStampedModel): help_text=_('Data pertaining to the transmissions API request response.') ) + transmission_status = models.JSONField(default=list, blank=True, null=True) + class Meta: abstract = True app_label = 'integrated_channel' @@ -603,6 +607,20 @@ def _payload_data(self): 'grade': self.grade, } + def add_transmission_status(self, status_code, error_message): + """ + Append the new entry to the list, keeping the list limited to latest three entries. + """ + new_entry = { + 'timestamp': timezone.now().isoformat(), + 'Status_code': status_code, + 'error_message': error_message, + } + + self.transmission_status.append(new_entry) + + self.transmission_status = self.transmission_status[-TRANSMISSION_STATUS_RECORDS_LIMIT:] + class GenericLearnerDataTransmissionAudit(LearnerDataTransmissionAudit): """ diff --git a/integrated_channels/integrated_channel/transmitters/learner_data.py b/integrated_channels/integrated_channel/transmitters/learner_data.py index 09398ce39b..23208d49ab 100644 --- a/integrated_channels/integrated_channel/transmitters/learner_data.py +++ b/integrated_channels/integrated_channel/transmitters/learner_data.py @@ -380,6 +380,7 @@ def transmit(self, payload, **kwargs): # pylint: disable=arguments-differ was_successful = code < 300 learner_data.status = str(code) learner_data.error_message = body if not was_successful else '' + learner_data.add_transmission_status(learner_data.status, learner_data.error_message) learner_data.save() self.enterprise_configuration.update_learner_synced_at(action_happened_at, was_successful) diff --git a/integrated_channels/moodle/migrations/0031_moodlelearnerdatatransmissionaudit_transmission_status.py b/integrated_channels/moodle/migrations/0031_moodlelearnerdatatransmissionaudit_transmission_status.py new file mode 100644 index 0000000000..c5ce2da304 --- /dev/null +++ b/integrated_channels/moodle/migrations/0031_moodlelearnerdatatransmissionaudit_transmission_status.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.23 on 2024-01-15 08:47 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('moodle', '0030_merge_0028_auto_20231116_1826_0029_auto_20231106_1233'), + ] + + operations = [ + migrations.AddField( + model_name='moodlelearnerdatatransmissionaudit', + name='transmission_status', + field=models.JSONField(blank=True, default=list, null=True), + ), + ] diff --git a/integrated_channels/sap_success_factors/migrations/0015_sapsuccessfactorslearnerdatatransmissionaudit_transmission_status.py b/integrated_channels/sap_success_factors/migrations/0015_sapsuccessfactorslearnerdatatransmissionaudit_transmission_status.py new file mode 100644 index 0000000000..e2a2af9165 --- /dev/null +++ b/integrated_channels/sap_success_factors/migrations/0015_sapsuccessfactorslearnerdatatransmissionaudit_transmission_status.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.23 on 2024-01-10 13:01 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('sap_success_factors', '0014_alter_sapsuccessfactorsenterprisecustomerconfiguration_show_course_price'), + ] + + operations = [ + migrations.AddField( + model_name='sapsuccessfactorslearnerdatatransmissionaudit', + name='transmission_status', + field=models.JSONField(blank=True, default=list, null=True), + ), + ] diff --git a/integrated_channels/xapi/migrations/0012_xapilearnerdatatransmissionaudit_transmission_status.py b/integrated_channels/xapi/migrations/0012_xapilearnerdatatransmissionaudit_transmission_status.py new file mode 100644 index 0000000000..1ce5515876 --- /dev/null +++ b/integrated_channels/xapi/migrations/0012_xapilearnerdatatransmissionaudit_transmission_status.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.23 on 2024-01-10 13:01 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('xapi', '0011_alter_xapilearnerdatatransmissionaudit_index_together'), + ] + + operations = [ + migrations.AddField( + model_name='xapilearnerdatatransmissionaudit', + name='transmission_status', + field=models.JSONField(blank=True, default=list, null=True), + ), + ] diff --git a/tests/test_integrated_channels/test_integrated_channel/test_transmitters/test_learner_data.py b/tests/test_integrated_channels/test_integrated_channel/test_transmitters/test_learner_data.py index 0d2b7345a2..a04161a274 100644 --- a/tests/test_integrated_channels/test_integrated_channel/test_transmitters/test_learner_data.py +++ b/tests/test_integrated_channels/test_integrated_channel/test_transmitters/test_learner_data.py @@ -1,7 +1,7 @@ """ Tests for the base learner data transmitter. """ - +import datetime import unittest from unittest import mock from unittest.mock import MagicMock, Mock @@ -9,9 +9,12 @@ import ddt from pytest import mark +from integrated_channels.integrated_channel.constants import TRANSMISSION_STATUS_RECORDS_LIMIT from integrated_channels.integrated_channel.exporters.learner_data import LearnerExporter from integrated_channels.integrated_channel.tasks import transmit_single_learner_data from integrated_channels.integrated_channel.transmitters.learner_data import LearnerTransmitter +from integrated_channels.sap_success_factors.models import SapSuccessFactorsLearnerDataTransmissionAudit +from integrated_channels.sap_success_factors.transmitters import learner_data from test_utils import factories @@ -26,6 +29,13 @@ def setUp(self): super().setUp() enterprise_customer = factories.EnterpriseCustomerFactory(name='Starfleet Academy') + self.enterprise_customer_user = factories.EnterpriseCustomerUserFactory( + enterprise_customer=enterprise_customer, + ) + self.enterprise_course_enrollment = factories.EnterpriseCourseEnrollmentFactory( + id=5, + enterprise_customer_user=self.enterprise_customer_user, + ) # We need some non-abstract configuration for these things to work, # so it's okay for it to be any arbitrary channel. We randomly choose SAPSF. @@ -37,6 +47,25 @@ def setUp(self): sapsf_user_id="user_id", secret="client_secret", ) + self.payload = SapSuccessFactorsLearnerDataTransmissionAudit( + enterprise_course_enrollment_id=self.enterprise_course_enrollment.id, + course_id='course-v1:edX+DemoX+DemoCourse', + course_completed=True, + sap_completed_timestamp=1486855998, + completed_timestamp=datetime.datetime.fromtimestamp(1486855998), + total_hours=1.0, + grade=.9, + ) + self.exporter = lambda payloads=self.payload: mock.MagicMock( + export=mock.MagicMock(return_value=iter(payloads)) + ) + # Mocks + create_course_completion_mock = mock.patch( + 'integrated_channels.sap_success_factors.client.SAPSuccessFactorsAPIClient.create_course_completion' + ) + + self.create_course_completion_mock = create_course_completion_mock.start() + self.addCleanup(create_course_completion_mock.stop) self.learner_transmitter = LearnerTransmitter(self.enterprise_config) @@ -193,3 +222,23 @@ def test_learner_data_transmission_dry_run_mode(self, already_transmitted_mock, ) # with dry_run_mode_enabled = True we shouldn't be able to call this method assert not self.learner_transmitter.client.create_assessment_reporting.called + + def test_transmission_status_learner_data_transmission(self): + """ + Test that transmission status records three most recent status instances. + """ + self.create_course_completion_mock.return_value = 200, '' + + transmitter = learner_data.SapSuccessFactorsLearnerTransmitter(self.enterprise_config) + for _ in range(TRANSMISSION_STATUS_RECORDS_LIMIT + 1): + if _ == TRANSMISSION_STATUS_RECORDS_LIMIT: + self.create_course_completion_mock.return_value = 400, '{"error":{"code":null,"message":"Invalid value for property \'courseCompleted\'."}}' + transmitter.transmit(self.exporter([self.payload])) + actual_transmission_status = self.payload.transmission_status + + expected_transmission_status = [ + {'timestamp': mock.ANY, 'Status_code': '200', 'error_message': ''}, + {'timestamp': mock.ANY, 'Status_code': '200', 'error_message': ''}, + {'timestamp': mock.ANY, 'Status_code': '400', 'error_message': 'Client create_course_completion failed: {"error":{"code":null,"message":"Invalid value for property \'courseCompleted\'."}}'}, + ] + assert expected_transmission_status == actual_transmission_status From 82c2888ad7b9aee5025cef92239d655fea54d7a0 Mon Sep 17 00:00:00 2001 From: MueezKhan246 <93375917+MueezKhan246@users.noreply.github.com> Date: Wed, 17 Jan 2024 18:25:11 +0500 Subject: [PATCH 084/164] Replaced unencrypted columns of user data credentials in moodle config (#1988) * feat: removed unencrypted user credentials data columns --- CHANGELOG.rst | 6 +++ enterprise/__init__.py | 2 +- integrated_channels/moodle/client.py | 18 ++------- .../migrations/0032_auto_20240117_1202.py | 37 +++++++++++++++++++ integrated_channels/moodle/models.py | 27 -------------- .../test_api/test_moodle/test_views.py | 6 --- .../test_moodle/test_client.py | 6 --- .../test_exporters/test_learner_data.py | 6 +-- .../test_content_metadata.py | 3 -- .../test_transmitters/test_learner_data.py | 3 -- 10 files changed, 50 insertions(+), 64 deletions(-) create mode 100644 integrated_channels/moodle/migrations/0032_auto_20240117_1202.py diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 359824ebb3..8b2252a1e7 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -16,6 +16,12 @@ Change Log Unreleased ---------- +[4.10.2] +-------- + +feat: removed unencrypted user credentials data columns + + [4.10.1] -------- diff --git a/enterprise/__init__.py b/enterprise/__init__.py index 9fe7d26ac9..1fff1428d6 100644 --- a/enterprise/__init__.py +++ b/enterprise/__init__.py @@ -2,4 +2,4 @@ Your project description goes here. """ -__version__ = "4.10.1" +__version__ = "4.10.2" diff --git a/integrated_channels/moodle/client.py b/integrated_channels/moodle/client.py index 9226c524bf..43eae8e4f6 100644 --- a/integrated_channels/moodle/client.py +++ b/integrated_channels/moodle/client.py @@ -10,7 +10,6 @@ import requests from django.apps import apps -from django.conf import settings from integrated_channels.exceptions import ClientError from integrated_channels.integrated_channel.client import IntegratedChannelApiClient @@ -135,12 +134,7 @@ def __init__(self, enterprise_configuration): """ super().__init__(enterprise_configuration) self.config = apps.get_app_config('moodle') - token = ( - enterprise_configuration.decrypted_token - if getattr(settings, 'FEATURES', {}).get('USE_ENCRYPTED_USER_DATA', False) - else enterprise_configuration.token - ) - self.token = token or self._get_access_token() + self.token = enterprise_configuration.decrypted_token or self._get_access_token() self.api_url = urljoin(self.enterprise_configuration.moodle_base_url, self.MOODLE_API_PATH) def _post(self, additional_params): @@ -180,12 +174,6 @@ def _get_access_token(self): 'service': self.enterprise_configuration.service_short_name } - decrypted_username = self.enterprise_configuration.decrypted_username - username = self.enterprise_configuration.username - decrypted_password = self.enterprise_configuration.decrypted_password - password = self.enterprise_configuration.password - use_encrypted_user_data = getattr(settings, 'FEATURES', {}).get('USE_ENCRYPTED_USER_DATA', False) - response = requests.post( urljoin( self.enterprise_configuration.moodle_base_url, @@ -196,8 +184,8 @@ def _get_access_token(self): 'Content-Type': 'application/x-www-form-urlencoded', }, data={ - "username": decrypted_username if use_encrypted_user_data else username, - "password": decrypted_password if use_encrypted_user_data else password, + "username": self.enterprise_configuration.decrypted_username, + "password": self.enterprise_configuration.decrypted_password, }, ) diff --git a/integrated_channels/moodle/migrations/0032_auto_20240117_1202.py b/integrated_channels/moodle/migrations/0032_auto_20240117_1202.py new file mode 100644 index 0000000000..7a445ec62a --- /dev/null +++ b/integrated_channels/moodle/migrations/0032_auto_20240117_1202.py @@ -0,0 +1,37 @@ +# Generated by Django 3.2.23 on 2024-01-17 12:02 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('moodle', '0031_moodlelearnerdatatransmissionaudit_transmission_status'), + ] + + operations = [ + migrations.RemoveField( + model_name='historicalmoodleenterprisecustomerconfiguration', + name='password', + ), + migrations.RemoveField( + model_name='historicalmoodleenterprisecustomerconfiguration', + name='token', + ), + migrations.RemoveField( + model_name='historicalmoodleenterprisecustomerconfiguration', + name='username', + ), + migrations.RemoveField( + model_name='moodleenterprisecustomerconfiguration', + name='password', + ), + migrations.RemoveField( + model_name='moodleenterprisecustomerconfiguration', + name='token', + ), + migrations.RemoveField( + model_name='moodleenterprisecustomerconfiguration', + name='username', + ), + ] diff --git a/integrated_channels/moodle/models.py b/integrated_channels/moodle/models.py index 83442f4d44..61c15b360a 100644 --- a/integrated_channels/moodle/models.py +++ b/integrated_channels/moodle/models.py @@ -57,15 +57,6 @@ class MoodleEnterpriseCustomerConfiguration(EnterpriseCustomerPluginConfiguratio ) ) - username = models.CharField( - max_length=255, - verbose_name="Webservice Username", - blank=True, - help_text=_( - "The API user's username used to obtain new tokens." - ) - ) - decrypted_username = EncryptedCharField( max_length=255, verbose_name="Encrypted Webservice Username", @@ -100,15 +91,6 @@ def encrypted_username(self, value): """ self.decrypted_username = value - password = models.CharField( - max_length=255, - blank=True, - verbose_name="Webservice Password", - help_text=_( - "The API user's password used to obtain new tokens." - ) - ) - decrypted_password = EncryptedCharField( max_length=255, verbose_name="Encrypted Webservice Password", @@ -143,15 +125,6 @@ def encrypted_password(self, value): """ self.decrypted_password = value - token = models.CharField( - max_length=255, - blank=True, - verbose_name="Webservice User Token", - help_text=_( - "The user's token for the Moodle webservice." - ) - ) - decrypted_token = EncryptedCharField( max_length=255, verbose_name="Encrypted Webservice Token", diff --git a/tests/test_integrated_channels/test_api/test_moodle/test_views.py b/tests/test_integrated_channels/test_api/test_moodle/test_views.py index ef420739e9..7deaac0de3 100644 --- a/tests/test_integrated_channels/test_api/test_moodle/test_views.py +++ b/tests/test_integrated_channels/test_api/test_moodle/test_views.py @@ -142,9 +142,6 @@ def test_is_valid_field(self, mock_current_request): self.moodle_config.decrypted_token = '' self.moodle_config.decrypted_username = '' self.moodle_config.decrypted_password = '' - self.moodle_config.token = '' - self.moodle_config.username = '' - self.moodle_config.password = '' self.moodle_config.moodle_base_url = '' self.moodle_config.service_short_name = '' self.moodle_config.save() @@ -158,9 +155,6 @@ def test_is_valid_field(self, mock_current_request): self.moodle_config.decrypted_username = 'lmao' self.moodle_config.decrypted_password = 'foobar' self.moodle_config.decrypted_token = 'baa' - self.moodle_config.username = 'lmao' - self.moodle_config.password = 'foobar' - self.moodle_config.token = 'baa' self.moodle_config.moodle_base_url = 'http://lovely.com' self.moodle_config.service_short_name = 'short' self.moodle_config.display_name = '1234!@#$' diff --git a/tests/test_integrated_channels/test_moodle/test_client.py b/tests/test_integrated_channels/test_moodle/test_client.py index c0f07a2eda..dd0f770b99 100644 --- a/tests/test_integrated_channels/test_moodle/test_client.py +++ b/tests/test_integrated_channels/test_moodle/test_client.py @@ -82,18 +82,12 @@ def setUp(self): decrypted_username=self.user, decrypted_password=self.password, decrypted_token=self.token, - username=self.user, - password=self.password, - token=self.token, ) self.enterprise_custom_config = factories.MoodleEnterpriseCustomerConfigurationFactory( moodle_base_url=self.custom_moodle_base_url, decrypted_username=self.user, decrypted_password=self.password, decrypted_token=self.token, - username=self.user, - password=self.password, - token=self.token, grade_scale=10, grade_assignment_name='edX Grade Test' ) diff --git a/tests/test_integrated_channels/test_moodle/test_exporters/test_learner_data.py b/tests/test_integrated_channels/test_moodle/test_exporters/test_learner_data.py index 6b27e4cd16..0ab89e387a 100644 --- a/tests/test_integrated_channels/test_moodle/test_exporters/test_learner_data.py +++ b/tests/test_integrated_channels/test_moodle/test_exporters/test_learner_data.py @@ -30,9 +30,9 @@ def setUp(self): moodle_base_url='foobar', service_short_name='shortname', category_id=1, - username='username', - password='password', - token='token', + decrypted_username='username', + decrypted_password='password', + decrypted_token='token', ) @mock.patch('enterprise.api_client.discovery.CourseCatalogApiServiceClient') diff --git a/tests/test_integrated_channels/test_moodle/test_transmitters/test_content_metadata.py b/tests/test_integrated_channels/test_moodle/test_transmitters/test_content_metadata.py index 46f0a8d21f..9a5311a1ec 100644 --- a/tests/test_integrated_channels/test_moodle/test_transmitters/test_content_metadata.py +++ b/tests/test_integrated_channels/test_moodle/test_transmitters/test_content_metadata.py @@ -36,9 +36,6 @@ def setUp(self): decrypted_username=self.user, decrypted_password=self.password, decrypted_token=self.api_token, - username=self.user, - password=self.password, - token=self.api_token, ) def test_prepare_items_for_transmission(self): diff --git a/tests/test_integrated_channels/test_moodle/test_transmitters/test_learner_data.py b/tests/test_integrated_channels/test_moodle/test_transmitters/test_learner_data.py index c55e0b6960..4928860943 100644 --- a/tests/test_integrated_channels/test_moodle/test_transmitters/test_learner_data.py +++ b/tests/test_integrated_channels/test_moodle/test_transmitters/test_learner_data.py @@ -39,9 +39,6 @@ def setUp(self): decrypted_username='username', decrypted_password='password', decrypted_token='token', - username='username', - password='password', - token='token', ) self.payload = MoodleLearnerDataTransmissionAudit( moodle_user_email=self.enterprise_customer.contact_email, From 6b761e7cc013d88e9e9675f01f659bba9ed15ced Mon Sep 17 00:00:00 2001 From: edX requirements bot Date: Mon, 15 Jan 2024 19:27:42 -0500 Subject: [PATCH 085/164] chore: Updating Python Requirements --- requirements/celery53.txt | 6 +- requirements/ci.txt | 7 +- requirements/common_constraints.txt | 4 - requirements/dev.txt | 115 +++++++------- requirements/django.txt | 2 +- requirements/doc.txt | 89 ++++++----- requirements/edx-platform-constraints.txt | 177 ++++++++++------------ requirements/js_test.txt | 32 ++-- requirements/test-master.txt | 75 +++++---- requirements/test.txt | 81 +++++----- 10 files changed, 281 insertions(+), 307 deletions(-) diff --git a/requirements/celery53.txt b/requirements/celery53.txt index f5e6e328aa..71b29858a8 100644 --- a/requirements/celery53.txt +++ b/requirements/celery53.txt @@ -1,9 +1,9 @@ amqp==5.2.0 billiard==4.2.0 -celery==5.3.4 +celery==5.3.6 click==8.1.7 click-didyoumean==0.3.0 click-repl==0.3.0 -kombu==5.3.3 -prompt-toolkit==3.0.39 +kombu==5.3.5 +prompt-toolkit==3.0.43 vine==5.1.0 diff --git a/requirements/ci.txt b/requirements/ci.txt index a055e8ef41..a7bca04d76 100644 --- a/requirements/ci.txt +++ b/requirements/ci.txt @@ -4,7 +4,7 @@ # # make upgrade # -distlib==0.3.7 +distlib==0.3.8 # via virtualenv filelock==3.13.1 # via @@ -12,7 +12,7 @@ filelock==3.13.1 # virtualenv packaging==23.2 # via tox -platformdirs==3.11.0 +platformdirs==4.1.0 # via virtualenv pluggy==1.3.0 # via tox @@ -24,8 +24,7 @@ tomli==2.0.1 # via tox tox==3.28.0 # via - # -c requirements/common_constraints.txt # -c requirements/constraints.txt # -r requirements/ci.in -virtualenv==20.24.6 +virtualenv==20.25.0 # via tox diff --git a/requirements/common_constraints.txt b/requirements/common_constraints.txt index 08e94f34dd..be61b7e0ed 100644 --- a/requirements/common_constraints.txt +++ b/requirements/common_constraints.txt @@ -21,7 +21,3 @@ Django<4.0 elasticsearch<7.14.0 # django-simple-history>3.0.0 adds indexing and causes a lot of migrations to be affected - -# tox>4.0.0 isn't yet compatible with many tox plugins, causing CI failures in almost all repos. -# Details can be found in this discussion: https://github.com/tox-dev/tox/discussions/1810 -tox<4.0.0 diff --git a/requirements/dev.txt b/requirements/dev.txt index fbe87b0ec8..fd13b2ccc9 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -8,7 +8,7 @@ accessible-pygments==0.0.4 # via # -r requirements/doc.txt # pydata-sphinx-theme -aiohttp==3.8.6 +aiohttp==3.9.1 # via # -r requirements/doc.txt # -r requirements/test-master.txt @@ -48,9 +48,8 @@ asn1crypto==1.5.1 # -r requirements/doc.txt # -r requirements/test-master.txt # -r requirements/test.txt - # oscrypto # snowflake-connector-python -astroid==3.0.1 +astroid==3.0.2 # via # pylint # pylint-celery @@ -67,7 +66,7 @@ attrs==23.1.0 # -r requirements/test.txt # aiohttp # pytest -babel==2.13.1 +babel==2.14.0 # via # -r requirements/doc.txt # pydata-sphinx-theme @@ -77,6 +76,7 @@ backports-zoneinfo[tzdata]==0.2.1 # -r requirements/doc.txt # -r requirements/test-master.txt # -r requirements/test.txt + # backports-zoneinfo # celery # kombu beautifulsoup4==4.12.2 @@ -96,13 +96,13 @@ bleach==6.1.0 # -r requirements/test.txt build==1.0.3 # via pip-tools -celery==5.3.4 +celery==5.3.6 # via # -c requirements/constraints.txt # -r requirements/doc.txt # -r requirements/test-master.txt # -r requirements/test.txt -certifi==2023.7.22 +certifi==2023.11.17 # via # -r requirements/doc.txt # -r requirements/test-master.txt @@ -126,7 +126,6 @@ charset-normalizer==2.0.12 # -r requirements/doc.txt # -r requirements/test-master.txt # -r requirements/test.txt - # aiohttp # requests # snowflake-connector-python click==8.1.7 @@ -183,9 +182,10 @@ coreschema==0.0.4 # -r requirements/test.txt # coreapi # drf-yasg -coverage[toml]==7.3.2 +coverage[toml]==7.4.0 # via # -r requirements/test.txt + # coverage # pytest-cov cryptography==38.0.4 # via @@ -212,13 +212,13 @@ deprecated==1.2.14 # -r requirements/test-master.txt # -r requirements/test.txt # jwcrypto -diff-cover==8.0.0 +diff-cover==8.0.2 # via -r requirements/test.txt dill==0.3.7 # via pylint -distlib==0.3.7 +distlib==0.3.8 # via virtualenv -django==3.2.22 +django==3.2.23 # via # -c requirements/common_constraints.txt # -r requirements/doc.txt @@ -270,12 +270,12 @@ django-fernet-fields-v2==0.9 # -r requirements/doc.txt # -r requirements/test-master.txt # -r requirements/test.txt -django-filter==23.3 +django-filter==23.5 # via # -r requirements/doc.txt # -r requirements/test-master.txt # -r requirements/test.txt -django-ipware==5.0.1 +django-ipware==6.0.2 # via # -r requirements/doc.txt # -r requirements/test-master.txt @@ -307,7 +307,7 @@ django-simple-history==3.1.1 # -r requirements/doc.txt # -r requirements/test-master.txt # -r requirements/test.txt -django-waffle==4.0.0 +django-waffle==4.1.0 # via # -r requirements/doc.txt # -r requirements/test-master.txt @@ -362,7 +362,7 @@ edx-braze-client==0.1.8 # -r requirements/doc.txt # -r requirements/test-master.txt # -r requirements/test.txt -edx-django-utils==5.7.0 +edx-django-utils==5.9.0 # via # -r requirements/doc.txt # -r requirements/test-master.txt @@ -371,7 +371,7 @@ edx-django-utils==5.7.0 # edx-drf-extensions # edx-rest-api-client # edx-toggles -edx-drf-extensions==8.12.0 +edx-drf-extensions==9.1.2 # via # -r requirements/doc.txt # -r requirements/test-master.txt @@ -387,12 +387,13 @@ edx-opaque-keys[django]==2.5.1 # -r requirements/test-master.txt # -r requirements/test.txt # edx-drf-extensions + # edx-opaque-keys edx-rbac==1.8.0 # via # -r requirements/doc.txt # -r requirements/test-master.txt # -r requirements/test.txt -edx-rest-api-client==5.6.0 +edx-rest-api-client==5.6.1 # via # -r requirements/doc.txt # -r requirements/test-master.txt @@ -412,12 +413,12 @@ factory-boy==3.3.0 # -c requirements/constraints.txt # -r requirements/doc.txt # -r requirements/test.txt -faker==19.13.0 +faker==22.2.0 # via # -r requirements/doc.txt # -r requirements/test.txt # factory-boy -filelock==3.12.4 +filelock==3.13.1 # via # -r requirements/doc.txt # -r requirements/test-master.txt @@ -436,7 +437,7 @@ frozenlist==1.4.0 # -r requirements/test.txt # aiohttp # aiosignal -idna==3.4 +idna==3.6 # via # -r requirements/doc.txt # -r requirements/test-master.txt @@ -448,7 +449,7 @@ imagesize==1.4.1 # via # -r requirements/doc.txt # sphinx -importlib-metadata==6.8.0 +importlib-metadata==7.0.1 # via # -r requirements/doc.txt # build @@ -464,7 +465,7 @@ iniconfig==2.0.0 # -r requirements/doc.txt # -r requirements/test.txt # pytest -isort==5.12.0 +isort==5.13.2 # via # -r requirements/dev.in # pylint @@ -499,13 +500,13 @@ jwcrypto==1.5.0 # -r requirements/test-master.txt # -r requirements/test.txt # django-oauth-toolkit -kombu==5.3.3 +kombu==5.3.5 # via # -r requirements/doc.txt # -r requirements/test-master.txt # -r requirements/test.txt # celery -lxml==4.9.3 +lxml==5.1.0 # via edx-i18n-tools markupsafe==2.1.3 # via @@ -526,13 +527,13 @@ multidict==6.0.4 # -r requirements/test.txt # aiohttp # yarl -newrelic==9.1.0 +newrelic==9.3.0 # via # -r requirements/doc.txt # -r requirements/test-master.txt # -r requirements/test.txt # edx-django-utils -nh3==0.2.14 +nh3==0.2.15 # via # -r requirements/doc.txt # readme-renderer @@ -547,12 +548,6 @@ openai==0.28.1 # -r requirements/doc.txt # -r requirements/test-master.txt # -r requirements/test.txt -oscrypto==1.3.0 - # via - # -r requirements/doc.txt - # -r requirements/test-master.txt - # -r requirements/test.txt - # snowflake-connector-python packaging==23.2 # via # -r requirements/doc.txt @@ -565,7 +560,7 @@ packaging==23.2 # snowflake-connector-python # sphinx # tox -path==16.7.1 +path==16.9.0 # via # -r requirements/doc.txt # -r requirements/test-master.txt @@ -577,7 +572,7 @@ path-py==12.5.0 # -r requirements/doc.txt # -r requirements/test-master.txt # -r requirements/test.txt -pbr==5.11.1 +pbr==6.0.0 # via # -r requirements/doc.txt # -r requirements/test-master.txt @@ -588,7 +583,7 @@ pgpy==0.6.0 # -r requirements/doc.txt # -r requirements/test-master.txt # -r requirements/test.txt -pillow==9.5.0 +pillow==10.1.0 # via # -r requirements/doc.txt # -r requirements/test-master.txt @@ -614,13 +609,13 @@ pluggy==1.3.0 # tox polib==1.2.0 # via edx-i18n-tools -prompt-toolkit==3.0.39 +prompt-toolkit==3.0.43 # via # -r requirements/doc.txt # -r requirements/test-master.txt # -r requirements/test.txt # click-repl -psutil==5.9.5 +psutil==5.9.6 # via # -r requirements/doc.txt # -r requirements/test-master.txt @@ -632,7 +627,7 @@ py==1.11.0 # -r requirements/test.txt # pytest # tox -pyasn1==0.5.0 +pyasn1==0.5.1 # via # -r requirements/doc.txt # -r requirements/test-master.txt @@ -646,19 +641,13 @@ pycparser==2.21 # -r requirements/test-master.txt # -r requirements/test.txt # cffi -pycryptodomex==3.19.0 - # via - # -r requirements/doc.txt - # -r requirements/test-master.txt - # -r requirements/test.txt - # snowflake-connector-python -pydata-sphinx-theme==0.14.3 +pydata-sphinx-theme==0.14.4 # via # -r requirements/doc.txt # sphinx-book-theme pydocstyle==6.3.0 # via -r requirements/dev.in -pygments==2.16.1 +pygments==2.17.2 # via # -r requirements/doc.txt # -r requirements/test.txt @@ -676,8 +665,9 @@ pyjwt[crypto]==2.8.0 # drf-jwt # edx-drf-extensions # edx-rest-api-client + # pyjwt # snowflake-connector-python -pylint==3.0.2 +pylint==3.0.3 # via # edx-lint # pylint-celery @@ -730,13 +720,19 @@ python-dateutil==2.8.2 # celery # faker # freezegun +python-ipware==2.0.0 + # via + # -r requirements/doc.txt + # -r requirements/test-master.txt + # -r requirements/test.txt + # django-ipware python-slugify==8.0.1 # via # -r requirements/doc.txt # -r requirements/test-master.txt # -r requirements/test.txt # code-annotations -pytz==2022.7.1 +pytz==2023.3.post1 # via # -r requirements/doc.txt # -r requirements/test-master.txt @@ -782,7 +778,7 @@ restructuredtext-lint==1.4.0 # via # -r requirements/doc.txt # doc8 -ruamel-yaml==0.17.35 +ruamel-yaml==0.18.5 # via # -r requirements/doc.txt # -r requirements/test-master.txt @@ -829,7 +825,7 @@ snowballstemmer==2.2.0 # -r requirements/doc.txt # pydocstyle # sphinx -snowflake-connector-python==3.2.1 +snowflake-connector-python==3.6.0 # via # -r requirements/doc.txt # -r requirements/test-master.txt @@ -890,7 +886,7 @@ stevedore==5.1.0 # doc8 # edx-django-utils # edx-opaque-keys -testfixtures==7.2.0 +testfixtures==7.2.2 # via # -r requirements/dev.in # -r requirements/doc.txt @@ -918,7 +914,7 @@ tomli==2.0.1 # pylint # pyproject-hooks # tox -tomlkit==0.12.1 +tomlkit==0.12.3 # via # -r requirements/doc.txt # -r requirements/test-master.txt @@ -927,7 +923,6 @@ tomlkit==0.12.1 # snowflake-connector-python tox==3.28.0 # via - # -c requirements/common_constraints.txt # -c requirements/constraints.txt # -r requirements/dev.in tqdm==4.66.1 @@ -939,7 +934,7 @@ tqdm==4.66.1 # twine twine==1.11.0 # via -r requirements/dev.in -typing-extensions==4.8.0 +typing-extensions==4.9.0 # via # -r requirements/doc.txt # -r requirements/test-master.txt @@ -972,7 +967,7 @@ uritemplate==4.1.1 # -r requirements/test.txt # coreapi # drf-yasg -urllib3==1.26.17 +urllib3==1.26.18 # via # -r requirements/doc.txt # -r requirements/test-master.txt @@ -987,9 +982,9 @@ vine==5.1.0 # amqp # celery # kombu -virtualenv==20.24.6 +virtualenv==20.25.0 # via tox -wcwidth==0.2.8 +wcwidth==0.2.12 # via # -r requirements/doc.txt # -r requirements/test-master.txt @@ -1001,17 +996,17 @@ webencodings==0.5.1 # -r requirements/test-master.txt # -r requirements/test.txt # bleach -wheel==0.41.3 +wheel==0.42.0 # via # -r requirements/dev.in # pip-tools -wrapt==1.15.0 +wrapt==1.16.0 # via # -r requirements/doc.txt # -r requirements/test-master.txt # -r requirements/test.txt # deprecated -yarl==1.9.2 +yarl==1.9.4 # via # -r requirements/doc.txt # -r requirements/test-master.txt diff --git a/requirements/django.txt b/requirements/django.txt index 5a28da341d..d296127a53 100644 --- a/requirements/django.txt +++ b/requirements/django.txt @@ -1 +1 @@ -django==3.2.22 +django==3.2.23 diff --git a/requirements/doc.txt b/requirements/doc.txt index 62eae90148..26c1f7cec8 100644 --- a/requirements/doc.txt +++ b/requirements/doc.txt @@ -6,7 +6,7 @@ # accessible-pygments==0.0.4 # via pydata-sphinx-theme -aiohttp==3.8.6 +aiohttp==3.9.1 # via # -r requirements/test-master.txt # openai @@ -32,7 +32,6 @@ asgiref==3.7.2 asn1crypto==1.5.1 # via # -r requirements/test-master.txt - # oscrypto # snowflake-connector-python async-timeout==4.0.3 # via @@ -43,13 +42,14 @@ attrs==23.1.0 # -r requirements/test-master.txt # aiohttp # pytest -babel==2.13.1 +babel==2.14.0 # via # pydata-sphinx-theme # sphinx backports-zoneinfo[tzdata]==0.2.1 # via # -r requirements/test-master.txt + # backports-zoneinfo # celery # kombu beautifulsoup4==4.12.2 @@ -60,11 +60,11 @@ billiard==4.2.0 # celery bleach==6.1.0 # via -r requirements/test-master.txt -celery==5.3.4 +celery==5.3.6 # via # -c requirements/constraints.txt # -r requirements/test-master.txt -certifi==2023.7.22 +certifi==2023.11.17 # via # -r requirements/test-master.txt # requests @@ -78,7 +78,6 @@ cffi==1.16.0 charset-normalizer==2.0.12 # via # -r requirements/test-master.txt - # aiohttp # requests # snowflake-connector-python click==8.1.7 @@ -132,7 +131,7 @@ deprecated==1.2.14 # via # -r requirements/test-master.txt # jwcrypto -django==3.2.22 +django==3.2.23 # via # -c requirements/common_constraints.txt # -r requirements/test-master.txt @@ -167,9 +166,9 @@ django-crum==0.7.9 # edx-toggles django-fernet-fields-v2==0.9 # via -r requirements/test-master.txt -django-filter==23.3 +django-filter==23.5 # via -r requirements/test-master.txt -django-ipware==5.0.1 +django-ipware==6.0.2 # via -r requirements/test-master.txt django-model-utils==4.3.1 # via @@ -185,7 +184,7 @@ django-simple-history==3.1.1 # via # -c requirements/constraints.txt # -r requirements/test-master.txt -django-waffle==4.0.0 +django-waffle==4.1.0 # via # -r requirements/test-master.txt # edx-django-utils @@ -223,14 +222,14 @@ edx-api-doc-tools==1.7.0 # via -r requirements/test-master.txt edx-braze-client==0.1.8 # via -r requirements/test-master.txt -edx-django-utils==5.7.0 +edx-django-utils==5.9.0 # via # -r requirements/test-master.txt # django-config-models # edx-drf-extensions # edx-rest-api-client # edx-toggles -edx-drf-extensions==8.12.0 +edx-drf-extensions==9.1.2 # via # -r requirements/test-master.txt # edx-rbac @@ -238,9 +237,10 @@ edx-opaque-keys[django]==2.5.1 # via # -r requirements/test-master.txt # edx-drf-extensions + # edx-opaque-keys edx-rbac==1.8.0 # via -r requirements/test-master.txt -edx-rest-api-client==5.6.0 +edx-rest-api-client==5.6.1 # via -r requirements/test-master.txt edx-tincan-py35==1.0.0 # via -r requirements/test-master.txt @@ -250,9 +250,9 @@ factory-boy==3.3.0 # via # -c requirements/constraints.txt # -r requirements/doc.in -faker==19.13.0 +faker==22.2.0 # via factory-boy -filelock==3.12.4 +filelock==3.13.1 # via # -r requirements/test-master.txt # snowflake-connector-python @@ -261,7 +261,7 @@ frozenlist==1.4.0 # -r requirements/test-master.txt # aiohttp # aiosignal -idna==3.4 +idna==3.6 # via # -r requirements/test-master.txt # requests @@ -269,7 +269,7 @@ idna==3.4 # yarl imagesize==1.4.1 # via sphinx -importlib-metadata==6.8.0 +importlib-metadata==7.0.1 # via sphinx inflection==0.5.1 # via @@ -295,7 +295,7 @@ jwcrypto==1.5.0 # via # -r requirements/test-master.txt # django-oauth-toolkit -kombu==5.3.3 +kombu==5.3.5 # via # -r requirements/test-master.txt # celery @@ -308,11 +308,11 @@ multidict==6.0.4 # -r requirements/test-master.txt # aiohttp # yarl -newrelic==9.1.0 +newrelic==9.3.0 # via # -r requirements/test-master.txt # edx-django-utils -nh3==0.2.14 +nh3==0.2.15 # via readme-renderer oauthlib==3.2.2 # via @@ -320,10 +320,6 @@ oauthlib==3.2.2 # django-oauth-toolkit openai==0.28.1 # via -r requirements/test-master.txt -oscrypto==1.3.0 - # via - # -r requirements/test-master.txt - # snowflake-connector-python packaging==23.2 # via # -r requirements/test-master.txt @@ -332,19 +328,19 @@ packaging==23.2 # pytest # snowflake-connector-python # sphinx -path==16.7.1 +path==16.9.0 # via # -r requirements/test-master.txt # path-py path-py==12.5.0 # via -r requirements/test-master.txt -pbr==5.11.1 +pbr==6.0.0 # via # -r requirements/test-master.txt # stevedore pgpy==0.6.0 # via -r requirements/test-master.txt -pillow==9.5.0 +pillow==10.1.0 # via -r requirements/test-master.txt platformdirs==3.11.0 # via @@ -352,17 +348,17 @@ platformdirs==3.11.0 # snowflake-connector-python pluggy==1.3.0 # via pytest -prompt-toolkit==3.0.39 +prompt-toolkit==3.0.43 # via # -r requirements/test-master.txt # click-repl -psutil==5.9.5 +psutil==5.9.6 # via # -r requirements/test-master.txt # edx-django-utils py==1.11.0 # via pytest -pyasn1==0.5.0 +pyasn1==0.5.1 # via # -r requirements/test-master.txt # pgpy @@ -370,13 +366,9 @@ pycparser==2.21 # via # -r requirements/test-master.txt # cffi -pycryptodomex==3.19.0 - # via - # -r requirements/test-master.txt - # snowflake-connector-python -pydata-sphinx-theme==0.14.3 +pydata-sphinx-theme==0.14.4 # via sphinx-book-theme -pygments==2.16.1 +pygments==2.17.2 # via # accessible-pygments # doc8 @@ -389,6 +381,7 @@ pyjwt[crypto]==2.8.0 # drf-jwt # edx-drf-extensions # edx-rest-api-client + # pyjwt # snowflake-connector-python pymongo==3.13.0 # via @@ -411,11 +404,15 @@ python-dateutil==2.8.2 # -r requirements/test-master.txt # celery # faker +python-ipware==2.0.0 + # via + # -r requirements/test-master.txt + # django-ipware python-slugify==8.0.1 # via # -r requirements/test-master.txt # code-annotations -pytz==2022.7.1 +pytz==2023.3.post1 # via # -r requirements/test-master.txt # babel @@ -443,7 +440,7 @@ requests==2.31.0 # sphinx restructuredtext-lint==1.4.0 # via doc8 -ruamel-yaml==0.17.35 +ruamel-yaml==0.18.5 # via # -r requirements/test-master.txt # drf-yasg @@ -469,7 +466,7 @@ slumber==0.7.1 # edx-rest-api-client snowballstemmer==2.2.0 # via sphinx -snowflake-connector-python==3.2.1 +snowflake-connector-python==3.6.0 # via -r requirements/test-master.txt sortedcontainers==2.4.0 # via @@ -507,7 +504,7 @@ stevedore==5.1.0 # doc8 # edx-django-utils # edx-opaque-keys -testfixtures==7.2.0 +testfixtures==7.2.2 # via -r requirements/test-master.txt text-unidecode==1.3 # via @@ -517,7 +514,7 @@ toml==0.10.2 # via pytest tomli==2.0.1 # via doc8 -tomlkit==0.12.1 +tomlkit==0.12.3 # via # -r requirements/test-master.txt # snowflake-connector-python @@ -525,7 +522,7 @@ tqdm==4.66.1 # via # -r requirements/test-master.txt # openai -typing-extensions==4.8.0 +typing-extensions==4.9.0 # via # -r requirements/test-master.txt # asgiref @@ -547,7 +544,7 @@ uritemplate==4.1.1 # -r requirements/test-master.txt # coreapi # drf-yasg -urllib3==1.26.17 +urllib3==1.26.18 # via # -r requirements/test-master.txt # requests @@ -558,7 +555,7 @@ vine==5.1.0 # amqp # celery # kombu -wcwidth==0.2.8 +wcwidth==0.2.12 # via # -r requirements/test-master.txt # prompt-toolkit @@ -566,11 +563,11 @@ webencodings==0.5.1 # via # -r requirements/test-master.txt # bleach -wrapt==1.15.0 +wrapt==1.16.0 # via # -r requirements/test-master.txt # deprecated -yarl==1.9.2 +yarl==1.9.4 # via # -r requirements/test-master.txt # aiohttp diff --git a/requirements/edx-platform-constraints.txt b/requirements/edx-platform-constraints.txt index 4fe802bf37..9316e38d56 100644 --- a/requirements/edx-platform-constraints.txt +++ b/requirements/edx-platform-constraints.txt @@ -8,16 +8,14 @@ # via -r requirements/edx/github.in acid-xblock==0.2.1 # via -r requirements/edx/kernel.in -aiohttp==3.8.6 +aiohttp==3.9.1 # via # geoip2 # openai aiosignal==1.3.1 # via aiohttp -algoliasearch==2.6.3 - # via - # -c requirements/edx/../constraints.txt - # -r requirements/edx/bundled.in +algoliasearch==3.0.0 + # via -r requirements/edx/bundled.in # via kombu analytics-python==1.4.post1 # via -r requirements/edx/kernel.in @@ -28,11 +26,10 @@ appdirs==1.4.4 asgiref==3.7.2 # via # django + # django-cors-headers # django-countries asn1crypto==1.5.1 - # via - # oscrypto - # snowflake-connector-python + # via snowflake-connector-python async-timeout==4.0.3 # via # aiohttp @@ -48,9 +45,8 @@ attrs==23.1.0 # openedx-events # openedx-learning # referencing -babel==2.11.0 +babel==2.14.0 # via - # -c requirements/edx/../constraints.txt # -r requirements/edx/kernel.in # enmerkar # enmerkar-underscore @@ -73,17 +69,15 @@ bleach[css]==6.1.0 # ora2 # xblock-drag-and-drop-v2 # xblock-poll -boto==2.39.0 - # via - # -c requirements/edx/../constraints.txt - # -r requirements/edx/kernel.in -boto3==1.28.62 +boto==2.49.0 + # via -r requirements/edx/kernel.in +boto3==1.33.12 # via # -r requirements/edx/kernel.in # django-ses # fs-s3fs # ora2 -botocore==1.31.62 +botocore==1.33.12 # via # -r requirements/edx/kernel.in # boto3 @@ -99,7 +93,7 @@ bridgekeeper==0.9 # edx-enterprise # event-tracking # openedx-learning -certifi==2023.7.22 +certifi==2023.11.17 # via # -r requirements/edx/paver.txt # elasticsearch @@ -117,7 +111,6 @@ charset-normalizer==2.0.12 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/paver.txt - # aiohttp # requests # snowflake-connector-python chem==1.2.0 @@ -163,7 +156,7 @@ cryptography==38.0.4 # pyopenssl # snowflake-connector-python # social-auth-core -cssutils==2.7.1 +cssutils==2.9.0 # via pynliner defusedxml==0.7.1 # via @@ -174,7 +167,7 @@ defusedxml==0.7.1 # social-auth-core deprecated==1.2.14 # via jwcrypto -django==3.2.22 +django==3.2.23 # via # -c requirements/edx/../common_constraints.txt # -r requirements/edx/kernel.in @@ -245,7 +238,7 @@ django==3.2.22 # ora2 # super-csv # xss-utils -django-appconf==1.0.5 +django-appconf==1.0.6 # via django-statici18n django-cache-memoize==0.2.0 django-celery-results==2.5.1 @@ -258,7 +251,7 @@ django-config-models==2.5.1 # edx-enterprise # edx-name-affirmation # lti-consumer-xblock -django-cors-headers==4.2.0 +django-cors-headers==4.3.1 # via -r requirements/edx/kernel.in django-countries==7.5.1 # via @@ -276,13 +269,13 @@ django-crum==0.7.9 django-environ==0.11.2 # via openedx-blockstore django-fernet-fields-v2==0.9 -django-filter==23.3 +django-filter==23.5 # via # -r requirements/edx/kernel.in # edx-enterprise # lti-consumer-xblock # openedx-blockstore -django-ipware==5.0.1 +django-ipware==6.0.2 # via # -r requirements/edx/kernel.in # edx-enterprise @@ -314,7 +307,7 @@ django-mptt==0.14.0 # -r requirements/edx/kernel.in # openedx-django-wiki django-multi-email-field==0.7.0 -django-mysql==4.11.0 +django-mysql==4.12.0 # via -r requirements/edx/kernel.in django-oauth-toolkit==1.7.1 # via @@ -330,7 +323,7 @@ django-sekizai==4.1.0 # via # -r requirements/edx/kernel.in # openedx-django-wiki -django-ses==3.5.0 +django-ses==3.5.2 # via -r requirements/edx/bundled.in # via # -r requirements/edx/kernel.in @@ -346,14 +339,13 @@ django-statici18n==2.4.0 # -r requirements/edx/kernel.in # lti-consumer-xblock # xblock-drag-and-drop-v2 -django-storages==1.14 +django-storages==1.14.2 # via - # -c requirements/edx/../constraints.txt # -r requirements/edx/kernel.in # edxval django-user-tasks==3.1.0 # via -r requirements/edx/kernel.in -django-waffle==4.0.0 +django-waffle==4.1.0 # via # -r requirements/edx/kernel.in # edx-django-utils @@ -390,13 +382,13 @@ djangorestframework==3.14.0 # ora2 # super-csv djangorestframework-xml==2.0.0 -done-xblock==2.1.0 +done-xblock==2.2.0 # via -r requirements/edx/bundled.in drf-jwt==1.19.2 # via edx-drf-extensions drf-nested-routers==0.93.4 # via openedx-blockstore -drf-spectacular==0.26.5 +drf-spectacular==0.27.0 # via -r requirements/edx/kernel.in drf-yasg==1.21.5 # via @@ -433,16 +425,16 @@ edx-celeryutils==1.2.3 # super-csv edx-codejail==3.3.3 # via -r requirements/edx/kernel.in -edx-completion==4.3.0 +edx-completion==4.4.0 # via -r requirements/edx/kernel.in edx-django-release-util==1.3.0 # via # -r requirements/edx/kernel.in # edxval # openedx-blockstore -edx-django-sites-extensions==4.0.1 +edx-django-sites-extensions==4.0.2 # via -r requirements/edx/kernel.in -edx-django-utils==5.7.0 +edx-django-utils==5.9.0 # via # -r requirements/edx/kernel.in # django-config-models @@ -458,7 +450,7 @@ edx-django-utils==5.7.0 # openedx-blockstore # ora2 # super-csv -edx-drf-extensions==8.12.0 +edx-drf-extensions==9.1.2 # via # -r requirements/edx/kernel.in # edx-completion @@ -470,7 +462,7 @@ edx-drf-extensions==8.12.0 # edx-when # edxval # openedx-learning -edx-enterprise==4.6.12 +edx-enterprise==4.9.5 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/kernel.in @@ -507,12 +499,12 @@ edx-proctoring==4.16.1 # -r requirements/edx/kernel.in # edx-proctoring-proctortrack edx-rbac==1.8.0 -edx-rest-api-client==5.6.0 +edx-rest-api-client==5.6.1 # via # -r requirements/edx/kernel.in # edx-enterprise # edx-proctoring -edx-search==3.6.0 +edx-search==3.8.2 # via -r requirements/edx/kernel.in edx-sga==0.23.0 # via -r requirements/edx/bundled.in @@ -551,11 +543,12 @@ enmerkar-underscore==2.2.0 event-tracking==2.2.0 # via # -r requirements/edx/kernel.in + # edx-completion # edx-proctoring # edx-search -fastavro==1.8.4 +fastavro==1.9.1 # via openedx-events -filelock==3.12.4 +filelock==3.13.1 # via snowflake-connector-python frozenlist==1.4.0 # via @@ -573,7 +566,7 @@ fs-s3fs==0.1.8 # openedx-django-pyfs future==0.18.3 # via pyjwkest -geoip2==4.7.0 +geoip2==4.8.0 # via -r requirements/edx/kernel.in glob2==0.7 # via -r requirements/edx/kernel.in @@ -585,21 +578,22 @@ html5lib==1.1 # via # -r requirements/edx/kernel.in # ora2 -icalendar==5.0.10 +icalendar==5.0.11 # via -r requirements/edx/kernel.in -idna==3.4 +idna==3.6 # via # -r requirements/edx/paver.txt # optimizely-sdk # requests # snowflake-connector-python # yarl -importlib-metadata==6.8.0 +importlib-metadata==7.0.0 # via markdown -importlib-resources==6.1.0 +importlib-resources==5.13.0 # via # jsonschema # jsonschema-specifications + # pycountry inflection==0.5.1 # via # drf-spectacular @@ -632,11 +626,11 @@ jsonfield==3.1.0 # edx-submissions # lti-consumer-xblock # ora2 -jsonschema==4.19.1 +jsonschema==4.20.0 # via # drf-spectacular # optimizely-sdk -jsonschema-specifications==2023.7.1 +jsonschema-specifications==2023.11.2 # via jsonschema jwcrypto==1.5.0 # via @@ -658,10 +652,8 @@ libsass==0.10.0 # -r requirements/edx/paver.txt loremipsum==1.0.5 # via ora2 -lti-consumer-xblock==9.6.1 - # via - # -c requirements/edx/../constraints.txt - # -r requirements/edx/kernel.in +lti-consumer-xblock==9.8.1 + # via -r requirements/edx/kernel.in lxml==4.9.3 # via # -r requirements/edx/kernel.in @@ -676,7 +668,7 @@ lxml==4.9.3 # xmlsec mailsnake==1.6.4 # via -r requirements/edx/bundled.in -mako==1.2.4 +mako==1.3.0 # via # -r requirements/edx/kernel.in # acid-xblock @@ -701,7 +693,7 @@ markupsafe==2.1.3 # mako # openedx-calc # xblock -maxminddb==2.4.0 +maxminddb==2.5.1 # via geoip2 mock==5.1.0 # via -r requirements/edx/paver.txt @@ -721,7 +713,7 @@ mysqlclient==2.2.0 # via # -r requirements/edx/kernel.in # openedx-blockstore -newrelic==9.1.0 +newrelic==9.3.0 # via # -r requirements/edx/bundled.in # edx-django-utils @@ -745,6 +737,9 @@ oauthlib==3.2.2 olxcleaner==0.2.1 # via -r requirements/edx/kernel.in openai==0.28.1 + # via + # -c requirements/edx/../constraints.txt + # edx-enterprise openedx-atlas==0.5.0 # via -r requirements/edx/kernel.in openedx-blockstore==1.4.0 @@ -759,7 +754,7 @@ openedx-django-require==2.1.0 # via -r requirements/edx/kernel.in openedx-django-wiki==2.0.3 # via -r requirements/edx/kernel.in -openedx-events==9.0.1 +openedx-events==9.2.0 # via # -r requirements/edx/kernel.in # edx-event-bus-kafka @@ -768,7 +763,7 @@ openedx-filters==1.6.0 # via # -r requirements/edx/kernel.in # lti-consumer-xblock -openedx-learning==0.3.2 +openedx-learning==0.4.2 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/kernel.in @@ -776,10 +771,8 @@ openedx-mongodbproxy==0.2.0 # via -r requirements/edx/kernel.in optimizely-sdk==4.1.1 # via -r requirements/edx/bundled.in -ora2==6.0.0 +ora2==6.0.25 # via -r requirements/edx/bundled.in -oscrypto==1.3.0 - # via snowflake-connector-python packaging==23.2 # via # drf-yasg @@ -788,7 +781,7 @@ packaging==23.2 # snowflake-connector-python pansi==2020.7.3 # via py2neo -path==16.7.1 +path==16.9.0 # via # -r requirements/edx/kernel.in # -r requirements/edx/paver.txt @@ -801,16 +794,15 @@ path-py==12.5.0 # staff-graded-xblock paver==1.3.4 # via -r requirements/edx/paver.txt -pbr==5.11.1 +pbr==6.0.0 # via # -r requirements/edx/paver.txt # stevedore pgpy==0.6.0 piexif==1.1.3 # via -r requirements/edx/kernel.in -pillow==9.5.0 +pillow==10.1.0 # via - # -c requirements/edx/../constraints.txt # -r requirements/edx/kernel.in # edx-enterprise # edx-organizations @@ -822,7 +814,7 @@ platformdirs==3.11.0 polib==1.2.0 # via edx-i18n-tools # via click-repl -psutil==5.9.5 +psutil==5.9.6 # via # -r requirements/edx/paver.txt # edx-django-utils @@ -830,9 +822,9 @@ py2neo @ https://github.com/overhangio/py2neo/releases/download/2021.2.3/py2neo- # via # -c requirements/edx/../constraints.txt # -r requirements/edx/bundled.in -pyasn1==0.5.0 +pyasn1==0.5.1 # via pgpy -pycountry==22.3.5 +pycountry==23.12.11 # via -r requirements/edx/kernel.in pycparser==2.21 # via cffi @@ -842,8 +834,7 @@ pycryptodomex==3.19.0 # edx-proctoring # lti-consumer-xblock # pyjwkest - # snowflake-connector-python -pygments==2.16.1 +pygments==2.17.2 # via # -r requirements/edx/bundled.in # py2neo @@ -891,7 +882,7 @@ pyparsing==3.1.1 # via # chem # openedx-calc -pyrsistent==0.19.3 +pyrsistent==0.20.0 # via optimizely-sdk pysrt==1.1.2 # via @@ -910,6 +901,8 @@ python-dateutil==2.8.2 # olxcleaner # ora2 # xblock +python-ipware==2.0.0 + # via django-ipware python-memcached==1.59 # via -r requirements/edx/paver.txt python-slugify==8.0.1 @@ -922,9 +915,8 @@ python3-openid==3.2.0 ; python_version >= "3" # social-auth-core python3-saml==1.16.0 # via -r requirements/edx/kernel.in -pytz==2022.7.1 +pytz==2023.3.post1 # via - # -c requirements/edx/../constraints.txt # -r requirements/edx/kernel.in # babel # django @@ -963,7 +955,7 @@ redis==5.0.1 # via # -r requirements/edx/kernel.in # walrus -referencing==0.30.2 +referencing==0.32.0 # via # jsonschema # jsonschema-specifications @@ -996,11 +988,11 @@ requests-oauthlib==1.3.1 # via # -r requirements/edx/kernel.in # social-auth-core -rpds-py==0.10.4 +rpds-py==0.13.2 # via # jsonschema # referencing -ruamel-yaml==0.17.35 +ruamel-yaml==0.18.5 # via drf-yasg ruamel-yaml-clib==0.2.8 # via ruamel-yaml @@ -1010,7 +1002,7 @@ rules==3.3 # edx-enterprise # edx-proctoring # openedx-learning -s3transfer==0.7.0 +s3transfer==0.8.2 # via boto3 sailthru-client==2.2.3 # via edx-ace @@ -1021,7 +1013,7 @@ scipy==1.7.3 # openedx-calc semantic-version==2.10.0 # via edx-drf-extensions -shapely==2.0.1 +shapely==2.0.2 # via -r requirements/edx/kernel.in simplejson==3.19.2 # via @@ -1062,10 +1054,11 @@ six==1.16.0 # python-memcached slumber==0.7.1 # via + # -r requirements/edx/kernel.in # edx-bulk-grades # edx-enterprise # edx-rest-api-client -snowflake-connector-python==3.2.1 +snowflake-connector-python==3.6.0 social-auth-app-django==5.0.0 # via # -c requirements/edx/../constraints.txt @@ -1092,7 +1085,7 @@ sqlparse==0.4.4 # -r requirements/edx/kernel.in # django # openedx-blockstore -staff-graded-xblock==2.1.1 +staff-graded-xblock==2.2.0 # via -r requirements/edx/bundled.in stevedore==5.1.0 # via @@ -1107,22 +1100,23 @@ super-csv==3.1.0 # via edx-bulk-grades sympy==1.12 # via openedx-calc -testfixtures==7.2.0 +testfixtures==7.2.2 text-unidecode==1.3 # via python-slugify tinycss2==1.2.1 # via bleach -tomlkit==0.12.1 +tomlkit==0.12.3 # via snowflake-connector-python tqdm==4.66.1 # via # nltk # openai -typing-extensions==4.8.0 +typing-extensions==4.9.0 # via # -r requirements/edx/paver.txt # asgiref # django-countries + # drf-spectacular # edx-opaque-keys # kombu # pylti1p3 @@ -1140,7 +1134,7 @@ uritemplate==4.1.1 # coreapi # drf-spectacular # drf-yasg -urllib3==1.26.17 +urllib3==1.26.18 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/paver.txt @@ -1155,13 +1149,13 @@ user-util==1.0.0 # amqp # celery # kombu -voluptuous==0.13.1 +voluptuous==0.14.1 # via ora2 walrus==0.9.3 # via edx-event-bus-redis watchdog==3.0.0 # via -r requirements/edx/paver.txt -wcwidth==0.2.8 +wcwidth==0.2.12 # via prompt-toolkit web-fragments==2.1.0 # via @@ -1180,11 +1174,11 @@ webob==1.8.7 # via # -r requirements/edx/kernel.in # xblock -wrapt==1.15.0 +wrapt==1.16.0 # via # -r requirements/edx/paver.txt # deprecated -xblock[django]==1.8.1 +xblock[django]==1.9.0 # via # -r requirements/edx/kernel.in # acid-xblock @@ -1196,28 +1190,25 @@ xblock[django]==1.8.1 # lti-consumer-xblock # ora2 # staff-graded-xblock + # xblock-drag-and-drop-v2 # xblock-google-drive # xblock-poll # xblock-utils -xblock-drag-and-drop-v2==3.2.0 +xblock-drag-and-drop-v2==3.3.0 # via -r requirements/edx/bundled.in -xblock-google-drive==0.4.0 +xblock-google-drive==0.5.0 # via -r requirements/edx/bundled.in xblock-poll==1.13.0 # via -r requirements/edx/bundled.in xblock-utils==4.0.0 # via - # done-xblock # edx-sga - # lti-consumer-xblock - # staff-graded-xblock - # xblock-drag-and-drop-v2 # xblock-google-drive xmlsec==1.3.13 # via python3-saml xss-utils==0.5.0 # via -r requirements/edx/kernel.in -yarl==1.9.2 +yarl==1.9.4 # via aiohttp zipp==3.17.0 # via diff --git a/requirements/js_test.txt b/requirements/js_test.txt index 49ff64e80f..967c73c540 100644 --- a/requirements/js_test.txt +++ b/requirements/js_test.txt @@ -6,19 +6,19 @@ # annotated-types==0.6.0 # via pydantic -attrs==23.1.0 +attrs==23.2.0 # via # outcome # trio autocommand==2.2.2 # via jaraco-text -certifi==2023.7.22 +certifi==2023.11.17 # via selenium cheroot==10.0.0 # via cherrypy -cherrypy==18.8.0 +cherrypy==18.9.0 # via jasmine -exceptiongroup==1.1.3 +exceptiongroup==1.2.0 # via # trio # trio-websocket @@ -26,7 +26,7 @@ glob2==0.7 # via jasmine-core h11==0.14.0 # via wsproto -idna==3.4 +idna==3.6 # via trio importlib-resources==6.1.1 # via jaraco-text @@ -34,7 +34,7 @@ inflect==7.0.0 # via jaraco-text jaraco-classes==3.3.0 # via -r requirements/js_test.in -jaraco-collections==4.3.0 +jaraco-collections==5.0.0 # via # -r requirements/js_test.in # cherrypy @@ -46,7 +46,7 @@ jaraco-functools==4.0.0 # cheroot # jaraco-text # tempora -jaraco-text==3.11.1 +jaraco-text==3.12.0 # via jaraco-collections jasmine==3.99.0 # via -r requirements/js_test.in @@ -56,7 +56,7 @@ jinja2==2.11.3 # via jasmine markupsafe==2.1.3 # via jinja2 -more-itertools==10.1.0 +more-itertools==10.2.0 # via # cheroot # cherrypy @@ -69,9 +69,9 @@ outcome==1.3.0.post0 # via trio portend==3.2.0 # via cherrypy -pydantic==2.4.2 +pydantic==2.5.3 # via inflect -pydantic-core==2.10.1 +pydantic-core==2.14.6 # via pydantic pysocks==1.7.1 # via urllib3 @@ -79,7 +79,7 @@ pytz==2023.3.post1 # via tempora pyyaml==6.0.1 # via jasmine -selenium==4.15.2 +selenium==4.16.0 # via jasmine sniffio==1.3.0 # via trio @@ -89,20 +89,22 @@ tempora==5.5.0 # via # -r requirements/js_test.in # portend -trio==0.23.1 +trio==0.24.0 # via # selenium # trio-websocket trio-websocket==0.11.1 # via selenium -typing-extensions==4.8.0 +typing-extensions==4.9.0 # via # annotated-types # inflect # pydantic # pydantic-core -urllib3[socks]==2.0.7 - # via selenium +urllib3[socks]==2.1.0 + # via + # selenium + # urllib3 wsproto==1.2.0 # via trio-websocket zc-lockfile==3.0.post1 diff --git a/requirements/test-master.txt b/requirements/test-master.txt index 6f7c3fcff4..8c3d25ecaf 100644 --- a/requirements/test-master.txt +++ b/requirements/test-master.txt @@ -4,7 +4,7 @@ # # make upgrade # -aiohttp==3.8.6 +aiohttp==3.9.1 # via # -c requirements/edx-platform-constraints.txt # openai @@ -26,7 +26,6 @@ asgiref==3.7.2 asn1crypto==1.5.1 # via # -c requirements/edx-platform-constraints.txt - # oscrypto # snowflake-connector-python async-timeout==4.0.3 # via @@ -47,11 +46,11 @@ bleach==6.1.0 # via # -c requirements/edx-platform-constraints.txt # -r requirements/base.in -celery==5.3.4 +celery==5.3.6 # via # -c requirements/constraints.txt # -r requirements/base.in -certifi==2023.7.22 +certifi==2023.11.17 # via # -c requirements/edx-platform-constraints.txt # requests @@ -65,7 +64,6 @@ cffi==1.16.0 charset-normalizer==2.0.12 # via # -c requirements/edx-platform-constraints.txt - # aiohttp # requests # snowflake-connector-python click==8.1.7 @@ -116,7 +114,7 @@ deprecated==1.2.14 # via # -c requirements/edx-platform-constraints.txt # jwcrypto -django==3.2.22 +django==3.2.23 # via # -c requirements/common_constraints.txt # -c requirements/edx-platform-constraints.txt @@ -161,11 +159,11 @@ django-fernet-fields-v2==0.9 # via # -c requirements/edx-platform-constraints.txt # -r requirements/base.in -django-filter==23.3 +django-filter==23.5 # via # -c requirements/edx-platform-constraints.txt # -r requirements/base.in -django-ipware==5.0.1 +django-ipware==6.0.2 # via # -c requirements/edx-platform-constraints.txt # -r requirements/base.in @@ -190,7 +188,7 @@ django-simple-history==3.1.1 # via # -c requirements/constraints.txt # -r requirements/base.in -django-waffle==4.0.0 +django-waffle==4.1.0 # via # -c requirements/edx-platform-constraints.txt # -r requirements/base.in @@ -226,7 +224,7 @@ edx-braze-client==0.1.8 # via # -c requirements/edx-platform-constraints.txt # -r requirements/base.in -edx-django-utils==5.7.0 +edx-django-utils==5.9.0 # via # -c requirements/edx-platform-constraints.txt # -r requirements/base.in @@ -234,7 +232,7 @@ edx-django-utils==5.7.0 # edx-drf-extensions # edx-rest-api-client # edx-toggles -edx-drf-extensions==8.12.0 +edx-drf-extensions==9.1.2 # via # -c requirements/edx-platform-constraints.txt # -r requirements/base.in @@ -248,7 +246,7 @@ edx-rbac==1.8.0 # via # -c requirements/edx-platform-constraints.txt # -r requirements/base.in -edx-rest-api-client==5.6.0 +edx-rest-api-client==5.6.1 # via # -c requirements/edx-platform-constraints.txt # -r requirements/base.in @@ -260,7 +258,7 @@ edx-toggles==5.1.0 # via # -c requirements/edx-platform-constraints.txt # -r requirements/base.in -filelock==3.12.4 +filelock==3.13.1 # via # -c requirements/edx-platform-constraints.txt # snowflake-connector-python @@ -269,7 +267,7 @@ frozenlist==1.4.0 # -c requirements/edx-platform-constraints.txt # aiohttp # aiosignal -idna==3.4 +idna==3.6 # via # -c requirements/edx-platform-constraints.txt # requests @@ -300,7 +298,7 @@ jwcrypto==1.5.0 # via # -c requirements/edx-platform-constraints.txt # django-oauth-toolkit -kombu==5.3.3 +kombu==5.3.5 # via celery markupsafe==2.1.3 # via @@ -311,7 +309,7 @@ multidict==6.0.4 # -c requirements/edx-platform-constraints.txt # aiohttp # yarl -newrelic==9.1.0 +newrelic==9.3.0 # via # -c requirements/edx-platform-constraints.txt # edx-django-utils @@ -323,16 +321,12 @@ openai==0.28.1 # via # -c requirements/edx-platform-constraints.txt # -r requirements/base.in -oscrypto==1.3.0 - # via - # -c requirements/edx-platform-constraints.txt - # snowflake-connector-python packaging==23.2 # via # -c requirements/edx-platform-constraints.txt # drf-yasg # snowflake-connector-python -path==16.7.1 +path==16.9.0 # via # -c requirements/edx-platform-constraints.txt # path-py @@ -340,7 +334,7 @@ path-py==12.5.0 # via # -c requirements/edx-platform-constraints.txt # -r requirements/base.in -pbr==5.11.1 +pbr==6.0.0 # via # -c requirements/edx-platform-constraints.txt # stevedore @@ -348,7 +342,7 @@ pgpy==0.6.0 # via # -c requirements/edx-platform-constraints.txt # -r requirements/base.in -pillow==9.5.0 +pillow==10.1.0 # via # -c requirements/edx-platform-constraints.txt # -r requirements/base.in @@ -356,13 +350,13 @@ platformdirs==3.11.0 # via # -c requirements/edx-platform-constraints.txt # snowflake-connector-python -prompt-toolkit==3.0.39 +prompt-toolkit==3.0.43 # via click-repl -psutil==5.9.5 +psutil==5.9.6 # via # -c requirements/edx-platform-constraints.txt # edx-django-utils -pyasn1==0.5.0 +pyasn1==0.5.1 # via # -c requirements/edx-platform-constraints.txt # pgpy @@ -370,16 +364,13 @@ pycparser==2.21 # via # -c requirements/edx-platform-constraints.txt # cffi -pycryptodomex==3.19.0 - # via - # -c requirements/edx-platform-constraints.txt - # snowflake-connector-python pyjwt[crypto]==2.8.0 # via # -c requirements/edx-platform-constraints.txt # drf-jwt # edx-drf-extensions # edx-rest-api-client + # pyjwt # snowflake-connector-python pymongo==3.13.0 # via @@ -398,11 +389,15 @@ python-dateutil==2.8.2 # -c requirements/edx-platform-constraints.txt # -r requirements/base.in # celery +python-ipware==2.0.0 + # via + # -c requirements/edx-platform-constraints.txt + # django-ipware python-slugify==8.0.1 # via # -c requirements/edx-platform-constraints.txt # code-annotations -pytz==2022.7.1 +pytz==2023.3.post1 # via # -c requirements/edx-platform-constraints.txt # -r requirements/base.in @@ -426,7 +421,7 @@ requests==2.31.0 # openai # slumber # snowflake-connector-python -ruamel-yaml==0.17.35 +ruamel-yaml==0.18.5 # via # -c requirements/edx-platform-constraints.txt # drf-yasg @@ -453,7 +448,7 @@ slumber==0.7.1 # -c requirements/edx-platform-constraints.txt # -r requirements/base.in # edx-rest-api-client -snowflake-connector-python==3.2.1 +snowflake-connector-python==3.6.0 # via # -c requirements/edx-platform-constraints.txt # -r requirements/base.in @@ -472,7 +467,7 @@ stevedore==5.1.0 # code-annotations # edx-django-utils # edx-opaque-keys -testfixtures==7.2.0 +testfixtures==7.2.2 # via # -c requirements/edx-platform-constraints.txt # -r requirements/base.in @@ -480,7 +475,7 @@ text-unidecode==1.3 # via # -c requirements/edx-platform-constraints.txt # python-slugify -tomlkit==0.12.1 +tomlkit==0.12.3 # via # -c requirements/edx-platform-constraints.txt # snowflake-connector-python @@ -488,7 +483,7 @@ tqdm==4.66.1 # via # -c requirements/edx-platform-constraints.txt # openai -typing-extensions==4.8.0 +typing-extensions==4.9.0 # via # -c requirements/edx-platform-constraints.txt # asgiref @@ -510,7 +505,7 @@ uritemplate==4.1.1 # -c requirements/edx-platform-constraints.txt # coreapi # drf-yasg -urllib3==1.26.17 +urllib3==1.26.18 # via # -c requirements/edx-platform-constraints.txt # requests @@ -520,7 +515,7 @@ vine==5.1.0 # amqp # celery # kombu -wcwidth==0.2.8 +wcwidth==0.2.12 # via # -c requirements/edx-platform-constraints.txt # prompt-toolkit @@ -528,11 +523,11 @@ webencodings==0.5.1 # via # -c requirements/edx-platform-constraints.txt # bleach -wrapt==1.15.0 +wrapt==1.16.0 # via # -c requirements/edx-platform-constraints.txt # deprecated -yarl==1.9.2 +yarl==1.9.4 # via # -c requirements/edx-platform-constraints.txt # aiohttp diff --git a/requirements/test.txt b/requirements/test.txt index 6880b62747..bc26e50556 100644 --- a/requirements/test.txt +++ b/requirements/test.txt @@ -4,7 +4,7 @@ # # make upgrade # -aiohttp==3.8.6 +aiohttp==3.9.1 # via # -r requirements/test-master.txt # openai @@ -27,7 +27,6 @@ asgiref==3.7.2 asn1crypto==1.5.1 # via # -r requirements/test-master.txt - # oscrypto # snowflake-connector-python async-timeout==4.0.3 # via @@ -42,6 +41,7 @@ backports-zoneinfo[tzdata]==0.2.1 # via # -r requirements/test-master.txt # -r requirements/test.in + # backports-zoneinfo # celery # kombu # via @@ -52,7 +52,7 @@ bleach==6.1.0 # via # -c requirements/constraints.txt # -r requirements/test-master.txt -certifi==2023.7.22 +certifi==2023.11.17 # via # -r requirements/test-master.txt # requests @@ -68,7 +68,6 @@ chardet==5.2.0 charset-normalizer==2.0.12 # via # -r requirements/test-master.txt - # aiohttp # requests # snowflake-connector-python # via @@ -102,8 +101,10 @@ coreschema==0.0.4 # -r requirements/test-master.txt # coreapi # drf-yasg -coverage[toml]==7.3.2 - # via pytest-cov +coverage[toml]==7.4.0 + # via + # coverage + # pytest-cov cryptography==38.0.4 # via # -r requirements/test-master.txt @@ -123,7 +124,7 @@ deprecated==1.2.14 # via # -r requirements/test-master.txt # jwcrypto -diff-cover==8.0.0 +diff-cover==8.0.2 # via -r requirements/test.in # via # -c requirements/common_constraints.txt @@ -159,9 +160,9 @@ django-crum==0.7.9 # edx-toggles django-fernet-fields-v2==0.9 # via -r requirements/test-master.txt -django-filter==23.3 +django-filter==23.5 # via -r requirements/test-master.txt -django-ipware==5.0.1 +django-ipware==6.0.2 # via -r requirements/test-master.txt django-model-utils==4.3.1 # via @@ -178,7 +179,7 @@ django-simple-history==3.1.1 # via # -c requirements/constraints.txt # -r requirements/test-master.txt -django-waffle==4.0.0 +django-waffle==4.1.0 # via # -r requirements/test-master.txt # edx-django-utils @@ -206,14 +207,14 @@ edx-api-doc-tools==1.7.0 # via -r requirements/test-master.txt edx-braze-client==0.1.8 # via -r requirements/test-master.txt -edx-django-utils==5.7.0 +edx-django-utils==5.9.0 # via # -r requirements/test-master.txt # django-config-models # edx-drf-extensions # edx-rest-api-client # edx-toggles -edx-drf-extensions==8.12.0 +edx-drf-extensions==9.1.2 # via # -r requirements/test-master.txt # edx-rbac @@ -221,9 +222,10 @@ edx-opaque-keys[django]==2.5.1 # via # -r requirements/test-master.txt # edx-drf-extensions + # edx-opaque-keys edx-rbac==1.8.0 # via -r requirements/test-master.txt -edx-rest-api-client==5.6.0 +edx-rest-api-client==5.6.1 # via -r requirements/test-master.txt edx-tincan-py35==1.0.0 # via -r requirements/test-master.txt @@ -233,9 +235,9 @@ factory-boy==3.3.0 # via # -c requirements/constraints.txt # -r requirements/test.in -faker==19.13.0 +faker==22.2.0 # via factory-boy -filelock==3.12.4 +filelock==3.13.1 # via # -r requirements/test-master.txt # snowflake-connector-python @@ -248,7 +250,7 @@ frozenlist==1.4.0 # -r requirements/test-master.txt # aiohttp # aiosignal -idna==3.4 +idna==3.6 # via # -r requirements/test-master.txt # requests @@ -294,7 +296,7 @@ multidict==6.0.4 # -r requirements/test-master.txt # aiohttp # yarl -newrelic==9.1.0 +newrelic==9.3.0 # via # -r requirements/test-master.txt # edx-django-utils @@ -304,29 +306,25 @@ oauthlib==3.2.2 # django-oauth-toolkit openai==0.28.1 # via -r requirements/test-master.txt -oscrypto==1.3.0 - # via - # -r requirements/test-master.txt - # snowflake-connector-python packaging==23.2 # via # -r requirements/test-master.txt # drf-yasg # pytest # snowflake-connector-python -path==16.7.1 +path==16.9.0 # via # -r requirements/test-master.txt # path-py path-py==12.5.0 # via -r requirements/test-master.txt -pbr==5.11.1 +pbr==6.0.0 # via # -r requirements/test-master.txt # stevedore pgpy==0.6.0 # via -r requirements/test-master.txt -pillow==9.5.0 +pillow==10.1.0 # via -r requirements/test-master.txt platformdirs==3.11.0 # via @@ -339,13 +337,13 @@ pluggy==1.3.0 # via # -r requirements/test-master.txt # click-repl -psutil==5.9.5 +psutil==5.9.6 # via # -r requirements/test-master.txt # edx-django-utils py==1.11.0 # via pytest -pyasn1==0.5.0 +pyasn1==0.5.1 # via # -r requirements/test-master.txt # pgpy @@ -353,11 +351,7 @@ pycparser==2.21 # via # -r requirements/test-master.txt # cffi -pycryptodomex==3.19.0 - # via - # -r requirements/test-master.txt - # snowflake-connector-python -pygments==2.16.1 +pygments==2.17.2 # via diff-cover pyjwt[crypto]==2.8.0 # via @@ -365,6 +359,7 @@ pyjwt[crypto]==2.8.0 # drf-jwt # edx-drf-extensions # edx-rest-api-client + # pyjwt # snowflake-connector-python pymongo==3.13.0 # via @@ -393,11 +388,15 @@ python-dateutil==2.8.2 # celery # faker # freezegun +python-ipware==2.0.0 + # via + # -r requirements/test-master.txt + # django-ipware python-slugify==8.0.1 # via # -r requirements/test-master.txt # code-annotations -pytz==2022.7.1 +pytz==2023.3.post1 # via # -r requirements/test-master.txt # django @@ -424,7 +423,7 @@ responses==0.10.15 # via # -c requirements/constraints.txt # -r requirements/test.in -ruamel-yaml==0.17.35 +ruamel-yaml==0.18.5 # via # -r requirements/test-master.txt # drf-yasg @@ -451,7 +450,7 @@ slumber==0.7.1 # via # -r requirements/test-master.txt # edx-rest-api-client -snowflake-connector-python==3.2.1 +snowflake-connector-python==3.6.0 # via -r requirements/test-master.txt sortedcontainers==2.4.0 # via @@ -467,7 +466,7 @@ stevedore==5.1.0 # code-annotations # edx-django-utils # edx-opaque-keys -testfixtures==7.2.0 +testfixtures==7.2.2 # via # -r requirements/test-master.txt # -r requirements/test.in @@ -479,7 +478,7 @@ toml==0.10.2 # via pytest tomli==2.0.1 # via coverage -tomlkit==0.12.1 +tomlkit==0.12.3 # via # -r requirements/test-master.txt # snowflake-connector-python @@ -487,7 +486,7 @@ tqdm==4.66.1 # via # -r requirements/test-master.txt # openai -typing-extensions==4.8.0 +typing-extensions==4.9.0 # via # -r requirements/test-master.txt # asgiref @@ -508,7 +507,7 @@ uritemplate==4.1.1 # -r requirements/test-master.txt # coreapi # drf-yasg -urllib3==1.26.17 +urllib3==1.26.18 # via # -r requirements/test-master.txt # requests @@ -518,7 +517,7 @@ urllib3==1.26.17 # amqp # celery # kombu -wcwidth==0.2.8 +wcwidth==0.2.12 # via # -r requirements/test-master.txt # prompt-toolkit @@ -526,11 +525,11 @@ webencodings==0.5.1 # via # -r requirements/test-master.txt # bleach -wrapt==1.15.0 +wrapt==1.16.0 # via # -r requirements/test-master.txt # deprecated -yarl==1.9.2 +yarl==1.9.4 # via # -r requirements/test-master.txt # aiohttp From 7afe90a8b5077fae9cf661918aab7a72b038d639 Mon Sep 17 00:00:00 2001 From: John Nagro Date: Thu, 18 Jan 2024 11:22:11 -0500 Subject: [PATCH 086/164] feat: mgmt command to explore removing exec-ed cat flag (#1989) --- CHANGELOG.rst | 6 ++ enterprise/__init__.py | 2 +- ...mpare_discovery_and_enterprise_catalogs.py | 76 +++++++++++++++++++ 3 files changed, 83 insertions(+), 1 deletion(-) create mode 100644 enterprise/management/commands/compare_discovery_and_enterprise_catalogs.py diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 8b2252a1e7..46900d0a42 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -16,6 +16,12 @@ Change Log Unreleased ---------- +[4.10.3] +-------- + +feat: management command to test query migration + + [4.10.2] -------- diff --git a/enterprise/__init__.py b/enterprise/__init__.py index 1fff1428d6..ad8416750c 100644 --- a/enterprise/__init__.py +++ b/enterprise/__init__.py @@ -2,4 +2,4 @@ Your project description goes here. """ -__version__ = "4.10.2" +__version__ = "4.10.3" diff --git a/enterprise/management/commands/compare_discovery_and_enterprise_catalogs.py b/enterprise/management/commands/compare_discovery_and_enterprise_catalogs.py new file mode 100644 index 0000000000..efcf4510eb --- /dev/null +++ b/enterprise/management/commands/compare_discovery_and_enterprise_catalogs.py @@ -0,0 +1,76 @@ +""" +Django management command to explore Exec Ed inclusion flag migration +""" + +import copy +import json +import logging + +from django.core.management import BaseCommand + +from enterprise.api_client.discovery import CourseCatalogApiServiceClient +from enterprise.api_client.enterprise_catalog import EnterpriseCatalogApiClient +from enterprise.models import EnterpriseCatalogQuery, EnterpriseCustomerCatalog +from integrated_channels.utils import batch_by_pk + +logger = logging.getLogger(__name__) + + +class Command(BaseCommand): + """ + Enumerate the catalog filters and log information about how we might migrate them. + """ + + def handle(self, *args, **options): + enterprise_catalog_client = EnterpriseCatalogApiClient() + discovery_client = CourseCatalogApiServiceClient() + + for catalog_query_batch in batch_by_pk(EnterpriseCatalogQuery): + for catalog_query in catalog_query_batch: + logger.info(f'{catalog_query.id} {catalog_query.include_exec_ed_2u_courses}') + + if catalog_query.include_exec_ed_2u_courses: + logger.info( + 'compare_discovery_and_enterprise_catalogs ' + f'query {catalog_query.id} already includes exec ed' + ) + continue + + if catalog_query.content_filter.get('course_type'): + logger.info( + 'compare_discovery_and_enterprise_catalogs ' + f'query {catalog_query.id} already references course_type somehow' + ) + continue + + new_content_filter = copy.deepcopy(catalog_query.content_filter) + new_content_filter['course_type__exclude'] = 'executive-education-2u' + new_content_filter_json = json.dumps(new_content_filter) + logger.info( + 'compare_discovery_and_enterprise_catalogs ' + f'query {catalog_query.id} new filter: {new_content_filter_json}' + ) + + for cusrtomer_catalog_batch in batch_by_pk(EnterpriseCustomerCatalog): + for customer_catalog in cusrtomer_catalog_batch: + logger.info(f'{customer_catalog.uuid}') + + if customer_catalog.content_filter.get('course_type'): + logger.info( + 'compare_discovery_and_enterprise_catalogs ' + f'catalog {customer_catalog.uuid} already references course_type somehow' + ) + continue + + new_content_filter = copy.deepcopy(customer_catalog.content_filter) + new_content_filter['course_type__exclude'] = 'executive-education-2u' + new_content_filter_json = json.dumps(new_content_filter) + discovery_count = discovery_client.get_catalog_results_from_discovery(new_content_filter).get('count') + enterprise_count = enterprise_catalog_client.get_enterprise_catalog(customer_catalog.uuid).get('count') + logger.info( + 'compare_discovery_and_enterprise_catalogs catalog ' + f'{customer_catalog.uuid} ' + f'discovery count: {discovery_count}, ' + f'enterprise count: {enterprise_count}, ' + f'new filter: {new_content_filter_json}' + ) From 1e69e78a31cbf1939e492e67544ecb25dc26da1c Mon Sep 17 00:00:00 2001 From: John Nagro Date: Thu, 18 Jan 2024 18:06:18 -0500 Subject: [PATCH 087/164] fix: revert 4.10.1 (#1992) --- CHANGELOG.rst | 5 ++ enterprise/__init__.py | 2 +- .../migrations/0018_auto_20240110_1301.py | 23 --------- .../migrations/0033_auto_20240110_1301.py | 23 --------- ...tatransmissionaudit_transmission_status.py | 18 ------- ...tatransmissionaudit_transmission_status.py | 18 ------- ...tatransmissionaudit_transmission_status.py | 18 ------- .../integrated_channel/constants.py | 1 - ...tatransmissionaudit_transmission_status.py | 18 ------- .../integrated_channel/models.py | 18 ------- .../transmitters/learner_data.py | 1 - ...117_1202.py => 0031_auto_20240117_1202.py} | 2 +- ...tatransmissionaudit_transmission_status.py | 18 ------- ...tatransmissionaudit_transmission_status.py | 18 ------- ...tatransmissionaudit_transmission_status.py | 18 ------- .../test_transmitters/test_learner_data.py | 51 +------------------ 16 files changed, 8 insertions(+), 244 deletions(-) delete mode 100644 integrated_channels/blackboard/migrations/0018_auto_20240110_1301.py delete mode 100644 integrated_channels/canvas/migrations/0033_auto_20240110_1301.py delete mode 100644 integrated_channels/cornerstone/migrations/0032_cornerstonelearnerdatatransmissionaudit_transmission_status.py delete mode 100644 integrated_channels/degreed/migrations/0032_degreedlearnerdatatransmissionaudit_transmission_status.py delete mode 100644 integrated_channels/degreed2/migrations/0024_degreed2learnerdatatransmissionaudit_transmission_status.py delete mode 100644 integrated_channels/integrated_channel/migrations/0031_genericlearnerdatatransmissionaudit_transmission_status.py rename integrated_channels/moodle/migrations/{0032_auto_20240117_1202.py => 0031_auto_20240117_1202.py} (92%) delete mode 100644 integrated_channels/moodle/migrations/0031_moodlelearnerdatatransmissionaudit_transmission_status.py delete mode 100644 integrated_channels/sap_success_factors/migrations/0015_sapsuccessfactorslearnerdatatransmissionaudit_transmission_status.py delete mode 100644 integrated_channels/xapi/migrations/0012_xapilearnerdatatransmissionaudit_transmission_status.py diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 46900d0a42..e7795974e5 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -16,6 +16,11 @@ Change Log Unreleased ---------- +[4.10.4] +-------- + +revert: 4.10.1 + [4.10.3] -------- diff --git a/enterprise/__init__.py b/enterprise/__init__.py index ad8416750c..a7d65f0abd 100644 --- a/enterprise/__init__.py +++ b/enterprise/__init__.py @@ -2,4 +2,4 @@ Your project description goes here. """ -__version__ = "4.10.3" +__version__ = "4.10.4" diff --git a/integrated_channels/blackboard/migrations/0018_auto_20240110_1301.py b/integrated_channels/blackboard/migrations/0018_auto_20240110_1301.py deleted file mode 100644 index 79c0ef22ba..0000000000 --- a/integrated_channels/blackboard/migrations/0018_auto_20240110_1301.py +++ /dev/null @@ -1,23 +0,0 @@ -# Generated by Django 3.2.23 on 2024-01-10 13:01 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('blackboard', '0017_alter_historicalblackboardenterprisecustomerconfiguration_options'), - ] - - operations = [ - migrations.AddField( - model_name='blackboardlearnerassessmentdatatransmissionaudit', - name='transmission_status', - field=models.JSONField(blank=True, default=list, null=True), - ), - migrations.AddField( - model_name='blackboardlearnerdatatransmissionaudit', - name='transmission_status', - field=models.JSONField(blank=True, default=list, null=True), - ), - ] diff --git a/integrated_channels/canvas/migrations/0033_auto_20240110_1301.py b/integrated_channels/canvas/migrations/0033_auto_20240110_1301.py deleted file mode 100644 index 3c4ab2ff5d..0000000000 --- a/integrated_channels/canvas/migrations/0033_auto_20240110_1301.py +++ /dev/null @@ -1,23 +0,0 @@ -# Generated by Django 3.2.23 on 2024-01-10 13:01 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('canvas', '0032_alter_historicalcanvasenterprisecustomerconfiguration_options'), - ] - - operations = [ - migrations.AddField( - model_name='canvaslearnerassessmentdatatransmissionaudit', - name='transmission_status', - field=models.JSONField(blank=True, default=list, null=True), - ), - migrations.AddField( - model_name='canvaslearnerdatatransmissionaudit', - name='transmission_status', - field=models.JSONField(blank=True, default=list, null=True), - ), - ] diff --git a/integrated_channels/cornerstone/migrations/0032_cornerstonelearnerdatatransmissionaudit_transmission_status.py b/integrated_channels/cornerstone/migrations/0032_cornerstonelearnerdatatransmissionaudit_transmission_status.py deleted file mode 100644 index b606802498..0000000000 --- a/integrated_channels/cornerstone/migrations/0032_cornerstonelearnerdatatransmissionaudit_transmission_status.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 3.2.23 on 2024-01-16 07:30 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('cornerstone', '0031_cornerstoneapirequestlogs'), - ] - - operations = [ - migrations.AddField( - model_name='cornerstonelearnerdatatransmissionaudit', - name='transmission_status', - field=models.JSONField(blank=True, default=list, null=True), - ), - ] diff --git a/integrated_channels/degreed/migrations/0032_degreedlearnerdatatransmissionaudit_transmission_status.py b/integrated_channels/degreed/migrations/0032_degreedlearnerdatatransmissionaudit_transmission_status.py deleted file mode 100644 index 4bfd135b84..0000000000 --- a/integrated_channels/degreed/migrations/0032_degreedlearnerdatatransmissionaudit_transmission_status.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 3.2.23 on 2024-01-10 13:01 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('degreed', '0031_alter_historicaldegreedenterprisecustomerconfiguration_options'), - ] - - operations = [ - migrations.AddField( - model_name='degreedlearnerdatatransmissionaudit', - name='transmission_status', - field=models.JSONField(blank=True, default=list, null=True), - ), - ] diff --git a/integrated_channels/degreed2/migrations/0024_degreed2learnerdatatransmissionaudit_transmission_status.py b/integrated_channels/degreed2/migrations/0024_degreed2learnerdatatransmissionaudit_transmission_status.py deleted file mode 100644 index 0f367ac477..0000000000 --- a/integrated_channels/degreed2/migrations/0024_degreed2learnerdatatransmissionaudit_transmission_status.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 3.2.23 on 2024-01-10 13:01 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('degreed2', '0023_alter_historicaldegreed2enterprisecustomerconfiguration_options'), - ] - - operations = [ - migrations.AddField( - model_name='degreed2learnerdatatransmissionaudit', - name='transmission_status', - field=models.JSONField(blank=True, default=list, null=True), - ), - ] diff --git a/integrated_channels/integrated_channel/constants.py b/integrated_channels/integrated_channel/constants.py index 7f0e33189c..8636e653ab 100644 --- a/integrated_channels/integrated_channel/constants.py +++ b/integrated_channels/integrated_channel/constants.py @@ -4,4 +4,3 @@ ISO_8601_DATE_FORMAT = '%Y-%m-%dT%H:%M:%S.%fZ' TASK_LOCK_EXPIRY_SECONDS = 60 * 60 * 12 -TRANSMISSION_STATUS_RECORDS_LIMIT = 3 diff --git a/integrated_channels/integrated_channel/migrations/0031_genericlearnerdatatransmissionaudit_transmission_status.py b/integrated_channels/integrated_channel/migrations/0031_genericlearnerdatatransmissionaudit_transmission_status.py deleted file mode 100644 index 843c65d649..0000000000 --- a/integrated_channels/integrated_channel/migrations/0031_genericlearnerdatatransmissionaudit_transmission_status.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 3.2.23 on 2024-01-16 07:59 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('integrated_channel', '0030_integratedchannelapirequestlogs'), - ] - - operations = [ - migrations.AddField( - model_name='genericlearnerdatatransmissionaudit', - name='transmission_status', - field=models.JSONField(blank=True, default=list, null=True), - ), - ] diff --git a/integrated_channels/integrated_channel/models.py b/integrated_channels/integrated_channel/models.py index cc0d60fc64..0d2a26f4e3 100644 --- a/integrated_channels/integrated_channel/models.py +++ b/integrated_channels/integrated_channel/models.py @@ -12,7 +12,6 @@ from django.db import models from django.db.models import Q from django.db.models.query import QuerySet -from django.utils import timezone from django.utils.translation import gettext_lazy as _ from model_utils.models import TimeStampedModel @@ -20,7 +19,6 @@ from enterprise.constants import TRANSMISSION_MARK_CREATE, TRANSMISSION_MARK_DELETE, TRANSMISSION_MARK_UPDATE from enterprise.models import EnterpriseCustomer, EnterpriseCustomerCatalog from enterprise.utils import localized_utcnow -from integrated_channels.integrated_channel.constants import TRANSMISSION_STATUS_RECORDS_LIMIT from integrated_channels.integrated_channel.exporters.content_metadata import ContentMetadataExporter from integrated_channels.integrated_channel.exporters.learner_data import LearnerExporter from integrated_channels.integrated_channel.transmitters.content_metadata import ContentMetadataTransmitter @@ -533,8 +531,6 @@ class LearnerDataTransmissionAudit(TimeStampedModel): help_text=_('Data pertaining to the transmissions API request response.') ) - transmission_status = models.JSONField(default=list, blank=True, null=True) - class Meta: abstract = True app_label = 'integrated_channel' @@ -607,20 +603,6 @@ def _payload_data(self): 'grade': self.grade, } - def add_transmission_status(self, status_code, error_message): - """ - Append the new entry to the list, keeping the list limited to latest three entries. - """ - new_entry = { - 'timestamp': timezone.now().isoformat(), - 'Status_code': status_code, - 'error_message': error_message, - } - - self.transmission_status.append(new_entry) - - self.transmission_status = self.transmission_status[-TRANSMISSION_STATUS_RECORDS_LIMIT:] - class GenericLearnerDataTransmissionAudit(LearnerDataTransmissionAudit): """ diff --git a/integrated_channels/integrated_channel/transmitters/learner_data.py b/integrated_channels/integrated_channel/transmitters/learner_data.py index 23208d49ab..09398ce39b 100644 --- a/integrated_channels/integrated_channel/transmitters/learner_data.py +++ b/integrated_channels/integrated_channel/transmitters/learner_data.py @@ -380,7 +380,6 @@ def transmit(self, payload, **kwargs): # pylint: disable=arguments-differ was_successful = code < 300 learner_data.status = str(code) learner_data.error_message = body if not was_successful else '' - learner_data.add_transmission_status(learner_data.status, learner_data.error_message) learner_data.save() self.enterprise_configuration.update_learner_synced_at(action_happened_at, was_successful) diff --git a/integrated_channels/moodle/migrations/0032_auto_20240117_1202.py b/integrated_channels/moodle/migrations/0031_auto_20240117_1202.py similarity index 92% rename from integrated_channels/moodle/migrations/0032_auto_20240117_1202.py rename to integrated_channels/moodle/migrations/0031_auto_20240117_1202.py index 7a445ec62a..29ed6e8e9f 100644 --- a/integrated_channels/moodle/migrations/0032_auto_20240117_1202.py +++ b/integrated_channels/moodle/migrations/0031_auto_20240117_1202.py @@ -6,7 +6,7 @@ class Migration(migrations.Migration): dependencies = [ - ('moodle', '0031_moodlelearnerdatatransmissionaudit_transmission_status'), + ('moodle', '0030_merge_0028_auto_20231116_1826_0029_auto_20231106_1233'), ] operations = [ diff --git a/integrated_channels/moodle/migrations/0031_moodlelearnerdatatransmissionaudit_transmission_status.py b/integrated_channels/moodle/migrations/0031_moodlelearnerdatatransmissionaudit_transmission_status.py deleted file mode 100644 index c5ce2da304..0000000000 --- a/integrated_channels/moodle/migrations/0031_moodlelearnerdatatransmissionaudit_transmission_status.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 3.2.23 on 2024-01-15 08:47 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('moodle', '0030_merge_0028_auto_20231116_1826_0029_auto_20231106_1233'), - ] - - operations = [ - migrations.AddField( - model_name='moodlelearnerdatatransmissionaudit', - name='transmission_status', - field=models.JSONField(blank=True, default=list, null=True), - ), - ] diff --git a/integrated_channels/sap_success_factors/migrations/0015_sapsuccessfactorslearnerdatatransmissionaudit_transmission_status.py b/integrated_channels/sap_success_factors/migrations/0015_sapsuccessfactorslearnerdatatransmissionaudit_transmission_status.py deleted file mode 100644 index e2a2af9165..0000000000 --- a/integrated_channels/sap_success_factors/migrations/0015_sapsuccessfactorslearnerdatatransmissionaudit_transmission_status.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 3.2.23 on 2024-01-10 13:01 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('sap_success_factors', '0014_alter_sapsuccessfactorsenterprisecustomerconfiguration_show_course_price'), - ] - - operations = [ - migrations.AddField( - model_name='sapsuccessfactorslearnerdatatransmissionaudit', - name='transmission_status', - field=models.JSONField(blank=True, default=list, null=True), - ), - ] diff --git a/integrated_channels/xapi/migrations/0012_xapilearnerdatatransmissionaudit_transmission_status.py b/integrated_channels/xapi/migrations/0012_xapilearnerdatatransmissionaudit_transmission_status.py deleted file mode 100644 index 1ce5515876..0000000000 --- a/integrated_channels/xapi/migrations/0012_xapilearnerdatatransmissionaudit_transmission_status.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 3.2.23 on 2024-01-10 13:01 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('xapi', '0011_alter_xapilearnerdatatransmissionaudit_index_together'), - ] - - operations = [ - migrations.AddField( - model_name='xapilearnerdatatransmissionaudit', - name='transmission_status', - field=models.JSONField(blank=True, default=list, null=True), - ), - ] diff --git a/tests/test_integrated_channels/test_integrated_channel/test_transmitters/test_learner_data.py b/tests/test_integrated_channels/test_integrated_channel/test_transmitters/test_learner_data.py index a04161a274..0d2b7345a2 100644 --- a/tests/test_integrated_channels/test_integrated_channel/test_transmitters/test_learner_data.py +++ b/tests/test_integrated_channels/test_integrated_channel/test_transmitters/test_learner_data.py @@ -1,7 +1,7 @@ """ Tests for the base learner data transmitter. """ -import datetime + import unittest from unittest import mock from unittest.mock import MagicMock, Mock @@ -9,12 +9,9 @@ import ddt from pytest import mark -from integrated_channels.integrated_channel.constants import TRANSMISSION_STATUS_RECORDS_LIMIT from integrated_channels.integrated_channel.exporters.learner_data import LearnerExporter from integrated_channels.integrated_channel.tasks import transmit_single_learner_data from integrated_channels.integrated_channel.transmitters.learner_data import LearnerTransmitter -from integrated_channels.sap_success_factors.models import SapSuccessFactorsLearnerDataTransmissionAudit -from integrated_channels.sap_success_factors.transmitters import learner_data from test_utils import factories @@ -29,13 +26,6 @@ def setUp(self): super().setUp() enterprise_customer = factories.EnterpriseCustomerFactory(name='Starfleet Academy') - self.enterprise_customer_user = factories.EnterpriseCustomerUserFactory( - enterprise_customer=enterprise_customer, - ) - self.enterprise_course_enrollment = factories.EnterpriseCourseEnrollmentFactory( - id=5, - enterprise_customer_user=self.enterprise_customer_user, - ) # We need some non-abstract configuration for these things to work, # so it's okay for it to be any arbitrary channel. We randomly choose SAPSF. @@ -47,25 +37,6 @@ def setUp(self): sapsf_user_id="user_id", secret="client_secret", ) - self.payload = SapSuccessFactorsLearnerDataTransmissionAudit( - enterprise_course_enrollment_id=self.enterprise_course_enrollment.id, - course_id='course-v1:edX+DemoX+DemoCourse', - course_completed=True, - sap_completed_timestamp=1486855998, - completed_timestamp=datetime.datetime.fromtimestamp(1486855998), - total_hours=1.0, - grade=.9, - ) - self.exporter = lambda payloads=self.payload: mock.MagicMock( - export=mock.MagicMock(return_value=iter(payloads)) - ) - # Mocks - create_course_completion_mock = mock.patch( - 'integrated_channels.sap_success_factors.client.SAPSuccessFactorsAPIClient.create_course_completion' - ) - - self.create_course_completion_mock = create_course_completion_mock.start() - self.addCleanup(create_course_completion_mock.stop) self.learner_transmitter = LearnerTransmitter(self.enterprise_config) @@ -222,23 +193,3 @@ def test_learner_data_transmission_dry_run_mode(self, already_transmitted_mock, ) # with dry_run_mode_enabled = True we shouldn't be able to call this method assert not self.learner_transmitter.client.create_assessment_reporting.called - - def test_transmission_status_learner_data_transmission(self): - """ - Test that transmission status records three most recent status instances. - """ - self.create_course_completion_mock.return_value = 200, '' - - transmitter = learner_data.SapSuccessFactorsLearnerTransmitter(self.enterprise_config) - for _ in range(TRANSMISSION_STATUS_RECORDS_LIMIT + 1): - if _ == TRANSMISSION_STATUS_RECORDS_LIMIT: - self.create_course_completion_mock.return_value = 400, '{"error":{"code":null,"message":"Invalid value for property \'courseCompleted\'."}}' - transmitter.transmit(self.exporter([self.payload])) - actual_transmission_status = self.payload.transmission_status - - expected_transmission_status = [ - {'timestamp': mock.ANY, 'Status_code': '200', 'error_message': ''}, - {'timestamp': mock.ANY, 'Status_code': '200', 'error_message': ''}, - {'timestamp': mock.ANY, 'Status_code': '400', 'error_message': 'Client create_course_completion failed: {"error":{"code":null,"message":"Invalid value for property \'courseCompleted\'."}}'}, - ] - assert expected_transmission_status == actual_transmission_status From d5d42864cfad46ca3cd840e5e78f5bfc651517bc Mon Sep 17 00:00:00 2001 From: Troy Sankey Date: Wed, 17 Jan 2024 16:12:12 -0800 Subject: [PATCH 088/164] Revert "Python Requirements Update" --- requirements/celery53.txt | 6 +- requirements/ci.txt | 7 +- requirements/common_constraints.txt | 4 + requirements/dev.txt | 115 +++++++------- requirements/django.txt | 2 +- requirements/doc.txt | 89 +++++------ requirements/edx-platform-constraints.txt | 177 ++++++++++++---------- requirements/js_test.txt | 32 ++-- requirements/test-master.txt | 75 ++++----- requirements/test.txt | 81 +++++----- 10 files changed, 307 insertions(+), 281 deletions(-) diff --git a/requirements/celery53.txt b/requirements/celery53.txt index 71b29858a8..f5e6e328aa 100644 --- a/requirements/celery53.txt +++ b/requirements/celery53.txt @@ -1,9 +1,9 @@ amqp==5.2.0 billiard==4.2.0 -celery==5.3.6 +celery==5.3.4 click==8.1.7 click-didyoumean==0.3.0 click-repl==0.3.0 -kombu==5.3.5 -prompt-toolkit==3.0.43 +kombu==5.3.3 +prompt-toolkit==3.0.39 vine==5.1.0 diff --git a/requirements/ci.txt b/requirements/ci.txt index a7bca04d76..a055e8ef41 100644 --- a/requirements/ci.txt +++ b/requirements/ci.txt @@ -4,7 +4,7 @@ # # make upgrade # -distlib==0.3.8 +distlib==0.3.7 # via virtualenv filelock==3.13.1 # via @@ -12,7 +12,7 @@ filelock==3.13.1 # virtualenv packaging==23.2 # via tox -platformdirs==4.1.0 +platformdirs==3.11.0 # via virtualenv pluggy==1.3.0 # via tox @@ -24,7 +24,8 @@ tomli==2.0.1 # via tox tox==3.28.0 # via + # -c requirements/common_constraints.txt # -c requirements/constraints.txt # -r requirements/ci.in -virtualenv==20.25.0 +virtualenv==20.24.6 # via tox diff --git a/requirements/common_constraints.txt b/requirements/common_constraints.txt index be61b7e0ed..08e94f34dd 100644 --- a/requirements/common_constraints.txt +++ b/requirements/common_constraints.txt @@ -21,3 +21,7 @@ Django<4.0 elasticsearch<7.14.0 # django-simple-history>3.0.0 adds indexing and causes a lot of migrations to be affected + +# tox>4.0.0 isn't yet compatible with many tox plugins, causing CI failures in almost all repos. +# Details can be found in this discussion: https://github.com/tox-dev/tox/discussions/1810 +tox<4.0.0 diff --git a/requirements/dev.txt b/requirements/dev.txt index fd13b2ccc9..fbe87b0ec8 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -8,7 +8,7 @@ accessible-pygments==0.0.4 # via # -r requirements/doc.txt # pydata-sphinx-theme -aiohttp==3.9.1 +aiohttp==3.8.6 # via # -r requirements/doc.txt # -r requirements/test-master.txt @@ -48,8 +48,9 @@ asn1crypto==1.5.1 # -r requirements/doc.txt # -r requirements/test-master.txt # -r requirements/test.txt + # oscrypto # snowflake-connector-python -astroid==3.0.2 +astroid==3.0.1 # via # pylint # pylint-celery @@ -66,7 +67,7 @@ attrs==23.1.0 # -r requirements/test.txt # aiohttp # pytest -babel==2.14.0 +babel==2.13.1 # via # -r requirements/doc.txt # pydata-sphinx-theme @@ -76,7 +77,6 @@ backports-zoneinfo[tzdata]==0.2.1 # -r requirements/doc.txt # -r requirements/test-master.txt # -r requirements/test.txt - # backports-zoneinfo # celery # kombu beautifulsoup4==4.12.2 @@ -96,13 +96,13 @@ bleach==6.1.0 # -r requirements/test.txt build==1.0.3 # via pip-tools -celery==5.3.6 +celery==5.3.4 # via # -c requirements/constraints.txt # -r requirements/doc.txt # -r requirements/test-master.txt # -r requirements/test.txt -certifi==2023.11.17 +certifi==2023.7.22 # via # -r requirements/doc.txt # -r requirements/test-master.txt @@ -126,6 +126,7 @@ charset-normalizer==2.0.12 # -r requirements/doc.txt # -r requirements/test-master.txt # -r requirements/test.txt + # aiohttp # requests # snowflake-connector-python click==8.1.7 @@ -182,10 +183,9 @@ coreschema==0.0.4 # -r requirements/test.txt # coreapi # drf-yasg -coverage[toml]==7.4.0 +coverage[toml]==7.3.2 # via # -r requirements/test.txt - # coverage # pytest-cov cryptography==38.0.4 # via @@ -212,13 +212,13 @@ deprecated==1.2.14 # -r requirements/test-master.txt # -r requirements/test.txt # jwcrypto -diff-cover==8.0.2 +diff-cover==8.0.0 # via -r requirements/test.txt dill==0.3.7 # via pylint -distlib==0.3.8 +distlib==0.3.7 # via virtualenv -django==3.2.23 +django==3.2.22 # via # -c requirements/common_constraints.txt # -r requirements/doc.txt @@ -270,12 +270,12 @@ django-fernet-fields-v2==0.9 # -r requirements/doc.txt # -r requirements/test-master.txt # -r requirements/test.txt -django-filter==23.5 +django-filter==23.3 # via # -r requirements/doc.txt # -r requirements/test-master.txt # -r requirements/test.txt -django-ipware==6.0.2 +django-ipware==5.0.1 # via # -r requirements/doc.txt # -r requirements/test-master.txt @@ -307,7 +307,7 @@ django-simple-history==3.1.1 # -r requirements/doc.txt # -r requirements/test-master.txt # -r requirements/test.txt -django-waffle==4.1.0 +django-waffle==4.0.0 # via # -r requirements/doc.txt # -r requirements/test-master.txt @@ -362,7 +362,7 @@ edx-braze-client==0.1.8 # -r requirements/doc.txt # -r requirements/test-master.txt # -r requirements/test.txt -edx-django-utils==5.9.0 +edx-django-utils==5.7.0 # via # -r requirements/doc.txt # -r requirements/test-master.txt @@ -371,7 +371,7 @@ edx-django-utils==5.9.0 # edx-drf-extensions # edx-rest-api-client # edx-toggles -edx-drf-extensions==9.1.2 +edx-drf-extensions==8.12.0 # via # -r requirements/doc.txt # -r requirements/test-master.txt @@ -387,13 +387,12 @@ edx-opaque-keys[django]==2.5.1 # -r requirements/test-master.txt # -r requirements/test.txt # edx-drf-extensions - # edx-opaque-keys edx-rbac==1.8.0 # via # -r requirements/doc.txt # -r requirements/test-master.txt # -r requirements/test.txt -edx-rest-api-client==5.6.1 +edx-rest-api-client==5.6.0 # via # -r requirements/doc.txt # -r requirements/test-master.txt @@ -413,12 +412,12 @@ factory-boy==3.3.0 # -c requirements/constraints.txt # -r requirements/doc.txt # -r requirements/test.txt -faker==22.2.0 +faker==19.13.0 # via # -r requirements/doc.txt # -r requirements/test.txt # factory-boy -filelock==3.13.1 +filelock==3.12.4 # via # -r requirements/doc.txt # -r requirements/test-master.txt @@ -437,7 +436,7 @@ frozenlist==1.4.0 # -r requirements/test.txt # aiohttp # aiosignal -idna==3.6 +idna==3.4 # via # -r requirements/doc.txt # -r requirements/test-master.txt @@ -449,7 +448,7 @@ imagesize==1.4.1 # via # -r requirements/doc.txt # sphinx -importlib-metadata==7.0.1 +importlib-metadata==6.8.0 # via # -r requirements/doc.txt # build @@ -465,7 +464,7 @@ iniconfig==2.0.0 # -r requirements/doc.txt # -r requirements/test.txt # pytest -isort==5.13.2 +isort==5.12.0 # via # -r requirements/dev.in # pylint @@ -500,13 +499,13 @@ jwcrypto==1.5.0 # -r requirements/test-master.txt # -r requirements/test.txt # django-oauth-toolkit -kombu==5.3.5 +kombu==5.3.3 # via # -r requirements/doc.txt # -r requirements/test-master.txt # -r requirements/test.txt # celery -lxml==5.1.0 +lxml==4.9.3 # via edx-i18n-tools markupsafe==2.1.3 # via @@ -527,13 +526,13 @@ multidict==6.0.4 # -r requirements/test.txt # aiohttp # yarl -newrelic==9.3.0 +newrelic==9.1.0 # via # -r requirements/doc.txt # -r requirements/test-master.txt # -r requirements/test.txt # edx-django-utils -nh3==0.2.15 +nh3==0.2.14 # via # -r requirements/doc.txt # readme-renderer @@ -548,6 +547,12 @@ openai==0.28.1 # -r requirements/doc.txt # -r requirements/test-master.txt # -r requirements/test.txt +oscrypto==1.3.0 + # via + # -r requirements/doc.txt + # -r requirements/test-master.txt + # -r requirements/test.txt + # snowflake-connector-python packaging==23.2 # via # -r requirements/doc.txt @@ -560,7 +565,7 @@ packaging==23.2 # snowflake-connector-python # sphinx # tox -path==16.9.0 +path==16.7.1 # via # -r requirements/doc.txt # -r requirements/test-master.txt @@ -572,7 +577,7 @@ path-py==12.5.0 # -r requirements/doc.txt # -r requirements/test-master.txt # -r requirements/test.txt -pbr==6.0.0 +pbr==5.11.1 # via # -r requirements/doc.txt # -r requirements/test-master.txt @@ -583,7 +588,7 @@ pgpy==0.6.0 # -r requirements/doc.txt # -r requirements/test-master.txt # -r requirements/test.txt -pillow==10.1.0 +pillow==9.5.0 # via # -r requirements/doc.txt # -r requirements/test-master.txt @@ -609,13 +614,13 @@ pluggy==1.3.0 # tox polib==1.2.0 # via edx-i18n-tools -prompt-toolkit==3.0.43 +prompt-toolkit==3.0.39 # via # -r requirements/doc.txt # -r requirements/test-master.txt # -r requirements/test.txt # click-repl -psutil==5.9.6 +psutil==5.9.5 # via # -r requirements/doc.txt # -r requirements/test-master.txt @@ -627,7 +632,7 @@ py==1.11.0 # -r requirements/test.txt # pytest # tox -pyasn1==0.5.1 +pyasn1==0.5.0 # via # -r requirements/doc.txt # -r requirements/test-master.txt @@ -641,13 +646,19 @@ pycparser==2.21 # -r requirements/test-master.txt # -r requirements/test.txt # cffi -pydata-sphinx-theme==0.14.4 +pycryptodomex==3.19.0 + # via + # -r requirements/doc.txt + # -r requirements/test-master.txt + # -r requirements/test.txt + # snowflake-connector-python +pydata-sphinx-theme==0.14.3 # via # -r requirements/doc.txt # sphinx-book-theme pydocstyle==6.3.0 # via -r requirements/dev.in -pygments==2.17.2 +pygments==2.16.1 # via # -r requirements/doc.txt # -r requirements/test.txt @@ -665,9 +676,8 @@ pyjwt[crypto]==2.8.0 # drf-jwt # edx-drf-extensions # edx-rest-api-client - # pyjwt # snowflake-connector-python -pylint==3.0.3 +pylint==3.0.2 # via # edx-lint # pylint-celery @@ -720,19 +730,13 @@ python-dateutil==2.8.2 # celery # faker # freezegun -python-ipware==2.0.0 - # via - # -r requirements/doc.txt - # -r requirements/test-master.txt - # -r requirements/test.txt - # django-ipware python-slugify==8.0.1 # via # -r requirements/doc.txt # -r requirements/test-master.txt # -r requirements/test.txt # code-annotations -pytz==2023.3.post1 +pytz==2022.7.1 # via # -r requirements/doc.txt # -r requirements/test-master.txt @@ -778,7 +782,7 @@ restructuredtext-lint==1.4.0 # via # -r requirements/doc.txt # doc8 -ruamel-yaml==0.18.5 +ruamel-yaml==0.17.35 # via # -r requirements/doc.txt # -r requirements/test-master.txt @@ -825,7 +829,7 @@ snowballstemmer==2.2.0 # -r requirements/doc.txt # pydocstyle # sphinx -snowflake-connector-python==3.6.0 +snowflake-connector-python==3.2.1 # via # -r requirements/doc.txt # -r requirements/test-master.txt @@ -886,7 +890,7 @@ stevedore==5.1.0 # doc8 # edx-django-utils # edx-opaque-keys -testfixtures==7.2.2 +testfixtures==7.2.0 # via # -r requirements/dev.in # -r requirements/doc.txt @@ -914,7 +918,7 @@ tomli==2.0.1 # pylint # pyproject-hooks # tox -tomlkit==0.12.3 +tomlkit==0.12.1 # via # -r requirements/doc.txt # -r requirements/test-master.txt @@ -923,6 +927,7 @@ tomlkit==0.12.3 # snowflake-connector-python tox==3.28.0 # via + # -c requirements/common_constraints.txt # -c requirements/constraints.txt # -r requirements/dev.in tqdm==4.66.1 @@ -934,7 +939,7 @@ tqdm==4.66.1 # twine twine==1.11.0 # via -r requirements/dev.in -typing-extensions==4.9.0 +typing-extensions==4.8.0 # via # -r requirements/doc.txt # -r requirements/test-master.txt @@ -967,7 +972,7 @@ uritemplate==4.1.1 # -r requirements/test.txt # coreapi # drf-yasg -urllib3==1.26.18 +urllib3==1.26.17 # via # -r requirements/doc.txt # -r requirements/test-master.txt @@ -982,9 +987,9 @@ vine==5.1.0 # amqp # celery # kombu -virtualenv==20.25.0 +virtualenv==20.24.6 # via tox -wcwidth==0.2.12 +wcwidth==0.2.8 # via # -r requirements/doc.txt # -r requirements/test-master.txt @@ -996,17 +1001,17 @@ webencodings==0.5.1 # -r requirements/test-master.txt # -r requirements/test.txt # bleach -wheel==0.42.0 +wheel==0.41.3 # via # -r requirements/dev.in # pip-tools -wrapt==1.16.0 +wrapt==1.15.0 # via # -r requirements/doc.txt # -r requirements/test-master.txt # -r requirements/test.txt # deprecated -yarl==1.9.4 +yarl==1.9.2 # via # -r requirements/doc.txt # -r requirements/test-master.txt diff --git a/requirements/django.txt b/requirements/django.txt index d296127a53..5a28da341d 100644 --- a/requirements/django.txt +++ b/requirements/django.txt @@ -1 +1 @@ -django==3.2.23 +django==3.2.22 diff --git a/requirements/doc.txt b/requirements/doc.txt index 26c1f7cec8..62eae90148 100644 --- a/requirements/doc.txt +++ b/requirements/doc.txt @@ -6,7 +6,7 @@ # accessible-pygments==0.0.4 # via pydata-sphinx-theme -aiohttp==3.9.1 +aiohttp==3.8.6 # via # -r requirements/test-master.txt # openai @@ -32,6 +32,7 @@ asgiref==3.7.2 asn1crypto==1.5.1 # via # -r requirements/test-master.txt + # oscrypto # snowflake-connector-python async-timeout==4.0.3 # via @@ -42,14 +43,13 @@ attrs==23.1.0 # -r requirements/test-master.txt # aiohttp # pytest -babel==2.14.0 +babel==2.13.1 # via # pydata-sphinx-theme # sphinx backports-zoneinfo[tzdata]==0.2.1 # via # -r requirements/test-master.txt - # backports-zoneinfo # celery # kombu beautifulsoup4==4.12.2 @@ -60,11 +60,11 @@ billiard==4.2.0 # celery bleach==6.1.0 # via -r requirements/test-master.txt -celery==5.3.6 +celery==5.3.4 # via # -c requirements/constraints.txt # -r requirements/test-master.txt -certifi==2023.11.17 +certifi==2023.7.22 # via # -r requirements/test-master.txt # requests @@ -78,6 +78,7 @@ cffi==1.16.0 charset-normalizer==2.0.12 # via # -r requirements/test-master.txt + # aiohttp # requests # snowflake-connector-python click==8.1.7 @@ -131,7 +132,7 @@ deprecated==1.2.14 # via # -r requirements/test-master.txt # jwcrypto -django==3.2.23 +django==3.2.22 # via # -c requirements/common_constraints.txt # -r requirements/test-master.txt @@ -166,9 +167,9 @@ django-crum==0.7.9 # edx-toggles django-fernet-fields-v2==0.9 # via -r requirements/test-master.txt -django-filter==23.5 +django-filter==23.3 # via -r requirements/test-master.txt -django-ipware==6.0.2 +django-ipware==5.0.1 # via -r requirements/test-master.txt django-model-utils==4.3.1 # via @@ -184,7 +185,7 @@ django-simple-history==3.1.1 # via # -c requirements/constraints.txt # -r requirements/test-master.txt -django-waffle==4.1.0 +django-waffle==4.0.0 # via # -r requirements/test-master.txt # edx-django-utils @@ -222,14 +223,14 @@ edx-api-doc-tools==1.7.0 # via -r requirements/test-master.txt edx-braze-client==0.1.8 # via -r requirements/test-master.txt -edx-django-utils==5.9.0 +edx-django-utils==5.7.0 # via # -r requirements/test-master.txt # django-config-models # edx-drf-extensions # edx-rest-api-client # edx-toggles -edx-drf-extensions==9.1.2 +edx-drf-extensions==8.12.0 # via # -r requirements/test-master.txt # edx-rbac @@ -237,10 +238,9 @@ edx-opaque-keys[django]==2.5.1 # via # -r requirements/test-master.txt # edx-drf-extensions - # edx-opaque-keys edx-rbac==1.8.0 # via -r requirements/test-master.txt -edx-rest-api-client==5.6.1 +edx-rest-api-client==5.6.0 # via -r requirements/test-master.txt edx-tincan-py35==1.0.0 # via -r requirements/test-master.txt @@ -250,9 +250,9 @@ factory-boy==3.3.0 # via # -c requirements/constraints.txt # -r requirements/doc.in -faker==22.2.0 +faker==19.13.0 # via factory-boy -filelock==3.13.1 +filelock==3.12.4 # via # -r requirements/test-master.txt # snowflake-connector-python @@ -261,7 +261,7 @@ frozenlist==1.4.0 # -r requirements/test-master.txt # aiohttp # aiosignal -idna==3.6 +idna==3.4 # via # -r requirements/test-master.txt # requests @@ -269,7 +269,7 @@ idna==3.6 # yarl imagesize==1.4.1 # via sphinx -importlib-metadata==7.0.1 +importlib-metadata==6.8.0 # via sphinx inflection==0.5.1 # via @@ -295,7 +295,7 @@ jwcrypto==1.5.0 # via # -r requirements/test-master.txt # django-oauth-toolkit -kombu==5.3.5 +kombu==5.3.3 # via # -r requirements/test-master.txt # celery @@ -308,11 +308,11 @@ multidict==6.0.4 # -r requirements/test-master.txt # aiohttp # yarl -newrelic==9.3.0 +newrelic==9.1.0 # via # -r requirements/test-master.txt # edx-django-utils -nh3==0.2.15 +nh3==0.2.14 # via readme-renderer oauthlib==3.2.2 # via @@ -320,6 +320,10 @@ oauthlib==3.2.2 # django-oauth-toolkit openai==0.28.1 # via -r requirements/test-master.txt +oscrypto==1.3.0 + # via + # -r requirements/test-master.txt + # snowflake-connector-python packaging==23.2 # via # -r requirements/test-master.txt @@ -328,19 +332,19 @@ packaging==23.2 # pytest # snowflake-connector-python # sphinx -path==16.9.0 +path==16.7.1 # via # -r requirements/test-master.txt # path-py path-py==12.5.0 # via -r requirements/test-master.txt -pbr==6.0.0 +pbr==5.11.1 # via # -r requirements/test-master.txt # stevedore pgpy==0.6.0 # via -r requirements/test-master.txt -pillow==10.1.0 +pillow==9.5.0 # via -r requirements/test-master.txt platformdirs==3.11.0 # via @@ -348,17 +352,17 @@ platformdirs==3.11.0 # snowflake-connector-python pluggy==1.3.0 # via pytest -prompt-toolkit==3.0.43 +prompt-toolkit==3.0.39 # via # -r requirements/test-master.txt # click-repl -psutil==5.9.6 +psutil==5.9.5 # via # -r requirements/test-master.txt # edx-django-utils py==1.11.0 # via pytest -pyasn1==0.5.1 +pyasn1==0.5.0 # via # -r requirements/test-master.txt # pgpy @@ -366,9 +370,13 @@ pycparser==2.21 # via # -r requirements/test-master.txt # cffi -pydata-sphinx-theme==0.14.4 +pycryptodomex==3.19.0 + # via + # -r requirements/test-master.txt + # snowflake-connector-python +pydata-sphinx-theme==0.14.3 # via sphinx-book-theme -pygments==2.17.2 +pygments==2.16.1 # via # accessible-pygments # doc8 @@ -381,7 +389,6 @@ pyjwt[crypto]==2.8.0 # drf-jwt # edx-drf-extensions # edx-rest-api-client - # pyjwt # snowflake-connector-python pymongo==3.13.0 # via @@ -404,15 +411,11 @@ python-dateutil==2.8.2 # -r requirements/test-master.txt # celery # faker -python-ipware==2.0.0 - # via - # -r requirements/test-master.txt - # django-ipware python-slugify==8.0.1 # via # -r requirements/test-master.txt # code-annotations -pytz==2023.3.post1 +pytz==2022.7.1 # via # -r requirements/test-master.txt # babel @@ -440,7 +443,7 @@ requests==2.31.0 # sphinx restructuredtext-lint==1.4.0 # via doc8 -ruamel-yaml==0.18.5 +ruamel-yaml==0.17.35 # via # -r requirements/test-master.txt # drf-yasg @@ -466,7 +469,7 @@ slumber==0.7.1 # edx-rest-api-client snowballstemmer==2.2.0 # via sphinx -snowflake-connector-python==3.6.0 +snowflake-connector-python==3.2.1 # via -r requirements/test-master.txt sortedcontainers==2.4.0 # via @@ -504,7 +507,7 @@ stevedore==5.1.0 # doc8 # edx-django-utils # edx-opaque-keys -testfixtures==7.2.2 +testfixtures==7.2.0 # via -r requirements/test-master.txt text-unidecode==1.3 # via @@ -514,7 +517,7 @@ toml==0.10.2 # via pytest tomli==2.0.1 # via doc8 -tomlkit==0.12.3 +tomlkit==0.12.1 # via # -r requirements/test-master.txt # snowflake-connector-python @@ -522,7 +525,7 @@ tqdm==4.66.1 # via # -r requirements/test-master.txt # openai -typing-extensions==4.9.0 +typing-extensions==4.8.0 # via # -r requirements/test-master.txt # asgiref @@ -544,7 +547,7 @@ uritemplate==4.1.1 # -r requirements/test-master.txt # coreapi # drf-yasg -urllib3==1.26.18 +urllib3==1.26.17 # via # -r requirements/test-master.txt # requests @@ -555,7 +558,7 @@ vine==5.1.0 # amqp # celery # kombu -wcwidth==0.2.12 +wcwidth==0.2.8 # via # -r requirements/test-master.txt # prompt-toolkit @@ -563,11 +566,11 @@ webencodings==0.5.1 # via # -r requirements/test-master.txt # bleach -wrapt==1.16.0 +wrapt==1.15.0 # via # -r requirements/test-master.txt # deprecated -yarl==1.9.4 +yarl==1.9.2 # via # -r requirements/test-master.txt # aiohttp diff --git a/requirements/edx-platform-constraints.txt b/requirements/edx-platform-constraints.txt index 9316e38d56..4fe802bf37 100644 --- a/requirements/edx-platform-constraints.txt +++ b/requirements/edx-platform-constraints.txt @@ -8,14 +8,16 @@ # via -r requirements/edx/github.in acid-xblock==0.2.1 # via -r requirements/edx/kernel.in -aiohttp==3.9.1 +aiohttp==3.8.6 # via # geoip2 # openai aiosignal==1.3.1 # via aiohttp -algoliasearch==3.0.0 - # via -r requirements/edx/bundled.in +algoliasearch==2.6.3 + # via + # -c requirements/edx/../constraints.txt + # -r requirements/edx/bundled.in # via kombu analytics-python==1.4.post1 # via -r requirements/edx/kernel.in @@ -26,10 +28,11 @@ appdirs==1.4.4 asgiref==3.7.2 # via # django - # django-cors-headers # django-countries asn1crypto==1.5.1 - # via snowflake-connector-python + # via + # oscrypto + # snowflake-connector-python async-timeout==4.0.3 # via # aiohttp @@ -45,8 +48,9 @@ attrs==23.1.0 # openedx-events # openedx-learning # referencing -babel==2.14.0 +babel==2.11.0 # via + # -c requirements/edx/../constraints.txt # -r requirements/edx/kernel.in # enmerkar # enmerkar-underscore @@ -69,15 +73,17 @@ bleach[css]==6.1.0 # ora2 # xblock-drag-and-drop-v2 # xblock-poll -boto==2.49.0 - # via -r requirements/edx/kernel.in -boto3==1.33.12 +boto==2.39.0 + # via + # -c requirements/edx/../constraints.txt + # -r requirements/edx/kernel.in +boto3==1.28.62 # via # -r requirements/edx/kernel.in # django-ses # fs-s3fs # ora2 -botocore==1.33.12 +botocore==1.31.62 # via # -r requirements/edx/kernel.in # boto3 @@ -93,7 +99,7 @@ bridgekeeper==0.9 # edx-enterprise # event-tracking # openedx-learning -certifi==2023.11.17 +certifi==2023.7.22 # via # -r requirements/edx/paver.txt # elasticsearch @@ -111,6 +117,7 @@ charset-normalizer==2.0.12 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/paver.txt + # aiohttp # requests # snowflake-connector-python chem==1.2.0 @@ -156,7 +163,7 @@ cryptography==38.0.4 # pyopenssl # snowflake-connector-python # social-auth-core -cssutils==2.9.0 +cssutils==2.7.1 # via pynliner defusedxml==0.7.1 # via @@ -167,7 +174,7 @@ defusedxml==0.7.1 # social-auth-core deprecated==1.2.14 # via jwcrypto -django==3.2.23 +django==3.2.22 # via # -c requirements/edx/../common_constraints.txt # -r requirements/edx/kernel.in @@ -238,7 +245,7 @@ django==3.2.23 # ora2 # super-csv # xss-utils -django-appconf==1.0.6 +django-appconf==1.0.5 # via django-statici18n django-cache-memoize==0.2.0 django-celery-results==2.5.1 @@ -251,7 +258,7 @@ django-config-models==2.5.1 # edx-enterprise # edx-name-affirmation # lti-consumer-xblock -django-cors-headers==4.3.1 +django-cors-headers==4.2.0 # via -r requirements/edx/kernel.in django-countries==7.5.1 # via @@ -269,13 +276,13 @@ django-crum==0.7.9 django-environ==0.11.2 # via openedx-blockstore django-fernet-fields-v2==0.9 -django-filter==23.5 +django-filter==23.3 # via # -r requirements/edx/kernel.in # edx-enterprise # lti-consumer-xblock # openedx-blockstore -django-ipware==6.0.2 +django-ipware==5.0.1 # via # -r requirements/edx/kernel.in # edx-enterprise @@ -307,7 +314,7 @@ django-mptt==0.14.0 # -r requirements/edx/kernel.in # openedx-django-wiki django-multi-email-field==0.7.0 -django-mysql==4.12.0 +django-mysql==4.11.0 # via -r requirements/edx/kernel.in django-oauth-toolkit==1.7.1 # via @@ -323,7 +330,7 @@ django-sekizai==4.1.0 # via # -r requirements/edx/kernel.in # openedx-django-wiki -django-ses==3.5.2 +django-ses==3.5.0 # via -r requirements/edx/bundled.in # via # -r requirements/edx/kernel.in @@ -339,13 +346,14 @@ django-statici18n==2.4.0 # -r requirements/edx/kernel.in # lti-consumer-xblock # xblock-drag-and-drop-v2 -django-storages==1.14.2 +django-storages==1.14 # via + # -c requirements/edx/../constraints.txt # -r requirements/edx/kernel.in # edxval django-user-tasks==3.1.0 # via -r requirements/edx/kernel.in -django-waffle==4.1.0 +django-waffle==4.0.0 # via # -r requirements/edx/kernel.in # edx-django-utils @@ -382,13 +390,13 @@ djangorestframework==3.14.0 # ora2 # super-csv djangorestframework-xml==2.0.0 -done-xblock==2.2.0 +done-xblock==2.1.0 # via -r requirements/edx/bundled.in drf-jwt==1.19.2 # via edx-drf-extensions drf-nested-routers==0.93.4 # via openedx-blockstore -drf-spectacular==0.27.0 +drf-spectacular==0.26.5 # via -r requirements/edx/kernel.in drf-yasg==1.21.5 # via @@ -425,16 +433,16 @@ edx-celeryutils==1.2.3 # super-csv edx-codejail==3.3.3 # via -r requirements/edx/kernel.in -edx-completion==4.4.0 +edx-completion==4.3.0 # via -r requirements/edx/kernel.in edx-django-release-util==1.3.0 # via # -r requirements/edx/kernel.in # edxval # openedx-blockstore -edx-django-sites-extensions==4.0.2 +edx-django-sites-extensions==4.0.1 # via -r requirements/edx/kernel.in -edx-django-utils==5.9.0 +edx-django-utils==5.7.0 # via # -r requirements/edx/kernel.in # django-config-models @@ -450,7 +458,7 @@ edx-django-utils==5.9.0 # openedx-blockstore # ora2 # super-csv -edx-drf-extensions==9.1.2 +edx-drf-extensions==8.12.0 # via # -r requirements/edx/kernel.in # edx-completion @@ -462,7 +470,7 @@ edx-drf-extensions==9.1.2 # edx-when # edxval # openedx-learning -edx-enterprise==4.9.5 +edx-enterprise==4.6.12 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/kernel.in @@ -499,12 +507,12 @@ edx-proctoring==4.16.1 # -r requirements/edx/kernel.in # edx-proctoring-proctortrack edx-rbac==1.8.0 -edx-rest-api-client==5.6.1 +edx-rest-api-client==5.6.0 # via # -r requirements/edx/kernel.in # edx-enterprise # edx-proctoring -edx-search==3.8.2 +edx-search==3.6.0 # via -r requirements/edx/kernel.in edx-sga==0.23.0 # via -r requirements/edx/bundled.in @@ -543,12 +551,11 @@ enmerkar-underscore==2.2.0 event-tracking==2.2.0 # via # -r requirements/edx/kernel.in - # edx-completion # edx-proctoring # edx-search -fastavro==1.9.1 +fastavro==1.8.4 # via openedx-events -filelock==3.13.1 +filelock==3.12.4 # via snowflake-connector-python frozenlist==1.4.0 # via @@ -566,7 +573,7 @@ fs-s3fs==0.1.8 # openedx-django-pyfs future==0.18.3 # via pyjwkest -geoip2==4.8.0 +geoip2==4.7.0 # via -r requirements/edx/kernel.in glob2==0.7 # via -r requirements/edx/kernel.in @@ -578,22 +585,21 @@ html5lib==1.1 # via # -r requirements/edx/kernel.in # ora2 -icalendar==5.0.11 +icalendar==5.0.10 # via -r requirements/edx/kernel.in -idna==3.6 +idna==3.4 # via # -r requirements/edx/paver.txt # optimizely-sdk # requests # snowflake-connector-python # yarl -importlib-metadata==7.0.0 +importlib-metadata==6.8.0 # via markdown -importlib-resources==5.13.0 +importlib-resources==6.1.0 # via # jsonschema # jsonschema-specifications - # pycountry inflection==0.5.1 # via # drf-spectacular @@ -626,11 +632,11 @@ jsonfield==3.1.0 # edx-submissions # lti-consumer-xblock # ora2 -jsonschema==4.20.0 +jsonschema==4.19.1 # via # drf-spectacular # optimizely-sdk -jsonschema-specifications==2023.11.2 +jsonschema-specifications==2023.7.1 # via jsonschema jwcrypto==1.5.0 # via @@ -652,8 +658,10 @@ libsass==0.10.0 # -r requirements/edx/paver.txt loremipsum==1.0.5 # via ora2 -lti-consumer-xblock==9.8.1 - # via -r requirements/edx/kernel.in +lti-consumer-xblock==9.6.1 + # via + # -c requirements/edx/../constraints.txt + # -r requirements/edx/kernel.in lxml==4.9.3 # via # -r requirements/edx/kernel.in @@ -668,7 +676,7 @@ lxml==4.9.3 # xmlsec mailsnake==1.6.4 # via -r requirements/edx/bundled.in -mako==1.3.0 +mako==1.2.4 # via # -r requirements/edx/kernel.in # acid-xblock @@ -693,7 +701,7 @@ markupsafe==2.1.3 # mako # openedx-calc # xblock -maxminddb==2.5.1 +maxminddb==2.4.0 # via geoip2 mock==5.1.0 # via -r requirements/edx/paver.txt @@ -713,7 +721,7 @@ mysqlclient==2.2.0 # via # -r requirements/edx/kernel.in # openedx-blockstore -newrelic==9.3.0 +newrelic==9.1.0 # via # -r requirements/edx/bundled.in # edx-django-utils @@ -737,9 +745,6 @@ oauthlib==3.2.2 olxcleaner==0.2.1 # via -r requirements/edx/kernel.in openai==0.28.1 - # via - # -c requirements/edx/../constraints.txt - # edx-enterprise openedx-atlas==0.5.0 # via -r requirements/edx/kernel.in openedx-blockstore==1.4.0 @@ -754,7 +759,7 @@ openedx-django-require==2.1.0 # via -r requirements/edx/kernel.in openedx-django-wiki==2.0.3 # via -r requirements/edx/kernel.in -openedx-events==9.2.0 +openedx-events==9.0.1 # via # -r requirements/edx/kernel.in # edx-event-bus-kafka @@ -763,7 +768,7 @@ openedx-filters==1.6.0 # via # -r requirements/edx/kernel.in # lti-consumer-xblock -openedx-learning==0.4.2 +openedx-learning==0.3.2 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/kernel.in @@ -771,8 +776,10 @@ openedx-mongodbproxy==0.2.0 # via -r requirements/edx/kernel.in optimizely-sdk==4.1.1 # via -r requirements/edx/bundled.in -ora2==6.0.25 +ora2==6.0.0 # via -r requirements/edx/bundled.in +oscrypto==1.3.0 + # via snowflake-connector-python packaging==23.2 # via # drf-yasg @@ -781,7 +788,7 @@ packaging==23.2 # snowflake-connector-python pansi==2020.7.3 # via py2neo -path==16.9.0 +path==16.7.1 # via # -r requirements/edx/kernel.in # -r requirements/edx/paver.txt @@ -794,15 +801,16 @@ path-py==12.5.0 # staff-graded-xblock paver==1.3.4 # via -r requirements/edx/paver.txt -pbr==6.0.0 +pbr==5.11.1 # via # -r requirements/edx/paver.txt # stevedore pgpy==0.6.0 piexif==1.1.3 # via -r requirements/edx/kernel.in -pillow==10.1.0 +pillow==9.5.0 # via + # -c requirements/edx/../constraints.txt # -r requirements/edx/kernel.in # edx-enterprise # edx-organizations @@ -814,7 +822,7 @@ platformdirs==3.11.0 polib==1.2.0 # via edx-i18n-tools # via click-repl -psutil==5.9.6 +psutil==5.9.5 # via # -r requirements/edx/paver.txt # edx-django-utils @@ -822,9 +830,9 @@ py2neo @ https://github.com/overhangio/py2neo/releases/download/2021.2.3/py2neo- # via # -c requirements/edx/../constraints.txt # -r requirements/edx/bundled.in -pyasn1==0.5.1 +pyasn1==0.5.0 # via pgpy -pycountry==23.12.11 +pycountry==22.3.5 # via -r requirements/edx/kernel.in pycparser==2.21 # via cffi @@ -834,7 +842,8 @@ pycryptodomex==3.19.0 # edx-proctoring # lti-consumer-xblock # pyjwkest -pygments==2.17.2 + # snowflake-connector-python +pygments==2.16.1 # via # -r requirements/edx/bundled.in # py2neo @@ -882,7 +891,7 @@ pyparsing==3.1.1 # via # chem # openedx-calc -pyrsistent==0.20.0 +pyrsistent==0.19.3 # via optimizely-sdk pysrt==1.1.2 # via @@ -901,8 +910,6 @@ python-dateutil==2.8.2 # olxcleaner # ora2 # xblock -python-ipware==2.0.0 - # via django-ipware python-memcached==1.59 # via -r requirements/edx/paver.txt python-slugify==8.0.1 @@ -915,8 +922,9 @@ python3-openid==3.2.0 ; python_version >= "3" # social-auth-core python3-saml==1.16.0 # via -r requirements/edx/kernel.in -pytz==2023.3.post1 +pytz==2022.7.1 # via + # -c requirements/edx/../constraints.txt # -r requirements/edx/kernel.in # babel # django @@ -955,7 +963,7 @@ redis==5.0.1 # via # -r requirements/edx/kernel.in # walrus -referencing==0.32.0 +referencing==0.30.2 # via # jsonschema # jsonschema-specifications @@ -988,11 +996,11 @@ requests-oauthlib==1.3.1 # via # -r requirements/edx/kernel.in # social-auth-core -rpds-py==0.13.2 +rpds-py==0.10.4 # via # jsonschema # referencing -ruamel-yaml==0.18.5 +ruamel-yaml==0.17.35 # via drf-yasg ruamel-yaml-clib==0.2.8 # via ruamel-yaml @@ -1002,7 +1010,7 @@ rules==3.3 # edx-enterprise # edx-proctoring # openedx-learning -s3transfer==0.8.2 +s3transfer==0.7.0 # via boto3 sailthru-client==2.2.3 # via edx-ace @@ -1013,7 +1021,7 @@ scipy==1.7.3 # openedx-calc semantic-version==2.10.0 # via edx-drf-extensions -shapely==2.0.2 +shapely==2.0.1 # via -r requirements/edx/kernel.in simplejson==3.19.2 # via @@ -1054,11 +1062,10 @@ six==1.16.0 # python-memcached slumber==0.7.1 # via - # -r requirements/edx/kernel.in # edx-bulk-grades # edx-enterprise # edx-rest-api-client -snowflake-connector-python==3.6.0 +snowflake-connector-python==3.2.1 social-auth-app-django==5.0.0 # via # -c requirements/edx/../constraints.txt @@ -1085,7 +1092,7 @@ sqlparse==0.4.4 # -r requirements/edx/kernel.in # django # openedx-blockstore -staff-graded-xblock==2.2.0 +staff-graded-xblock==2.1.1 # via -r requirements/edx/bundled.in stevedore==5.1.0 # via @@ -1100,23 +1107,22 @@ super-csv==3.1.0 # via edx-bulk-grades sympy==1.12 # via openedx-calc -testfixtures==7.2.2 +testfixtures==7.2.0 text-unidecode==1.3 # via python-slugify tinycss2==1.2.1 # via bleach -tomlkit==0.12.3 +tomlkit==0.12.1 # via snowflake-connector-python tqdm==4.66.1 # via # nltk # openai -typing-extensions==4.9.0 +typing-extensions==4.8.0 # via # -r requirements/edx/paver.txt # asgiref # django-countries - # drf-spectacular # edx-opaque-keys # kombu # pylti1p3 @@ -1134,7 +1140,7 @@ uritemplate==4.1.1 # coreapi # drf-spectacular # drf-yasg -urllib3==1.26.18 +urllib3==1.26.17 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/paver.txt @@ -1149,13 +1155,13 @@ user-util==1.0.0 # amqp # celery # kombu -voluptuous==0.14.1 +voluptuous==0.13.1 # via ora2 walrus==0.9.3 # via edx-event-bus-redis watchdog==3.0.0 # via -r requirements/edx/paver.txt -wcwidth==0.2.12 +wcwidth==0.2.8 # via prompt-toolkit web-fragments==2.1.0 # via @@ -1174,11 +1180,11 @@ webob==1.8.7 # via # -r requirements/edx/kernel.in # xblock -wrapt==1.16.0 +wrapt==1.15.0 # via # -r requirements/edx/paver.txt # deprecated -xblock[django]==1.9.0 +xblock[django]==1.8.1 # via # -r requirements/edx/kernel.in # acid-xblock @@ -1190,25 +1196,28 @@ xblock[django]==1.9.0 # lti-consumer-xblock # ora2 # staff-graded-xblock - # xblock-drag-and-drop-v2 # xblock-google-drive # xblock-poll # xblock-utils -xblock-drag-and-drop-v2==3.3.0 +xblock-drag-and-drop-v2==3.2.0 # via -r requirements/edx/bundled.in -xblock-google-drive==0.5.0 +xblock-google-drive==0.4.0 # via -r requirements/edx/bundled.in xblock-poll==1.13.0 # via -r requirements/edx/bundled.in xblock-utils==4.0.0 # via + # done-xblock # edx-sga + # lti-consumer-xblock + # staff-graded-xblock + # xblock-drag-and-drop-v2 # xblock-google-drive xmlsec==1.3.13 # via python3-saml xss-utils==0.5.0 # via -r requirements/edx/kernel.in -yarl==1.9.4 +yarl==1.9.2 # via aiohttp zipp==3.17.0 # via diff --git a/requirements/js_test.txt b/requirements/js_test.txt index 967c73c540..49ff64e80f 100644 --- a/requirements/js_test.txt +++ b/requirements/js_test.txt @@ -6,19 +6,19 @@ # annotated-types==0.6.0 # via pydantic -attrs==23.2.0 +attrs==23.1.0 # via # outcome # trio autocommand==2.2.2 # via jaraco-text -certifi==2023.11.17 +certifi==2023.7.22 # via selenium cheroot==10.0.0 # via cherrypy -cherrypy==18.9.0 +cherrypy==18.8.0 # via jasmine -exceptiongroup==1.2.0 +exceptiongroup==1.1.3 # via # trio # trio-websocket @@ -26,7 +26,7 @@ glob2==0.7 # via jasmine-core h11==0.14.0 # via wsproto -idna==3.6 +idna==3.4 # via trio importlib-resources==6.1.1 # via jaraco-text @@ -34,7 +34,7 @@ inflect==7.0.0 # via jaraco-text jaraco-classes==3.3.0 # via -r requirements/js_test.in -jaraco-collections==5.0.0 +jaraco-collections==4.3.0 # via # -r requirements/js_test.in # cherrypy @@ -46,7 +46,7 @@ jaraco-functools==4.0.0 # cheroot # jaraco-text # tempora -jaraco-text==3.12.0 +jaraco-text==3.11.1 # via jaraco-collections jasmine==3.99.0 # via -r requirements/js_test.in @@ -56,7 +56,7 @@ jinja2==2.11.3 # via jasmine markupsafe==2.1.3 # via jinja2 -more-itertools==10.2.0 +more-itertools==10.1.0 # via # cheroot # cherrypy @@ -69,9 +69,9 @@ outcome==1.3.0.post0 # via trio portend==3.2.0 # via cherrypy -pydantic==2.5.3 +pydantic==2.4.2 # via inflect -pydantic-core==2.14.6 +pydantic-core==2.10.1 # via pydantic pysocks==1.7.1 # via urllib3 @@ -79,7 +79,7 @@ pytz==2023.3.post1 # via tempora pyyaml==6.0.1 # via jasmine -selenium==4.16.0 +selenium==4.15.2 # via jasmine sniffio==1.3.0 # via trio @@ -89,22 +89,20 @@ tempora==5.5.0 # via # -r requirements/js_test.in # portend -trio==0.24.0 +trio==0.23.1 # via # selenium # trio-websocket trio-websocket==0.11.1 # via selenium -typing-extensions==4.9.0 +typing-extensions==4.8.0 # via # annotated-types # inflect # pydantic # pydantic-core -urllib3[socks]==2.1.0 - # via - # selenium - # urllib3 +urllib3[socks]==2.0.7 + # via selenium wsproto==1.2.0 # via trio-websocket zc-lockfile==3.0.post1 diff --git a/requirements/test-master.txt b/requirements/test-master.txt index 8c3d25ecaf..6f7c3fcff4 100644 --- a/requirements/test-master.txt +++ b/requirements/test-master.txt @@ -4,7 +4,7 @@ # # make upgrade # -aiohttp==3.9.1 +aiohttp==3.8.6 # via # -c requirements/edx-platform-constraints.txt # openai @@ -26,6 +26,7 @@ asgiref==3.7.2 asn1crypto==1.5.1 # via # -c requirements/edx-platform-constraints.txt + # oscrypto # snowflake-connector-python async-timeout==4.0.3 # via @@ -46,11 +47,11 @@ bleach==6.1.0 # via # -c requirements/edx-platform-constraints.txt # -r requirements/base.in -celery==5.3.6 +celery==5.3.4 # via # -c requirements/constraints.txt # -r requirements/base.in -certifi==2023.11.17 +certifi==2023.7.22 # via # -c requirements/edx-platform-constraints.txt # requests @@ -64,6 +65,7 @@ cffi==1.16.0 charset-normalizer==2.0.12 # via # -c requirements/edx-platform-constraints.txt + # aiohttp # requests # snowflake-connector-python click==8.1.7 @@ -114,7 +116,7 @@ deprecated==1.2.14 # via # -c requirements/edx-platform-constraints.txt # jwcrypto -django==3.2.23 +django==3.2.22 # via # -c requirements/common_constraints.txt # -c requirements/edx-platform-constraints.txt @@ -159,11 +161,11 @@ django-fernet-fields-v2==0.9 # via # -c requirements/edx-platform-constraints.txt # -r requirements/base.in -django-filter==23.5 +django-filter==23.3 # via # -c requirements/edx-platform-constraints.txt # -r requirements/base.in -django-ipware==6.0.2 +django-ipware==5.0.1 # via # -c requirements/edx-platform-constraints.txt # -r requirements/base.in @@ -188,7 +190,7 @@ django-simple-history==3.1.1 # via # -c requirements/constraints.txt # -r requirements/base.in -django-waffle==4.1.0 +django-waffle==4.0.0 # via # -c requirements/edx-platform-constraints.txt # -r requirements/base.in @@ -224,7 +226,7 @@ edx-braze-client==0.1.8 # via # -c requirements/edx-platform-constraints.txt # -r requirements/base.in -edx-django-utils==5.9.0 +edx-django-utils==5.7.0 # via # -c requirements/edx-platform-constraints.txt # -r requirements/base.in @@ -232,7 +234,7 @@ edx-django-utils==5.9.0 # edx-drf-extensions # edx-rest-api-client # edx-toggles -edx-drf-extensions==9.1.2 +edx-drf-extensions==8.12.0 # via # -c requirements/edx-platform-constraints.txt # -r requirements/base.in @@ -246,7 +248,7 @@ edx-rbac==1.8.0 # via # -c requirements/edx-platform-constraints.txt # -r requirements/base.in -edx-rest-api-client==5.6.1 +edx-rest-api-client==5.6.0 # via # -c requirements/edx-platform-constraints.txt # -r requirements/base.in @@ -258,7 +260,7 @@ edx-toggles==5.1.0 # via # -c requirements/edx-platform-constraints.txt # -r requirements/base.in -filelock==3.13.1 +filelock==3.12.4 # via # -c requirements/edx-platform-constraints.txt # snowflake-connector-python @@ -267,7 +269,7 @@ frozenlist==1.4.0 # -c requirements/edx-platform-constraints.txt # aiohttp # aiosignal -idna==3.6 +idna==3.4 # via # -c requirements/edx-platform-constraints.txt # requests @@ -298,7 +300,7 @@ jwcrypto==1.5.0 # via # -c requirements/edx-platform-constraints.txt # django-oauth-toolkit -kombu==5.3.5 +kombu==5.3.3 # via celery markupsafe==2.1.3 # via @@ -309,7 +311,7 @@ multidict==6.0.4 # -c requirements/edx-platform-constraints.txt # aiohttp # yarl -newrelic==9.3.0 +newrelic==9.1.0 # via # -c requirements/edx-platform-constraints.txt # edx-django-utils @@ -321,12 +323,16 @@ openai==0.28.1 # via # -c requirements/edx-platform-constraints.txt # -r requirements/base.in +oscrypto==1.3.0 + # via + # -c requirements/edx-platform-constraints.txt + # snowflake-connector-python packaging==23.2 # via # -c requirements/edx-platform-constraints.txt # drf-yasg # snowflake-connector-python -path==16.9.0 +path==16.7.1 # via # -c requirements/edx-platform-constraints.txt # path-py @@ -334,7 +340,7 @@ path-py==12.5.0 # via # -c requirements/edx-platform-constraints.txt # -r requirements/base.in -pbr==6.0.0 +pbr==5.11.1 # via # -c requirements/edx-platform-constraints.txt # stevedore @@ -342,7 +348,7 @@ pgpy==0.6.0 # via # -c requirements/edx-platform-constraints.txt # -r requirements/base.in -pillow==10.1.0 +pillow==9.5.0 # via # -c requirements/edx-platform-constraints.txt # -r requirements/base.in @@ -350,13 +356,13 @@ platformdirs==3.11.0 # via # -c requirements/edx-platform-constraints.txt # snowflake-connector-python -prompt-toolkit==3.0.43 +prompt-toolkit==3.0.39 # via click-repl -psutil==5.9.6 +psutil==5.9.5 # via # -c requirements/edx-platform-constraints.txt # edx-django-utils -pyasn1==0.5.1 +pyasn1==0.5.0 # via # -c requirements/edx-platform-constraints.txt # pgpy @@ -364,13 +370,16 @@ pycparser==2.21 # via # -c requirements/edx-platform-constraints.txt # cffi +pycryptodomex==3.19.0 + # via + # -c requirements/edx-platform-constraints.txt + # snowflake-connector-python pyjwt[crypto]==2.8.0 # via # -c requirements/edx-platform-constraints.txt # drf-jwt # edx-drf-extensions # edx-rest-api-client - # pyjwt # snowflake-connector-python pymongo==3.13.0 # via @@ -389,15 +398,11 @@ python-dateutil==2.8.2 # -c requirements/edx-platform-constraints.txt # -r requirements/base.in # celery -python-ipware==2.0.0 - # via - # -c requirements/edx-platform-constraints.txt - # django-ipware python-slugify==8.0.1 # via # -c requirements/edx-platform-constraints.txt # code-annotations -pytz==2023.3.post1 +pytz==2022.7.1 # via # -c requirements/edx-platform-constraints.txt # -r requirements/base.in @@ -421,7 +426,7 @@ requests==2.31.0 # openai # slumber # snowflake-connector-python -ruamel-yaml==0.18.5 +ruamel-yaml==0.17.35 # via # -c requirements/edx-platform-constraints.txt # drf-yasg @@ -448,7 +453,7 @@ slumber==0.7.1 # -c requirements/edx-platform-constraints.txt # -r requirements/base.in # edx-rest-api-client -snowflake-connector-python==3.6.0 +snowflake-connector-python==3.2.1 # via # -c requirements/edx-platform-constraints.txt # -r requirements/base.in @@ -467,7 +472,7 @@ stevedore==5.1.0 # code-annotations # edx-django-utils # edx-opaque-keys -testfixtures==7.2.2 +testfixtures==7.2.0 # via # -c requirements/edx-platform-constraints.txt # -r requirements/base.in @@ -475,7 +480,7 @@ text-unidecode==1.3 # via # -c requirements/edx-platform-constraints.txt # python-slugify -tomlkit==0.12.3 +tomlkit==0.12.1 # via # -c requirements/edx-platform-constraints.txt # snowflake-connector-python @@ -483,7 +488,7 @@ tqdm==4.66.1 # via # -c requirements/edx-platform-constraints.txt # openai -typing-extensions==4.9.0 +typing-extensions==4.8.0 # via # -c requirements/edx-platform-constraints.txt # asgiref @@ -505,7 +510,7 @@ uritemplate==4.1.1 # -c requirements/edx-platform-constraints.txt # coreapi # drf-yasg -urllib3==1.26.18 +urllib3==1.26.17 # via # -c requirements/edx-platform-constraints.txt # requests @@ -515,7 +520,7 @@ vine==5.1.0 # amqp # celery # kombu -wcwidth==0.2.12 +wcwidth==0.2.8 # via # -c requirements/edx-platform-constraints.txt # prompt-toolkit @@ -523,11 +528,11 @@ webencodings==0.5.1 # via # -c requirements/edx-platform-constraints.txt # bleach -wrapt==1.16.0 +wrapt==1.15.0 # via # -c requirements/edx-platform-constraints.txt # deprecated -yarl==1.9.4 +yarl==1.9.2 # via # -c requirements/edx-platform-constraints.txt # aiohttp diff --git a/requirements/test.txt b/requirements/test.txt index bc26e50556..6880b62747 100644 --- a/requirements/test.txt +++ b/requirements/test.txt @@ -4,7 +4,7 @@ # # make upgrade # -aiohttp==3.9.1 +aiohttp==3.8.6 # via # -r requirements/test-master.txt # openai @@ -27,6 +27,7 @@ asgiref==3.7.2 asn1crypto==1.5.1 # via # -r requirements/test-master.txt + # oscrypto # snowflake-connector-python async-timeout==4.0.3 # via @@ -41,7 +42,6 @@ backports-zoneinfo[tzdata]==0.2.1 # via # -r requirements/test-master.txt # -r requirements/test.in - # backports-zoneinfo # celery # kombu # via @@ -52,7 +52,7 @@ bleach==6.1.0 # via # -c requirements/constraints.txt # -r requirements/test-master.txt -certifi==2023.11.17 +certifi==2023.7.22 # via # -r requirements/test-master.txt # requests @@ -68,6 +68,7 @@ chardet==5.2.0 charset-normalizer==2.0.12 # via # -r requirements/test-master.txt + # aiohttp # requests # snowflake-connector-python # via @@ -101,10 +102,8 @@ coreschema==0.0.4 # -r requirements/test-master.txt # coreapi # drf-yasg -coverage[toml]==7.4.0 - # via - # coverage - # pytest-cov +coverage[toml]==7.3.2 + # via pytest-cov cryptography==38.0.4 # via # -r requirements/test-master.txt @@ -124,7 +123,7 @@ deprecated==1.2.14 # via # -r requirements/test-master.txt # jwcrypto -diff-cover==8.0.2 +diff-cover==8.0.0 # via -r requirements/test.in # via # -c requirements/common_constraints.txt @@ -160,9 +159,9 @@ django-crum==0.7.9 # edx-toggles django-fernet-fields-v2==0.9 # via -r requirements/test-master.txt -django-filter==23.5 +django-filter==23.3 # via -r requirements/test-master.txt -django-ipware==6.0.2 +django-ipware==5.0.1 # via -r requirements/test-master.txt django-model-utils==4.3.1 # via @@ -179,7 +178,7 @@ django-simple-history==3.1.1 # via # -c requirements/constraints.txt # -r requirements/test-master.txt -django-waffle==4.1.0 +django-waffle==4.0.0 # via # -r requirements/test-master.txt # edx-django-utils @@ -207,14 +206,14 @@ edx-api-doc-tools==1.7.0 # via -r requirements/test-master.txt edx-braze-client==0.1.8 # via -r requirements/test-master.txt -edx-django-utils==5.9.0 +edx-django-utils==5.7.0 # via # -r requirements/test-master.txt # django-config-models # edx-drf-extensions # edx-rest-api-client # edx-toggles -edx-drf-extensions==9.1.2 +edx-drf-extensions==8.12.0 # via # -r requirements/test-master.txt # edx-rbac @@ -222,10 +221,9 @@ edx-opaque-keys[django]==2.5.1 # via # -r requirements/test-master.txt # edx-drf-extensions - # edx-opaque-keys edx-rbac==1.8.0 # via -r requirements/test-master.txt -edx-rest-api-client==5.6.1 +edx-rest-api-client==5.6.0 # via -r requirements/test-master.txt edx-tincan-py35==1.0.0 # via -r requirements/test-master.txt @@ -235,9 +233,9 @@ factory-boy==3.3.0 # via # -c requirements/constraints.txt # -r requirements/test.in -faker==22.2.0 +faker==19.13.0 # via factory-boy -filelock==3.13.1 +filelock==3.12.4 # via # -r requirements/test-master.txt # snowflake-connector-python @@ -250,7 +248,7 @@ frozenlist==1.4.0 # -r requirements/test-master.txt # aiohttp # aiosignal -idna==3.6 +idna==3.4 # via # -r requirements/test-master.txt # requests @@ -296,7 +294,7 @@ multidict==6.0.4 # -r requirements/test-master.txt # aiohttp # yarl -newrelic==9.3.0 +newrelic==9.1.0 # via # -r requirements/test-master.txt # edx-django-utils @@ -306,25 +304,29 @@ oauthlib==3.2.2 # django-oauth-toolkit openai==0.28.1 # via -r requirements/test-master.txt +oscrypto==1.3.0 + # via + # -r requirements/test-master.txt + # snowflake-connector-python packaging==23.2 # via # -r requirements/test-master.txt # drf-yasg # pytest # snowflake-connector-python -path==16.9.0 +path==16.7.1 # via # -r requirements/test-master.txt # path-py path-py==12.5.0 # via -r requirements/test-master.txt -pbr==6.0.0 +pbr==5.11.1 # via # -r requirements/test-master.txt # stevedore pgpy==0.6.0 # via -r requirements/test-master.txt -pillow==10.1.0 +pillow==9.5.0 # via -r requirements/test-master.txt platformdirs==3.11.0 # via @@ -337,13 +339,13 @@ pluggy==1.3.0 # via # -r requirements/test-master.txt # click-repl -psutil==5.9.6 +psutil==5.9.5 # via # -r requirements/test-master.txt # edx-django-utils py==1.11.0 # via pytest -pyasn1==0.5.1 +pyasn1==0.5.0 # via # -r requirements/test-master.txt # pgpy @@ -351,7 +353,11 @@ pycparser==2.21 # via # -r requirements/test-master.txt # cffi -pygments==2.17.2 +pycryptodomex==3.19.0 + # via + # -r requirements/test-master.txt + # snowflake-connector-python +pygments==2.16.1 # via diff-cover pyjwt[crypto]==2.8.0 # via @@ -359,7 +365,6 @@ pyjwt[crypto]==2.8.0 # drf-jwt # edx-drf-extensions # edx-rest-api-client - # pyjwt # snowflake-connector-python pymongo==3.13.0 # via @@ -388,15 +393,11 @@ python-dateutil==2.8.2 # celery # faker # freezegun -python-ipware==2.0.0 - # via - # -r requirements/test-master.txt - # django-ipware python-slugify==8.0.1 # via # -r requirements/test-master.txt # code-annotations -pytz==2023.3.post1 +pytz==2022.7.1 # via # -r requirements/test-master.txt # django @@ -423,7 +424,7 @@ responses==0.10.15 # via # -c requirements/constraints.txt # -r requirements/test.in -ruamel-yaml==0.18.5 +ruamel-yaml==0.17.35 # via # -r requirements/test-master.txt # drf-yasg @@ -450,7 +451,7 @@ slumber==0.7.1 # via # -r requirements/test-master.txt # edx-rest-api-client -snowflake-connector-python==3.6.0 +snowflake-connector-python==3.2.1 # via -r requirements/test-master.txt sortedcontainers==2.4.0 # via @@ -466,7 +467,7 @@ stevedore==5.1.0 # code-annotations # edx-django-utils # edx-opaque-keys -testfixtures==7.2.2 +testfixtures==7.2.0 # via # -r requirements/test-master.txt # -r requirements/test.in @@ -478,7 +479,7 @@ toml==0.10.2 # via pytest tomli==2.0.1 # via coverage -tomlkit==0.12.3 +tomlkit==0.12.1 # via # -r requirements/test-master.txt # snowflake-connector-python @@ -486,7 +487,7 @@ tqdm==4.66.1 # via # -r requirements/test-master.txt # openai -typing-extensions==4.9.0 +typing-extensions==4.8.0 # via # -r requirements/test-master.txt # asgiref @@ -507,7 +508,7 @@ uritemplate==4.1.1 # -r requirements/test-master.txt # coreapi # drf-yasg -urllib3==1.26.18 +urllib3==1.26.17 # via # -r requirements/test-master.txt # requests @@ -517,7 +518,7 @@ urllib3==1.26.18 # amqp # celery # kombu -wcwidth==0.2.12 +wcwidth==0.2.8 # via # -r requirements/test-master.txt # prompt-toolkit @@ -525,11 +526,11 @@ webencodings==0.5.1 # via # -r requirements/test-master.txt # bleach -wrapt==1.16.0 +wrapt==1.15.0 # via # -r requirements/test-master.txt # deprecated -yarl==1.9.4 +yarl==1.9.2 # via # -r requirements/test-master.txt # aiohttp From a9734d80b5bb6016a7f6639af06e854f0aa7bb92 Mon Sep 17 00:00:00 2001 From: hamzawaleed01 Date: Mon, 22 Jan 2024 18:09:24 +0500 Subject: [PATCH 089/164] feat: WIP - update cornerstone client to store cornerstone API calls in db --- .../cornerstone/admin/__init__.py | 10 ++ integrated_channels/cornerstone/client.py | 12 ++- ...alter_cornerstoneapirequestlogs_options.py | 17 ++++ integrated_channels/cornerstone/models.py | 1 + integrated_channels/cornerstone/utils.py | 32 +++++++ .../migrations/0032_auto_20240122_1206.py | 27 ++++++ .../integrated_channel/models.py | 14 +-- .../test_cornerstone/test_client.py | 91 +++++++++++++++++++ 8 files changed, 197 insertions(+), 7 deletions(-) create mode 100644 integrated_channels/cornerstone/migrations/0033_alter_cornerstoneapirequestlogs_options.py create mode 100644 integrated_channels/integrated_channel/migrations/0032_auto_20240122_1206.py create mode 100644 tests/test_integrated_channels/test_cornerstone/test_client.py diff --git a/integrated_channels/cornerstone/admin/__init__.py b/integrated_channels/cornerstone/admin/__init__.py index cb9c791e49..413da0e385 100644 --- a/integrated_channels/cornerstone/admin/__init__.py +++ b/integrated_channels/cornerstone/admin/__init__.py @@ -13,6 +13,7 @@ CornerstoneEnterpriseCustomerConfiguration, CornerstoneGlobalConfiguration, CornerstoneLearnerDataTransmissionAudit, + CornerstoneAPIRequestLogs ) from integrated_channels.integrated_channel.admin import BaseLearnerDataTransmissionAuditAdmin @@ -138,3 +139,12 @@ def user_email(self, obj): being rendered with this admin form. """ return obj.user.email + +@admin.register(CornerstoneAPIRequestLogs) +class CornerstoneAPIRequestLogAdmin(DjangoObjectActions, admin.ModelAdmin): + """ + Django admin model for CornerstoneAPIRequestLogs. + """ + + class Meta: + model = CornerstoneAPIRequestLogs diff --git a/integrated_channels/cornerstone/client.py b/integrated_channels/cornerstone/client.py index 5a9bed5270..a5ae41c09e 100644 --- a/integrated_channels/cornerstone/client.py +++ b/integrated_channels/cornerstone/client.py @@ -10,7 +10,7 @@ from django.apps import apps -from integrated_channels.cornerstone.utils import get_or_create_key_pair +from integrated_channels.cornerstone.utils import get_or_create_key_pair, store_cornerstone_api_calls from integrated_channels.integrated_channel.client import IntegratedChannelApiClient from integrated_channels.utils import generate_formatted_log @@ -113,6 +113,16 @@ def create_course_completion(self, user_id, payload): 'Content-Type': 'application/json' } ) + store_cornerstone_api_calls( + user_agent='asdf', + user_ip='123', + enterprise_customer=self.enterprise_configuration.enterprise_customer, + endpoint=url, + payload=json_payload['data'], + time_taken=100, + status_code=200, + response_body='{}' + ) return response.status_code, response.text def create_assessment_reporting(self, user_id, payload): diff --git a/integrated_channels/cornerstone/migrations/0033_alter_cornerstoneapirequestlogs_options.py b/integrated_channels/cornerstone/migrations/0033_alter_cornerstoneapirequestlogs_options.py new file mode 100644 index 0000000000..016d210399 --- /dev/null +++ b/integrated_channels/cornerstone/migrations/0033_alter_cornerstoneapirequestlogs_options.py @@ -0,0 +1,17 @@ +# Generated by Django 3.2.23 on 2024-01-22 12:06 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('cornerstone', '0032_cornerstonelearnerdatatransmissionaudit_transmission_status'), + ] + + operations = [ + migrations.AlterModelOptions( + name='cornerstoneapirequestlogs', + options={'verbose_name_plural': 'Cornerstone API request logs'}, + ), + ] diff --git a/integrated_channels/cornerstone/models.py b/integrated_channels/cornerstone/models.py index 46592bc4ea..aa4b696cd7 100644 --- a/integrated_channels/cornerstone/models.py +++ b/integrated_channels/cornerstone/models.py @@ -330,6 +330,7 @@ class CornerstoneAPIRequestLogs(IntegratedChannelAPIRequestLogs): class Meta: app_label = 'cornerstone' + verbose_name_plural = "Cornerstone API request logs" def __str__(self): """ diff --git a/integrated_channels/cornerstone/utils.py b/integrated_channels/cornerstone/utils.py index e6c1defbb9..37b9d5aed5 100644 --- a/integrated_channels/cornerstone/utils.py +++ b/integrated_channels/cornerstone/utils.py @@ -22,6 +22,13 @@ def cornerstone_course_key_model(): return apps.get_model('cornerstone', 'CornerstoneCourseKey') +def cornerstone_request_log__model(): + """ + Returns the ``CornerstoneAPIRequestLogs`` class. + """ + return apps.get_model('cornerstone', 'CornerstoneAPIRequestLogs') + + LOGGER = getLogger(__name__) @@ -80,3 +87,28 @@ def get_or_create_key_pair(course_id): internal_course_id=course_id, defaults={ 'external_course_id': str(uuid4())}) return key_mapping + + +def store_cornerstone_api_calls( + user_agent, + user_ip, + enterprise_customer, + endpoint, + payload, + time_taken, + status_code, + response_body, +): + """ + Creates new record in CornerstoneAPIRequestLogs table. + """ + cornerstone_request_log__model().objects.create( + user_agent=user_agent, + user_ip=user_ip, + enterprise_customer=enterprise_customer, + endpoint=endpoint, + payload=payload, + time_taken=time_taken, + status_code=status_code, + response_body=response_body, + ) diff --git a/integrated_channels/integrated_channel/migrations/0032_auto_20240122_1206.py b/integrated_channels/integrated_channel/migrations/0032_auto_20240122_1206.py new file mode 100644 index 0000000000..c45ba9431f --- /dev/null +++ b/integrated_channels/integrated_channel/migrations/0032_auto_20240122_1206.py @@ -0,0 +1,27 @@ +# Generated by Django 3.2.23 on 2024-01-22 12:06 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('integrated_channel', '0031_genericlearnerdatatransmissionaudit_transmission_status'), + ] + + operations = [ + migrations.RemoveField( + model_name='integratedchannelapirequestlogs', + name='api_record', + ), + migrations.AddField( + model_name='integratedchannelapirequestlogs', + name='response_body', + field=models.TextField(blank=True, help_text='TAPI call response body', null=True), + ), + migrations.AddField( + model_name='integratedchannelapirequestlogs', + name='status_code', + field=models.PositiveIntegerField(blank=True, help_text='API call response HTTP status code', null=True), + ), + ] diff --git a/integrated_channels/integrated_channel/models.py b/integrated_channels/integrated_channel/models.py index cc0d60fc64..436b7258ac 100644 --- a/integrated_channels/integrated_channel/models.py +++ b/integrated_channels/integrated_channel/models.py @@ -910,13 +910,15 @@ class IntegratedChannelAPIRequestLogs(TimeStampedModel): endpoint = models.TextField(blank=False, null=False) payload = models.TextField(blank=False, null=False) time_taken = models.DurationField(blank=False, null=False) - api_record = models.OneToOneField( - ApiResponseRecord, + status_code = models.PositiveIntegerField( + help_text='API call response HTTP status code', blank=True, - null=True, - on_delete=models.CASCADE, - help_text=_( - 'Data pertaining to the transmissions API request response.') + null=True + ) + response_body = models.TextField( + help_text='API call response body', + blank=True, + null=True ) class Meta: diff --git a/tests/test_integrated_channels/test_cornerstone/test_client.py b/tests/test_integrated_channels/test_cornerstone/test_client.py new file mode 100644 index 0000000000..92cfb21bad --- /dev/null +++ b/tests/test_integrated_channels/test_cornerstone/test_client.py @@ -0,0 +1,91 @@ +""" +Tests for Degreed2 client for integrated_channels. +""" + +import datetime +import json +import unittest + +import mock +import pytest +import requests +import responses +from freezegun import freeze_time +from six.moves.urllib.parse import urljoin + +from django.apps.registry import apps + +from enterprise.api_client.enterprise_catalog import EnterpriseCatalogApiClient +from enterprise.models import EnterpriseCustomerUser +from integrated_channels.cornerstone.client import CornerstoneAPIClient +from integrated_channels.exceptions import ClientError +from test_utils import factories + +NOW = datetime.datetime(2017, 1, 2, 3, 4, 5, tzinfo=datetime.timezone.utc) +NOW_TIMESTAMP_FORMATTED = NOW.strftime("%F") + + +def create_course_payload(): + return json.dumps( + { + "courses": [ + { + "title": "title", + "summary": "description", + "image-url": "image", + "url": "enrollment_url", + "language": "content_language", + "external-id": "key", + "duration": "duration", + "duration-type": "Days", + } + ], + }, + sort_keys=True, + ).encode("utf-8") + + +@pytest.mark.django_db +@freeze_time(NOW) +class TestCornerstoneApiClient(unittest.TestCase): + """ + Test Degreed2 API client methods. + """ + + def setUp(self): + super().setUp() + self.cornerstone_base_url = "https://edx.example.com/" + self.csod_config = factories.CornerstoneEnterpriseCustomerConfigurationFactory( + cornerstone_base_url=self.cornerstone_base_url + ) + + @responses.activate + def test_create_course_completion(self): + """ + ``create_course_completion`` should use the appropriate URLs for transmission. + """ + cornerstone_api_client = CornerstoneAPIClient(self.csod_config) + callbackUrl = "dummy_callback_url" + sessionToken = "dummy_session_oken" + payload = { + "data": { + "userGuid": "dummy_id", + "sessionToken": sessionToken, + "callbackUrl": callbackUrl, + "subdomain": "dummy_subdomain", + } + } + responses.add( + responses.POST, + f"{self.cornerstone_base_url}{callbackUrl}?sessionToken={sessionToken}", + json="{}", + status=200, + ) + output = cornerstone_api_client.create_course_completion( + "test-learner@example.com", json.dumps(payload) + ) + + assert output == (200, '"{}"') + # assert len(responses.calls) == 2 + # assert responses.calls[0].request.url == cornerstone_api_client.get_oauth_url() + # assert responses.calls[1].request.url == cornerstone_api_client.get_completions_url() From 966e18fbfdc9f9ae31bab68c8406240220dde72e Mon Sep 17 00:00:00 2001 From: John Nagro Date: Mon, 22 Jan 2024 10:32:19 -0500 Subject: [PATCH 090/164] feat: create/use an ENT-CAT count method (#1995) --- CHANGELOG.rst | 6 ++++++ enterprise/__init__.py | 2 +- enterprise/api_client/enterprise_catalog.py | 10 ++++++++++ .../compare_discovery_and_enterprise_catalogs.py | 2 +- 4 files changed, 18 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index e7795974e5..d4446868eb 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -16,6 +16,12 @@ Change Log Unreleased ---------- +[4.10.5] +-------- + +fix: tweak catalog compare mgmt command + + [4.10.4] -------- diff --git a/enterprise/__init__.py b/enterprise/__init__.py index a7d65f0abd..a3d8dca62d 100644 --- a/enterprise/__init__.py +++ b/enterprise/__init__.py @@ -2,4 +2,4 @@ Your project description goes here. """ -__version__ = "4.10.4" +__version__ = "4.10.5" diff --git a/enterprise/api_client/enterprise_catalog.py b/enterprise/api_client/enterprise_catalog.py index 6a3285cf65..9800a444d0 100644 --- a/enterprise/api_client/enterprise_catalog.py +++ b/enterprise/api_client/enterprise_catalog.py @@ -258,6 +258,16 @@ def get_content_metadata(self, enterprise_customer, enterprise_catalogs=None, co return list(content_metadata.values()) + @UserAPIClient.refresh_token + def get_catalog_content_count(self, catalog_uuid): + """ + Gets the content metadata count for a catalog from the first page of results + """ + api_url = self.get_api_url(self.GET_CONTENT_METADATA_ENDPOINT.format(catalog_uuid)) + response = self.client.get(api_url) + response.raise_for_status() + return response.json()['count'] + @UserAPIClient.refresh_token def refresh_catalogs(self, enterprise_catalogs): """ diff --git a/enterprise/management/commands/compare_discovery_and_enterprise_catalogs.py b/enterprise/management/commands/compare_discovery_and_enterprise_catalogs.py index efcf4510eb..5d6492cd31 100644 --- a/enterprise/management/commands/compare_discovery_and_enterprise_catalogs.py +++ b/enterprise/management/commands/compare_discovery_and_enterprise_catalogs.py @@ -66,7 +66,7 @@ def handle(self, *args, **options): new_content_filter['course_type__exclude'] = 'executive-education-2u' new_content_filter_json = json.dumps(new_content_filter) discovery_count = discovery_client.get_catalog_results_from_discovery(new_content_filter).get('count') - enterprise_count = enterprise_catalog_client.get_enterprise_catalog(customer_catalog.uuid).get('count') + enterprise_count = enterprise_catalog_client.get_catalog_content_count(customer_catalog.uuid) logger.info( 'compare_discovery_and_enterprise_catalogs catalog ' f'{customer_catalog.uuid} ' From 1ac2e15b5f80b1e719fad08869b5320f62272660 Mon Sep 17 00:00:00 2001 From: John Nagro Date: Mon, 22 Jan 2024 15:54:36 -0500 Subject: [PATCH 091/164] feat: tweaks for better data (#1996) --- CHANGELOG.rst | 6 +++ enterprise/__init__.py | 2 +- ...mpare_discovery_and_enterprise_catalogs.py | 42 +++++++++++++------ 3 files changed, 37 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index d4446868eb..fad2920b33 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -16,6 +16,12 @@ Change Log Unreleased ---------- +[4.10.6] +-------- + +fix: tweak catalog compare mgmt command + + [4.10.5] -------- diff --git a/enterprise/__init__.py b/enterprise/__init__.py index a3d8dca62d..2f7ede3f73 100644 --- a/enterprise/__init__.py +++ b/enterprise/__init__.py @@ -2,4 +2,4 @@ Your project description goes here. """ -__version__ = "4.10.5" +__version__ = "4.10.6" diff --git a/enterprise/management/commands/compare_discovery_and_enterprise_catalogs.py b/enterprise/management/commands/compare_discovery_and_enterprise_catalogs.py index 5d6492cd31..e48b569e92 100644 --- a/enterprise/management/commands/compare_discovery_and_enterprise_catalogs.py +++ b/enterprise/management/commands/compare_discovery_and_enterprise_catalogs.py @@ -6,6 +6,8 @@ import json import logging +from requests.exceptions import HTTPError + from django.core.management import BaseCommand from enterprise.api_client.discovery import CourseCatalogApiServiceClient @@ -43,6 +45,13 @@ def handle(self, *args, **options): ) continue + if catalog_query.content_filter.get('aggregation_key'): + logger.info( + 'compare_discovery_and_enterprise_catalogs ' + f'query {catalog_query.id} references aggregation_key somehow' + ) + continue + new_content_filter = copy.deepcopy(catalog_query.content_filter) new_content_filter['course_type__exclude'] = 'executive-education-2u' new_content_filter_json = json.dumps(new_content_filter) @@ -62,15 +71,24 @@ def handle(self, *args, **options): ) continue - new_content_filter = copy.deepcopy(customer_catalog.content_filter) - new_content_filter['course_type__exclude'] = 'executive-education-2u' - new_content_filter_json = json.dumps(new_content_filter) - discovery_count = discovery_client.get_catalog_results_from_discovery(new_content_filter).get('count') - enterprise_count = enterprise_catalog_client.get_catalog_content_count(customer_catalog.uuid) - logger.info( - 'compare_discovery_and_enterprise_catalogs catalog ' - f'{customer_catalog.uuid} ' - f'discovery count: {discovery_count}, ' - f'enterprise count: {enterprise_count}, ' - f'new filter: {new_content_filter_json}' - ) + try: + old_content_filter = customer_catalog.content_filter + new_content_filter = copy.deepcopy(customer_catalog.content_filter) + new_content_filter['course_type__exclude'] = 'executive-education-2u' + new_content_filter_json = json.dumps(new_content_filter) + old_discovery_count = discovery_client.get_catalog_results_from_discovery(old_content_filter).get('count') # pylint: disable=line-too-long + new_discovery_count = discovery_client.get_catalog_results_from_discovery(new_content_filter).get('count') # pylint: disable=line-too-long + enterprise_count = enterprise_catalog_client.get_catalog_content_count(customer_catalog.uuid) + logger.info( + 'compare_discovery_and_enterprise_catalogs catalog ' + f'{customer_catalog.uuid} ' + f'existing discovery count: {old_discovery_count}, ' + f'new discovery count: {new_discovery_count}, ' + f'existing enterprise count: {enterprise_count}, ' + f'new filter: {new_content_filter_json}' + ) + except HTTPError: + logger.exception( + 'compare_discovery_and_enterprise_catalogs ' + f'error checking catalog {customer_catalog.uuid}' + ) From 95071abb991ecb1cb261da01dd8e37db872a439c Mon Sep 17 00:00:00 2001 From: hamzawaleed01 Date: Tue, 23 Jan 2024 16:01:46 +0500 Subject: [PATCH 092/164] feat: introduced base abstract model --- integrated_channels/cornerstone/client.py | 17 +++--- .../0031_cornerstoneapirequestlogs.py | 23 ++++++-- ...alter_cornerstoneapirequestlogs_options.py | 17 ------ integrated_channels/cornerstone/models.py | 29 +++++----- integrated_channels/cornerstone/utils.py | 6 ++- .../0030_integratedchannelapirequestlogs.py | 9 ++-- .../migrations/0032_auto_20240122_1206.py | 27 ---------- .../integrated_channel/models.py | 54 ++++++++++++------- 8 files changed, 85 insertions(+), 97 deletions(-) delete mode 100644 integrated_channels/cornerstone/migrations/0033_alter_cornerstoneapirequestlogs_options.py delete mode 100644 integrated_channels/integrated_channel/migrations/0032_auto_20240122_1206.py diff --git a/integrated_channels/cornerstone/client.py b/integrated_channels/cornerstone/client.py index a5ae41c09e..b003276ea9 100644 --- a/integrated_channels/cornerstone/client.py +++ b/integrated_channels/cornerstone/client.py @@ -5,6 +5,7 @@ import base64 import json import logging +import time import requests @@ -104,7 +105,7 @@ def create_course_completion(self, user_id, payload): completion_path=self.global_cornerstone_config.completion_status_api_path, session_token=session_token, ) - + start_time = time.time() response = requests.post( url, json=[json_payload['data']], @@ -113,16 +114,16 @@ def create_course_completion(self, user_id, payload): 'Content-Type': 'application/json' } ) + duration_seconds = time.time() - start_time store_cornerstone_api_calls( - user_agent='asdf', - user_ip='123', enterprise_customer=self.enterprise_configuration.enterprise_customer, + enterprise_customer_configuration_id=self.enterprise_configuration.id, endpoint=url, - payload=json_payload['data'], - time_taken=100, - status_code=200, - response_body='{}' - ) + payload=json_payload["data"], + time_taken=duration_seconds, + status_code=response.status_code, + response_body=response.text, + ) return response.status_code, response.text def create_assessment_reporting(self, user_id, payload): diff --git a/integrated_channels/cornerstone/migrations/0031_cornerstoneapirequestlogs.py b/integrated_channels/cornerstone/migrations/0031_cornerstoneapirequestlogs.py index 35a3c66b68..914b533ef8 100644 --- a/integrated_channels/cornerstone/migrations/0031_cornerstoneapirequestlogs.py +++ b/integrated_channels/cornerstone/migrations/0031_cornerstoneapirequestlogs.py @@ -1,13 +1,15 @@ -# Generated by Django 3.2.23 on 2024-01-15 07:54 +# Generated by Django 3.2.23 on 2024-01-23 10:59 from django.db import migrations, models import django.db.models.deletion +import django.utils.timezone +import model_utils.fields class Migration(migrations.Migration): dependencies = [ - ('integrated_channel', '0030_integratedchannelapirequestlogs'), + ('enterprise', '0198_alter_enterprisecourseenrollment_options'), ('cornerstone', '0030_auto_20231010_1654'), ] @@ -15,10 +17,21 @@ class Migration(migrations.Migration): migrations.CreateModel( name='CornerstoneAPIRequestLogs', fields=[ - ('integratedchannelapirequestlogs_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='integrated_channel.integratedchannelapirequestlogs')), - ('user_agent', models.CharField(max_length=255)), + ('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')), + ('enterprise_customer_configuration_id', models.IntegerField(help_text='ID from the EnterpriseCustomerConfiguration model')), + ('endpoint', models.TextField()), + ('payload', models.TextField()), + ('time_taken', models.FloatField()), + ('status_code', models.PositiveIntegerField(blank=True, help_text='API call response HTTP status code', null=True)), + ('response_body', models.TextField(blank=True, help_text='API call response body', null=True)), + ('user_agent', models.CharField(blank=True, max_length=255, null=True)), ('user_ip', models.GenericIPAddressField(blank=True, null=True)), + ('enterprise_customer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='enterprise.enterprisecustomer')), ], - bases=('integrated_channel.integratedchannelapirequestlogs',), + options={ + 'verbose_name_plural': 'Cornerstone API request logs', + }, ), ] diff --git a/integrated_channels/cornerstone/migrations/0033_alter_cornerstoneapirequestlogs_options.py b/integrated_channels/cornerstone/migrations/0033_alter_cornerstoneapirequestlogs_options.py deleted file mode 100644 index 016d210399..0000000000 --- a/integrated_channels/cornerstone/migrations/0033_alter_cornerstoneapirequestlogs_options.py +++ /dev/null @@ -1,17 +0,0 @@ -# Generated by Django 3.2.23 on 2024-01-22 12:06 - -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ('cornerstone', '0032_cornerstonelearnerdatatransmissionaudit_transmission_status'), - ] - - operations = [ - migrations.AlterModelOptions( - name='cornerstoneapirequestlogs', - options={'verbose_name_plural': 'Cornerstone API request logs'}, - ), - ] diff --git a/integrated_channels/cornerstone/models.py b/integrated_channels/cornerstone/models.py index aa4b696cd7..e51999135f 100644 --- a/integrated_channels/cornerstone/models.py +++ b/integrated_channels/cornerstone/models.py @@ -18,8 +18,8 @@ from integrated_channels.cornerstone.transmitters.content_metadata import CornerstoneContentMetadataTransmitter from integrated_channels.cornerstone.transmitters.learner_data import CornerstoneLearnerTransmitter from integrated_channels.integrated_channel.models import ( + BaseIntegratedChannelAPIRequestLogs, EnterpriseCustomerPluginConfiguration, - IntegratedChannelAPIRequestLogs, LearnerDataTransmissionAudit, ) from integrated_channels.utils import is_valid_url @@ -321,15 +321,16 @@ class Meta: app_label = 'cornerstone' -class CornerstoneAPIRequestLogs(IntegratedChannelAPIRequestLogs): +class CornerstoneAPIRequestLogs(BaseIntegratedChannelAPIRequestLogs): """ - A model to track basic information about every API call we make from the integrated channels. + A model to track basic information about every API call we make from the integrated channels. """ - user_agent = models.CharField(max_length=255) + + user_agent = models.CharField(blank=True, null=True, max_length=255) user_ip = models.GenericIPAddressField(blank=True, null=True) class Meta: - app_label = 'cornerstone' + app_label = "cornerstone" verbose_name_plural = "Cornerstone API request logs" def __str__(self): @@ -337,15 +338,15 @@ def __str__(self): Return a human-readable string representation of the object. """ return ( - f'' - f', endpoint: {self.endpoint}' - f', time_taken: {self.time_taken}' - f', user_agent: {self.user_agent}' - f', user_ip: {self.user_ip}' - f', api_record.body: {self.api_record.body}' - f', api_record.status_code: {self.api_record.status_code}' + f"" + f", endpoint: {self.endpoint}" + f", time_taken: {self.time_taken}" + f", user_agent: {self.user_agent}" + f", user_ip: {self.user_ip}" + f", api_record.body: {self.api_record.body}" + f", api_record.status_code: {self.api_record.status_code}" ) def __repr__(self): diff --git a/integrated_channels/cornerstone/utils.py b/integrated_channels/cornerstone/utils.py index 37b9d5aed5..55c15d27d5 100644 --- a/integrated_channels/cornerstone/utils.py +++ b/integrated_channels/cornerstone/utils.py @@ -90,14 +90,15 @@ def get_or_create_key_pair(course_id): def store_cornerstone_api_calls( - user_agent, - user_ip, enterprise_customer, + enterprise_customer_configuration_id, endpoint, payload, time_taken, status_code, response_body, + user_agent=None, + user_ip=None, ): """ Creates new record in CornerstoneAPIRequestLogs table. @@ -106,6 +107,7 @@ def store_cornerstone_api_calls( user_agent=user_agent, user_ip=user_ip, enterprise_customer=enterprise_customer, + enterprise_customer_configuration_id=enterprise_customer_configuration_id, endpoint=endpoint, payload=payload, time_taken=time_taken, diff --git a/integrated_channels/integrated_channel/migrations/0030_integratedchannelapirequestlogs.py b/integrated_channels/integrated_channel/migrations/0030_integratedchannelapirequestlogs.py index db8f99c3e5..1e187aeb8c 100644 --- a/integrated_channels/integrated_channel/migrations/0030_integratedchannelapirequestlogs.py +++ b/integrated_channels/integrated_channel/migrations/0030_integratedchannelapirequestlogs.py @@ -1,4 +1,4 @@ -# Generated by Django 3.2.23 on 2024-01-12 07:24 +# Generated by Django 3.2.23 on 2024-01-23 10:51 from django.db import migrations, models import django.db.models.deletion @@ -9,7 +9,7 @@ class Migration(migrations.Migration): dependencies = [ - ('enterprise', '0197_auto_20231130_2239'), + ('enterprise', '0198_alter_enterprisecourseenrollment_options'), ('integrated_channel', '0029_genericenterprisecustomerpluginconfiguration_show_course_price'), ] @@ -23,8 +23,9 @@ class Migration(migrations.Migration): ('enterprise_customer_configuration_id', models.IntegerField(help_text='ID from the EnterpriseCustomerConfiguration model')), ('endpoint', models.TextField()), ('payload', models.TextField()), - ('time_taken', models.DurationField()), - ('api_record', models.OneToOneField(blank=True, help_text='Data pertaining to the transmissions API request response.', null=True, on_delete=django.db.models.deletion.CASCADE, to='integrated_channel.apiresponserecord')), + ('time_taken', models.FloatField()), + ('status_code', models.PositiveIntegerField(blank=True, help_text='API call response HTTP status code', null=True)), + ('response_body', models.TextField(blank=True, help_text='API call response body', null=True)), ('enterprise_customer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='enterprise.enterprisecustomer')), ], ), diff --git a/integrated_channels/integrated_channel/migrations/0032_auto_20240122_1206.py b/integrated_channels/integrated_channel/migrations/0032_auto_20240122_1206.py deleted file mode 100644 index c45ba9431f..0000000000 --- a/integrated_channels/integrated_channel/migrations/0032_auto_20240122_1206.py +++ /dev/null @@ -1,27 +0,0 @@ -# Generated by Django 3.2.23 on 2024-01-22 12:06 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('integrated_channel', '0031_genericlearnerdatatransmissionaudit_transmission_status'), - ] - - operations = [ - migrations.RemoveField( - model_name='integratedchannelapirequestlogs', - name='api_record', - ), - migrations.AddField( - model_name='integratedchannelapirequestlogs', - name='response_body', - field=models.TextField(blank=True, help_text='TAPI call response body', null=True), - ), - migrations.AddField( - model_name='integratedchannelapirequestlogs', - name='status_code', - field=models.PositiveIntegerField(blank=True, help_text='API call response HTTP status code', null=True), - ), - ] diff --git a/integrated_channels/integrated_channel/models.py b/integrated_channels/integrated_channel/models.py index 17e7dea872..d00a9521c8 100644 --- a/integrated_channels/integrated_channel/models.py +++ b/integrated_channels/integrated_channel/models.py @@ -881,43 +881,57 @@ class Meta: resolved = models.BooleanField(default=False) -class IntegratedChannelAPIRequestLogs(TimeStampedModel): +class BaseIntegratedChannelAPIRequestLogs(TimeStampedModel): """ - A model to track basic information about every API call we make from the integrated channels. + A model to track basic information about every API call we make from the integrated channels. """ + enterprise_customer = models.ForeignKey( - EnterpriseCustomer, on_delete=models.CASCADE) + EnterpriseCustomer, on_delete=models.CASCADE + ) enterprise_customer_configuration_id = models.IntegerField( - blank=False, null=False, help_text='ID from the EnterpriseCustomerConfiguration model') - endpoint = models.TextField(blank=False, null=False) + blank=False, + null=False, + help_text="ID from the EnterpriseCustomerConfiguration model", + ) + endpoint = models.TextField( + blank=False, + null=False, + ) payload = models.TextField(blank=False, null=False) - time_taken = models.DurationField(blank=False, null=False) + time_taken = models.FloatField(blank=False, null=False) status_code = models.PositiveIntegerField( - help_text='API call response HTTP status code', - blank=True, - null=True + help_text="API call response HTTP status code", blank=True, null=True ) response_body = models.TextField( - help_text='API call response body', - blank=True, - null=True + help_text="API call response body", blank=True, null=True ) class Meta: - app_label = 'integrated_channel' + app_label = "integrated_channel" + abstract = True + + +class IntegratedChannelAPIRequestLogs(BaseIntegratedChannelAPIRequestLogs): + """ + A model to track basic information about every API call we make from the integrated channels. + """ + + class Meta: + app_label = "integrated_channel" def __str__(self): """ Return a human-readable string representation of the object. """ return ( - f'' - f', endpoint: {self.endpoint}' - f', time_taken: {self.time_taken}' - f', api_record.body: {self.api_record.body}' - f', api_record.status_code: {self.api_record.status_code}' + f"" + f", endpoint: {self.endpoint}" + f", time_taken: {self.time_taken}" + f", api_record.body: {self.api_record.body}" + f", api_record.status_code: {self.api_record.status_code}" ) def __repr__(self): From d0ad1250e1f5f630013cad10a2a2e84b524a83e9 Mon Sep 17 00:00:00 2001 From: John Nagro Date: Tue, 23 Jan 2024 10:59:40 -0500 Subject: [PATCH 093/164] feat: mgmt command to migrate catalog exclusions (#1998) --- CHANGELOG.rst | 6 ++ enterprise/__init__.py | 2 +- .../add_exec_ed_exclusion_to_catalogs.py | 90 +++++++++++++++++++ ...mpare_discovery_and_enterprise_catalogs.py | 7 ++ 4 files changed, 104 insertions(+), 1 deletion(-) create mode 100644 enterprise/management/commands/add_exec_ed_exclusion_to_catalogs.py diff --git a/CHANGELOG.rst b/CHANGELOG.rst index fad2920b33..29aa8547f1 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -16,6 +16,12 @@ Change Log Unreleased ---------- +[4.10.7] +-------- + +feat: mgmt command to add exec-ed exclusions to catalogs + + [4.10.6] -------- diff --git a/enterprise/__init__.py b/enterprise/__init__.py index 2f7ede3f73..face05c6ba 100644 --- a/enterprise/__init__.py +++ b/enterprise/__init__.py @@ -2,4 +2,4 @@ Your project description goes here. """ -__version__ = "4.10.6" +__version__ = "4.10.7" diff --git a/enterprise/management/commands/add_exec_ed_exclusion_to_catalogs.py b/enterprise/management/commands/add_exec_ed_exclusion_to_catalogs.py new file mode 100644 index 0000000000..77f6144091 --- /dev/null +++ b/enterprise/management/commands/add_exec_ed_exclusion_to_catalogs.py @@ -0,0 +1,90 @@ +""" +Django management command to add Exec Ed exclusion flag to catalogs +""" + +import logging + +from django.core.management import BaseCommand + +from enterprise.models import EnterpriseCatalogQuery, EnterpriseCustomerCatalog +from integrated_channels.utils import batch_by_pk + +logger = logging.getLogger(__name__) + + +class Command(BaseCommand): + """ + Enumerate the catalog filters and add course_type exclusions where required + """ + + def handle(self, *args, **options): + for catalog_query_batch in batch_by_pk(EnterpriseCatalogQuery): + for catalog_query in catalog_query_batch: + logger.info(f'{catalog_query.id} {catalog_query.include_exec_ed_2u_courses}') + + if catalog_query.include_exec_ed_2u_courses: + logger.info( + 'add_exec_ed_exclusion_to_catalogs ' + f'query {catalog_query.id} already includes exec ed' + ) + continue + + if catalog_query.content_filter.get('course_type__exclude'): + logger.info( + 'add_exec_ed_exclusion_to_catalogs ' + f'query {catalog_query.id} already references course_type__exclude somehow' + ) + continue + + if catalog_query.content_filter.get('course_type'): + logger.info( + 'add_exec_ed_exclusion_to_catalogs ' + f'query {catalog_query.id} already references course_type somehow' + ) + continue + + if catalog_query.content_filter.get('aggregation_key'): + logger.info( + 'add_exec_ed_exclusion_to_catalogs ' + f'query {catalog_query.id} references aggregation_key somehow' + ) + continue + + catalog_query.content_filter['course_type__exclude'] = 'executive-education-2u' + catalog_query.save() + logger.info( + 'add_exec_ed_exclusion_to_catalogs ' + f'updated query {catalog_query.id}' + ) + + for cusrtomer_catalog_batch in batch_by_pk(EnterpriseCustomerCatalog): + for customer_catalog in cusrtomer_catalog_batch: + logger.info(f'{customer_catalog.uuid}') + + if customer_catalog.content_filter.get('course_type__exclude'): + logger.info( + 'add_exec_ed_exclusion_to_catalogs ' + f'catalog {customer_catalog.uuid} already references course_type__exclude somehow' + ) + continue + + if customer_catalog.content_filter.get('course_type'): + logger.info( + 'add_exec_ed_exclusion_to_catalogs ' + f'catalog {customer_catalog.uuid} already references course_type somehow' + ) + continue + + if customer_catalog.content_filter.get('aggregation_key'): + logger.info( + 'add_exec_ed_exclusion_to_catalogs ' + f'catalog {customer_catalog.uuid} references aggregation_key somehow' + ) + continue + + customer_catalog.content_filter['course_type__exclude'] = 'executive-education-2u' + customer_catalog.save() + logger.info( + 'add_exec_ed_exclusion_to_catalogs ' + f'updated catalog {customer_catalog.uuid}' + ) diff --git a/enterprise/management/commands/compare_discovery_and_enterprise_catalogs.py b/enterprise/management/commands/compare_discovery_and_enterprise_catalogs.py index e48b569e92..ad61fdb4f6 100644 --- a/enterprise/management/commands/compare_discovery_and_enterprise_catalogs.py +++ b/enterprise/management/commands/compare_discovery_and_enterprise_catalogs.py @@ -71,6 +71,13 @@ def handle(self, *args, **options): ) continue + if customer_catalog.content_filter.get('aggregation_key'): + logger.info( + 'compare_discovery_and_enterprise_catalogs ' + f'catalog {customer_catalog.uuid} references aggregation_key somehow' + ) + continue + try: old_content_filter = customer_catalog.content_filter new_content_filter = copy.deepcopy(customer_catalog.content_filter) From e26205b2168a3ce7201dd4ddfc8528a34d5ae01f Mon Sep 17 00:00:00 2001 From: John Nagro Date: Tue, 23 Jan 2024 15:57:32 -0500 Subject: [PATCH 094/164] fix: guard against null content_filters (#1999) --- CHANGELOG.rst | 6 ++++++ enterprise/__init__.py | 2 +- .../commands/add_exec_ed_exclusion_to_catalogs.py | 11 +++++++++-- 3 files changed, 16 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 29aa8547f1..da50c4a3c9 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -16,6 +16,12 @@ Change Log Unreleased ---------- +[4.10.8] +-------- + +fix: guard against null content_filters + + [4.10.7] -------- diff --git a/enterprise/__init__.py b/enterprise/__init__.py index face05c6ba..52d07213bd 100644 --- a/enterprise/__init__.py +++ b/enterprise/__init__.py @@ -2,4 +2,4 @@ Your project description goes here. """ -__version__ = "4.10.7" +__version__ = "4.10.8" diff --git a/enterprise/management/commands/add_exec_ed_exclusion_to_catalogs.py b/enterprise/management/commands/add_exec_ed_exclusion_to_catalogs.py index 77f6144091..62c9d63628 100644 --- a/enterprise/management/commands/add_exec_ed_exclusion_to_catalogs.py +++ b/enterprise/management/commands/add_exec_ed_exclusion_to_catalogs.py @@ -57,10 +57,17 @@ def handle(self, *args, **options): f'updated query {catalog_query.id}' ) - for cusrtomer_catalog_batch in batch_by_pk(EnterpriseCustomerCatalog): - for customer_catalog in cusrtomer_catalog_batch: + for customer_catalog_batch in batch_by_pk(EnterpriseCustomerCatalog): + for customer_catalog in customer_catalog_batch: logger.info(f'{customer_catalog.uuid}') + if customer_catalog.content_filter is None: + logger.info( + 'add_exec_ed_exclusion_to_catalogs ' + f'catalog {customer_catalog.uuid} has no content_filter' + ) + continue + if customer_catalog.content_filter.get('course_type__exclude'): logger.info( 'add_exec_ed_exclusion_to_catalogs ' From 0ef6ae697e571231e98ad4d426d2a2d56bb3861e Mon Sep 17 00:00:00 2001 From: hamzawaleed01 Date: Wed, 24 Jan 2024 14:43:50 +0500 Subject: [PATCH 095/164] feat: update tests --- integrated_channels/cornerstone/utils.py | 36 ++++++++++++------- .../test_cornerstone/test_client.py | 35 +----------------- 2 files changed, 25 insertions(+), 46 deletions(-) diff --git a/integrated_channels/cornerstone/utils.py b/integrated_channels/cornerstone/utils.py index 55c15d27d5..23fc4698a2 100644 --- a/integrated_channels/cornerstone/utils.py +++ b/integrated_channels/cornerstone/utils.py @@ -22,7 +22,7 @@ def cornerstone_course_key_model(): return apps.get_model('cornerstone', 'CornerstoneCourseKey') -def cornerstone_request_log__model(): +def cornerstone_request_log_model(): """ Returns the ``CornerstoneAPIRequestLogs`` class. """ @@ -103,14 +103,26 @@ def store_cornerstone_api_calls( """ Creates new record in CornerstoneAPIRequestLogs table. """ - cornerstone_request_log__model().objects.create( - user_agent=user_agent, - user_ip=user_ip, - enterprise_customer=enterprise_customer, - enterprise_customer_configuration_id=enterprise_customer_configuration_id, - endpoint=endpoint, - payload=payload, - time_taken=time_taken, - status_code=status_code, - response_body=response_body, - ) + try: + cornerstone_request_log_model().objects.create( + user_agent=user_agent, + user_ip=user_ip, + enterprise_customer=enterprise_customer, + enterprise_customer_configuration_id=enterprise_customer_configuration_id, + endpoint=endpoint, + payload=payload, + time_taken=time_taken, + status_code=status_code, + response_body=response_body, + ) + except Exception as e: # pylint: disable=broad-except + LOGGER.error( + f"[Cornerstone]: Error occurred while storing API call: {e}" + f"user_agent={user_agent}, user_ip={user_ip}, enterprise_customer={enterprise_customer}" + f"enterprise_customer_configuration_id={enterprise_customer_configuration_id}," + f"endpoint={endpoint}" + f"payload={payload}" + f"time_taken={time_taken}" + f"status_code={status_code}" + f"response_body={response_body}" + ) diff --git a/tests/test_integrated_channels/test_cornerstone/test_client.py b/tests/test_integrated_channels/test_cornerstone/test_client.py index 92cfb21bad..af2dcdb9b4 100644 --- a/tests/test_integrated_channels/test_cornerstone/test_client.py +++ b/tests/test_integrated_channels/test_cornerstone/test_client.py @@ -2,51 +2,20 @@ Tests for Degreed2 client for integrated_channels. """ -import datetime import json import unittest -import mock import pytest -import requests import responses from freezegun import freeze_time -from six.moves.urllib.parse import urljoin from django.apps.registry import apps -from enterprise.api_client.enterprise_catalog import EnterpriseCatalogApiClient -from enterprise.models import EnterpriseCustomerUser from integrated_channels.cornerstone.client import CornerstoneAPIClient -from integrated_channels.exceptions import ClientError from test_utils import factories -NOW = datetime.datetime(2017, 1, 2, 3, 4, 5, tzinfo=datetime.timezone.utc) -NOW_TIMESTAMP_FORMATTED = NOW.strftime("%F") - - -def create_course_payload(): - return json.dumps( - { - "courses": [ - { - "title": "title", - "summary": "description", - "image-url": "image", - "url": "enrollment_url", - "language": "content_language", - "external-id": "key", - "duration": "duration", - "duration-type": "Days", - } - ], - }, - sort_keys=True, - ).encode("utf-8") - @pytest.mark.django_db -@freeze_time(NOW) class TestCornerstoneApiClient(unittest.TestCase): """ Test Degreed2 API client methods. @@ -85,7 +54,5 @@ def test_create_course_completion(self): "test-learner@example.com", json.dumps(payload) ) + assert len(responses.calls) == 1 assert output == (200, '"{}"') - # assert len(responses.calls) == 2 - # assert responses.calls[0].request.url == cornerstone_api_client.get_oauth_url() - # assert responses.calls[1].request.url == cornerstone_api_client.get_completions_url() From b043645f4ae256669ca689b1f501a3c2e5f3fd06 Mon Sep 17 00:00:00 2001 From: hamzawaleed01 Date: Wed, 24 Jan 2024 14:45:46 +0500 Subject: [PATCH 096/164] feat: add logs to see values --- CHANGELOG.rst | 5 +++++ enterprise/__init__.py | 2 +- integrated_channels/cornerstone/views.py | 9 ++++++++- 3 files changed, 14 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index da50c4a3c9..dbe6228f6c 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -16,6 +16,11 @@ Change Log Unreleased ---------- +[4.10.9] +-------- + +feat: added logs temporarily for ENT-8276 + [4.10.8] -------- diff --git a/enterprise/__init__.py b/enterprise/__init__.py index 52d07213bd..dd3b6160f6 100644 --- a/enterprise/__init__.py +++ b/enterprise/__init__.py @@ -2,4 +2,4 @@ Your project description goes here. """ -__version__ = "4.10.8" +__version__ = "4.10.9" diff --git a/integrated_channels/cornerstone/views.py b/integrated_channels/cornerstone/views.py index 38d7445392..a0fe176a2f 100644 --- a/integrated_channels/cornerstone/views.py +++ b/integrated_channels/cornerstone/views.py @@ -156,5 +156,12 @@ def get(self, request, *args, **kwargs): f'fixing modified/header mismatch') if_modified_since_dt = datetime.datetime.fromtimestamp(if_modified_since) item['LastModifiedUTC'] = if_modified_since_dt.strftime(ISO_8601_DATE_FORMAT) - + # TODO remove following logs (temporarily added) + logger.info( + f"[Cornerstone]: request.headers={request.headers}" + f"GET params={request.GET}" + f"enterprise_config={enterprise_config}" + f"enterprise_config.id={enterprise_config.id}" + f"data={data}" + ) return Response(data) From 2365f54eadf0e11dd2b923586b27182717636e47 Mon Sep 17 00:00:00 2001 From: hamzawaleed01 Date: Wed, 24 Jan 2024 16:04:59 +0500 Subject: [PATCH 097/164] feat: store cornerstone api calls from views --- integrated_channels/cornerstone/views.py | 14 +++++++++++++- .../test_cornerstone/test_client.py | 3 --- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/integrated_channels/cornerstone/views.py b/integrated_channels/cornerstone/views.py index 38d7445392..0117cbefde 100644 --- a/integrated_channels/cornerstone/views.py +++ b/integrated_channels/cornerstone/views.py @@ -4,6 +4,7 @@ import datetime from logging import getLogger +import time from dateutil import parser from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthentication @@ -16,6 +17,7 @@ from enterprise.api.throttles import ServiceUserThrottle from enterprise.utils import get_enterprise_customer, get_enterprise_worker_user, get_oauth2authentication_class from integrated_channels.cornerstone.models import CornerstoneEnterpriseCustomerConfiguration +from integrated_channels.cornerstone.utils import store_cornerstone_api_calls from integrated_channels.integrated_channel.constants import ISO_8601_DATE_FORMAT logger = getLogger(__name__) @@ -98,6 +100,7 @@ class CornerstoneCoursesListView(BaseViewSet): """ def get(self, request, *args, **kwargs): + start_time = time.time() enterprise_customer_uuid = request.GET.get('ciid') if not enterprise_customer_uuid: return Response( @@ -156,5 +159,14 @@ def get(self, request, *args, **kwargs): f'fixing modified/header mismatch') if_modified_since_dt = datetime.datetime.fromtimestamp(if_modified_since) item['LastModifiedUTC'] = if_modified_since_dt.strftime(ISO_8601_DATE_FORMAT) - + duration_seconds = time.time() - start_time + store_cornerstone_api_calls( + enterprise_customer=enterprise_customer, + enterprise_customer_configuration_id=enterprise_config.id, + endpoint=request.get_full_path(), + payload=f"Request Headers: {request.headers}", + time_taken=duration_seconds, + status_code=200, + response_body=data, + ) return Response(data) diff --git a/tests/test_integrated_channels/test_cornerstone/test_client.py b/tests/test_integrated_channels/test_cornerstone/test_client.py index af2dcdb9b4..06a1f0ddb4 100644 --- a/tests/test_integrated_channels/test_cornerstone/test_client.py +++ b/tests/test_integrated_channels/test_cornerstone/test_client.py @@ -7,9 +7,6 @@ import pytest import responses -from freezegun import freeze_time - -from django.apps.registry import apps from integrated_channels.cornerstone.client import CornerstoneAPIClient from test_utils import factories From 46ebbc6eee5172b75fded47348914b9d640b5ada Mon Sep 17 00:00:00 2001 From: hamzawaleed01 Date: Wed, 24 Jan 2024 18:17:59 +0500 Subject: [PATCH 098/164] refactor: common util to save api records --- integrated_channels/cornerstone/client.py | 9 +-- integrated_channels/cornerstone/models.py | 6 +- integrated_channels/cornerstone/utils.py | 48 -------------- integrated_channels/cornerstone/views.py | 26 ++++---- .../integrated_channel/models.py | 4 +- integrated_channels/utils.py | 62 +++++++++++++++++++ 6 files changed, 87 insertions(+), 68 deletions(-) diff --git a/integrated_channels/cornerstone/client.py b/integrated_channels/cornerstone/client.py index b003276ea9..760667dfe0 100644 --- a/integrated_channels/cornerstone/client.py +++ b/integrated_channels/cornerstone/client.py @@ -11,9 +11,9 @@ from django.apps import apps -from integrated_channels.cornerstone.utils import get_or_create_key_pair, store_cornerstone_api_calls +from integrated_channels.cornerstone.utils import get_or_create_key_pair from integrated_channels.integrated_channel.client import IntegratedChannelApiClient -from integrated_channels.utils import generate_formatted_log +from integrated_channels.utils import generate_formatted_log, store_api_call LOGGER = logging.getLogger(__name__) @@ -115,12 +115,13 @@ def create_course_completion(self, user_id, payload): } ) duration_seconds = time.time() - start_time - store_cornerstone_api_calls( + store_api_call( enterprise_customer=self.enterprise_configuration.enterprise_customer, enterprise_customer_configuration_id=self.enterprise_configuration.id, endpoint=url, - payload=json_payload["data"], + payload=json.dumps(json_payload["data"]), time_taken=duration_seconds, + channel_code=self.enterprise_configuration.channel_code(), status_code=response.status_code, response_body=response.text, ) diff --git a/integrated_channels/cornerstone/models.py b/integrated_channels/cornerstone/models.py index e51999135f..be18cba9d5 100644 --- a/integrated_channels/cornerstone/models.py +++ b/integrated_channels/cornerstone/models.py @@ -339,14 +339,14 @@ def __str__(self): """ return ( f"" f", endpoint: {self.endpoint}" f", time_taken: {self.time_taken}" f", user_agent: {self.user_agent}" f", user_ip: {self.user_ip}" - f", api_record.body: {self.api_record.body}" - f", api_record.status_code: {self.api_record.status_code}" + f", response_body: {self.response_body}" + f", status_code: {self.status_code}" ) def __repr__(self): diff --git a/integrated_channels/cornerstone/utils.py b/integrated_channels/cornerstone/utils.py index 23fc4698a2..c3ea08ad7e 100644 --- a/integrated_channels/cornerstone/utils.py +++ b/integrated_channels/cornerstone/utils.py @@ -21,17 +21,8 @@ def cornerstone_course_key_model(): """ return apps.get_model('cornerstone', 'CornerstoneCourseKey') - -def cornerstone_request_log_model(): - """ - Returns the ``CornerstoneAPIRequestLogs`` class. - """ - return apps.get_model('cornerstone', 'CornerstoneAPIRequestLogs') - - LOGGER = getLogger(__name__) - def create_cornerstone_learner_data(request, cornerstone_customer_configuration, course_id): """ updates or creates CornerstoneLearnerDataTransmissionAudit @@ -87,42 +78,3 @@ def get_or_create_key_pair(course_id): internal_course_id=course_id, defaults={ 'external_course_id': str(uuid4())}) return key_mapping - - -def store_cornerstone_api_calls( - enterprise_customer, - enterprise_customer_configuration_id, - endpoint, - payload, - time_taken, - status_code, - response_body, - user_agent=None, - user_ip=None, -): - """ - Creates new record in CornerstoneAPIRequestLogs table. - """ - try: - cornerstone_request_log_model().objects.create( - user_agent=user_agent, - user_ip=user_ip, - enterprise_customer=enterprise_customer, - enterprise_customer_configuration_id=enterprise_customer_configuration_id, - endpoint=endpoint, - payload=payload, - time_taken=time_taken, - status_code=status_code, - response_body=response_body, - ) - except Exception as e: # pylint: disable=broad-except - LOGGER.error( - f"[Cornerstone]: Error occurred while storing API call: {e}" - f"user_agent={user_agent}, user_ip={user_ip}, enterprise_customer={enterprise_customer}" - f"enterprise_customer_configuration_id={enterprise_customer_configuration_id}," - f"endpoint={endpoint}" - f"payload={payload}" - f"time_taken={time_taken}" - f"status_code={status_code}" - f"response_body={response_body}" - ) diff --git a/integrated_channels/cornerstone/views.py b/integrated_channels/cornerstone/views.py index 52b2317796..5ff9e84be0 100644 --- a/integrated_channels/cornerstone/views.py +++ b/integrated_channels/cornerstone/views.py @@ -3,6 +3,7 @@ """ import datetime +import json from logging import getLogger import time @@ -17,7 +18,7 @@ from enterprise.api.throttles import ServiceUserThrottle from enterprise.utils import get_enterprise_customer, get_enterprise_worker_user, get_oauth2authentication_class from integrated_channels.cornerstone.models import CornerstoneEnterpriseCustomerConfiguration -from integrated_channels.cornerstone.utils import store_cornerstone_api_calls +from integrated_channels.utils import store_api_call from integrated_channels.integrated_channel.constants import ISO_8601_DATE_FORMAT logger = getLogger(__name__) @@ -160,16 +161,7 @@ def get(self, request, *args, **kwargs): if_modified_since_dt = datetime.datetime.fromtimestamp(if_modified_since) item['LastModifiedUTC'] = if_modified_since_dt.strftime(ISO_8601_DATE_FORMAT) duration_seconds = time.time() - start_time - store_cornerstone_api_calls( - enterprise_customer=enterprise_customer, - enterprise_customer_configuration_id=enterprise_config.id, - endpoint=request.get_full_path(), - payload=f"Request Headers: {request.headers}", - time_taken=duration_seconds, - status_code=200, - response_body=data, - ) - # TODO remove following logs (temporarily added) + # TODO remove following log (temporarily added) logger.info( f"[Cornerstone]: request.headers={request.headers}" f"GET params={request.GET}" @@ -177,4 +169,16 @@ def get(self, request, *args, **kwargs): f"enterprise_config.id={enterprise_config.id}" f"data={data}" ) + headers_dict = dict(request.headers) + headers_json = json.dumps(headers_dict) + store_api_call( + enterprise_customer=enterprise_customer, + enterprise_customer_configuration_id=enterprise_config.id, + endpoint=request.get_full_path(), + payload=f"Request Headers: {headers_json}", + time_taken=duration_seconds, + status_code=200, + response_body=json.dumps(data), + channel_code="CSOD" + ) return Response(data) diff --git a/integrated_channels/integrated_channel/models.py b/integrated_channels/integrated_channel/models.py index d00a9521c8..d0b57d5fff 100644 --- a/integrated_channels/integrated_channel/models.py +++ b/integrated_channels/integrated_channel/models.py @@ -930,8 +930,8 @@ def __str__(self): f", enterprise_customer_configuration_id: {self.enterprise_customer_configuration_id}>" f", endpoint: {self.endpoint}" f", time_taken: {self.time_taken}" - f", api_record.body: {self.api_record.body}" - f", api_record.status_code: {self.api_record.status_code}" + f", response_body: {self.response_body}" + f", status_code: {self.status_code}" ) def __repr__(self): diff --git a/integrated_channels/utils.py b/integrated_channels/utils.py index 62ba54fa71..1205db3c87 100644 --- a/integrated_channels/utils.py +++ b/integrated_channels/utils.py @@ -476,6 +476,18 @@ def get_enterprise_customer_model(): """ return apps.get_model('enterprise', 'EnterpriseCustomer') +def cornerstone_request_log_model(): + """ + Returns the ``CornerstoneAPIRequestLogs`` class. + """ + return apps.get_model("cornerstone", "CornerstoneAPIRequestLogs") + + +def integrated_channel_request_log_model(): + """ + Returns the ``IntegratedChannelAPIRequestLogs`` class. + """ + return apps.get_model("integrated_channel", "IntegratedChannelAPIRequestLogs") def get_enterprise_customer_from_enterprise_enrollment(enrollment_id): """ @@ -501,3 +513,53 @@ def get_enterprise_client_by_channel_code(channel_code): 'canvas': CanvasAPIClient, } return _enterprise_client_model_by_channel_code[channel_code] + +def store_api_call( + enterprise_customer, + enterprise_customer_configuration_id, + endpoint, + payload, + time_taken, + status_code, + response_body, + channel_code="", + user_agent=None, + user_ip=None, +): + """ + Creates new record in CornerstoneAPIRequestLogs table. + """ + try: + if channel_code == "CSOD": + cornerstone_request_log_model().objects.create( + user_agent=user_agent, + user_ip=user_ip, + enterprise_customer=enterprise_customer, + enterprise_customer_configuration_id=enterprise_customer_configuration_id, + endpoint=endpoint, + payload=payload, + time_taken=time_taken, + status_code=status_code, + response_body=response_body, + ) + else: + integrated_channel_request_log_model().objects.create( + enterprise_customer=enterprise_customer, + enterprise_customer_configuration_id=enterprise_customer_configuration_id, + endpoint=endpoint, + payload=payload, + time_taken=time_taken, + status_code=status_code, + response_body=response_body, + ) + except Exception as e: # pylint: disable=broad-except + LOGGER.error( + f"[{channel_code}]: store_api_call raised error while storing API call: {e}" + f"user_agent={user_agent}, user_ip={user_ip}, enterprise_customer={enterprise_customer}" + f"enterprise_customer_configuration_id={enterprise_customer_configuration_id}," + f"endpoint={endpoint}" + f"payload={payload}" + f"time_taken={time_taken}" + f"status_code={status_code}" + f"response_body={response_body}" + ) From b5d6b33e8713802b549d8ff1e61f5dc1ef4c9aec Mon Sep 17 00:00:00 2001 From: hamzawaleed01 Date: Thu, 25 Jan 2024 14:04:31 +0500 Subject: [PATCH 099/164] feat: use same model for integrated channels API logs --- .../cornerstone/admin/__init__.py | 10 ---- integrated_channels/cornerstone/client.py | 1 - .../0031_cornerstoneapirequestlogs.py | 23 ++-------- .../0032_delete_cornerstoneapirequestlogs.py | 16 +++++++ integrated_channels/cornerstone/models.py | 36 --------------- integrated_channels/cornerstone/utils.py | 2 + integrated_channels/cornerstone/views.py | 15 ++---- .../integrated_channel/admin/__init__.py | 16 ++++++- .../0030_integratedchannelapirequestlogs.py | 9 ++-- ...integratedchannelapirequestlogs_options.py | 17 +++++++ .../migrations/0032_auto_20240125_0936.py | 32 +++++++++++++ .../integrated_channel/models.py | 1 + integrated_channels/utils.py | 46 ++++++------------- 13 files changed, 108 insertions(+), 116 deletions(-) create mode 100644 integrated_channels/cornerstone/migrations/0032_delete_cornerstoneapirequestlogs.py create mode 100644 integrated_channels/integrated_channel/migrations/0031_alter_integratedchannelapirequestlogs_options.py create mode 100644 integrated_channels/integrated_channel/migrations/0032_auto_20240125_0936.py diff --git a/integrated_channels/cornerstone/admin/__init__.py b/integrated_channels/cornerstone/admin/__init__.py index 413da0e385..cb9c791e49 100644 --- a/integrated_channels/cornerstone/admin/__init__.py +++ b/integrated_channels/cornerstone/admin/__init__.py @@ -13,7 +13,6 @@ CornerstoneEnterpriseCustomerConfiguration, CornerstoneGlobalConfiguration, CornerstoneLearnerDataTransmissionAudit, - CornerstoneAPIRequestLogs ) from integrated_channels.integrated_channel.admin import BaseLearnerDataTransmissionAuditAdmin @@ -139,12 +138,3 @@ def user_email(self, obj): being rendered with this admin form. """ return obj.user.email - -@admin.register(CornerstoneAPIRequestLogs) -class CornerstoneAPIRequestLogAdmin(DjangoObjectActions, admin.ModelAdmin): - """ - Django admin model for CornerstoneAPIRequestLogs. - """ - - class Meta: - model = CornerstoneAPIRequestLogs diff --git a/integrated_channels/cornerstone/client.py b/integrated_channels/cornerstone/client.py index 760667dfe0..40e33bd863 100644 --- a/integrated_channels/cornerstone/client.py +++ b/integrated_channels/cornerstone/client.py @@ -121,7 +121,6 @@ def create_course_completion(self, user_id, payload): endpoint=url, payload=json.dumps(json_payload["data"]), time_taken=duration_seconds, - channel_code=self.enterprise_configuration.channel_code(), status_code=response.status_code, response_body=response.text, ) diff --git a/integrated_channels/cornerstone/migrations/0031_cornerstoneapirequestlogs.py b/integrated_channels/cornerstone/migrations/0031_cornerstoneapirequestlogs.py index 914b533ef8..35a3c66b68 100644 --- a/integrated_channels/cornerstone/migrations/0031_cornerstoneapirequestlogs.py +++ b/integrated_channels/cornerstone/migrations/0031_cornerstoneapirequestlogs.py @@ -1,15 +1,13 @@ -# Generated by Django 3.2.23 on 2024-01-23 10:59 +# Generated by Django 3.2.23 on 2024-01-15 07:54 from django.db import migrations, models import django.db.models.deletion -import django.utils.timezone -import model_utils.fields class Migration(migrations.Migration): dependencies = [ - ('enterprise', '0198_alter_enterprisecourseenrollment_options'), + ('integrated_channel', '0030_integratedchannelapirequestlogs'), ('cornerstone', '0030_auto_20231010_1654'), ] @@ -17,21 +15,10 @@ class Migration(migrations.Migration): migrations.CreateModel( name='CornerstoneAPIRequestLogs', 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')), - ('enterprise_customer_configuration_id', models.IntegerField(help_text='ID from the EnterpriseCustomerConfiguration model')), - ('endpoint', models.TextField()), - ('payload', models.TextField()), - ('time_taken', models.FloatField()), - ('status_code', models.PositiveIntegerField(blank=True, help_text='API call response HTTP status code', null=True)), - ('response_body', models.TextField(blank=True, help_text='API call response body', null=True)), - ('user_agent', models.CharField(blank=True, max_length=255, null=True)), + ('integratedchannelapirequestlogs_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='integrated_channel.integratedchannelapirequestlogs')), + ('user_agent', models.CharField(max_length=255)), ('user_ip', models.GenericIPAddressField(blank=True, null=True)), - ('enterprise_customer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='enterprise.enterprisecustomer')), ], - options={ - 'verbose_name_plural': 'Cornerstone API request logs', - }, + bases=('integrated_channel.integratedchannelapirequestlogs',), ), ] diff --git a/integrated_channels/cornerstone/migrations/0032_delete_cornerstoneapirequestlogs.py b/integrated_channels/cornerstone/migrations/0032_delete_cornerstoneapirequestlogs.py new file mode 100644 index 0000000000..932d71c798 --- /dev/null +++ b/integrated_channels/cornerstone/migrations/0032_delete_cornerstoneapirequestlogs.py @@ -0,0 +1,16 @@ +# Generated by Django 3.2.22 on 2024-01-25 09:25 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('cornerstone', '0031_cornerstoneapirequestlogs'), + ] + + operations = [ + migrations.DeleteModel( + name='CornerstoneAPIRequestLogs', + ), + ] diff --git a/integrated_channels/cornerstone/models.py b/integrated_channels/cornerstone/models.py index be18cba9d5..88b0f24e93 100644 --- a/integrated_channels/cornerstone/models.py +++ b/integrated_channels/cornerstone/models.py @@ -18,7 +18,6 @@ from integrated_channels.cornerstone.transmitters.content_metadata import CornerstoneContentMetadataTransmitter from integrated_channels.cornerstone.transmitters.learner_data import CornerstoneLearnerTransmitter from integrated_channels.integrated_channel.models import ( - BaseIntegratedChannelAPIRequestLogs, EnterpriseCustomerPluginConfiguration, LearnerDataTransmissionAudit, ) @@ -319,38 +318,3 @@ class CornerstoneCourseKey(models.Model): class Meta: app_label = 'cornerstone' - - -class CornerstoneAPIRequestLogs(BaseIntegratedChannelAPIRequestLogs): - """ - A model to track basic information about every API call we make from the integrated channels. - """ - - user_agent = models.CharField(blank=True, null=True, max_length=255) - user_ip = models.GenericIPAddressField(blank=True, null=True) - - class Meta: - app_label = "cornerstone" - verbose_name_plural = "Cornerstone API request logs" - - def __str__(self): - """ - Return a human-readable string representation of the object. - """ - return ( - f"" - f", endpoint: {self.endpoint}" - f", time_taken: {self.time_taken}" - f", user_agent: {self.user_agent}" - f", user_ip: {self.user_ip}" - f", response_body: {self.response_body}" - f", status_code: {self.status_code}" - ) - - def __repr__(self): - """ - Return uniquely identifying string representation. - """ - return self.__str__() diff --git a/integrated_channels/cornerstone/utils.py b/integrated_channels/cornerstone/utils.py index c3ea08ad7e..e6c1defbb9 100644 --- a/integrated_channels/cornerstone/utils.py +++ b/integrated_channels/cornerstone/utils.py @@ -21,8 +21,10 @@ def cornerstone_course_key_model(): """ return apps.get_model('cornerstone', 'CornerstoneCourseKey') + LOGGER = getLogger(__name__) + def create_cornerstone_learner_data(request, cornerstone_customer_configuration, course_id): """ updates or creates CornerstoneLearnerDataTransmissionAudit diff --git a/integrated_channels/cornerstone/views.py b/integrated_channels/cornerstone/views.py index 5ff9e84be0..363e2ab2a4 100644 --- a/integrated_channels/cornerstone/views.py +++ b/integrated_channels/cornerstone/views.py @@ -4,8 +4,8 @@ import datetime import json -from logging import getLogger import time +from logging import getLogger from dateutil import parser from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthentication @@ -18,8 +18,8 @@ from enterprise.api.throttles import ServiceUserThrottle from enterprise.utils import get_enterprise_customer, get_enterprise_worker_user, get_oauth2authentication_class from integrated_channels.cornerstone.models import CornerstoneEnterpriseCustomerConfiguration -from integrated_channels.utils import store_api_call from integrated_channels.integrated_channel.constants import ISO_8601_DATE_FORMAT +from integrated_channels.utils import store_api_call logger = getLogger(__name__) @@ -161,14 +161,6 @@ def get(self, request, *args, **kwargs): if_modified_since_dt = datetime.datetime.fromtimestamp(if_modified_since) item['LastModifiedUTC'] = if_modified_since_dt.strftime(ISO_8601_DATE_FORMAT) duration_seconds = time.time() - start_time - # TODO remove following log (temporarily added) - logger.info( - f"[Cornerstone]: request.headers={request.headers}" - f"GET params={request.GET}" - f"enterprise_config={enterprise_config}" - f"enterprise_config.id={enterprise_config.id}" - f"data={data}" - ) headers_dict = dict(request.headers) headers_json = json.dumps(headers_dict) store_api_call( @@ -178,7 +170,6 @@ def get(self, request, *args, **kwargs): payload=f"Request Headers: {headers_json}", time_taken=duration_seconds, status_code=200, - response_body=json.dumps(data), - channel_code="CSOD" + response_body=json.dumps(data) ) return Response(data) diff --git a/integrated_channels/integrated_channel/admin/__init__.py b/integrated_channels/integrated_channel/admin/__init__.py index f79b1659bb..960b49c182 100644 --- a/integrated_channels/integrated_channel/admin/__init__.py +++ b/integrated_channels/integrated_channel/admin/__init__.py @@ -4,7 +4,11 @@ from django.contrib import admin -from integrated_channels.integrated_channel.models import ApiResponseRecord, ContentMetadataItemTransmission +from integrated_channels.integrated_channel.models import ( + ApiResponseRecord, + ContentMetadataItemTransmission, + IntegratedChannelAPIRequestLogs, +) from integrated_channels.utils import get_enterprise_customer_from_enterprise_enrollment @@ -85,3 +89,13 @@ class ApiResponseRecordAdmin(admin.ModelAdmin): ) list_per_page = 1000 + + +@admin.register(IntegratedChannelAPIRequestLogs) +class CornerstoneAPIRequestLogAdmin(admin.ModelAdmin): + """ + Django admin model for IntegratedChannelAPIRequestLogs. + """ + + class Meta: + model = IntegratedChannelAPIRequestLogs diff --git a/integrated_channels/integrated_channel/migrations/0030_integratedchannelapirequestlogs.py b/integrated_channels/integrated_channel/migrations/0030_integratedchannelapirequestlogs.py index 1e187aeb8c..db8f99c3e5 100644 --- a/integrated_channels/integrated_channel/migrations/0030_integratedchannelapirequestlogs.py +++ b/integrated_channels/integrated_channel/migrations/0030_integratedchannelapirequestlogs.py @@ -1,4 +1,4 @@ -# Generated by Django 3.2.23 on 2024-01-23 10:51 +# Generated by Django 3.2.23 on 2024-01-12 07:24 from django.db import migrations, models import django.db.models.deletion @@ -9,7 +9,7 @@ class Migration(migrations.Migration): dependencies = [ - ('enterprise', '0198_alter_enterprisecourseenrollment_options'), + ('enterprise', '0197_auto_20231130_2239'), ('integrated_channel', '0029_genericenterprisecustomerpluginconfiguration_show_course_price'), ] @@ -23,9 +23,8 @@ class Migration(migrations.Migration): ('enterprise_customer_configuration_id', models.IntegerField(help_text='ID from the EnterpriseCustomerConfiguration model')), ('endpoint', models.TextField()), ('payload', models.TextField()), - ('time_taken', models.FloatField()), - ('status_code', models.PositiveIntegerField(blank=True, help_text='API call response HTTP status code', null=True)), - ('response_body', models.TextField(blank=True, help_text='API call response body', null=True)), + ('time_taken', models.DurationField()), + ('api_record', models.OneToOneField(blank=True, help_text='Data pertaining to the transmissions API request response.', null=True, on_delete=django.db.models.deletion.CASCADE, to='integrated_channel.apiresponserecord')), ('enterprise_customer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='enterprise.enterprisecustomer')), ], ), diff --git a/integrated_channels/integrated_channel/migrations/0031_alter_integratedchannelapirequestlogs_options.py b/integrated_channels/integrated_channel/migrations/0031_alter_integratedchannelapirequestlogs_options.py new file mode 100644 index 0000000000..5b11a9f695 --- /dev/null +++ b/integrated_channels/integrated_channel/migrations/0031_alter_integratedchannelapirequestlogs_options.py @@ -0,0 +1,17 @@ +# Generated by Django 3.2.22 on 2024-01-25 09:25 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('integrated_channel', '0030_integratedchannelapirequestlogs'), + ] + + operations = [ + migrations.AlterModelOptions( + name='integratedchannelapirequestlogs', + options={'verbose_name_plural': 'Integrated channels API request logs'}, + ), + ] diff --git a/integrated_channels/integrated_channel/migrations/0032_auto_20240125_0936.py b/integrated_channels/integrated_channel/migrations/0032_auto_20240125_0936.py new file mode 100644 index 0000000000..077a1af02d --- /dev/null +++ b/integrated_channels/integrated_channel/migrations/0032_auto_20240125_0936.py @@ -0,0 +1,32 @@ +# Generated by Django 3.2.22 on 2024-01-25 09:36 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('integrated_channel', '0031_alter_integratedchannelapirequestlogs_options'), + ] + + operations = [ + migrations.RemoveField( + model_name='integratedchannelapirequestlogs', + name='api_record', + ), + migrations.AddField( + model_name='integratedchannelapirequestlogs', + name='response_body', + field=models.TextField(blank=True, help_text='API call response body', null=True), + ), + migrations.AddField( + model_name='integratedchannelapirequestlogs', + name='status_code', + field=models.PositiveIntegerField(blank=True, help_text='API call response HTTP status code', null=True), + ), + migrations.AlterField( + model_name='integratedchannelapirequestlogs', + name='time_taken', + field=models.FloatField(), + ), + ] diff --git a/integrated_channels/integrated_channel/models.py b/integrated_channels/integrated_channel/models.py index d0b57d5fff..c5700eb446 100644 --- a/integrated_channels/integrated_channel/models.py +++ b/integrated_channels/integrated_channel/models.py @@ -919,6 +919,7 @@ class IntegratedChannelAPIRequestLogs(BaseIntegratedChannelAPIRequestLogs): class Meta: app_label = "integrated_channel" + verbose_name_plural = "Integrated channels API request logs" def __str__(self): """ diff --git a/integrated_channels/utils.py b/integrated_channels/utils.py index 1205db3c87..bf728c94a9 100644 --- a/integrated_channels/utils.py +++ b/integrated_channels/utils.py @@ -476,12 +476,6 @@ def get_enterprise_customer_model(): """ return apps.get_model('enterprise', 'EnterpriseCustomer') -def cornerstone_request_log_model(): - """ - Returns the ``CornerstoneAPIRequestLogs`` class. - """ - return apps.get_model("cornerstone", "CornerstoneAPIRequestLogs") - def integrated_channel_request_log_model(): """ @@ -489,6 +483,7 @@ def integrated_channel_request_log_model(): """ return apps.get_model("integrated_channel", "IntegratedChannelAPIRequestLogs") + def get_enterprise_customer_from_enterprise_enrollment(enrollment_id): """ Returns the Django ORM enterprise customer object that is associated with an enterprise enrollment ID @@ -514,6 +509,7 @@ def get_enterprise_client_by_channel_code(channel_code): } return _enterprise_client_model_by_channel_code[channel_code] + def store_api_call( enterprise_customer, enterprise_customer_configuration_id, @@ -522,40 +518,24 @@ def store_api_call( time_taken, status_code, response_body, - channel_code="", - user_agent=None, - user_ip=None, ): """ Creates new record in CornerstoneAPIRequestLogs table. """ try: - if channel_code == "CSOD": - cornerstone_request_log_model().objects.create( - user_agent=user_agent, - user_ip=user_ip, - enterprise_customer=enterprise_customer, - enterprise_customer_configuration_id=enterprise_customer_configuration_id, - endpoint=endpoint, - payload=payload, - time_taken=time_taken, - status_code=status_code, - response_body=response_body, - ) - else: - integrated_channel_request_log_model().objects.create( - enterprise_customer=enterprise_customer, - enterprise_customer_configuration_id=enterprise_customer_configuration_id, - endpoint=endpoint, - payload=payload, - time_taken=time_taken, - status_code=status_code, - response_body=response_body, - ) + integrated_channel_request_log_model().objects.create( + enterprise_customer=enterprise_customer, + enterprise_customer_configuration_id=enterprise_customer_configuration_id, + endpoint=endpoint, + payload=payload, + time_taken=time_taken, + status_code=status_code, + response_body=response_body, + ) except Exception as e: # pylint: disable=broad-except LOGGER.error( - f"[{channel_code}]: store_api_call raised error while storing API call: {e}" - f"user_agent={user_agent}, user_ip={user_ip}, enterprise_customer={enterprise_customer}" + f"store_api_call raised error while storing API call: {e}" + f"enterprise_customer={enterprise_customer}" f"enterprise_customer_configuration_id={enterprise_customer_configuration_id}," f"endpoint={endpoint}" f"payload={payload}" From 419100032a606740fd20eb4785b1bd8c58703d90 Mon Sep 17 00:00:00 2001 From: hamzawaleed01 Date: Thu, 25 Jan 2024 15:22:04 +0500 Subject: [PATCH 100/164] fix: update tests --- .../test_integrated_channel/test_models.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/tests/test_integrated_channels/test_integrated_channel/test_models.py b/tests/test_integrated_channels/test_integrated_channel/test_models.py index 49c4c21bc9..8625ae51d3 100644 --- a/tests/test_integrated_channels/test_integrated_channel/test_models.py +++ b/tests/test_integrated_channels/test_integrated_channel/test_models.py @@ -274,9 +274,8 @@ def setUp(self): self.endpoint = 'https://example.com/endpoint' self.payload = "{}" self.time_taken = 500 - api_record = ApiResponseRecord(status_code=200, body='SUCCESS') - api_record.save() - self.api_record = api_record + self.response_body = "{}" + self.status_code = 200 super().setUp() def test_content_meta_data_string_representation(self): @@ -285,12 +284,12 @@ def test_content_meta_data_string_representation(self): """ expected_string = ( f'' f', endpoint: {self.endpoint}' f', time_taken: {self.time_taken}' - f', api_record.body: {self.api_record.body}' - f', api_record.status_code: {self.api_record.status_code}' + f", response_body: {self.response_body}" + f", status_code: {self.status_code}" ) request_log = IntegratedChannelAPIRequestLogs( @@ -300,6 +299,7 @@ def test_content_meta_data_string_representation(self): endpoint=self.endpoint, payload=self.payload, time_taken=self.time_taken, - api_record=self.api_record + response_body=self.response_body, + status_code=self.status_code ) assert expected_string == repr(request_log) From 317e4ee3139bc69fcd32d13435941215f518969a Mon Sep 17 00:00:00 2001 From: hamzawaleed01 Date: Thu, 25 Jan 2024 16:40:51 +0500 Subject: [PATCH 101/164] chore: bump version + add changelog --- CHANGELOG.rst | 5 +++++ enterprise/__init__.py | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index dbe6228f6c..77bf5295e0 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -16,6 +16,11 @@ Change Log Unreleased ---------- +[4.11.0] +-------- + +feat: update cornerstone client to store API calls in DB + [4.10.9] -------- diff --git a/enterprise/__init__.py b/enterprise/__init__.py index dd3b6160f6..f2d38d70bf 100644 --- a/enterprise/__init__.py +++ b/enterprise/__init__.py @@ -2,4 +2,4 @@ Your project description goes here. """ -__version__ = "4.10.9" +__version__ = "4.11.0" From 0c69460e06f8f8a5145c2dd0c38edc230e23c4de Mon Sep 17 00:00:00 2001 From: John Nagro Date: Fri, 26 Jan 2024 09:51:45 -0500 Subject: [PATCH 102/164] feat: remove ability to edit catalog include_exec_ed_2u_courses (#2001) --- CHANGELOG.rst | 6 ++++++ enterprise/__init__.py | 2 +- enterprise/admin/__init__.py | 8 +++++++- 3 files changed, 14 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index dbe6228f6c..2d771a6875 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -16,6 +16,12 @@ Change Log Unreleased ---------- +[4.10.10] +--------- + +feat: remove ability to edit catalog `include_exec_ed_2u_courses` + + [4.10.9] -------- diff --git a/enterprise/__init__.py b/enterprise/__init__.py index dd3b6160f6..178701f986 100644 --- a/enterprise/__init__.py +++ b/enterprise/__init__.py @@ -2,4 +2,4 @@ Your project description goes here. """ -__version__ = "4.10.9" +__version__ = "4.10.10" diff --git a/enterprise/admin/__init__.py b/enterprise/admin/__init__.py index 3182c91968..1bb9069235 100644 --- a/enterprise/admin/__init__.py +++ b/enterprise/admin/__init__.py @@ -810,6 +810,13 @@ class EnterpriseCatalogQueryAdmin(admin.ModelAdmin): class Meta: model = models.EnterpriseCatalogQuery + fields = ( + 'uuid' + 'title', + 'discovery_query_url', + 'content_filter', + ) + def get_urls(self): """ Returns the additional urls used by the custom object tools. @@ -826,7 +833,6 @@ def get_urls(self): list_display = ( 'title', 'discovery_query_url', - 'include_exec_ed_2u_courses', ) @admin.display( From 37cc96b58c010f4136960ae998a9bc8c045f08b7 Mon Sep 17 00:00:00 2001 From: Alex Dusenbery Date: Sun, 28 Jan 2024 13:56:44 -0500 Subject: [PATCH 103/164] fix: add missing comma to catalog query fields list. --- CHANGELOG.rst | 4 ++++ enterprise/__init__.py | 2 +- enterprise/admin/__init__.py | 2 +- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 2d771a6875..86ef51c3cd 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -16,6 +16,10 @@ Change Log Unreleased ---------- +[4.10.11] +--------- +* fix: add missing comma to catalog query fields list. + [4.10.10] --------- diff --git a/enterprise/__init__.py b/enterprise/__init__.py index 178701f986..b2fa7a38e1 100644 --- a/enterprise/__init__.py +++ b/enterprise/__init__.py @@ -2,4 +2,4 @@ Your project description goes here. """ -__version__ = "4.10.10" +__version__ = "4.10.11" diff --git a/enterprise/admin/__init__.py b/enterprise/admin/__init__.py index 1bb9069235..df241caaf4 100644 --- a/enterprise/admin/__init__.py +++ b/enterprise/admin/__init__.py @@ -811,7 +811,7 @@ class Meta: model = models.EnterpriseCatalogQuery fields = ( - 'uuid' + 'uuid', 'title', 'discovery_query_url', 'content_filter', From ddc58d9162d220dcf88fc7025bba3f8c065a4609 Mon Sep 17 00:00:00 2001 From: hamzawaleed01 Date: Mon, 29 Jan 2024 14:32:17 +0500 Subject: [PATCH 104/164] feat: record blackboard API calls --- CHANGELOG.rst | 5 ++ enterprise/__init__.py | 2 +- integrated_channels/blackboard/client.py | 73 +++++++++++++++++++++++- 3 files changed, 78 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 34553448ce..704742d1e0 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -16,6 +16,11 @@ Change Log Unreleased ---------- +[4.10.13] +--------- + +feat: update blackboard client to store API calls in DB + [4.10.12] --------- diff --git a/enterprise/__init__.py b/enterprise/__init__.py index 002e116dd1..d907380c2c 100644 --- a/enterprise/__init__.py +++ b/enterprise/__init__.py @@ -2,4 +2,4 @@ Your project description goes here. """ -__version__ = "4.10.12" +__version__ = "4.10.13" diff --git a/integrated_channels/blackboard/client.py b/integrated_channels/blackboard/client.py index 3927030556..f5586b1dc9 100644 --- a/integrated_channels/blackboard/client.py +++ b/integrated_channels/blackboard/client.py @@ -5,6 +5,7 @@ import copy import json import logging +import time from http import HTTPStatus from urllib.parse import urljoin @@ -16,7 +17,11 @@ from integrated_channels.blackboard.exporters.content_metadata import BLACKBOARD_COURSE_CONTENT_NAME from integrated_channels.exceptions import ClientError from integrated_channels.integrated_channel.client import IntegratedChannelApiClient -from integrated_channels.utils import generate_formatted_log, refresh_session_if_expired +from integrated_channels.utils import ( + generate_formatted_log, + refresh_session_if_expired, + store_api_call, +) LOGGER = logging.getLogger(__name__) @@ -594,11 +599,49 @@ def generate_course_content_delete_url(self, course_id, content_id): path=COURSE_CONTENT_DELETE_PATH.format(course_id=course_id, content_id=content_id) ) + def stringify_and_store_api_record( + self, url, data, time_taken, status_code, response_body + ): + if data is not None: + # Convert data to string if it's not already a string + if not isinstance(data, str): + try: + # Check if data is a dictionary, list, or tuple then convert to JSON string + if isinstance(data, (dict, list, tuple)): + data = json.dumps(data) + else: + # If it's another type, simply convert to string + data = str(data) + except Exception as e: + pass + # Store stringified data in the database + try: + store_api_call( + enterprise_customer=self.enterprise_configuration.enterprise_customer, + enterprise_customer_configuration_id=self.enterprise_configuration.id, + endpoint=url, + payload=data, + time_taken=time_taken, + status_code=status_code, + response_body=response_body, + ) + except Exception as e: + print(f"Failed to store data in the database: {e}") + def _get(self, url, data=None): """ Returns request's get response and raises Client Errors if appropriate. """ + start_time = time.time() get_response = self.session.get(url, params=data) + time_taken = time.time() - start_time + self.stringify_and_store_api_record( + url=url, + data=data, + time_taken=time_taken, + status_code=get_response.status_code, + response_body=get_response.text, + ) if get_response.status_code >= 400: raise ClientError(get_response.text, get_response.status_code) return get_response @@ -607,7 +650,16 @@ def _patch(self, url, data): """ Returns request's patch response and raises Client Errors if appropriate. """ + start_time = time.time() patch_response = self.session.patch(url, json=data) + time_taken = time.time() - start_time + self.stringify_and_store_api_record( + url=url, + data=data, + time_taken=time_taken, + status_code=patch_response.status_code, + response_body=patch_response.text, + ) if patch_response.status_code >= 400: raise ClientError(patch_response.text, patch_response.status_code) return patch_response @@ -616,7 +668,17 @@ def _post(self, url, data): """ Returns request's post response and raises Client Errors if appropriate. """ + start_time = time.time() post_response = self.session.post(url, json=data) + time_taken = time.time() - start_time + self.stringify_and_store_api_record( + url=url, + data=data, + time_taken=time_taken, + status_code=post_response.status_code, + response_body=post_response.text, + ) + if post_response.status_code >= 400: raise ClientError(post_response.text, post_response.status_code) return post_response @@ -625,7 +687,16 @@ def _delete(self, url): """ Returns request's delete response and raises Client Errors if appropriate. """ + start_time = time.time() response = self.session.delete(url) + time_taken = time.time() - start_time + self.stringify_and_store_api_record( + url=url, + data='', + time_taken=time_taken, + status_code=response.status_code, + response_body=response.text, + ) if response.status_code >= 400: raise ClientError(response.text, response.status_code) return response From b297b8373b9f8b75700167ccae49b002f229c62f Mon Sep 17 00:00:00 2001 From: edX requirements bot Date: Mon, 22 Jan 2024 19:28:13 -0500 Subject: [PATCH 105/164] chore: Updating Python Requirements --- requirements/celery53.txt | 6 +- requirements/ci.txt | 7 +- requirements/common_constraints.txt | 4 - requirements/dev.txt | 117 +++++++------- requirements/django.txt | 2 +- requirements/doc.txt | 91 ++++++----- requirements/edx-platform-constraints.txt | 177 ++++++++++------------ requirements/js_test.txt | 34 +++-- requirements/test-master.txt | 75 +++++---- requirements/test.txt | 81 +++++----- 10 files changed, 284 insertions(+), 310 deletions(-) diff --git a/requirements/celery53.txt b/requirements/celery53.txt index f5e6e328aa..71b29858a8 100644 --- a/requirements/celery53.txt +++ b/requirements/celery53.txt @@ -1,9 +1,9 @@ amqp==5.2.0 billiard==4.2.0 -celery==5.3.4 +celery==5.3.6 click==8.1.7 click-didyoumean==0.3.0 click-repl==0.3.0 -kombu==5.3.3 -prompt-toolkit==3.0.39 +kombu==5.3.5 +prompt-toolkit==3.0.43 vine==5.1.0 diff --git a/requirements/ci.txt b/requirements/ci.txt index a055e8ef41..a7bca04d76 100644 --- a/requirements/ci.txt +++ b/requirements/ci.txt @@ -4,7 +4,7 @@ # # make upgrade # -distlib==0.3.7 +distlib==0.3.8 # via virtualenv filelock==3.13.1 # via @@ -12,7 +12,7 @@ filelock==3.13.1 # virtualenv packaging==23.2 # via tox -platformdirs==3.11.0 +platformdirs==4.1.0 # via virtualenv pluggy==1.3.0 # via tox @@ -24,8 +24,7 @@ tomli==2.0.1 # via tox tox==3.28.0 # via - # -c requirements/common_constraints.txt # -c requirements/constraints.txt # -r requirements/ci.in -virtualenv==20.24.6 +virtualenv==20.25.0 # via tox diff --git a/requirements/common_constraints.txt b/requirements/common_constraints.txt index 08e94f34dd..be61b7e0ed 100644 --- a/requirements/common_constraints.txt +++ b/requirements/common_constraints.txt @@ -21,7 +21,3 @@ Django<4.0 elasticsearch<7.14.0 # django-simple-history>3.0.0 adds indexing and causes a lot of migrations to be affected - -# tox>4.0.0 isn't yet compatible with many tox plugins, causing CI failures in almost all repos. -# Details can be found in this discussion: https://github.com/tox-dev/tox/discussions/1810 -tox<4.0.0 diff --git a/requirements/dev.txt b/requirements/dev.txt index fbe87b0ec8..30852d5719 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -8,7 +8,7 @@ accessible-pygments==0.0.4 # via # -r requirements/doc.txt # pydata-sphinx-theme -aiohttp==3.8.6 +aiohttp==3.9.1 # via # -r requirements/doc.txt # -r requirements/test-master.txt @@ -48,9 +48,8 @@ asn1crypto==1.5.1 # -r requirements/doc.txt # -r requirements/test-master.txt # -r requirements/test.txt - # oscrypto # snowflake-connector-python -astroid==3.0.1 +astroid==3.0.2 # via # pylint # pylint-celery @@ -67,7 +66,7 @@ attrs==23.1.0 # -r requirements/test.txt # aiohttp # pytest -babel==2.13.1 +babel==2.14.0 # via # -r requirements/doc.txt # pydata-sphinx-theme @@ -77,9 +76,10 @@ backports-zoneinfo[tzdata]==0.2.1 # -r requirements/doc.txt # -r requirements/test-master.txt # -r requirements/test.txt + # backports-zoneinfo # celery # kombu -beautifulsoup4==4.12.2 +beautifulsoup4==4.12.3 # via # -r requirements/doc.txt # pydata-sphinx-theme @@ -96,13 +96,13 @@ bleach==6.1.0 # -r requirements/test.txt build==1.0.3 # via pip-tools -celery==5.3.4 +celery==5.3.6 # via # -c requirements/constraints.txt # -r requirements/doc.txt # -r requirements/test-master.txt # -r requirements/test.txt -certifi==2023.7.22 +certifi==2023.11.17 # via # -r requirements/doc.txt # -r requirements/test-master.txt @@ -126,7 +126,6 @@ charset-normalizer==2.0.12 # -r requirements/doc.txt # -r requirements/test-master.txt # -r requirements/test.txt - # aiohttp # requests # snowflake-connector-python click==8.1.7 @@ -183,9 +182,10 @@ coreschema==0.0.4 # -r requirements/test.txt # coreapi # drf-yasg -coverage[toml]==7.3.2 +coverage[toml]==7.4.0 # via # -r requirements/test.txt + # coverage # pytest-cov cryptography==38.0.4 # via @@ -212,13 +212,13 @@ deprecated==1.2.14 # -r requirements/test-master.txt # -r requirements/test.txt # jwcrypto -diff-cover==8.0.0 +diff-cover==8.0.3 # via -r requirements/test.txt dill==0.3.7 # via pylint -distlib==0.3.7 +distlib==0.3.8 # via virtualenv -django==3.2.22 +django==3.2.23 # via # -c requirements/common_constraints.txt # -r requirements/doc.txt @@ -270,12 +270,12 @@ django-fernet-fields-v2==0.9 # -r requirements/doc.txt # -r requirements/test-master.txt # -r requirements/test.txt -django-filter==23.3 +django-filter==23.5 # via # -r requirements/doc.txt # -r requirements/test-master.txt # -r requirements/test.txt -django-ipware==5.0.1 +django-ipware==6.0.2 # via # -r requirements/doc.txt # -r requirements/test-master.txt @@ -307,7 +307,7 @@ django-simple-history==3.1.1 # -r requirements/doc.txt # -r requirements/test-master.txt # -r requirements/test.txt -django-waffle==4.0.0 +django-waffle==4.1.0 # via # -r requirements/doc.txt # -r requirements/test-master.txt @@ -362,7 +362,7 @@ edx-braze-client==0.1.8 # -r requirements/doc.txt # -r requirements/test-master.txt # -r requirements/test.txt -edx-django-utils==5.7.0 +edx-django-utils==5.9.0 # via # -r requirements/doc.txt # -r requirements/test-master.txt @@ -371,7 +371,7 @@ edx-django-utils==5.7.0 # edx-drf-extensions # edx-rest-api-client # edx-toggles -edx-drf-extensions==8.12.0 +edx-drf-extensions==9.1.2 # via # -r requirements/doc.txt # -r requirements/test-master.txt @@ -387,12 +387,13 @@ edx-opaque-keys[django]==2.5.1 # -r requirements/test-master.txt # -r requirements/test.txt # edx-drf-extensions + # edx-opaque-keys edx-rbac==1.8.0 # via # -r requirements/doc.txt # -r requirements/test-master.txt # -r requirements/test.txt -edx-rest-api-client==5.6.0 +edx-rest-api-client==5.6.1 # via # -r requirements/doc.txt # -r requirements/test-master.txt @@ -412,12 +413,12 @@ factory-boy==3.3.0 # -c requirements/constraints.txt # -r requirements/doc.txt # -r requirements/test.txt -faker==19.13.0 +faker==22.5.0 # via # -r requirements/doc.txt # -r requirements/test.txt # factory-boy -filelock==3.12.4 +filelock==3.13.1 # via # -r requirements/doc.txt # -r requirements/test-master.txt @@ -436,7 +437,7 @@ frozenlist==1.4.0 # -r requirements/test.txt # aiohttp # aiosignal -idna==3.4 +idna==3.6 # via # -r requirements/doc.txt # -r requirements/test-master.txt @@ -448,7 +449,7 @@ imagesize==1.4.1 # via # -r requirements/doc.txt # sphinx -importlib-metadata==6.8.0 +importlib-metadata==7.0.1 # via # -r requirements/doc.txt # build @@ -464,7 +465,7 @@ iniconfig==2.0.0 # -r requirements/doc.txt # -r requirements/test.txt # pytest -isort==5.12.0 +isort==5.13.2 # via # -r requirements/dev.in # pylint @@ -499,13 +500,13 @@ jwcrypto==1.5.0 # -r requirements/test-master.txt # -r requirements/test.txt # django-oauth-toolkit -kombu==5.3.3 +kombu==5.3.5 # via # -r requirements/doc.txt # -r requirements/test-master.txt # -r requirements/test.txt # celery -lxml==4.9.3 +lxml==5.1.0 # via edx-i18n-tools markupsafe==2.1.3 # via @@ -526,13 +527,13 @@ multidict==6.0.4 # -r requirements/test.txt # aiohttp # yarl -newrelic==9.1.0 +newrelic==9.3.0 # via # -r requirements/doc.txt # -r requirements/test-master.txt # -r requirements/test.txt # edx-django-utils -nh3==0.2.14 +nh3==0.2.15 # via # -r requirements/doc.txt # readme-renderer @@ -547,12 +548,6 @@ openai==0.28.1 # -r requirements/doc.txt # -r requirements/test-master.txt # -r requirements/test.txt -oscrypto==1.3.0 - # via - # -r requirements/doc.txt - # -r requirements/test-master.txt - # -r requirements/test.txt - # snowflake-connector-python packaging==23.2 # via # -r requirements/doc.txt @@ -565,7 +560,7 @@ packaging==23.2 # snowflake-connector-python # sphinx # tox -path==16.7.1 +path==16.9.0 # via # -r requirements/doc.txt # -r requirements/test-master.txt @@ -577,7 +572,7 @@ path-py==12.5.0 # -r requirements/doc.txt # -r requirements/test-master.txt # -r requirements/test.txt -pbr==5.11.1 +pbr==6.0.0 # via # -r requirements/doc.txt # -r requirements/test-master.txt @@ -588,7 +583,7 @@ pgpy==0.6.0 # -r requirements/doc.txt # -r requirements/test-master.txt # -r requirements/test.txt -pillow==9.5.0 +pillow==10.1.0 # via # -r requirements/doc.txt # -r requirements/test-master.txt @@ -614,13 +609,13 @@ pluggy==1.3.0 # tox polib==1.2.0 # via edx-i18n-tools -prompt-toolkit==3.0.39 +prompt-toolkit==3.0.43 # via # -r requirements/doc.txt # -r requirements/test-master.txt # -r requirements/test.txt # click-repl -psutil==5.9.5 +psutil==5.9.6 # via # -r requirements/doc.txt # -r requirements/test-master.txt @@ -632,7 +627,7 @@ py==1.11.0 # -r requirements/test.txt # pytest # tox -pyasn1==0.5.0 +pyasn1==0.5.1 # via # -r requirements/doc.txt # -r requirements/test-master.txt @@ -646,19 +641,13 @@ pycparser==2.21 # -r requirements/test-master.txt # -r requirements/test.txt # cffi -pycryptodomex==3.19.0 - # via - # -r requirements/doc.txt - # -r requirements/test-master.txt - # -r requirements/test.txt - # snowflake-connector-python -pydata-sphinx-theme==0.14.3 +pydata-sphinx-theme==0.14.4 # via # -r requirements/doc.txt # sphinx-book-theme pydocstyle==6.3.0 # via -r requirements/dev.in -pygments==2.16.1 +pygments==2.17.2 # via # -r requirements/doc.txt # -r requirements/test.txt @@ -676,8 +665,9 @@ pyjwt[crypto]==2.8.0 # drf-jwt # edx-drf-extensions # edx-rest-api-client + # pyjwt # snowflake-connector-python -pylint==3.0.2 +pylint==3.0.3 # via # edx-lint # pylint-celery @@ -730,13 +720,19 @@ python-dateutil==2.8.2 # celery # faker # freezegun +python-ipware==2.0.0 + # via + # -r requirements/doc.txt + # -r requirements/test-master.txt + # -r requirements/test.txt + # django-ipware python-slugify==8.0.1 # via # -r requirements/doc.txt # -r requirements/test-master.txt # -r requirements/test.txt # code-annotations -pytz==2022.7.1 +pytz==2023.3.post1 # via # -r requirements/doc.txt # -r requirements/test-master.txt @@ -782,7 +778,7 @@ restructuredtext-lint==1.4.0 # via # -r requirements/doc.txt # doc8 -ruamel-yaml==0.17.35 +ruamel-yaml==0.18.5 # via # -r requirements/doc.txt # -r requirements/test-master.txt @@ -829,7 +825,7 @@ snowballstemmer==2.2.0 # -r requirements/doc.txt # pydocstyle # sphinx -snowflake-connector-python==3.2.1 +snowflake-connector-python==3.6.0 # via # -r requirements/doc.txt # -r requirements/test-master.txt @@ -890,7 +886,7 @@ stevedore==5.1.0 # doc8 # edx-django-utils # edx-opaque-keys -testfixtures==7.2.0 +testfixtures==7.2.2 # via # -r requirements/dev.in # -r requirements/doc.txt @@ -918,7 +914,7 @@ tomli==2.0.1 # pylint # pyproject-hooks # tox -tomlkit==0.12.1 +tomlkit==0.12.3 # via # -r requirements/doc.txt # -r requirements/test-master.txt @@ -927,7 +923,6 @@ tomlkit==0.12.1 # snowflake-connector-python tox==3.28.0 # via - # -c requirements/common_constraints.txt # -c requirements/constraints.txt # -r requirements/dev.in tqdm==4.66.1 @@ -939,7 +934,7 @@ tqdm==4.66.1 # twine twine==1.11.0 # via -r requirements/dev.in -typing-extensions==4.8.0 +typing-extensions==4.9.0 # via # -r requirements/doc.txt # -r requirements/test-master.txt @@ -972,7 +967,7 @@ uritemplate==4.1.1 # -r requirements/test.txt # coreapi # drf-yasg -urllib3==1.26.17 +urllib3==1.26.18 # via # -r requirements/doc.txt # -r requirements/test-master.txt @@ -987,9 +982,9 @@ vine==5.1.0 # amqp # celery # kombu -virtualenv==20.24.6 +virtualenv==20.25.0 # via tox -wcwidth==0.2.8 +wcwidth==0.2.12 # via # -r requirements/doc.txt # -r requirements/test-master.txt @@ -1001,17 +996,17 @@ webencodings==0.5.1 # -r requirements/test-master.txt # -r requirements/test.txt # bleach -wheel==0.41.3 +wheel==0.42.0 # via # -r requirements/dev.in # pip-tools -wrapt==1.15.0 +wrapt==1.16.0 # via # -r requirements/doc.txt # -r requirements/test-master.txt # -r requirements/test.txt # deprecated -yarl==1.9.2 +yarl==1.9.4 # via # -r requirements/doc.txt # -r requirements/test-master.txt diff --git a/requirements/django.txt b/requirements/django.txt index 5a28da341d..d296127a53 100644 --- a/requirements/django.txt +++ b/requirements/django.txt @@ -1 +1 @@ -django==3.2.22 +django==3.2.23 diff --git a/requirements/doc.txt b/requirements/doc.txt index 62eae90148..fce593f30e 100644 --- a/requirements/doc.txt +++ b/requirements/doc.txt @@ -6,7 +6,7 @@ # accessible-pygments==0.0.4 # via pydata-sphinx-theme -aiohttp==3.8.6 +aiohttp==3.9.1 # via # -r requirements/test-master.txt # openai @@ -32,7 +32,6 @@ asgiref==3.7.2 asn1crypto==1.5.1 # via # -r requirements/test-master.txt - # oscrypto # snowflake-connector-python async-timeout==4.0.3 # via @@ -43,16 +42,17 @@ attrs==23.1.0 # -r requirements/test-master.txt # aiohttp # pytest -babel==2.13.1 +babel==2.14.0 # via # pydata-sphinx-theme # sphinx backports-zoneinfo[tzdata]==0.2.1 # via # -r requirements/test-master.txt + # backports-zoneinfo # celery # kombu -beautifulsoup4==4.12.2 +beautifulsoup4==4.12.3 # via pydata-sphinx-theme billiard==4.2.0 # via @@ -60,11 +60,11 @@ billiard==4.2.0 # celery bleach==6.1.0 # via -r requirements/test-master.txt -celery==5.3.4 +celery==5.3.6 # via # -c requirements/constraints.txt # -r requirements/test-master.txt -certifi==2023.7.22 +certifi==2023.11.17 # via # -r requirements/test-master.txt # requests @@ -78,7 +78,6 @@ cffi==1.16.0 charset-normalizer==2.0.12 # via # -r requirements/test-master.txt - # aiohttp # requests # snowflake-connector-python click==8.1.7 @@ -132,7 +131,7 @@ deprecated==1.2.14 # via # -r requirements/test-master.txt # jwcrypto -django==3.2.22 +django==3.2.23 # via # -c requirements/common_constraints.txt # -r requirements/test-master.txt @@ -167,9 +166,9 @@ django-crum==0.7.9 # edx-toggles django-fernet-fields-v2==0.9 # via -r requirements/test-master.txt -django-filter==23.3 +django-filter==23.5 # via -r requirements/test-master.txt -django-ipware==5.0.1 +django-ipware==6.0.2 # via -r requirements/test-master.txt django-model-utils==4.3.1 # via @@ -185,7 +184,7 @@ django-simple-history==3.1.1 # via # -c requirements/constraints.txt # -r requirements/test-master.txt -django-waffle==4.0.0 +django-waffle==4.1.0 # via # -r requirements/test-master.txt # edx-django-utils @@ -223,14 +222,14 @@ edx-api-doc-tools==1.7.0 # via -r requirements/test-master.txt edx-braze-client==0.1.8 # via -r requirements/test-master.txt -edx-django-utils==5.7.0 +edx-django-utils==5.9.0 # via # -r requirements/test-master.txt # django-config-models # edx-drf-extensions # edx-rest-api-client # edx-toggles -edx-drf-extensions==8.12.0 +edx-drf-extensions==9.1.2 # via # -r requirements/test-master.txt # edx-rbac @@ -238,9 +237,10 @@ edx-opaque-keys[django]==2.5.1 # via # -r requirements/test-master.txt # edx-drf-extensions + # edx-opaque-keys edx-rbac==1.8.0 # via -r requirements/test-master.txt -edx-rest-api-client==5.6.0 +edx-rest-api-client==5.6.1 # via -r requirements/test-master.txt edx-tincan-py35==1.0.0 # via -r requirements/test-master.txt @@ -250,9 +250,9 @@ factory-boy==3.3.0 # via # -c requirements/constraints.txt # -r requirements/doc.in -faker==19.13.0 +faker==22.5.0 # via factory-boy -filelock==3.12.4 +filelock==3.13.1 # via # -r requirements/test-master.txt # snowflake-connector-python @@ -261,7 +261,7 @@ frozenlist==1.4.0 # -r requirements/test-master.txt # aiohttp # aiosignal -idna==3.4 +idna==3.6 # via # -r requirements/test-master.txt # requests @@ -269,7 +269,7 @@ idna==3.4 # yarl imagesize==1.4.1 # via sphinx -importlib-metadata==6.8.0 +importlib-metadata==7.0.1 # via sphinx inflection==0.5.1 # via @@ -295,7 +295,7 @@ jwcrypto==1.5.0 # via # -r requirements/test-master.txt # django-oauth-toolkit -kombu==5.3.3 +kombu==5.3.5 # via # -r requirements/test-master.txt # celery @@ -308,11 +308,11 @@ multidict==6.0.4 # -r requirements/test-master.txt # aiohttp # yarl -newrelic==9.1.0 +newrelic==9.3.0 # via # -r requirements/test-master.txt # edx-django-utils -nh3==0.2.14 +nh3==0.2.15 # via readme-renderer oauthlib==3.2.2 # via @@ -320,10 +320,6 @@ oauthlib==3.2.2 # django-oauth-toolkit openai==0.28.1 # via -r requirements/test-master.txt -oscrypto==1.3.0 - # via - # -r requirements/test-master.txt - # snowflake-connector-python packaging==23.2 # via # -r requirements/test-master.txt @@ -332,19 +328,19 @@ packaging==23.2 # pytest # snowflake-connector-python # sphinx -path==16.7.1 +path==16.9.0 # via # -r requirements/test-master.txt # path-py path-py==12.5.0 # via -r requirements/test-master.txt -pbr==5.11.1 +pbr==6.0.0 # via # -r requirements/test-master.txt # stevedore pgpy==0.6.0 # via -r requirements/test-master.txt -pillow==9.5.0 +pillow==10.1.0 # via -r requirements/test-master.txt platformdirs==3.11.0 # via @@ -352,17 +348,17 @@ platformdirs==3.11.0 # snowflake-connector-python pluggy==1.3.0 # via pytest -prompt-toolkit==3.0.39 +prompt-toolkit==3.0.43 # via # -r requirements/test-master.txt # click-repl -psutil==5.9.5 +psutil==5.9.6 # via # -r requirements/test-master.txt # edx-django-utils py==1.11.0 # via pytest -pyasn1==0.5.0 +pyasn1==0.5.1 # via # -r requirements/test-master.txt # pgpy @@ -370,13 +366,9 @@ pycparser==2.21 # via # -r requirements/test-master.txt # cffi -pycryptodomex==3.19.0 - # via - # -r requirements/test-master.txt - # snowflake-connector-python -pydata-sphinx-theme==0.14.3 +pydata-sphinx-theme==0.14.4 # via sphinx-book-theme -pygments==2.16.1 +pygments==2.17.2 # via # accessible-pygments # doc8 @@ -389,6 +381,7 @@ pyjwt[crypto]==2.8.0 # drf-jwt # edx-drf-extensions # edx-rest-api-client + # pyjwt # snowflake-connector-python pymongo==3.13.0 # via @@ -411,11 +404,15 @@ python-dateutil==2.8.2 # -r requirements/test-master.txt # celery # faker +python-ipware==2.0.0 + # via + # -r requirements/test-master.txt + # django-ipware python-slugify==8.0.1 # via # -r requirements/test-master.txt # code-annotations -pytz==2022.7.1 +pytz==2023.3.post1 # via # -r requirements/test-master.txt # babel @@ -443,7 +440,7 @@ requests==2.31.0 # sphinx restructuredtext-lint==1.4.0 # via doc8 -ruamel-yaml==0.17.35 +ruamel-yaml==0.18.5 # via # -r requirements/test-master.txt # drf-yasg @@ -469,7 +466,7 @@ slumber==0.7.1 # edx-rest-api-client snowballstemmer==2.2.0 # via sphinx -snowflake-connector-python==3.2.1 +snowflake-connector-python==3.6.0 # via -r requirements/test-master.txt sortedcontainers==2.4.0 # via @@ -507,7 +504,7 @@ stevedore==5.1.0 # doc8 # edx-django-utils # edx-opaque-keys -testfixtures==7.2.0 +testfixtures==7.2.2 # via -r requirements/test-master.txt text-unidecode==1.3 # via @@ -517,7 +514,7 @@ toml==0.10.2 # via pytest tomli==2.0.1 # via doc8 -tomlkit==0.12.1 +tomlkit==0.12.3 # via # -r requirements/test-master.txt # snowflake-connector-python @@ -525,7 +522,7 @@ tqdm==4.66.1 # via # -r requirements/test-master.txt # openai -typing-extensions==4.8.0 +typing-extensions==4.9.0 # via # -r requirements/test-master.txt # asgiref @@ -547,7 +544,7 @@ uritemplate==4.1.1 # -r requirements/test-master.txt # coreapi # drf-yasg -urllib3==1.26.17 +urllib3==1.26.18 # via # -r requirements/test-master.txt # requests @@ -558,7 +555,7 @@ vine==5.1.0 # amqp # celery # kombu -wcwidth==0.2.8 +wcwidth==0.2.12 # via # -r requirements/test-master.txt # prompt-toolkit @@ -566,11 +563,11 @@ webencodings==0.5.1 # via # -r requirements/test-master.txt # bleach -wrapt==1.15.0 +wrapt==1.16.0 # via # -r requirements/test-master.txt # deprecated -yarl==1.9.2 +yarl==1.9.4 # via # -r requirements/test-master.txt # aiohttp diff --git a/requirements/edx-platform-constraints.txt b/requirements/edx-platform-constraints.txt index 4fe802bf37..9663e6f7c5 100644 --- a/requirements/edx-platform-constraints.txt +++ b/requirements/edx-platform-constraints.txt @@ -8,16 +8,14 @@ # via -r requirements/edx/github.in acid-xblock==0.2.1 # via -r requirements/edx/kernel.in -aiohttp==3.8.6 +aiohttp==3.9.1 # via # geoip2 # openai aiosignal==1.3.1 # via aiohttp -algoliasearch==2.6.3 - # via - # -c requirements/edx/../constraints.txt - # -r requirements/edx/bundled.in +algoliasearch==3.0.0 + # via -r requirements/edx/bundled.in # via kombu analytics-python==1.4.post1 # via -r requirements/edx/kernel.in @@ -28,11 +26,10 @@ appdirs==1.4.4 asgiref==3.7.2 # via # django + # django-cors-headers # django-countries asn1crypto==1.5.1 - # via - # oscrypto - # snowflake-connector-python + # via snowflake-connector-python async-timeout==4.0.3 # via # aiohttp @@ -48,9 +45,8 @@ attrs==23.1.0 # openedx-events # openedx-learning # referencing -babel==2.11.0 +babel==2.14.0 # via - # -c requirements/edx/../constraints.txt # -r requirements/edx/kernel.in # enmerkar # enmerkar-underscore @@ -73,17 +69,15 @@ bleach[css]==6.1.0 # ora2 # xblock-drag-and-drop-v2 # xblock-poll -boto==2.39.0 - # via - # -c requirements/edx/../constraints.txt - # -r requirements/edx/kernel.in -boto3==1.28.62 +boto==2.49.0 + # via -r requirements/edx/kernel.in +boto3==1.33.12 # via # -r requirements/edx/kernel.in # django-ses # fs-s3fs # ora2 -botocore==1.31.62 +botocore==1.33.12 # via # -r requirements/edx/kernel.in # boto3 @@ -99,7 +93,7 @@ bridgekeeper==0.9 # edx-enterprise # event-tracking # openedx-learning -certifi==2023.7.22 +certifi==2023.11.17 # via # -r requirements/edx/paver.txt # elasticsearch @@ -117,7 +111,6 @@ charset-normalizer==2.0.12 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/paver.txt - # aiohttp # requests # snowflake-connector-python chem==1.2.0 @@ -163,7 +156,7 @@ cryptography==38.0.4 # pyopenssl # snowflake-connector-python # social-auth-core -cssutils==2.7.1 +cssutils==2.9.0 # via pynliner defusedxml==0.7.1 # via @@ -174,7 +167,7 @@ defusedxml==0.7.1 # social-auth-core deprecated==1.2.14 # via jwcrypto -django==3.2.22 +django==3.2.23 # via # -c requirements/edx/../common_constraints.txt # -r requirements/edx/kernel.in @@ -245,7 +238,7 @@ django==3.2.22 # ora2 # super-csv # xss-utils -django-appconf==1.0.5 +django-appconf==1.0.6 # via django-statici18n django-cache-memoize==0.2.0 django-celery-results==2.5.1 @@ -258,7 +251,7 @@ django-config-models==2.5.1 # edx-enterprise # edx-name-affirmation # lti-consumer-xblock -django-cors-headers==4.2.0 +django-cors-headers==4.3.1 # via -r requirements/edx/kernel.in django-countries==7.5.1 # via @@ -276,13 +269,13 @@ django-crum==0.7.9 django-environ==0.11.2 # via openedx-blockstore django-fernet-fields-v2==0.9 -django-filter==23.3 +django-filter==23.5 # via # -r requirements/edx/kernel.in # edx-enterprise # lti-consumer-xblock # openedx-blockstore -django-ipware==5.0.1 +django-ipware==6.0.2 # via # -r requirements/edx/kernel.in # edx-enterprise @@ -314,7 +307,7 @@ django-mptt==0.14.0 # -r requirements/edx/kernel.in # openedx-django-wiki django-multi-email-field==0.7.0 -django-mysql==4.11.0 +django-mysql==4.12.0 # via -r requirements/edx/kernel.in django-oauth-toolkit==1.7.1 # via @@ -330,7 +323,7 @@ django-sekizai==4.1.0 # via # -r requirements/edx/kernel.in # openedx-django-wiki -django-ses==3.5.0 +django-ses==3.5.2 # via -r requirements/edx/bundled.in # via # -r requirements/edx/kernel.in @@ -346,14 +339,13 @@ django-statici18n==2.4.0 # -r requirements/edx/kernel.in # lti-consumer-xblock # xblock-drag-and-drop-v2 -django-storages==1.14 +django-storages==1.14.2 # via - # -c requirements/edx/../constraints.txt # -r requirements/edx/kernel.in # edxval django-user-tasks==3.1.0 # via -r requirements/edx/kernel.in -django-waffle==4.0.0 +django-waffle==4.1.0 # via # -r requirements/edx/kernel.in # edx-django-utils @@ -390,13 +382,13 @@ djangorestframework==3.14.0 # ora2 # super-csv djangorestframework-xml==2.0.0 -done-xblock==2.1.0 +done-xblock==2.2.0 # via -r requirements/edx/bundled.in drf-jwt==1.19.2 # via edx-drf-extensions drf-nested-routers==0.93.4 # via openedx-blockstore -drf-spectacular==0.26.5 +drf-spectacular==0.27.0 # via -r requirements/edx/kernel.in drf-yasg==1.21.5 # via @@ -433,16 +425,16 @@ edx-celeryutils==1.2.3 # super-csv edx-codejail==3.3.3 # via -r requirements/edx/kernel.in -edx-completion==4.3.0 +edx-completion==4.4.0 # via -r requirements/edx/kernel.in edx-django-release-util==1.3.0 # via # -r requirements/edx/kernel.in # edxval # openedx-blockstore -edx-django-sites-extensions==4.0.1 +edx-django-sites-extensions==4.0.2 # via -r requirements/edx/kernel.in -edx-django-utils==5.7.0 +edx-django-utils==5.9.0 # via # -r requirements/edx/kernel.in # django-config-models @@ -458,7 +450,7 @@ edx-django-utils==5.7.0 # openedx-blockstore # ora2 # super-csv -edx-drf-extensions==8.12.0 +edx-drf-extensions==9.1.2 # via # -r requirements/edx/kernel.in # edx-completion @@ -470,7 +462,7 @@ edx-drf-extensions==8.12.0 # edx-when # edxval # openedx-learning -edx-enterprise==4.6.12 +edx-enterprise==4.10.6 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/kernel.in @@ -507,12 +499,12 @@ edx-proctoring==4.16.1 # -r requirements/edx/kernel.in # edx-proctoring-proctortrack edx-rbac==1.8.0 -edx-rest-api-client==5.6.0 +edx-rest-api-client==5.6.1 # via # -r requirements/edx/kernel.in # edx-enterprise # edx-proctoring -edx-search==3.6.0 +edx-search==3.8.2 # via -r requirements/edx/kernel.in edx-sga==0.23.0 # via -r requirements/edx/bundled.in @@ -551,11 +543,12 @@ enmerkar-underscore==2.2.0 event-tracking==2.2.0 # via # -r requirements/edx/kernel.in + # edx-completion # edx-proctoring # edx-search -fastavro==1.8.4 +fastavro==1.9.1 # via openedx-events -filelock==3.12.4 +filelock==3.13.1 # via snowflake-connector-python frozenlist==1.4.0 # via @@ -573,7 +566,7 @@ fs-s3fs==0.1.8 # openedx-django-pyfs future==0.18.3 # via pyjwkest -geoip2==4.7.0 +geoip2==4.8.0 # via -r requirements/edx/kernel.in glob2==0.7 # via -r requirements/edx/kernel.in @@ -585,21 +578,22 @@ html5lib==1.1 # via # -r requirements/edx/kernel.in # ora2 -icalendar==5.0.10 +icalendar==5.0.11 # via -r requirements/edx/kernel.in -idna==3.4 +idna==3.6 # via # -r requirements/edx/paver.txt # optimizely-sdk # requests # snowflake-connector-python # yarl -importlib-metadata==6.8.0 +importlib-metadata==7.0.0 # via markdown -importlib-resources==6.1.0 +importlib-resources==5.13.0 # via # jsonschema # jsonschema-specifications + # pycountry inflection==0.5.1 # via # drf-spectacular @@ -632,11 +626,11 @@ jsonfield==3.1.0 # edx-submissions # lti-consumer-xblock # ora2 -jsonschema==4.19.1 +jsonschema==4.20.0 # via # drf-spectacular # optimizely-sdk -jsonschema-specifications==2023.7.1 +jsonschema-specifications==2023.11.2 # via jsonschema jwcrypto==1.5.0 # via @@ -658,10 +652,8 @@ libsass==0.10.0 # -r requirements/edx/paver.txt loremipsum==1.0.5 # via ora2 -lti-consumer-xblock==9.6.1 - # via - # -c requirements/edx/../constraints.txt - # -r requirements/edx/kernel.in +lti-consumer-xblock==9.8.1 + # via -r requirements/edx/kernel.in lxml==4.9.3 # via # -r requirements/edx/kernel.in @@ -676,7 +668,7 @@ lxml==4.9.3 # xmlsec mailsnake==1.6.4 # via -r requirements/edx/bundled.in -mako==1.2.4 +mako==1.3.0 # via # -r requirements/edx/kernel.in # acid-xblock @@ -701,7 +693,7 @@ markupsafe==2.1.3 # mako # openedx-calc # xblock -maxminddb==2.4.0 +maxminddb==2.5.1 # via geoip2 mock==5.1.0 # via -r requirements/edx/paver.txt @@ -721,7 +713,7 @@ mysqlclient==2.2.0 # via # -r requirements/edx/kernel.in # openedx-blockstore -newrelic==9.1.0 +newrelic==9.3.0 # via # -r requirements/edx/bundled.in # edx-django-utils @@ -745,6 +737,9 @@ oauthlib==3.2.2 olxcleaner==0.2.1 # via -r requirements/edx/kernel.in openai==0.28.1 + # via + # -c requirements/edx/../constraints.txt + # edx-enterprise openedx-atlas==0.5.0 # via -r requirements/edx/kernel.in openedx-blockstore==1.4.0 @@ -759,7 +754,7 @@ openedx-django-require==2.1.0 # via -r requirements/edx/kernel.in openedx-django-wiki==2.0.3 # via -r requirements/edx/kernel.in -openedx-events==9.0.1 +openedx-events==9.2.0 # via # -r requirements/edx/kernel.in # edx-event-bus-kafka @@ -768,7 +763,7 @@ openedx-filters==1.6.0 # via # -r requirements/edx/kernel.in # lti-consumer-xblock -openedx-learning==0.3.2 +openedx-learning==0.4.2 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/kernel.in @@ -776,10 +771,8 @@ openedx-mongodbproxy==0.2.0 # via -r requirements/edx/kernel.in optimizely-sdk==4.1.1 # via -r requirements/edx/bundled.in -ora2==6.0.0 +ora2==6.0.28 # via -r requirements/edx/bundled.in -oscrypto==1.3.0 - # via snowflake-connector-python packaging==23.2 # via # drf-yasg @@ -788,7 +781,7 @@ packaging==23.2 # snowflake-connector-python pansi==2020.7.3 # via py2neo -path==16.7.1 +path==16.9.0 # via # -r requirements/edx/kernel.in # -r requirements/edx/paver.txt @@ -801,16 +794,15 @@ path-py==12.5.0 # staff-graded-xblock paver==1.3.4 # via -r requirements/edx/paver.txt -pbr==5.11.1 +pbr==6.0.0 # via # -r requirements/edx/paver.txt # stevedore pgpy==0.6.0 piexif==1.1.3 # via -r requirements/edx/kernel.in -pillow==9.5.0 +pillow==10.1.0 # via - # -c requirements/edx/../constraints.txt # -r requirements/edx/kernel.in # edx-enterprise # edx-organizations @@ -822,7 +814,7 @@ platformdirs==3.11.0 polib==1.2.0 # via edx-i18n-tools # via click-repl -psutil==5.9.5 +psutil==5.9.6 # via # -r requirements/edx/paver.txt # edx-django-utils @@ -830,9 +822,9 @@ py2neo @ https://github.com/overhangio/py2neo/releases/download/2021.2.3/py2neo- # via # -c requirements/edx/../constraints.txt # -r requirements/edx/bundled.in -pyasn1==0.5.0 +pyasn1==0.5.1 # via pgpy -pycountry==22.3.5 +pycountry==23.12.11 # via -r requirements/edx/kernel.in pycparser==2.21 # via cffi @@ -842,8 +834,7 @@ pycryptodomex==3.19.0 # edx-proctoring # lti-consumer-xblock # pyjwkest - # snowflake-connector-python -pygments==2.16.1 +pygments==2.17.2 # via # -r requirements/edx/bundled.in # py2neo @@ -891,7 +882,7 @@ pyparsing==3.1.1 # via # chem # openedx-calc -pyrsistent==0.19.3 +pyrsistent==0.20.0 # via optimizely-sdk pysrt==1.1.2 # via @@ -910,6 +901,8 @@ python-dateutil==2.8.2 # olxcleaner # ora2 # xblock +python-ipware==2.0.0 + # via django-ipware python-memcached==1.59 # via -r requirements/edx/paver.txt python-slugify==8.0.1 @@ -922,9 +915,8 @@ python3-openid==3.2.0 ; python_version >= "3" # social-auth-core python3-saml==1.16.0 # via -r requirements/edx/kernel.in -pytz==2022.7.1 +pytz==2023.3.post1 # via - # -c requirements/edx/../constraints.txt # -r requirements/edx/kernel.in # babel # django @@ -963,7 +955,7 @@ redis==5.0.1 # via # -r requirements/edx/kernel.in # walrus -referencing==0.30.2 +referencing==0.32.0 # via # jsonschema # jsonschema-specifications @@ -996,11 +988,11 @@ requests-oauthlib==1.3.1 # via # -r requirements/edx/kernel.in # social-auth-core -rpds-py==0.10.4 +rpds-py==0.13.2 # via # jsonschema # referencing -ruamel-yaml==0.17.35 +ruamel-yaml==0.18.5 # via drf-yasg ruamel-yaml-clib==0.2.8 # via ruamel-yaml @@ -1010,7 +1002,7 @@ rules==3.3 # edx-enterprise # edx-proctoring # openedx-learning -s3transfer==0.7.0 +s3transfer==0.8.2 # via boto3 sailthru-client==2.2.3 # via edx-ace @@ -1021,7 +1013,7 @@ scipy==1.7.3 # openedx-calc semantic-version==2.10.0 # via edx-drf-extensions -shapely==2.0.1 +shapely==2.0.2 # via -r requirements/edx/kernel.in simplejson==3.19.2 # via @@ -1062,10 +1054,11 @@ six==1.16.0 # python-memcached slumber==0.7.1 # via + # -r requirements/edx/kernel.in # edx-bulk-grades # edx-enterprise # edx-rest-api-client -snowflake-connector-python==3.2.1 +snowflake-connector-python==3.6.0 social-auth-app-django==5.0.0 # via # -c requirements/edx/../constraints.txt @@ -1092,7 +1085,7 @@ sqlparse==0.4.4 # -r requirements/edx/kernel.in # django # openedx-blockstore -staff-graded-xblock==2.1.1 +staff-graded-xblock==2.2.0 # via -r requirements/edx/bundled.in stevedore==5.1.0 # via @@ -1107,22 +1100,23 @@ super-csv==3.1.0 # via edx-bulk-grades sympy==1.12 # via openedx-calc -testfixtures==7.2.0 +testfixtures==7.2.2 text-unidecode==1.3 # via python-slugify tinycss2==1.2.1 # via bleach -tomlkit==0.12.1 +tomlkit==0.12.3 # via snowflake-connector-python tqdm==4.66.1 # via # nltk # openai -typing-extensions==4.8.0 +typing-extensions==4.9.0 # via # -r requirements/edx/paver.txt # asgiref # django-countries + # drf-spectacular # edx-opaque-keys # kombu # pylti1p3 @@ -1140,7 +1134,7 @@ uritemplate==4.1.1 # coreapi # drf-spectacular # drf-yasg -urllib3==1.26.17 +urllib3==1.26.18 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/paver.txt @@ -1155,13 +1149,13 @@ user-util==1.0.0 # amqp # celery # kombu -voluptuous==0.13.1 +voluptuous==0.14.1 # via ora2 walrus==0.9.3 # via edx-event-bus-redis watchdog==3.0.0 # via -r requirements/edx/paver.txt -wcwidth==0.2.8 +wcwidth==0.2.12 # via prompt-toolkit web-fragments==2.1.0 # via @@ -1180,11 +1174,11 @@ webob==1.8.7 # via # -r requirements/edx/kernel.in # xblock -wrapt==1.15.0 +wrapt==1.16.0 # via # -r requirements/edx/paver.txt # deprecated -xblock[django]==1.8.1 +xblock[django]==1.9.0 # via # -r requirements/edx/kernel.in # acid-xblock @@ -1196,28 +1190,25 @@ xblock[django]==1.8.1 # lti-consumer-xblock # ora2 # staff-graded-xblock + # xblock-drag-and-drop-v2 # xblock-google-drive # xblock-poll # xblock-utils -xblock-drag-and-drop-v2==3.2.0 +xblock-drag-and-drop-v2==3.3.0 # via -r requirements/edx/bundled.in -xblock-google-drive==0.4.0 +xblock-google-drive==0.5.0 # via -r requirements/edx/bundled.in xblock-poll==1.13.0 # via -r requirements/edx/bundled.in xblock-utils==4.0.0 # via - # done-xblock # edx-sga - # lti-consumer-xblock - # staff-graded-xblock - # xblock-drag-and-drop-v2 # xblock-google-drive xmlsec==1.3.13 # via python3-saml xss-utils==0.5.0 # via -r requirements/edx/kernel.in -yarl==1.9.2 +yarl==1.9.4 # via aiohttp zipp==3.17.0 # via diff --git a/requirements/js_test.txt b/requirements/js_test.txt index 49ff64e80f..025c512c6b 100644 --- a/requirements/js_test.txt +++ b/requirements/js_test.txt @@ -6,19 +6,19 @@ # annotated-types==0.6.0 # via pydantic -attrs==23.1.0 +attrs==23.2.0 # via # outcome # trio autocommand==2.2.2 # via jaraco-text -certifi==2023.7.22 +certifi==2023.11.17 # via selenium cheroot==10.0.0 # via cherrypy -cherrypy==18.8.0 +cherrypy==18.9.0 # via jasmine -exceptiongroup==1.1.3 +exceptiongroup==1.2.0 # via # trio # trio-websocket @@ -26,7 +26,7 @@ glob2==0.7 # via jasmine-core h11==0.14.0 # via wsproto -idna==3.4 +idna==3.6 # via trio importlib-resources==6.1.1 # via jaraco-text @@ -34,7 +34,7 @@ inflect==7.0.0 # via jaraco-text jaraco-classes==3.3.0 # via -r requirements/js_test.in -jaraco-collections==4.3.0 +jaraco-collections==5.0.0 # via # -r requirements/js_test.in # cherrypy @@ -46,7 +46,7 @@ jaraco-functools==4.0.0 # cheroot # jaraco-text # tempora -jaraco-text==3.11.1 +jaraco-text==3.12.0 # via jaraco-collections jasmine==3.99.0 # via -r requirements/js_test.in @@ -54,9 +54,9 @@ jasmine-core==3.99.0 # via jasmine jinja2==2.11.3 # via jasmine -markupsafe==2.1.3 +markupsafe==2.1.4 # via jinja2 -more-itertools==10.1.0 +more-itertools==10.2.0 # via # cheroot # cherrypy @@ -69,9 +69,9 @@ outcome==1.3.0.post0 # via trio portend==3.2.0 # via cherrypy -pydantic==2.4.2 +pydantic==2.5.3 # via inflect -pydantic-core==2.10.1 +pydantic-core==2.14.6 # via pydantic pysocks==1.7.1 # via urllib3 @@ -79,7 +79,7 @@ pytz==2023.3.post1 # via tempora pyyaml==6.0.1 # via jasmine -selenium==4.15.2 +selenium==4.16.0 # via jasmine sniffio==1.3.0 # via trio @@ -89,20 +89,22 @@ tempora==5.5.0 # via # -r requirements/js_test.in # portend -trio==0.23.1 +trio==0.24.0 # via # selenium # trio-websocket trio-websocket==0.11.1 # via selenium -typing-extensions==4.8.0 +typing-extensions==4.9.0 # via # annotated-types # inflect # pydantic # pydantic-core -urllib3[socks]==2.0.7 - # via selenium +urllib3[socks]==2.1.0 + # via + # selenium + # urllib3 wsproto==1.2.0 # via trio-websocket zc-lockfile==3.0.post1 diff --git a/requirements/test-master.txt b/requirements/test-master.txt index 6f7c3fcff4..8c3d25ecaf 100644 --- a/requirements/test-master.txt +++ b/requirements/test-master.txt @@ -4,7 +4,7 @@ # # make upgrade # -aiohttp==3.8.6 +aiohttp==3.9.1 # via # -c requirements/edx-platform-constraints.txt # openai @@ -26,7 +26,6 @@ asgiref==3.7.2 asn1crypto==1.5.1 # via # -c requirements/edx-platform-constraints.txt - # oscrypto # snowflake-connector-python async-timeout==4.0.3 # via @@ -47,11 +46,11 @@ bleach==6.1.0 # via # -c requirements/edx-platform-constraints.txt # -r requirements/base.in -celery==5.3.4 +celery==5.3.6 # via # -c requirements/constraints.txt # -r requirements/base.in -certifi==2023.7.22 +certifi==2023.11.17 # via # -c requirements/edx-platform-constraints.txt # requests @@ -65,7 +64,6 @@ cffi==1.16.0 charset-normalizer==2.0.12 # via # -c requirements/edx-platform-constraints.txt - # aiohttp # requests # snowflake-connector-python click==8.1.7 @@ -116,7 +114,7 @@ deprecated==1.2.14 # via # -c requirements/edx-platform-constraints.txt # jwcrypto -django==3.2.22 +django==3.2.23 # via # -c requirements/common_constraints.txt # -c requirements/edx-platform-constraints.txt @@ -161,11 +159,11 @@ django-fernet-fields-v2==0.9 # via # -c requirements/edx-platform-constraints.txt # -r requirements/base.in -django-filter==23.3 +django-filter==23.5 # via # -c requirements/edx-platform-constraints.txt # -r requirements/base.in -django-ipware==5.0.1 +django-ipware==6.0.2 # via # -c requirements/edx-platform-constraints.txt # -r requirements/base.in @@ -190,7 +188,7 @@ django-simple-history==3.1.1 # via # -c requirements/constraints.txt # -r requirements/base.in -django-waffle==4.0.0 +django-waffle==4.1.0 # via # -c requirements/edx-platform-constraints.txt # -r requirements/base.in @@ -226,7 +224,7 @@ edx-braze-client==0.1.8 # via # -c requirements/edx-platform-constraints.txt # -r requirements/base.in -edx-django-utils==5.7.0 +edx-django-utils==5.9.0 # via # -c requirements/edx-platform-constraints.txt # -r requirements/base.in @@ -234,7 +232,7 @@ edx-django-utils==5.7.0 # edx-drf-extensions # edx-rest-api-client # edx-toggles -edx-drf-extensions==8.12.0 +edx-drf-extensions==9.1.2 # via # -c requirements/edx-platform-constraints.txt # -r requirements/base.in @@ -248,7 +246,7 @@ edx-rbac==1.8.0 # via # -c requirements/edx-platform-constraints.txt # -r requirements/base.in -edx-rest-api-client==5.6.0 +edx-rest-api-client==5.6.1 # via # -c requirements/edx-platform-constraints.txt # -r requirements/base.in @@ -260,7 +258,7 @@ edx-toggles==5.1.0 # via # -c requirements/edx-platform-constraints.txt # -r requirements/base.in -filelock==3.12.4 +filelock==3.13.1 # via # -c requirements/edx-platform-constraints.txt # snowflake-connector-python @@ -269,7 +267,7 @@ frozenlist==1.4.0 # -c requirements/edx-platform-constraints.txt # aiohttp # aiosignal -idna==3.4 +idna==3.6 # via # -c requirements/edx-platform-constraints.txt # requests @@ -300,7 +298,7 @@ jwcrypto==1.5.0 # via # -c requirements/edx-platform-constraints.txt # django-oauth-toolkit -kombu==5.3.3 +kombu==5.3.5 # via celery markupsafe==2.1.3 # via @@ -311,7 +309,7 @@ multidict==6.0.4 # -c requirements/edx-platform-constraints.txt # aiohttp # yarl -newrelic==9.1.0 +newrelic==9.3.0 # via # -c requirements/edx-platform-constraints.txt # edx-django-utils @@ -323,16 +321,12 @@ openai==0.28.1 # via # -c requirements/edx-platform-constraints.txt # -r requirements/base.in -oscrypto==1.3.0 - # via - # -c requirements/edx-platform-constraints.txt - # snowflake-connector-python packaging==23.2 # via # -c requirements/edx-platform-constraints.txt # drf-yasg # snowflake-connector-python -path==16.7.1 +path==16.9.0 # via # -c requirements/edx-platform-constraints.txt # path-py @@ -340,7 +334,7 @@ path-py==12.5.0 # via # -c requirements/edx-platform-constraints.txt # -r requirements/base.in -pbr==5.11.1 +pbr==6.0.0 # via # -c requirements/edx-platform-constraints.txt # stevedore @@ -348,7 +342,7 @@ pgpy==0.6.0 # via # -c requirements/edx-platform-constraints.txt # -r requirements/base.in -pillow==9.5.0 +pillow==10.1.0 # via # -c requirements/edx-platform-constraints.txt # -r requirements/base.in @@ -356,13 +350,13 @@ platformdirs==3.11.0 # via # -c requirements/edx-platform-constraints.txt # snowflake-connector-python -prompt-toolkit==3.0.39 +prompt-toolkit==3.0.43 # via click-repl -psutil==5.9.5 +psutil==5.9.6 # via # -c requirements/edx-platform-constraints.txt # edx-django-utils -pyasn1==0.5.0 +pyasn1==0.5.1 # via # -c requirements/edx-platform-constraints.txt # pgpy @@ -370,16 +364,13 @@ pycparser==2.21 # via # -c requirements/edx-platform-constraints.txt # cffi -pycryptodomex==3.19.0 - # via - # -c requirements/edx-platform-constraints.txt - # snowflake-connector-python pyjwt[crypto]==2.8.0 # via # -c requirements/edx-platform-constraints.txt # drf-jwt # edx-drf-extensions # edx-rest-api-client + # pyjwt # snowflake-connector-python pymongo==3.13.0 # via @@ -398,11 +389,15 @@ python-dateutil==2.8.2 # -c requirements/edx-platform-constraints.txt # -r requirements/base.in # celery +python-ipware==2.0.0 + # via + # -c requirements/edx-platform-constraints.txt + # django-ipware python-slugify==8.0.1 # via # -c requirements/edx-platform-constraints.txt # code-annotations -pytz==2022.7.1 +pytz==2023.3.post1 # via # -c requirements/edx-platform-constraints.txt # -r requirements/base.in @@ -426,7 +421,7 @@ requests==2.31.0 # openai # slumber # snowflake-connector-python -ruamel-yaml==0.17.35 +ruamel-yaml==0.18.5 # via # -c requirements/edx-platform-constraints.txt # drf-yasg @@ -453,7 +448,7 @@ slumber==0.7.1 # -c requirements/edx-platform-constraints.txt # -r requirements/base.in # edx-rest-api-client -snowflake-connector-python==3.2.1 +snowflake-connector-python==3.6.0 # via # -c requirements/edx-platform-constraints.txt # -r requirements/base.in @@ -472,7 +467,7 @@ stevedore==5.1.0 # code-annotations # edx-django-utils # edx-opaque-keys -testfixtures==7.2.0 +testfixtures==7.2.2 # via # -c requirements/edx-platform-constraints.txt # -r requirements/base.in @@ -480,7 +475,7 @@ text-unidecode==1.3 # via # -c requirements/edx-platform-constraints.txt # python-slugify -tomlkit==0.12.1 +tomlkit==0.12.3 # via # -c requirements/edx-platform-constraints.txt # snowflake-connector-python @@ -488,7 +483,7 @@ tqdm==4.66.1 # via # -c requirements/edx-platform-constraints.txt # openai -typing-extensions==4.8.0 +typing-extensions==4.9.0 # via # -c requirements/edx-platform-constraints.txt # asgiref @@ -510,7 +505,7 @@ uritemplate==4.1.1 # -c requirements/edx-platform-constraints.txt # coreapi # drf-yasg -urllib3==1.26.17 +urllib3==1.26.18 # via # -c requirements/edx-platform-constraints.txt # requests @@ -520,7 +515,7 @@ vine==5.1.0 # amqp # celery # kombu -wcwidth==0.2.8 +wcwidth==0.2.12 # via # -c requirements/edx-platform-constraints.txt # prompt-toolkit @@ -528,11 +523,11 @@ webencodings==0.5.1 # via # -c requirements/edx-platform-constraints.txt # bleach -wrapt==1.15.0 +wrapt==1.16.0 # via # -c requirements/edx-platform-constraints.txt # deprecated -yarl==1.9.2 +yarl==1.9.4 # via # -c requirements/edx-platform-constraints.txt # aiohttp diff --git a/requirements/test.txt b/requirements/test.txt index 6880b62747..9e57a322ee 100644 --- a/requirements/test.txt +++ b/requirements/test.txt @@ -4,7 +4,7 @@ # # make upgrade # -aiohttp==3.8.6 +aiohttp==3.9.1 # via # -r requirements/test-master.txt # openai @@ -27,7 +27,6 @@ asgiref==3.7.2 asn1crypto==1.5.1 # via # -r requirements/test-master.txt - # oscrypto # snowflake-connector-python async-timeout==4.0.3 # via @@ -42,6 +41,7 @@ backports-zoneinfo[tzdata]==0.2.1 # via # -r requirements/test-master.txt # -r requirements/test.in + # backports-zoneinfo # celery # kombu # via @@ -52,7 +52,7 @@ bleach==6.1.0 # via # -c requirements/constraints.txt # -r requirements/test-master.txt -certifi==2023.7.22 +certifi==2023.11.17 # via # -r requirements/test-master.txt # requests @@ -68,7 +68,6 @@ chardet==5.2.0 charset-normalizer==2.0.12 # via # -r requirements/test-master.txt - # aiohttp # requests # snowflake-connector-python # via @@ -102,8 +101,10 @@ coreschema==0.0.4 # -r requirements/test-master.txt # coreapi # drf-yasg -coverage[toml]==7.3.2 - # via pytest-cov +coverage[toml]==7.4.0 + # via + # coverage + # pytest-cov cryptography==38.0.4 # via # -r requirements/test-master.txt @@ -123,7 +124,7 @@ deprecated==1.2.14 # via # -r requirements/test-master.txt # jwcrypto -diff-cover==8.0.0 +diff-cover==8.0.3 # via -r requirements/test.in # via # -c requirements/common_constraints.txt @@ -159,9 +160,9 @@ django-crum==0.7.9 # edx-toggles django-fernet-fields-v2==0.9 # via -r requirements/test-master.txt -django-filter==23.3 +django-filter==23.5 # via -r requirements/test-master.txt -django-ipware==5.0.1 +django-ipware==6.0.2 # via -r requirements/test-master.txt django-model-utils==4.3.1 # via @@ -178,7 +179,7 @@ django-simple-history==3.1.1 # via # -c requirements/constraints.txt # -r requirements/test-master.txt -django-waffle==4.0.0 +django-waffle==4.1.0 # via # -r requirements/test-master.txt # edx-django-utils @@ -206,14 +207,14 @@ edx-api-doc-tools==1.7.0 # via -r requirements/test-master.txt edx-braze-client==0.1.8 # via -r requirements/test-master.txt -edx-django-utils==5.7.0 +edx-django-utils==5.9.0 # via # -r requirements/test-master.txt # django-config-models # edx-drf-extensions # edx-rest-api-client # edx-toggles -edx-drf-extensions==8.12.0 +edx-drf-extensions==9.1.2 # via # -r requirements/test-master.txt # edx-rbac @@ -221,9 +222,10 @@ edx-opaque-keys[django]==2.5.1 # via # -r requirements/test-master.txt # edx-drf-extensions + # edx-opaque-keys edx-rbac==1.8.0 # via -r requirements/test-master.txt -edx-rest-api-client==5.6.0 +edx-rest-api-client==5.6.1 # via -r requirements/test-master.txt edx-tincan-py35==1.0.0 # via -r requirements/test-master.txt @@ -233,9 +235,9 @@ factory-boy==3.3.0 # via # -c requirements/constraints.txt # -r requirements/test.in -faker==19.13.0 +faker==22.5.0 # via factory-boy -filelock==3.12.4 +filelock==3.13.1 # via # -r requirements/test-master.txt # snowflake-connector-python @@ -248,7 +250,7 @@ frozenlist==1.4.0 # -r requirements/test-master.txt # aiohttp # aiosignal -idna==3.4 +idna==3.6 # via # -r requirements/test-master.txt # requests @@ -294,7 +296,7 @@ multidict==6.0.4 # -r requirements/test-master.txt # aiohttp # yarl -newrelic==9.1.0 +newrelic==9.3.0 # via # -r requirements/test-master.txt # edx-django-utils @@ -304,29 +306,25 @@ oauthlib==3.2.2 # django-oauth-toolkit openai==0.28.1 # via -r requirements/test-master.txt -oscrypto==1.3.0 - # via - # -r requirements/test-master.txt - # snowflake-connector-python packaging==23.2 # via # -r requirements/test-master.txt # drf-yasg # pytest # snowflake-connector-python -path==16.7.1 +path==16.9.0 # via # -r requirements/test-master.txt # path-py path-py==12.5.0 # via -r requirements/test-master.txt -pbr==5.11.1 +pbr==6.0.0 # via # -r requirements/test-master.txt # stevedore pgpy==0.6.0 # via -r requirements/test-master.txt -pillow==9.5.0 +pillow==10.1.0 # via -r requirements/test-master.txt platformdirs==3.11.0 # via @@ -339,13 +337,13 @@ pluggy==1.3.0 # via # -r requirements/test-master.txt # click-repl -psutil==5.9.5 +psutil==5.9.6 # via # -r requirements/test-master.txt # edx-django-utils py==1.11.0 # via pytest -pyasn1==0.5.0 +pyasn1==0.5.1 # via # -r requirements/test-master.txt # pgpy @@ -353,11 +351,7 @@ pycparser==2.21 # via # -r requirements/test-master.txt # cffi -pycryptodomex==3.19.0 - # via - # -r requirements/test-master.txt - # snowflake-connector-python -pygments==2.16.1 +pygments==2.17.2 # via diff-cover pyjwt[crypto]==2.8.0 # via @@ -365,6 +359,7 @@ pyjwt[crypto]==2.8.0 # drf-jwt # edx-drf-extensions # edx-rest-api-client + # pyjwt # snowflake-connector-python pymongo==3.13.0 # via @@ -393,11 +388,15 @@ python-dateutil==2.8.2 # celery # faker # freezegun +python-ipware==2.0.0 + # via + # -r requirements/test-master.txt + # django-ipware python-slugify==8.0.1 # via # -r requirements/test-master.txt # code-annotations -pytz==2022.7.1 +pytz==2023.3.post1 # via # -r requirements/test-master.txt # django @@ -424,7 +423,7 @@ responses==0.10.15 # via # -c requirements/constraints.txt # -r requirements/test.in -ruamel-yaml==0.17.35 +ruamel-yaml==0.18.5 # via # -r requirements/test-master.txt # drf-yasg @@ -451,7 +450,7 @@ slumber==0.7.1 # via # -r requirements/test-master.txt # edx-rest-api-client -snowflake-connector-python==3.2.1 +snowflake-connector-python==3.6.0 # via -r requirements/test-master.txt sortedcontainers==2.4.0 # via @@ -467,7 +466,7 @@ stevedore==5.1.0 # code-annotations # edx-django-utils # edx-opaque-keys -testfixtures==7.2.0 +testfixtures==7.2.2 # via # -r requirements/test-master.txt # -r requirements/test.in @@ -479,7 +478,7 @@ toml==0.10.2 # via pytest tomli==2.0.1 # via coverage -tomlkit==0.12.1 +tomlkit==0.12.3 # via # -r requirements/test-master.txt # snowflake-connector-python @@ -487,7 +486,7 @@ tqdm==4.66.1 # via # -r requirements/test-master.txt # openai -typing-extensions==4.8.0 +typing-extensions==4.9.0 # via # -r requirements/test-master.txt # asgiref @@ -508,7 +507,7 @@ uritemplate==4.1.1 # -r requirements/test-master.txt # coreapi # drf-yasg -urllib3==1.26.17 +urllib3==1.26.18 # via # -r requirements/test-master.txt # requests @@ -518,7 +517,7 @@ urllib3==1.26.17 # amqp # celery # kombu -wcwidth==0.2.8 +wcwidth==0.2.12 # via # -r requirements/test-master.txt # prompt-toolkit @@ -526,11 +525,11 @@ webencodings==0.5.1 # via # -r requirements/test-master.txt # bleach -wrapt==1.15.0 +wrapt==1.16.0 # via # -r requirements/test-master.txt # deprecated -yarl==1.9.2 +yarl==1.9.4 # via # -r requirements/test-master.txt # aiohttp From 1a5562e696f8c584a6c8f1cc191f726068818c22 Mon Sep 17 00:00:00 2001 From: edX requirements bot Date: Mon, 29 Jan 2024 19:26:01 -0500 Subject: [PATCH 106/164] chore: Updating Python Requirements --- requirements/ci.txt | 2 +- requirements/dev.txt | 42 ++++---- requirements/doc.txt | 38 +++---- requirements/edx-platform-constraints.txt | 115 +++++++++++----------- requirements/js_test.txt | 9 +- requirements/test-master.txt | 34 +++---- requirements/test.txt | 40 ++++---- 7 files changed, 141 insertions(+), 139 deletions(-) diff --git a/requirements/ci.txt b/requirements/ci.txt index a7bca04d76..05665c77e4 100644 --- a/requirements/ci.txt +++ b/requirements/ci.txt @@ -14,7 +14,7 @@ packaging==23.2 # via tox platformdirs==4.1.0 # via virtualenv -pluggy==1.3.0 +pluggy==1.4.0 # via tox py==1.11.0 # via tox diff --git a/requirements/dev.txt b/requirements/dev.txt index 30852d5719..ceaed3644b 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -59,7 +59,7 @@ async-timeout==4.0.3 # -r requirements/test-master.txt # -r requirements/test.txt # aiohttp -attrs==23.1.0 +attrs==23.2.0 # via # -r requirements/doc.txt # -r requirements/test-master.txt @@ -182,7 +182,7 @@ coreschema==0.0.4 # -r requirements/test.txt # coreapi # drf-yasg -coverage[toml]==7.4.0 +coverage[toml]==7.4.1 # via # -r requirements/test.txt # coverage @@ -214,7 +214,7 @@ deprecated==1.2.14 # jwcrypto diff-cover==8.0.3 # via -r requirements/test.txt -dill==0.3.7 +dill==0.3.8 # via pylint distlib==0.3.8 # via virtualenv @@ -275,7 +275,7 @@ django-filter==23.5 # -r requirements/doc.txt # -r requirements/test-master.txt # -r requirements/test.txt -django-ipware==6.0.2 +django-ipware==6.0.3 # via # -r requirements/doc.txt # -r requirements/test-master.txt @@ -357,12 +357,12 @@ edx-api-doc-tools==1.7.0 # -r requirements/doc.txt # -r requirements/test-master.txt # -r requirements/test.txt -edx-braze-client==0.1.8 +edx-braze-client==0.2.1 # via # -r requirements/doc.txt # -r requirements/test-master.txt # -r requirements/test.txt -edx-django-utils==5.9.0 +edx-django-utils==5.10.1 # via # -r requirements/doc.txt # -r requirements/test-master.txt @@ -371,7 +371,7 @@ edx-django-utils==5.9.0 # edx-drf-extensions # edx-rest-api-client # edx-toggles -edx-drf-extensions==9.1.2 +edx-drf-extensions==10.1.0 # via # -r requirements/doc.txt # -r requirements/test-master.txt @@ -413,7 +413,7 @@ factory-boy==3.3.0 # -c requirements/constraints.txt # -r requirements/doc.txt # -r requirements/test.txt -faker==22.5.0 +faker==22.6.0 # via # -r requirements/doc.txt # -r requirements/test.txt @@ -430,7 +430,7 @@ freezegun==0.3.14 # via # -c requirements/constraints.txt # -r requirements/test.txt -frozenlist==1.4.0 +frozenlist==1.4.1 # via # -r requirements/doc.txt # -r requirements/test-master.txt @@ -475,7 +475,7 @@ itypes==1.2.0 # -r requirements/test-master.txt # -r requirements/test.txt # coreapi -jinja2==3.1.2 +jinja2==3.1.3 # via # -r requirements/doc.txt # -r requirements/test-master.txt @@ -494,7 +494,7 @@ jsonfield==3.1.0 # -r requirements/doc.txt # -r requirements/test-master.txt # -r requirements/test.txt -jwcrypto==1.5.0 +jwcrypto==1.5.1 # via # -r requirements/doc.txt # -r requirements/test-master.txt @@ -508,7 +508,7 @@ kombu==5.3.5 # celery lxml==5.1.0 # via edx-i18n-tools -markupsafe==2.1.3 +markupsafe==2.1.4 # via # -r requirements/doc.txt # -r requirements/test-master.txt @@ -527,7 +527,7 @@ multidict==6.0.4 # -r requirements/test.txt # aiohttp # yarl -newrelic==9.3.0 +newrelic==9.6.0 # via # -r requirements/doc.txt # -r requirements/test-master.txt @@ -583,7 +583,7 @@ pgpy==0.6.0 # -r requirements/doc.txt # -r requirements/test-master.txt # -r requirements/test.txt -pillow==10.1.0 +pillow==10.2.0 # via # -r requirements/doc.txt # -r requirements/test-master.txt @@ -600,7 +600,7 @@ platformdirs==3.11.0 # pylint # snowflake-connector-python # virtualenv -pluggy==1.3.0 +pluggy==1.4.0 # via # -r requirements/doc.txt # -r requirements/test.txt @@ -615,7 +615,7 @@ prompt-toolkit==3.0.43 # -r requirements/test-master.txt # -r requirements/test.txt # click-repl -psutil==5.9.6 +psutil==5.9.8 # via # -r requirements/doc.txt # -r requirements/test-master.txt @@ -720,13 +720,13 @@ python-dateutil==2.8.2 # celery # faker # freezegun -python-ipware==2.0.0 +python-ipware==2.0.1 # via # -r requirements/doc.txt # -r requirements/test-master.txt # -r requirements/test.txt # django-ipware -python-slugify==8.0.1 +python-slugify==8.0.2 # via # -r requirements/doc.txt # -r requirements/test-master.txt @@ -825,7 +825,7 @@ snowballstemmer==2.2.0 # -r requirements/doc.txt # pydocstyle # sphinx -snowflake-connector-python==3.6.0 +snowflake-connector-python==3.7.0 # via # -r requirements/doc.txt # -r requirements/test-master.txt @@ -948,7 +948,7 @@ typing-extensions==4.9.0 # pydata-sphinx-theme # pylint # snowflake-connector-python -tzdata==2023.3 +tzdata==2023.4 # via # -r requirements/doc.txt # -r requirements/test-master.txt @@ -984,7 +984,7 @@ vine==5.1.0 # kombu virtualenv==20.25.0 # via tox -wcwidth==0.2.12 +wcwidth==0.2.13 # via # -r requirements/doc.txt # -r requirements/test-master.txt diff --git a/requirements/doc.txt b/requirements/doc.txt index fce593f30e..491ea9d85f 100644 --- a/requirements/doc.txt +++ b/requirements/doc.txt @@ -37,7 +37,7 @@ async-timeout==4.0.3 # via # -r requirements/test-master.txt # aiohttp -attrs==23.1.0 +attrs==23.2.0 # via # -r requirements/test-master.txt # aiohttp @@ -168,7 +168,7 @@ django-fernet-fields-v2==0.9 # via -r requirements/test-master.txt django-filter==23.5 # via -r requirements/test-master.txt -django-ipware==6.0.2 +django-ipware==6.0.3 # via -r requirements/test-master.txt django-model-utils==4.3.1 # via @@ -220,16 +220,16 @@ drf-yasg==1.21.5 # edx-api-doc-tools edx-api-doc-tools==1.7.0 # via -r requirements/test-master.txt -edx-braze-client==0.1.8 +edx-braze-client==0.2.1 # via -r requirements/test-master.txt -edx-django-utils==5.9.0 +edx-django-utils==5.10.1 # via # -r requirements/test-master.txt # django-config-models # edx-drf-extensions # edx-rest-api-client # edx-toggles -edx-drf-extensions==9.1.2 +edx-drf-extensions==10.1.0 # via # -r requirements/test-master.txt # edx-rbac @@ -250,13 +250,13 @@ factory-boy==3.3.0 # via # -c requirements/constraints.txt # -r requirements/doc.in -faker==22.5.0 +faker==22.6.0 # via factory-boy filelock==3.13.1 # via # -r requirements/test-master.txt # snowflake-connector-python -frozenlist==1.4.0 +frozenlist==1.4.1 # via # -r requirements/test-master.txt # aiohttp @@ -281,7 +281,7 @@ itypes==1.2.0 # via # -r requirements/test-master.txt # coreapi -jinja2==3.1.2 +jinja2==3.1.3 # via # -r requirements/test-master.txt # code-annotations @@ -291,7 +291,7 @@ jsondiff==2.0.0 # via -r requirements/test-master.txt jsonfield==3.1.0 # via -r requirements/test-master.txt -jwcrypto==1.5.0 +jwcrypto==1.5.1 # via # -r requirements/test-master.txt # django-oauth-toolkit @@ -299,7 +299,7 @@ kombu==5.3.5 # via # -r requirements/test-master.txt # celery -markupsafe==2.1.3 +markupsafe==2.1.4 # via # -r requirements/test-master.txt # jinja2 @@ -308,7 +308,7 @@ multidict==6.0.4 # -r requirements/test-master.txt # aiohttp # yarl -newrelic==9.3.0 +newrelic==9.6.0 # via # -r requirements/test-master.txt # edx-django-utils @@ -340,19 +340,19 @@ pbr==6.0.0 # stevedore pgpy==0.6.0 # via -r requirements/test-master.txt -pillow==10.1.0 +pillow==10.2.0 # via -r requirements/test-master.txt platformdirs==3.11.0 # via # -r requirements/test-master.txt # snowflake-connector-python -pluggy==1.3.0 +pluggy==1.4.0 # via pytest prompt-toolkit==3.0.43 # via # -r requirements/test-master.txt # click-repl -psutil==5.9.6 +psutil==5.9.8 # via # -r requirements/test-master.txt # edx-django-utils @@ -404,11 +404,11 @@ python-dateutil==2.8.2 # -r requirements/test-master.txt # celery # faker -python-ipware==2.0.0 +python-ipware==2.0.1 # via # -r requirements/test-master.txt # django-ipware -python-slugify==8.0.1 +python-slugify==8.0.2 # via # -r requirements/test-master.txt # code-annotations @@ -466,7 +466,7 @@ slumber==0.7.1 # edx-rest-api-client snowballstemmer==2.2.0 # via sphinx -snowflake-connector-python==3.6.0 +snowflake-connector-python==3.7.0 # via -r requirements/test-master.txt sortedcontainers==2.4.0 # via @@ -532,7 +532,7 @@ typing-extensions==4.9.0 # kombu # pydata-sphinx-theme # snowflake-connector-python -tzdata==2023.3 +tzdata==2023.4 # via # -r requirements/test-master.txt # backports-zoneinfo @@ -555,7 +555,7 @@ vine==5.1.0 # amqp # celery # kombu -wcwidth==0.2.12 +wcwidth==0.2.13 # via # -r requirements/test-master.txt # prompt-toolkit diff --git a/requirements/edx-platform-constraints.txt b/requirements/edx-platform-constraints.txt index 9663e6f7c5..e3c6847679 100644 --- a/requirements/edx-platform-constraints.txt +++ b/requirements/edx-platform-constraints.txt @@ -34,7 +34,7 @@ async-timeout==4.0.3 # via # aiohttp # redis -attrs==23.1.0 +attrs==23.2.0 # via # -r requirements/edx/kernel.in # aiohttp @@ -57,7 +57,7 @@ backports-zoneinfo[tzdata]==0.2.1 # celery # icalendar # kombu -beautifulsoup4==4.12.2 +beautifulsoup4==4.12.3 # via pynliner # via celery bleach[css]==6.1.0 @@ -71,13 +71,13 @@ bleach[css]==6.1.0 # xblock-poll boto==2.49.0 # via -r requirements/edx/kernel.in -boto3==1.33.12 +boto3==1.34.28 # via # -r requirements/edx/kernel.in # django-ses # fs-s3fs # ora2 -botocore==1.33.12 +botocore==1.34.28 # via # -r requirements/edx/kernel.in # boto3 @@ -237,6 +237,7 @@ django==3.2.23 # openedx-learning # ora2 # super-csv + # xblock-google-drive # xss-utils django-appconf==1.0.6 # via django-statici18n @@ -275,12 +276,12 @@ django-filter==23.5 # edx-enterprise # lti-consumer-xblock # openedx-blockstore -django-ipware==6.0.2 +django-ipware==6.0.3 # via # -r requirements/edx/kernel.in # edx-enterprise # edx-proctoring -django-js-asset==2.1.0 +django-js-asset==2.2.0 # via django-mptt django-method-override==1.0.4 # via -r requirements/edx/kernel.in @@ -315,7 +316,7 @@ django-oauth-toolkit==1.7.1 # -r requirements/edx/kernel.in # edx-enterprise django-object-actions==4.2.0 -django-pipeline==2.1.0 +django-pipeline==3.0.0 # via -r requirements/edx/kernel.in django-ratelimit==4.1.0 # via -r requirements/edx/kernel.in @@ -386,9 +387,9 @@ done-xblock==2.2.0 # via -r requirements/edx/bundled.in drf-jwt==1.19.2 # via edx-drf-extensions -drf-nested-routers==0.93.4 +drf-nested-routers==0.93.5 # via openedx-blockstore -drf-spectacular==0.27.0 +drf-spectacular==0.27.1 # via -r requirements/edx/kernel.in drf-yasg==1.21.5 # via @@ -406,7 +407,7 @@ edx-auth-backends==4.2.0 # via # -r requirements/edx/kernel.in # openedx-blockstore -edx-braze-client==0.1.8 +edx-braze-client==0.2.1 # via # -r requirements/edx/bundled.in # edx-enterprise @@ -434,7 +435,7 @@ edx-django-release-util==1.3.0 # openedx-blockstore edx-django-sites-extensions==4.0.2 # via -r requirements/edx/kernel.in -edx-django-utils==5.9.0 +edx-django-utils==5.10.1 # via # -r requirements/edx/kernel.in # django-config-models @@ -450,7 +451,7 @@ edx-django-utils==5.9.0 # openedx-blockstore # ora2 # super-csv -edx-drf-extensions==9.1.2 +edx-drf-extensions==10.1.0 # via # -r requirements/edx/kernel.in # edx-completion @@ -462,11 +463,11 @@ edx-drf-extensions==9.1.2 # edx-when # edxval # openedx-learning -edx-enterprise==4.10.6 +edx-enterprise==4.10.11 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/kernel.in -edx-event-bus-kafka==5.5.0 +edx-event-bus-kafka==5.6.0 # via -r requirements/edx/kernel.in edx-event-bus-redis==0.3.2 # via -r requirements/edx/kernel.in @@ -506,7 +507,7 @@ edx-rest-api-client==5.6.1 # edx-proctoring edx-search==3.8.2 # via -r requirements/edx/kernel.in -edx-sga==0.23.0 +edx-sga==0.23.1 # via -r requirements/edx/bundled.in edx-submissions==3.6.0 # via @@ -546,11 +547,11 @@ event-tracking==2.2.0 # edx-completion # edx-proctoring # edx-search -fastavro==1.9.1 +fastavro==1.9.3 # via openedx-events filelock==3.13.1 # via snowflake-connector-python -frozenlist==1.4.0 +frozenlist==1.4.1 # via # aiohttp # aiosignal @@ -587,7 +588,7 @@ idna==3.6 # requests # snowflake-connector-python # yarl -importlib-metadata==7.0.0 +importlib-metadata==7.0.1 # via markdown importlib-resources==5.13.0 # via @@ -606,7 +607,7 @@ isodate==0.6.1 # via python3-saml itypes==1.2.0 # via coreapi -jinja2==3.1.2 +jinja2==3.1.3 # via # code-annotations # coreschema @@ -626,13 +627,13 @@ jsonfield==3.1.0 # edx-submissions # lti-consumer-xblock # ora2 -jsonschema==4.20.0 +jsonschema==4.21.1 # via # drf-spectacular # optimizely-sdk -jsonschema-specifications==2023.11.2 +jsonschema-specifications==2023.12.1 # via jsonschema -jwcrypto==1.5.0 +jwcrypto==1.5.1 # via # django-oauth-toolkit # pylti1p3 @@ -652,10 +653,11 @@ libsass==0.10.0 # -r requirements/edx/paver.txt loremipsum==1.0.5 # via ora2 -lti-consumer-xblock==9.8.1 +lti-consumer-xblock==9.8.3 # via -r requirements/edx/kernel.in -lxml==4.9.3 +lxml==4.9.4 # via + # -c requirements/edx/../constraints.txt # -r requirements/edx/kernel.in # edx-i18n-tools # edxval @@ -674,7 +676,6 @@ mako==1.3.0 # acid-xblock # lti-consumer-xblock # xblock - # xblock-google-drive # xblock-utils markdown==3.3.7 # via @@ -685,7 +686,7 @@ markdown==3.3.7 # xblock-poll markey==0.8 # via enmerkar-underscore -markupsafe==2.1.3 +markupsafe==2.1.4 # via # -r requirements/edx/paver.txt # chem @@ -693,7 +694,7 @@ markupsafe==2.1.3 # mako # openedx-calc # xblock -maxminddb==2.5.1 +maxminddb==2.5.2 # via geoip2 mock==5.1.0 # via -r requirements/edx/paver.txt @@ -709,11 +710,11 @@ multidict==6.0.4 # via # aiohttp # yarl -mysqlclient==2.2.0 +mysqlclient==2.2.1 # via # -r requirements/edx/kernel.in # openedx-blockstore -newrelic==9.3.0 +newrelic==9.6.0 # via # -r requirements/edx/bundled.in # edx-django-utils @@ -740,13 +741,13 @@ openai==0.28.1 # via # -c requirements/edx/../constraints.txt # edx-enterprise -openedx-atlas==0.5.0 +openedx-atlas==0.6.0 # via -r requirements/edx/kernel.in openedx-blockstore==1.4.0 # via -r requirements/edx/kernel.in openedx-calc==3.0.1 # via -r requirements/edx/kernel.in -openedx-django-pyfs==3.4.0 +openedx-django-pyfs==3.4.1 # via # lti-consumer-xblock # xblock @@ -754,7 +755,7 @@ openedx-django-require==2.1.0 # via -r requirements/edx/kernel.in openedx-django-wiki==2.0.3 # via -r requirements/edx/kernel.in -openedx-events==9.2.0 +openedx-events==9.3.0 # via # -r requirements/edx/kernel.in # edx-event-bus-kafka @@ -763,15 +764,17 @@ openedx-filters==1.6.0 # via # -r requirements/edx/kernel.in # lti-consumer-xblock -openedx-learning==0.4.2 +openedx-learning==0.4.4 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/kernel.in openedx-mongodbproxy==0.2.0 # via -r requirements/edx/kernel.in optimizely-sdk==4.1.1 - # via -r requirements/edx/bundled.in -ora2==6.0.28 + # via + # -c requirements/edx/../constraints.txt + # -r requirements/edx/bundled.in +ora2==6.0.30 # via -r requirements/edx/bundled.in packaging==23.2 # via @@ -801,7 +804,7 @@ pbr==6.0.0 pgpy==0.6.0 piexif==1.1.3 # via -r requirements/edx/kernel.in -pillow==10.1.0 +pillow==10.2.0 # via # -r requirements/edx/kernel.in # edx-enterprise @@ -814,7 +817,7 @@ platformdirs==3.11.0 polib==1.2.0 # via edx-i18n-tools # via click-repl -psutil==5.9.6 +psutil==5.9.8 # via # -r requirements/edx/paver.txt # edx-django-utils @@ -828,7 +831,7 @@ pycountry==23.12.11 # via -r requirements/edx/kernel.in pycparser==2.21 # via cffi -pycryptodomex==3.19.0 +pycryptodomex==3.20.0 # via # -r requirements/edx/kernel.in # edx-proctoring @@ -901,11 +904,11 @@ python-dateutil==2.8.2 # olxcleaner # ora2 # xblock -python-ipware==2.0.0 +python-ipware==2.0.1 # via django-ipware -python-memcached==1.59 +python-memcached==1.62 # via -r requirements/edx/paver.txt -python-slugify==8.0.1 +python-slugify==8.0.2 # via code-annotations python-swiftclient==4.4.0 # via ora2 @@ -947,19 +950,19 @@ pyyaml==6.0.1 # edx-django-release-util # edx-i18n-tools # xblock -random2==1.0.1 +random2==1.0.2 # via -r requirements/edx/kernel.in -recommender-xblock==2.0.1 +recommender-xblock==2.1.1 # via -r requirements/edx/bundled.in redis==5.0.1 # via # -r requirements/edx/kernel.in # walrus -referencing==0.32.0 +referencing==0.32.1 # via # jsonschema # jsonschema-specifications -regex==2023.10.3 +regex==2023.12.25 # via nltk requests==2.31.0 # via @@ -984,11 +987,12 @@ requests==2.31.0 # slumber # snowflake-connector-python # social-auth-core + # xblock-google-drive requests-oauthlib==1.3.1 # via # -r requirements/edx/kernel.in # social-auth-core -rpds-py==0.13.2 +rpds-py==0.17.1 # via # jsonschema # referencing @@ -1002,7 +1006,7 @@ rules==3.3 # edx-enterprise # edx-proctoring # openedx-learning -s3transfer==0.8.2 +s3transfer==0.10.0 # via boto3 sailthru-client==2.2.3 # via edx-ace @@ -1051,14 +1055,13 @@ six==1.16.0 # py2neo # pyjwkest # python-dateutil - # python-memcached slumber==0.7.1 # via # -r requirements/edx/kernel.in # edx-bulk-grades # edx-enterprise # edx-rest-api-client -snowflake-connector-python==3.6.0 +snowflake-connector-python==3.7.0 social-auth-app-django==5.0.0 # via # -c requirements/edx/../constraints.txt @@ -1121,7 +1124,7 @@ typing-extensions==4.9.0 # kombu # pylti1p3 # snowflake-connector-python -tzdata==2023.3 +tzdata==2023.4 # via # backports-zoneinfo # celery @@ -1155,7 +1158,7 @@ walrus==0.9.3 # via edx-event-bus-redis watchdog==3.0.0 # via -r requirements/edx/paver.txt -wcwidth==0.2.12 +wcwidth==0.2.13 # via prompt-toolkit web-fragments==2.1.0 # via @@ -1178,7 +1181,7 @@ wrapt==1.16.0 # via # -r requirements/edx/paver.txt # deprecated -xblock[django]==1.9.0 +xblock[django]==1.10.0 # via # -r requirements/edx/kernel.in # acid-xblock @@ -1194,16 +1197,14 @@ xblock[django]==1.9.0 # xblock-google-drive # xblock-poll # xblock-utils -xblock-drag-and-drop-v2==3.3.0 +xblock-drag-and-drop-v2==3.4.0 # via -r requirements/edx/bundled.in -xblock-google-drive==0.5.0 +xblock-google-drive==0.6.1 # via -r requirements/edx/bundled.in xblock-poll==1.13.0 # via -r requirements/edx/bundled.in xblock-utils==4.0.0 - # via - # edx-sga - # xblock-google-drive + # via edx-sga xmlsec==1.3.13 # via python3-saml xss-utils==0.5.0 diff --git a/requirements/js_test.txt b/requirements/js_test.txt index 025c512c6b..343cfaf576 100644 --- a/requirements/js_test.txt +++ b/requirements/js_test.txt @@ -69,17 +69,17 @@ outcome==1.3.0.post0 # via trio portend==3.2.0 # via cherrypy -pydantic==2.5.3 +pydantic==2.6.0 # via inflect -pydantic-core==2.14.6 +pydantic-core==2.16.1 # via pydantic pysocks==1.7.1 # via urllib3 -pytz==2023.3.post1 +pytz==2023.4 # via tempora pyyaml==6.0.1 # via jasmine -selenium==4.16.0 +selenium==4.17.2 # via jasmine sniffio==1.3.0 # via trio @@ -101,6 +101,7 @@ typing-extensions==4.9.0 # inflect # pydantic # pydantic-core + # selenium urllib3[socks]==2.1.0 # via # selenium diff --git a/requirements/test-master.txt b/requirements/test-master.txt index 8c3d25ecaf..f74ef54a78 100644 --- a/requirements/test-master.txt +++ b/requirements/test-master.txt @@ -31,7 +31,7 @@ async-timeout==4.0.3 # via # -c requirements/edx-platform-constraints.txt # aiohttp -attrs==23.1.0 +attrs==23.2.0 # via # -c requirements/edx-platform-constraints.txt # aiohttp @@ -163,7 +163,7 @@ django-filter==23.5 # via # -c requirements/edx-platform-constraints.txt # -r requirements/base.in -django-ipware==6.0.2 +django-ipware==6.0.3 # via # -c requirements/edx-platform-constraints.txt # -r requirements/base.in @@ -220,11 +220,11 @@ edx-api-doc-tools==1.7.0 # via # -c requirements/edx-platform-constraints.txt # -r requirements/test-master.in -edx-braze-client==0.1.8 +edx-braze-client==0.2.1 # via # -c requirements/edx-platform-constraints.txt # -r requirements/base.in -edx-django-utils==5.9.0 +edx-django-utils==5.10.1 # via # -c requirements/edx-platform-constraints.txt # -r requirements/base.in @@ -232,7 +232,7 @@ edx-django-utils==5.9.0 # edx-drf-extensions # edx-rest-api-client # edx-toggles -edx-drf-extensions==9.1.2 +edx-drf-extensions==10.1.0 # via # -c requirements/edx-platform-constraints.txt # -r requirements/base.in @@ -262,7 +262,7 @@ filelock==3.13.1 # via # -c requirements/edx-platform-constraints.txt # snowflake-connector-python -frozenlist==1.4.0 +frozenlist==1.4.1 # via # -c requirements/edx-platform-constraints.txt # aiohttp @@ -281,7 +281,7 @@ itypes==1.2.0 # via # -c requirements/edx-platform-constraints.txt # coreapi -jinja2==3.1.2 +jinja2==3.1.3 # via # -c requirements/edx-platform-constraints.txt # code-annotations @@ -294,13 +294,13 @@ jsonfield==3.1.0 # via # -c requirements/edx-platform-constraints.txt # -r requirements/base.in -jwcrypto==1.5.0 +jwcrypto==1.5.1 # via # -c requirements/edx-platform-constraints.txt # django-oauth-toolkit kombu==5.3.5 # via celery -markupsafe==2.1.3 +markupsafe==2.1.4 # via # -c requirements/edx-platform-constraints.txt # jinja2 @@ -309,7 +309,7 @@ multidict==6.0.4 # -c requirements/edx-platform-constraints.txt # aiohttp # yarl -newrelic==9.3.0 +newrelic==9.6.0 # via # -c requirements/edx-platform-constraints.txt # edx-django-utils @@ -342,7 +342,7 @@ pgpy==0.6.0 # via # -c requirements/edx-platform-constraints.txt # -r requirements/base.in -pillow==10.1.0 +pillow==10.2.0 # via # -c requirements/edx-platform-constraints.txt # -r requirements/base.in @@ -352,7 +352,7 @@ platformdirs==3.11.0 # snowflake-connector-python prompt-toolkit==3.0.43 # via click-repl -psutil==5.9.6 +psutil==5.9.8 # via # -c requirements/edx-platform-constraints.txt # edx-django-utils @@ -389,11 +389,11 @@ python-dateutil==2.8.2 # -c requirements/edx-platform-constraints.txt # -r requirements/base.in # celery -python-ipware==2.0.0 +python-ipware==2.0.1 # via # -c requirements/edx-platform-constraints.txt # django-ipware -python-slugify==8.0.1 +python-slugify==8.0.2 # via # -c requirements/edx-platform-constraints.txt # code-annotations @@ -448,7 +448,7 @@ slumber==0.7.1 # -c requirements/edx-platform-constraints.txt # -r requirements/base.in # edx-rest-api-client -snowflake-connector-python==3.6.0 +snowflake-connector-python==3.7.0 # via # -c requirements/edx-platform-constraints.txt # -r requirements/base.in @@ -491,7 +491,7 @@ typing-extensions==4.9.0 # edx-opaque-keys # kombu # snowflake-connector-python -tzdata==2023.3 +tzdata==2023.4 # via # -c requirements/edx-platform-constraints.txt # backports-zoneinfo @@ -515,7 +515,7 @@ vine==5.1.0 # amqp # celery # kombu -wcwidth==0.2.12 +wcwidth==0.2.13 # via # -c requirements/edx-platform-constraints.txt # prompt-toolkit diff --git a/requirements/test.txt b/requirements/test.txt index 9e57a322ee..a8aa6b188c 100644 --- a/requirements/test.txt +++ b/requirements/test.txt @@ -32,7 +32,7 @@ async-timeout==4.0.3 # via # -r requirements/test-master.txt # aiohttp -attrs==23.1.0 +attrs==23.2.0 # via # -r requirements/test-master.txt # aiohttp @@ -101,7 +101,7 @@ coreschema==0.0.4 # -r requirements/test-master.txt # coreapi # drf-yasg -coverage[toml]==7.4.0 +coverage[toml]==7.4.1 # via # coverage # pytest-cov @@ -162,7 +162,7 @@ django-fernet-fields-v2==0.9 # via -r requirements/test-master.txt django-filter==23.5 # via -r requirements/test-master.txt -django-ipware==6.0.2 +django-ipware==6.0.3 # via -r requirements/test-master.txt django-model-utils==4.3.1 # via @@ -205,16 +205,16 @@ drf-yasg==1.21.5 # edx-api-doc-tools edx-api-doc-tools==1.7.0 # via -r requirements/test-master.txt -edx-braze-client==0.1.8 +edx-braze-client==0.2.1 # via -r requirements/test-master.txt -edx-django-utils==5.9.0 +edx-django-utils==5.10.1 # via # -r requirements/test-master.txt # django-config-models # edx-drf-extensions # edx-rest-api-client # edx-toggles -edx-drf-extensions==9.1.2 +edx-drf-extensions==10.1.0 # via # -r requirements/test-master.txt # edx-rbac @@ -235,7 +235,7 @@ factory-boy==3.3.0 # via # -c requirements/constraints.txt # -r requirements/test.in -faker==22.5.0 +faker==22.6.0 # via factory-boy filelock==3.13.1 # via @@ -245,7 +245,7 @@ freezegun==0.3.14 # via # -c requirements/constraints.txt # -r requirements/test.in -frozenlist==1.4.0 +frozenlist==1.4.1 # via # -r requirements/test-master.txt # aiohttp @@ -266,7 +266,7 @@ itypes==1.2.0 # via # -r requirements/test-master.txt # coreapi -jinja2==3.1.2 +jinja2==3.1.3 # via # -r requirements/test-master.txt # code-annotations @@ -276,14 +276,14 @@ jsondiff==2.0.0 # via -r requirements/test-master.txt jsonfield==3.1.0 # via -r requirements/test-master.txt -jwcrypto==1.5.0 +jwcrypto==1.5.1 # via # -r requirements/test-master.txt # django-oauth-toolkit # via # -r requirements/test-master.txt # celery -markupsafe==2.1.3 +markupsafe==2.1.4 # via # -r requirements/test-master.txt # jinja2 @@ -296,7 +296,7 @@ multidict==6.0.4 # -r requirements/test-master.txt # aiohttp # yarl -newrelic==9.3.0 +newrelic==9.6.0 # via # -r requirements/test-master.txt # edx-django-utils @@ -324,20 +324,20 @@ pbr==6.0.0 # stevedore pgpy==0.6.0 # via -r requirements/test-master.txt -pillow==10.1.0 +pillow==10.2.0 # via -r requirements/test-master.txt platformdirs==3.11.0 # via # -r requirements/test-master.txt # snowflake-connector-python -pluggy==1.3.0 +pluggy==1.4.0 # via # diff-cover # pytest # via # -r requirements/test-master.txt # click-repl -psutil==5.9.6 +psutil==5.9.8 # via # -r requirements/test-master.txt # edx-django-utils @@ -388,11 +388,11 @@ python-dateutil==2.8.2 # celery # faker # freezegun -python-ipware==2.0.0 +python-ipware==2.0.1 # via # -r requirements/test-master.txt # django-ipware -python-slugify==8.0.1 +python-slugify==8.0.2 # via # -r requirements/test-master.txt # code-annotations @@ -450,7 +450,7 @@ slumber==0.7.1 # via # -r requirements/test-master.txt # edx-rest-api-client -snowflake-connector-python==3.6.0 +snowflake-connector-python==3.7.0 # via -r requirements/test-master.txt sortedcontainers==2.4.0 # via @@ -495,7 +495,7 @@ typing-extensions==4.9.0 # faker # kombu # snowflake-connector-python -tzdata==2023.3 +tzdata==2023.4 # via # -r requirements/test-master.txt # backports-zoneinfo @@ -517,7 +517,7 @@ urllib3==1.26.18 # amqp # celery # kombu -wcwidth==0.2.12 +wcwidth==0.2.13 # via # -r requirements/test-master.txt # prompt-toolkit From a62035553592dd0004ca9af79e44ba63e8e83bd8 Mon Sep 17 00:00:00 2001 From: Saleem Latif Date: Tue, 30 Jan 2024 11:34:48 +0500 Subject: [PATCH 107/164] feat: Added the ability for enterprise customers to enable/disable academies. --- CHANGELOG.rst | 4 ++++ enterprise/__init__.py | 2 +- enterprise/admin/__init__.py | 2 +- enterprise/api/v1/serializers.py | 2 +- .../migrations/0199_auto_20240130_0628.py | 23 +++++++++++++++++++ enterprise/models.py | 8 +++++++ tests/test_enterprise/api/test_views.py | 5 ++++ tests/test_utilities.py | 1 + 8 files changed, 44 insertions(+), 3 deletions(-) create mode 100644 enterprise/migrations/0199_auto_20240130_0628.py diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 86ef51c3cd..0305d32abb 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -16,6 +16,10 @@ Change Log Unreleased ---------- +[4.11.0] +--------- +* Added the ability for enterprise customers to enable/disable academies. + [4.10.11] --------- * fix: add missing comma to catalog query fields list. diff --git a/enterprise/__init__.py b/enterprise/__init__.py index b2fa7a38e1..f2d38d70bf 100644 --- a/enterprise/__init__.py +++ b/enterprise/__init__.py @@ -2,4 +2,4 @@ Your project description goes here. """ -__version__ = "4.10.11" +__version__ = "4.11.0" diff --git a/enterprise/admin/__init__.py b/enterprise/admin/__init__.py index df241caaf4..2d88a33dd1 100644 --- a/enterprise/admin/__init__.py +++ b/enterprise/admin/__init__.py @@ -214,7 +214,7 @@ class EnterpriseCustomerAdmin(DjangoObjectActions, SimpleHistoryAdmin): 'enable_executive_education_2U_fulfillment', 'enable_career_engagement_network_on_learner_portal', 'career_engagement_network_message', 'enable_pathways', 'enable_programs', - 'enable_demo_data_for_analytics_and_lpr'), + 'enable_demo_data_for_analytics_and_lpr', 'enable_academies'), 'description': ('The following default settings should be the same for ' 'the majority of enterprise customers, ' 'and are either rarely used, unlikely to be sold, ' diff --git a/enterprise/api/v1/serializers.py b/enterprise/api/v1/serializers.py index d991cc445b..0c171d32a3 100644 --- a/enterprise/api/v1/serializers.py +++ b/enterprise/api/v1/serializers.py @@ -222,7 +222,7 @@ class Meta: 'enterprise_customer_catalogs', 'reply_to', 'enterprise_notification_banner', 'hide_labor_market_data', 'modified', 'enable_universal_link', 'enable_browse_and_request', 'admin_users', 'enable_career_engagement_network_on_learner_portal', 'career_engagement_network_message', - 'enable_pathways', 'enable_programs', 'enable_demo_data_for_analytics_and_lpr', + 'enable_pathways', 'enable_programs', 'enable_demo_data_for_analytics_and_lpr', 'enable_academies', ) identity_providers = EnterpriseCustomerIdentityProviderSerializer(many=True, read_only=True) diff --git a/enterprise/migrations/0199_auto_20240130_0628.py b/enterprise/migrations/0199_auto_20240130_0628.py new file mode 100644 index 0000000000..7247fbdda3 --- /dev/null +++ b/enterprise/migrations/0199_auto_20240130_0628.py @@ -0,0 +1,23 @@ +# Generated by Django 3.2.23 on 2024-01-30 06:28 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('enterprise', '0198_alter_enterprisecourseenrollment_options'), + ] + + operations = [ + migrations.AddField( + model_name='enterprisecustomer', + name='enable_academies', + field=models.BooleanField(default=False, help_text='If checked, the learners will be able to see the academies on the learner portal dashboard.', verbose_name='Display academies screen'), + ), + migrations.AddField( + model_name='historicalenterprisecustomer', + name='enable_academies', + field=models.BooleanField(default=False, help_text='If checked, the learners will be able to see the academies on the learner portal dashboard.', verbose_name='Display academies screen'), + ), + ] diff --git a/enterprise/models.py b/enterprise/models.py index 6c2bfec1ca..543283976d 100644 --- a/enterprise/models.py +++ b/enterprise/models.py @@ -396,6 +396,14 @@ class Meta: ) ) + enable_academies = models.BooleanField( + verbose_name="Display academies screen", + default=False, + help_text=_( + "If checked, the learners will be able to see the academies on the learner portal dashboard." + ) + ) + enable_analytics_screen = models.BooleanField( verbose_name="Display analytics page", default=True, diff --git a/tests/test_enterprise/api/test_views.py b/tests/test_enterprise/api/test_views.py index 6383f3dce1..97ff1a3512 100644 --- a/tests/test_enterprise/api/test_views.py +++ b/tests/test_enterprise/api/test_views.py @@ -1197,6 +1197,7 @@ class TestEnterpriseCustomerViewSet(BaseTestEnterpriseAPIViews): 'enable_pathways': True, 'enable_programs': True, 'enable_demo_data_for_analytics_and_lpr': False, + 'enable_academies': False, }], ), ( @@ -1255,6 +1256,7 @@ class TestEnterpriseCustomerViewSet(BaseTestEnterpriseAPIViews): 'enable_pathways': True, 'enable_programs': True, 'enable_demo_data_for_analytics_and_lpr': False, + 'enable_academies': False, }, 'active': True, 'user_id': 0, 'user': None, 'data_sharing_consent_records': [], 'groups': [], @@ -1350,6 +1352,7 @@ class TestEnterpriseCustomerViewSet(BaseTestEnterpriseAPIViews): 'enable_pathways': True, 'enable_programs': True, 'enable_demo_data_for_analytics_and_lpr': False, + 'enable_academies': False, }], ), ( @@ -1416,6 +1419,7 @@ class TestEnterpriseCustomerViewSet(BaseTestEnterpriseAPIViews): 'enable_pathways': True, 'enable_programs': True, 'enable_demo_data_for_analytics_and_lpr': False, + 'enable_academies': False, }], ), ( @@ -1653,6 +1657,7 @@ def test_enterprise_customer_with_access_to( 'enable_pathways': True, 'enable_programs': True, 'enable_demo_data_for_analytics_and_lpr': False, + 'enable_academies': False, } else: mock_empty_200_success_response = { diff --git a/tests/test_utilities.py b/tests/test_utilities.py index 92edcc185d..906e4e7f3c 100644 --- a/tests/test_utilities.py +++ b/tests/test_utilities.py @@ -177,6 +177,7 @@ def setUp(self): "enable_pathways", "enable_programs", "enable_demo_data_for_analytics_and_lpr", + "enable_academies", ] ), ( From c9bff319439481ccde6c46d38cd892ab4d769706 Mon Sep 17 00:00:00 2001 From: hamzawaleed01 Date: Tue, 30 Jan 2024 13:04:50 +0500 Subject: [PATCH 108/164] refactor: move util function to classmethod --- integrated_channels/cornerstone/client.py | 7 ++- integrated_channels/cornerstone/views.py | 7 ++- .../integrated_channel/admin/__init__.py | 10 ---- ...ntegratedchannelapirequestlogs_endpoint.py | 18 +++++++ .../integrated_channel/models.py | 53 ++++++++++++++----- integrated_channels/utils.py | 42 --------------- 6 files changed, 69 insertions(+), 68 deletions(-) create mode 100644 integrated_channels/integrated_channel/migrations/0033_alter_integratedchannelapirequestlogs_endpoint.py diff --git a/integrated_channels/cornerstone/client.py b/integrated_channels/cornerstone/client.py index 40e33bd863..72362bd4ee 100644 --- a/integrated_channels/cornerstone/client.py +++ b/integrated_channels/cornerstone/client.py @@ -13,7 +13,7 @@ from integrated_channels.cornerstone.utils import get_or_create_key_pair from integrated_channels.integrated_channel.client import IntegratedChannelApiClient -from integrated_channels.utils import generate_formatted_log, store_api_call +from integrated_channels.utils import generate_formatted_log LOGGER = logging.getLogger(__name__) @@ -36,6 +36,9 @@ def __init__(self, enterprise_configuration): """ super().__init__(enterprise_configuration) self.global_cornerstone_config = apps.get_model('cornerstone', 'CornerstoneGlobalConfiguration').current() + self.IntegratedChannelAPIRequestLogs = apps.get_model( + "integrated_channel", "IntegratedChannelAPIRequestLogs" + ) self.session = None self.expires_at = None @@ -115,7 +118,7 @@ def create_course_completion(self, user_id, payload): } ) duration_seconds = time.time() - start_time - store_api_call( + self.IntegratedChannelAPIRequestLogs.store_api_call( enterprise_customer=self.enterprise_configuration.enterprise_customer, enterprise_customer_configuration_id=self.enterprise_configuration.id, endpoint=url, diff --git a/integrated_channels/cornerstone/views.py b/integrated_channels/cornerstone/views.py index 363e2ab2a4..9ef4b9e015 100644 --- a/integrated_channels/cornerstone/views.py +++ b/integrated_channels/cornerstone/views.py @@ -15,11 +15,11 @@ from django.utils.http import parse_http_date_safe +from django.apps import apps from enterprise.api.throttles import ServiceUserThrottle from enterprise.utils import get_enterprise_customer, get_enterprise_worker_user, get_oauth2authentication_class from integrated_channels.cornerstone.models import CornerstoneEnterpriseCustomerConfiguration from integrated_channels.integrated_channel.constants import ISO_8601_DATE_FORMAT -from integrated_channels.utils import store_api_call logger = getLogger(__name__) @@ -103,6 +103,9 @@ class CornerstoneCoursesListView(BaseViewSet): def get(self, request, *args, **kwargs): start_time = time.time() enterprise_customer_uuid = request.GET.get('ciid') + IntegratedChannelAPIRequestLogs = apps.get_model( + "integrated_channel", "IntegratedChannelAPIRequestLogs" + ) if not enterprise_customer_uuid: return Response( status=status.HTTP_400_BAD_REQUEST, @@ -163,7 +166,7 @@ def get(self, request, *args, **kwargs): duration_seconds = time.time() - start_time headers_dict = dict(request.headers) headers_json = json.dumps(headers_dict) - store_api_call( + IntegratedChannelAPIRequestLogs.store_api_call( enterprise_customer=enterprise_customer, enterprise_customer_configuration_id=enterprise_config.id, endpoint=request.get_full_path(), diff --git a/integrated_channels/integrated_channel/admin/__init__.py b/integrated_channels/integrated_channel/admin/__init__.py index 960b49c182..27dd98e384 100644 --- a/integrated_channels/integrated_channel/admin/__init__.py +++ b/integrated_channels/integrated_channel/admin/__init__.py @@ -89,13 +89,3 @@ class ApiResponseRecordAdmin(admin.ModelAdmin): ) list_per_page = 1000 - - -@admin.register(IntegratedChannelAPIRequestLogs) -class CornerstoneAPIRequestLogAdmin(admin.ModelAdmin): - """ - Django admin model for IntegratedChannelAPIRequestLogs. - """ - - class Meta: - model = IntegratedChannelAPIRequestLogs diff --git a/integrated_channels/integrated_channel/migrations/0033_alter_integratedchannelapirequestlogs_endpoint.py b/integrated_channels/integrated_channel/migrations/0033_alter_integratedchannelapirequestlogs_endpoint.py new file mode 100644 index 0000000000..b5220703ed --- /dev/null +++ b/integrated_channels/integrated_channel/migrations/0033_alter_integratedchannelapirequestlogs_endpoint.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.22 on 2024-01-29 13:24 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('integrated_channel', '0032_auto_20240125_0936'), + ] + + operations = [ + migrations.AlterField( + model_name='integratedchannelapirequestlogs', + name='endpoint', + field=models.URLField(max_length=255), + ), + ] diff --git a/integrated_channels/integrated_channel/models.py b/integrated_channels/integrated_channel/models.py index c5700eb446..1eaf5e4ee1 100644 --- a/integrated_channels/integrated_channel/models.py +++ b/integrated_channels/integrated_channel/models.py @@ -881,7 +881,7 @@ class Meta: resolved = models.BooleanField(default=False) -class BaseIntegratedChannelAPIRequestLogs(TimeStampedModel): +class IntegratedChannelAPIRequestLogs(TimeStampedModel): """ A model to track basic information about every API call we make from the integrated channels. """ @@ -894,8 +894,9 @@ class BaseIntegratedChannelAPIRequestLogs(TimeStampedModel): null=False, help_text="ID from the EnterpriseCustomerConfiguration model", ) - endpoint = models.TextField( + endpoint = models.URLField( blank=False, + max_length=255, null=False, ) payload = models.TextField(blank=False, null=False) @@ -907,16 +908,6 @@ class BaseIntegratedChannelAPIRequestLogs(TimeStampedModel): help_text="API call response body", blank=True, null=True ) - class Meta: - app_label = "integrated_channel" - abstract = True - - -class IntegratedChannelAPIRequestLogs(BaseIntegratedChannelAPIRequestLogs): - """ - A model to track basic information about every API call we make from the integrated channels. - """ - class Meta: app_label = "integrated_channel" verbose_name_plural = "Integrated channels API request logs" @@ -940,3 +931,41 @@ def __repr__(self): Return uniquely identifying string representation. """ return self.__str__() + + + @classmethod + def store_api_call( + cls, + enterprise_customer, + enterprise_customer_configuration_id, + endpoint, + payload, + time_taken, + status_code, + response_body, + ): + """ + Creates new record in IntegratedChannelAPIRequestLogs table. + """ + try: + record = cls( + enterprise_customer=enterprise_customer, + enterprise_customer_configuration_id=enterprise_customer_configuration_id, + endpoint=endpoint, + payload=payload, + time_taken=time_taken, + status_code=status_code, + response_body=response_body, + ) + record.save() + except Exception as e: # pylint: disable=broad-except + LOGGER.error( + f"store_api_call raised error while storing API call: {e}" + f"enterprise_customer={enterprise_customer}" + f"enterprise_customer_configuration_id={enterprise_customer_configuration_id}," + f"endpoint={endpoint}" + f"payload={payload}" + f"time_taken={time_taken}" + f"status_code={status_code}" + f"response_body={response_body}" + ) diff --git a/integrated_channels/utils.py b/integrated_channels/utils.py index bf728c94a9..62ba54fa71 100644 --- a/integrated_channels/utils.py +++ b/integrated_channels/utils.py @@ -477,13 +477,6 @@ def get_enterprise_customer_model(): return apps.get_model('enterprise', 'EnterpriseCustomer') -def integrated_channel_request_log_model(): - """ - Returns the ``IntegratedChannelAPIRequestLogs`` class. - """ - return apps.get_model("integrated_channel", "IntegratedChannelAPIRequestLogs") - - def get_enterprise_customer_from_enterprise_enrollment(enrollment_id): """ Returns the Django ORM enterprise customer object that is associated with an enterprise enrollment ID @@ -508,38 +501,3 @@ def get_enterprise_client_by_channel_code(channel_code): 'canvas': CanvasAPIClient, } return _enterprise_client_model_by_channel_code[channel_code] - - -def store_api_call( - enterprise_customer, - enterprise_customer_configuration_id, - endpoint, - payload, - time_taken, - status_code, - response_body, -): - """ - Creates new record in CornerstoneAPIRequestLogs table. - """ - try: - integrated_channel_request_log_model().objects.create( - enterprise_customer=enterprise_customer, - enterprise_customer_configuration_id=enterprise_customer_configuration_id, - endpoint=endpoint, - payload=payload, - time_taken=time_taken, - status_code=status_code, - response_body=response_body, - ) - except Exception as e: # pylint: disable=broad-except - LOGGER.error( - f"store_api_call raised error while storing API call: {e}" - f"enterprise_customer={enterprise_customer}" - f"enterprise_customer_configuration_id={enterprise_customer_configuration_id}," - f"endpoint={endpoint}" - f"payload={payload}" - f"time_taken={time_taken}" - f"status_code={status_code}" - f"response_body={response_body}" - ) From e021b217a6a412a15396e98ad15dccc8de8a89da Mon Sep 17 00:00:00 2001 From: hamzawaleed01 Date: Tue, 30 Jan 2024 13:06:32 +0500 Subject: [PATCH 109/164] refactor: move util function to classmethod --- integrated_channels/blackboard/client.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/integrated_channels/blackboard/client.py b/integrated_channels/blackboard/client.py index f5586b1dc9..743a6f6202 100644 --- a/integrated_channels/blackboard/client.py +++ b/integrated_channels/blackboard/client.py @@ -20,7 +20,6 @@ from integrated_channels.utils import ( generate_formatted_log, refresh_session_if_expired, - store_api_call, ) LOGGER = logging.getLogger(__name__) @@ -59,6 +58,9 @@ def __init__(self, enterprise_configuration): 'blackboard', 'BlackboardGlobalConfiguration' ) + self.IntegratedChannelAPIRequestLogs = apps.get_model( + "integrated_channel", "IntegratedChannelAPIRequestLogs" + ) self.global_blackboard_config = BlackboardGlobalConfiguration.current() self.config = apps.get_app_config('blackboard') self.session = None @@ -616,7 +618,7 @@ def stringify_and_store_api_record( pass # Store stringified data in the database try: - store_api_call( + self.IntegratedChannelAPIRequestLogs.store_api_call( enterprise_customer=self.enterprise_configuration.enterprise_customer, enterprise_customer_configuration_id=self.enterprise_configuration.id, endpoint=url, From d5bd99d3f5b21f785adc0c9ba0429de8259e0094 Mon Sep 17 00:00:00 2001 From: hamzawaleed01 Date: Tue, 30 Jan 2024 13:17:24 +0500 Subject: [PATCH 110/164] refactor: squash migrations --- integrated_channels/cornerstone/views.py | 2 +- ...integratedchannelapirequestlogs_options.py | 21 +++++++++++- ...tegratedchannelapirequestlogs_endpoint.py} | 2 +- .../migrations/0032_auto_20240125_0936.py | 32 ------------------- .../integrated_channel/models.py | 1 - .../test_cornerstone/test_client.py | 11 +++++-- 6 files changed, 31 insertions(+), 38 deletions(-) rename integrated_channels/integrated_channel/migrations/{0033_alter_integratedchannelapirequestlogs_endpoint.py => 0032_alter_integratedchannelapirequestlogs_endpoint.py} (80%) delete mode 100644 integrated_channels/integrated_channel/migrations/0032_auto_20240125_0936.py diff --git a/integrated_channels/cornerstone/views.py b/integrated_channels/cornerstone/views.py index 9ef4b9e015..47308b86ff 100644 --- a/integrated_channels/cornerstone/views.py +++ b/integrated_channels/cornerstone/views.py @@ -13,9 +13,9 @@ from rest_framework.authentication import SessionAuthentication from rest_framework.response import Response +from django.apps import apps from django.utils.http import parse_http_date_safe -from django.apps import apps from enterprise.api.throttles import ServiceUserThrottle from enterprise.utils import get_enterprise_customer, get_enterprise_worker_user, get_oauth2authentication_class from integrated_channels.cornerstone.models import CornerstoneEnterpriseCustomerConfiguration diff --git a/integrated_channels/integrated_channel/migrations/0031_alter_integratedchannelapirequestlogs_options.py b/integrated_channels/integrated_channel/migrations/0031_alter_integratedchannelapirequestlogs_options.py index 5b11a9f695..7f04148866 100644 --- a/integrated_channels/integrated_channel/migrations/0031_alter_integratedchannelapirequestlogs_options.py +++ b/integrated_channels/integrated_channel/migrations/0031_alter_integratedchannelapirequestlogs_options.py @@ -1,6 +1,6 @@ # Generated by Django 3.2.22 on 2024-01-25 09:25 -from django.db import migrations +from django.db import migrations, models class Migration(migrations.Migration): @@ -14,4 +14,23 @@ class Migration(migrations.Migration): name='integratedchannelapirequestlogs', options={'verbose_name_plural': 'Integrated channels API request logs'}, ), + migrations.RemoveField( + model_name='integratedchannelapirequestlogs', + name='api_record', + ), + migrations.AddField( + model_name='integratedchannelapirequestlogs', + name='response_body', + field=models.TextField(blank=True, help_text='API call response body', null=True), + ), + migrations.AddField( + model_name='integratedchannelapirequestlogs', + name='status_code', + field=models.PositiveIntegerField(blank=True, help_text='API call response HTTP status code', null=True), + ), + migrations.AlterField( + model_name='integratedchannelapirequestlogs', + name='time_taken', + field=models.FloatField(), + ), ] diff --git a/integrated_channels/integrated_channel/migrations/0033_alter_integratedchannelapirequestlogs_endpoint.py b/integrated_channels/integrated_channel/migrations/0032_alter_integratedchannelapirequestlogs_endpoint.py similarity index 80% rename from integrated_channels/integrated_channel/migrations/0033_alter_integratedchannelapirequestlogs_endpoint.py rename to integrated_channels/integrated_channel/migrations/0032_alter_integratedchannelapirequestlogs_endpoint.py index b5220703ed..ccd5d2b95c 100644 --- a/integrated_channels/integrated_channel/migrations/0033_alter_integratedchannelapirequestlogs_endpoint.py +++ b/integrated_channels/integrated_channel/migrations/0032_alter_integratedchannelapirequestlogs_endpoint.py @@ -6,7 +6,7 @@ class Migration(migrations.Migration): dependencies = [ - ('integrated_channel', '0032_auto_20240125_0936'), + ('integrated_channel', '0031_alter_integratedchannelapirequestlogs_options'), ] operations = [ diff --git a/integrated_channels/integrated_channel/migrations/0032_auto_20240125_0936.py b/integrated_channels/integrated_channel/migrations/0032_auto_20240125_0936.py deleted file mode 100644 index 077a1af02d..0000000000 --- a/integrated_channels/integrated_channel/migrations/0032_auto_20240125_0936.py +++ /dev/null @@ -1,32 +0,0 @@ -# Generated by Django 3.2.22 on 2024-01-25 09:36 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('integrated_channel', '0031_alter_integratedchannelapirequestlogs_options'), - ] - - operations = [ - migrations.RemoveField( - model_name='integratedchannelapirequestlogs', - name='api_record', - ), - migrations.AddField( - model_name='integratedchannelapirequestlogs', - name='response_body', - field=models.TextField(blank=True, help_text='API call response body', null=True), - ), - migrations.AddField( - model_name='integratedchannelapirequestlogs', - name='status_code', - field=models.PositiveIntegerField(blank=True, help_text='API call response HTTP status code', null=True), - ), - migrations.AlterField( - model_name='integratedchannelapirequestlogs', - name='time_taken', - field=models.FloatField(), - ), - ] diff --git a/integrated_channels/integrated_channel/models.py b/integrated_channels/integrated_channel/models.py index 1eaf5e4ee1..44ab495e35 100644 --- a/integrated_channels/integrated_channel/models.py +++ b/integrated_channels/integrated_channel/models.py @@ -932,7 +932,6 @@ def __repr__(self): """ return self.__str__() - @classmethod def store_api_call( cls, diff --git a/tests/test_integrated_channels/test_cornerstone/test_client.py b/tests/test_integrated_channels/test_cornerstone/test_client.py index 06a1f0ddb4..ffc5b07aed 100644 --- a/tests/test_integrated_channels/test_cornerstone/test_client.py +++ b/tests/test_integrated_channels/test_cornerstone/test_client.py @@ -8,9 +8,15 @@ import pytest import responses +from django.apps import apps + from integrated_channels.cornerstone.client import CornerstoneAPIClient from test_utils import factories +IntegratedChannelAPIRequestLogs = apps.get_model( + "integrated_channel", "IntegratedChannelAPIRequestLogs" +) + @pytest.mark.django_db class TestCornerstoneApiClient(unittest.TestCase): @@ -26,7 +32,7 @@ def setUp(self): ) @responses.activate - def test_create_course_completion(self): + def test_create_course_completion_stores_api_record(self): """ ``create_course_completion`` should use the appropriate URLs for transmission. """ @@ -47,9 +53,10 @@ def test_create_course_completion(self): json="{}", status=200, ) + assert IntegratedChannelAPIRequestLogs.objects.count() == 0 output = cornerstone_api_client.create_course_completion( "test-learner@example.com", json.dumps(payload) ) - + assert IntegratedChannelAPIRequestLogs.objects.count() == 1 assert len(responses.calls) == 1 assert output == (200, '"{}"') From 1750a654dad7591ded28026a3a876bf052a33100 Mon Sep 17 00:00:00 2001 From: Zaman Afzal Date: Tue, 30 Jan 2024 19:21:02 +0500 Subject: [PATCH 111/164] feat: Added management command to fix `LearnerDataTransmissionAudit` table records. (#2005) --- CHANGELOG.rst | 4 ++ enterprise/__init__.py | 2 +- ...cate_learner_transmission_audit_records.py | 67 +++++++++++++++++++ 3 files changed, 72 insertions(+), 1 deletion(-) create mode 100644 integrated_channels/integrated_channel/management/commands/remove_duplicate_learner_transmission_audit_records.py diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 0305d32abb..8053cc9936 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -16,6 +16,10 @@ Change Log Unreleased ---------- +[4.11.1] +--------- +* Added management command to fix `LearnerDataTransmissionAudit` table records. + [4.11.0] --------- * Added the ability for enterprise customers to enable/disable academies. diff --git a/enterprise/__init__.py b/enterprise/__init__.py index f2d38d70bf..9e1444557d 100644 --- a/enterprise/__init__.py +++ b/enterprise/__init__.py @@ -2,4 +2,4 @@ Your project description goes here. """ -__version__ = "4.11.0" +__version__ = "4.11.1" diff --git a/integrated_channels/integrated_channel/management/commands/remove_duplicate_learner_transmission_audit_records.py b/integrated_channels/integrated_channel/management/commands/remove_duplicate_learner_transmission_audit_records.py new file mode 100644 index 0000000000..b7edf86f91 --- /dev/null +++ b/integrated_channels/integrated_channel/management/commands/remove_duplicate_learner_transmission_audit_records.py @@ -0,0 +1,67 @@ +""" +Transmits consenting enterprise learner data to the integrated channels. +""" +from logging import getLogger + +from django.apps import apps +from django.contrib import auth +from django.core.management.base import BaseCommand +from django.db import transaction +from django.db.models import Max +from django.utils.translation import gettext as _ + +User = auth.get_user_model() +LOGGER = getLogger(__name__) + + +class Command(BaseCommand): + """ + Management command which removes the duplicated transmission audit records for integration channels + """ + help = _(''' + Transmit Enterprise learner course completion data for the given EnterpriseCustomer. + ''') + + def handle(self, *args, **options): + """ + Remove the duplicated transmission audit records for integration channels. + """ + # Multiple transmission records were being saved against single enterprise_course_enrollment_id in case + # transmission fails against course and course run id. Job of this management command is to keep the latest + # record for enterprise_course_enrollment_id that doesn't start with "course-v1: and delete all other records." + channel_learner_audit_models = [ + ('moodle', 'MoodleLearnerDataTransmissionAudit'), + ('blackboard', 'BlackboardLearnerDataTransmissionAudit'), + ('cornerstone', 'CornerstoneLearnerDataTransmissionAudit'), + ('canvas', 'CanvasLearnerAssessmentDataTransmissionAudit'), + ('degreed2', 'Degreed2LearnerDataTransmissionAudit'), + ('sap_success_factors', 'SapSuccessFactorsLearnerDataTransmissionAudit'), + ] + for app_label, model_name in channel_learner_audit_models: + model_class = apps.get_model(app_label=app_label, model_name=model_name) + + latest_records_without_prefix = ( + model_class.objects.exclude(course_id__startswith='course-v1:') + .values('enterprise_course_enrollment_id').annotate(most_recent_transmission_id=Max('id')) + ) + + LOGGER.info( + f'{app_label} channel has {latest_records_without_prefix.count()} records without prefix' + ) + + # Delete all duplicate records for each enterprise_course_enrollment_id + with transaction.atomic(): + for entry in latest_records_without_prefix: + enterprise_course_enrollment_id = entry['enterprise_course_enrollment_id'] + most_recent_transmission_id = entry['most_recent_transmission_id'] + + # Delete all records except the latest one without "course-v1:" + duplicate_records_to_delete = ( + model_class.objects + .filter(enterprise_course_enrollment_id=enterprise_course_enrollment_id) + .exclude(id=most_recent_transmission_id) + ) + LOGGER.info( + f'{app_label} channel - {duplicate_records_to_delete.count()} duplicate records are deleted' + ) + duplicate_records_to_delete.delete() From 10095b2997b51349489a968db82255e889fb2ea4 Mon Sep 17 00:00:00 2001 From: Ehmad Saeed Date: Wed, 31 Jan 2024 13:51:06 +0500 Subject: [PATCH 112/164] feat: added caching for fetching degreed course id --- integrated_channels/degreed2/client.py | 19 ++++++++++-- .../test_degreed2/test_client.py | 29 +++++++++++++++++-- 2 files changed, 43 insertions(+), 5 deletions(-) diff --git a/integrated_channels/degreed2/client.py b/integrated_channels/degreed2/client.py index bee591fed7..1424d692ef 100644 --- a/integrated_channels/degreed2/client.py +++ b/integrated_channels/degreed2/client.py @@ -9,6 +9,7 @@ from http import HTTPStatus import requests +from edx_django_utils.cache import TieredCache, get_cache_key from six.moves.urllib.parse import urljoin from django.apps import apps @@ -182,9 +183,17 @@ def delete_course_completion(self, user_id, payload): def fetch_degreed_course_id(self, external_id): """ - Fetch the 'id' of a course from Degreed2, given the external-id as a search param - 'external-id' is the edX course key + Fetch the 'id' of a course from cache first and if not found then send a request to Degreed2, + given the external-id as a search param 'external-id' is the edX course key. """ + cache_key = get_cache_key( + resource='degreed2_course_id', + resource_id=external_id, + ) + cached_course_id = TieredCache.get_cached_response(cache_key) + if cached_course_id.is_found: + LOGGER.info(self.make_log_msg(external_id, f'Found cached course id: {cached_course_id.value}')) + return cached_course_id.value # QueryDict converts + to space params = QueryDict(f"filter[external_id]={external_id.replace('+','%2B')}") course_search_url = f'{self.get_courses_url()}?{params.urlencode(safe="[]")}' @@ -201,7 +210,11 @@ def fetch_degreed_course_id(self, external_id): ) response_json = json.loads(response_body) if response_json['data']: - return response_json['data'][0]['id'] + # cache the course id with a 1 day expiration + response_course_id = response_json['data'][0]['id'] + expires_in = 60 * 60 * 24 # 1 day + TieredCache.set_all_tiers(cache_key, response_course_id, expires_in) + return response_course_id raise ClientError( f'Degreed2: Attempted to find degreed course id but failed, external id was {external_id}' f', Response from Degreed was {response_body}') diff --git a/tests/test_integrated_channels/test_degreed2/test_client.py b/tests/test_integrated_channels/test_degreed2/test_client.py index 52e431f621..bbc3c409f2 100644 --- a/tests/test_integrated_channels/test_degreed2/test_client.py +++ b/tests/test_integrated_channels/test_degreed2/test_client.py @@ -206,6 +206,29 @@ def test_delete_course_completion(self): degreed_api_client = Degreed2APIClient(enterprise_config) degreed_api_client.delete_course_completion(None, None) + @mock.patch('integrated_channels.degreed2.client.Degreed2APIClient._get') + def test_fetch_degreed_course_id_cache(self, mock_get_request): + """ + ``fetch_degreed_course_id`` should fetch data from the API only if the cache is empty. + """ + enterprise_config = factories.Degreed2EnterpriseCustomerConfigurationFactory() + degreed_api_client = Degreed2APIClient(enterprise_config) + mock_get_request.return_value = ( + 200, '{"data": [{"id": "degreed_course_id"}]}' + ) + degreed_external_course_id_1 = 'course_id_1' + degreed_external_course_id_2 = 'course_id_2' + + degreed_api_client.fetch_degreed_course_id(degreed_external_course_id_1) + degreed_api_client.fetch_degreed_course_id(degreed_external_course_id_2) + assert mock_get_request.call_count == 2 + + # The second call for the same course id should return the degreed_course_id from the cache + mock_get_request.reset_mock() + degreed_api_client.fetch_degreed_course_id(degreed_external_course_id_1) + degreed_api_client.fetch_degreed_course_id(degreed_external_course_id_2) + assert mock_get_request.call_count == 0 + @responses.activate @pytest.mark.django_db @mock.patch('enterprise.api_client.client.JwtBuilder', mock.Mock()) @@ -239,6 +262,7 @@ def test_create_content_metadata_success(self): json={"skill_names": ["Supply Chain", "Supply Chain Management"]}, status=200, ) + # The second call for the same course id should return the degreed_course_id from the cache responses.add( responses.GET, course_url + "?filter%5Bexternal_id%5D=key", @@ -253,7 +277,7 @@ def test_create_content_metadata_success(self): ) status_code, response_body = degreed_api_client.create_content_metadata(create_course_payload()) - assert len(responses.calls) == 5 + assert len(responses.calls) == 4 assert responses.calls[0].request.url == oauth_url assert responses.calls[1].request.url == course_url assert status_code == 200 @@ -298,6 +322,7 @@ def test_create_content_metadata_retry_success(self): json={"skill_names": ["Supply Chain", "Supply Chain Management"]}, status=200, ) + # The second call for the same course id should return the degreed_course_id from the cache responses.add( responses.GET, course_url + "?filter%5Bexternal_id%5D=key", @@ -311,7 +336,7 @@ def test_create_content_metadata_retry_success(self): status=200, ) status_code, response_body = degreed_api_client.create_content_metadata(create_course_payload()) - assert len(responses.calls) == 6 + assert len(responses.calls) == 5 assert responses.calls[0].request.url == oauth_url assert responses.calls[1].request.url == course_url assert responses.calls[2].request.url == course_url From 716a0cd9b9ea1144dab77387ed2ad95e2943d118 Mon Sep 17 00:00:00 2001 From: hamzawaleed01 Date: Wed, 31 Jan 2024 14:55:43 +0500 Subject: [PATCH 113/164] refactor: IntegratedChannelAPIRequestLogs not a class attribute anymore --- integrated_channels/cornerstone/client.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/integrated_channels/cornerstone/client.py b/integrated_channels/cornerstone/client.py index 72362bd4ee..6f4a74244e 100644 --- a/integrated_channels/cornerstone/client.py +++ b/integrated_channels/cornerstone/client.py @@ -36,9 +36,6 @@ def __init__(self, enterprise_configuration): """ super().__init__(enterprise_configuration) self.global_cornerstone_config = apps.get_model('cornerstone', 'CornerstoneGlobalConfiguration').current() - self.IntegratedChannelAPIRequestLogs = apps.get_model( - "integrated_channel", "IntegratedChannelAPIRequestLogs" - ) self.session = None self.expires_at = None @@ -91,6 +88,9 @@ def create_course_completion(self, user_id, payload): Raises: HTTPError: if we received a failure response code from Cornerstone """ + IntegratedChannelAPIRequestLogs = apps.get_model( + "integrated_channel", "IntegratedChannelAPIRequestLogs" + ) json_payload = json.loads(payload) callback_url = json_payload['data'].pop('callbackUrl') session_token = self.enterprise_configuration.session_token @@ -118,7 +118,7 @@ def create_course_completion(self, user_id, payload): } ) duration_seconds = time.time() - start_time - self.IntegratedChannelAPIRequestLogs.store_api_call( + IntegratedChannelAPIRequestLogs.store_api_call( enterprise_customer=self.enterprise_configuration.enterprise_customer, enterprise_customer_configuration_id=self.enterprise_configuration.id, endpoint=url, From c435d4d4f15a21e407f63aef7de0b35c975b3c53 Mon Sep 17 00:00:00 2001 From: Zaman Afzal Date: Wed, 31 Jan 2024 20:36:10 +0500 Subject: [PATCH 114/164] fix: Added logs and removed cornerstone from loop (#2012) --- CHANGELOG.rst | 4 ++++ enterprise/__init__.py | 2 +- .../remove_duplicate_learner_transmission_audit_records.py | 6 +++--- 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index d4df216f8f..24a7d38f09 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -16,6 +16,10 @@ Change Log Unreleased ---------- +[4.11.5] +--------- +* fix: Added logs and remove cornerstone from management command + [4.11.4] --------- * feat: update blackboard client to store API calls in DB diff --git a/enterprise/__init__.py b/enterprise/__init__.py index 8e72b8f9c9..a6b7377a0a 100644 --- a/enterprise/__init__.py +++ b/enterprise/__init__.py @@ -2,4 +2,4 @@ Your project description goes here. """ -__version__ = "4.11.4" +__version__ = "4.11.5" diff --git a/integrated_channels/integrated_channel/management/commands/remove_duplicate_learner_transmission_audit_records.py b/integrated_channels/integrated_channel/management/commands/remove_duplicate_learner_transmission_audit_records.py index b7edf86f91..8233740ed7 100644 --- a/integrated_channels/integrated_channel/management/commands/remove_duplicate_learner_transmission_audit_records.py +++ b/integrated_channels/integrated_channel/management/commands/remove_duplicate_learner_transmission_audit_records.py @@ -32,8 +32,7 @@ def handle(self, *args, **options): channel_learner_audit_models = [ ('moodle', 'MoodleLearnerDataTransmissionAudit'), ('blackboard', 'BlackboardLearnerDataTransmissionAudit'), - ('cornerstone', 'CornerstoneLearnerDataTransmissionAudit'), - ('canvas', 'CanvasLearnerAssessmentDataTransmissionAudit'), + ('canvas', 'CanvasLearnerDataTransmissionAudit'), ('degreed2', 'Degreed2LearnerDataTransmissionAudit'), ('sap_success_factors', 'SapSuccessFactorsLearnerDataTransmissionAudit'), ] @@ -62,6 +61,7 @@ def handle(self, *args, **options): .exclude(id=most_recent_transmission_id) ) LOGGER.info( - f'{app_label} channel - {duplicate_records_to_delete.count()} duplicate records are deleted' + f'{app_label} channel - {duplicate_records_to_delete.count()} duplicate records are deleted ' + f' for enrollment id {enterprise_course_enrollment_id}' ) duplicate_records_to_delete.delete() From a1c8dab8016b2867428dcee5095ccf127b7557ad Mon Sep 17 00:00:00 2001 From: Katrina Nguyen <71999631+katrinan029@users.noreply.github.com> Date: Wed, 31 Jan 2024 13:58:11 -0800 Subject: [PATCH 115/164] feat: adds waffle flag for prequery search (#2013) * feat: adds waffle flag for prequery search --- CHANGELOG.rst | 4 ++ enterprise/__init__.py | 2 +- enterprise/toggles.py | 20 ++++++++++ tests/test_enterprise/api/test_filters.py | 3 +- tests/test_enterprise/api/test_views.py | 47 +++++++++++++++-------- 5 files changed, 57 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 24a7d38f09..567a9be96b 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -16,6 +16,10 @@ Change Log Unreleased ---------- +[4.11.6] +--------- +* feat: Added a flag for prequery search suggestions + [4.11.5] --------- * fix: Added logs and remove cornerstone from management command diff --git a/enterprise/__init__.py b/enterprise/__init__.py index a6b7377a0a..c7aa312f0b 100644 --- a/enterprise/__init__.py +++ b/enterprise/__init__.py @@ -2,4 +2,4 @@ Your project description goes here. """ -__version__ = "4.11.5" +__version__ = "4.11.6" diff --git a/enterprise/toggles.py b/enterprise/toggles.py index 70b00233b3..82f33077d2 100644 --- a/enterprise/toggles.py +++ b/enterprise/toggles.py @@ -19,6 +19,18 @@ ENTERPRISE_LOG_PREFIX, ) +# .. toggle_name: enterprise.feature_prequery_search_suggestions +# .. toggle_implementation: WaffleFlag +# .. toggle_default: False +# .. toggle_description: Enables prequery search suggestions +# .. toggle_use_cases: open_edx +# .. toggle_creation_date: 2024-01-31 +FEATURE_PREQUERY_SEARCH_SUGGESTIONS = WaffleFlag( + f'{ENTERPRISE_NAMESPACE}.feature_prequery_search_suggestions', + __name__, + ENTERPRISE_LOG_PREFIX, +) + def top_down_assignment_real_time_lcm(): """ @@ -27,10 +39,18 @@ def top_down_assignment_real_time_lcm(): return TOP_DOWN_ASSIGNMENT_REAL_TIME_LCM.is_enabled() +def feature_prequery_search_suggestions(): + """ + Returns whether the prequery search suggestion feature flag is enabled. + """ + return FEATURE_PREQUERY_SEARCH_SUGGESTIONS.is_enabled() + + def enterprise_features(): """ Returns a dict of enterprise Waffle-based feature flags. """ return { 'top_down_assignment_real_time_lcm': top_down_assignment_real_time_lcm(), + 'feature_prequery_search_suggestions': feature_prequery_search_suggestions(), } diff --git a/tests/test_enterprise/api/test_filters.py b/tests/test_enterprise/api/test_filters.py index 1076c58f4b..a8c631f515 100644 --- a/tests/test_enterprise/api/test_filters.py +++ b/tests/test_enterprise/api/test_filters.py @@ -300,7 +300,8 @@ def test_filter(self, is_staff, is_linked_to_enterprise, has_access): 'start': 0, 'results': [], 'enterprise_features': { - 'top_down_assignment_real_time_lcm': False + 'top_down_assignment_real_time_lcm': False, + 'feature_prequery_search_suggestions': False } } assert response == mock_empty_200_success_response diff --git a/tests/test_enterprise/api/test_views.py b/tests/test_enterprise/api/test_views.py index 97ff1a3512..e704ab7071 100644 --- a/tests/test_enterprise/api/test_views.py +++ b/tests/test_enterprise/api/test_views.py @@ -58,7 +58,7 @@ PendingEnrollment, PendingEnterpriseCustomerUser, ) -from enterprise.toggles import TOP_DOWN_ASSIGNMENT_REAL_TIME_LCM +from enterprise.toggles import FEATURE_PREQUERY_SEARCH_SUGGESTIONS, TOP_DOWN_ASSIGNMENT_REAL_TIME_LCM from enterprise.utils import ( NotConnectedToOpenEdX, get_sso_orchestrator_api_base_url, @@ -1495,58 +1495,59 @@ def test_enterprise_customer_basic_list(self): @ddt.data( # Request missing required permissions query param. - (True, False, [], {}, False, {'detail': 'User is not allowed to access the view.'}, False), + (True, False, [], {}, False, {'detail': 'User is not allowed to access the view.'}, False, False), # Staff user that does not have the specified group permission. (True, False, [], {'permissions': ['enterprise_enrollment_api_access']}, False, - {'detail': 'User is not allowed to access the view.'}, False), + {'detail': 'User is not allowed to access the view.'}, False, False), # Staff user that does have the specified group permission. (True, False, ['enterprise_enrollment_api_access'], {'permissions': ['enterprise_enrollment_api_access']}, - True, None, False), + True, None, False, False), # Non staff user that is not linked to the enterprise, nor do they have the group permission. (False, False, [], {'permissions': ['enterprise_enrollment_api_access']}, False, - {'detail': 'User is not allowed to access the view.'}, False), + {'detail': 'User is not allowed to access the view.'}, False, False), # Non staff user that is not linked to the enterprise, but does have the group permission. (False, False, ['enterprise_enrollment_api_access'], {'permissions': ['enterprise_enrollment_api_access']}, - False, None, False), + False, None, False, False), # Non staff user that is linked to the enterprise, but does not have the group permission. (False, True, [], {'permissions': ['enterprise_enrollment_api_access']}, False, - {'detail': 'User is not allowed to access the view.'}, False), + {'detail': 'User is not allowed to access the view.'}, False, False), # Non staff user that is linked to the enterprise and does have the group permission (False, True, ['enterprise_enrollment_api_access'], {'permissions': ['enterprise_enrollment_api_access']}, - True, None, False), + True, None, False, False), # Non staff user that is linked to the enterprise and has group permission and the request has passed # multiple groups to check. (False, True, ['enterprise_enrollment_api_access'], - {'permissions': ['enterprise_enrollment_api_access', 'enterprise_data_api_access']}, True, None, False), + {'permissions': ['enterprise_enrollment_api_access', 'enterprise_data_api_access']}, True, None, False, False), # Staff user with group permission filtering on non existent enterprise id. (True, False, ['enterprise_enrollment_api_access'], {'permissions': ['enterprise_enrollment_api_access'], 'enterprise_id': FAKE_UUIDS[1]}, False, - None, False), + None, False, False), # Staff user with group permission filtering on enterprise id successfully. (True, False, ['enterprise_enrollment_api_access'], {'permissions': ['enterprise_enrollment_api_access'], 'enterprise_id': FAKE_UUIDS[0]}, True, - None, False), + None, False, False), # Staff user with group permission filtering on search param with no results. (True, False, ['enterprise_enrollment_api_access'], {'permissions': ['enterprise_enrollment_api_access'], 'search': 'blah'}, False, - None, False), + None, False, False), # Staff user with group permission filtering on search param with results. (True, False, ['enterprise_enrollment_api_access'], {'permissions': ['enterprise_enrollment_api_access'], 'search': 'test'}, True, - None, False), + None, False, False), # Staff user with group permission filtering on slug with results. (True, False, ['enterprise_enrollment_api_access'], {'permissions': ['enterprise_enrollment_api_access'], 'slug': TEST_SLUG}, True, - None, False), + None, False, False), # Staff user with group permissions filtering on slug with no results. (True, False, ['enterprise_enrollment_api_access'], {'permissions': ['enterprise_enrollment_api_access'], 'slug': 'blah'}, False, - None, False), + None, False, False), # Staff user with group permission filtering on slug with results, with - # top down assignment & real-time LCM feature enabled + # top down assignment & real-time LCM feature enabled and + # prequery search results enabled (True, False, ['enterprise_enrollment_api_access'], {'permissions': ['enterprise_enrollment_api_access'], 'slug': TEST_SLUG}, True, - None, True), + None, True, True), ) @ddt.unpack @mock.patch('enterprise.utils.get_logo_url') @@ -1559,6 +1560,7 @@ def test_enterprise_customer_with_access_to( has_access_to_enterprise, expected_error, is_top_down_assignment_real_time_lcm_enabled, + feature_prequery_search_suggestions_enabled, mock_get_logo_url, ): """ @@ -1608,6 +1610,15 @@ def test_enterprise_customer_with_access_to( TOP_DOWN_ASSIGNMENT_REAL_TIME_LCM, active=is_top_down_assignment_real_time_lcm_enabled ): + + response = client.get( + f"{settings.TEST_SERVER}{ENTERPRISE_CUSTOMER_WITH_ACCESS_TO_ENDPOINT}?{urlencode(query_params, True)}" + ) + with override_waffle_flag( + FEATURE_PREQUERY_SEARCH_SUGGESTIONS, + active=feature_prequery_search_suggestions_enabled + ): + response = client.get( f"{settings.TEST_SERVER}{ENTERPRISE_CUSTOMER_WITH_ACCESS_TO_ENDPOINT}?{urlencode(query_params, True)}" ) @@ -1670,6 +1681,8 @@ def test_enterprise_customer_with_access_to( 'results': [], 'enterprise_features': { 'top_down_assignment_real_time_lcm': is_top_down_assignment_real_time_lcm_enabled, + 'feature_prequery_search_suggestions': feature_prequery_search_suggestions_enabled, + } } assert response in (expected_error, mock_empty_200_success_response) From 5ee6bf4a2db448eb74d952acead3c992655bd34f Mon Sep 17 00:00:00 2001 From: MueezKhan246 <93375917+MueezKhan246@users.noreply.github.com> Date: Thu, 1 Feb 2024 15:52:07 +0500 Subject: [PATCH 116/164] Shifted stringify_and_store_api_record to common utils (#2010) * test: add test for stringify_and_store_api_record util --- integrated_channels/blackboard/client.py | 78 ++++------------- integrated_channels/utils.py | 92 ++++++++++++++++++++ tests/test_integrated_channels/test_utils.py | 34 ++++++++ 3 files changed, 143 insertions(+), 61 deletions(-) diff --git a/integrated_channels/blackboard/client.py b/integrated_channels/blackboard/client.py index 224407f8b2..4d93163603 100644 --- a/integrated_channels/blackboard/client.py +++ b/integrated_channels/blackboard/client.py @@ -17,7 +17,7 @@ from integrated_channels.blackboard.exporters.content_metadata import BLACKBOARD_COURSE_CONTENT_NAME from integrated_channels.exceptions import ClientError from integrated_channels.integrated_channel.client import IntegratedChannelApiClient -from integrated_channels.utils import generate_formatted_log, refresh_session_if_expired +from integrated_channels.utils import generate_formatted_log, refresh_session_if_expired, stringify_and_store_api_record LOGGER = logging.getLogger(__name__) @@ -595,58 +595,6 @@ def generate_course_content_delete_url(self, course_id, content_id): path=COURSE_CONTENT_DELETE_PATH.format(course_id=course_id, content_id=content_id) ) - def stringify_and_store_api_record( - self, url, data, time_taken, status_code, response_body - ): - """ - Helper method to stringify `data` arg and create new record in - `IntegratedChannelAPIRequestLogs` model - """ - if data is not None: - # Convert data to string if it's not already a string - if not isinstance(data, str): - try: - # Check if data is a dictionary, list, or tuple then convert to JSON string - if isinstance(data, (dict, list, tuple)): - data = json.dumps(data) - else: - # If it's another type, simply convert to string - data = str(data) - except Exception as e: # pylint: disable=broad-except - LOGGER.error( - generate_formatted_log( - self.enterprise_configuration.channel_code(), - self.enterprise_configuration.enterprise_customer.uuid, - None, - None, - f"stringify_and_store_api_record: Unable to stringify data: {e}", - ) - ) - # Store stringified data in the database - try: - IntegratedChannelAPIRequestLogs = apps.get_model( - "integrated_channel", "IntegratedChannelAPIRequestLogs" - ) - IntegratedChannelAPIRequestLogs.store_api_call( - enterprise_customer=self.enterprise_configuration.enterprise_customer, - enterprise_customer_configuration_id=self.enterprise_configuration.id, - endpoint=url, - payload=data, - time_taken=time_taken, - status_code=status_code, - response_body=response_body, - ) - except Exception as e: # pylint: disable=broad-except - LOGGER.error( - generate_formatted_log( - self.enterprise_configuration.channel_code(), - self.enterprise_configuration.enterprise_customer.uuid, - None, - None, - f"stringify_and_store_api_record: Failed to store data in the database: {e}", - ) - ) - def _get(self, url, data=None): """ Returns request's get response and raises Client Errors if appropriate. @@ -654,8 +602,10 @@ def _get(self, url, data=None): start_time = time.time() get_response = self.session.get(url, params=data) time_taken = time.time() - start_time - self.stringify_and_store_api_record( - url=url, + stringify_and_store_api_record( + enterprise_customer=self.enterprise_configuration.enterprise_customer, + enterprise_customer_configuration_id=self.enterprise_configuration.id, + endpoint=url, data=data, time_taken=time_taken, status_code=get_response.status_code, @@ -672,8 +622,10 @@ def _patch(self, url, data): start_time = time.time() patch_response = self.session.patch(url, json=data) time_taken = time.time() - start_time - self.stringify_and_store_api_record( - url=url, + stringify_and_store_api_record( + enterprise_customer=self.enterprise_configuration.enterprise_customer, + enterprise_customer_configuration_id=self.enterprise_configuration.id, + endpoint=url, data=data, time_taken=time_taken, status_code=patch_response.status_code, @@ -690,8 +642,10 @@ def _post(self, url, data): start_time = time.time() post_response = self.session.post(url, json=data) time_taken = time.time() - start_time - self.stringify_and_store_api_record( - url=url, + stringify_and_store_api_record( + enterprise_customer=self.enterprise_configuration.enterprise_customer, + enterprise_customer_configuration_id=self.enterprise_configuration.id, + endpoint=url, data=data, time_taken=time_taken, status_code=post_response.status_code, @@ -709,8 +663,10 @@ def _delete(self, url): start_time = time.time() response = self.session.delete(url) time_taken = time.time() - start_time - self.stringify_and_store_api_record( - url=url, + stringify_and_store_api_record( + enterprise_customer=self.enterprise_configuration.enterprise_customer, + enterprise_customer_configuration_id=self.enterprise_configuration.id, + endpoint=url, data='', time_taken=time_taken, status_code=response.status_code, diff --git a/integrated_channels/utils.py b/integrated_channels/utils.py index 62ba54fa71..b14bcffbef 100644 --- a/integrated_channels/utils.py +++ b/integrated_channels/utils.py @@ -477,6 +477,13 @@ def get_enterprise_customer_model(): return apps.get_model('enterprise', 'EnterpriseCustomer') +def integrated_channel_request_log_model(): + """ + Returns the ``IntegratedChannelAPIRequestLogs`` class. + """ + return apps.get_model("integrated_channel", "IntegratedChannelAPIRequestLogs") + + def get_enterprise_customer_from_enterprise_enrollment(enrollment_id): """ Returns the Django ORM enterprise customer object that is associated with an enterprise enrollment ID @@ -501,3 +508,88 @@ def get_enterprise_client_by_channel_code(channel_code): 'canvas': CanvasAPIClient, } return _enterprise_client_model_by_channel_code[channel_code] + + +def store_api_call( + enterprise_customer, + enterprise_customer_configuration_id, + endpoint, + payload, + time_taken, + status_code, + response_body, +): + """ + Creates new record in CornerstoneAPIRequestLogs table. + """ + try: + integrated_channel_request_log_model().objects.create( + enterprise_customer=enterprise_customer, + enterprise_customer_configuration_id=enterprise_customer_configuration_id, + endpoint=endpoint, + payload=payload, + time_taken=time_taken, + status_code=status_code, + response_body=response_body, + ) + except Exception as e: # pylint: disable=broad-except + LOGGER.error( + f"store_api_call raised error while storing API call: {e}" + f"enterprise_customer={enterprise_customer}" + f"enterprise_customer_configuration_id={enterprise_customer_configuration_id}," + f"endpoint={endpoint}" + f"payload={payload}" + f"time_taken={time_taken}" + f"status_code={status_code}" + f"response_body={response_body}" + ) + + +def stringify_and_store_api_record( + enterprise_customer, + enterprise_customer_configuration_id, + endpoint, + data, + time_taken, + status_code, + response_body +): + """ + Stringify the given data and store the API record in the database. + """ + if data is not None: + # Convert data to string if it's not already a string + if not isinstance(data, str): + try: + # Check if data is a dictionary, list, or tuple then convert to JSON string + if isinstance(data, (dict, list, tuple)): + data = json.dumps(data) + else: + # If it's another type, simply convert to string + data = str(data) + except (TypeError, ValueError) as e: + LOGGER.error( + f"stringify_and_store_api_record: Error occured during stringification: {e}" + f"enterprise_customer={enterprise_customer}" + f"enterprise_customer_configuration_id={enterprise_customer_configuration_id}" + f"data={data}" + ) + # Store stringified data in the database + try: + integrated_channel_request_log_model().store_api_call( + enterprise_customer=enterprise_customer, + enterprise_customer_configuration_id=enterprise_customer_configuration_id, + endpoint=endpoint, + payload=data, + time_taken=time_taken, + status_code=status_code, + response_body=response_body, + ) + except Exception as e: # pylint: disable=broad-except + LOGGER.error( + f"stringify_and_store_api_record: Error occured while storing: {e}" + f"enterprise_customer={enterprise_customer}" + f"enterprise_customer_configuration_id={enterprise_customer_configuration_id}" + f"data={data}" + ) + return data diff --git a/tests/test_integrated_channels/test_utils.py b/tests/test_integrated_channels/test_utils.py index 79264c9267..c7910be641 100644 --- a/tests/test_integrated_channels/test_utils.py +++ b/tests/test_integrated_channels/test_utils.py @@ -2,6 +2,7 @@ Tests for the utilities used by integration channels. """ +import json import unittest from collections import namedtuple from datetime import timedelta @@ -348,3 +349,36 @@ def test_truncate_item_dicts(self): assert len(out_a) == 1 assert len(out_b) == 0 assert len(out_c) == 7 + + @mock.patch("integrated_channels.utils.integrated_channel_request_log_model") + def test_stringify_and_store_api_record( + self, mock_integrated_channel_request_log_model + ): + data = {"key": "value"} + mock_integrated_channel_request_log_model.return_value = MagicMock() + + # Test with dict input + stringified_data = utils.stringify_and_store_api_record( + "Customer", 123, "/endpoint", data, 1.23, 200, "response" + ) + assert stringified_data == json.dumps(data) + + # Test with int input + stringified_int = utils.stringify_and_store_api_record( + "Customer", 123, "/endpoint", 123, 1.23, 200, "response" + ) + assert stringified_int == "123" + + # Test with tuple input + data_tuple = (1, 2, "hello") + stringified_tuple = utils.stringify_and_store_api_record( + "Customer", 123, "/endpoint", data_tuple, 1.23, 200, "response" + ) + assert stringified_tuple == json.dumps(data_tuple) + + # Test with list input + data_list = [1, 2, "world"] + stringified_list = utils.stringify_and_store_api_record( + "Customer", 123, "/endpoint", data_list, 1.23, 200, "response" + ) + assert stringified_list == json.dumps(data_list) From 0ae98216afa064830b4d7ed2d783b82a3f8327b4 Mon Sep 17 00:00:00 2001 From: MueezKhan246 <93375917+MueezKhan246@users.noreply.github.com> Date: Tue, 6 Feb 2024 15:42:02 +0500 Subject: [PATCH 117/164] Record canvas api calls in DB (#2009) * feat: update canvas client to store cornerstone API calls in db --- CHANGELOG.rst | 4 + enterprise/__init__.py | 2 +- integrated_channels/canvas/client.py | 135 +++++++++++++++++- integrated_channels/canvas/utils.py | 25 +++- .../integrated_channel/admin/__init__.py | 6 +- integrated_channels/utils.py | 35 ----- .../test_canvas/test_client.py | 42 +++++- 7 files changed, 205 insertions(+), 44 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 567a9be96b..5bd7d7ee8e 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -16,6 +16,10 @@ Change Log Unreleased ---------- +[4.11.7] +--------- +* feat: update canvas client to store API calls in DB + [4.11.6] --------- * feat: Added a flag for prequery search suggestions diff --git a/enterprise/__init__.py b/enterprise/__init__.py index c7aa312f0b..8f4b27e835 100644 --- a/enterprise/__init__.py +++ b/enterprise/__init__.py @@ -2,4 +2,4 @@ Your project description goes here. """ -__version__ = "4.11.6" +__version__ = "4.11.7" diff --git a/integrated_channels/canvas/client.py b/integrated_channels/canvas/client.py index d93199716c..277f9488fc 100644 --- a/integrated_channels/canvas/client.py +++ b/integrated_channels/canvas/client.py @@ -3,6 +3,7 @@ """ import json import logging +import time from http import HTTPStatus from urllib.parse import quote_plus, urljoin @@ -17,6 +18,7 @@ from integrated_channels.utils import ( # pylint: disable=cyclic-import generate_formatted_log, refresh_session_if_expired, + stringify_and_store_api_record, ) LOGGER = logging.getLogger(__name__) @@ -62,6 +64,9 @@ def __init__(self, enterprise_configuration): self.session = None self.expires_at = None self.course_create_url = CanvasUtil.course_create_endpoint(self.enterprise_configuration) + self.IntegratedChannelAPIRequestLogs = apps.get_model( + "integrated_channel", "IntegratedChannelAPIRequestLogs" + ) def create_content_metadata(self, serialized_data): """ @@ -309,7 +314,18 @@ def cleanup_duplicate_assignment_records(self, courses): # Continue iterating over assignment responses while more paginated results exist or until the page count # limit is hit while more_pages_present and current_page_count < 150: + start_time = time.time() resp = self.session.get(canvas_assignments_url) + duration_seconds = time.time() - start_time + self.IntegratedChannelAPIRequestLogs.store_api_call( + enterprise_customer=self.enterprise_configuration.enterprise_customer, + enterprise_customer_configuration_id=self.enterprise_configuration.id, + endpoint=canvas_assignments_url, + payload='', + time_taken=duration_seconds, + status_code=resp.status_code, + response_body=resp.text, + ) if resp.status_code >= 400: LOGGER.error( @@ -530,7 +546,19 @@ def _post(self, url, data): url (str): The url to send a POST request to. data (bytearray): The json encoded payload to POST. """ + start_time = time.time() post_response = self.session.post(url, data=data) + duration_seconds = time.time() - start_time + stringify_and_store_api_record( + enterprise_customer=self.enterprise_configuration.enterprise_customer, + enterprise_customer_configuration_id=self.enterprise_configuration.id, + endpoint=url, + data=data, + time_taken=duration_seconds, + status_code=post_response.status_code, + response_body=post_response.text, + ) + if post_response.status_code >= 400: raise ClientError(post_response.text, post_response.status_code) return post_response.status_code, post_response.text @@ -544,7 +572,18 @@ def _put(self, url, data): data (bytearray): The json encoded payload to UPDATE. This also contains the integration ID used to match a course with a course ID. """ + start_time = time.time() put_response = self.session.put(url, data=data) + duration_seconds = time.time() - start_time + stringify_and_store_api_record( + enterprise_customer=self.enterprise_configuration.enterprise_customer, + enterprise_customer_configuration_id=self.enterprise_configuration.id, + endpoint=url, + data=data, + time_taken=duration_seconds, + status_code=put_response.status_code, + response_body=put_response.text, + ) if put_response.status_code >= 400: raise ClientError(put_response.text, put_response.status_code) return put_response.status_code, put_response.text @@ -559,7 +598,19 @@ def _delete(self, url): Args: url (str): The canvas url to send delete requests to. """ - delete_response = self.session.delete(url, data='{"event":"conclude"}') + start_time = time.time() + data = '{"event":"conclude"}' + delete_response = self.session.delete(url, data=data) + duration_seconds = time.time() - start_time + stringify_and_store_api_record( + enterprise_customer=self.enterprise_configuration.enterprise_customer, + enterprise_customer_configuration_id=self.enterprise_configuration.id, + endpoint=url, + data=data, + time_taken=duration_seconds, + status_code=delete_response.status_code, + response_body=delete_response.text, + ) if delete_response.status_code >= 400: raise ClientError(delete_response.text, delete_response.status_code) return delete_response.status_code, delete_response.text @@ -609,7 +660,18 @@ def _search_for_canvas_user_by_email(self, user_email): path = f'/api/v1/accounts/{self.enterprise_configuration.canvas_account_id}/users' query_params = f'?search_term={quote_plus(user_email)}' # emails with unique symbols such as `+` cause issues get_user_id_from_email_url = urljoin(self.enterprise_configuration.canvas_base_url, path + query_params) + start_time = time.time() rsps = self.session.get(get_user_id_from_email_url) + duration_seconds = time.time() - start_time + self.IntegratedChannelAPIRequestLogs.store_api_call( + enterprise_customer=self.enterprise_configuration.enterprise_customer, + enterprise_customer_configuration_id=self.enterprise_configuration.id, + endpoint=get_user_id_from_email_url, + payload='', + time_taken=duration_seconds, + status_code=rsps.status_code, + response_body=rsps.text, + ) if rsps.status_code >= 400: raise ClientError( @@ -632,7 +694,18 @@ def _get_canvas_user_courses_by_id(self, user_id): """Helper method to retrieve all courses that a Canvas user is enrolled in.""" path = f'/api/v1/users/{user_id}/courses' get_users_courses_url = urljoin(self.enterprise_configuration.canvas_base_url, path) + start_time = time.time() rsps = self.session.get(get_users_courses_url) + duration_seconds = time.time() - start_time + self.IntegratedChannelAPIRequestLogs.store_api_call( + enterprise_customer=self.enterprise_configuration.enterprise_customer, + enterprise_customer_configuration_id=self.enterprise_configuration.id, + endpoint=get_users_courses_url, + payload='', + time_taken=duration_seconds, + status_code=rsps.status_code, + response_body=rsps.text, + ) if rsps.status_code >= 400: raise ClientError( @@ -665,7 +738,18 @@ def _handle_canvas_assignment_retrieval( """ # Check if the course assignment already exists canvas_assignments_url = CanvasUtil.course_assignments_endpoint(self.enterprise_configuration, course_id) + start_time = time.time() resp = self.session.get(canvas_assignments_url) + duration_seconds = time.time() - start_time + self.IntegratedChannelAPIRequestLogs.store_api_call( + enterprise_customer=self.enterprise_configuration.enterprise_customer, + enterprise_customer_configuration_id=self.enterprise_configuration.id, + endpoint=canvas_assignments_url, + payload='', + time_taken=duration_seconds, + status_code=resp.status_code, + response_body=resp.text, + ) more_pages_present = True current_page_count = 0 @@ -701,7 +785,19 @@ def _handle_canvas_assignment_retrieval( if not assignment_id: next_page = CanvasUtil.determine_next_results_page(resp) if next_page: + start_time = time.time() resp = self.session.get(next_page) + duration_seconds = time.time() - start_time + self.IntegratedChannelAPIRequestLogs.store_api_call( + enterprise_customer=self.enterprise_configuration.enterprise_customer, + enterprise_customer_configuration_id=self.enterprise_configuration.id, + endpoint=next_page, + payload='', + time_taken=duration_seconds, + status_code=resp.status_code, + response_body=resp.text, + ) + current_page_count += 1 else: more_pages_present = False @@ -721,7 +817,18 @@ def _handle_canvas_assignment_retrieval( 'omit_from_final_grade': is_assessment_grade, } } + start_time = time.time() create_assignment_resp = self.session.post(canvas_assignments_url, json=assignment_creation_data) + duration_seconds = time.time() - start_time + stringify_and_store_api_record( + enterprise_customer=self.enterprise_configuration.enterprise_customer, + enterprise_customer_configuration_id=self.enterprise_configuration.id, + endpoint=canvas_assignments_url, + data=assignment_creation_data, + time_taken=duration_seconds, + status_code=resp.status_code, + response_body=resp.text, + ) try: assignment_id = create_assignment_resp.json()['id'] @@ -747,7 +854,18 @@ def _handle_canvas_assignment_submission(self, grade, course_id, assignment_id, 'posted_grade': grade } } + start_time = time.time() submission_response = self.session.put(submission_url, json=submission_data) + duration_seconds = time.time() - start_time + stringify_and_store_api_record( + enterprise_customer=self.enterprise_configuration.enterprise_customer, + enterprise_customer_configuration_id=self.enterprise_configuration.id, + endpoint=submission_url, + data=submission_data, + time_taken=duration_seconds, + status_code=submission_response.status_code, + response_body=submission_response.text, + ) if submission_response.status_code >= 400: raise ClientError( @@ -846,7 +964,22 @@ def _get_oauth_access_token(self): 'refresh_token': self.enterprise_configuration.refresh_token, } + start_time = time.time() auth_response = requests.post(auth_token_url, auth_token_params) + # Combine the base URL and parameters to form the complete URL + complete_url = "{}?{}".format( + auth_token_url, "&".join(f"{key}={value}" for key, value in auth_token_params.items()) + ) + duration_seconds = time.time() - start_time + self.IntegratedChannelAPIRequestLogs.store_api_call( + enterprise_customer=self.enterprise_configuration.enterprise_customer, + enterprise_customer_configuration_id=self.enterprise_configuration.id, + endpoint=complete_url, + payload='', + time_taken=duration_seconds, + status_code=auth_response.status_code, + response_body=auth_response.text, + ) if auth_response.status_code >= 400: raise ClientError(auth_response.text, auth_response.status_code) try: diff --git a/integrated_channels/canvas/utils.py b/integrated_channels/canvas/utils.py index 2c51a7439b..4a5321cfe8 100644 --- a/integrated_channels/canvas/utils.py +++ b/integrated_channels/canvas/utils.py @@ -1,12 +1,13 @@ '''Collection of static util methods for various Canvas operations''' import logging +import time from http import HTTPStatus from urllib.parse import urljoin from requests.utils import quote from integrated_channels.exceptions import ClientError -from integrated_channels.utils import generate_formatted_log +from integrated_channels.utils import generate_formatted_log, integrated_channel_request_log_model LOGGER = logging.getLogger(__name__) @@ -42,7 +43,18 @@ def find_root_canvas_account(enterprise_configuration, session): If root account cannot be found, returns None """ url = urljoin(enterprise_configuration.canvas_base_url, "/api/v1/accounts") + start_time = time.time() resp = session.get(url) + duration_seconds = time.time() - start_time + integrated_channel_request_log_model().store_api_call( + enterprise_customer=enterprise_configuration.enterprise_customer, + enterprise_customer_configuration_id=enterprise_configuration.id, + endpoint=url, + payload='', + time_taken=duration_seconds, + status_code=resp.status_code, + response_body=resp.text, + ) all_accounts = resp.json() root_account = None for account in all_accounts: @@ -74,7 +86,18 @@ def find_course_in_account(enterprise_configuration, session, canvas_account_id, """ path = f"/api/v1/accounts/{canvas_account_id}/courses/?search_term={quote(edx_course_id)}&state[]=all" url = urljoin(enterprise_configuration.canvas_base_url, path) + start_time = time.time() resp = session.get(url) + duration_seconds = time.time() - start_time + integrated_channel_request_log_model().store_api_call( + enterprise_customer=enterprise_configuration.enterprise_customer, + enterprise_customer_configuration_id=enterprise_configuration.id, + endpoint=url, + payload='', + time_taken=duration_seconds, + status_code=resp.status_code, + response_body=resp.text, + ) all_courses_response = resp.json() if resp.status_code >= 400: diff --git a/integrated_channels/integrated_channel/admin/__init__.py b/integrated_channels/integrated_channel/admin/__init__.py index 27dd98e384..f79b1659bb 100644 --- a/integrated_channels/integrated_channel/admin/__init__.py +++ b/integrated_channels/integrated_channel/admin/__init__.py @@ -4,11 +4,7 @@ from django.contrib import admin -from integrated_channels.integrated_channel.models import ( - ApiResponseRecord, - ContentMetadataItemTransmission, - IntegratedChannelAPIRequestLogs, -) +from integrated_channels.integrated_channel.models import ApiResponseRecord, ContentMetadataItemTransmission from integrated_channels.utils import get_enterprise_customer_from_enterprise_enrollment diff --git a/integrated_channels/utils.py b/integrated_channels/utils.py index b14bcffbef..9c8a273128 100644 --- a/integrated_channels/utils.py +++ b/integrated_channels/utils.py @@ -510,41 +510,6 @@ def get_enterprise_client_by_channel_code(channel_code): return _enterprise_client_model_by_channel_code[channel_code] -def store_api_call( - enterprise_customer, - enterprise_customer_configuration_id, - endpoint, - payload, - time_taken, - status_code, - response_body, -): - """ - Creates new record in CornerstoneAPIRequestLogs table. - """ - try: - integrated_channel_request_log_model().objects.create( - enterprise_customer=enterprise_customer, - enterprise_customer_configuration_id=enterprise_customer_configuration_id, - endpoint=endpoint, - payload=payload, - time_taken=time_taken, - status_code=status_code, - response_body=response_body, - ) - except Exception as e: # pylint: disable=broad-except - LOGGER.error( - f"store_api_call raised error while storing API call: {e}" - f"enterprise_customer={enterprise_customer}" - f"enterprise_customer_configuration_id={enterprise_customer_configuration_id}," - f"endpoint={endpoint}" - f"payload={payload}" - f"time_taken={time_taken}" - f"status_code={status_code}" - f"response_body={response_body}" - ) - - def stringify_and_store_api_record( enterprise_customer, enterprise_customer_configuration_id, diff --git a/tests/test_integrated_channels/test_canvas/test_client.py b/tests/test_integrated_channels/test_canvas/test_client.py index a7944aaf81..278206286d 100644 --- a/tests/test_integrated_channels/test_canvas/test_client.py +++ b/tests/test_integrated_channels/test_canvas/test_client.py @@ -14,12 +14,17 @@ from freezegun import freeze_time from requests.models import Response +from django.apps import apps + from integrated_channels.canvas.client import MESSAGE_WHEN_COURSE_WAS_DELETED, CanvasAPIClient from integrated_channels.canvas.utils import CanvasUtil from integrated_channels.exceptions import ClientError from integrated_channels.integrated_channel.client import IntegratedChannelHealthStatus from test_utils import factories +IntegratedChannelAPIRequestLogs = apps.get_model( + "integrated_channel", "IntegratedChannelAPIRequestLogs" +) NOW = datetime.datetime(2017, 1, 2, 3, 4, 5, tzinfo=datetime.timezone.utc) NOW_TIMESTAMP_FORMATTED = NOW.strftime('%F') @@ -181,6 +186,7 @@ def test_search_for_canvas_user_with_400(self): with pytest.raises(ClientError) as client_error: canvas_api_client._search_for_canvas_user_by_email(self.canvas_email) # pylint: disable=protected-access + assert IntegratedChannelAPIRequestLogs.objects.count() == 2 assert client_error.value.message == \ "Course: {course_id} not found registered in Canvas for Edx " \ "learner: {canvas_email}/Canvas learner: {canvas_user_id}.".format( @@ -213,6 +219,7 @@ def test_assessment_reporting_with_no_canvas_course_found(self): with pytest.raises(ClientError) as client_error: canvas_api_client._handle_get_user_canvas_course(self.canvas_user_id, self.course_id) # pylint: disable=protected-access + assert IntegratedChannelAPIRequestLogs.objects.count() == 2 assert client_error.value.message == \ "Course: {course_id} not found registered in Canvas for Edx " \ "learner: {canvas_email}/Canvas learner: {canvas_user_id}.".format( @@ -250,6 +257,7 @@ def test_grade_reporting_get_assignment_500s(self): self.canvas_course_id, 'assignment_name' ) + assert IntegratedChannelAPIRequestLogs.objects.count() == 2 assert client_error.value.message == 'Something went wrong retrieving assignments from Canvas. Got' \ ' response: {"error": "something went wrong"}' @@ -333,6 +341,7 @@ def test_grade_reporting_post_submission_500s(self): self.canvas_assignment_id, self.canvas_user_id ) + assert IntegratedChannelAPIRequestLogs.objects.count() == 2 assert client_error.value.message == ( 'Something went wrong while posting a submission to Canvas ' 'assignment: {} under Canvas course: {}. Recieved response ' @@ -375,6 +384,7 @@ def test_assessment_level_reporting_omits_from_final_grade(self): assert canvas_api_client._handle_canvas_assignment_retrieval.mock_calls[0].kwargs[ # pylint: disable=protected-access 'is_assessment_grade' ] + assert IntegratedChannelAPIRequestLogs.objects.count() == 1 def test_completion_level_reporting_included_in_final_grade(self): with responses.RequestsMock() as rsps: @@ -540,6 +550,7 @@ def test_existing_course_is_ignored_if_deleted(self, mock_find_course_by_course_ ) status_code, response_text = canvas_api_client.create_content_metadata(course_to_create) + assert IntegratedChannelAPIRequestLogs.objects.count() == 1 assert status_code == 200 assert response_text == MESSAGE_WHEN_COURSE_WAS_DELETED @@ -606,7 +617,7 @@ def test_assignment_retrieval_pagination(self): self.canvas_course_id, 'Test Assignment' ) - + assert IntegratedChannelAPIRequestLogs.objects.count() == 4 assert canvas_assignment == 1 @mock.patch.object(CanvasUtil, 'find_course_by_course_id') @@ -691,6 +702,7 @@ def test_successful_assignment_dedup(self, mock_find_course_by_course_id): canvas_api_client._create_session() # pylint: disable=protected-access code, body = canvas_api_client.cleanup_duplicate_assignment_records([self.course_id, self.course_id_2]) + assert IntegratedChannelAPIRequestLogs.objects.count() == 3 assert code == 200 assert body == "Removed 3 duplicate assignments from Canvas." @@ -741,6 +753,7 @@ def test_assignment_dedup_partial_failure(self, mock_find_course_by_course_id): ) canvas_api_client._create_session() # pylint: disable=protected-access code, body = canvas_api_client.cleanup_duplicate_assignment_records([self.course_id, self.course_id_2]) + assert IntegratedChannelAPIRequestLogs.objects.count() == 2 assert code == 400 assert body == ( "Failed to dedup all assignments for the following courses: ['{}']. Number of individual assignments " @@ -852,6 +865,7 @@ def test_successful_bulk_remove_course_assignments(self): [self.canvas_assignment_id, self.canvas_assignment_id_2] ) + assert IntegratedChannelAPIRequestLogs.objects.count() == 3 assert len(assignments_removed) == 2 assert self.canvas_assignment_id_2 in assignments_removed assert self.canvas_assignment_id in assignments_removed @@ -885,6 +899,7 @@ def test_bulk_remove_course_assignments_partial_failure(self): self.canvas_course_id, [self.canvas_assignment_id, self.canvas_assignment_id_2] ) + assert IntegratedChannelAPIRequestLogs.objects.count() == 3 assert self.canvas_assignment_id in failed_assignments assert len(failed_assignments) == 1 assert len(assignments_removed) == 1 @@ -925,6 +940,7 @@ def test_create_course_success_with_image_url(self, mock_find_course_by_course_i status=200 ) status_code, response_text = canvas_api_client.create_content_metadata(course_to_create) + assert IntegratedChannelAPIRequestLogs.objects.count() == 3 assert status_code == 201 assert response_text == expected_resp @@ -964,6 +980,8 @@ def test_course_delete_fails_when_course_id_not_found(self, mock_find_course_by_ ) canvas_api_client.delete_content_metadata(course_to_update) + assert IntegratedChannelAPIRequestLogs.objects.count() == 1 + assert client_error.value.message == 'No Canvas courses found with associated edx course ID: {}.'.format( self.integration_id ) @@ -993,6 +1011,7 @@ def test_course_update_creates_when_course_id_not_found(self, mock_find_course_b status=200 ) status, response = canvas_api_client.update_content_metadata(course_to_update) + assert IntegratedChannelAPIRequestLogs.objects.count() == 2 assert status == 200 assert response == mocked_create_messaged @@ -1059,6 +1078,7 @@ def test_successful_client_update(self): body=b'Mock update response text' ) canvas_api_client.update_content_metadata(course_to_update) + assert IntegratedChannelAPIRequestLogs.objects.count() == 3 @mock.patch('integrated_channels.canvas.client.refresh_session_if_expired', lambda x: ('mock session', 30)) def test_health_check_healthy(self): @@ -1150,3 +1170,23 @@ def transmission_with_empty_data(self, request_type): transmitter_method(empty_data) assert client_error.value.message == 'No data to transmit.' + + @responses.activate + def test_canvas_api_client_get_oauth_access_token(self): + """ + Test _get_oauth_access_token method's correct handling of OAuth token response. + """ + canvas_api_client = CanvasAPIClient(self.enterprise_config) + responses.add( + responses.POST, + self.oauth_url, + json=self._token_response(), + status=200, + ) + assert IntegratedChannelAPIRequestLogs.objects.count() == 0 + output = canvas_api_client._get_oauth_access_token() # pylint: disable=protected-access + + assert IntegratedChannelAPIRequestLogs.objects.count() == 1 + assert len(responses.calls) == 1 + token_response = self._token_response() + assert output == (token_response["access_token"], token_response["expires_in"]) From 7787131775699ac54ab5d4af2489d11e4fa46275 Mon Sep 17 00:00:00 2001 From: hamzawaleed01 Date: Tue, 6 Feb 2024 16:39:11 +0500 Subject: [PATCH 118/164] feat: register admin view for IntegratedChannelAPIRequestLogs --- CHANGELOG.rst | 4 ++++ enterprise/__init__.py | 2 +- .../integrated_channel/admin/__init__.py | 16 +++++++++++++++- 3 files changed, 20 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 5bd7d7ee8e..38246d2d13 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -16,6 +16,10 @@ Change Log Unreleased ---------- +[4.11.8] +--------- +* feat: register admin view for IntegratedChannelAPIRequestLogs + [4.11.7] --------- * feat: update canvas client to store API calls in DB diff --git a/enterprise/__init__.py b/enterprise/__init__.py index 8f4b27e835..67cc0baf51 100644 --- a/enterprise/__init__.py +++ b/enterprise/__init__.py @@ -2,4 +2,4 @@ Your project description goes here. """ -__version__ = "4.11.7" +__version__ = "4.11.8" diff --git a/integrated_channels/integrated_channel/admin/__init__.py b/integrated_channels/integrated_channel/admin/__init__.py index f79b1659bb..960b49c182 100644 --- a/integrated_channels/integrated_channel/admin/__init__.py +++ b/integrated_channels/integrated_channel/admin/__init__.py @@ -4,7 +4,11 @@ from django.contrib import admin -from integrated_channels.integrated_channel.models import ApiResponseRecord, ContentMetadataItemTransmission +from integrated_channels.integrated_channel.models import ( + ApiResponseRecord, + ContentMetadataItemTransmission, + IntegratedChannelAPIRequestLogs, +) from integrated_channels.utils import get_enterprise_customer_from_enterprise_enrollment @@ -85,3 +89,13 @@ class ApiResponseRecordAdmin(admin.ModelAdmin): ) list_per_page = 1000 + + +@admin.register(IntegratedChannelAPIRequestLogs) +class CornerstoneAPIRequestLogAdmin(admin.ModelAdmin): + """ + Django admin model for IntegratedChannelAPIRequestLogs. + """ + + class Meta: + model = IntegratedChannelAPIRequestLogs From 66473aa911a349df56474339643314c3d4bfdcc7 Mon Sep 17 00:00:00 2001 From: Brian Beggs Date: Tue, 6 Feb 2024 10:55:00 -0500 Subject: [PATCH 119/164] chore: Update package version to 4.11.8 --- CHANGELOG.rst | 4 ++++ enterprise/__init__.py | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 5bd7d7ee8e..b0ce396aa4 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -16,6 +16,10 @@ Change Log Unreleased ---------- +[4.11.8] +--------- +* chore: Update requirements + [4.11.7] --------- * feat: update canvas client to store API calls in DB diff --git a/enterprise/__init__.py b/enterprise/__init__.py index 8f4b27e835..67cc0baf51 100644 --- a/enterprise/__init__.py +++ b/enterprise/__init__.py @@ -2,4 +2,4 @@ Your project description goes here. """ -__version__ = "4.11.7" +__version__ = "4.11.8" From af931c1af167911e22100ec313061891d7a2dfa8 Mon Sep 17 00:00:00 2001 From: Kira Miller <31229189+kiram15@users.noreply.github.com> Date: Thu, 8 Feb 2024 09:40:30 -0700 Subject: [PATCH 120/164] feat: adding groups adr (#2014) * feat: adding groups adr * fix: PR requests * fix: more pr requests --- ...-custom-groups-for-enterprise-learners.rst | 139 ++++++++++++++++++ 1 file changed, 139 insertions(+) create mode 100644 docs/decisions/0013-custom-groups-for-enterprise-learners.rst diff --git a/docs/decisions/0013-custom-groups-for-enterprise-learners.rst b/docs/decisions/0013-custom-groups-for-enterprise-learners.rst new file mode 100644 index 0000000000..c28c2c488b --- /dev/null +++ b/docs/decisions/0013-custom-groups-for-enterprise-learners.rst @@ -0,0 +1,139 @@ +0013 Creating Custom Groups for Enterprise Customers +############################## + +Status +****** + +**Accepted** + +Terminology +******* +*Enterprise Group* - Collection of enterprise customer users + +*Membership* - Relationship between enterprise learner and enterprise group + +Context +******* + +Groups is a new feature that will allow admins further customization and control of their learners. At a high level, a group is a subset of learners within an organization that can be tied to an enterprise customer's policy. A customer can have multiple policies and groups, and a learner can be in multiple groups. These changes will improve the user management and budgeting from the admin perspective, as well as adding personalization to the learner experience. In the past, we’ve used workarounds to sidestep this functionality, but with this new implementation, we will increase scalability and personalization. + +In this MVP, groups will be associated with a learner credit budget, though they are something that exists independently. Groups will allow for analytics segmentation, bulk actions, access control, and more throughout the admin experience. + +EnterpriseGroup +********************* +**Model properties** +------ +- uuid, name, created, modified, history (boilerplate) +- enterprise_customer_uuid (NOT NULL, FK to EnterpriseCustomer, related_name=”groups”) + +**CRUD** +------ +**api/v1/enterprise-group/** + +Outputs +========== +The root URL for getting the basic information about the group +:: + { + 'group_uuid': 'group_uuid', + 'name': 'group_name', + 'enterprise_customer_uuid': 'enterprise_customer_uuid', + } + + +**api/v1/enterprise-group/?learner_uuid=[enterprise_customer_user_id]&enterprise_uuid=[enterprise_uuid]** + +Accepted Query Params +========== +- ``learner_uuid`` (optional): Get all the groups that the learner is associated with +- ``enterprise_uuid`` (optional): Get all the groups under the enterprise + +Outputs +========== +Returns a paginated list of groups filtered by the query params +:: + { + 'count': 1, + 'next': null, + 'previous': null, + 'results': [ + { + 'group_uuid': 'group_uuid', + 'name': 'group_name', + 'enterprise_customer_uuid': 'enterprise_customer_uuid', + } + ] + } + + +**GET (list) /learners** +------ +**api/v1/enterprise-group//learners/** + +Outputs +========== +Returns a paginated list of learners that are associated with the enterprise group uuid +:: + { + 'count': 1, + 'next': null, + 'previous': null, + 'results': [ + { + 'learner_uuid': 'enterprise_customer_user_id', + 'enterprise_group_membership_uuid': 'enterprise_group_membership_uuid', + } + ] + } + + +**POST /assign_learners** +------ +**api/v1/enterprise-group//assign_learners** + +Inputs +========== +- ``learner_uuids`` (POST data, required): A list of enterprise_customer_user_ids to assign to the group + +Outputs +========== +Returns a list of the EnterpriseGroupMembership objects that were created +:: + { + 'count': 1, + 'next': null, + 'previous': null, + 'results': [ + { + 'learner_uuid': 'enterprise_customer_user_id', + 'enterprise_group_membership_uuid': 'enterprise_group_membership_uuid', + } + ] + } + + +**POST /remove_learners** +------ +**api/v1/enterprise-group//remove_learners** + +Inputs +========== +- ``learner_uuids`` (POST data, required): A list of enterprise_customer_user_ids to assign to the group + + +EnterpriseGroupMembership +********************* +**Model properties** +------ +- uuid, created, modified, history (boilerplate) +- group (NOT NULL, FK to EnterpriseGroup with related name ``members``) +- enterprise_customer_user_id (FK to EnterpriseCustomerUser with related_name of ``memberships``) +- pending_enterprise_customer_user_id (FK to PendingEnterpriseCustomerUser with related_name of ``pending_memberships``) + +Consequences +********************* +Now with the implementation of groups, this will be another facet that we will filter on. Now, not all learners under organizations necessarily have equal access to content. These subsets will provide a more personalized experience for the learner, and more control for the admin. + +Further Improvements +********************* +Groups will have analytics, learning goals, and other customizations associated with them in the future From d71e9298d60470b0165b5642e7a2d23e35cffe1b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ehmad=20Saeed=E2=9A=A1?= Date: Mon, 12 Feb 2024 14:34:35 +0500 Subject: [PATCH 121/164] [ENT-8008] feat: add unique constraint on learner transmission audit models (#2016) * feat: add unique constraint on learner transmission audit models * chore: fix class name by Hamza * fix: read the docs indentation errors --- CHANGELOG.rst | 4 + ...-custom-groups-for-enterprise-learners.rst | 24 +++--- enterprise/__init__.py | 2 +- ..._blackboard_unique_enrollment_course_id.py | 17 ++++ integrated_channels/blackboard/models.py | 6 ++ ...udit_canvas_unique_enrollment_course_id.py | 17 ++++ integrated_channels/canvas/models.py | 6 ++ ...it_degreed2_unique_enrollment_course_id.py | 17 ++++ integrated_channels/degreed2/models.py | 6 ++ .../integrated_channel/admin/__init__.py | 2 +- ...udit_moodle_unique_enrollment_course_id.py | 17 ++++ integrated_channels/moodle/models.py | 6 ++ ...onaudit_sap_unique_enrollment_course_id.py | 17 ++++ .../sap_success_factors/models.py | 6 ++ .../test_exporters/test_learner_data.py | 21 +++++ .../test_exporters/test_learner_data.py | 42 ++++++++-- .../test_exporters/test_learner_data.py | 22 +++++ .../test_exporters/test_learner_data.py | 30 +++++++ .../test_exporters/test_learner_data.py | 83 ++++++++++++++----- 19 files changed, 304 insertions(+), 41 deletions(-) create mode 100644 integrated_channels/blackboard/migrations/0018_blackboardlearnerdatatransmissionaudit_blackboard_unique_enrollment_course_id.py create mode 100644 integrated_channels/canvas/migrations/0033_canvaslearnerdatatransmissionaudit_canvas_unique_enrollment_course_id.py create mode 100644 integrated_channels/degreed2/migrations/0024_degreed2learnerdatatransmissionaudit_degreed2_unique_enrollment_course_id.py create mode 100644 integrated_channels/moodle/migrations/0032_moodlelearnerdatatransmissionaudit_moodle_unique_enrollment_course_id.py create mode 100644 integrated_channels/sap_success_factors/migrations/0015_sapsuccessfactorslearnerdatatransmissionaudit_sap_unique_enrollment_course_id.py diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 91e68a5aaf..78385a3241 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -16,6 +16,10 @@ Change Log Unreleased ---------- +[4.11.10] +--------- +* feat: add unique constraint on learner data transmission audit models + [4.11.9] --------- * feat: register admin view for IntegratedChannelAPIRequestLogs diff --git a/docs/decisions/0013-custom-groups-for-enterprise-learners.rst b/docs/decisions/0013-custom-groups-for-enterprise-learners.rst index c28c2c488b..17c63f3d97 100644 --- a/docs/decisions/0013-custom-groups-for-enterprise-learners.rst +++ b/docs/decisions/0013-custom-groups-for-enterprise-learners.rst @@ -1,5 +1,5 @@ 0013 Creating Custom Groups for Enterprise Customers -############################## +#################################################### Status ****** @@ -7,7 +7,7 @@ Status **Accepted** Terminology -******* +*********** *Enterprise Group* - Collection of enterprise customer users *Membership* - Relationship between enterprise learner and enterprise group @@ -22,18 +22,19 @@ In this MVP, groups will be associated with a learner credit budget, though they EnterpriseGroup ********************* **Model properties** ------- +-------------------- - uuid, name, created, modified, history (boilerplate) - enterprise_customer_uuid (NOT NULL, FK to EnterpriseCustomer, related_name=”groups”) **CRUD** ------- +-------- **api/v1/enterprise-group/** Outputs ========== The root URL for getting the basic information about the group :: + { 'group_uuid': 'group_uuid', 'name': 'group_name', @@ -44,7 +45,7 @@ The root URL for getting the basic information about the group **api/v1/enterprise-group/?learner_uuid=[enterprise_customer_user_id]&enterprise_uuid=[enterprise_uuid]** Accepted Query Params -========== +===================== - ``learner_uuid`` (optional): Get all the groups that the learner is associated with - ``enterprise_uuid`` (optional): Get all the groups under the enterprise @@ -52,6 +53,7 @@ Outputs ========== Returns a paginated list of groups filtered by the query params :: + { 'count': 1, 'next': null, @@ -67,13 +69,14 @@ Returns a paginated list of groups filtered by the query params **GET (list) /learners** ------- +------------------------ **api/v1/enterprise-group//learners/** Outputs ========== Returns a paginated list of learners that are associated with the enterprise group uuid :: + { 'count': 1, 'next': null, @@ -88,7 +91,7 @@ Returns a paginated list of learners that are associated with the enterprise gro **POST /assign_learners** ------- +------------------------- **api/v1/enterprise-group//assign_learners** Inputs @@ -99,6 +102,7 @@ Outputs ========== Returns a list of the EnterpriseGroupMembership objects that were created :: + { 'count': 1, 'next': null, @@ -113,7 +117,7 @@ Returns a list of the EnterpriseGroupMembership objects that were created **POST /remove_learners** ------- +------------------------- **api/v1/enterprise-group//remove_learners** Inputs @@ -122,9 +126,9 @@ Inputs EnterpriseGroupMembership -********************* +************************* **Model properties** ------- +-------------------- - uuid, created, modified, history (boilerplate) - group (NOT NULL, FK to EnterpriseGroup with related name ``members``) - enterprise_customer_user_id (FK to EnterpriseCustomerUser with related_name of ``memberships``) diff --git a/enterprise/__init__.py b/enterprise/__init__.py index bb0503c3c7..1021220ab0 100644 --- a/enterprise/__init__.py +++ b/enterprise/__init__.py @@ -2,4 +2,4 @@ Your project description goes here. """ -__version__ = "4.11.9" +__version__ = "4.11.10" diff --git a/integrated_channels/blackboard/migrations/0018_blackboardlearnerdatatransmissionaudit_blackboard_unique_enrollment_course_id.py b/integrated_channels/blackboard/migrations/0018_blackboardlearnerdatatransmissionaudit_blackboard_unique_enrollment_course_id.py new file mode 100644 index 0000000000..8a8758010d --- /dev/null +++ b/integrated_channels/blackboard/migrations/0018_blackboardlearnerdatatransmissionaudit_blackboard_unique_enrollment_course_id.py @@ -0,0 +1,17 @@ +# Generated by Django 3.2.22 on 2024-02-06 12:25 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('blackboard', '0017_alter_historicalblackboardenterprisecustomerconfiguration_options'), + ] + + operations = [ + migrations.AddConstraint( + model_name='blackboardlearnerdatatransmissionaudit', + constraint=models.UniqueConstraint(fields=('enterprise_course_enrollment_id', 'course_id'), name='blackboard_unique_enrollment_course_id'), + ), + ] diff --git a/integrated_channels/blackboard/models.py b/integrated_channels/blackboard/models.py index b0c613cf35..c48c9e052c 100644 --- a/integrated_channels/blackboard/models.py +++ b/integrated_channels/blackboard/models.py @@ -332,6 +332,12 @@ class BlackboardLearnerDataTransmissionAudit(LearnerDataTransmissionAudit): class Meta: app_label = 'blackboard' + constraints = [ + models.UniqueConstraint( + fields=['enterprise_course_enrollment_id', 'course_id'], + name='blackboard_unique_enrollment_course_id' + ) + ] index_together = ['enterprise_customer_uuid', 'plugin_configuration_id'] def __str__(self): diff --git a/integrated_channels/canvas/migrations/0033_canvaslearnerdatatransmissionaudit_canvas_unique_enrollment_course_id.py b/integrated_channels/canvas/migrations/0033_canvaslearnerdatatransmissionaudit_canvas_unique_enrollment_course_id.py new file mode 100644 index 0000000000..b9af213ea7 --- /dev/null +++ b/integrated_channels/canvas/migrations/0033_canvaslearnerdatatransmissionaudit_canvas_unique_enrollment_course_id.py @@ -0,0 +1,17 @@ +# Generated by Django 3.2.22 on 2024-02-06 11:14 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('canvas', '0032_alter_historicalcanvasenterprisecustomerconfiguration_options'), + ] + + operations = [ + migrations.AddConstraint( + model_name='canvaslearnerdatatransmissionaudit', + constraint=models.UniqueConstraint(fields=('enterprise_course_enrollment_id', 'course_id'), name='canvas_unique_enrollment_course_id'), + ), + ] diff --git a/integrated_channels/canvas/models.py b/integrated_channels/canvas/models.py index 602bcc0728..b402566ff8 100644 --- a/integrated_channels/canvas/models.py +++ b/integrated_channels/canvas/models.py @@ -284,6 +284,12 @@ class CanvasLearnerDataTransmissionAudit(LearnerDataTransmissionAudit): class Meta: app_label = 'canvas' + constraints = [ + models.UniqueConstraint( + fields=['enterprise_course_enrollment_id', 'course_id'], + name='canvas_unique_enrollment_course_id' + ) + ] index_together = ['enterprise_customer_uuid', 'plugin_configuration_id'] def __str__(self): diff --git a/integrated_channels/degreed2/migrations/0024_degreed2learnerdatatransmissionaudit_degreed2_unique_enrollment_course_id.py b/integrated_channels/degreed2/migrations/0024_degreed2learnerdatatransmissionaudit_degreed2_unique_enrollment_course_id.py new file mode 100644 index 0000000000..d4c495e95d --- /dev/null +++ b/integrated_channels/degreed2/migrations/0024_degreed2learnerdatatransmissionaudit_degreed2_unique_enrollment_course_id.py @@ -0,0 +1,17 @@ +# Generated by Django 3.2.22 on 2024-02-06 09:32 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('degreed2', '0023_alter_historicaldegreed2enterprisecustomerconfiguration_options'), + ] + + operations = [ + migrations.AddConstraint( + model_name='degreed2learnerdatatransmissionaudit', + constraint=models.UniqueConstraint(fields=('enterprise_course_enrollment_id', 'course_id'), name='degreed2_unique_enrollment_course_id'), + ), + ] diff --git a/integrated_channels/degreed2/models.py b/integrated_channels/degreed2/models.py index 11336309d7..a83ef240d4 100644 --- a/integrated_channels/degreed2/models.py +++ b/integrated_channels/degreed2/models.py @@ -177,6 +177,12 @@ class Degreed2LearnerDataTransmissionAudit(LearnerDataTransmissionAudit): class Meta: app_label = 'degreed2' + constraints = [ + models.UniqueConstraint( + fields=['enterprise_course_enrollment_id', 'course_id'], + name='degreed2_unique_enrollment_course_id' + ) + ] index_together = ['enterprise_customer_uuid', 'plugin_configuration_id'] def __str__(self): diff --git a/integrated_channels/integrated_channel/admin/__init__.py b/integrated_channels/integrated_channel/admin/__init__.py index 960b49c182..37c5e26c5a 100644 --- a/integrated_channels/integrated_channel/admin/__init__.py +++ b/integrated_channels/integrated_channel/admin/__init__.py @@ -92,7 +92,7 @@ class ApiResponseRecordAdmin(admin.ModelAdmin): @admin.register(IntegratedChannelAPIRequestLogs) -class CornerstoneAPIRequestLogAdmin(admin.ModelAdmin): +class IntegratedChannelAPIRequestLogAdmin(admin.ModelAdmin): """ Django admin model for IntegratedChannelAPIRequestLogs. """ diff --git a/integrated_channels/moodle/migrations/0032_moodlelearnerdatatransmissionaudit_moodle_unique_enrollment_course_id.py b/integrated_channels/moodle/migrations/0032_moodlelearnerdatatransmissionaudit_moodle_unique_enrollment_course_id.py new file mode 100644 index 0000000000..eb9ff9acf0 --- /dev/null +++ b/integrated_channels/moodle/migrations/0032_moodlelearnerdatatransmissionaudit_moodle_unique_enrollment_course_id.py @@ -0,0 +1,17 @@ +# Generated by Django 3.2.22 on 2024-02-06 09:32 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('moodle', '0031_auto_20240117_1202'), + ] + + operations = [ + migrations.AddConstraint( + model_name='moodlelearnerdatatransmissionaudit', + constraint=models.UniqueConstraint(fields=('enterprise_course_enrollment_id', 'course_id'), name='moodle_unique_enrollment_course_id'), + ), + ] diff --git a/integrated_channels/moodle/models.py b/integrated_channels/moodle/models.py index 61c15b360a..d12f017e50 100644 --- a/integrated_channels/moodle/models.py +++ b/integrated_channels/moodle/models.py @@ -271,6 +271,12 @@ class MoodleLearnerDataTransmissionAudit(LearnerDataTransmissionAudit): class Meta: app_label = 'moodle' + constraints = [ + models.UniqueConstraint( + fields=['enterprise_course_enrollment_id', 'course_id'], + name='moodle_unique_enrollment_course_id' + ) + ] index_together = ['enterprise_customer_uuid', 'plugin_configuration_id'] def __str__(self): diff --git a/integrated_channels/sap_success_factors/migrations/0015_sapsuccessfactorslearnerdatatransmissionaudit_sap_unique_enrollment_course_id.py b/integrated_channels/sap_success_factors/migrations/0015_sapsuccessfactorslearnerdatatransmissionaudit_sap_unique_enrollment_course_id.py new file mode 100644 index 0000000000..de54f4376e --- /dev/null +++ b/integrated_channels/sap_success_factors/migrations/0015_sapsuccessfactorslearnerdatatransmissionaudit_sap_unique_enrollment_course_id.py @@ -0,0 +1,17 @@ +# Generated by Django 3.2.22 on 2024-02-06 09:32 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('sap_success_factors', '0014_alter_sapsuccessfactorsenterprisecustomerconfiguration_show_course_price'), + ] + + operations = [ + migrations.AddConstraint( + model_name='sapsuccessfactorslearnerdatatransmissionaudit', + constraint=models.UniqueConstraint(fields=('enterprise_course_enrollment_id', 'course_id'), name='sap_unique_enrollment_course_id'), + ), + ] diff --git a/integrated_channels/sap_success_factors/models.py b/integrated_channels/sap_success_factors/models.py index b0b6330bc8..9956f95103 100644 --- a/integrated_channels/sap_success_factors/models.py +++ b/integrated_channels/sap_success_factors/models.py @@ -289,6 +289,12 @@ class SapSuccessFactorsLearnerDataTransmissionAudit(LearnerDataTransmissionAudit class Meta: app_label = 'sap_success_factors' + constraints = [ + models.UniqueConstraint( + fields=['enterprise_course_enrollment_id', 'course_id'], + name='sap_unique_enrollment_course_id' + ) + ] index_together = ['enterprise_customer_uuid', 'plugin_configuration_id'] def __str__(self): diff --git a/tests/test_integrated_channels/test_blackboard/test_exporters/test_learner_data.py b/tests/test_integrated_channels/test_blackboard/test_exporters/test_learner_data.py index ce3778347c..534fab40bc 100644 --- a/tests/test_integrated_channels/test_blackboard/test_exporters/test_learner_data.py +++ b/tests/test_integrated_channels/test_blackboard/test_exporters/test_learner_data.py @@ -7,7 +7,10 @@ from pytest import mark +from django.db.utils import IntegrityError + from integrated_channels.blackboard.exporters.learner_data import BlackboardLearnerExporter +from integrated_channels.blackboard.models import BlackboardLearnerDataTransmissionAudit from test_utils import factories @@ -33,6 +36,24 @@ def setUp(self): refresh_token='token', ) + def test_unique_enrollment_id_course_id_constraint(self): + """ + Ensure that the unique constraint on enterprise_course_enrollment_id and course_id is enforced. + """ + BlackboardLearnerDataTransmissionAudit.objects.create( + enterprise_course_enrollment_id=5, + course_id=self.course_id, + course_completed=True, + blackboard_completed_timestamp=1486855998, + ) + with self.assertRaises(IntegrityError): + BlackboardLearnerDataTransmissionAudit.objects.create( + enterprise_course_enrollment_id=5, + course_id=self.course_id, + course_completed=True, + blackboard_completed_timestamp=1486855998, + ) + @mock.patch('enterprise.api_client.discovery.CourseCatalogApiServiceClient') def test_retrieve_same_learner_data_record(self, mock_course_catalog_api): """ diff --git a/tests/test_integrated_channels/test_canvas/test_exporters/test_learner_data.py b/tests/test_integrated_channels/test_canvas/test_exporters/test_learner_data.py index c24b3d209b..7a97f4859f 100644 --- a/tests/test_integrated_channels/test_canvas/test_exporters/test_learner_data.py +++ b/tests/test_integrated_channels/test_canvas/test_exporters/test_learner_data.py @@ -2,12 +2,16 @@ Tests for Canvas learner data exporters. """ +import datetime import unittest from unittest import mock from pytest import mark +from django.db.utils import IntegrityError + from integrated_channels.canvas.exporters.learner_data import CanvasLearnerExporter +from integrated_channels.canvas.models import CanvasLearnerDataTransmissionAudit from test_utils import factories @@ -27,25 +31,51 @@ def setUp(self): ) self.course_id = 'course-v1:edX+DemoX+DemoCourse' self.course_key = 'edX+DemoX' + self.enterprise_course_enrollment = factories.EnterpriseCourseEnrollmentFactory( + id=5, + enterprise_customer_user=self.enterprise_customer_user, + ) self.config = factories.CanvasEnterpriseCustomerConfigurationFactory( enterprise_customer=self.enterprise_customer, canvas_base_url='foobar', ) + def test_unique_enrollment_id_course_id_constraint(self): + """ + Ensure that the unique constraint on enterprise_course_enrollment_id and course_id is enforced. + """ + CanvasLearnerDataTransmissionAudit.objects.create( + canvas_user_email=self.user.email, + enterprise_course_enrollment_id=self.enterprise_course_enrollment.id, + course_id=self.course_id, + course_completed=True, + canvas_completed_timestamp=1486855998, + completed_timestamp=datetime.datetime.fromtimestamp(1486855998), + total_hours=1.0, + grade=.9, + ) + with self.assertRaises(IntegrityError): + CanvasLearnerDataTransmissionAudit.objects.create( + canvas_user_email=self.user.email, + enterprise_course_enrollment_id=self.enterprise_course_enrollment.id, + course_id=self.course_id, + course_completed=True, + canvas_completed_timestamp=1486855998, + completed_timestamp=datetime.datetime.fromtimestamp(1486855998), + total_hours=1.0, + grade=.9, + ) + @mock.patch('enterprise.api_client.discovery.CourseCatalogApiServiceClient') def test_retrieve_same_learner_data_record(self, mock_course_catalog_api): """ If a learner data record already exists for the enrollment, it should be retrieved instead of created. """ - enterprise_course_enrollment = factories.EnterpriseCourseEnrollmentFactory( - course_id=self.course_id, - enterprise_customer_user=self.enterprise_customer_user, - ) mock_course_catalog_api.return_value.get_course_id.return_value = self.course_key exporter = CanvasLearnerExporter('fake-user', self.config) - learner_data_records_1 = exporter.get_learner_data_records(enterprise_course_enrollment)[0] + learner_data_records_1 = exporter.get_learner_data_records(self.enterprise_course_enrollment)[0] learner_data_records_1.save() - learner_data_records_2 = exporter.get_learner_data_records(enterprise_course_enrollment)[0] + learner_data_records_2 = exporter.get_learner_data_records(self.enterprise_course_enrollment)[0] learner_data_records_2.save() assert learner_data_records_1.id == learner_data_records_2.id diff --git a/tests/test_integrated_channels/test_degreed2/test_exporters/test_learner_data.py b/tests/test_integrated_channels/test_degreed2/test_exporters/test_learner_data.py index be9d5fb83f..dba7de8bbd 100644 --- a/tests/test_integrated_channels/test_degreed2/test_exporters/test_learner_data.py +++ b/tests/test_integrated_channels/test_degreed2/test_exporters/test_learner_data.py @@ -12,7 +12,10 @@ from mock.mock import MagicMock from pytest import mark +from django.db.utils import IntegrityError + from integrated_channels.degreed2.exporters.learner_data import Degreed2LearnerExporter +from integrated_channels.degreed2.models import Degreed2LearnerDataTransmissionAudit from test_utils import factories from test_utils.fake_catalog_api import setup_course_catalog_api_client_mock @@ -60,6 +63,25 @@ def setUp(self): self.addCleanup(course_catalog_api_client_mock.stop) super().setUp() + def test_unique_enrollment_id_course_id_constraint(self): + """ + Ensure that the unique constraint on enterprise_course_enrollment_id and course_id is enforced. + """ + course_id = 'course-v1:edX+DemoX+DemoCourse' + enterprise_course_enrollment = factories.EnterpriseCourseEnrollmentFactory( + enterprise_customer_user=self.enterprise_customer_user, + course_id=course_id, + ) + Degreed2LearnerDataTransmissionAudit.objects.create( + enterprise_course_enrollment_id=enterprise_course_enrollment.id, + course_id=course_id, + ) + with self.assertRaises(IntegrityError): + Degreed2LearnerDataTransmissionAudit.objects.create( + enterprise_course_enrollment_id=enterprise_course_enrollment.id, + course_id=course_id, + ) + @ddt.data( (None, None,), (NOW, .83), diff --git a/tests/test_integrated_channels/test_moodle/test_exporters/test_learner_data.py b/tests/test_integrated_channels/test_moodle/test_exporters/test_learner_data.py index 0ab89e387a..4b7da52c16 100644 --- a/tests/test_integrated_channels/test_moodle/test_exporters/test_learner_data.py +++ b/tests/test_integrated_channels/test_moodle/test_exporters/test_learner_data.py @@ -2,12 +2,16 @@ Tests for Moodle learner data exporters. """ +import datetime import unittest from unittest import mock from pytest import mark +from django.db.utils import IntegrityError + from integrated_channels.moodle.exporters.learner_data import MoodleLearnerExporter +from integrated_channels.moodle.models import MoodleLearnerDataTransmissionAudit from test_utils import factories @@ -35,6 +39,32 @@ def setUp(self): decrypted_token='token', ) + def test_unique_enrollment_id_course_id_constraint(self): + """ + Ensure that the unique constraint on enterprise_course_enrollment_id and course_id is enforced. + """ + MoodleLearnerDataTransmissionAudit.objects.create( + moodle_user_email=self.enterprise_customer.contact_email, + enterprise_course_enrollment_id=5, + course_id=self.course_id, + course_completed=True, + moodle_completed_timestamp=1486855998, + completed_timestamp=datetime.datetime.fromtimestamp(1486855998), + total_hours=1.0, + grade=.9, + ) + with self.assertRaises(IntegrityError): + MoodleLearnerDataTransmissionAudit.objects.create( + moodle_user_email=self.enterprise_customer.contact_email, + enterprise_course_enrollment_id=5, + course_id=self.course_id, + course_completed=True, + moodle_completed_timestamp=1486855998, + completed_timestamp=datetime.datetime.fromtimestamp(1486855998), + total_hours=2.0, + grade=.9, + ) + @mock.patch('enterprise.api_client.discovery.CourseCatalogApiServiceClient') def test_retrieve_same_learner_data_record(self, mock_course_catalog_api): """ diff --git a/tests/test_integrated_channels/test_sap_success_factors/test_exporters/test_learner_data.py b/tests/test_integrated_channels/test_sap_success_factors/test_exporters/test_learner_data.py index 581ab2e914..09f17d0be8 100644 --- a/tests/test_integrated_channels/test_sap_success_factors/test_exporters/test_learner_data.py +++ b/tests/test_integrated_channels/test_sap_success_factors/test_exporters/test_learner_data.py @@ -2,6 +2,7 @@ Tests for SAPSF learner data exporters. """ +import datetime import unittest from unittest import mock from unittest.mock import MagicMock, Mock @@ -9,7 +10,10 @@ import ddt from pytest import mark +from django.db.utils import IntegrityError + from integrated_channels.sap_success_factors.exporters.learner_data import SapSuccessFactorsLearnerExporter +from integrated_channels.sap_success_factors.models import SapSuccessFactorsLearnerDataTransmissionAudit from test_utils.factories import ( EnterpriseCourseEnrollmentFactory, EnterpriseCustomerFactory, @@ -26,26 +30,70 @@ class TestSAPSuccessFactorLearnerDataExporter(unittest.TestCase): Tests for the ``SapSuccessFactorsLearnerDataExporter`` class. """ + def setUp(self): + super().setUp() + self.enterprise_customer = EnterpriseCustomerFactory() + self.enterprise_customer_user = EnterpriseCustomerUserFactory( + enterprise_customer=self.enterprise_customer, + ) + self.enterprise_course_enrollment = EnterpriseCourseEnrollmentFactory( + id=5, + enterprise_customer_user=self.enterprise_customer_user, + ) + self.enterprise_config = SAPSuccessFactorsEnterpriseCustomerConfigurationFactory( + enterprise_customer=self.enterprise_customer, + key="client_id", + sapsf_base_url="http://test.successfactors.com/", + sapsf_company_id="company_id", + sapsf_user_id="user_id", + secret="client_secret" + ) + + def test_unique_enrollment_id_course_id_constraint(self): + """ + Ensure that the unique constraint on enterprise_course_enrollment_id and course_id is enforced. + """ + course_id = 'course-v1:edX+DemoX+DemoCourse' + SapSuccessFactorsLearnerDataTransmissionAudit.objects.create( + enterprise_course_enrollment_id=self.enterprise_course_enrollment.id, + sapsf_user_id='sap_user', + course_id=course_id, + course_completed=True, + sap_completed_timestamp=1486755998, + completed_timestamp=datetime.datetime.fromtimestamp(1486755998), + instructor_name='Professor Professorson', + grade='Pass', + enterprise_customer_uuid=self.enterprise_customer.uuid, + plugin_configuration_id=self.enterprise_config.id, + ) + with self.assertRaises(IntegrityError): + SapSuccessFactorsLearnerDataTransmissionAudit.objects.create( + enterprise_course_enrollment_id=self.enterprise_course_enrollment.id, + sapsf_user_id='sap_user', + course_id=course_id, + course_completed=True, + sap_completed_timestamp=1486755998, + completed_timestamp=datetime.datetime.fromtimestamp(1486755998), + instructor_name='Professor Professorson', + grade='Pass', + enterprise_customer_uuid=self.enterprise_customer.uuid, + plugin_configuration_id=self.enterprise_config.id, + ) + @mock.patch('integrated_channels.sap_success_factors.exporters.learner_data.get_course_id_for_enrollment') @mock.patch('integrated_channels.sap_success_factors.exporters.learner_data.get_course_run_for_enrollment') def test_call_get_remote_id(self, mock_get_course_run_for_enrollment, mock_get_course_id_for_enrollment): mock_get_course_run_for_enrollment.return_value = MagicMock() mock_get_course_id_for_enrollment.return_value = 'test:id' user = UserFactory() - enterprise_customer = EnterpriseCustomerFactory() - enterprise_configuration = SAPSuccessFactorsEnterpriseCustomerConfigurationFactory( - enterprise_customer=enterprise_customer, - idp_id='test-id' - ) completed_date = None grade = 'Pass' course_completed = False - enterprise_customer_user = EnterpriseCustomerUserFactory() enterprise_enrollment = EnterpriseCourseEnrollmentFactory( - enterprise_customer_user=enterprise_customer_user + enterprise_customer_user=self.enterprise_customer_user ) enterprise_enrollment.enterprise_customer_user.get_remote_id = MagicMock() - exporter = SapSuccessFactorsLearnerExporter(user, enterprise_configuration) + exporter = SapSuccessFactorsLearnerExporter(user, self.enterprise_config) exporter.get_learner_data_records( enterprise_enrollment, @@ -54,7 +102,7 @@ def test_call_get_remote_id(self, mock_get_course_run_for_enrollment, mock_get_c course_completed ) enterprise_enrollment.enterprise_customer_user.get_remote_id.assert_called_once_with( - enterprise_configuration.idp_id + self.enterprise_config.idp_id ) @mock.patch('integrated_channels.sap_success_factors.exporters.learner_data.get_course_id_for_enrollment') @@ -70,20 +118,14 @@ def test_retrieve_same_learner_data_record( mock_get_course_run_for_enrollment.return_value = MagicMock() mock_get_course_id_for_enrollment.return_value = 'test:id' user = UserFactory() - enterprise_customer = EnterpriseCustomerFactory() - enterprise_configuration = SAPSuccessFactorsEnterpriseCustomerConfigurationFactory( - enterprise_customer=enterprise_customer, - idp_id='test-id' - ) completed_date = None grade = 'Pass' course_completed = False - enterprise_customer_user = EnterpriseCustomerUserFactory() enterprise_enrollment = EnterpriseCourseEnrollmentFactory( - enterprise_customer_user=enterprise_customer_user + enterprise_customer_user=self.enterprise_customer_user ) enterprise_enrollment.enterprise_customer_user.get_remote_id = Mock(return_value=99) - exporter = SapSuccessFactorsLearnerExporter(user, enterprise_configuration) + exporter = SapSuccessFactorsLearnerExporter(user, self.enterprise_config) learner_data_records_1 = exporter.get_learner_data_records( enterprise_enrollment, completed_date, @@ -107,12 +149,7 @@ def test_override_of_default_channel_settings(self): If you override any settings to the ChannelSettingsMixin, add a test here for those """ user = UserFactory() - enterprise_customer = EnterpriseCustomerFactory() - enterprise_configuration = SAPSuccessFactorsEnterpriseCustomerConfigurationFactory( - enterprise_customer=enterprise_customer, - idp_id='test-id' - ) assert SapSuccessFactorsLearnerExporter( user, - enterprise_configuration + self.enterprise_config ).INCLUDE_GRADE_FOR_COMPLETION_AUDIT_CHECK is False From 28ee7ec54ca64b37bbebab343e46058970709495 Mon Sep 17 00:00:00 2001 From: MueezKhan246 <93375917+MueezKhan246@users.noreply.github.com> Date: Wed, 14 Feb 2024 15:00:47 +0500 Subject: [PATCH 122/164] Record Degreed API calls (#2020) * feat: record degreed API calls --- CHANGELOG.rst | 4 ++ enterprise/__init__.py | 2 +- integrated_channels/degreed2/client.py | 76 +++++++++++++++++++++++--- 3 files changed, 73 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 78385a3241..631174beff 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -16,6 +16,10 @@ Change Log Unreleased ---------- +[4.11.11] +--------- +* feat: record degreed API calls + [4.11.10] --------- * feat: add unique constraint on learner data transmission audit models diff --git a/enterprise/__init__.py b/enterprise/__init__.py index 1021220ab0..2e313f1531 100644 --- a/enterprise/__init__.py +++ b/enterprise/__init__.py @@ -2,4 +2,4 @@ Your project description goes here. """ -__version__ = "4.11.10" +__version__ = "4.11.11" diff --git a/integrated_channels/degreed2/client.py b/integrated_channels/degreed2/client.py index 1424d692ef..95b8d1eae1 100644 --- a/integrated_channels/degreed2/client.py +++ b/integrated_channels/degreed2/client.py @@ -20,7 +20,7 @@ from enterprise.models import EnterpriseCustomerUser from integrated_channels.exceptions import ClientError from integrated_channels.integrated_channel.client import IntegratedChannelApiClient -from integrated_channels.utils import generate_formatted_log, refresh_session_if_expired +from integrated_channels.utils import generate_formatted_log, refresh_session_if_expired, stringify_and_store_api_record LOGGER = logging.getLogger(__name__) @@ -64,6 +64,9 @@ def __init__(self, enterprise_configuration): message, ) self.enterprise_catalog_api_client = EnterpriseCatalogApiClient() + self.IntegratedChannelAPIRequestLogs = apps.get_model( + "integrated_channel", "IntegratedChannelAPIRequestLogs" + ) def get_oauth_url(self): config = self.enterprise_configuration @@ -473,7 +476,18 @@ def _get(self, url, scope): attempts = 0 while True: attempts = attempts + 1 + start_time = time.time() response = self.session.get(url) + duration_seconds = time.time() - start_time + self.IntegratedChannelAPIRequestLogs.store_api_call( + enterprise_customer=self.enterprise_configuration.enterprise_customer, + enterprise_customer_configuration_id=self.enterprise_configuration.id, + endpoint=url, + payload='', + time_taken=duration_seconds, + status_code=response.status_code, + response_body=response.text, + ) if attempts <= self.MAX_RETRIES and response.status_code == 429: sleep_seconds = self._calculate_backoff(attempts) LOGGER.warning( @@ -506,7 +520,18 @@ def _post(self, url, data, scope): attempts = 0 while True: attempts = attempts + 1 + start_time = time.time() response = self.session.post(url, json=data) + duration_seconds = time.time() - start_time + stringify_and_store_api_record( + enterprise_customer=self.enterprise_configuration.enterprise_customer, + enterprise_customer_configuration_id=self.enterprise_configuration.id, + endpoint=url, + data=data, + time_taken=duration_seconds, + status_code=response.status_code, + response_body=response.text, + ) if attempts <= self.MAX_RETRIES and response.status_code == 429: sleep_seconds = self._calculate_backoff(attempts) LOGGER.warning( @@ -539,7 +564,18 @@ def _patch(self, url, data, scope): attempts = 0 while True: attempts = attempts + 1 + start_time = time.time() response = self.session.patch(url, json=data) + duration_seconds = time.time() - start_time + stringify_and_store_api_record( + enterprise_customer=self.enterprise_configuration.enterprise_customer, + enterprise_customer_configuration_id=self.enterprise_configuration.id, + endpoint=url, + data=data, + time_taken=duration_seconds, + status_code=response.status_code, + response_body=response.text, + ) if attempts <= self.MAX_RETRIES and response.status_code == 429: sleep_seconds = self._calculate_backoff(attempts) LOGGER.warning( @@ -572,7 +608,18 @@ def _delete(self, url, data, scope): attempts = 0 while True: attempts = attempts + 1 + start_time = time.time() response = self.session.delete(url, json=data) if data else self.session.delete(url) + duration_seconds = time.time() - start_time + stringify_and_store_api_record( + enterprise_customer=self.enterprise_configuration.enterprise_customer, + enterprise_customer_configuration_id=self.enterprise_configuration.id, + endpoint=url, + data=data if data else '', + time_taken=duration_seconds, + status_code=response.status_code, + response_body=response.text, + ) if attempts <= self.MAX_RETRIES and response.status_code == 429: sleep_seconds = self._calculate_backoff(attempts) LOGGER.warning( @@ -613,16 +660,29 @@ def _get_oauth_access_token(self, scope): ClientError: If an unexpected response format was received that we could not parse. """ config = self.enterprise_configuration + url = self.get_oauth_url() + data = { + 'grant_type': 'client_credentials', + 'scope': scope, + 'client_id': config.client_id, + 'client_secret': config.client_secret, + } + start_time = time.time() response = requests.post( - self.get_oauth_url(), - data={ - 'grant_type': 'client_credentials', - 'scope': scope, - 'client_id': config.client_id, - 'client_secret': config.client_secret, - }, + url, + data=data, headers={'Content-Type': 'application/x-www-form-urlencoded'} ) + duration_seconds = time.time() - start_time + stringify_and_store_api_record( + enterprise_customer=self.enterprise_configuration.enterprise_customer, + enterprise_customer_configuration_id=self.enterprise_configuration.id, + endpoint=url, + data=data, + time_taken=duration_seconds, + status_code=response.status_code, + response_body=response.text, + ) try: data = response.json() From 8a08cc4a0f2d3c5e329d5f8ce1b77a11a6981ecc Mon Sep 17 00:00:00 2001 From: MueezKhan246 <93375917+MueezKhan246@users.noreply.github.com> Date: Wed, 14 Feb 2024 15:14:50 +0500 Subject: [PATCH 123/164] Record SAP success factors API calls (#2019) * feat: record sap success factors API calls --- .../sap_success_factors/client.py | 79 +++++++++++++++---- 1 file changed, 63 insertions(+), 16 deletions(-) diff --git a/integrated_channels/sap_success_factors/client.py b/integrated_channels/sap_success_factors/client.py index ec1f6a63b7..3ba32ec971 100644 --- a/integrated_channels/sap_success_factors/client.py +++ b/integrated_channels/sap_success_factors/client.py @@ -15,7 +15,7 @@ from integrated_channels.exceptions import ClientError from integrated_channels.integrated_channel.client import IntegratedChannelApiClient -from integrated_channels.utils import generate_formatted_log +from integrated_channels.utils import generate_formatted_log, stringify_and_store_api_record LOGGER = logging.getLogger(__name__) @@ -46,6 +46,9 @@ def __init__(self, enterprise_configuration): self.global_sap_config = apps.get_model('sap_success_factors', 'SAPSuccessFactorsGlobalConfiguration').current() self.session = None self.expires_at = None + self.IntegratedChannelAPIRequestLogs = apps.get_model( + "integrated_channel", "IntegratedChannelAPIRequestLogs" + ) def get_oauth_access_token(self, client_id, client_secret, company_id, user_id, user_type, customer_uuid): """ @@ -70,24 +73,36 @@ def get_oauth_access_token(self, client_id, client_secret, company_id, user_id, 'SAPSuccessFactorsGlobalConfiguration' ) global_sap_config = SAPSuccessFactorsGlobalConfiguration.current() - + start_time = time.time() + complete_url = urljoin( + self.enterprise_configuration.sapsf_base_url, + global_sap_config.oauth_api_path, + ) + serialized_data = { + 'grant_type': 'client_credentials', + 'scope': { + 'userId': user_id, + 'companyId': company_id, + 'userType': user_type, + 'resourceType': 'learning_public_api', + } + } response = requests.post( - urljoin( - self.enterprise_configuration.sapsf_base_url, - global_sap_config.oauth_api_path, - ), - json={ - 'grant_type': 'client_credentials', - 'scope': { - 'userId': user_id, - 'companyId': company_id, - 'userType': user_type, - 'resourceType': 'learning_public_api', - } - }, + complete_url, + json=serialized_data, auth=(client_id, client_secret), headers={'content-type': CONTENT_TYPE_APP_JSON} ) + duration_seconds = time.time() - start_time + stringify_and_store_api_record( + enterprise_customer=self.enterprise_configuration.enterprise_customer, + enterprise_customer_configuration_id=self.enterprise_configuration.id, + endpoint=complete_url, + data=serialized_data, + time_taken=duration_seconds, + status_code=response.status_code, + response_body=response.text, + ) try: data = response.json() @@ -256,7 +271,7 @@ def _call_post_with_user_override(self, sap_user_id, url, payload): SAPSuccessFactorsEnterpriseCustomerConfiguration.USER_TYPE_USER, self.enterprise_configuration.enterprise_customer.uuid ) - + start_time = time.time() response = requests.post( url, data=payload, @@ -265,6 +280,16 @@ def _call_post_with_user_override(self, sap_user_id, url, payload): 'content-type': CONTENT_TYPE_APP_JSON } ) + duration_seconds = time.time() - start_time + stringify_and_store_api_record( + enterprise_customer=self.enterprise_configuration.enterprise_customer, + enterprise_customer_configuration_id=self.enterprise_configuration.id, + endpoint=url, + data=payload, + time_taken=duration_seconds, + status_code=response.status_code, + response_body=response.text, + ) if response.status_code >= 400: raise ClientError( @@ -284,7 +309,18 @@ def _call_post_with_session(self, url, payload): payload (str): The json encoded payload to post. """ self._create_session() + start_time = time.time() response = self.session.post(url, data=payload) + duration_seconds = time.time() - start_time + stringify_and_store_api_record( + enterprise_customer=self.enterprise_configuration.enterprise_customer, + enterprise_customer_configuration_id=self.enterprise_configuration.id, + endpoint=url, + data=payload, + time_taken=duration_seconds, + status_code=response.status_code, + response_body=response.text, + ) if response.status_code >= 400: LOGGER.error( generate_formatted_log( @@ -354,7 +390,18 @@ def _call_search_students_recursively(self, sap_search_student_url, all_inactive ), ) try: + start_time = time.time() response = self.session.get(search_student_paginated_url) + duration_seconds = time.time() - start_time + self.IntegratedChannelAPIRequestLogs.store_api_call( + enterprise_customer=self.enterprise_configuration.enterprise_customer, + enterprise_customer_configuration_id=self.enterprise_configuration.id, + endpoint=search_student_paginated_url, + payload='', + time_taken=duration_seconds, + status_code=response.status_code, + response_body=response.text, + ) sap_inactive_learners = response.json() except ValueError as error: raise ClientError(response, response.status_code) from error From b490ed223eb58a5e57fdfefaadf55c399ebf6ccd Mon Sep 17 00:00:00 2001 From: MueezKhan246 <93375917+MueezKhan246@users.noreply.github.com> Date: Wed, 14 Feb 2024 15:27:58 +0500 Subject: [PATCH 124/164] Record Moodle api calls (#2018) * feat: record moodle API calls --- integrated_channels/moodle/client.py | 70 ++++++++++++++++--- .../test_moodle/test_client.py | 60 ++++++++++++++-- 2 files changed, 116 insertions(+), 14 deletions(-) diff --git a/integrated_channels/moodle/client.py b/integrated_channels/moodle/client.py index 43eae8e4f6..6192311250 100644 --- a/integrated_channels/moodle/client.py +++ b/integrated_channels/moodle/client.py @@ -4,8 +4,9 @@ import json import logging +import time from http import HTTPStatus -from urllib.parse import urljoin +from urllib.parse import urlencode, urljoin import requests @@ -13,7 +14,7 @@ from integrated_channels.exceptions import ClientError from integrated_channels.integrated_channel.client import IntegratedChannelApiClient -from integrated_channels.utils import generate_formatted_log +from integrated_channels.utils import generate_formatted_log, stringify_and_store_api_record LOGGER = logging.getLogger(__name__) @@ -136,6 +137,9 @@ def __init__(self, enterprise_configuration): self.config = apps.get_app_config('moodle') self.token = enterprise_configuration.decrypted_token or self._get_access_token() self.api_url = urljoin(self.enterprise_configuration.moodle_base_url, self.MOODLE_API_PATH) + self.IntegratedChannelAPIRequestLogs = apps.get_model( + "integrated_channel", "IntegratedChannelAPIRequestLogs" + ) def _post(self, additional_params): """ @@ -150,11 +154,22 @@ def _post(self, additional_params): } params.update(additional_params) + start_time = time.time() response = requests.post( url=self.api_url, data=params, headers=headers ) + duration_seconds = time.time() - start_time + stringify_and_store_api_record( + enterprise_customer=self.enterprise_configuration.enterprise_customer, + enterprise_customer_configuration_id=self.enterprise_configuration.id, + endpoint=self.api_url, + data=params, + time_taken=duration_seconds, + status_code=response.status_code, + response_body=response.text, + ) return response @@ -174,19 +189,30 @@ def _get_access_token(self): 'service': self.enterprise_configuration.service_short_name } + url = urljoin(self.enterprise_configuration.moodle_base_url, 'login/token.php') + complete_url = "{}?{}".format(url, urlencode(querystring)) + start_time = time.time() + data = { + "username": self.enterprise_configuration.decrypted_username, + "password": self.enterprise_configuration.decrypted_password, + } response = requests.post( - urljoin( - self.enterprise_configuration.moodle_base_url, - 'login/token.php', - ), + url, params=querystring, headers={ 'Content-Type': 'application/x-www-form-urlencoded', }, - data={ - "username": self.enterprise_configuration.decrypted_username, - "password": self.enterprise_configuration.decrypted_password, - }, + data=data, + ) + duration_seconds = time.time() - start_time + stringify_and_store_api_record( + enterprise_customer=self.enterprise_configuration.enterprise_customer, + enterprise_customer_configuration_id=self.enterprise_configuration.id, + endpoint=complete_url, + data=data, + time_taken=duration_seconds, + status_code=response.status_code, + response_body=response.text, ) try: @@ -246,10 +272,22 @@ def _get_course_contents(self, course_id): 'courseid': course_id, 'moodlewsrestformat': 'json' } + complete_url = "{}?{}".format(self.api_url, urlencode(params)) + start_time = time.time() response = requests.get( self.api_url, params=params ) + duration_seconds = time.time() - start_time + self.IntegratedChannelAPIRequestLogs.store_api_call( + enterprise_customer=self.enterprise_configuration.enterprise_customer, + enterprise_customer_configuration_id=self.enterprise_configuration.id, + endpoint=complete_url, + payload='', + time_taken=duration_seconds, + status_code=response.status_code, + response_body=response.text, + ) return response def get_course_final_grade_module(self, course_id): @@ -294,10 +332,22 @@ def _get_courses(self, key): 'value': key, 'moodlewsrestformat': 'json' } + complete_url = "{}?{}".format(self.api_url, urlencode(params)) + start_time = time.time() response = requests.get( self.api_url, params=params ) + duration_seconds = time.time() - start_time + self.IntegratedChannelAPIRequestLogs.store_api_call( + enterprise_customer=self.enterprise_configuration.enterprise_customer, + enterprise_customer_configuration_id=self.enterprise_configuration.id, + endpoint=complete_url, + payload='', + time_taken=duration_seconds, + status_code=response.status_code, + response_body=response.text, + ) return response def _get_course_id(self, key): diff --git a/tests/test_integrated_channels/test_moodle/test_client.py b/tests/test_integrated_channels/test_moodle/test_client.py index dd0f770b99..7f9a49f27e 100644 --- a/tests/test_integrated_channels/test_moodle/test_client.py +++ b/tests/test_integrated_channels/test_moodle/test_client.py @@ -4,15 +4,22 @@ import random import unittest +from urllib.parse import urljoin import pytest import responses from requests.models import Response +from django.apps import apps + from integrated_channels.exceptions import ClientError from integrated_channels.moodle.client import MoodleAPIClient, MoodleClientError from test_utils import factories +IntegratedChannelAPIRequestLogs = apps.get_model( + "integrated_channel", "IntegratedChannelAPIRequestLogs" +) + SERIALIZED_DATA = { 'courses[0][summary]': 'edX Demonstration Course', 'courses[0][shortname]': 'edX Demonstration Course (edX+DemoX)', @@ -360,6 +367,7 @@ def test_client_behavior_on_successful_learner_data_transmission_customization(s client._post.assert_called_once_with(expected_params) # pylint: disable=protected-access + @responses.activate def test_get_course_final_grade_module_custom_name(self): """ Test that given successful requests for moodle learner data, @@ -370,19 +378,24 @@ def test_get_course_final_grade_module_custom_name(self): client.get_course_id = unittest.mock.MagicMock(name='_get_course_id') client.get_course_id.return_value = self.moodle_course_id - mock_response = unittest.mock.Mock(spec=Response) - mock_response.json.return_value = [{ + mock_response = [{ 'name': 'General', 'modules': [{'name': self.enterprise_custom_config.grade_assignment_name, 'id': 1337, 'modname': 'foobar'}]}] - - client._get_course_contents = unittest.mock.MagicMock(name='_get_course_contents', return_value=mock_response) # pylint: disable=protected-access + responses.add( + responses.GET, + client.api_url, + json=mock_response, + status=200, + ) client.get_creds_of_user_in_course = unittest.mock.MagicMock(name='get_user_in_course') client.get_creds_of_user_in_course.return_value = self.moodle_user_id # The base transmitter expects the create course completion response to be a tuple of (code, body) + assert IntegratedChannelAPIRequestLogs.objects.count() == 0 assert client.get_course_final_grade_module(2) == (1337, 'foobar') + assert IntegratedChannelAPIRequestLogs.objects.count() == 1 def test_successful_update_existing_content_metadata(self): """ @@ -408,3 +421,42 @@ def test_successful_update_existing_content_metadata(self): client._get_courses.return_value = mock_response # pylint: disable=protected-access client.create_content_metadata(SERIALIZED_DATA) client._post.assert_called_once_with(expected_data) # pylint: disable=protected-access + + @responses.activate + def test_create_content_metadata_with_mocked_api_requests(self): + """ + Test to verify that the content metadata creation process correctly interacts + with the Moodle API by mocking the necessary API requests. + """ + self.enterprise_config.decrypted_token = None + url = urljoin(self.enterprise_config.moodle_base_url, 'login/token.php') + responses.add( + responses.POST, + url, + json={"token": "token"}, + status=200, + ) + assert IntegratedChannelAPIRequestLogs.objects.count() == 0 + client = MoodleAPIClient(self.enterprise_config) + assert IntegratedChannelAPIRequestLogs.objects.count() == 1 + client._get_courses = unittest.mock.MagicMock(name='_get_courses') # pylint: disable=protected-access + mock_response = Response() + mock_response.status_code = 200 + mock_response._content = self._get_courses_response_empty # pylint: disable=protected-access + client._get_courses.return_value = mock_response # pylint: disable=protected-access + params = { + 'wstoken': self.token, + 'moodlewsrestformat': 'json', + } + params.update(SERIALIZED_DATA) + + api_url = urljoin(self.enterprise_config.moodle_base_url, client.MOODLE_API_PATH) + responses.add( + responses.POST, + api_url, + json={}, + status=200, + ) + client.create_content_metadata(SERIALIZED_DATA) + assert IntegratedChannelAPIRequestLogs.objects.count() == 2 + assert len(responses.calls) == 2 From a4bb35e5de9b8d05724407d587384cf4154dc727 Mon Sep 17 00:00:00 2001 From: hamzawaleed01 Date: Wed, 14 Feb 2024 18:20:16 +0500 Subject: [PATCH 125/164] feat: add IntegratedChannelAPIRequestLogs list_display, search_fields and readonly_f ields --- .../integrated_channel/admin/__init__.py | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/integrated_channels/integrated_channel/admin/__init__.py b/integrated_channels/integrated_channel/admin/__init__.py index 37c5e26c5a..41042bc142 100644 --- a/integrated_channels/integrated_channel/admin/__init__.py +++ b/integrated_channels/integrated_channel/admin/__init__.py @@ -97,5 +97,36 @@ class IntegratedChannelAPIRequestLogAdmin(admin.ModelAdmin): Django admin model for IntegratedChannelAPIRequestLogs. """ + list_display = [ + "endpoint", + "enterprise_customer", + "time_taken", + "status_code", + ] + list_filter = [ + "status_code", + "enterprise_customer", + "endpoint", + "time_taken", + ] + search_fields = [ + "status_code", + "enterprise_customer", + "enterprise_customer_configuration_id", + "endpoint", + "time_taken", + "response_body", + "payload", + ] + readonly_fields = [ + "status_code", + "enterprise_customer", + "enterprise_customer_configuration_id", + "endpoint", + "time_taken", + "response_body", + "payload", + ] + class Meta: model = IntegratedChannelAPIRequestLogs From c0349ded02d2fe1f875aa0d74d1fb88d539ae96b Mon Sep 17 00:00:00 2001 From: Alexander J Sheehan Date: Tue, 13 Feb 2024 17:52:05 +0000 Subject: [PATCH 126/164] feat: new enterprise models relating to enterprise groups --- CHANGELOG.rst | 3 + enterprise/__init__.py | 2 +- ...egroup_historicalenterprisegroupmembers.py | 98 +++++++++++++++++++ enterprise/models.py | 69 +++++++++++++ test_utils/factories.py | 41 ++++++++ tests/test_models.py | 56 +++++++++++ tests/test_utilities.py | 2 + 7 files changed, 270 insertions(+), 1 deletion(-) create mode 100644 enterprise/migrations/0200_enterprisegroup_enterprisegroupmembership_historicalenterprisegroup_historicalenterprisegroupmembers.py diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 631174beff..0266516afb 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -15,6 +15,9 @@ Change Log Unreleased ---------- +[4.11.12] +--------- +* feat: new enterprise models relating to enterprise groups [4.11.11] --------- diff --git a/enterprise/__init__.py b/enterprise/__init__.py index 2e313f1531..8015450126 100644 --- a/enterprise/__init__.py +++ b/enterprise/__init__.py @@ -2,4 +2,4 @@ Your project description goes here. """ -__version__ = "4.11.11" +__version__ = "4.11.12" diff --git a/enterprise/migrations/0200_enterprisegroup_enterprisegroupmembership_historicalenterprisegroup_historicalenterprisegroupmembers.py b/enterprise/migrations/0200_enterprisegroup_enterprisegroupmembership_historicalenterprisegroup_historicalenterprisegroupmembers.py new file mode 100644 index 0000000000..e2c1b09cd2 --- /dev/null +++ b/enterprise/migrations/0200_enterprisegroup_enterprisegroupmembership_historicalenterprisegroup_historicalenterprisegroupmembers.py @@ -0,0 +1,98 @@ +# Generated by Django 3.2.23 on 2024-02-13 20:40 + +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): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('enterprise', '0199_auto_20240130_0628'), + ] + + operations = [ + migrations.CreateModel( + name='EnterpriseGroup', + fields=[ + ('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')), + ('uuid', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('name', models.CharField(help_text='Specifies enterprise group name.', max_length=25)), + ('enterprise_customer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='groups', to='enterprise.enterprisecustomer')), + ], + options={ + 'verbose_name': 'Enterprise Group', + 'verbose_name_plural': 'Enterprise Groups', + 'ordering': ['-modified'], + 'unique_together': {('name', 'enterprise_customer')}, + }, + ), + migrations.CreateModel( + name='HistoricalEnterpriseGroupMembership', + fields=[ + ('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')), + ('uuid', 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)), + ('enterprise_customer_user', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='enterprise.enterprisecustomeruser')), + ('group', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='enterprise.enterprisegroup')), + ('history_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), + ('pending_enterprise_customer_user', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='enterprise.pendingenterprisecustomeruser')), + ], + options={ + 'verbose_name': 'historical Enterprise Group Membership', + 'verbose_name_plural': 'historical Enterprise Group Memberships', + 'ordering': ('-history_date', '-history_id'), + 'get_latest_by': ('history_date', 'history_id'), + }, + bases=(simple_history.models.HistoricalChanges, models.Model), + ), + migrations.CreateModel( + name='HistoricalEnterpriseGroup', + fields=[ + ('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')), + ('uuid', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False)), + ('name', models.CharField(help_text='Specifies enterprise group name.', max_length=25)), + ('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)), + ('enterprise_customer', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='enterprise.enterprisecustomer')), + ('history_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'verbose_name': 'historical Enterprise Group', + 'verbose_name_plural': 'historical Enterprise Groups', + 'ordering': ('-history_date', '-history_id'), + 'get_latest_by': ('history_date', 'history_id'), + }, + bases=(simple_history.models.HistoricalChanges, models.Model), + ), + migrations.CreateModel( + name='EnterpriseGroupMembership', + fields=[ + ('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')), + ('uuid', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('enterprise_customer_user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='memberships', to='enterprise.enterprisecustomeruser')), + ('group', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='members', to='enterprise.enterprisegroup')), + ('pending_enterprise_customer_user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='memberships', to='enterprise.pendingenterprisecustomeruser')), + ], + options={ + 'verbose_name': 'Enterprise Group Membership', + 'verbose_name_plural': 'Enterprise Group Memberships', + 'ordering': ['-modified'], + 'unique_together': {('group', 'pending_enterprise_customer_user'), ('group', 'enterprise_customer_user')}, + }, + ), + ] diff --git a/enterprise/models.py b/enterprise/models.py index 543283976d..52958cc54b 100644 --- a/enterprise/models.py +++ b/enterprise/models.py @@ -4198,3 +4198,72 @@ def submit_for_configuration(self, updating_existing_record=False): self.submitted_at = localized_utcnow() self.save() return sp_metadata_url + + +class EnterpriseGroup(TimeStampedModel): + """ + Enterprise Group model + + .. no_pii: + """ + uuid = models.UUIDField(primary_key=True, default=uuid4, editable=False) + name = models.CharField( + max_length=25, + blank=False, + help_text=_( + 'Specifies enterprise group name.' + ) + ) + enterprise_customer = models.ForeignKey( + EnterpriseCustomer, + blank=False, + null=False, + related_name='groups', + on_delete=models.deletion.CASCADE + ) + history = HistoricalRecords() + + class Meta: + verbose_name = _("Enterprise Group") + verbose_name_plural = _("Enterprise Groups") + unique_together = (("name", "enterprise_customer"),) + ordering = ['-modified'] + + +class EnterpriseGroupMembership(TimeStampedModel): + """ + Enterprise Group Membership model + + .. no_pii: + """ + uuid = models.UUIDField(primary_key=True, default=uuid4, editable=False) + group = models.ForeignKey( + EnterpriseGroup, + blank=False, + null=False, + related_name='members', + on_delete=models.deletion.CASCADE, + ) + enterprise_customer_user = models.ForeignKey( + EnterpriseCustomerUser, + blank=True, + null=True, + related_name='memberships', + on_delete=models.deletion.CASCADE, + ) + pending_enterprise_customer_user = models.ForeignKey( + PendingEnterpriseCustomerUser, + blank=True, + null=True, + related_name='memberships', + on_delete=models.deletion.CASCADE, + ) + history = HistoricalRecords() + + class Meta: + verbose_name = _("Enterprise Group Membership") + verbose_name_plural = _("Enterprise Group Memberships") + # https://code.djangoproject.com/ticket/9039 - NULL value fields should not throw unique constraint errors + # ie no issue if multiple fields have: group = A and pending_enterprise_customer_user = NULL + unique_together = (("group", "enterprise_customer_user"), ("group", "pending_enterprise_customer_user")) + ordering = ['-modified'] diff --git a/test_utils/factories.py b/test_utils/factories.py index 811cb5c27a..e988a1cc1a 100644 --- a/test_utils/factories.py +++ b/test_utils/factories.py @@ -29,6 +29,8 @@ EnterpriseCustomerUser, EnterpriseFeatureRole, EnterpriseFeatureUserRoleAssignment, + EnterpriseGroup, + EnterpriseGroupMembership, LearnerCreditEnterpriseCourseEnrollment, LicensedEnterpriseCourseEnrollment, PendingEnrollment, @@ -1095,3 +1097,42 @@ class Meta: metadata_url = factory.LazyAttribute(lambda x: FAKER.url()) entity_id = factory.LazyAttribute(lambda x: FAKER.url()) update_from_metadata = True + + +class EnterpriseGroupFactory(factory.django.DjangoModelFactory): + """ + EnterpriseGroup factory. + + Creates an instance of EnterpriseGroup with minimal boilerplate. + """ + + class Meta: + """ + Meta for EnterpriseGroupFactory. + """ + + model = EnterpriseGroup + + uuid = factory.LazyAttribute(lambda x: UUID(FAKER.uuid4())) + enterprise_customer = factory.SubFactory(EnterpriseCustomerFactory) + name = factory.LazyAttribute(lambda x: FAKER.company()) + + +class EnterpriseGroupMembershipFactory(factory.django.DjangoModelFactory): + """ + EnterpriseGroupMembership factory. + + Creates an instance of EnterpriseGroupMembership with minimal boilerplate. + """ + + class Meta: + """ + Meta for EnterpriseGroupMembershipFactory. + """ + + model = EnterpriseGroupMembership + + uuid = factory.LazyAttribute(lambda x: UUID(FAKER.uuid4())) + group = factory.SubFactory(EnterpriseGroupFactory) + enterprise_customer_user = factory.SubFactory(EnterpriseCustomerUserFactory) + pending_enterprise_customer_user = factory.SubFactory(PendingEnterpriseCustomerUserFactory) diff --git a/tests/test_models.py b/tests/test_models.py index 674668c24e..397163e2be 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -2644,6 +2644,62 @@ def test_reactivating_key_fails(self): enterprise_customer_key.save() +@mark.django_db +@ddt.ddt +class TestEnterpriseGroup(unittest.TestCase): + """ + Tests for the EnterpriseGroup model. + """ + + def test_group_name_uniqueness(self): + """ + Test the unique constraints on the EnterpriseGroup table + """ + group_1 = factories.EnterpriseGroupFactory(name="foobar") + # Test that group names under each customer must be unique + factories.EnterpriseGroupFactory(name="foobar") + with raises(Exception): + factories.EnterpriseGroupFactory(name="foobar", enterprise_customer=group_1.enterprise_customer) + + +@mark.django_db +@ddt.ddt +class TestEnterpriseGroupMembership(unittest.TestCase): + """ + Tests for the EnterpriseGroupMembership model. + """ + + def test_group_membership_uniqueness(self): + """ + Test the unique constraints on the EnterpriseGroupMembership table + """ + # Test that NULL values in either customer user and pending customer users will not trigger uniqueness + # constraints + null_user_membership = factories.EnterpriseGroupMembershipFactory( + enterprise_customer_user=None, + pending_enterprise_customer_user=None, + ) + factories.EnterpriseGroupMembershipFactory( + enterprise_customer_user=None, + pending_enterprise_customer_user=None, + group=null_user_membership.group, + ) + group_membership_1 = factories.EnterpriseGroupMembershipFactory() + + # Test that a user cannot be assigned to a group more than once + with raises(Exception): + factories.EnterpriseGroupMembershipFactory( + group=group_membership_1.group, + enterprise_customer_user=group_membership_1.enterprise_customer_user, + ) + # Test that a pending user cannot be assigned to a group more than once + with raises(Exception): + factories.EnterpriseGroupMembershipFactory( + group=group_membership_1.group, + pending_enterprise_customer_user=group_membership_1.pending_enterprise_customer_user, + ) + + @mark.django_db @ddt.ddt class TestEnterpriseCustomerSsoConfiguration(unittest.TestCase): diff --git a/tests/test_utilities.py b/tests/test_utilities.py index 906e4e7f3c..8e7a48d354 100644 --- a/tests/test_utilities.py +++ b/tests/test_utilities.py @@ -178,6 +178,7 @@ def setUp(self): "enable_programs", "enable_demo_data_for_analytics_and_lpr", "enable_academies", + "groups", ] ), ( @@ -188,6 +189,7 @@ def setUp(self): "enterprise_enrollments", "id", "created", + "memberships", "modified", "enterprise_customer", "user_id", From bd6af6faf0f4b72c39719e52b66008eb7668d236 Mon Sep 17 00:00:00 2001 From: hamzawaleed01 Date: Thu, 15 Feb 2024 15:39:38 +0500 Subject: [PATCH 127/164] docs: update changelog and bump version --- CHANGELOG.rst | 4 ++++ enterprise/__init__.py | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 0266516afb..21f004ee9c 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -15,6 +15,10 @@ Change Log Unreleased ---------- +[4.11.13] +--------- +* feat: Update IntegratedChannelAPIRequestLogs list view + [4.11.12] --------- * feat: new enterprise models relating to enterprise groups diff --git a/enterprise/__init__.py b/enterprise/__init__.py index 8015450126..3bf0eda716 100644 --- a/enterprise/__init__.py +++ b/enterprise/__init__.py @@ -2,4 +2,4 @@ Your project description goes here. """ -__version__ = "4.11.12" +__version__ = "4.11.13" From 63774018b01047bff1b0af8925a7c47887d56f41 Mon Sep 17 00:00:00 2001 From: hamzawaleed01 Date: Mon, 19 Feb 2024 15:54:58 +0500 Subject: [PATCH 128/164] feat: unlink canvas user if decommissioned on canvas side --- integrated_channels/canvas/client.py | 34 +++++++++++++++---- .../test_canvas/test_client.py | 30 +++++++++++++++- 2 files changed, 57 insertions(+), 7 deletions(-) diff --git a/integrated_channels/canvas/client.py b/integrated_channels/canvas/client.py index 277f9488fc..d866c24d6e 100644 --- a/integrated_channels/canvas/client.py +++ b/integrated_channels/canvas/client.py @@ -20,6 +20,7 @@ refresh_session_if_expired, stringify_and_store_api_record, ) +from enterprise.models import EnterpriseCustomerUser LOGGER = logging.getLogger(__name__) @@ -682,13 +683,34 @@ def _search_for_canvas_user_by_email(self, user_email): get_users_by_email_response = rsps.json() try: - canvas_user_id = get_users_by_email_response[0]['id'] + canvas_user_id = get_users_by_email_response[0]["id"] + return canvas_user_id except (KeyError, IndexError) as error: - raise ClientError( - "No Canvas user ID found associated with email: {}".format(user_email), - HTTPStatus.NOT_FOUND.value - ) from error - return canvas_user_id + # learner is decommissioned on Canvas side - unlink it from enterprise + try: + enterprise_customer = self.enterprise_configuration.enterprise_customer + # Unlink user from related Enterprise Customer + EnterpriseCustomerUser.objects.unlink_user( + enterprise_customer=enterprise_customer, + user_email=user_email, + ) + raise ClientError( + "No Canvas user ID found associated with email: {} - User unlinked from enterprise now".format( + user_email + ), + HTTPStatus.NOT_FOUND.value, + ) from error + except Exception as e: # pylint: disable=broad-except + LOGGER.error( + generate_formatted_log( + self.enterprise_configuration.channel_code(), + self.enterprise_configuration.enterprise_customer.uuid, + None, + None, + f"Error occurred while unlinking a Canvas learner: {user_email}. " + f"Error: {e}", + ) + ) def _get_canvas_user_courses_by_id(self, user_id): """Helper method to retrieve all courses that a Canvas user is enrolled in.""" diff --git a/tests/test_integrated_channels/test_canvas/test_client.py b/tests/test_integrated_channels/test_canvas/test_client.py index 278206286d..f3a6820f9e 100644 --- a/tests/test_integrated_channels/test_canvas/test_client.py +++ b/tests/test_integrated_channels/test_canvas/test_client.py @@ -7,10 +7,13 @@ import random import unittest from unittest import mock -from urllib.parse import urljoin +from urllib.parse import quote_plus, urljoin +from unittest.mock import patch import pytest import responses +import requests + from freezegun import freeze_time from requests.models import Response @@ -21,6 +24,7 @@ from integrated_channels.exceptions import ClientError from integrated_channels.integrated_channel.client import IntegratedChannelHealthStatus from test_utils import factories +from enterprise.models import EnterpriseCustomerUser IntegratedChannelAPIRequestLogs = apps.get_model( "integrated_channel", "IntegratedChannelAPIRequestLogs" @@ -419,6 +423,30 @@ def test_completion_level_reporting_included_in_final_grade(self): 'is_assessment_grade' ) + @responses.activate + def test_search_for_deleted_user(self): + """ + ``_search_for_canvas_user_by_email`` should handle exception for deleted users gracefully + by unlinking that user from enterprise + """ + responses.add( + responses.POST, self.oauth_url, json=self._token_response(), status=200 + ) + path = f"/api/v1/accounts/{self.account_id}/users" + query_params = f'?search_term={quote_plus("test@test.com")}' # emails with unique symbols such as `+` cause issues + get_user_id_from_email_url = urljoin(self.url_base, path + query_params) + responses.add(responses.GET, get_user_id_from_email_url, json=[], status=200) + canvas_api_client = CanvasAPIClient(self.enterprise_config) + canvas_api_client._create_session() # pylint: disable=protected-access + assert responses.calls[0].request.url == self.oauth_url + + with mock.patch.object( + EnterpriseCustomerUser.objects, "unlink_user" + ) as unlink_user_mock: + canvas_api_client._search_for_canvas_user_by_email(self.canvas_email) + unlink_user_mock.assert_called_once() + assert len(responses.calls) == 2 + def test_create_client_session_with_oauth_access_key(self): """ Test instantiating the client will fetch and set the session's oauth access key""" with responses.RequestsMock() as rsps: From 873ecb27c608fa21f6b8094a6645937e9400d5a4 Mon Sep 17 00:00:00 2001 From: hamzawaleed01 Date: Tue, 20 Feb 2024 12:47:00 +0500 Subject: [PATCH 129/164] chore: remove unused imports --- integrated_channels/canvas/client.py | 4 ++-- .../test_canvas/test_client.py | 13 ++++++------- 2 files changed, 8 insertions(+), 9 deletions(-) diff --git a/integrated_channels/canvas/client.py b/integrated_channels/canvas/client.py index d866c24d6e..e8083a9a2f 100644 --- a/integrated_channels/canvas/client.py +++ b/integrated_channels/canvas/client.py @@ -12,6 +12,7 @@ from django.apps import apps +from enterprise.models import EnterpriseCustomerUser from integrated_channels.canvas.utils import CanvasUtil # pylint: disable=cyclic-import from integrated_channels.exceptions import ClientError from integrated_channels.integrated_channel.client import IntegratedChannelApiClient, IntegratedChannelHealthStatus @@ -20,7 +21,6 @@ refresh_session_if_expired, stringify_and_store_api_record, ) -from enterprise.models import EnterpriseCustomerUser LOGGER = logging.getLogger(__name__) @@ -651,7 +651,7 @@ def _extract_integration_id(self, data): return integration_id - def _search_for_canvas_user_by_email(self, user_email): + def _search_for_canvas_user_by_email(self, user_email): # pylint: disable=inconsistent-return-statements """ Helper method to make an api call to Canvas using the user's email as a search term. diff --git a/tests/test_integrated_channels/test_canvas/test_client.py b/tests/test_integrated_channels/test_canvas/test_client.py index f3a6820f9e..ca2dc912af 100644 --- a/tests/test_integrated_channels/test_canvas/test_client.py +++ b/tests/test_integrated_channels/test_canvas/test_client.py @@ -8,23 +8,20 @@ import unittest from unittest import mock from urllib.parse import quote_plus, urljoin -from unittest.mock import patch import pytest import responses -import requests - from freezegun import freeze_time from requests.models import Response from django.apps import apps +from enterprise.models import EnterpriseCustomerUser from integrated_channels.canvas.client import MESSAGE_WHEN_COURSE_WAS_DELETED, CanvasAPIClient from integrated_channels.canvas.utils import CanvasUtil from integrated_channels.exceptions import ClientError from integrated_channels.integrated_channel.client import IntegratedChannelHealthStatus from test_utils import factories -from enterprise.models import EnterpriseCustomerUser IntegratedChannelAPIRequestLogs = apps.get_model( "integrated_channel", "IntegratedChannelAPIRequestLogs" @@ -433,8 +430,10 @@ def test_search_for_deleted_user(self): responses.POST, self.oauth_url, json=self._token_response(), status=200 ) path = f"/api/v1/accounts/{self.account_id}/users" - query_params = f'?search_term={quote_plus("test@test.com")}' # emails with unique symbols such as `+` cause issues - get_user_id_from_email_url = urljoin(self.url_base, path + query_params) + # emails with unique symbols such as `+` cause issues + query_params = f'?search_term={quote_plus("test@test.com")}' + get_user_id_from_email_url = urljoin( + self.url_base, path + query_params) responses.add(responses.GET, get_user_id_from_email_url, json=[], status=200) canvas_api_client = CanvasAPIClient(self.enterprise_config) canvas_api_client._create_session() # pylint: disable=protected-access @@ -443,7 +442,7 @@ def test_search_for_deleted_user(self): with mock.patch.object( EnterpriseCustomerUser.objects, "unlink_user" ) as unlink_user_mock: - canvas_api_client._search_for_canvas_user_by_email(self.canvas_email) + canvas_api_client._search_for_canvas_user_by_email(self.canvas_email) # pylint: disable=protected-access unlink_user_mock.assert_called_once() assert len(responses.calls) == 2 From d7f0cfe13081233bbf5af561590f1574f0cfcb4b Mon Sep 17 00:00:00 2001 From: hamzawaleed01 Date: Tue, 20 Feb 2024 14:23:05 +0500 Subject: [PATCH 130/164] refactor: merge new test with existing test --- .../test_canvas/test_client.py | 74 +++++-------------- 1 file changed, 18 insertions(+), 56 deletions(-) diff --git a/tests/test_integrated_channels/test_canvas/test_client.py b/tests/test_integrated_channels/test_canvas/test_client.py index ca2dc912af..45ad649df3 100644 --- a/tests/test_integrated_channels/test_canvas/test_client.py +++ b/tests/test_integrated_channels/test_canvas/test_client.py @@ -7,7 +7,7 @@ import random import unittest from unittest import mock -from urllib.parse import quote_plus, urljoin +from urllib.parse import urljoin import pytest import responses @@ -162,39 +162,27 @@ def test_expires_at_is_updated_after_session_expiry(self): canvas_api_client._create_session() # pylint: disable=protected-access assert canvas_api_client.expires_at > orig_expires_at + @responses.activate def test_search_for_canvas_user_with_400(self): """ - Test that we properly raise exceptions if the client can't find the edx user in Canvas while reporting - grades (assessment and course level reporting both use the same method of retrieval). + Test that we properly raise exception and unlink user if the client can't find the edx user in Canvas + while reporting grades (assessment and course level reporting both use the same method of retrieval). """ - with responses.RequestsMock() as rsps: - rsps.add( - responses.GET, - self.canvas_users_url, - body="[]", - status=200 - ) - canvas_api_client = CanvasAPIClient(self.enterprise_config) - - # Searching for canvas users will require the session to be created - rsps.add( - responses.POST, - self.oauth_url, - json=self._token_response(), - status=200 - ) - canvas_api_client._create_session() # pylint: disable=protected-access + responses.add( + responses.POST, self.oauth_url, json=self._token_response(), status=200 + ) + responses.add(responses.GET, self.canvas_users_url, json=[], status=200) + canvas_api_client = CanvasAPIClient(self.enterprise_config) + canvas_api_client._create_session() # pylint: disable=protected-access + assert responses.calls[0].request.url == self.oauth_url - with pytest.raises(ClientError) as client_error: - canvas_api_client._search_for_canvas_user_by_email(self.canvas_email) # pylint: disable=protected-access - assert IntegratedChannelAPIRequestLogs.objects.count() == 2 - assert client_error.value.message == \ - "Course: {course_id} not found registered in Canvas for Edx " \ - "learner: {canvas_email}/Canvas learner: {canvas_user_id}.".format( - course_id=self.course_id, - canvas_email=self.canvas_email, - canvas_user_id=self.canvas_user_id - ) + with mock.patch.object( + EnterpriseCustomerUser.objects, "unlink_user" + ) as unlink_user_mock: + canvas_api_client._search_for_canvas_user_by_email(self.canvas_email) # pylint: disable=protected-access + unlink_user_mock.assert_called_once() + assert len(responses.calls) == 2 + assert IntegratedChannelAPIRequestLogs.objects.count() == 2 def test_assessment_reporting_with_no_canvas_course_found(self): """ @@ -420,32 +408,6 @@ def test_completion_level_reporting_included_in_final_grade(self): 'is_assessment_grade' ) - @responses.activate - def test_search_for_deleted_user(self): - """ - ``_search_for_canvas_user_by_email`` should handle exception for deleted users gracefully - by unlinking that user from enterprise - """ - responses.add( - responses.POST, self.oauth_url, json=self._token_response(), status=200 - ) - path = f"/api/v1/accounts/{self.account_id}/users" - # emails with unique symbols such as `+` cause issues - query_params = f'?search_term={quote_plus("test@test.com")}' - get_user_id_from_email_url = urljoin( - self.url_base, path + query_params) - responses.add(responses.GET, get_user_id_from_email_url, json=[], status=200) - canvas_api_client = CanvasAPIClient(self.enterprise_config) - canvas_api_client._create_session() # pylint: disable=protected-access - assert responses.calls[0].request.url == self.oauth_url - - with mock.patch.object( - EnterpriseCustomerUser.objects, "unlink_user" - ) as unlink_user_mock: - canvas_api_client._search_for_canvas_user_by_email(self.canvas_email) # pylint: disable=protected-access - unlink_user_mock.assert_called_once() - assert len(responses.calls) == 2 - def test_create_client_session_with_oauth_access_key(self): """ Test instantiating the client will fetch and set the session's oauth access key""" with responses.RequestsMock() as rsps: From 844d9a65b534a3af40b6540cf3f67f4635da7b93 Mon Sep 17 00:00:00 2001 From: MueezKhan246 <93375917+MueezKhan246@users.noreply.github.com> Date: Tue, 20 Feb 2024 15:02:28 +0500 Subject: [PATCH 131/164] Added channel_name for api call logs records (#2023) * feat: added channel_name for api call logs records --- CHANGELOG.rst | 4 ++++ enterprise/__init__.py | 2 +- integrated_channels/blackboard/client.py | 4 ++++ integrated_channels/canvas/client.py | 11 +++++++++++ integrated_channels/canvas/utils.py | 2 ++ integrated_channels/cornerstone/client.py | 1 + integrated_channels/cornerstone/views.py | 3 ++- integrated_channels/degreed2/client.py | 5 +++++ ...gratedchannelapirequestlogs_channel_name.py | 18 ++++++++++++++++++ .../integrated_channel/models.py | 7 +++++++ integrated_channels/moodle/client.py | 4 ++++ .../sap_success_factors/client.py | 4 ++++ integrated_channels/utils.py | 6 +++++- tests/test_integrated_channels/test_utils.py | 8 ++++---- 14 files changed, 72 insertions(+), 7 deletions(-) create mode 100644 integrated_channels/integrated_channel/migrations/0033_integratedchannelapirequestlogs_channel_name.py diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 21f004ee9c..e33c86c79e 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -15,6 +15,10 @@ Change Log Unreleased ---------- +[4.11.14] +--------- +* feat: added channel_name for api call logs records + [4.11.13] --------- * feat: Update IntegratedChannelAPIRequestLogs list view diff --git a/enterprise/__init__.py b/enterprise/__init__.py index 3bf0eda716..0adaa06b59 100644 --- a/enterprise/__init__.py +++ b/enterprise/__init__.py @@ -2,4 +2,4 @@ Your project description goes here. """ -__version__ = "4.11.13" +__version__ = "4.11.14" diff --git a/integrated_channels/blackboard/client.py b/integrated_channels/blackboard/client.py index 4d93163603..a4e871138e 100644 --- a/integrated_channels/blackboard/client.py +++ b/integrated_channels/blackboard/client.py @@ -610,6 +610,7 @@ def _get(self, url, data=None): time_taken=time_taken, status_code=get_response.status_code, response_body=get_response.text, + channel_name=self.enterprise_configuration.channel_code() ) if get_response.status_code >= 400: raise ClientError(get_response.text, get_response.status_code) @@ -630,6 +631,7 @@ def _patch(self, url, data): time_taken=time_taken, status_code=patch_response.status_code, response_body=patch_response.text, + channel_name=self.enterprise_configuration.channel_code() ) if patch_response.status_code >= 400: raise ClientError(patch_response.text, patch_response.status_code) @@ -650,6 +652,7 @@ def _post(self, url, data): time_taken=time_taken, status_code=post_response.status_code, response_body=post_response.text, + channel_name=self.enterprise_configuration.channel_code() ) if post_response.status_code >= 400: @@ -671,6 +674,7 @@ def _delete(self, url): time_taken=time_taken, status_code=response.status_code, response_body=response.text, + channel_name=self.enterprise_configuration.channel_code() ) if response.status_code >= 400: raise ClientError(response.text, response.status_code) diff --git a/integrated_channels/canvas/client.py b/integrated_channels/canvas/client.py index 277f9488fc..8911ccf6ad 100644 --- a/integrated_channels/canvas/client.py +++ b/integrated_channels/canvas/client.py @@ -325,6 +325,7 @@ def cleanup_duplicate_assignment_records(self, courses): time_taken=duration_seconds, status_code=resp.status_code, response_body=resp.text, + channel_name=self.enterprise_configuration.channel_code() ) if resp.status_code >= 400: @@ -557,6 +558,7 @@ def _post(self, url, data): time_taken=duration_seconds, status_code=post_response.status_code, response_body=post_response.text, + channel_name=self.enterprise_configuration.channel_code() ) if post_response.status_code >= 400: @@ -583,6 +585,7 @@ def _put(self, url, data): time_taken=duration_seconds, status_code=put_response.status_code, response_body=put_response.text, + channel_name=self.enterprise_configuration.channel_code() ) if put_response.status_code >= 400: raise ClientError(put_response.text, put_response.status_code) @@ -610,6 +613,7 @@ def _delete(self, url): time_taken=duration_seconds, status_code=delete_response.status_code, response_body=delete_response.text, + channel_name=self.enterprise_configuration.channel_code() ) if delete_response.status_code >= 400: raise ClientError(delete_response.text, delete_response.status_code) @@ -671,6 +675,7 @@ def _search_for_canvas_user_by_email(self, user_email): time_taken=duration_seconds, status_code=rsps.status_code, response_body=rsps.text, + channel_name=self.enterprise_configuration.channel_code() ) if rsps.status_code >= 400: @@ -705,6 +710,7 @@ def _get_canvas_user_courses_by_id(self, user_id): time_taken=duration_seconds, status_code=rsps.status_code, response_body=rsps.text, + channel_name=self.enterprise_configuration.channel_code() ) if rsps.status_code >= 400: @@ -749,6 +755,7 @@ def _handle_canvas_assignment_retrieval( time_taken=duration_seconds, status_code=resp.status_code, response_body=resp.text, + channel_name=self.enterprise_configuration.channel_code() ) more_pages_present = True @@ -796,6 +803,7 @@ def _handle_canvas_assignment_retrieval( time_taken=duration_seconds, status_code=resp.status_code, response_body=resp.text, + channel_name=self.enterprise_configuration.channel_code() ) current_page_count += 1 @@ -828,6 +836,7 @@ def _handle_canvas_assignment_retrieval( time_taken=duration_seconds, status_code=resp.status_code, response_body=resp.text, + channel_name=self.enterprise_configuration.channel_code() ) try: @@ -865,6 +874,7 @@ def _handle_canvas_assignment_submission(self, grade, course_id, assignment_id, time_taken=duration_seconds, status_code=submission_response.status_code, response_body=submission_response.text, + channel_name=self.enterprise_configuration.channel_code() ) if submission_response.status_code >= 400: @@ -979,6 +989,7 @@ def _get_oauth_access_token(self): time_taken=duration_seconds, status_code=auth_response.status_code, response_body=auth_response.text, + channel_name=self.enterprise_configuration.channel_code() ) if auth_response.status_code >= 400: raise ClientError(auth_response.text, auth_response.status_code) diff --git a/integrated_channels/canvas/utils.py b/integrated_channels/canvas/utils.py index 4a5321cfe8..3f9c82f551 100644 --- a/integrated_channels/canvas/utils.py +++ b/integrated_channels/canvas/utils.py @@ -54,6 +54,7 @@ def find_root_canvas_account(enterprise_configuration, session): time_taken=duration_seconds, status_code=resp.status_code, response_body=resp.text, + channel_name=enterprise_configuration.channel_code() ) all_accounts = resp.json() root_account = None @@ -97,6 +98,7 @@ def find_course_in_account(enterprise_configuration, session, canvas_account_id, time_taken=duration_seconds, status_code=resp.status_code, response_body=resp.text, + channel_name=enterprise_configuration.channel_code() ) all_courses_response = resp.json() diff --git a/integrated_channels/cornerstone/client.py b/integrated_channels/cornerstone/client.py index 6f4a74244e..97b7db0dfc 100644 --- a/integrated_channels/cornerstone/client.py +++ b/integrated_channels/cornerstone/client.py @@ -126,6 +126,7 @@ def create_course_completion(self, user_id, payload): time_taken=duration_seconds, status_code=response.status_code, response_body=response.text, + channel_name=self.enterprise_configuration.channel_code() ) return response.status_code, response.text diff --git a/integrated_channels/cornerstone/views.py b/integrated_channels/cornerstone/views.py index 47308b86ff..457cbdd2d3 100644 --- a/integrated_channels/cornerstone/views.py +++ b/integrated_channels/cornerstone/views.py @@ -173,6 +173,7 @@ def get(self, request, *args, **kwargs): payload=f"Request Headers: {headers_json}", time_taken=duration_seconds, status_code=200, - response_body=json.dumps(data) + response_body=json.dumps(data), + channel_name=enterprise_config.channel_code() ) return Response(data) diff --git a/integrated_channels/degreed2/client.py b/integrated_channels/degreed2/client.py index 95b8d1eae1..7929f12de9 100644 --- a/integrated_channels/degreed2/client.py +++ b/integrated_channels/degreed2/client.py @@ -487,6 +487,7 @@ def _get(self, url, scope): time_taken=duration_seconds, status_code=response.status_code, response_body=response.text, + channel_name=self.enterprise_configuration.channel_code() ) if attempts <= self.MAX_RETRIES and response.status_code == 429: sleep_seconds = self._calculate_backoff(attempts) @@ -531,6 +532,7 @@ def _post(self, url, data, scope): time_taken=duration_seconds, status_code=response.status_code, response_body=response.text, + channel_name=self.enterprise_configuration.channel_code() ) if attempts <= self.MAX_RETRIES and response.status_code == 429: sleep_seconds = self._calculate_backoff(attempts) @@ -575,6 +577,7 @@ def _patch(self, url, data, scope): time_taken=duration_seconds, status_code=response.status_code, response_body=response.text, + channel_name=self.enterprise_configuration.channel_code() ) if attempts <= self.MAX_RETRIES and response.status_code == 429: sleep_seconds = self._calculate_backoff(attempts) @@ -619,6 +622,7 @@ def _delete(self, url, data, scope): time_taken=duration_seconds, status_code=response.status_code, response_body=response.text, + channel_name=self.enterprise_configuration.channel_code() ) if attempts <= self.MAX_RETRIES and response.status_code == 429: sleep_seconds = self._calculate_backoff(attempts) @@ -682,6 +686,7 @@ def _get_oauth_access_token(self, scope): time_taken=duration_seconds, status_code=response.status_code, response_body=response.text, + channel_name=self.enterprise_configuration.channel_code() ) try: diff --git a/integrated_channels/integrated_channel/migrations/0033_integratedchannelapirequestlogs_channel_name.py b/integrated_channels/integrated_channel/migrations/0033_integratedchannelapirequestlogs_channel_name.py new file mode 100644 index 0000000000..6c6fec4596 --- /dev/null +++ b/integrated_channels/integrated_channel/migrations/0033_integratedchannelapirequestlogs_channel_name.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.23 on 2024-02-16 07:05 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('integrated_channel', '0032_alter_integratedchannelapirequestlogs_endpoint'), + ] + + operations = [ + migrations.AddField( + model_name='integratedchannelapirequestlogs', + name='channel_name', + field=models.TextField(blank=True, help_text='Name of the integrated channel associated with this API call log record.'), + ), + ] diff --git a/integrated_channels/integrated_channel/models.py b/integrated_channels/integrated_channel/models.py index 44ab495e35..792e60ee5a 100644 --- a/integrated_channels/integrated_channel/models.py +++ b/integrated_channels/integrated_channel/models.py @@ -907,6 +907,10 @@ class IntegratedChannelAPIRequestLogs(TimeStampedModel): response_body = models.TextField( help_text="API call response body", blank=True, null=True ) + channel_name = models.TextField( + help_text="Name of the integrated channel associated with this API call log record.", + blank=True + ) class Meta: app_label = "integrated_channel" @@ -942,6 +946,7 @@ def store_api_call( time_taken, status_code, response_body, + channel_name ): """ Creates new record in IntegratedChannelAPIRequestLogs table. @@ -955,6 +960,7 @@ def store_api_call( time_taken=time_taken, status_code=status_code, response_body=response_body, + channel_name=channel_name ) record.save() except Exception as e: # pylint: disable=broad-except @@ -967,4 +973,5 @@ def store_api_call( f"time_taken={time_taken}" f"status_code={status_code}" f"response_body={response_body}" + f"channel_name={channel_name}" ) diff --git a/integrated_channels/moodle/client.py b/integrated_channels/moodle/client.py index 6192311250..cd883e12a3 100644 --- a/integrated_channels/moodle/client.py +++ b/integrated_channels/moodle/client.py @@ -169,6 +169,7 @@ def _post(self, additional_params): time_taken=duration_seconds, status_code=response.status_code, response_body=response.text, + channel_name=self.enterprise_configuration.channel_code() ) return response @@ -213,6 +214,7 @@ def _get_access_token(self): time_taken=duration_seconds, status_code=response.status_code, response_body=response.text, + channel_name=self.enterprise_configuration.channel_code() ) try: @@ -287,6 +289,7 @@ def _get_course_contents(self, course_id): time_taken=duration_seconds, status_code=response.status_code, response_body=response.text, + channel_name=self.enterprise_configuration.channel_code() ) return response @@ -347,6 +350,7 @@ def _get_courses(self, key): time_taken=duration_seconds, status_code=response.status_code, response_body=response.text, + channel_name=self.enterprise_configuration.channel_code() ) return response diff --git a/integrated_channels/sap_success_factors/client.py b/integrated_channels/sap_success_factors/client.py index 3ba32ec971..a4e89eadc7 100644 --- a/integrated_channels/sap_success_factors/client.py +++ b/integrated_channels/sap_success_factors/client.py @@ -102,6 +102,7 @@ def get_oauth_access_token(self, client_id, client_secret, company_id, user_id, time_taken=duration_seconds, status_code=response.status_code, response_body=response.text, + channel_name=self.enterprise_configuration.channel_code() ) try: @@ -289,6 +290,7 @@ def _call_post_with_user_override(self, sap_user_id, url, payload): time_taken=duration_seconds, status_code=response.status_code, response_body=response.text, + channel_name=self.enterprise_configuration.channel_code() ) if response.status_code >= 400: @@ -320,6 +322,7 @@ def _call_post_with_session(self, url, payload): time_taken=duration_seconds, status_code=response.status_code, response_body=response.text, + channel_name=self.enterprise_configuration.channel_code() ) if response.status_code >= 400: LOGGER.error( @@ -401,6 +404,7 @@ def _call_search_students_recursively(self, sap_search_student_url, all_inactive time_taken=duration_seconds, status_code=response.status_code, response_body=response.text, + channel_name=self.enterprise_configuration.channel_code() ) sap_inactive_learners = response.json() except ValueError as error: diff --git a/integrated_channels/utils.py b/integrated_channels/utils.py index 9c8a273128..879043a9e0 100644 --- a/integrated_channels/utils.py +++ b/integrated_channels/utils.py @@ -517,7 +517,8 @@ def stringify_and_store_api_record( data, time_taken, status_code, - response_body + response_body, + channel_name ): """ Stringify the given data and store the API record in the database. @@ -537,6 +538,7 @@ def stringify_and_store_api_record( f"stringify_and_store_api_record: Error occured during stringification: {e}" f"enterprise_customer={enterprise_customer}" f"enterprise_customer_configuration_id={enterprise_customer_configuration_id}" + f"channel name={channel_name}" f"data={data}" ) # Store stringified data in the database @@ -549,12 +551,14 @@ def stringify_and_store_api_record( time_taken=time_taken, status_code=status_code, response_body=response_body, + channel_name=channel_name ) except Exception as e: # pylint: disable=broad-except LOGGER.error( f"stringify_and_store_api_record: Error occured while storing: {e}" f"enterprise_customer={enterprise_customer}" f"enterprise_customer_configuration_id={enterprise_customer_configuration_id}" + f"channel name={channel_name}" f"data={data}" ) return data diff --git a/tests/test_integrated_channels/test_utils.py b/tests/test_integrated_channels/test_utils.py index c7910be641..1725666c75 100644 --- a/tests/test_integrated_channels/test_utils.py +++ b/tests/test_integrated_channels/test_utils.py @@ -359,26 +359,26 @@ def test_stringify_and_store_api_record( # Test with dict input stringified_data = utils.stringify_and_store_api_record( - "Customer", 123, "/endpoint", data, 1.23, 200, "response" + "Customer", 123, "/endpoint", data, 1.23, 200, "response", 'integrated_channel_name' ) assert stringified_data == json.dumps(data) # Test with int input stringified_int = utils.stringify_and_store_api_record( - "Customer", 123, "/endpoint", 123, 1.23, 200, "response" + "Customer", 123, "/endpoint", 123, 1.23, 200, "response", 'integrated_channel_name' ) assert stringified_int == "123" # Test with tuple input data_tuple = (1, 2, "hello") stringified_tuple = utils.stringify_and_store_api_record( - "Customer", 123, "/endpoint", data_tuple, 1.23, 200, "response" + "Customer", 123, "/endpoint", data_tuple, 1.23, 200, "response", 'integrated_channel_name' ) assert stringified_tuple == json.dumps(data_tuple) # Test with list input data_list = [1, 2, "world"] stringified_list = utils.stringify_and_store_api_record( - "Customer", 123, "/endpoint", data_list, 1.23, 200, "response" + "Customer", 123, "/endpoint", data_list, 1.23, 200, "response", 'integrated_channel_name' ) assert stringified_list == json.dumps(data_list) From 81b907417ca311a32e8be86a6206cb2e9a3c8c68 Mon Sep 17 00:00:00 2001 From: Alexander J Sheehan Date: Fri, 16 Feb 2024 19:28:25 +0000 Subject: [PATCH 132/164] feat: CRUD api endpoints for the enterprise group table --- CHANGELOG.rst | 4 + enterprise/__init__.py | 2 +- enterprise/api/v1/serializers.py | 9 ++ enterprise/api/v1/urls.py | 4 + enterprise/api/v1/views/enterprise_group.py | 49 +++++++ tests/test_enterprise/api/test_views.py | 140 ++++++++++++++++++++ 6 files changed, 207 insertions(+), 1 deletion(-) create mode 100644 enterprise/api/v1/views/enterprise_group.py diff --git a/CHANGELOG.rst b/CHANGELOG.rst index e33c86c79e..2404ed7742 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -15,6 +15,10 @@ Change Log Unreleased ---------- +[4.11.15] +--------- +* feat: CRUD api endpoints for the enterprise group table + [4.11.14] --------- * feat: added channel_name for api call logs records diff --git a/enterprise/__init__.py b/enterprise/__init__.py index 0adaa06b59..97a0074ddd 100644 --- a/enterprise/__init__.py +++ b/enterprise/__init__.py @@ -2,4 +2,4 @@ Your project description goes here. """ -__version__ = "4.11.14" +__version__ = "4.11.15" diff --git a/enterprise/api/v1/serializers.py b/enterprise/api/v1/serializers.py index 0c171d32a3..cfd7c2c0aa 100644 --- a/enterprise/api/v1/serializers.py +++ b/enterprise/api/v1/serializers.py @@ -802,6 +802,15 @@ def to_representation(self, instance): return updated_course +class EnterpriseGroupSerializer(serializers.ModelSerializer): + """ + Serializer for EnterpriseGroup model. + """ + class Meta: + model = models.EnterpriseGroup + fields = ('enterprise_customer', 'name', 'uuid') + + class CourseRunDetailSerializer(ImmutableStateSerializer): """ Serializer for course run data retrieved from the discovery service course_run detail API endpoint. diff --git a/enterprise/api/v1/urls.py b/enterprise/api/v1/urls.py index 519535da28..c1a5ec644c 100644 --- a/enterprise/api/v1/urls.py +++ b/enterprise/api/v1/urls.py @@ -19,6 +19,7 @@ enterprise_customer_reporting, enterprise_customer_sso_configuration, enterprise_customer_user, + enterprise_group, enterprise_subsidy_fulfillment, notifications, pending_enterprise_customer_user, @@ -71,6 +72,9 @@ router.register( "enterprise_catalogs", enterprise_customer_catalog.EnterpriseCustomerCatalogViewSet, 'enterprise-catalogs' ) +router.register( + "enterprise_group", enterprise_group.EnterpriseGroupViewSet, 'enterprise-group' +) urlpatterns = [ diff --git a/enterprise/api/v1/views/enterprise_group.py b/enterprise/api/v1/views/enterprise_group.py new file mode 100644 index 0000000000..59f053bc10 --- /dev/null +++ b/enterprise/api/v1/views/enterprise_group.py @@ -0,0 +1,49 @@ +""" +Views for the ``enterprise-group`` API endpoint. +""" + +from django_filters.rest_framework import DjangoFilterBackend +from rest_framework import filters, permissions + +from django.db.models import Q + +from enterprise import models +from enterprise.api.v1 import serializers +from enterprise.api.v1.views.base_views import EnterpriseReadWriteModelViewSet + + +class EnterpriseGroupViewSet(EnterpriseReadWriteModelViewSet): + """ + API views for the ``enterprise-group`` API endpoint. + """ + queryset = models.EnterpriseGroup.objects.all() + permission_classes = (permissions.IsAuthenticated,) + filter_backends = (filters.OrderingFilter, DjangoFilterBackend,) + serializer_class = serializers.EnterpriseGroupSerializer + + def get_queryset(self, **kwargs): + """ + - Filter down the queryset of groups available to the requesting user. + - Account for requested filtering query params + """ + queryset = self.queryset + if not self.request.user.is_staff: + enterprise_user_objects = models.EnterpriseCustomerUser.objects.filter( + user_id=self.request.user.id, + active=True, + ) + associated_customers = [] + for user_object in enterprise_user_objects: + associated_customers.append(user_object.enterprise_customer) + queryset = queryset.filter(enterprise_customer__in=associated_customers) + + if self.request.method in ('GET',): + if learner_uuids := self.request.query_params.getlist('learner_uuids'): + # groups can apply to both existing and pending users + queryset = queryset.filter( + Q(members__enterprise_customer_user__in=learner_uuids) | + Q(members__pending_enterprise_customer_user__in=learner_uuids), + ) + if enterprise_uuids := self.request.query_params.getlist('enterprise_uuids'): + queryset = queryset.filter(enterprise_customer__in=enterprise_uuids) + return queryset diff --git a/tests/test_enterprise/api/test_views.py b/tests/test_enterprise/api/test_views.py index e704ab7071..523fde4e78 100644 --- a/tests/test_enterprise/api/test_views.py +++ b/tests/test_enterprise/api/test_views.py @@ -53,6 +53,8 @@ EnterpriseEnrollmentSource, EnterpriseFeatureRole, EnterpriseFeatureUserRoleAssignment, + EnterpriseGroup, + EnterpriseGroupMembership, LearnerCreditEnterpriseCourseEnrollment, LicensedEnterpriseCourseEnrollment, PendingEnrollment, @@ -87,6 +89,8 @@ EnterpriseCustomerFactory, EnterpriseCustomerSsoConfigurationFactory, EnterpriseCustomerUserFactory, + EnterpriseGroupFactory, + EnterpriseGroupMembershipFactory, PendingEnterpriseCustomerUserFactory, UserFactory, ) @@ -7245,6 +7249,142 @@ def test_api_credentials_delete(self): assert not Application.objects.filter(user=user).exists() +@mark.django_db +class TestEnterpriseGroupViewSet(APITest): + """ + Tests for the EnterpriseGroupViewSet + """ + def setUp(self): + super().setUp() + self.enterprise_customer = EnterpriseCustomerFactory() + self.user = UserFactory( + is_active=True, + is_staff=False, + ) + self.enterprise_customer_user = EnterpriseCustomerUserFactory( + user_id=self.user.id, enterprise_customer=self.enterprise_customer + ) + self.user.set_password(TEST_PASSWORD) + self.user.save() + self.client = APIClient() + self.client.login(username=self.user.username, password=TEST_PASSWORD) + + self.group_1 = EnterpriseGroupFactory(enterprise_customer=self.enterprise_customer) + self.group_2 = EnterpriseGroupFactory() + for _ in range(5): + EnterpriseGroupMembershipFactory( + group=self.group_1, + pending_enterprise_customer_user=None, + enterprise_customer_user__enterprise_customer=self.enterprise_customer, + ) + + def test_group_permissions(self): + """ + Test that the requesting user must be authenticated + """ + self.client.logout() + url = settings.TEST_SERVER + reverse( + 'enterprise-group-list', + ) + response = self.client.get(url) + assert response.status_code == 401 + + def test_successful_list_groups(self): + """ + Test a successful GET request to the list endpoint. + """ + # url: 'http://testserver/enterprise/api/v1/enterprise_group/' + url = settings.TEST_SERVER + reverse( + 'enterprise-group-list', + ) + response = self.client.get(url) + assert response.json().get('count') == 1 + assert response.json().get('results')[0].get('uuid') == str(self.group_1.uuid) + + def test_successful_retrieve_group(self): + """ + Test retrieving a single group record + """ + # url: 'http://testserver/enterprise/api/v1/enterprise_group/' + url = settings.TEST_SERVER + reverse( + 'enterprise-group-detail', + kwargs={'pk': self.group_1.uuid}, + ) + response = self.client.get(url) + assert response.json().get('uuid') == str(self.group_1.uuid) + + def test_successful_list_with_filters(self): + """ + Test that the list endpoint can be filtered down via query params + """ + url = settings.TEST_SERVER + reverse('enterprise-group-list') + new_group = EnterpriseGroupFactory() + new_membership = EnterpriseGroupMembershipFactory(group=new_group) + EnterpriseCustomerUserFactory( + user_id=self.user.id, enterprise_customer=new_group.enterprise_customer, + ) + learner_query_param = f"?learner_uuids={new_membership.pending_enterprise_customer_user.id}" + learner_filtered_response = self.client.get(url + learner_query_param) + assert len(learner_filtered_response.json().get('results')) == 1 + assert learner_filtered_response.json().get('results')[0].get('uuid') == str(new_group.uuid) + + enterprise_query_param = f"?enterprise_uuids={new_group.enterprise_customer.uuid}" + enterprise_filtered_response = self.client.get(url + enterprise_query_param) + assert len(enterprise_filtered_response.json().get('results')) == 1 + assert enterprise_filtered_response.json().get('results')[0].get('uuid') == str(new_group.uuid) + + random_enterprise_query_param = f"?enterprise_uuids={uuid.uuid4()}" + response = self.client.get(url + random_enterprise_query_param) + assert not response.json().get('results') + + def test_successful_post_group(self): + """ + Test creating a new group record + """ + # url: 'http://testserver/enterprise/api/v1/enterprise_group/' + url = settings.TEST_SERVER + reverse( + 'enterprise-group-list', + ) + new_customer = EnterpriseCustomerFactory() + request_data = { + 'enterprise_customer': str(new_customer.uuid), + 'name': 'foobar', + } + response = self.client.post(url, data=request_data) + assert response.json().get('name') == 'foobar' + assert len(EnterpriseGroup.objects.filter(name='foobar')) == 1 + + def test_successful_update_group(self): + """ + Test patching an existing group record + """ + # url: 'http://testserver/enterprise/api/v1/enterprise_group/' + url = settings.TEST_SERVER + reverse( + 'enterprise-group-detail', + kwargs={'pk': self.group_1.uuid}, + ) + request_data = {'name': 'ayylmao'} + response = self.client.patch(url, data=request_data) + assert response.json().get('uuid') == str(self.group_1.uuid) + assert response.json().get('name') == 'ayylmao' + assert len(EnterpriseGroup.objects.filter(name='ayylmao')) == 1 + + def test_successful_delete_group(self): + """ + Test deleting a group record + """ + group_to_delete_uuid = EnterpriseGroupFactory(enterprise_customer=self.enterprise_customer).uuid + # url: 'http://testserver/enterprise/api/v1/enterprise_group/' + url = settings.TEST_SERVER + reverse( + 'enterprise-group-detail', + kwargs={'pk': group_to_delete_uuid}, + ) + response = self.client.delete(url) + assert response.status_code == 204 + assert not EnterpriseGroup.objects.filter(uuid=group_to_delete_uuid) + assert not EnterpriseGroupMembership.objects.filter(group=group_to_delete_uuid) + + @mark.django_db class TestEnterpriseCustomerSsoConfigurationViewSet(APITest): """ From ab0ffd65e926b233f50024289eeac1d792663e2e Mon Sep 17 00:00:00 2001 From: Zaman Afzal Date: Wed, 21 Feb 2024 14:46:05 +0500 Subject: [PATCH 133/164] feat: Remove historical records for enterprise_customer_configuration (#2027) --- CHANGELOG.rst | 5 +++++ enterprise/__init__.py | 2 +- ...lblackboardenterprisecustomerconfiguration.py | 16 ++++++++++++++++ integrated_channels/blackboard/models.py | 3 --- ...ricalcanvasenterprisecustomerconfiguration.py | 16 ++++++++++++++++ integrated_channels/canvas/models.py | 3 --- ...cornerstoneenterprisecustomerconfiguration.py | 16 ++++++++++++++++ integrated_channels/cornerstone/models.py | 3 --- ...icaldegreedenterprisecustomerconfiguration.py | 16 ++++++++++++++++ integrated_channels/degreed/models.py | 3 --- ...caldegreed2enterprisecustomerconfiguration.py | 16 ++++++++++++++++ integrated_channels/degreed2/models.py | 4 ---- ...ricalmoodleenterprisecustomerconfiguration.py | 16 ++++++++++++++++ integrated_channels/moodle/models.py | 3 --- 14 files changed, 102 insertions(+), 20 deletions(-) create mode 100644 integrated_channels/blackboard/migrations/0019_delete_historicalblackboardenterprisecustomerconfiguration.py create mode 100644 integrated_channels/canvas/migrations/0034_delete_historicalcanvasenterprisecustomerconfiguration.py create mode 100644 integrated_channels/cornerstone/migrations/0033_delete_historicalcornerstoneenterprisecustomerconfiguration.py create mode 100644 integrated_channels/degreed/migrations/0032_delete_historicaldegreedenterprisecustomerconfiguration.py create mode 100644 integrated_channels/degreed2/migrations/0025_delete_historicaldegreed2enterprisecustomerconfiguration.py create mode 100644 integrated_channels/moodle/migrations/0033_delete_historicalmoodleenterprisecustomerconfiguration.py diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 2404ed7742..8737534702 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -15,6 +15,11 @@ Change Log Unreleased ---------- + +[4.12.0] +--------- +* feat: Remove history tables for integrated channels customers configurations. + [4.11.15] --------- * feat: CRUD api endpoints for the enterprise group table diff --git a/enterprise/__init__.py b/enterprise/__init__.py index 97a0074ddd..da4f333ca2 100644 --- a/enterprise/__init__.py +++ b/enterprise/__init__.py @@ -2,4 +2,4 @@ Your project description goes here. """ -__version__ = "4.11.15" +__version__ = "4.12.0" diff --git a/integrated_channels/blackboard/migrations/0019_delete_historicalblackboardenterprisecustomerconfiguration.py b/integrated_channels/blackboard/migrations/0019_delete_historicalblackboardenterprisecustomerconfiguration.py new file mode 100644 index 0000000000..3fd29be7fb --- /dev/null +++ b/integrated_channels/blackboard/migrations/0019_delete_historicalblackboardenterprisecustomerconfiguration.py @@ -0,0 +1,16 @@ +# Generated by Django 3.2.19 on 2024-02-20 17:39 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('blackboard', '0018_blackboardlearnerdatatransmissionaudit_blackboard_unique_enrollment_course_id'), + ] + + operations = [ + migrations.DeleteModel( + name='HistoricalBlackboardEnterpriseCustomerConfiguration', + ), + ] diff --git a/integrated_channels/blackboard/models.py b/integrated_channels/blackboard/models.py index c48c9e052c..b5e1b83778 100644 --- a/integrated_channels/blackboard/models.py +++ b/integrated_channels/blackboard/models.py @@ -7,7 +7,6 @@ from logging import getLogger from config_models.models import ConfigurationModel -from simple_history.models import HistoricalRecords from six.moves.urllib.parse import urljoin from django.conf import settings @@ -154,8 +153,6 @@ class BlackboardEnterpriseCustomerConfiguration(EnterpriseCustomerPluginConfigur ) ) - history = HistoricalRecords() - class Meta: app_label = 'blackboard' diff --git a/integrated_channels/canvas/migrations/0034_delete_historicalcanvasenterprisecustomerconfiguration.py b/integrated_channels/canvas/migrations/0034_delete_historicalcanvasenterprisecustomerconfiguration.py new file mode 100644 index 0000000000..3f7a381440 --- /dev/null +++ b/integrated_channels/canvas/migrations/0034_delete_historicalcanvasenterprisecustomerconfiguration.py @@ -0,0 +1,16 @@ +# Generated by Django 3.2.19 on 2024-02-20 17:11 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('canvas', '0033_canvaslearnerdatatransmissionaudit_canvas_unique_enrollment_course_id'), + ] + + operations = [ + migrations.DeleteModel( + name='HistoricalCanvasEnterpriseCustomerConfiguration', + ), + ] diff --git a/integrated_channels/canvas/models.py b/integrated_channels/canvas/models.py index b402566ff8..a9ad17af20 100644 --- a/integrated_channels/canvas/models.py +++ b/integrated_channels/canvas/models.py @@ -6,7 +6,6 @@ import uuid from logging import getLogger -from simple_history.models import HistoricalRecords from six.moves.urllib.parse import urljoin from django.conf import settings @@ -96,8 +95,6 @@ class CanvasEnterpriseCustomerConfiguration(EnterpriseCustomerPluginConfiguratio ) ) - history = HistoricalRecords() - class Meta: app_label = 'canvas' diff --git a/integrated_channels/cornerstone/migrations/0033_delete_historicalcornerstoneenterprisecustomerconfiguration.py b/integrated_channels/cornerstone/migrations/0033_delete_historicalcornerstoneenterprisecustomerconfiguration.py new file mode 100644 index 0000000000..914dd4e7ac --- /dev/null +++ b/integrated_channels/cornerstone/migrations/0033_delete_historicalcornerstoneenterprisecustomerconfiguration.py @@ -0,0 +1,16 @@ +# Generated by Django 3.2.19 on 2024-02-20 06:02 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('cornerstone', '0032_delete_cornerstoneapirequestlogs'), + ] + + operations = [ + migrations.DeleteModel( + name='HistoricalCornerstoneEnterpriseCustomerConfiguration', + ), + ] diff --git a/integrated_channels/cornerstone/models.py b/integrated_channels/cornerstone/models.py index 88b0f24e93..009e4240a1 100644 --- a/integrated_channels/cornerstone/models.py +++ b/integrated_channels/cornerstone/models.py @@ -7,7 +7,6 @@ from config_models.models import ConfigurationModel from jsonfield import JSONField -from simple_history.models import HistoricalRecords from django.contrib import auth from django.db import models @@ -126,8 +125,6 @@ class CornerstoneEnterpriseCustomerConfiguration(EnterpriseCustomerPluginConfigu ) ) - history = HistoricalRecords() - class Meta: app_label = 'cornerstone' diff --git a/integrated_channels/degreed/migrations/0032_delete_historicaldegreedenterprisecustomerconfiguration.py b/integrated_channels/degreed/migrations/0032_delete_historicaldegreedenterprisecustomerconfiguration.py new file mode 100644 index 0000000000..6f7fed7738 --- /dev/null +++ b/integrated_channels/degreed/migrations/0032_delete_historicaldegreedenterprisecustomerconfiguration.py @@ -0,0 +1,16 @@ +# Generated by Django 3.2.19 on 2024-02-20 07:04 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('degreed', '0031_alter_historicaldegreedenterprisecustomerconfiguration_options'), + ] + + operations = [ + migrations.DeleteModel( + name='HistoricalDegreedEnterpriseCustomerConfiguration', + ), + ] diff --git a/integrated_channels/degreed/models.py b/integrated_channels/degreed/models.py index 372b36a249..30a1719ce1 100644 --- a/integrated_channels/degreed/models.py +++ b/integrated_channels/degreed/models.py @@ -6,7 +6,6 @@ from logging import getLogger from config_models.models import ConfigurationModel -from simple_history.models import HistoricalRecords from django.db import models @@ -141,8 +140,6 @@ class DegreedEnterpriseCustomerConfiguration(EnterpriseCustomerPluginConfigurati help_text="The provider code that Degreed gives to the content provider." ) - history = HistoricalRecords() - class Meta: app_label = 'degreed' diff --git a/integrated_channels/degreed2/migrations/0025_delete_historicaldegreed2enterprisecustomerconfiguration.py b/integrated_channels/degreed2/migrations/0025_delete_historicaldegreed2enterprisecustomerconfiguration.py new file mode 100644 index 0000000000..82bd3eacd3 --- /dev/null +++ b/integrated_channels/degreed2/migrations/0025_delete_historicaldegreed2enterprisecustomerconfiguration.py @@ -0,0 +1,16 @@ +# Generated by Django 3.2.19 on 2024-02-20 17:57 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('degreed2', '0024_degreed2learnerdatatransmissionaudit_degreed2_unique_enrollment_course_id'), + ] + + operations = [ + migrations.DeleteModel( + name='HistoricalDegreed2EnterpriseCustomerConfiguration', + ), + ] diff --git a/integrated_channels/degreed2/models.py b/integrated_channels/degreed2/models.py index a83ef240d4..01a4d17dd6 100644 --- a/integrated_channels/degreed2/models.py +++ b/integrated_channels/degreed2/models.py @@ -6,8 +6,6 @@ import json from logging import getLogger -from simple_history.models import HistoricalRecords - from django.db import models from integrated_channels.degreed2.exporters.content_metadata import Degreed2ContentMetadataExporter @@ -74,8 +72,6 @@ class Degreed2EnterpriseCustomerConfiguration(EnterpriseCustomerPluginConfigurat help_text="The maximum number of data items to transmit to the integrated channel with each request." ) - history = HistoricalRecords() - class Meta: app_label = 'degreed2' diff --git a/integrated_channels/moodle/migrations/0033_delete_historicalmoodleenterprisecustomerconfiguration.py b/integrated_channels/moodle/migrations/0033_delete_historicalmoodleenterprisecustomerconfiguration.py new file mode 100644 index 0000000000..8660fb7d6b --- /dev/null +++ b/integrated_channels/moodle/migrations/0033_delete_historicalmoodleenterprisecustomerconfiguration.py @@ -0,0 +1,16 @@ +# Generated by Django 3.2.19 on 2024-02-20 17:36 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('moodle', '0032_moodlelearnerdatatransmissionaudit_moodle_unique_enrollment_course_id'), + ] + + operations = [ + migrations.DeleteModel( + name='HistoricalMoodleEnterpriseCustomerConfiguration', + ), + ] diff --git a/integrated_channels/moodle/models.py b/integrated_channels/moodle/models.py index d12f017e50..8352c019c0 100644 --- a/integrated_channels/moodle/models.py +++ b/integrated_channels/moodle/models.py @@ -6,7 +6,6 @@ from logging import getLogger from fernet_fields import EncryptedCharField -from simple_history.models import HistoricalRecords from django.db import models from django.utils.encoding import force_bytes, force_str @@ -185,8 +184,6 @@ def encrypted_token(self, value): default=False, ) - history = HistoricalRecords() - class Meta: app_label = 'moodle' From d92c19936d6f2bb12fa349bab92e8ccd6999a29f Mon Sep 17 00:00:00 2001 From: Katrina Nguyen <71999631+katrinan029@users.noreply.github.com> Date: Thu, 22 Feb 2024 15:00:34 -0800 Subject: [PATCH 134/164] feat: add api /learners/ endpoint to the enterprise group viewset (#2032) feat: add api /learners/ endpoint to the enterprise group viewset --- CHANGELOG.rst | 4 ++ enterprise/__init__.py | 2 +- enterprise/api/v1/serializers.py | 13 ++++ enterprise/api/v1/urls.py | 7 +++ enterprise/api/v1/views/enterprise_group.py | 42 +++++++++++++ tests/test_enterprise/api/test_views.py | 67 ++++++++++++++++++++- 6 files changed, 131 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 5bdc93da28..8bb63ae5c1 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -16,6 +16,10 @@ Change Log Unreleased ---------- +[4.12.2] +--------- +* feat: add api /learners/ endpoint to the enterprise group viewset + [4.12.1] --------- * feat: unlink canvas user if not decommissioned on canvas side diff --git a/enterprise/__init__.py b/enterprise/__init__.py index 2b34b661db..8b3a8d5814 100644 --- a/enterprise/__init__.py +++ b/enterprise/__init__.py @@ -2,4 +2,4 @@ Your project description goes here. """ -__version__ = "4.12.1" +__version__ = "4.12.2" diff --git a/enterprise/api/v1/serializers.py b/enterprise/api/v1/serializers.py index cfd7c2c0aa..d0a3ff28df 100644 --- a/enterprise/api/v1/serializers.py +++ b/enterprise/api/v1/serializers.py @@ -811,6 +811,19 @@ class Meta: fields = ('enterprise_customer', 'name', 'uuid') +class EnterpriseGroupMembershipSerializer(serializers.ModelSerializer): + """ + Serializer for EnterpriseGroupMembership model. + """ + learner_id = serializers.IntegerField(source='enterprise_customer_user.id', allow_null=True) + pending_learner_id = serializers.IntegerField(source='pending_enterprise_customer_user.id', allow_null=True) + enterprise_group_membership_uuid = serializers.UUIDField(source='uuid', allow_null=True, read_only=True) + + class Meta: + model = models.EnterpriseGroupMembership + fields = ('learner_id', 'pending_learner_id', 'enterprise_group_membership_uuid') + + class CourseRunDetailSerializer(ImmutableStateSerializer): """ Serializer for course run data retrieved from the discovery service course_run detail API endpoint. diff --git a/enterprise/api/v1/urls.py b/enterprise/api/v1/urls.py index c1a5ec644c..0c8e524ba6 100644 --- a/enterprise/api/v1/urls.py +++ b/enterprise/api/v1/urls.py @@ -174,6 +174,13 @@ ), name='enterprise-customer-sso-configuration-base' ), + re_path( + r'^enterprise-group/(?P[A-Za-z0-9-]+)/learners/?$', + enterprise_group.EnterpriseGroupViewSet.as_view( + {'get': 'get_learners'} + ), + name='enterprise-group-learners' + ), ] urlpatterns += router.urls diff --git a/enterprise/api/v1/views/enterprise_group.py b/enterprise/api/v1/views/enterprise_group.py index 59f053bc10..525536b40f 100644 --- a/enterprise/api/v1/views/enterprise_group.py +++ b/enterprise/api/v1/views/enterprise_group.py @@ -4,12 +4,17 @@ from django_filters.rest_framework import DjangoFilterBackend from rest_framework import filters, permissions +from rest_framework.decorators import action from django.db.models import Q +from django.http import Http404 from enterprise import models from enterprise.api.v1 import serializers from enterprise.api.v1.views.base_views import EnterpriseReadWriteModelViewSet +from enterprise.logging import getEnterpriseLogger + +LOGGER = getEnterpriseLogger(__name__) class EnterpriseGroupViewSet(EnterpriseReadWriteModelViewSet): @@ -47,3 +52,40 @@ def get_queryset(self, **kwargs): if enterprise_uuids := self.request.query_params.getlist('enterprise_uuids'): queryset = queryset.filter(enterprise_customer__in=enterprise_uuids) return queryset + + @action(detail=True, methods=['get']) + def get_learners(self, *args, **kwargs): + """ + Endpoint Location: GET api/v1/enterprise-group//learners/ + + Request Arguments: + - ``group_uuid`` (URL location, required): The uuid of the group from which learners should be listed. + + Returns: Paginated list of learners that are associated with the enterprise group uuid:: + + { + 'count': 1, + 'next': null, + 'previous': null, + 'results': [ + { + 'learner_uuid': 'enterprise_customer_user_id', + 'pending_learner_id': 'pending_enterprise_customer_user_id', + 'enterprise_group_membership_uuid': 'enterprise_group_membership_uuid', + }, + ], + } + + """ + + group_uuid = kwargs.get('group_uuid') + try: + learner_list = self.get_queryset().get(uuid=group_uuid).members.all() + page = self.paginate_queryset(learner_list) + serializer = serializers.EnterpriseGroupMembershipSerializer(page, many=True) + response = self.get_paginated_response(serializer.data) + return response + + except models.EnterpriseGroup.DoesNotExist as exc: + LOGGER.warning(f"group_uuid {group_uuid} does not exist") + raise Http404 from exc diff --git a/tests/test_enterprise/api/test_views.py b/tests/test_enterprise/api/test_views.py index 523fde4e78..4efcf0d215 100644 --- a/tests/test_enterprise/api/test_views.py +++ b/tests/test_enterprise/api/test_views.py @@ -7271,12 +7271,13 @@ def setUp(self): self.group_1 = EnterpriseGroupFactory(enterprise_customer=self.enterprise_customer) self.group_2 = EnterpriseGroupFactory() - for _ in range(5): - EnterpriseGroupMembershipFactory( + self.enterprise_group_memberships = [] + for _ in range(11): + self.enterprise_group_memberships.append(EnterpriseGroupMembershipFactory( group=self.group_1, pending_enterprise_customer_user=None, enterprise_customer_user__enterprise_customer=self.enterprise_customer, - ) + )) def test_group_permissions(self): """ @@ -7313,6 +7314,66 @@ def test_successful_retrieve_group(self): response = self.client.get(url) assert response.json().get('uuid') == str(self.group_1.uuid) + def test_successful_list_learners(self): + """ + Test a successful GET request to the list endpoint. + """ + # url: 'http://testserver/enterprise/api/v1/enterprise_group//learners/' + url = settings.TEST_SERVER + reverse( + 'enterprise-group-learners', + kwargs={'group_uuid': self.group_1.uuid}, + ) + results_list = [] + for i in reversed(range(1, 11)): + results_list.append( + { + 'learner_id': self.enterprise_group_memberships[i].enterprise_customer_user.id, + 'pending_learner_id': None, + 'enterprise_group_membership_uuid': str(self.enterprise_group_memberships[i].uuid), + }, + ) + expected_response = { + 'count': 11, + 'next': f'http://testserver/enterprise/api/v1/enterprise-group/{self.group_1.uuid}/learners?page=2', + 'previous': None, + 'results': results_list, + } + response = self.client.get(url) + assert response.json() == expected_response + # verify page 2 of paginated response + url_page_2 = settings.TEST_SERVER + reverse( + 'enterprise-group-learners', + kwargs={'group_uuid': self.group_1.uuid}, + ) + '?page=2' + page_2_response = self.client.get(url_page_2) + expected_response_page_2 = { + 'count': 11, + 'next': None, + 'previous': f'http://testserver/enterprise/api/v1/enterprise-group/{self.group_1.uuid}/learners', + 'results': [ + { + 'learner_id': self.enterprise_group_memberships[0].enterprise_customer_user.id, + 'pending_learner_id': None, + 'enterprise_group_membership_uuid': str(self.enterprise_group_memberships[0].uuid), + } + ], + } + assert page_2_response.json() == expected_response_page_2 + + def test_group_uuid_not_found(self): + """ + Verify that the endpoint api/v1/enterprise_group//learners/ + returns 404 when the group_uuid is not found. + """ + # url: 'http://testserver/enterprise/api/v1/enterprise_group//learners/' + group_uuid = fake.uuid4() + url = settings.TEST_SERVER + reverse( + 'enterprise-group-learners', + kwargs={'group_uuid': group_uuid}, + ) + response = self.client.get(url) + assert response.status_code == 404 + def test_successful_list_with_filters(self): """ Test that the list endpoint can be filtered down via query params From 454ccae9abb2ad98f12be27f3a2d34e951bf39db Mon Sep 17 00:00:00 2001 From: MueezKhan246 <93375917+MueezKhan246@users.noreply.github.com> Date: Mon, 26 Feb 2024 17:48:07 +0500 Subject: [PATCH 135/164] Added management command to clear out excessive records for integrated channel API request log table (#2029) * feat: management command to clear out excessive records for API log table --- CHANGELOG.rst | 4 ++ enterprise/__init__.py | 2 +- .../integrated_channel/admin/__init__.py | 2 +- ...emove_stale_integrated_channel_api_logs.py | 41 ++++++++++++ tests/test_management.py | 64 ++++++++++++++++++- 5 files changed, 110 insertions(+), 3 deletions(-) create mode 100644 integrated_channels/integrated_channel/management/commands/remove_stale_integrated_channel_api_logs.py diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 8bb63ae5c1..5fd0d8f174 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -16,6 +16,10 @@ Change Log Unreleased ---------- +[4.12.3] +--------- +* feat: management command to clear out excessive records for API log table + [4.12.2] --------- * feat: add api /learners/ endpoint to the enterprise group viewset diff --git a/enterprise/__init__.py b/enterprise/__init__.py index 8b3a8d5814..e52fa437bd 100644 --- a/enterprise/__init__.py +++ b/enterprise/__init__.py @@ -2,4 +2,4 @@ Your project description goes here. """ -__version__ = "4.12.2" +__version__ = "4.12.3" diff --git a/integrated_channels/integrated_channel/admin/__init__.py b/integrated_channels/integrated_channel/admin/__init__.py index 5103a8198c..c3ee07a92c 100644 --- a/integrated_channels/integrated_channel/admin/__init__.py +++ b/integrated_channels/integrated_channel/admin/__init__.py @@ -128,7 +128,7 @@ class IntegratedChannelAPIRequestLogAdmin(admin.ModelAdmin): "payload", ] - list_per_page = 100 + list_per_page = 20 class Meta: model = IntegratedChannelAPIRequestLogs diff --git a/integrated_channels/integrated_channel/management/commands/remove_stale_integrated_channel_api_logs.py b/integrated_channels/integrated_channel/management/commands/remove_stale_integrated_channel_api_logs.py new file mode 100644 index 0000000000..b15b7b02f7 --- /dev/null +++ b/integrated_channels/integrated_channel/management/commands/remove_stale_integrated_channel_api_logs.py @@ -0,0 +1,41 @@ +""" +Deletes records from the IntegratedChannelAPIRequestLogs model that are older than one month.. +""" +from datetime import timedelta +from logging import getLogger + +from django.contrib import auth +from django.core.management.base import BaseCommand +from django.utils import timezone +from django.utils.translation import gettext as _ + +from integrated_channels.utils import integrated_channel_request_log_model + +User = auth.get_user_model() +LOGGER = getLogger(__name__) + + +class Command(BaseCommand): + """ + Management command to delete old records from the IntegratedChannelAPIRequestLogs model. + """ + help = _(''' + This management command deletes records from the IntegratedChannelAPIRequestLogs model that are older than one month + ''') + + def add_arguments(self, parser): + """ + Adds custom arguments to the parser. + """ + parser.add_argument('time_duration', nargs='?', type=int, default=30, + help='The duration in days for deleting old records. Default is 30 days.') + + def handle(self, *args, **options): + """ + Remove the duplicated transmission audit records for integration channels. + """ + time_duration = options['time_duration'] + time_threshold = timezone.now() - timedelta(days=time_duration) + deleted_count, _ = integrated_channel_request_log_model().objects.filter(created__lt=time_threshold).delete() + + LOGGER.info(f"Deleting records from IntegratedChannelAPIRequestLogs. Total records to delete: {deleted_count}") diff --git a/tests/test_management.py b/tests/test_management.py index f6555edaf7..6e313dc96d 100644 --- a/tests/test_management.py +++ b/tests/test_management.py @@ -3,6 +3,7 @@ """ import logging +import random import unittest import uuid from contextlib import contextmanager @@ -54,7 +55,11 @@ INTEGRATED_CHANNEL_CHOICES, IntegratedChannelCommandMixin, ) -from integrated_channels.integrated_channel.models import ContentMetadataItemTransmission, OrphanedContentTransmissions +from integrated_channels.integrated_channel.models import ( + ContentMetadataItemTransmission, + IntegratedChannelAPIRequestLogs, + OrphanedContentTransmissions, +) from integrated_channels.sap_success_factors.client import SAPSuccessFactorsAPIClient from integrated_channels.sap_success_factors.exporters.learner_data import SapSuccessFactorsLearnerManger from integrated_channels.sap_success_factors.models import SAPSuccessFactorsEnterpriseCustomerConfiguration @@ -2099,3 +2104,60 @@ def test_normal_run(self): assert ContentMetadataItemTransmission.objects.filter( enterprise_customer_catalog_uuid=None ).count() == 0 + + +@mark.django_db +class TestRemoveStaleIntegratedChannelAPILogsCommand(unittest.TestCase, EnterpriseMockMixin): + """ + Test the ``remove_stale_integrated_channel_api_logs`` management command. + """ + + def setUp(self): + self.enterprise_customer = factories.EnterpriseCustomerFactory() + self.pk = 1 + self.enterprise_customer_configuration_id = 1 + self.endpoint = 'https://example.com/endpoint' + self.payload = "{}" + self.time_taken = 500 + self.response_body = "{}" + self.status_code = 200 + super().setUp() + + def test_remove_stale_integrated_channel_api_logs(self): + """ + Test the remove stale integrated channel api logs command. + """ + time_duration_value = random.randint(15, 60) + time_threshold = timezone.now() - timedelta(days=time_duration_value) + + data = { + 'enterprise_customer': self.enterprise_customer, + 'enterprise_customer_configuration_id': self.enterprise_customer_configuration_id, + 'endpoint': self.endpoint, + 'payload': self.payload, + 'time_taken': self.time_taken, + 'response_body': self.response_body, + 'status_code': self.status_code, + 'created': time_threshold, + 'modified': time_threshold + } + + instances = [] + + num_records = 10 + + for _ in range(num_records): + instances.append(IntegratedChannelAPIRequestLogs(**data)) + + IntegratedChannelAPIRequestLogs.objects.bulk_create(instances) + data["created"] = data["modified"] = timezone.now() + IntegratedChannelAPIRequestLogs.objects.create(**data) + + assert IntegratedChannelAPIRequestLogs.objects.all().count() == num_records + 1 + call_command('remove_stale_integrated_channel_api_logs', time_duration=time_duration_value) + assert IntegratedChannelAPIRequestLogs.objects.all().count() == 1 + + older_than_one_month = IntegratedChannelAPIRequestLogs.objects.filter( + created__lt=time_threshold + ).exists() + self.assertFalse(older_than_one_month) From cc2f9740032c4004f71781c894e096a8a4a063fa Mon Sep 17 00:00:00 2001 From: Alexander J Sheehan Date: Thu, 22 Feb 2024 18:54:31 +0000 Subject: [PATCH 136/164] feat: adding assign_learners and remove_learners api endpoints for enterprise groups --- CHANGELOG.rst | 8 +- enterprise/__init__.py | 2 +- enterprise/api/utils.py | 11 ++ enterprise/api/v1/urls.py | 10 + enterprise/api/v1/views/enterprise_group.py | 192 +++++++++++++++++++- enterprise/models.py | 12 ++ enterprise/signals.py | 1 + test_utils/__init__.py | 16 ++ tests/test_enterprise/api/test_views.py | 185 ++++++++++++++++++- tests/test_enterprise/test_signals.py | 20 ++ 10 files changed, 442 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 5fd0d8f174..d26026fd9a 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -16,6 +16,10 @@ Change Log Unreleased ---------- +[4.12.4] +--------- +* feat: adding assign_learners and remove_learners api endpoints for enterprise groups + [4.12.3] --------- * feat: management command to clear out excessive records for API log table @@ -34,7 +38,7 @@ Unreleased [4.11.15] --------- -* feat: CRUD api endpoints for the enterprise group table +* feat: CRUD api endpoints for the enterprise group table [4.11.14] --------- @@ -86,7 +90,7 @@ Unreleased [4.11.2] --------- -* feat: added caching for fetching degreed course id +* feat: added caching for fetching degreed course id [4.11.1] --------- diff --git a/enterprise/__init__.py b/enterprise/__init__.py index e52fa437bd..90e32c55aa 100644 --- a/enterprise/__init__.py +++ b/enterprise/__init__.py @@ -2,4 +2,4 @@ Your project description goes here. """ -__version__ = "4.12.3" +__version__ = "4.12.4" diff --git a/enterprise/api/utils.py b/enterprise/api/utils.py index 63ccce6ea0..4e6d5cab96 100644 --- a/enterprise/api/utils.py +++ b/enterprise/api/utils.py @@ -19,6 +19,7 @@ EnterpriseCustomerUser, EnterpriseFeatureRole, EnterpriseFeatureUserRoleAssignment, + EnterpriseGroup, ) User = auth.get_user_model() @@ -38,6 +39,16 @@ def get_service_usernames(): return {getattr(settings, username, None) for username in SERVICE_USERNAMES} +def get_enterprise_customer_from_enterprise_group_id(group_id): + """ + Get the enterprise customer id given an enterprise customer group id. + """ + try: + return str(EnterpriseGroup.objects.get(pk=group_id).enterprise_customer.uuid) + except EnterpriseGroup.DoesNotExist: + return None + + def get_enterprise_customer_from_catalog_id(catalog_id): """ Get the enterprise customer id given an enterprise customer catalog id. diff --git a/enterprise/api/v1/urls.py b/enterprise/api/v1/urls.py index 0c8e524ba6..e9099638ed 100644 --- a/enterprise/api/v1/urls.py +++ b/enterprise/api/v1/urls.py @@ -181,6 +181,16 @@ ), name='enterprise-group-learners' ), + re_path( + r'^enterprise_group/(?P[A-Za-z0-9-]+)/assign_learners/?$', + enterprise_group.EnterpriseGroupViewSet.as_view({'post': 'assign_learners'}), + name='enterprise-group-assign-learners' + ), + re_path( + r'^enterprise_group/(?P[A-Za-z0-9-]+)/remove_learners/?$', + enterprise_group.EnterpriseGroupViewSet.as_view({'post': 'remove_learners'}), + name='enterprise-group-remove-learners' + ), ] urlpatterns += router.urls diff --git a/enterprise/api/v1/views/enterprise_group.py b/enterprise/api/v1/views/enterprise_group.py index 525536b40f..ff2371b570 100644 --- a/enterprise/api/v1/views/enterprise_group.py +++ b/enterprise/api/v1/views/enterprise_group.py @@ -3,19 +3,25 @@ """ from django_filters.rest_framework import DjangoFilterBackend +from edx_rbac.decorators import permission_required from rest_framework import filters, permissions from rest_framework.decorators import action +from rest_framework.response import Response +from django.contrib import auth from django.db.models import Q from django.http import Http404 -from enterprise import models +from enterprise import models, rules, utils +from enterprise.api.utils import get_enterprise_customer_from_enterprise_group_id from enterprise.api.v1 import serializers from enterprise.api.v1.views.base_views import EnterpriseReadWriteModelViewSet from enterprise.logging import getEnterpriseLogger LOGGER = getEnterpriseLogger(__name__) +User = auth.get_user_model() + class EnterpriseGroupViewSet(EnterpriseReadWriteModelViewSet): """ @@ -42,17 +48,44 @@ def get_queryset(self, **kwargs): associated_customers.append(user_object.enterprise_customer) queryset = queryset.filter(enterprise_customer__in=associated_customers) - if self.request.method in ('GET',): - if learner_uuids := self.request.query_params.getlist('learner_uuids'): + if self.request.method == 'GET': + if learner_ids := self.request.query_params.getlist('learner_ids'): # groups can apply to both existing and pending users queryset = queryset.filter( - Q(members__enterprise_customer_user__in=learner_uuids) | - Q(members__pending_enterprise_customer_user__in=learner_uuids), + Q(members__enterprise_customer_user__in=learner_ids) | + Q(members__pending_enterprise_customer_user__in=learner_ids), ) if enterprise_uuids := self.request.query_params.getlist('enterprise_uuids'): queryset = queryset.filter(enterprise_customer__in=enterprise_uuids) return queryset + @permission_required( + 'enterprise.can_access_admin_dashboard', + fn=lambda request, *args, **kwargs: get_enterprise_customer_from_enterprise_group_id(kwargs['pk']) + ) + def update(self, request, *args, **kwargs): + """ + PATCH /enterprise/api/v1/enterprise-group/ + """ + if requested_customer := self.request.data.get('enterprise_customer'): + # Essentially checking ``enterprise.can_access_admin_dashboard`` but for the customer the requester is + # attempting to update the group record to. + implicit_access = rules.has_implicit_access_to_dashboard(self.request.user, requested_customer) + explicit_access = rules.has_explicit_access_to_dashboard(self.request.user, requested_customer) + if not implicit_access and not explicit_access: + return Response('Unauthorized', status=401) + return super().update(request, *args, **kwargs) + + @permission_required( + 'enterprise.can_access_admin_dashboard', + fn=lambda request, *args, **kwargs: request.POST.dict().get('enterprise_customer') + ) + def create(self, request, *args, **kwargs): + """ + POST /enterprise/api/v1/enterprise-group/ + """ + return super().create(request, *args, **kwargs) + @action(detail=True, methods=['get']) def get_learners(self, *args, **kwargs): """ @@ -89,3 +122,152 @@ def get_learners(self, *args, **kwargs): except models.EnterpriseGroup.DoesNotExist as exc: LOGGER.warning(f"group_uuid {group_uuid} does not exist") raise Http404 from exc + + @action(methods=['post'], detail=False, permission_classes=[permissions.IsAuthenticated]) + @permission_required( + 'enterprise.can_access_admin_dashboard', + fn=lambda request, group_uuid: get_enterprise_customer_from_enterprise_group_id(group_uuid) + ) + def assign_learners(self, request, group_uuid): + """ + POST /enterprise/api/v1/enterprise-group//assign_learners + + Required Arguments: + - ``learner_emails``: List of learner emails to associate with the group. Note: only processes the first + 1000 records provided. + + Returns: + - ``records_processed``: Total number of group membership records processed. + - ``new_learners``: Total number of group membership records associated with new pending enterprise learners + that were processed. + - ``existing_learners``: Total number of group membership records associated with existing enterprise learners + that were processed. + + """ + try: + group = self.get_queryset().get(uuid=group_uuid) + customer = group.enterprise_customer + except models.EnterpriseGroup.DoesNotExist as exc: + raise Http404 from exc + if requested_emails := request.POST.dict().get('learner_emails'): + total_records_processed = 0 + total_existing_users_processed = 0 + total_new_users_processed = 0 + for user_email_batch in utils.batch(requested_emails.rstrip(',').split(',')[: 1000], batch_size=200): + user_emails_to_create = [] + memberships_to_create = [] + # ecus: enterprise customer users + ecus = [] + # Gather all existing User objects associated with the email batch + existing_users = User.objects.filter(email__in=user_email_batch) + + # Build and create a list of EnterpriseCustomerUser objects for the emails of existing Users + # Ignore conflicts in case any of the ent customer user objects already exist + ecu_by_email = { + user.email: models.EnterpriseCustomerUser( + enterprise_customer=customer, user_id=user.id, active=True + ) for user in existing_users + } + models.EnterpriseCustomerUser.objects.bulk_create( + ecu_by_email.values(), + ignore_conflicts=True, + ) + + # Fetch all ent customer users related to existing users provided by requester + # whether they were created above or already existed + ecus.extend( + models.EnterpriseCustomerUser.objects.filter( + user_id__in=existing_users.values_list('id', flat=True) + ) + ) + + # Extend the list of emails that don't have User objects associated and need to be turned into + # new PendingEnterpriseCustomerUser objects + user_emails_to_create.extend(set(user_email_batch).difference(set(ecu_by_email.keys()))) + + # Extend the list of memberships that need to be created associated with existing Users + ent_customer_users = [ + models.EnterpriseGroupMembership( + enterprise_customer_user=ecu, + group=group + ) + for ecu in ecus + ] + total_existing_users_processed += len(ent_customer_users) + memberships_to_create.extend(ent_customer_users) + + # Go over (in batches) all emails that don't have User objects + for emails_to_create_batch in utils.batch(user_emails_to_create, batch_size=200): + # Create the PendingEnterpriseCustomerUser objects + pecu_records = [ + models.PendingEnterpriseCustomerUser( + enterprise_customer=customer, user_email=user_email + ) for user_email in emails_to_create_batch + ] + # According to Django docs, bulk created objects can't be used in future bulk creates as the in memory + # objects returned by bulk_create won't have PK's assigned. + models.PendingEnterpriseCustomerUser.objects.bulk_create(pecu_records) + pecus = models.PendingEnterpriseCustomerUser.objects.filter( + user_email__in=emails_to_create_batch, + enterprise_customer=customer, + ) + total_new_users_processed += len(pecus) + # Extend the list of memberships that need to be created associated with the new pending users + memberships_to_create.extend([ + models.EnterpriseGroupMembership( + pending_enterprise_customer_user=pecu, + group=group + ) for pecu in pecus + ]) + + # Create all our memberships, bulk_create will batch for us. + memberships = models.EnterpriseGroupMembership.objects.bulk_create( + memberships_to_create, ignore_conflicts=True + ) + total_records_processed += len(memberships) + data = { + 'records_processed': total_records_processed, + 'new_learners': total_new_users_processed, + 'existing_learners': total_existing_users_processed, + } + return Response(data, status=201) + return Response(data="Error: missing request data: `learner_emails`.", status=400) + + @action(methods=['post'], detail=False, permission_classes=[permissions.IsAuthenticated]) + @permission_required( + 'enterprise.can_access_admin_dashboard', + fn=lambda request, group_uuid: get_enterprise_customer_from_enterprise_group_id(group_uuid) + ) + def remove_learners(self, request, group_uuid): + """ + POST /enterprise/api/v1/enterprise-group//remove_learners + + Required Arguments: + - ``learner_emails``: + List of learner emails to associate with the group. + + Returns: + - ``records_deleted``: + Number of membership records removed + """ + try: + group = self.get_queryset().get(uuid=group_uuid) + except models.EnterpriseGroup.DoesNotExist as exc: + raise Http404 from exc + if requested_emails := request.POST.dict().get('learner_emails'): + records_deleted = 0 + for user_email_batch in utils.batch(requested_emails.split(','), batch_size=200): + existing_users = User.objects.filter(email__in=user_email_batch).values_list("id", flat=True) + group_q = Q(group=group) + ecu_in_q = Q(enterprise_customer_user__user_id__in=existing_users) + pecu_in_q = Q(pending_enterprise_customer_user__user_email__in=user_email_batch) + records_to_delete = models.EnterpriseGroupMembership.objects.filter( + group_q & (ecu_in_q | pecu_in_q), + ) + records_deleted += len(records_to_delete) + records_to_delete.delete() + data = { + 'records_deleted': records_deleted, + } + return Response(data, status=200) + return Response(data="Error: missing request data: `learner_emails`.", status=400) diff --git a/enterprise/models.py b/enterprise/models.py index 52958cc54b..0e680160ba 100644 --- a/enterprise/models.py +++ b/enterprise/models.py @@ -1476,6 +1476,18 @@ def link_pending_enterprise_user(self, user, is_user_created): ) return enterprise_customer_user + def fulfill_pending_group_memberships(self, enterprise_customer_user): + """ + Updates any membership records associated with a new created enterprise customer user object. + + Arguments: + enterprise_customer_user: a EnterpriseCustomerUser instance + """ + self.memberships.update( + pending_enterprise_customer_user=None, + enterprise_customer_user=enterprise_customer_user + ) + def fulfill_pending_course_enrollments(self, enterprise_customer_user): """ Enrolls a newly created EnterpriseCustomerUser in any courses attached to their diff --git a/enterprise/signals.py b/enterprise/signals.py index 87af0ac401..52ec0adf72 100644 --- a/enterprise/signals.py +++ b/enterprise/signals.py @@ -85,6 +85,7 @@ def handle_user_post_save(sender, **kwargs): # pylint: disable=unused-argument is_user_created=created, ) pending_ecu.fulfill_pending_course_enrollments(enterprise_customer_user) + pending_ecu.fulfill_pending_group_memberships(enterprise_customer_user) pending_ecu.delete() enterprise_customer_users = models.EnterpriseCustomerUser.objects.filter(user_id=user_instance.id) diff --git a/test_utils/__init__.py b/test_utils/__init__.py index 9b6a714dd0..b992f3ed4c 100644 --- a/test_utils/__init__.py +++ b/test_utils/__init__.py @@ -330,6 +330,22 @@ def get_request_with_jwt_cookie(self, system_wide_role=None, context=None): request.COOKIES[jwt_cookie_name()] = jwt_token return request + def set_multiple_enterprise_roles_to_jwt(self, context_and_roles): + """ + Sets multiple roles to the jwt token cookies + """ + jwt_roles = [] + for pairs in context_and_roles: + context, role = pairs + jwt_roles.append(f"{context}:{role}") + payload = generate_unversioned_payload(self.user) + payload.update({ + 'roles': jwt_roles + }) + jwt_token = generate_jwt_token(payload) + + self.client.cookies[jwt_cookie_name()] = jwt_token + def set_jwt_cookie(self, system_wide_role='enterprise_admin', context='some_context'): """ Set jwt token in cookies. diff --git a/tests/test_enterprise/api/test_views.py b/tests/test_enterprise/api/test_views.py index 4efcf0d215..97431e47ef 100644 --- a/tests/test_enterprise/api/test_views.py +++ b/tests/test_enterprise/api/test_views.py @@ -511,7 +511,6 @@ def test_get_enterprise_customer_user_with_groups(self): ) expected_groups = ['enterprise_enrollment_api_access'] - response = self.client.get( '{host}{path}?username={username}'.format( host=settings.TEST_SERVER, @@ -7270,7 +7269,12 @@ def setUp(self): self.client.login(username=self.user.username, password=TEST_PASSWORD) self.group_1 = EnterpriseGroupFactory(enterprise_customer=self.enterprise_customer) - self.group_2 = EnterpriseGroupFactory() + self.group_2 = EnterpriseGroupFactory(enterprise_customer=self.enterprise_customer) + self.set_multiple_enterprise_roles_to_jwt([ + (ENTERPRISE_ADMIN_ROLE, self.enterprise_customer.pk), + (ENTERPRISE_ADMIN_ROLE, self.group_2.enterprise_customer.pk) + ]) + self.enterprise_group_memberships = [] for _ in range(11): self.enterprise_group_memberships.append(EnterpriseGroupMembershipFactory( @@ -7299,8 +7303,7 @@ def test_successful_list_groups(self): 'enterprise-group-list', ) response = self.client.get(url) - assert response.json().get('count') == 1 - assert response.json().get('results')[0].get('uuid') == str(self.group_1.uuid) + assert response.json().get('count') == 2 def test_successful_retrieve_group(self): """ @@ -7411,6 +7414,10 @@ def test_successful_post_group(self): 'enterprise_customer': str(new_customer.uuid), 'name': 'foobar', } + unauthorized_response = self.client.post(url, data=request_data) + assert unauthorized_response.status_code == 403 + + self.set_jwt_cookie(ENTERPRISE_ADMIN_ROLE, new_customer.pk) response = self.client.post(url, data=request_data) assert response.json().get('name') == 'foobar' assert len(EnterpriseGroup.objects.filter(name='foobar')) == 1 @@ -7424,11 +7431,19 @@ def test_successful_update_group(self): 'enterprise-group-detail', kwargs={'pk': self.group_1.uuid}, ) - request_data = {'name': 'ayylmao'} + new_uuid = uuid.uuid4() + new_customer = EnterpriseCustomerFactory(uuid=new_uuid) + self.set_multiple_enterprise_roles_to_jwt([ + (ENTERPRISE_ADMIN_ROLE, self.enterprise_customer.pk), + (ENTERPRISE_ADMIN_ROLE, self.group_2.enterprise_customer.pk), + (ENTERPRISE_ADMIN_ROLE, new_customer.pk), + ]) + + request_data = {'enterprise_customer': new_uuid} response = self.client.patch(url, data=request_data) assert response.json().get('uuid') == str(self.group_1.uuid) - assert response.json().get('name') == 'ayylmao' - assert len(EnterpriseGroup.objects.filter(name='ayylmao')) == 1 + assert response.json().get('enterprise_customer') == str(new_uuid) + assert len(EnterpriseGroup.objects.filter(enterprise_customer=str(new_uuid))) == 1 def test_successful_delete_group(self): """ @@ -7445,6 +7460,162 @@ def test_successful_delete_group(self): assert not EnterpriseGroup.objects.filter(uuid=group_to_delete_uuid) assert not EnterpriseGroupMembership.objects.filter(group=group_to_delete_uuid) + def test_assign_learners_404(self): + """ + Test that the assign learners endpoint properly handles no finding the provided group + """ + url = settings.TEST_SERVER + reverse( + 'enterprise-group-assign-learners', + kwargs={'group_uuid': uuid.uuid4()}, + ) + assert self.client.post(url).status_code == 404 + + def test_assign_learners_requires_learner_emails(self): + """ + Test that the assign learners endpoint requires a POST body param: `learner_emails` + """ + url = settings.TEST_SERVER + reverse( + 'enterprise-group-assign-learners', + kwargs={'group_uuid': self.group_2.uuid}, + ) + response = self.client.post(url) + assert response.status_code == 400 + assert response.data == "Error: missing request data: `learner_emails`." + + def test_successful_assign_learners_to_group(self): + """ + Test that both existing and new learners assigned to groups properly creates membership records + """ + url = settings.TEST_SERVER + reverse( + 'enterprise-group-assign-learners', + kwargs={'group_uuid': self.group_2.uuid}, + ) + + existing_emails = ",".join([(UserFactory().email) for _ in range(10)]) + new_emails = ",".join([(f"email_{x}@example.com") for x in range(10)]) + + request_data = {'learner_emails': f"{new_emails},{existing_emails}"} + response = self.client.post(url, data=request_data) + + assert response.status_code == 201 + assert response.data == {'records_processed': 20, 'new_learners': 10, 'existing_learners': 10} + assert len( + EnterpriseGroupMembership.objects.filter( + group=self.group_2, + pending_enterprise_customer_user__isnull=True + ) + ) == 10 + assert len( + EnterpriseGroupMembership.objects.filter( + group=self.group_2, + enterprise_customer_user__isnull=True + ) + ) == 10 + + def test_remove_learners_404(self): + """ + Test that the remove learners endpoint properly handles no finding the provided group + """ + url = settings.TEST_SERVER + reverse( + 'enterprise-group-remove-learners', + kwargs={'group_uuid': uuid.uuid4()}, + ) + assert self.client.post(url).status_code == 404 + + def test_remove_learners_requires_learner_emails(self): + """ + Test that the remove learners endpoint requires a POST body param: `learner_emails` + """ + url = settings.TEST_SERVER + reverse( + 'enterprise-group-remove-learners', + kwargs={'group_uuid': self.group_2.uuid}, + ) + response = self.client.post(url) + assert response.status_code == 400 + assert response.data == "Error: missing request data: `learner_emails`." + + def test_patch_with_bad_request_customer_to_change_to(self): + """ + Test that the PATCH endpoint will not allow the user to update a group to a customer that the requester + doesn't have access to + """ + # url: 'http://testserver/enterprise/api/v1/enterprise_group/' + url = settings.TEST_SERVER + reverse( + 'enterprise-group-detail', + kwargs={'pk': self.group_1.uuid}, + ) + new_uuid = uuid.uuid4() + new_customer = EnterpriseCustomerFactory(uuid=new_uuid) + + request_data = {'enterprise_customer': new_uuid} + response = self.client.patch(url, data=request_data) + assert response.status_code == 401 + + self.set_multiple_enterprise_roles_to_jwt([ + (ENTERPRISE_ADMIN_ROLE, self.enterprise_customer.pk), + (ENTERPRISE_ADMIN_ROLE, self.group_2.enterprise_customer.pk), + (ENTERPRISE_ADMIN_ROLE, new_customer.pk), + ]) + response = self.client.patch(url, data=request_data) + assert response.status_code == 200 + + request_data = {'enterprise_customer': uuid.uuid4()} + response = self.client.patch(url, data=request_data) + assert response.status_code == 401 + + def test_successful_remove_learners_from_group(self): + """ + Test that both existing and new learners in groups are properly removed by the remove_learners endpoint + """ + url = settings.TEST_SERVER + reverse( + 'enterprise-group-remove-learners', + kwargs={'group_uuid': self.group_2.uuid}, + ) + existing_emails = "" + memberships_to_delete = [] + for _ in range(10): + membership = EnterpriseGroupMembershipFactory(group=self.group_2) + memberships_to_delete.append(membership) + existing_emails += membership.enterprise_customer_user.user.email + ',' + + request_data = {'learner_emails': existing_emails} + response = self.client.post(url, data=request_data) + assert response.status_code == 200 + assert response.data == {'records_deleted': 10} + for membership in memberships_to_delete: + with self.assertRaises(EnterpriseGroupMembership.DoesNotExist): + EnterpriseGroupMembership.objects.get(pk=membership.pk) + + def test_remove_learners_from_group_only_removes_from_specified_group(self): + """ + Test that removing a learner's membership from a group will only effect the specified group + """ + existing_group = EnterpriseGroupFactory(enterprise_customer=self.enterprise_customer) + group_to_remove_from = EnterpriseGroupFactory(enterprise_customer=self.enterprise_customer) + pending_user = PendingEnterpriseCustomerUserFactory(enterprise_customer=self.enterprise_customer) + existing_membership = EnterpriseGroupMembershipFactory( + group=existing_group, + pending_enterprise_customer_user=pending_user, + enterprise_customer_user=None + ) + membership_to_remove = EnterpriseGroupMembershipFactory( + group=group_to_remove_from, + pending_enterprise_customer_user=pending_user, + enterprise_customer_user=None + ) + + url = settings.TEST_SERVER + reverse( + 'enterprise-group-remove-learners', + kwargs={'group_uuid': group_to_remove_from.uuid}, + ) + + request_data = {'learner_emails': pending_user.user_email} + response = self.client.post(url, data=request_data) + assert response.status_code == 200 + with self.assertRaises(EnterpriseGroupMembership.DoesNotExist): + EnterpriseGroupMembership.objects.get(pk=membership_to_remove.pk) + assert EnterpriseGroupMembership.objects.get(pk=existing_membership.pk) + @mark.django_db class TestEnterpriseCustomerSsoConfigurationViewSet(APITest): diff --git a/tests/test_enterprise/test_signals.py b/tests/test_enterprise/test_signals.py index 665a23a527..e93f14e758 100644 --- a/tests/test_enterprise/test_signals.py +++ b/tests/test_enterprise/test_signals.py @@ -18,6 +18,7 @@ EnterpriseCourseEnrollment, EnterpriseCustomerCatalog, EnterpriseCustomerUser, + EnterpriseGroupMembership, PendingEnrollment, PendingEnterpriseCustomerAdminUser, PendingEnterpriseCustomerUser, @@ -33,6 +34,7 @@ EnterpriseCustomerCatalogFactory, EnterpriseCustomerFactory, EnterpriseCustomerUserFactory, + EnterpriseGroupMembershipFactory, PendingEnrollmentFactory, PendingEnterpriseCustomerAdminUserFactory, PendingEnterpriseCustomerUserFactory, @@ -250,6 +252,24 @@ def test_handle_user_post_save_raw(self): assert PendingEnterpriseCustomerUser.objects.filter(user_email=email).count() == 1, \ "Pending link should be kept" + def test_handle_user_post_save_fulfills_pending_group_memberships(self): + email = "jackie.chan@hollywood.com" + user = UserFactory(id=1, email=email) + pending_user = PendingEnterpriseCustomerUserFactory(user_email=email) + EnterpriseGroupMembershipFactory( + pending_enterprise_customer_user=pending_user, + enterprise_customer_user=None + ) + parameters = {"instance": user, "created": False} + handle_user_post_save(mock.Mock(), **parameters) + # Should delete pending link + assert PendingEnterpriseCustomerUser.objects.count() == 0 + assert len(EnterpriseGroupMembership.objects.all()) == 1 + + new_enterprise_user = EnterpriseCustomerUser.objects.get(user_id=user.id) + assert EnterpriseGroupMembership.objects.first().pending_enterprise_customer_user is None + assert EnterpriseGroupMembership.objects.first().enterprise_customer_user == new_enterprise_user + @mark.django_db @ddt.ddt From b4f6918d779902522c0e6f8991c2bc6e53b89bdb Mon Sep 17 00:00:00 2001 From: Kira Miller <31229189+kiram15@users.noreply.github.com> Date: Tue, 27 Feb 2024 11:31:13 -0700 Subject: [PATCH 137/164] feat: adding group to user serializer (#2028) * feat: adding group to user serializer * fix: PR requests * fix: version bump --- CHANGELOG.rst | 4 ++ enterprise/__init__.py | 2 +- enterprise/api/v1/serializers.py | 54 +++++++++++-------- tests/test_enterprise/api/test_serializers.py | 38 +++++++++++++ tests/test_enterprise/api/test_views.py | 1 + 5 files changed, 75 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index d26026fd9a..a1f44d8f4c 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -16,6 +16,10 @@ Change Log Unreleased ---------- +[4.12.5] +--------- +* feat: adding a group membership to the EnterpriseCustomerUserReadOnlySerializer + [4.12.4] --------- * feat: adding assign_learners and remove_learners api endpoints for enterprise groups diff --git a/enterprise/__init__.py b/enterprise/__init__.py index 90e32c55aa..bd6e4768ef 100644 --- a/enterprise/__init__.py +++ b/enterprise/__init__.py @@ -2,4 +2,4 @@ Your project description goes here. """ -__version__ = "4.12.4" +__version__ = "4.12.5" diff --git a/enterprise/api/v1/serializers.py b/enterprise/api/v1/serializers.py index d0a3ff28df..7bfafbc5f9 100644 --- a/enterprise/api/v1/serializers.py +++ b/enterprise/api/v1/serializers.py @@ -583,6 +583,28 @@ class Meta: } +class EnterpriseGroupSerializer(serializers.ModelSerializer): + """ + Serializer for EnterpriseGroup model. + """ + class Meta: + model = models.EnterpriseGroup + fields = ('enterprise_customer', 'name', 'uuid') + + +class EnterpriseGroupMembershipSerializer(serializers.ModelSerializer): + """ + Serializer for EnterpriseGroupMembership model. + """ + learner_id = serializers.IntegerField(source='enterprise_customer_user.id', allow_null=True) + pending_learner_id = serializers.IntegerField(source='pending_enterprise_customer_user.id', allow_null=True) + enterprise_group_membership_uuid = serializers.UUIDField(source='uuid', allow_null=True, read_only=True) + + class Meta: + model = models.EnterpriseGroupMembership + fields = ('learner_id', 'pending_learner_id', 'enterprise_group_membership_uuid') + + class EnterpriseCustomerUserReadOnlySerializer(serializers.ModelSerializer): """ Serializer for EnterpriseCustomerUser model. @@ -600,7 +622,8 @@ class Meta: 'groups', 'created', 'invite_key', - 'role_assignments' + 'role_assignments', + 'enterprise_group', ) user = UserSerializer() @@ -608,6 +631,7 @@ class Meta: data_sharing_consent_records = serializers.SerializerMethodField() groups = serializers.SerializerMethodField() role_assignments = serializers.SerializerMethodField() + enterprise_group = serializers.SerializerMethodField() def _get_role_assignments_by_ecu_id(self, enterprise_customer_users): """ @@ -664,6 +688,12 @@ def get_role_assignments(self, obj): """ return self.role_assignments_by_ecu_id.get(obj.id, []) + def get_enterprise_group(self, obj): + """ + Return the enterprise group membership for this enterprise customer user. + """ + return obj.memberships.select_related('group').all() + class EnterpriseCustomerUserWriteSerializer(serializers.ModelSerializer): """ @@ -802,28 +832,6 @@ def to_representation(self, instance): return updated_course -class EnterpriseGroupSerializer(serializers.ModelSerializer): - """ - Serializer for EnterpriseGroup model. - """ - class Meta: - model = models.EnterpriseGroup - fields = ('enterprise_customer', 'name', 'uuid') - - -class EnterpriseGroupMembershipSerializer(serializers.ModelSerializer): - """ - Serializer for EnterpriseGroupMembership model. - """ - learner_id = serializers.IntegerField(source='enterprise_customer_user.id', allow_null=True) - pending_learner_id = serializers.IntegerField(source='pending_enterprise_customer_user.id', allow_null=True) - enterprise_group_membership_uuid = serializers.UUIDField(source='uuid', allow_null=True, read_only=True) - - class Meta: - model = models.EnterpriseGroupMembership - fields = ('learner_id', 'pending_learner_id', 'enterprise_group_membership_uuid') - - class CourseRunDetailSerializer(ImmutableStateSerializer): """ Serializer for course run data retrieved from the discovery service course_run detail API endpoint. diff --git a/tests/test_enterprise/api/test_serializers.py b/tests/test_enterprise/api/test_serializers.py index 67e5603aeb..4f84058626 100644 --- a/tests/test_enterprise/api/test_serializers.py +++ b/tests/test_enterprise/api/test_serializers.py @@ -297,6 +297,44 @@ def test_serialize_role_assignments_many(self): assert sorted(ecu_1_data['role_assignments']) == sorted([ENTERPRISE_LEARNER_ROLE, ENTERPRISE_ADMIN_ROLE]) assert ecu_2_data['role_assignments'] == [ENTERPRISE_LEARNER_ROLE] + def test_group_membership(self): + """ + Test that group memberships are associated properly with a single instance. + """ + + enterprise_group = factories.EnterpriseGroupFactory(enterprise_customer=self.enterprise_customer_1) + membership = factories.EnterpriseGroupMembershipFactory( + enterprise_customer_user=self.enterprise_customer_user_1, + group=enterprise_group + ) + + serializer = EnterpriseCustomerUserReadOnlySerializer(self.enterprise_customer_user_1) + assert len(serializer.data['enterprise_group']) == 1 + assert serializer.data['enterprise_group'][0].uuid == membership.uuid + + def test_multi_group_membership(self): + """ + Test that multiple group memberships are associated properly with a single instance. + """ + + enterprise_group = factories.EnterpriseGroupFactory(enterprise_customer=self.enterprise_customer_1) + enterprise_group_2 = factories.EnterpriseGroupFactory(enterprise_customer=self.enterprise_customer_1) + membership = factories.EnterpriseGroupMembershipFactory( + enterprise_customer_user=self.enterprise_customer_user_1, + group=enterprise_group + ) + membership_2 = factories.EnterpriseGroupMembershipFactory( + enterprise_customer_user=self.enterprise_customer_user_1, + group=enterprise_group_2 + ) + + serializer = EnterpriseCustomerUserReadOnlySerializer(self.enterprise_customer_user_1) + assert len(serializer.data['enterprise_group']) == 2 + assert sorted([membership.uuid, membership_2.uuid]) == sorted([ + serializer.data['enterprise_group'][0].uuid, + serializer.data['enterprise_group'][1].uuid, + ]) + @mark.django_db class TestEnterpriseCustomerReportingConfigurationSerializer(APITest): diff --git a/tests/test_enterprise/api/test_views.py b/tests/test_enterprise/api/test_views.py index 97431e47ef..82e14e14dc 100644 --- a/tests/test_enterprise/api/test_views.py +++ b/tests/test_enterprise/api/test_views.py @@ -1261,6 +1261,7 @@ class TestEnterpriseCustomerViewSet(BaseTestEnterpriseAPIViews): 'enable_demo_data_for_analytics_and_lpr': False, 'enable_academies': False, }, + 'enterprise_group': [], 'active': True, 'user_id': 0, 'user': None, 'data_sharing_consent_records': [], 'groups': [], 'created': '2021-10-20T19:01:31Z', 'invite_key': None, 'role_assignments': [], From 7e3907f0ecf41620a93c40012d439ff1c79bf400 Mon Sep 17 00:00:00 2001 From: Sameen Fatima <55431213+sameenfatima78@users.noreply.github.com> Date: Thu, 29 Feb 2024 04:47:32 +0500 Subject: [PATCH 138/164] fix: proximus learner transmission issue (#2034) --- CHANGELOG.rst | 4 ++ enterprise/__init__.py | 2 +- .../cornerstone/exporters/learner_data.py | 70 ++++++++++--------- .../test_exporters/test_learner_data.py | 14 ---- 4 files changed, 43 insertions(+), 47 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index a1f44d8f4c..ceff1922c9 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -16,6 +16,10 @@ Change Log Unreleased ---------- +[4.12.6] +--------- +* fix: Proximus learner transmission failures + [4.12.5] --------- * feat: adding a group membership to the EnterpriseCustomerUserReadOnlySerializer diff --git a/enterprise/__init__.py b/enterprise/__init__.py index bd6e4768ef..518246734b 100644 --- a/enterprise/__init__.py +++ b/enterprise/__init__.py @@ -2,4 +2,4 @@ Your project description goes here. """ -__version__ = "4.12.5" +__version__ = "4.12.6" diff --git a/integrated_channels/cornerstone/exporters/learner_data.py b/integrated_channels/cornerstone/exporters/learner_data.py index 4ab5fb318a..9b5bcbf791 100644 --- a/integrated_channels/cornerstone/exporters/learner_data.py +++ b/integrated_channels/cornerstone/exporters/learner_data.py @@ -36,41 +36,47 @@ def get_learner_data_records( 'cornerstone', 'CornerstoneLearnerDataTransmissionAudit' ) + enterprise_customer_user = enterprise_enrollment.enterprise_customer_user + # get the proper internal representation of the course key + course_id = get_course_id_for_enrollment(enterprise_enrollment) + # because CornerstoneLearnerDataTransmissionAudit records are created with a click-through + # the internal edX course_id is always used on the CornerstoneLearnerDataTransmissionAudit records + # rather than the external_course_id mapped via CornerstoneCourseKey + transmission_exists = CornerstoneLearnerDataTransmissionAudit.objects.filter( + user_id=enterprise_enrollment.enterprise_customer_user.user.id, + course_id=course_id, + plugin_configuration_id=self.enterprise_configuration.id, + enterprise_customer_uuid=self.enterprise_configuration.enterprise_customer.uuid, + ).exists() - try: - # get the proper internal representation of the course key - course_id = get_course_id_for_enrollment(enterprise_enrollment) - # because CornerstoneLearnerDataTransmissionAudit records are created with a click-through - # the internal edX course_id is always used on the CornerstoneLearnerDataTransmissionAudit records - # rather than the external_course_id mapped via CornerstoneCourseKey - csod_learner_data_transmission = CornerstoneLearnerDataTransmissionAudit.objects.get( - user_id=enterprise_enrollment.enterprise_customer_user.user.id, + if transmission_exists or enterprise_customer_user.user_email is not None: + csod_transmission_record, __ = CornerstoneLearnerDataTransmissionAudit.objects.update_or_create( + user_id=enterprise_customer_user.user.id, course_id=course_id, plugin_configuration_id=self.enterprise_configuration.id, enterprise_customer_uuid=self.enterprise_configuration.enterprise_customer.uuid, + defaults={ + "enterprise_course_enrollment_id": enterprise_enrollment.id, + "grade": grade, + "course_completed": course_completed, + "completed_timestamp": completed_date, + "user_email": enterprise_customer_user.user_email, + }, + ) + return [csod_transmission_record] + else: + LOGGER.info( + generate_formatted_log( + self.enterprise_configuration.channel_code(), + enterprise_customer_user.enterprise_customer.uuid, + enterprise_customer_user.user_id, + enterprise_enrollment.course_id, + ( + 'get_learner_data_records finished. No learner data was sent for this LMS User Id because ' + 'Cornerstone User ID not found for [{name}]'.format( + name=enterprise_customer_user.enterprise_customer.name + ) + ) + ) ) - csod_learner_data_transmission.enterprise_course_enrollment_id = enterprise_enrollment.id - csod_learner_data_transmission.grade = grade - csod_learner_data_transmission.course_completed = course_completed - csod_learner_data_transmission.completed_timestamp = completed_date - - # Used for api error reporting - csod_learner_data_transmission.user_email = enterprise_enrollment.enterprise_customer_user.user_email - - enterprise_customer = enterprise_enrollment.enterprise_customer_user.enterprise_customer - csod_learner_data_transmission.enterprise_customer_uuid = enterprise_customer.uuid - csod_learner_data_transmission.plugin_configuration_id = self.enterprise_configuration.id - return [ - csod_learner_data_transmission - ] - except CornerstoneLearnerDataTransmissionAudit.DoesNotExist: - LOGGER.info(generate_formatted_log( - self.enterprise_configuration.channel_code(), - enterprise_enrollment.enterprise_customer_user.enterprise_customer.uuid, - enterprise_enrollment.enterprise_customer_user.user_id, - enterprise_enrollment.course_id, - ('get_learner_data_records finished. No learner data was sent for this LMS User Id because ' - 'Cornerstone User ID not found for [{name}]'.format( - name=enterprise_enrollment.enterprise_customer_user.enterprise_customer.name - )))) return None diff --git a/tests/test_integrated_channels/test_cornerstone/test_exporters/test_learner_data.py b/tests/test_integrated_channels/test_cornerstone/test_exporters/test_learner_data.py index 6323b88677..39e10b469a 100644 --- a/tests/test_integrated_channels/test_cornerstone/test_exporters/test_learner_data.py +++ b/tests/test_integrated_channels/test_cornerstone/test_exporters/test_learner_data.py @@ -131,20 +131,6 @@ def test_retrieve_same_learner_data_record(self, mock_course_catalog_api): assert learner_data_records_1.id == learner_data_records_2.id - def test_get_learner_data_record_not_exist(self): - """ - If learner data is not already exist, nothing is returned. - """ - exporter = CornerstoneLearnerExporter('fake-user', self.config) - enterprise_course_enrollment = factories.EnterpriseCourseEnrollmentFactory( - enterprise_customer_user=factories.EnterpriseCustomerUserFactory( - user_id=self.other_user.id, - enterprise_customer=self.enterprise_customer, - ), - course_id=self.course_id, - ) - assert exporter.get_learner_data_records(enterprise_course_enrollment) is None - @responses.activate @mock.patch('integrated_channels.cornerstone.client.requests.post') @mock.patch('integrated_channels.integrated_channel.exporters.learner_data.get_course_certificate') From 16bd6fd6823e9deda0aaf07ee9e36afd4cca3279 Mon Sep 17 00:00:00 2001 From: Adam Stankiewicz Date: Thu, 29 Feb 2024 12:00:46 -0500 Subject: [PATCH 139/164] feat: add Waffle-based enterprise_features to EnterpriseCustomerUserViewSet (#2037) --- CHANGELOG.rst | 4 ++++ enterprise/__init__.py | 2 +- .../api/v1/views/enterprise_customer_user.py | 2 ++ tests/test_enterprise/api/test_views.py | 20 +++++++++++++++++++ 4 files changed, 27 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index ceff1922c9..b7e44bb0ed 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -16,6 +16,10 @@ Change Log Unreleased ---------- +[4.13.0] +--------- +* feat: add Waffle-based `enterprise_features` to the `EnterpriseCustomerUserViewSet`. + [4.12.6] --------- * fix: Proximus learner transmission failures diff --git a/enterprise/__init__.py b/enterprise/__init__.py index 518246734b..8fc5cca2e9 100644 --- a/enterprise/__init__.py +++ b/enterprise/__init__.py @@ -2,4 +2,4 @@ Your project description goes here. """ -__version__ = "4.12.6" +__version__ = "4.13.0" diff --git a/enterprise/api/v1/views/enterprise_customer_user.py b/enterprise/api/v1/views/enterprise_customer_user.py index 0873df114c..296f9fd63f 100644 --- a/enterprise/api/v1/views/enterprise_customer_user.py +++ b/enterprise/api/v1/views/enterprise_customer_user.py @@ -6,6 +6,7 @@ from enterprise import models from enterprise.api.filters import EnterpriseCustomerUserFilterBackend +from enterprise.api.pagination import PaginationWithFeatureFlags from enterprise.api.v1 import serializers from enterprise.api.v1.views.base_views import EnterpriseReadWriteModelViewSet @@ -17,6 +18,7 @@ class EnterpriseCustomerUserViewSet(EnterpriseReadWriteModelViewSet): queryset = models.EnterpriseCustomerUser.objects.all() filter_backends = (filters.OrderingFilter, DjangoFilterBackend, EnterpriseCustomerUserFilterBackend) + pagination_class = PaginationWithFeatureFlags FIELDS = ( 'enterprise_customer', 'user_id', 'active', diff --git a/tests/test_enterprise/api/test_views.py b/tests/test_enterprise/api/test_views.py index 82e14e14dc..4064482b9d 100644 --- a/tests/test_enterprise/api/test_views.py +++ b/tests/test_enterprise/api/test_views.py @@ -465,6 +465,26 @@ class TestEnterpriseCustomerUser(BaseTestEnterpriseAPIViews): Test enterprise learner list endpoint """ + def test_get_enterprise_customer_user_contains_features(self): + """ + Assert whether the paginated response contains `enterprise_features`. + """ + user = factories.UserFactory() + enterprise_customer = factories.EnterpriseCustomerFactory(uuid=FAKE_UUIDS[0]) + factories.EnterpriseCustomerUserFactory( + user_id=user.id, + enterprise_customer=enterprise_customer + ) + response = self.client.get( + '{host}{path}?username={username}'.format( + host=settings.TEST_SERVER, + path=ENTERPRISE_LEARNER_LIST_ENDPOINT, + username=user.username + ) + ) + response = self.load_json(response.content) + assert response['enterprise_features'] is not None + def test_get_enterprise_customer_user_contains_consent_records(self): user = factories.UserFactory() enterprise_customer = factories.EnterpriseCustomerFactory(uuid=FAKE_UUIDS[0]) From c2c298548b145327608426c686dadbc0c7f414dd Mon Sep 17 00:00:00 2001 From: Tim McCormack Date: Thu, 29 Feb 2024 14:00:52 -0500 Subject: [PATCH 140/164] fix: Use openedx/edx-platform since redirects are broken now (#2038) See https://github.com/edx/edx-arch-experiments/issues/558 --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 6d11600142..a615839b81 100644 --- a/Makefile +++ b/Makefile @@ -73,7 +73,7 @@ docs: ## generate Sphinx HTML documentation, including API docs # Define PIP_COMPILE_OPTS=-v to get more information during make upgrade. PIP_COMPILE = pip-compile --upgrade --rebuild $(PIP_COMPILE_OPTS) LOCAL_EDX_PINS = requirements/edx-platform-constraints.txt -PLATFORM_BASE_REQS = https://raw.githubusercontent.com/edx/edx-platform/master/requirements/edx/base.txt +PLATFORM_BASE_REQS = https://raw.githubusercontent.com/openedx/edx-platform/master/requirements/edx/base.txt COMMON_CONSTRAINTS_TXT=requirements/common_constraints.txt .PHONY: $(COMMON_CONSTRAINTS_TXT) $(COMMON_CONSTRAINTS_TXT): From a23e042628fc4d77ba1cfc4af120f08b77228894 Mon Sep 17 00:00:00 2001 From: Kira Miller <31229189+kiram15@users.noreply.github.com> Date: Thu, 29 Feb 2024 23:16:55 -0700 Subject: [PATCH 141/164] feat: adding soft delete functionality for groups (#2036) * feat: adding soft delete functionality for groups * fix: version bump --- CHANGELOG.rst | 5 +++ enterprise/__init__.py | 2 +- enterprise/api/v1/views/enterprise_group.py | 8 ++++- .../migrations/0201_auto_20240227_2227.py | 33 +++++++++++++++++++ enterprise/models.py | 4 +-- tests/test_enterprise/api/test_views.py | 20 +++++++++-- tests/test_models.py | 26 +++++++++++++++ 7 files changed, 92 insertions(+), 6 deletions(-) create mode 100644 enterprise/migrations/0201_auto_20240227_2227.py diff --git a/CHANGELOG.rst b/CHANGELOG.rst index b7e44bb0ed..8067f070d9 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -16,6 +16,11 @@ Change Log Unreleased ---------- +[4.13.1] +--------- +* feat: adding soft delete functionality for groups and group memberships + + [4.13.0] --------- * feat: add Waffle-based `enterprise_features` to the `EnterpriseCustomerUserViewSet`. diff --git a/enterprise/__init__.py b/enterprise/__init__.py index 8fc5cca2e9..fad2a3f768 100644 --- a/enterprise/__init__.py +++ b/enterprise/__init__.py @@ -2,4 +2,4 @@ Your project description goes here. """ -__version__ = "4.13.0" +__version__ = "4.13.1" diff --git a/enterprise/api/v1/views/enterprise_group.py b/enterprise/api/v1/views/enterprise_group.py index ff2371b570..4ab5368e0e 100644 --- a/enterprise/api/v1/views/enterprise_group.py +++ b/enterprise/api/v1/views/enterprise_group.py @@ -28,6 +28,7 @@ class EnterpriseGroupViewSet(EnterpriseReadWriteModelViewSet): API views for the ``enterprise-group`` API endpoint. """ queryset = models.EnterpriseGroup.objects.all() + queryset_with_removed = models.EnterpriseGroup.all_objects.all() permission_classes = (permissions.IsAuthenticated,) filter_backends = (filters.OrderingFilter, DjangoFilterBackend,) serializer_class = serializers.EnterpriseGroupSerializer @@ -37,7 +38,12 @@ def get_queryset(self, **kwargs): - Filter down the queryset of groups available to the requesting user. - Account for requested filtering query params """ - queryset = self.queryset + include_deleted = self.request.query_params.get('include_deleted', False) + if include_deleted: + queryset = self.queryset_with_removed + else: + queryset = self.queryset + if not self.request.user.is_staff: enterprise_user_objects = models.EnterpriseCustomerUser.objects.filter( user_id=self.request.user.id, diff --git a/enterprise/migrations/0201_auto_20240227_2227.py b/enterprise/migrations/0201_auto_20240227_2227.py new file mode 100644 index 0000000000..3cb627a66e --- /dev/null +++ b/enterprise/migrations/0201_auto_20240227_2227.py @@ -0,0 +1,33 @@ +# Generated by Django 3.2.23 on 2024-02-27 22:27 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('enterprise', '0200_enterprisegroup_enterprisegroupmembership_historicalenterprisegroup_historicalenterprisegroupmembers'), + ] + + operations = [ + migrations.AddField( + model_name='enterprisegroup', + name='is_removed', + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name='enterprisegroupmembership', + name='is_removed', + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name='historicalenterprisegroup', + name='is_removed', + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name='historicalenterprisegroupmembership', + name='is_removed', + field=models.BooleanField(default=False), + ), + ] diff --git a/enterprise/models.py b/enterprise/models.py index 0e680160ba..8b70f88c47 100644 --- a/enterprise/models.py +++ b/enterprise/models.py @@ -4212,7 +4212,7 @@ def submit_for_configuration(self, updating_existing_record=False): return sp_metadata_url -class EnterpriseGroup(TimeStampedModel): +class EnterpriseGroup(TimeStampedModel, SoftDeletableModel): """ Enterprise Group model @@ -4242,7 +4242,7 @@ class Meta: ordering = ['-modified'] -class EnterpriseGroupMembership(TimeStampedModel): +class EnterpriseGroupMembership(TimeStampedModel, SoftDeletableModel): """ Enterprise Group Membership model diff --git a/tests/test_enterprise/api/test_views.py b/tests/test_enterprise/api/test_views.py index 4064482b9d..8c90e7ee13 100644 --- a/tests/test_enterprise/api/test_views.py +++ b/tests/test_enterprise/api/test_views.py @@ -7384,6 +7384,10 @@ def test_successful_list_learners(self): } assert page_2_response.json() == expected_response_page_2 + self.enterprise_group_memberships[0].delete() + response = self.client.get(url) + assert response.json()['count'] == 10 + def test_group_uuid_not_found(self): """ Verify that the endpoint api/v1/enterprise_group//learners/ @@ -7422,6 +7426,15 @@ def test_successful_list_with_filters(self): response = self.client.get(url + random_enterprise_query_param) assert not response.json().get('results') + new_group.delete() + new_membership.delete() + enterprise_unfiltered_response = self.client.get(url) + assert len(enterprise_unfiltered_response.json().get('results')) == 0 + enterprise_query_param = "?include_deleted=true" + enterprise_filtered_response = self.client.get(url + enterprise_query_param) + assert len(enterprise_filtered_response.json().get('results')) == 1 + assert learner_filtered_response.json().get('results')[0].get('uuid') == str(new_group.uuid) + def test_successful_post_group(self): """ Test creating a new group record @@ -7478,8 +7491,11 @@ def test_successful_delete_group(self): ) response = self.client.delete(url) assert response.status_code == 204 - assert not EnterpriseGroup.objects.filter(uuid=group_to_delete_uuid) - assert not EnterpriseGroupMembership.objects.filter(group=group_to_delete_uuid) + assert EnterpriseGroup.available_objects.filter(uuid=group_to_delete_uuid).count() == 0 + assert EnterpriseGroup.all_objects.filter(uuid=group_to_delete_uuid).count() == 1 + # if a group gets soft deleted, we still cascade and actually delete the memberships + assert EnterpriseGroupMembership.available_objects.filter(group=group_to_delete_uuid).count() == 0 + assert EnterpriseGroupMembership.all_objects.filter(group=group_to_delete_uuid).count() == 0 def test_assign_learners_404(self): """ diff --git a/tests/test_models.py b/tests/test_models.py index 397163e2be..42d8131631 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -53,6 +53,8 @@ EnterpriseCustomerReportingConfiguration, EnterpriseCustomerSsoConfiguration, EnterpriseCustomerUser, + EnterpriseGroup, + EnterpriseGroupMembership, LicensedEnterpriseCourseEnrollment, PendingEnterpriseCustomerUser, SystemWideEnterpriseRole, @@ -2661,6 +2663,18 @@ def test_group_name_uniqueness(self): with raises(Exception): factories.EnterpriseGroupFactory(name="foobar", enterprise_customer=group_1.enterprise_customer) + def test_enterprise_group_soft_delete(self): + """ + Test ``EnterpriseGroup`` soft deletion property. + """ + group = factories.EnterpriseGroupFactory() + + assert EnterpriseGroup.all_objects.count() == 1 + assert EnterpriseGroup.available_objects.count() == 1 + group.delete() + assert EnterpriseGroup.all_objects.count() == 1 + assert EnterpriseGroup.available_objects.count() == 0 + @mark.django_db @ddt.ddt @@ -2699,6 +2713,18 @@ def test_group_membership_uniqueness(self): pending_enterprise_customer_user=group_membership_1.pending_enterprise_customer_user, ) + def test_enterprise_group_membership_soft_delete(self): + """ + Test ``EnterpriseGroupMembership`` soft deletion property. + """ + membership = factories.EnterpriseGroupMembershipFactory() + + assert EnterpriseGroupMembership.all_objects.count() == 1 + assert EnterpriseGroupMembership.available_objects.count() == 1 + membership.delete() + assert EnterpriseGroupMembership.all_objects.count() == 1 + assert EnterpriseGroupMembership.available_objects.count() == 0 + @mark.django_db @ddt.ddt From f383a8c824d97fc03973dc53e72f35c5ef42d8f4 Mon Sep 17 00:00:00 2001 From: Katrina Nguyen <71999631+katrinan029@users.noreply.github.com> Date: Fri, 1 Mar 2024 08:29:23 -0800 Subject: [PATCH 142/164] feat: add waffle flag for enterprise groups feature (#2039) feat: add waffle flag for enterprise groups feature feat: add waffle flag for enterprise groups feature fix: version fix: lint error chore: rebase --- CHANGELOG.rst | 3 ++ enterprise/__init__.py | 2 +- enterprise/toggles.py | 20 +++++++++ tests/test_enterprise/api/test_filters.py | 3 +- tests/test_enterprise/api/test_views.py | 53 +++++++++++++++-------- 5 files changed, 60 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 8067f070d9..7585f8a96f 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -15,6 +15,9 @@ Change Log Unreleased ---------- +[4.13.2] +--------- +* feat: add a waffle flag for enterprise groups feature [4.13.1] --------- diff --git a/enterprise/__init__.py b/enterprise/__init__.py index fad2a3f768..8f23b4aaf2 100644 --- a/enterprise/__init__.py +++ b/enterprise/__init__.py @@ -2,4 +2,4 @@ Your project description goes here. """ -__version__ = "4.13.1" +__version__ = "4.13.2" diff --git a/enterprise/toggles.py b/enterprise/toggles.py index 82f33077d2..ecf8de54e4 100644 --- a/enterprise/toggles.py +++ b/enterprise/toggles.py @@ -31,6 +31,18 @@ ENTERPRISE_LOG_PREFIX, ) +# .. toggle_name: enterprise.enterprise_groups_v1 +# .. toggle_implementation: WaffleFlag +# .. toggle_default: False +# .. toggle_description: Enables enterprise groups feature +# .. toggle_use_cases: open_edx +# .. toggle_creation_date: 2024-02-29 +ENTERPRISE_GROUPS_V1 = WaffleFlag( + f'{ENTERPRISE_NAMESPACE}.enterprise_groups_v1', + __name__, + ENTERPRISE_LOG_PREFIX, +) + def top_down_assignment_real_time_lcm(): """ @@ -46,6 +58,13 @@ def feature_prequery_search_suggestions(): return FEATURE_PREQUERY_SEARCH_SUGGESTIONS.is_enabled() +def enterprise_groups_v1(): + """ + Returns whether the enterprise groups feature flag is enabled. + """ + return ENTERPRISE_GROUPS_V1.is_enabled() + + def enterprise_features(): """ Returns a dict of enterprise Waffle-based feature flags. @@ -53,4 +72,5 @@ def enterprise_features(): return { 'top_down_assignment_real_time_lcm': top_down_assignment_real_time_lcm(), 'feature_prequery_search_suggestions': feature_prequery_search_suggestions(), + 'enterprise_groups_v1': enterprise_groups_v1(), } diff --git a/tests/test_enterprise/api/test_filters.py b/tests/test_enterprise/api/test_filters.py index a8c631f515..6d6eb439e7 100644 --- a/tests/test_enterprise/api/test_filters.py +++ b/tests/test_enterprise/api/test_filters.py @@ -301,7 +301,8 @@ def test_filter(self, is_staff, is_linked_to_enterprise, has_access): 'results': [], 'enterprise_features': { 'top_down_assignment_real_time_lcm': False, - 'feature_prequery_search_suggestions': False + 'feature_prequery_search_suggestions': False, + 'enterprise_groups_v1': False } } assert response == mock_empty_200_success_response diff --git a/tests/test_enterprise/api/test_views.py b/tests/test_enterprise/api/test_views.py index 8c90e7ee13..df0f2daf0c 100644 --- a/tests/test_enterprise/api/test_views.py +++ b/tests/test_enterprise/api/test_views.py @@ -60,7 +60,11 @@ PendingEnrollment, PendingEnterpriseCustomerUser, ) -from enterprise.toggles import FEATURE_PREQUERY_SEARCH_SUGGESTIONS, TOP_DOWN_ASSIGNMENT_REAL_TIME_LCM +from enterprise.toggles import ( + ENTERPRISE_GROUPS_V1, + FEATURE_PREQUERY_SEARCH_SUGGESTIONS, + TOP_DOWN_ASSIGNMENT_REAL_TIME_LCM, +) from enterprise.utils import ( NotConnectedToOpenEdX, get_sso_orchestrator_api_base_url, @@ -1519,59 +1523,61 @@ def test_enterprise_customer_basic_list(self): @ddt.data( # Request missing required permissions query param. - (True, False, [], {}, False, {'detail': 'User is not allowed to access the view.'}, False, False), + (True, False, [], {}, False, {'detail': 'User is not allowed to access the view.'}, False, False, False), # Staff user that does not have the specified group permission. (True, False, [], {'permissions': ['enterprise_enrollment_api_access']}, False, - {'detail': 'User is not allowed to access the view.'}, False, False), + {'detail': 'User is not allowed to access the view.'}, False, False, False), # Staff user that does have the specified group permission. (True, False, ['enterprise_enrollment_api_access'], {'permissions': ['enterprise_enrollment_api_access']}, - True, None, False, False), + True, None, False, False, False), # Non staff user that is not linked to the enterprise, nor do they have the group permission. (False, False, [], {'permissions': ['enterprise_enrollment_api_access']}, False, - {'detail': 'User is not allowed to access the view.'}, False, False), + {'detail': 'User is not allowed to access the view.'}, False, False, False), # Non staff user that is not linked to the enterprise, but does have the group permission. (False, False, ['enterprise_enrollment_api_access'], {'permissions': ['enterprise_enrollment_api_access']}, - False, None, False, False), + False, None, False, False, False), # Non staff user that is linked to the enterprise, but does not have the group permission. (False, True, [], {'permissions': ['enterprise_enrollment_api_access']}, False, - {'detail': 'User is not allowed to access the view.'}, False, False), + {'detail': 'User is not allowed to access the view.'}, False, False, False), # Non staff user that is linked to the enterprise and does have the group permission (False, True, ['enterprise_enrollment_api_access'], {'permissions': ['enterprise_enrollment_api_access']}, - True, None, False, False), + True, None, False, False, False), # Non staff user that is linked to the enterprise and has group permission and the request has passed # multiple groups to check. (False, True, ['enterprise_enrollment_api_access'], - {'permissions': ['enterprise_enrollment_api_access', 'enterprise_data_api_access']}, True, None, False, False), + {'permissions': ['enterprise_enrollment_api_access', 'enterprise_data_api_access']}, True, None, False, + False, False), # Staff user with group permission filtering on non existent enterprise id. (True, False, ['enterprise_enrollment_api_access'], {'permissions': ['enterprise_enrollment_api_access'], 'enterprise_id': FAKE_UUIDS[1]}, False, - None, False, False), + None, False, False, False), # Staff user with group permission filtering on enterprise id successfully. (True, False, ['enterprise_enrollment_api_access'], {'permissions': ['enterprise_enrollment_api_access'], 'enterprise_id': FAKE_UUIDS[0]}, True, - None, False, False), + None, False, False, False), # Staff user with group permission filtering on search param with no results. (True, False, ['enterprise_enrollment_api_access'], {'permissions': ['enterprise_enrollment_api_access'], 'search': 'blah'}, False, - None, False, False), + None, False, False, False), # Staff user with group permission filtering on search param with results. (True, False, ['enterprise_enrollment_api_access'], {'permissions': ['enterprise_enrollment_api_access'], 'search': 'test'}, True, - None, False, False), + None, False, False, False), # Staff user with group permission filtering on slug with results. (True, False, ['enterprise_enrollment_api_access'], {'permissions': ['enterprise_enrollment_api_access'], 'slug': TEST_SLUG}, True, - None, False, False), + None, False, False, False), # Staff user with group permissions filtering on slug with no results. (True, False, ['enterprise_enrollment_api_access'], {'permissions': ['enterprise_enrollment_api_access'], 'slug': 'blah'}, False, - None, False, False), + None, False, False, False), # Staff user with group permission filtering on slug with results, with - # top down assignment & real-time LCM feature enabled and - # prequery search results enabled + # top down assignment & real-time LCM feature enabled, + # prequery search results enabled and + # enterprise groups v1 feature enabled (True, False, ['enterprise_enrollment_api_access'], {'permissions': ['enterprise_enrollment_api_access'], 'slug': TEST_SLUG}, True, - None, True, True), + None, True, True, True), ) @ddt.unpack @mock.patch('enterprise.utils.get_logo_url') @@ -1585,6 +1591,7 @@ def test_enterprise_customer_with_access_to( expected_error, is_top_down_assignment_real_time_lcm_enabled, feature_prequery_search_suggestions_enabled, + enterprise_groups_v1_enabled, mock_get_logo_url, ): """ @@ -1643,6 +1650,14 @@ def test_enterprise_customer_with_access_to( active=feature_prequery_search_suggestions_enabled ): + response = client.get( + f"{settings.TEST_SERVER}{ENTERPRISE_CUSTOMER_WITH_ACCESS_TO_ENDPOINT}?{urlencode(query_params, True)}" + ) + with override_waffle_flag( + ENTERPRISE_GROUPS_V1, + active=enterprise_groups_v1_enabled + ): + response = client.get( f"{settings.TEST_SERVER}{ENTERPRISE_CUSTOMER_WITH_ACCESS_TO_ENDPOINT}?{urlencode(query_params, True)}" ) @@ -1706,7 +1721,7 @@ def test_enterprise_customer_with_access_to( 'enterprise_features': { 'top_down_assignment_real_time_lcm': is_top_down_assignment_real_time_lcm_enabled, 'feature_prequery_search_suggestions': feature_prequery_search_suggestions_enabled, - + 'enterprise_groups_v1': enterprise_groups_v1_enabled, } } assert response in (expected_error, mock_empty_200_success_response) From 1b81c0e02685733288ebe3767e6ef3a21c016765 Mon Sep 17 00:00:00 2001 From: hamzawaleed01 Date: Tue, 5 Mar 2024 14:58:48 +0500 Subject: [PATCH 143/164] feat: disable failed content transmissions for 24hrs --- .../exporters/content_metadata.py | 12 ++++++++++-- ...adataitemtransmission_remote_errored_at.py | 18 ++++++++++++++++++ .../integrated_channel/models.py | 19 ++++++++++++++++++- .../transmitters/content_metadata.py | 5 ++++- 4 files changed, 50 insertions(+), 4 deletions(-) create mode 100644 integrated_channels/integrated_channel/migrations/0034_contentmetadataitemtransmission_remote_errored_at.py diff --git a/integrated_channels/integrated_channel/exporters/content_metadata.py b/integrated_channels/integrated_channel/exporters/content_metadata.py index 70d20c4fd5..fe96f6c4ac 100644 --- a/integrated_channels/integrated_channel/exporters/content_metadata.py +++ b/integrated_channels/integrated_channel/exporters/content_metadata.py @@ -12,6 +12,7 @@ from django.apps import apps from django.conf import settings from django.db.models import Q +from django.utils import timezone from enterprise.api_client.enterprise_catalog import EnterpriseCatalogApiClient from enterprise.constants import ( @@ -71,6 +72,7 @@ class ContentMetadataExporter(Exporter): # TODO: Move this to the EnterpriseCustomerPluginConfiguration model as a JSONField. DATA_TRANSFORM_MAPPING = {} SKIP_KEY_IF_NONE = False + LAST_24_HRS = timezone.now() - timezone.timedelta(hours=24) def __init__(self, user, enterprise_configuration): """ @@ -201,6 +203,8 @@ def _check_matched_content_updated_at( remote_deleted_at__isnull=True, remote_created_at__isnull=False, ) + content_query.add(Q(remote_errored_at__lt=self.LAST_24_HRS) | Q( + remote_errored_at__isnull=True), Q.AND) # If not force_retrieve_all_catalogs, filter content records where `content last changed` is less than # the matched item's `date_updated`, otherwise select the row regardless of what the updated at time is. if not force_retrieve_all_catalogs: @@ -397,13 +401,17 @@ def _check_matched_content_to_delete(self, enterprise_customer_catalog, items): incomplete_transmission.mark_for_delete() items_to_delete[content_id] = incomplete_transmission else: - past_content = ContentMetadataItemTransmission.objects.filter( + past_content_query = Q( enterprise_customer=self.enterprise_configuration.enterprise_customer, integrated_channel_code=self.enterprise_configuration.channel_code(), enterprise_customer_catalog_uuid=enterprise_customer_catalog.uuid, plugin_configuration_id=self.enterprise_configuration.id, content_id=content_id - ).first() + ) + past_content_query.add(Q(remote_errored_at__lt=self.LAST_24_HRS) | Q( + remote_errored_at__isnull=True), Q.AND) + past_content = ContentMetadataItemTransmission.objects.filter( + past_content_query).first() if past_content: past_content.mark_for_delete() items_to_delete[content_id] = past_content diff --git a/integrated_channels/integrated_channel/migrations/0034_contentmetadataitemtransmission_remote_errored_at.py b/integrated_channels/integrated_channel/migrations/0034_contentmetadataitemtransmission_remote_errored_at.py new file mode 100644 index 0000000000..08c0c1c551 --- /dev/null +++ b/integrated_channels/integrated_channel/migrations/0034_contentmetadataitemtransmission_remote_errored_at.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.23 on 2024-03-05 09:49 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('integrated_channel', '0033_integratedchannelapirequestlogs_channel_name'), + ] + + operations = [ + migrations.AddField( + model_name='contentmetadataitemtransmission', + name='remote_errored_at', + field=models.DateTimeField(blank=True, help_text='Date when the content transmission was failed in the remote API.', null=True), + ), + ] diff --git a/integrated_channels/integrated_channel/models.py b/integrated_channels/integrated_channel/models.py index 792e60ee5a..05ddf5c99e 100644 --- a/integrated_channels/integrated_channel/models.py +++ b/integrated_channels/integrated_channel/models.py @@ -12,6 +12,7 @@ from django.db import models from django.db.models import Q from django.db.models.query import QuerySet +from django.utils import timezone from django.utils.translation import gettext_lazy as _ from model_utils.models import TimeStampedModel @@ -27,6 +28,7 @@ LOGGER = logging.getLogger(__name__) User = auth.get_user_model() +LAST_24_HRS = timezone.now() - timezone.timedelta(hours=24) def set_default_display_name(*args, **kw): @@ -671,6 +673,11 @@ class Meta: blank=True, null=True ) + remote_errored_at = models.DateTimeField( + help_text='Date when the content transmission was failed in the remote API.', + blank=True, + null=True + ) remote_updated_at = models.DateTimeField( help_text='Date when the content transmission was last updated in the remote API', blank=True, @@ -707,13 +714,16 @@ def deleted_transmissions(cls, enterprise_customer, plugin_configuration_id, int """ Return any pre-existing records for this customer/plugin/content which was previously deleted """ - return ContentMetadataItemTransmission.objects.filter( + query = Q( enterprise_customer=enterprise_customer, plugin_configuration_id=plugin_configuration_id, content_id=content_id, integrated_channel_code=integrated_channel_code, remote_deleted_at__isnull=False, ) + query.add(Q(remote_errored_at__lt=LAST_24_HRS) | + Q(remote_errored_at__isnull=True), Q.AND) + return ContentMetadataItemTransmission.objects.filter(query) @classmethod def incomplete_create_transmissions( @@ -734,6 +744,7 @@ def incomplete_create_transmissions( remote_created_at__isnull=True, remote_updated_at__isnull=True, remote_deleted_at__isnull=True, + remote_errored_at__isnull=True, ) in_db_but_failed_to_send_query = Q( enterprise_customer=enterprise_customer, @@ -745,6 +756,8 @@ def incomplete_create_transmissions( remote_deleted_at__isnull=True, api_response_status_code__gte=400, ) + in_db_but_failed_to_send_query.add( + Q(remote_errored_at__lt=LAST_24_HRS) | Q(remote_errored_at__isnull=True), Q.AND) in_db_but_unsent_query.add(in_db_but_failed_to_send_query, Q.OR) return ContentMetadataItemTransmission.objects.filter(in_db_but_unsent_query) @@ -769,6 +782,8 @@ def incomplete_update_transmissions( remote_deleted_at__isnull=True, api_response_status_code__gte=400, ) + in_db_but_failed_to_send_query.add( + Q(remote_errored_at__lt=LAST_24_HRS) | Q(remote_errored_at__isnull=True), Q.AND) return ContentMetadataItemTransmission.objects.filter(in_db_but_failed_to_send_query) @classmethod @@ -791,6 +806,8 @@ def incomplete_delete_transmissions( remote_deleted_at__isnull=False, api_response_status_code__gte=400, ) + in_db_but_failed_to_send_query.add( + Q(remote_errored_at__lt=LAST_24_HRS) | Q(remote_errored_at__isnull=True), Q.AND) return ContentMetadataItemTransmission.objects.filter(in_db_but_failed_to_send_query) def _mark_transmission(self, mark_for): diff --git a/integrated_channels/integrated_channel/transmitters/content_metadata.py b/integrated_channels/integrated_channel/transmitters/content_metadata.py index 25fe8ca8b0..0ce81b50f7 100644 --- a/integrated_channels/integrated_channel/transmitters/content_metadata.py +++ b/integrated_channels/integrated_channel/transmitters/content_metadata.py @@ -249,9 +249,12 @@ def _transmit_action(self, content_metadata_item_map, client_method, action_name transmission.remote_deleted_at = action_happened_at if was_successful: successfully_removed_content_keys.append(transmission.content_id) - transmission.save() if was_successful: transmission.remove_marked_for() + transmission.remote_errored_at = None + else: + transmission.remote_errored_at = action_happened_at + transmission.save() self.enterprise_configuration.update_content_synced_at(action_happened_at, was_successful) results.append(transmission) From 01124b93f48af4ed646b801ad9a396e14445efb9 Mon Sep 17 00:00:00 2001 From: Alexander J Sheehan Date: Fri, 1 Mar 2024 18:19:30 +0000 Subject: [PATCH 144/164] feat: adding management command to remove expired pending group memberships --- CHANGELOG.rst | 5 +- enterprise/__init__.py | 2 +- enterprise/api/v1/serializers.py | 18 ++- enterprise/api/v1/views/enterprise_group.py | 28 ++++- ...emove_expired_pending_group_memberships.py | 47 ++++++++ ...egroup_applies_to_all_contexts_and_more.py | 23 ++++ enterprise/models.py | 8 ++ test_utils/factories.py | 4 +- tests/test_enterprise/api/test_serializers.py | 23 +++- tests/test_enterprise/api/test_views.py | 22 ++++ ...emove_expired_pending_group_memberships.py | 112 ++++++++++++++++++ 11 files changed, 282 insertions(+), 10 deletions(-) create mode 100644 enterprise/management/commands/remove_expired_pending_group_memberships.py create mode 100644 enterprise/migrations/0202_enterprisegroup_applies_to_all_contexts_and_more.py create mode 100644 tests/test_enterprise/management/test_remove_expired_pending_group_memberships.py diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 7585f8a96f..a0b7dafd9a 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -15,6 +15,10 @@ Change Log Unreleased ---------- +[4.13.3] +--------- +* feat: adding management command to remove expired pending group memberships + [4.13.2] --------- * feat: add a waffle flag for enterprise groups feature @@ -23,7 +27,6 @@ Unreleased --------- * feat: adding soft delete functionality for groups and group memberships - [4.13.0] --------- * feat: add Waffle-based `enterprise_features` to the `EnterpriseCustomerUserViewSet`. diff --git a/enterprise/__init__.py b/enterprise/__init__.py index 8f23b4aaf2..e09e5a3e5d 100644 --- a/enterprise/__init__.py +++ b/enterprise/__init__.py @@ -2,4 +2,4 @@ Your project description goes here. """ -__version__ = "4.13.2" +__version__ = "4.13.3" diff --git a/enterprise/api/v1/serializers.py b/enterprise/api/v1/serializers.py index 7bfafbc5f9..5bdab56b1f 100644 --- a/enterprise/api/v1/serializers.py +++ b/enterprise/api/v1/serializers.py @@ -692,7 +692,23 @@ def get_enterprise_group(self, obj): """ Return the enterprise group membership for this enterprise customer user. """ - return obj.memberships.select_related('group').all() + related_customer = obj.enterprise_customer + # Find any groups that have ``applies_to_all_contexts`` set to True that are connected to the customer + # that's related to the customer associated with this customer user record. + all_context_groups = models.EnterpriseGroup.objects.filter( + enterprise_customer=related_customer, + applies_to_all_contexts=True + ).values_list('uuid', flat=True) + enterprise_groups_from_memberships = obj.memberships.select_related('group').all().values_list( + 'group', + flat=True + ) + # Combine both sets of group UUIDs + group_uuids = set(enterprise_groups_from_memberships) + for group in all_context_groups: + group_uuids.add(group) + + return list(group_uuids) class EnterpriseCustomerUserWriteSerializer(serializers.ModelSerializer): diff --git a/enterprise/api/v1/views/enterprise_group.py b/enterprise/api/v1/views/enterprise_group.py index 4ab5368e0e..00c3bbc38b 100644 --- a/enterprise/api/v1/views/enterprise_group.py +++ b/enterprise/api/v1/views/enterprise_group.py @@ -119,8 +119,32 @@ def get_learners(self, *args, **kwargs): group_uuid = kwargs.get('group_uuid') try: - learner_list = self.get_queryset().get(uuid=group_uuid).members.all() - page = self.paginate_queryset(learner_list) + group_object = self.get_queryset().get(uuid=group_uuid) + if group_object.applies_to_all_contexts: + members = [] + customer_users = models.EnterpriseCustomerUser.objects.filter( + enterprise_customer=group_object.enterprise_customer, + active=True, + ) + pending_customer_users = models.PendingEnterpriseCustomerUser.objects.filter( + enterprise_customer=group_object.enterprise_customer, + ) + for ent_user in customer_users: + members.append(models.EnterpriseGroupMembership( + uuid=None, + enterprise_customer_user=ent_user, + group=group_object, + )) + for pending_user in pending_customer_users: + members.append(models.EnterpriseGroupMembership( + uuid=None, + pending_enterprise_customer_user=pending_user, + group=group_object, + )) + page = self.paginate_queryset(members) + else: + learner_list = group_object.members.all() + page = self.paginate_queryset(learner_list) serializer = serializers.EnterpriseGroupMembershipSerializer(page, many=True) response = self.get_paginated_response(serializer.data) return response diff --git a/enterprise/management/commands/remove_expired_pending_group_memberships.py b/enterprise/management/commands/remove_expired_pending_group_memberships.py new file mode 100644 index 0000000000..ffbaee5098 --- /dev/null +++ b/enterprise/management/commands/remove_expired_pending_group_memberships.py @@ -0,0 +1,47 @@ +""" +Management command for ensuring any pending group membership, ie memberships associated with a pending enterprise user, +are removed after 90 days. +""" + +import logging +from datetime import timedelta + +from django.core.management.base import BaseCommand + +from enterprise.models import EnterpriseCustomer, EnterpriseGroupMembership +from enterprise.utils import localized_utcnow + +log = logging.getLogger(__name__) + + +class Command(BaseCommand): + """ + Management command for ensuring any pending group membership, ie memberships associated with a pending enterprise + user, are removed after 90 days. Optionally supply a ``--enterprise_customer`` arg to only run this command on + a singular customer. + + Example usage: + $ ./manage.py remove_expired_pending_group_memberships + """ + help = 'Removes pending group memberships if they are older than 90 days.' + + def add_arguments(self, parser): + parser.add_argument("-e", "--enterprise_customer") + + def handle(self, *args, **options): + queryset = EnterpriseGroupMembership.objects.all() + if enterprise_arg := options.get("enterprise_customer"): + try: + enterprise_customer = EnterpriseCustomer.objects.get(uuid=enterprise_arg) + queryset = queryset.filter(group__enterprise_customer=enterprise_customer) + except EnterpriseCustomer.DoesNotExist as exc: + log.exception(f'Enterprise Customer: {enterprise_arg} not found') + raise exc + expired_memberships = queryset.filter( + enterprise_customer_user=None, + pending_enterprise_customer_user__isnull=False, + created__lte=localized_utcnow() - timedelta(days=90) + ) + for membership in expired_memberships: + membership.pending_enterprise_customer_user.delete() + expired_memberships.delete() diff --git a/enterprise/migrations/0202_enterprisegroup_applies_to_all_contexts_and_more.py b/enterprise/migrations/0202_enterprisegroup_applies_to_all_contexts_and_more.py new file mode 100644 index 0000000000..3a3b0261de --- /dev/null +++ b/enterprise/migrations/0202_enterprisegroup_applies_to_all_contexts_and_more.py @@ -0,0 +1,23 @@ +# Generated by Django 4.2.10 on 2024-03-01 17:35 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('enterprise', '0201_auto_20240227_2227'), + ] + + operations = [ + migrations.AddField( + model_name='enterprisegroup', + name='applies_to_all_contexts', + field=models.BooleanField(default=False, help_text='When enabled, all learners connected to the org will be considered a member.', verbose_name='Set group membership to the entire org of learners.'), + ), + migrations.AddField( + model_name='historicalenterprisegroup', + name='applies_to_all_contexts', + field=models.BooleanField(default=False, help_text='When enabled, all learners connected to the org will be considered a member.', verbose_name='Set group membership to the entire org of learners.'), + ), + ] diff --git a/enterprise/models.py b/enterprise/models.py index 8b70f88c47..79323f0119 100644 --- a/enterprise/models.py +++ b/enterprise/models.py @@ -4233,6 +4233,14 @@ class EnterpriseGroup(TimeStampedModel, SoftDeletableModel): related_name='groups', on_delete=models.deletion.CASCADE ) + applies_to_all_contexts = models.BooleanField( + verbose_name="Set group membership to the entire org of learners.", + default=False, + help_text=_( + "When enabled, all learners connected to the org will be considered a member." + ) + ) + history = HistoricalRecords() class Meta: diff --git a/test_utils/factories.py b/test_utils/factories.py index e988a1cc1a..379ad0e908 100644 --- a/test_utils/factories.py +++ b/test_utils/factories.py @@ -2,6 +2,7 @@ Factoryboy factories. """ +from random import randint from uuid import UUID import factory @@ -244,7 +245,7 @@ class Meta: model = User email = factory.LazyAttribute(lambda x: FAKER.email()) - username = factory.LazyAttribute(lambda x: FAKER.user_name()) + username = factory.LazyAttribute(lambda x: FAKER.user_name() + str(randint(1, 10000))) first_name = factory.LazyAttribute(lambda x: FAKER.first_name()) last_name = factory.LazyAttribute(lambda x: FAKER.last_name()) is_staff = False @@ -1114,6 +1115,7 @@ class Meta: model = EnterpriseGroup uuid = factory.LazyAttribute(lambda x: UUID(FAKER.uuid4())) + applies_to_all_contexts = False enterprise_customer = factory.SubFactory(EnterpriseCustomerFactory) name = factory.LazyAttribute(lambda x: FAKER.company()) diff --git a/tests/test_enterprise/api/test_serializers.py b/tests/test_enterprise/api/test_serializers.py index 4f84058626..37c115b1b0 100644 --- a/tests/test_enterprise/api/test_serializers.py +++ b/tests/test_enterprise/api/test_serializers.py @@ -310,7 +310,22 @@ def test_group_membership(self): serializer = EnterpriseCustomerUserReadOnlySerializer(self.enterprise_customer_user_1) assert len(serializer.data['enterprise_group']) == 1 - assert serializer.data['enterprise_group'][0].uuid == membership.uuid + assert serializer.data['enterprise_group'][0] == membership.group.uuid + + def test_group_membership_when_applies_to_all_contexts(self): + """ + Test that when a group has ``applies_to_all_contexts`` set to True, that group is included in the enterprise + customer user serializer data when there is an associated via an enterprise customer object. + """ + enterprise_group = factories.EnterpriseGroupFactory( + enterprise_customer=self.enterprise_customer_1, + applies_to_all_contexts=True, + ) + serializer = EnterpriseCustomerUserReadOnlySerializer(self.enterprise_customer_user_1) + # Assert the enterprise customer user serializer found the group + assert serializer.data.get('enterprise_group') == [enterprise_group.uuid] + # Assert the group has no memberships that could be read by the serializer + assert not enterprise_group.members.all() def test_multi_group_membership(self): """ @@ -330,9 +345,9 @@ def test_multi_group_membership(self): serializer = EnterpriseCustomerUserReadOnlySerializer(self.enterprise_customer_user_1) assert len(serializer.data['enterprise_group']) == 2 - assert sorted([membership.uuid, membership_2.uuid]) == sorted([ - serializer.data['enterprise_group'][0].uuid, - serializer.data['enterprise_group'][1].uuid, + assert sorted([membership.group.uuid, membership_2.group.uuid]) == sorted([ + serializer.data['enterprise_group'][0], + serializer.data['enterprise_group'][1], ]) diff --git a/tests/test_enterprise/api/test_views.py b/tests/test_enterprise/api/test_views.py index df0f2daf0c..c08a05d97c 100644 --- a/tests/test_enterprise/api/test_views.py +++ b/tests/test_enterprise/api/test_views.py @@ -7668,6 +7668,28 @@ def test_remove_learners_from_group_only_removes_from_specified_group(self): EnterpriseGroupMembership.objects.get(pk=membership_to_remove.pk) assert EnterpriseGroupMembership.objects.get(pk=existing_membership.pk) + def test_group_applies_to_all_contexts_learner_list(self): + """ + Test that hitting the enterprise-group `/learners/` endpoint for a group that has ``applies_to_all_contexts`` + will return all learners in the group's org regardless of what membership records exist. + """ + new_group = EnterpriseGroupFactory(applies_to_all_contexts=True) + new_user = EnterpriseCustomerUserFactory( + user_id=self.user.id, enterprise_customer=new_group.enterprise_customer, + active=True + ) + pending_user = PendingEnterpriseCustomerUserFactory( + enterprise_customer=new_group.enterprise_customer, + ) + url = settings.TEST_SERVER + reverse( + 'enterprise-group-learners', + kwargs={'group_uuid': new_group.uuid}, + ) + response = self.client.get(url) + results = response.json().get('results') + for result in results: + assert (result.get('pending_learner_id') == pending_user.id) or (result.get('learner_id') == new_user.id) + @mark.django_db class TestEnterpriseCustomerSsoConfigurationViewSet(APITest): diff --git a/tests/test_enterprise/management/test_remove_expired_pending_group_memberships.py b/tests/test_enterprise/management/test_remove_expired_pending_group_memberships.py new file mode 100644 index 0000000000..e414875108 --- /dev/null +++ b/tests/test_enterprise/management/test_remove_expired_pending_group_memberships.py @@ -0,0 +1,112 @@ +""" +Tests for the django management command `remove_expired_pending_group_memberships`. +""" +from datetime import timedelta + +from pytest import mark + +from django.core.management import call_command +from django.test import TestCase + +from enterprise import models +from enterprise.utils import localized_utcnow +from test_utils import factories + + +@mark.django_db +class RemoveExpiredPendingGroupMembershipsCommandTests(TestCase): + """ + Test command `remove_expired_pending_group_memberships`. + """ + command = 'remove_expired_pending_group_memberships' + + def test_specifying_a_customer_limits_command_scope(self): + """ + Test that if the command is passed an optional ``--enterprise_customer`` arg, it will limit the scope of + queryable objects to just that customer's memberships + """ + # Target membership that should be removed because it has a pending user and is over 90 days old + group_to_remove_from = factories.EnterpriseGroupFactory() + membership_to_remove = factories.EnterpriseGroupMembershipFactory( + group=group_to_remove_from, + enterprise_customer_user=None, + ) + membership_to_remove.created = localized_utcnow() - timedelta(days=91) + membership_to_remove.save() + + # A membership that is older than 90 days but connected to a different customer + membership_to_keep = factories.EnterpriseGroupMembershipFactory( + enterprise_customer_user=None, + ) + membership_to_keep.created = localized_utcnow() - timedelta(days=91) + membership_to_keep.save() + + call_command(self.command, enterprise_customer=str(group_to_remove_from.enterprise_customer.uuid)) + + assert not models.EnterpriseGroupMembership.all_objects.filter(pk=membership_to_remove.pk) + assert not models.PendingEnterpriseCustomerUser.objects.filter( + pk=membership_to_remove.pending_enterprise_customer_user.pk + ) + + assert models.EnterpriseGroupMembership.all_objects.filter(pk=membership_to_keep.pk) + assert models.PendingEnterpriseCustomerUser.objects.filter( + pk=membership_to_keep.pending_enterprise_customer_user.pk + ) + + # Sanity check + call_command(self.command) + assert not models.EnterpriseGroupMembership.all_objects.filter(pk=membership_to_keep.pk) + assert not models.PendingEnterpriseCustomerUser.objects.filter( + pk=membership_to_keep.pending_enterprise_customer_user.pk + ) + + def test_removing_old_records(self): + """ + Test that the command properly hard deletes membership records and pending enterprise customer user records + """ + # Target membership that should be removed because it has a pending user and is over 90 days old + membership_to_remove = factories.EnterpriseGroupMembershipFactory( + enterprise_customer_user=None, + ) + membership_to_remove.created = localized_utcnow() - timedelta(days=91) + membership_to_remove.save() + + # A membership that is older than 90 days but has a realized enterprise customer user + old_membership_to_keep = factories.EnterpriseGroupMembershipFactory( + pending_enterprise_customer_user=None, + ) + old_membership_to_keep.created = localized_utcnow() - timedelta(days=91) + old_membership_to_keep.save() + + # A membership that has a pending user but has not reached the 90 days cutoff + new_pending_membership = factories.EnterpriseGroupMembershipFactory( + enterprise_customer_user=None, + ) + new_pending_membership.created = localized_utcnow() + + # Sanity check, a membership that is younger than 90 days and has a realized enterprise customer user + membership = factories.EnterpriseGroupMembershipFactory( + pending_enterprise_customer_user=None, + ) + membership.created = localized_utcnow() + membership.save() + + call_command(self.command) + + # Assert that memberships and pending customers are removed + assert not models.EnterpriseGroupMembership.all_objects.filter(pk=membership_to_remove.pk) + assert not models.PendingEnterpriseCustomerUser.objects.filter( + pk=membership_to_remove.pending_enterprise_customer_user.pk + ) + + # Assert that expected memberships and users are kept + assert models.EnterpriseGroupMembership.all_objects.filter(pk=old_membership_to_keep.pk) + assert models.EnterpriseCustomerUser.objects.filter(pk=old_membership_to_keep.enterprise_customer_user.pk) + + assert models.EnterpriseGroupMembership.all_objects.filter(pk=new_pending_membership.pk) + assert models.PendingEnterpriseCustomerUser.objects.filter( + pk=new_pending_membership.pending_enterprise_customer_user.pk + ) + + assert models.EnterpriseGroupMembership.all_objects.filter(pk=membership.pk) + assert models.EnterpriseCustomerUser.objects.filter(pk=membership.enterprise_customer_user.pk) From 5de56204f794d8479a9e2e1199723249d9783d7f Mon Sep 17 00:00:00 2001 From: hamzawaleed01 Date: Wed, 6 Mar 2024 17:26:53 +0500 Subject: [PATCH 145/164] test: add test to check if failing tranmissions are skipped in exporter or not --- .../test_exporters/test_content_metadata.py | 49 +++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/tests/test_integrated_channels/test_integrated_channel/test_exporters/test_content_metadata.py b/tests/test_integrated_channels/test_integrated_channel/test_exporters/test_content_metadata.py index aad438caf4..d5b59faac7 100644 --- a/tests/test_integrated_channels/test_integrated_channel/test_exporters/test_content_metadata.py +++ b/tests/test_integrated_channels/test_integrated_channel/test_exporters/test_content_metadata.py @@ -12,6 +12,7 @@ from testfixtures import LogCapture from django.test.utils import override_settings +from django.utils import timezone from enterprise.constants import EXEC_ED_COURSE_TYPE from enterprise.utils import get_content_metadata_item_id @@ -111,6 +112,7 @@ def test_exporter_transforms_metadata_of_items_to_be_deleted( ] } content_id = "NYIF+BOPC.4x" + content_id_to_skip = "CTIF+BOPC.4x" mock_metadata = { "aggregation_key": "course:NYIF+BOPC.4x", "content_type": "course", @@ -164,7 +166,20 @@ def test_exporter_transforms_metadata_of_items_to_be_deleted( channel_metadata=channel_metadata, remote_created_at=datetime.datetime.utcnow(), enterprise_customer_catalog_uuid=self.enterprise_customer_catalog.uuid, + remote_errored_at=None, ) + transmission_audit_to_skip = factories.ContentMetadataItemTransmissionFactory( + content_id=content_id_to_skip, + enterprise_customer=self.config.enterprise_customer, + plugin_configuration_id=sap_config.id, + integrated_channel_code=sap_config.channel_code(), + channel_metadata=channel_metadata, + remote_created_at=datetime.datetime.utcnow(), + enterprise_customer_catalog_uuid=self.enterprise_customer_catalog.uuid, + # failed within last 24hrs + remote_errored_at=timezone.now() - timezone.timedelta(hours=23), + ) + # Mock the catalog service to return the metadata of the content to be deleted mock_get_content_metadata.return_value = [mock_metadata] # Mock the catalog service to return the content to be deleted in the delete payload @@ -174,6 +189,14 @@ def test_exporter_transforms_metadata_of_items_to_be_deleted( assert delete_payload[transmission_audit.content_id].channel_metadata.get('schedule') == [] assert delete_payload[transmission_audit.content_id].channel_metadata.get('status') == 'INACTIVE' + # if transmission was attempted in last 24hrs, it shouldn't be reattempted + mock_get_catalog_diff.return_value = ( + [], [{'content_key': transmission_audit_to_skip.content_id}], []) + exporter = SapSuccessFactorsContentMetadataExporter( + 'fake-user', sap_config) + _, _, delete_payload = exporter.export() + assert not delete_payload + @mock.patch('enterprise.api_client.enterprise_catalog.EnterpriseCatalogApiClient.get_catalog_diff') def test_exporter_get_catalog_diff_works_with_orphaned_content(self, mock_get_catalog_diff): """ @@ -226,6 +249,8 @@ def test_exporter_considers_failed_updates_as_existing_content( remote_updated_at=datetime.datetime.utcnow(), content_last_changed=None, api_response_status_code=500, + # didn't failed within last 24hrs + remote_errored_at=timezone.now() - timezone.timedelta(hours=25), ) mock_metadata = get_fake_content_metadata()[:1] mock_metadata[0]['key'] = test_failed_updated_content.content_id @@ -256,6 +281,29 @@ def test_exporter_considers_failed_updates_as_existing_content( # The exporter should now properly include the content in the update payload. assert update_payload == {test_failed_updated_content.content_id: test_failed_updated_content} + # shouldn't export if it errored in last 24hrs + exporter = ContentMetadataExporter('fake-user', self.config) + content_id_to_skip = "CTIF+BOPC.4x" + test_failed_updated_content_to_skip = ContentMetadataItemTransmissionFactory( + content_id=content_id_to_skip, + enterprise_customer=self.enterprise_customer_catalog.enterprise_customer, + enterprise_customer_catalog_uuid=self.enterprise_customer_catalog.uuid, + integrated_channel_code=self.config.channel_code(), + plugin_configuration_id=self.config.id, + remote_created_at=datetime.datetime.utcnow(), + remote_updated_at=datetime.datetime.utcnow(), + content_last_changed=None, + api_response_status_code=500, + # failed within last 24hrs + remote_errored_at=timezone.now() - timezone.timedelta(hours=23), + ) + mock_get_catalog_diff.return_value = ( + [], [], [{'content_key': test_failed_updated_content_to_skip.content_id, + 'date_updated': datetime.datetime.now()}] + ) + _, update_payload, __ = exporter.export() + assert not update_payload + @mock.patch('enterprise.api_client.enterprise_catalog.EnterpriseCatalogApiClient.get_content_metadata') @mock.patch('enterprise.api_client.enterprise_catalog.EnterpriseCatalogApiClient.get_catalog_diff') def test_content_exporter_create_export(self, mock_get_catalog_diff, mock_get_content_metadata): @@ -389,6 +437,7 @@ def test_content_exporter_failed_create_export(self, mock_get_catalog_diff, mock remote_created_at=None, remote_updated_at=None, remote_deleted_at=None, + remote_errored_at=None, ) past_transmission.save() mock_get_content_metadata.return_value = get_fake_content_metadata() From c1ea9307f3d2d29ff4a47697960b1d3cc3be5b33 Mon Sep 17 00:00:00 2001 From: Alexander J Sheehan Date: Wed, 6 Mar 2024 21:29:13 +0000 Subject: [PATCH 146/164] feat: admin pages for enterprise groups and enterprise group memberships --- CHANGELOG.rst | 4 ++ enterprise/__init__.py | 2 +- enterprise/admin/__init__.py | 57 +++++++++++++++++++- enterprise/api/v1/views/enterprise_group.py | 27 +--------- enterprise/models.py | 58 +++++++++++++++++++++ 5 files changed, 121 insertions(+), 27 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index a0b7dafd9a..bbcc9ed91a 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -15,6 +15,10 @@ Change Log Unreleased ---------- +[4.13.4] +--------- +* feat: admin pages for enterprise groups and enterprise group memberships + [4.13.3] --------- * feat: adding management command to remove expired pending group memberships diff --git a/enterprise/__init__.py b/enterprise/__init__.py index e09e5a3e5d..1f625915c8 100644 --- a/enterprise/__init__.py +++ b/enterprise/__init__.py @@ -2,4 +2,4 @@ Your project description goes here. """ -__version__ = "4.13.3" +__version__ = "4.13.4" diff --git a/enterprise/admin/__init__.py b/enterprise/admin/__init__.py index 2d88a33dd1..019dce5851 100644 --- a/enterprise/admin/__init__.py +++ b/enterprise/admin/__init__.py @@ -456,7 +456,7 @@ class Meta: ) list_display = ('username', 'user_email', 'get_enterprise_customer') - search_fields = ('user_id',) + search_fields = ('user_id', 'user_email',) @admin.display( description='Enterprise Customer' @@ -583,6 +583,11 @@ class Meta: 'created' ) + search_fields = ( + 'user_email', + 'id' + ) + readonly_fields = ( 'user_email', 'enterprise_customer', @@ -1155,3 +1160,53 @@ def mark_configured(self, request, obj): obj.save() mark_configured.label = "Mark as Configured" + + +@admin.register(models.EnterpriseGroup) +class EnterpriseGroupAdmin(admin.ModelAdmin): + """ + Django admin for EnterpriseGroup model. + """ + model = models.EnterpriseGroup + list_display = ('uuid', 'enterprise_customer', 'applies_to_all_contexts', ) + list_filter = ('applies_to_all_contexts',) + search_fields = ( + 'uuid', + 'name', + 'enterprise_customer__name', + 'enterprise_customer__uuid', + ) + readonly_fields = ('count', 'members',) + + def members(self, obj): + """ + Return the non-deleted members of a group + """ + return obj.get_all_learners() + + @admin.display(description="Number of members in group") + def count(self, obj): + """ + Return the number of members in a group + """ + return len(obj.get_all_learners()) + + +@admin.register(models.EnterpriseGroupMembership) +class EnterpriseGroupMembershipAdmin(admin.ModelAdmin): + """ + Django admin for EnterpriseGroupMembership model. + """ + model = models.EnterpriseGroupMembership + list_display = ('group', 'membership_user',) + search_fields = ( + 'uuid', + 'group__enterprise_customer_user', + 'enterprise_customer_user', + 'pending_enterprise_customer_user', + ) + autocomplete_fields = ( + 'group', + 'enterprise_customer_user', + 'pending_enterprise_customer_user', + ) diff --git a/enterprise/api/v1/views/enterprise_group.py b/enterprise/api/v1/views/enterprise_group.py index 00c3bbc38b..8c324b1225 100644 --- a/enterprise/api/v1/views/enterprise_group.py +++ b/enterprise/api/v1/views/enterprise_group.py @@ -120,31 +120,8 @@ def get_learners(self, *args, **kwargs): group_uuid = kwargs.get('group_uuid') try: group_object = self.get_queryset().get(uuid=group_uuid) - if group_object.applies_to_all_contexts: - members = [] - customer_users = models.EnterpriseCustomerUser.objects.filter( - enterprise_customer=group_object.enterprise_customer, - active=True, - ) - pending_customer_users = models.PendingEnterpriseCustomerUser.objects.filter( - enterprise_customer=group_object.enterprise_customer, - ) - for ent_user in customer_users: - members.append(models.EnterpriseGroupMembership( - uuid=None, - enterprise_customer_user=ent_user, - group=group_object, - )) - for pending_user in pending_customer_users: - members.append(models.EnterpriseGroupMembership( - uuid=None, - pending_enterprise_customer_user=pending_user, - group=group_object, - )) - page = self.paginate_queryset(members) - else: - learner_list = group_object.members.all() - page = self.paginate_queryset(learner_list) + members = group_object.get_all_learners() + page = self.paginate_queryset(members) serializer = serializers.EnterpriseGroupMembershipSerializer(page, many=True) response = self.get_paginated_response(serializer.data) return response diff --git a/enterprise/models.py b/enterprise/models.py index 79323f0119..c47b9caf05 100644 --- a/enterprise/models.py +++ b/enterprise/models.py @@ -4249,6 +4249,36 @@ class Meta: unique_together = (("name", "enterprise_customer"),) ordering = ['-modified'] + def get_all_learners(self): + """ + Returns all users associated with the group, whether the group specifies the entire org else all associated + membership records. + """ + if self.applies_to_all_contexts: + members = [] + customer_users = EnterpriseCustomerUser.objects.filter( + enterprise_customer=self.enterprise_customer, + active=True, + ) + pending_customer_users = PendingEnterpriseCustomerUser.objects.filter( + enterprise_customer=self.enterprise_customer, + ) + for ent_user in customer_users: + members.append(EnterpriseGroupMembership( + uuid=None, + enterprise_customer_user=ent_user, + group=self, + )) + for pending_user in pending_customer_users: + members.append(EnterpriseGroupMembership( + uuid=None, + pending_enterprise_customer_user=pending_user, + group=self, + )) + return members + else: + return self.members.filter(is_removed=False) + class EnterpriseGroupMembership(TimeStampedModel, SoftDeletableModel): """ @@ -4287,3 +4317,31 @@ class Meta: # ie no issue if multiple fields have: group = A and pending_enterprise_customer_user = NULL unique_together = (("group", "enterprise_customer_user"), ("group", "pending_enterprise_customer_user")) ordering = ['-modified'] + + @property + def membership_user(self): + """ + Return the user record associated with the membership, defaulting to ``enterprise_customer_user`` + and falling back on ``obj.pending_enterprise_customer_user`` + """ + return self.enterprise_customer_user or self.pending_enterprise_customer_user + + def clean(self, *args, **kwargs): + """ + Ensure that records added via Django Admin have matching customer records between learner and group. + """ + user = self.membership_user + if user: + user_customer = user.enterprise_customer + if user_customer != self.group.enterprise_customer: + raise ValidationError( + 'Enterprise Customer associated with membership group must match the Enterprise Customer associated' + ' with the memberships user' + ) + super().clean(*args, **kwargs) + + def __str__(self): + """ + Return human-readable string representation. + """ + return f"member: {self.membership_user} in group: {self.uuid}" From 95bf99c9fcc311484569ab8acd45c4ab380ed437 Mon Sep 17 00:00:00 2001 From: IrfanUddinAhmad Date: Tue, 5 Mar 2024 12:55:22 +0500 Subject: [PATCH 147/164] feat: autocomplete for enterprise customer field in EnterpriseCustomerCatalogAdmin --- CHANGELOG.rst | 4 ++++ enterprise/__init__.py | 2 +- enterprise/admin/__init__.py | 2 ++ 3 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index bbcc9ed91a..364d97ac15 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -15,6 +15,10 @@ Change Log Unreleased ---------- +[4.13.5] +--------- +* feat: added autocomplete for enterprise customer in EnterpriseCustomerCatalogAdmin + [4.13.4] --------- * feat: admin pages for enterprise groups and enterprise group memberships diff --git a/enterprise/__init__.py b/enterprise/__init__.py index 1f625915c8..8de27c35c6 100644 --- a/enterprise/__init__.py +++ b/enterprise/__init__.py @@ -2,4 +2,4 @@ Your project description goes here. """ -__version__ = "4.13.4" +__version__ = "4.13.5" diff --git a/enterprise/admin/__init__.py b/enterprise/admin/__init__.py index 019dce5851..b14ecb0ac1 100644 --- a/enterprise/admin/__init__.py +++ b/enterprise/admin/__init__.py @@ -887,6 +887,8 @@ class Media: 'enterprise_customer__uuid', ) + autocomplete_fields = ['enterprise_customer'] + fields = ( 'title', 'enterprise_customer', From bc48ee2b15c470890e0457ba6f801262a8d7abbf Mon Sep 17 00:00:00 2001 From: MueezKhan246 <93375917+MueezKhan246@users.noreply.github.com> Date: Wed, 13 Mar 2024 16:58:38 +0500 Subject: [PATCH 148/164] Integrated channel api logs loading fix (#2035) * fix: adding get_queryset for fix of integrated channel api logs loading --- CHANGELOG.rst | 4 ++++ enterprise/__init__.py | 2 +- .../integrated_channel/admin/__init__.py | 12 +++++------- 3 files changed, 10 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 3555723138..0df3bc3cc0 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -15,6 +15,10 @@ Change Log Unreleased ---------- +[4.13.7] +--------- +* fix: adding get_queryset for fix of integrated channel api logs loading + [4.13.6] --------- * feat: disable failing transmissions for 24hrs diff --git a/enterprise/__init__.py b/enterprise/__init__.py index e8d7931c2a..f85bffb4cc 100644 --- a/enterprise/__init__.py +++ b/enterprise/__init__.py @@ -2,4 +2,4 @@ Your project description goes here. """ -__version__ = "4.13.6" +__version__ = "4.13.7" diff --git a/integrated_channels/integrated_channel/admin/__init__.py b/integrated_channels/integrated_channel/admin/__init__.py index c3ee07a92c..c262ce2994 100644 --- a/integrated_channels/integrated_channel/admin/__init__.py +++ b/integrated_channels/integrated_channel/admin/__init__.py @@ -99,16 +99,10 @@ class IntegratedChannelAPIRequestLogAdmin(admin.ModelAdmin): list_display = [ "endpoint", - "enterprise_customer", + "enterprise_customer_id", "time_taken", "status_code", ] - list_filter = [ - "status_code", - "enterprise_customer", - "endpoint", - "time_taken", - ] search_fields = [ "status_code", "enterprise_customer", @@ -130,5 +124,9 @@ class IntegratedChannelAPIRequestLogAdmin(admin.ModelAdmin): list_per_page = 20 + def get_queryset(self, request): + queryset = super().get_queryset(request) + return queryset.select_related('enterprise_customer') + class Meta: model = IntegratedChannelAPIRequestLogs From f50dad2349d8bf79a4a2d96b10396bf8ae80d32e Mon Sep 17 00:00:00 2001 From: Alexander J Sheehan Date: Tue, 12 Mar 2024 16:10:43 +0000 Subject: [PATCH 149/164] feat: adding an activated_at value to group membership records --- CHANGELOG.rst | 4 ++++ enterprise/__init__.py | 2 +- enterprise/api/v1/views/enterprise_group.py | 2 ++ .../migrations/0203_auto_20240312_1527.py | 23 +++++++++++++++++++ enterprise/models.py | 9 ++++++++ tests/test_enterprise/api/test_views.py | 22 ++++++++++++++++++ tests/test_enterprise/test_signals.py | 9 +++++--- 7 files changed, 67 insertions(+), 4 deletions(-) create mode 100644 enterprise/migrations/0203_auto_20240312_1527.py diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 0df3bc3cc0..d9d5893071 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -15,6 +15,10 @@ Change Log Unreleased ---------- +[4.13.8] +--------- +* feat: adding an activated_at value to group membership records + [4.13.7] --------- * fix: adding get_queryset for fix of integrated channel api logs loading diff --git a/enterprise/__init__.py b/enterprise/__init__.py index f85bffb4cc..a0194205ae 100644 --- a/enterprise/__init__.py +++ b/enterprise/__init__.py @@ -2,4 +2,4 @@ Your project description goes here. """ -__version__ = "4.13.7" +__version__ = "4.13.8" diff --git a/enterprise/api/v1/views/enterprise_group.py b/enterprise/api/v1/views/enterprise_group.py index 8c324b1225..951aace252 100644 --- a/enterprise/api/v1/views/enterprise_group.py +++ b/enterprise/api/v1/views/enterprise_group.py @@ -17,6 +17,7 @@ from enterprise.api.v1 import serializers from enterprise.api.v1.views.base_views import EnterpriseReadWriteModelViewSet from enterprise.logging import getEnterpriseLogger +from enterprise.utils import localized_utcnow LOGGER = getEnterpriseLogger(__name__) @@ -195,6 +196,7 @@ def assign_learners(self, request, group_uuid): # Extend the list of memberships that need to be created associated with existing Users ent_customer_users = [ models.EnterpriseGroupMembership( + activated_at=localized_utcnow(), enterprise_customer_user=ecu, group=group ) diff --git a/enterprise/migrations/0203_auto_20240312_1527.py b/enterprise/migrations/0203_auto_20240312_1527.py new file mode 100644 index 0000000000..29635c9c74 --- /dev/null +++ b/enterprise/migrations/0203_auto_20240312_1527.py @@ -0,0 +1,23 @@ +# Generated by Django 3.2.23 on 2024-03-12 15:27 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('enterprise', '0202_enterprisegroup_applies_to_all_contexts_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='enterprisegroupmembership', + name='activated_at', + field=models.DateTimeField(blank=True, default=None, help_text='The moment at which the membership record is written with an Enterprise Customer User record.', null=True), + ), + migrations.AddField( + model_name='historicalenterprisegroupmembership', + name='activated_at', + field=models.DateTimeField(blank=True, default=None, help_text='The moment at which the membership record is written with an Enterprise Customer User record.', null=True), + ), + ] diff --git a/enterprise/models.py b/enterprise/models.py index c47b9caf05..9dbb8d5b3b 100644 --- a/enterprise/models.py +++ b/enterprise/models.py @@ -1484,6 +1484,7 @@ def fulfill_pending_group_memberships(self, enterprise_customer_user): enterprise_customer_user: a EnterpriseCustomerUser instance """ self.memberships.update( + activated_at=localized_utcnow(), pending_enterprise_customer_user=None, enterprise_customer_user=enterprise_customer_user ) @@ -4308,6 +4309,14 @@ class EnterpriseGroupMembership(TimeStampedModel, SoftDeletableModel): related_name='memberships', on_delete=models.deletion.CASCADE, ) + activated_at = models.DateTimeField( + default=None, + blank=True, + null=True, + help_text=_( + "The moment at which the membership record is written with an Enterprise Customer User record." + ), + ) history = HistoricalRecords() class Meta: diff --git a/tests/test_enterprise/api/test_views.py b/tests/test_enterprise/api/test_views.py index c08a05d97c..5ae7d75132 100644 --- a/tests/test_enterprise/api/test_views.py +++ b/tests/test_enterprise/api/test_views.py @@ -7690,6 +7690,28 @@ def test_group_applies_to_all_contexts_learner_list(self): for result in results: assert (result.get('pending_learner_id') == pending_user.id) or (result.get('learner_id') == new_user.id) + def test_group_assign_realized_learner_adds_activated_at(self): + """ + Test that newly created membership records associated with an existing user have an activated at value written + but records associated with pending memberships do not. + """ + url = settings.TEST_SERVER + reverse( + 'enterprise-group-assign-learners', + kwargs={'group_uuid': self.group_2.uuid}, + ) + request_data = {'learner_emails': f"{UserFactory().email},email@example.com"} + self.client.post(url, data=request_data) + membership = EnterpriseGroupMembership.objects.filter( + group=self.group_2, + pending_enterprise_customer_user__isnull=True + ).first() + assert membership.activated_at + pending_membership = EnterpriseGroupMembership.objects.filter( + group=self.group_2, + enterprise_customer_user__isnull=True + ).first() + assert not pending_membership.activated_at + @mark.django_db class TestEnterpriseCustomerSsoConfigurationViewSet(APITest): diff --git a/tests/test_enterprise/test_signals.py b/tests/test_enterprise/test_signals.py index e93f14e758..ef5e6c47ff 100644 --- a/tests/test_enterprise/test_signals.py +++ b/tests/test_enterprise/test_signals.py @@ -256,10 +256,11 @@ def test_handle_user_post_save_fulfills_pending_group_memberships(self): email = "jackie.chan@hollywood.com" user = UserFactory(id=1, email=email) pending_user = PendingEnterpriseCustomerUserFactory(user_email=email) - EnterpriseGroupMembershipFactory( + new_membership = EnterpriseGroupMembershipFactory( pending_enterprise_customer_user=pending_user, enterprise_customer_user=None ) + assert not new_membership.activated_at parameters = {"instance": user, "created": False} handle_user_post_save(mock.Mock(), **parameters) # Should delete pending link @@ -267,8 +268,10 @@ def test_handle_user_post_save_fulfills_pending_group_memberships(self): assert len(EnterpriseGroupMembership.objects.all()) == 1 new_enterprise_user = EnterpriseCustomerUser.objects.get(user_id=user.id) - assert EnterpriseGroupMembership.objects.first().pending_enterprise_customer_user is None - assert EnterpriseGroupMembership.objects.first().enterprise_customer_user == new_enterprise_user + membership = EnterpriseGroupMembership.objects.first() + assert membership.pending_enterprise_customer_user is None + assert membership.enterprise_customer_user == new_enterprise_user + assert membership.activated_at @mark.django_db From ea84acab7564ad5eb57330bf6812f29f863f2e35 Mon Sep 17 00:00:00 2001 From: hamzawaleed01 Date: Thu, 14 Mar 2024 17:32:22 +0500 Subject: [PATCH 150/164] feat: add errored_at filter --- .../integrated_channel/exporters/content_metadata.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/integrated_channels/integrated_channel/exporters/content_metadata.py b/integrated_channels/integrated_channel/exporters/content_metadata.py index fe96f6c4ac..3943b68b77 100644 --- a/integrated_channels/integrated_channel/exporters/content_metadata.py +++ b/integrated_channels/integrated_channel/exporters/content_metadata.py @@ -137,6 +137,8 @@ def _get_catalog_content_keys(self, enterprise_customer_catalog=None): # filter only records who have failed to delete or update, meaning we know they exist on the customer's # instance and require some kind of action failed_query.add(Q(remote_deleted_at__isnull=False) | Q(remote_updated_at__isnull=False), Q.AND) + failed_query.add(Q(remote_errored_at__lt=self.LAST_24_HRS) | Q( + remote_errored_at__isnull=True), Q.AND) # enterprise_customer_catalog filter is optional if enterprise_customer_catalog is not None: failed_query.add(Q(enterprise_customer_catalog_uuid=enterprise_customer_catalog.uuid), Q.AND) @@ -489,7 +491,7 @@ def export(self, **kwargs): f'Retrieved {len(content_keys)} content keys for past transmissions to customer: ' f'{self.enterprise_customer.uuid} under catalog: {enterprise_customer_catalog.uuid}.' ) - + # From the saved content records, use the enterprise catalog API to determine what needs sending items_to_create, items_to_update, items_to_delete = self._get_catalog_diff( enterprise_customer_catalog, From a47a43734a70b3d09dce3a4c08b7e96d5b9c1b72 Mon Sep 17 00:00:00 2001 From: hamzawaleed01 Date: Mon, 18 Mar 2024 18:12:27 +0500 Subject: [PATCH 151/164] chore: debug failing transmissions not reattempting after 24hrs --- CHANGELOG.rst | 4 ++++ enterprise/__init__.py | 2 +- .../integrated_channel/exporters/content_metadata.py | 2 -- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index f4f480c7f2..c7209b4284 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -15,6 +15,10 @@ Change Log Unreleased ---------- +[4.13.10] +--------- +* fix: remove filter to debug failing transmissions + [4.13.9] --------- * fix: add missing filter to disable failing transmissions for 24hrs diff --git a/enterprise/__init__.py b/enterprise/__init__.py index 166f62c3fd..ae11b10a58 100644 --- a/enterprise/__init__.py +++ b/enterprise/__init__.py @@ -2,4 +2,4 @@ Your project description goes here. """ -__version__ = "4.13.9" +__version__ = "4.13.10" diff --git a/integrated_channels/integrated_channel/exporters/content_metadata.py b/integrated_channels/integrated_channel/exporters/content_metadata.py index 6c70cf0723..fe96f6c4ac 100644 --- a/integrated_channels/integrated_channel/exporters/content_metadata.py +++ b/integrated_channels/integrated_channel/exporters/content_metadata.py @@ -137,8 +137,6 @@ def _get_catalog_content_keys(self, enterprise_customer_catalog=None): # filter only records who have failed to delete or update, meaning we know they exist on the customer's # instance and require some kind of action failed_query.add(Q(remote_deleted_at__isnull=False) | Q(remote_updated_at__isnull=False), Q.AND) - failed_query.add(Q(remote_errored_at__lt=self.LAST_24_HRS) | Q( - remote_errored_at__isnull=True), Q.AND) # enterprise_customer_catalog filter is optional if enterprise_customer_catalog is not None: failed_query.add(Q(enterprise_customer_catalog_uuid=enterprise_customer_catalog.uuid), Q.AND) From 3f5667ac557035394a8da38adc9e8b5cb640a9ee Mon Sep 17 00:00:00 2001 From: Troy Sankey Date: Fri, 15 Mar 2024 09:45:59 -0700 Subject: [PATCH 152/164] feat: pass force_enrollment when bulk enrolling learners A `force_enrollment` boolean flag has been added to the "enrollment info" dict fed into the bulk enrollment endpoint. This enables consumers of the enterprise bulk enrollment endpoint to force specific enrollments even after the enrollment deadline has passed for the course. ENT-8525 --- CHANGELOG.rst | 4 + enterprise/__init__.py | 2 +- .../api/v1/views/enterprise_customer.py | 12 +- enterprise/utils.py | 7 +- tests/test_enterprise/test_utils.py | 114 ++++++++++++++++++ 5 files changed, 134 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index c7209b4284..c53f65a767 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -15,6 +15,10 @@ Change Log Unreleased ---------- +[4.13.11] +--------- +* feat: pass force_enrollment when bulk enrolling learners + [4.13.10] --------- * fix: remove filter to debug failing transmissions diff --git a/enterprise/__init__.py b/enterprise/__init__.py index ae11b10a58..2d9498e47f 100644 --- a/enterprise/__init__.py +++ b/enterprise/__init__.py @@ -2,4 +2,4 @@ Your project description goes here. """ -__version__ = "4.13.10" +__version__ = "4.13.11" diff --git a/enterprise/api/v1/views/enterprise_customer.py b/enterprise/api/v1/views/enterprise_customer.py index 1685304d17..3046b30660 100644 --- a/enterprise/api/v1/views/enterprise_customer.py +++ b/enterprise/api/v1/views/enterprise_customer.py @@ -171,9 +171,15 @@ def enroll_learners_in_courses(self, request, pk): Parameters: enrollments_info (list of dicts): an array of dictionaries, each containing the necessary information to create an enrollment based on a subsidy for a user in a specified course. Each dictionary must contain - a user email (or user_id), a course run key, and either a UUID of the license that the learner is using - to enroll with or a transaction ID related to Executive Education the enrollment. `licenses_info` is - also accepted as a body param name. + the following keys: + + * 'user_id' OR 'email': Either unique identifier describing the user to enroll. + * 'course_run_key': The course to enroll into. + * 'license_uuid' OR 'transaction_id': ID of either accepted form of subsidy. `license_uuid` refers to + subscription licenses, and `transaction_id` refers to Learner Credit transactions. + * 'force_enrollment' (bool, optional): Enroll even if enrollment deadline is expired (default False). + + `licenses_info` is also accepted as a body param name. Example:: diff --git a/enterprise/utils.py b/enterprise/utils.py index 631eeefd48..f0f47d38b5 100644 --- a/enterprise/utils.py +++ b/enterprise/utils.py @@ -1807,6 +1807,7 @@ def customer_admin_enroll_user_with_status( enrollment_source=None, license_uuid=None, transaction_id=None, + force_enrollment=False, ): """ For use with bulk enrollment, or any use case of admin enrolling a user @@ -1848,6 +1849,7 @@ def customer_admin_enroll_user_with_status( course_mode, is_active=True, enterprise_uuid=enterprise_customer.uuid, + force_enrollment=force_enrollment, ) succeeded = True LOGGER.info("Successfully enrolled user %s in course %s", user.id, course_id) @@ -1987,6 +1989,7 @@ def enroll_subsidy_users_in_courses(enterprise_customer, subsidy_users_info, dis * 'course_run_key': The course to enroll into. * 'course_mode': The course mode. * 'license_uuid' OR 'transaction_id': ID of either accepted form of subsidy. + * 'force_enrollment' (bool, optional): Enroll user even enrollment deadline is expired (default False). Example:: @@ -2037,6 +2040,7 @@ def enroll_subsidy_users_in_courses(enterprise_customer, subsidy_users_info, dis license_uuid = subsidy_user_info.get('license_uuid') transaction_id = subsidy_user_info.get('transaction_id') activation_link = subsidy_user_info.get('activation_link') + force_enrollment = subsidy_user_info.get('force_enrollment', False) if user_id and user_email: user = User.objects.filter(id=subsidy_user_info['user_id']).first() @@ -2066,7 +2070,8 @@ def enroll_subsidy_users_in_courses(enterprise_customer, subsidy_users_info, dis course_run_key, enrollment_source, license_uuid, - transaction_id + transaction_id, + force_enrollment=force_enrollment, ) if succeeded: success_dict = { diff --git a/tests/test_enterprise/test_utils.py b/tests/test_enterprise/test_utils.py index 9dc3cac117..fcc2fa793f 100644 --- a/tests/test_enterprise/test_utils.py +++ b/tests/test_enterprise/test_utils.py @@ -4,6 +4,7 @@ import unittest from datetime import timedelta from unittest import mock +from unittest.mock import call from urllib.parse import quote, urlencode import ddt @@ -325,6 +326,119 @@ def test_enroll_subsidy_users_in_courses_with_user_id_succeeds( ) self.assertEqual(len(EnterpriseCourseEnrollment.objects.all()), 2) + @mock.patch('enterprise.utils.lms_update_or_create_enrollment') + def test_enroll_subsidy_users_in_courses_with_force_enrollment( + self, + mock_update_or_create_enrollment, + ): + """ + """ + self.create_user() + another_user_1 = factories.UserFactory(is_active=True) + another_user_2 = factories.UserFactory(is_active=True) + ent_customer = factories.EnterpriseCustomerFactory( + uuid=FAKE_UUIDS[0], + name="test_enterprise" + ) + licensed_users_info = [ + { + # Should succeed with force_enrollment passed as False under the hood. + 'user_id': self.user.id, + 'course_run_key': 'course-key-1', + 'course_mode': 'verified', + 'license_uuid': '5b77bdbade7b4fcb838f8111b68e18ae', + }, + { + # Should also succeed with force_enrollment passed as False. + 'user_id': another_user_1.id, + 'course_run_key': 'course-key-2', + 'course_mode': 'verified', + 'license_uuid': '5b77bdbade7b4fcb838f8111b68e18ae', + 'force_enrollment': False, + }, + { + # Should succeed with force_enrollment passed as True. + 'user_id': another_user_2.id, + 'course_run_key': 'course-key-3', + 'course_mode': 'verified', + 'license_uuid': '5b77bdbade7b4fcb838f8111b68e18ae', + 'force_enrollment': True, + }, + ] + + mock_update_or_create_enrollment.return_value = True + + result = enroll_subsidy_users_in_courses(ent_customer, licensed_users_info) + self.assertEqual( + { + 'pending': [], + 'successes': [ + { + 'user_id': self.user.id, + 'email': self.user.email, + 'course_run_key': 'course-key-1', + 'user': self.user, + 'created': True, + 'activation_link': None, + 'enterprise_fulfillment_source_uuid': EnterpriseCourseEnrollment.objects.filter( + enterprise_customer_user__user_id=self.user.id + ).first().licensedenterprisecourseenrollment_enrollment_fulfillment.uuid, + }, + { + 'user_id': another_user_1.id, + 'email': another_user_1.email, + 'course_run_key': 'course-key-2', + 'user': another_user_1, + 'created': True, + 'activation_link': None, + 'enterprise_fulfillment_source_uuid': EnterpriseCourseEnrollment.objects.filter( + enterprise_customer_user__user_id=another_user_1.id + ).first().licensedenterprisecourseenrollment_enrollment_fulfillment.uuid, + }, + { + 'user_id': another_user_2.id, + 'email': another_user_2.email, + 'course_run_key': 'course-key-3', + 'user': another_user_2, + 'created': True, + 'activation_link': None, + 'enterprise_fulfillment_source_uuid': EnterpriseCourseEnrollment.objects.filter( + enterprise_customer_user__user_id=another_user_2.id + ).first().licensedenterprisecourseenrollment_enrollment_fulfillment.uuid, + }, + ], + 'failures': [], + }, + result + ) + self.assertEqual(len(EnterpriseCourseEnrollment.objects.all()), 3) + assert mock_update_or_create_enrollment.mock_calls == [ + call( + self.user.username, + 'course-key-1', + 'verified', + is_active=True, + enterprise_uuid=ent_customer.uuid, + force_enrollment=False, + ), + call( + another_user_1.username, + 'course-key-2', + 'verified', + is_active=True, + enterprise_uuid=ent_customer.uuid, + force_enrollment=False, + ), + call( + another_user_2.username, + 'course-key-3', + 'verified', + is_active=True, + enterprise_uuid=ent_customer.uuid, + force_enrollment=True, + ), + ] + @mock.patch('enterprise.utils.lms_update_or_create_enrollment') def test_enroll_subsidy_users_in_courses_user_identifier_failures( self, From 5dc934ad40f64aa14573fbab9dbe20143535c3b0 Mon Sep 17 00:00:00 2001 From: Alexander J Sheehan Date: Wed, 27 Mar 2024 16:27:19 +0000 Subject: [PATCH 153/164] feat: adding additional info to the enterprise group membership serializer --- CHANGELOG.rst | 4 ++ enterprise/__init__.py | 2 +- enterprise/api/v1/serializers.py | 41 +++++++++++++++- enterprise/models.py | 9 ++++ tests/test_enterprise/api/test_views.py | 65 ++++++++++++++++++++++++- 5 files changed, 117 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index c53f65a767..993c325a02 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -15,6 +15,10 @@ Change Log Unreleased ---------- +[4.13.12] +--------- +* feat: adding additional info to the enterprise group membership serializer + [4.13.11] --------- * feat: pass force_enrollment when bulk enrolling learners diff --git a/enterprise/__init__.py b/enterprise/__init__.py index 2d9498e47f..e5c45313d8 100644 --- a/enterprise/__init__.py +++ b/enterprise/__init__.py @@ -2,4 +2,4 @@ Your project description goes here. """ -__version__ = "4.13.11" +__version__ = "4.13.12" diff --git a/enterprise/api/v1/serializers.py b/enterprise/api/v1/serializers.py index 5bdab56b1f..67e00d43f2 100644 --- a/enterprise/api/v1/serializers.py +++ b/enterprise/api/v1/serializers.py @@ -600,9 +600,48 @@ class EnterpriseGroupMembershipSerializer(serializers.ModelSerializer): pending_learner_id = serializers.IntegerField(source='pending_enterprise_customer_user.id', allow_null=True) enterprise_group_membership_uuid = serializers.UUIDField(source='uuid', allow_null=True, read_only=True) + member_details = serializers.SerializerMethodField() + recent_action = serializers.SerializerMethodField() + member_status = serializers.SerializerMethodField() + class Meta: model = models.EnterpriseGroupMembership - fields = ('learner_id', 'pending_learner_id', 'enterprise_group_membership_uuid') + fields = ( + 'learner_id', + 'pending_learner_id', + 'enterprise_group_membership_uuid', + 'member_details', + 'recent_action', + 'member_status', + ) + + def get_member_details(self, obj): + """ + Return either the member's name and email if it's the case that the member is realized, otherwise just email + """ + if user := obj.enterprise_customer_user: + return {"user_email": user.user_email, "user_name": user.name} + return {"user_email": obj.pending_enterprise_customer_user.user_email} + + def get_recent_action(self, obj): + """ + Return the timestamp and name of the most recent action associated with the membership. + """ + if obj.is_removed: + return f"Removed: {obj.modified.strftime('%B %d, %Y')}" + if obj.enterprise_customer_user and obj.activated_at: + return f"Accepted: {obj.activated_at.strftime('%B %d, %Y')}" + return f"Invited: {obj.created.strftime('%B %d, %Y')}" + + def get_member_status(self, obj): + """ + Return the status related to the membership. + """ + if obj.is_removed: + return "removed" + if obj.enterprise_customer_user: + return "accepted" + return "pending" class EnterpriseCustomerUserReadOnlySerializer(serializers.ModelSerializer): diff --git a/enterprise/models.py b/enterprise/models.py index 9dbb8d5b3b..8f85aec7c7 100644 --- a/enterprise/models.py +++ b/enterprise/models.py @@ -1138,6 +1138,15 @@ def username(self): return self.user.username return None + @property + def name(self): + """ + Return linked user's name. + """ + if self.user is not None: + return f"{self.user.first_name} {self.user.last_name}" + return None + @property def data_sharing_consent_records(self): """ diff --git a/tests/test_enterprise/api/test_views.py b/tests/test_enterprise/api/test_views.py index 5ae7d75132..84f829cf18 100644 --- a/tests/test_enterprise/api/test_views.py +++ b/tests/test_enterprise/api/test_views.py @@ -7317,6 +7317,7 @@ def setUp(self): group=self.group_1, pending_enterprise_customer_user=None, enterprise_customer_user__enterprise_customer=self.enterprise_customer, + activated_at=datetime.now() )) def test_group_permissions(self): @@ -7353,6 +7354,52 @@ def test_successful_retrieve_group(self): response = self.client.get(url) assert response.json().get('uuid') == str(self.group_1.uuid) + def test_list_learner_pending_learner_data(self): + """ + Test the response data of the list learners in group endpoint when the membership is pending + """ + group = EnterpriseGroupFactory(enterprise_customer=self.enterprise_customer) + url = settings.TEST_SERVER + reverse( + 'enterprise-group-learners', + kwargs={'group_uuid': group.uuid}, + ) + pending_user = PendingEnterpriseCustomerUserFactory() + EnterpriseGroupMembershipFactory( + group=group, + pending_enterprise_customer_user=pending_user, + enterprise_customer_user=None, + ) + response = self.client.get(url) + assert response.json().get('results')[0].get('member_details') == {'user_email': pending_user.user_email} + assert response.json().get('results')[0].get( + 'recent_action' + ) == f'Invited: {datetime.now().strftime("%B %d, %Y")}' + + def test_list_learner_statuses(self): + """ + Test the response data of the list learners in group endpoint when the membership is pending + """ + group = EnterpriseGroupFactory(enterprise_customer=self.enterprise_customer) + url = settings.TEST_SERVER + reverse( + 'enterprise-group-learners', + kwargs={'group_uuid': group.uuid}, + ) + EnterpriseGroupMembershipFactory( + group=group, + pending_enterprise_customer_user=PendingEnterpriseCustomerUserFactory(), + enterprise_customer_user=None, + ) + EnterpriseGroupMembershipFactory( + group=group, + pending_enterprise_customer_user=None, + enterprise_customer_user__enterprise_customer=self.enterprise_customer, + activated_at=datetime.now() + ) + response = self.client.get(url) + assert response.json().get('count') == 2 + statuses = [result.get('member_status') for result in response.json().get('results')] + assert statuses.sort() == ['accepted', 'pending'].sort() + def test_successful_list_learners(self): """ Test a successful GET request to the list endpoint. @@ -7364,11 +7411,18 @@ def test_successful_list_learners(self): ) results_list = [] for i in reversed(range(1, 11)): + member_user = self.enterprise_group_memberships[i].enterprise_customer_user results_list.append( { - 'learner_id': self.enterprise_group_memberships[i].enterprise_customer_user.id, + 'learner_id': member_user.id, 'pending_learner_id': None, 'enterprise_group_membership_uuid': str(self.enterprise_group_memberships[i].uuid), + 'member_details': { + 'user_name': member_user.name, + 'user_email': member_user.user_email + }, + 'recent_action': f'Accepted: {datetime.now().strftime("%B %d, %Y")}', + 'member_status': 'accepted', }, ) expected_response = { @@ -7385,15 +7439,22 @@ def test_successful_list_learners(self): kwargs={'group_uuid': self.group_1.uuid}, ) + '?page=2' page_2_response = self.client.get(url_page_2) + user = self.enterprise_group_memberships[0].enterprise_customer_user expected_response_page_2 = { 'count': 11, 'next': None, 'previous': f'http://testserver/enterprise/api/v1/enterprise-group/{self.group_1.uuid}/learners', 'results': [ { - 'learner_id': self.enterprise_group_memberships[0].enterprise_customer_user.id, + 'learner_id': user.id, 'pending_learner_id': None, 'enterprise_group_membership_uuid': str(self.enterprise_group_memberships[0].uuid), + 'member_details': { + 'user_name': user.name, + 'user_email': user.user_email + }, + 'recent_action': f'Accepted: {datetime.now().strftime("%B %d, %Y")}', + 'member_status': 'accepted', } ], } From 751ac18516d71e08b0760777575ae965d9ada445 Mon Sep 17 00:00:00 2001 From: Kira Miller <31229189+kiram15@users.noreply.github.com> Date: Thu, 28 Mar 2024 12:51:12 -0600 Subject: [PATCH 154/164] fix: adding groups variable needed on frontend (#2056) * fix: adding groups variable needed on frontend * fix: version bump * fix: Update CHANGELOG.rst --- CHANGELOG.rst | 5 +++++ enterprise/__init__.py | 2 +- enterprise/api/v1/serializers.py | 2 +- 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 993c325a02..8256e044c4 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -15,6 +15,11 @@ Change Log Unreleased ---------- +[4.13.13] +--------- +* fix: adding additional info to the enterprise group serializer + + [4.13.12] --------- * feat: adding additional info to the enterprise group membership serializer diff --git a/enterprise/__init__.py b/enterprise/__init__.py index e5c45313d8..4535e3e13e 100644 --- a/enterprise/__init__.py +++ b/enterprise/__init__.py @@ -2,4 +2,4 @@ Your project description goes here. """ -__version__ = "4.13.12" +__version__ = "4.13.13" diff --git a/enterprise/api/v1/serializers.py b/enterprise/api/v1/serializers.py index 67e00d43f2..79fd32907f 100644 --- a/enterprise/api/v1/serializers.py +++ b/enterprise/api/v1/serializers.py @@ -589,7 +589,7 @@ class EnterpriseGroupSerializer(serializers.ModelSerializer): """ class Meta: model = models.EnterpriseGroup - fields = ('enterprise_customer', 'name', 'uuid') + fields = ('enterprise_customer', 'name', 'uuid', 'applies_to_all_contexts') class EnterpriseGroupMembershipSerializer(serializers.ModelSerializer): From 742b782e995696c325d463b773e9b784636f8d56 Mon Sep 17 00:00:00 2001 From: hamzawaleed01 Date: Fri, 29 Mar 2024 16:22:41 +0500 Subject: [PATCH 155/164] feat: handle 409 case (mark as active instead of recreating course) --- CHANGELOG.rst | 4 ++++ enterprise/__init__.py | 2 +- integrated_channels/degreed2/client.py | 17 +++++++++++++---- .../test_degreed2/test_client.py | 6 ++++++ 4 files changed, 24 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 8256e044c4..4d7538af39 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -15,6 +15,10 @@ Change Log Unreleased ---------- +[4.13.14] +--------- +* feat: handle Degreed 409 case (mark as active instead of recreating course) + [4.13.13] --------- * fix: adding additional info to the enterprise group serializer diff --git a/enterprise/__init__.py b/enterprise/__init__.py index 4535e3e13e..6b75a7c670 100644 --- a/enterprise/__init__.py +++ b/enterprise/__init__.py @@ -2,4 +2,4 @@ Your project description goes here. """ -__version__ = "4.13.13" +__version__ = "4.13.14" diff --git a/integrated_channels/degreed2/client.py b/integrated_channels/degreed2/client.py index 7929f12de9..b495f5e447 100644 --- a/integrated_channels/degreed2/client.py +++ b/integrated_channels/degreed2/client.py @@ -280,15 +280,24 @@ def create_content_metadata(self, serialized_data): external_id = a_course.get('external-id') status_code, response_body = self._sync_content_metadata(a_course, 'post', self.get_courses_url()) if status_code == 409: - # course already exists, don't raise failure, but log and move on + # course already exists, don't raise failure, but try to mark it as active on Degreed side + # if succeeds, we'll treat this as a success LOGGER.warning( self.make_log_msg( external_id, - f'Course with integration_id = {external_id} already exists, ' + f'Course with integration_id = {external_id} already exists, marking it as active' ) ) - # content already exists, we'll treat this as a success - status_code = 200 + try: + channel_metadata_item['courses'][0]['obsolete'] = False + return self.update_content_metadata(json.dumps(channel_metadata_item).encode('utf-8')) + except requests.exceptions.RequestException as exc: + raise ClientError( + 'Degreed2APIClient request failed while handling 409: {error} {message}'.format( + error=exc.__class__.__name__, + message=str(exc) + ) + ) from exc elif status_code >= 400: raise ClientError( f'Degreed2APIClient create_content_metadata failed with status {status_code}: {response_body}', diff --git a/tests/test_integrated_channels/test_degreed2/test_client.py b/tests/test_integrated_channels/test_degreed2/test_client.py index bbc3c409f2..163175804e 100644 --- a/tests/test_integrated_channels/test_degreed2/test_client.py +++ b/tests/test_integrated_channels/test_degreed2/test_client.py @@ -440,6 +440,12 @@ def test_create_content_metadata_course_exists(self): json='{}', status=200 ) + responses.add( + responses.PATCH, + f'{enterprise_config.degreed_base_url}api/v2/content/courses/{degreed_course_id}', + json='{}', + status=200 + ) status_code, _ = degreed_api_client.create_content_metadata(create_course_payload()) # we treat as "course exists" as a success assert status_code == 200 From fbd64ae7f14e0d283fa7c1442b7502ded2bfc893 Mon Sep 17 00:00:00 2001 From: Saleem Latif Date: Mon, 8 Apr 2024 12:42:02 +0500 Subject: [PATCH 156/164] feat: Added the ability for enterprise customers to enable One Academy for its learners. --- CHANGELOG.rst | 4 ++++ enterprise/__init__.py | 2 +- enterprise/admin/__init__.py | 2 +- enterprise/api/v1/serializers.py | 1 + .../migrations/0204_auto_20240408_0723.py | 23 +++++++++++++++++++ enterprise/models.py | 7 ++++++ tests/test_enterprise/api/test_views.py | 5 ++++ tests/test_utilities.py | 1 + 8 files changed, 43 insertions(+), 2 deletions(-) create mode 100644 enterprise/migrations/0204_auto_20240408_0723.py diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 4d7538af39..99fea06f6b 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -15,6 +15,10 @@ Change Log Unreleased ---------- +[4.14.0] +--------- +* Added the ability for enterprise customers to enable One Academy for its learners. + [4.13.14] --------- * feat: handle Degreed 409 case (mark as active instead of recreating course) diff --git a/enterprise/__init__.py b/enterprise/__init__.py index 6b75a7c670..9217322798 100644 --- a/enterprise/__init__.py +++ b/enterprise/__init__.py @@ -2,4 +2,4 @@ Your project description goes here. """ -__version__ = "4.13.14" +__version__ = "4.14.0" diff --git a/enterprise/admin/__init__.py b/enterprise/admin/__init__.py index b14ecb0ac1..a5b8d3e929 100644 --- a/enterprise/admin/__init__.py +++ b/enterprise/admin/__init__.py @@ -214,7 +214,7 @@ class EnterpriseCustomerAdmin(DjangoObjectActions, SimpleHistoryAdmin): 'enable_executive_education_2U_fulfillment', 'enable_career_engagement_network_on_learner_portal', 'career_engagement_network_message', 'enable_pathways', 'enable_programs', - 'enable_demo_data_for_analytics_and_lpr', 'enable_academies'), + 'enable_demo_data_for_analytics_and_lpr', 'enable_academies', 'enable_one_academy'), 'description': ('The following default settings should be the same for ' 'the majority of enterprise customers, ' 'and are either rarely used, unlikely to be sold, ' diff --git a/enterprise/api/v1/serializers.py b/enterprise/api/v1/serializers.py index 79fd32907f..c95cbbce1a 100644 --- a/enterprise/api/v1/serializers.py +++ b/enterprise/api/v1/serializers.py @@ -223,6 +223,7 @@ class Meta: 'modified', 'enable_universal_link', 'enable_browse_and_request', 'admin_users', 'enable_career_engagement_network_on_learner_portal', 'career_engagement_network_message', 'enable_pathways', 'enable_programs', 'enable_demo_data_for_analytics_and_lpr', 'enable_academies', + 'enable_one_academy', ) identity_providers = EnterpriseCustomerIdentityProviderSerializer(many=True, read_only=True) diff --git a/enterprise/migrations/0204_auto_20240408_0723.py b/enterprise/migrations/0204_auto_20240408_0723.py new file mode 100644 index 0000000000..d33b0af299 --- /dev/null +++ b/enterprise/migrations/0204_auto_20240408_0723.py @@ -0,0 +1,23 @@ +# Generated by Django 3.2.23 on 2024-04-08 07:23 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('enterprise', '0203_auto_20240312_1527'), + ] + + operations = [ + migrations.AddField( + model_name='enterprisecustomer', + name='enable_one_academy', + field=models.BooleanField(default=False, help_text='If checked, search will be replaced with one academy on enterprise learner portal.', verbose_name='Enable One Academy feature'), + ), + migrations.AddField( + model_name='historicalenterprisecustomer', + name='enable_one_academy', + field=models.BooleanField(default=False, help_text='If checked, search will be replaced with one academy on enterprise learner portal.', verbose_name='Enable One Academy feature'), + ), + ] diff --git a/enterprise/models.py b/enterprise/models.py index 8f85aec7c7..4acdd367bf 100644 --- a/enterprise/models.py +++ b/enterprise/models.py @@ -403,6 +403,13 @@ class Meta: "If checked, the learners will be able to see the academies on the learner portal dashboard." ) ) + enable_one_academy = models.BooleanField( + verbose_name="Enable One Academy feature", + default=False, + help_text=_( + "If checked, search will be replaced with one academy on enterprise learner portal." + ) + ) enable_analytics_screen = models.BooleanField( verbose_name="Display analytics page", diff --git a/tests/test_enterprise/api/test_views.py b/tests/test_enterprise/api/test_views.py index 84f829cf18..c008f715ba 100644 --- a/tests/test_enterprise/api/test_views.py +++ b/tests/test_enterprise/api/test_views.py @@ -1225,6 +1225,7 @@ class TestEnterpriseCustomerViewSet(BaseTestEnterpriseAPIViews): 'enable_programs': True, 'enable_demo_data_for_analytics_and_lpr': False, 'enable_academies': False, + 'enable_one_academy': False, }], ), ( @@ -1284,6 +1285,7 @@ class TestEnterpriseCustomerViewSet(BaseTestEnterpriseAPIViews): 'enable_programs': True, 'enable_demo_data_for_analytics_and_lpr': False, 'enable_academies': False, + 'enable_one_academy': False, }, 'enterprise_group': [], 'active': True, 'user_id': 0, 'user': None, @@ -1381,6 +1383,7 @@ class TestEnterpriseCustomerViewSet(BaseTestEnterpriseAPIViews): 'enable_programs': True, 'enable_demo_data_for_analytics_and_lpr': False, 'enable_academies': False, + 'enable_one_academy': False, }], ), ( @@ -1448,6 +1451,7 @@ class TestEnterpriseCustomerViewSet(BaseTestEnterpriseAPIViews): 'enable_programs': True, 'enable_demo_data_for_analytics_and_lpr': False, 'enable_academies': False, + 'enable_one_academy': False, }], ), ( @@ -1708,6 +1712,7 @@ def test_enterprise_customer_with_access_to( 'enable_programs': True, 'enable_demo_data_for_analytics_and_lpr': False, 'enable_academies': False, + 'enable_one_academy': False, } else: mock_empty_200_success_response = { diff --git a/tests/test_utilities.py b/tests/test_utilities.py index 8e7a48d354..1f863c5117 100644 --- a/tests/test_utilities.py +++ b/tests/test_utilities.py @@ -178,6 +178,7 @@ def setUp(self): "enable_programs", "enable_demo_data_for_analytics_and_lpr", "enable_academies", + "enable_one_academy", "groups", ] ), From 08f3b16224a8c249c03598299cec930c4f3c8701 Mon Sep 17 00:00:00 2001 From: muhammad-ammar Date: Mon, 8 Apr 2024 16:20:39 +0500 Subject: [PATCH 157/164] feat: add new languages in enterprise customer admin --- CHANGELOG.rst | 4 ++++ enterprise/__init__.py | 2 +- enterprise/constants.py | 19 +++++++++++++++ .../migrations/0205_auto_20240408_1117.py | 23 +++++++++++++++++++ 4 files changed, 47 insertions(+), 1 deletion(-) create mode 100644 enterprise/migrations/0205_auto_20240408_1117.py diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 99fea06f6b..ab73f25b51 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -15,6 +15,10 @@ Change Log Unreleased ---------- +[4.15.0] +--------- +* feat: Add new languages in enterprise customer admin + [4.14.0] --------- * Added the ability for enterprise customers to enable One Academy for its learners. diff --git a/enterprise/__init__.py b/enterprise/__init__.py index 9217322798..dff3a96b8f 100644 --- a/enterprise/__init__.py +++ b/enterprise/__init__.py @@ -2,4 +2,4 @@ Your project description goes here. """ -__version__ = "4.14.0" +__version__ = "4.15.0" diff --git a/enterprise/constants.py b/enterprise/constants.py index 91aa7b6c87..5efb678957 100644 --- a/enterprise/constants.py +++ b/enterprise/constants.py @@ -186,6 +186,25 @@ def json_serialized_course_modes(): AVAILABLE_LANGUAGES = [ ('en', 'English'), ('es-419', 'Español (Latinoamérica)'), # Spanish (Latin America) + ('ar', 'العربية'), # Arabic + ('zh-cn', '中文 (简体)'), # Chinese (China) + ('fr-ca', 'français (Canada)'), # French (Canada) + ('da', 'dansk'), # Danish + ('de-de', 'Deutsch (Deutschland)'), # German (Germany) + ('el', 'Ελληνικά'), # Greek + ('he', 'עברית'), # Hebrew + ('hi', 'हिन्दी'), # Hindi + ('id', 'Bahasa Indonesia'), # Indonesian + ('it-it', 'Italiano (Italia)'), # Italian (Italy) + ('pt-br', 'Português (Brasil)'), # Portuguese (Brazil) + ('pt-pt', 'Português (Portugal)'), # Portuguese (Portugal) + ('ru', 'Русский'), # Russian + ('es-es', 'Español (España)'), # Spanish (Spain) + ('sw', 'Kiswahili'), # Swahili + ('te', 'తెలుగు'), # Telugu + ('th', 'ไทย'), # Thai + ('tr-tr', 'Türkçe (Türkiye)'), # Turkish (Turkey) + ('uk', 'українська'), # Ukrainian ] LMS_API_DATETIME_FORMAT = '%Y-%m-%dT%H:%M:%SZ' diff --git a/enterprise/migrations/0205_auto_20240408_1117.py b/enterprise/migrations/0205_auto_20240408_1117.py new file mode 100644 index 0000000000..de37870b91 --- /dev/null +++ b/enterprise/migrations/0205_auto_20240408_1117.py @@ -0,0 +1,23 @@ +# Generated by Django 3.2.23 on 2024-04-08 11:17 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('enterprise', '0204_auto_20240408_0723'), + ] + + operations = [ + migrations.AlterField( + model_name='enterprisecustomer', + name='default_language', + field=models.CharField(blank=True, choices=[('en', 'English'), ('es-419', 'Español (Latinoamérica)'), ('ar', 'العربية'), ('zh-cn', '中文 (简体)'), ('fr-ca', 'français (Canada)'), ('da', 'dansk'), ('de-de', 'Deutsch (Deutschland)'), ('el', 'Ελληνικά'), ('he', 'עברית'), ('hi', 'हिन्दी'), ('id', 'Bahasa Indonesia'), ('it-it', 'Italiano (Italia)'), ('pt-br', 'Português (Brasil)'), ('pt-pt', 'Português (Portugal)'), ('ru', 'Русский'), ('es-es', 'Español (España)'), ('sw', 'Kiswahili'), ('te', 'తెలుగు'), ('th', 'ไทย'), ('tr-tr', 'Türkçe (Türkiye)'), ('uk', 'українська')], default=None, help_text='Specifies the default language for learners of the organization.', max_length=25, null=True, verbose_name='Learner default language'), + ), + migrations.AlterField( + model_name='historicalenterprisecustomer', + name='default_language', + field=models.CharField(blank=True, choices=[('en', 'English'), ('es-419', 'Español (Latinoamérica)'), ('ar', 'العربية'), ('zh-cn', '中文 (简体)'), ('fr-ca', 'français (Canada)'), ('da', 'dansk'), ('de-de', 'Deutsch (Deutschland)'), ('el', 'Ελληνικά'), ('he', 'עברית'), ('hi', 'हिन्दी'), ('id', 'Bahasa Indonesia'), ('it-it', 'Italiano (Italia)'), ('pt-br', 'Português (Brasil)'), ('pt-pt', 'Português (Portugal)'), ('ru', 'Русский'), ('es-es', 'Español (España)'), ('sw', 'Kiswahili'), ('te', 'తెలుగు'), ('th', 'ไทย'), ('tr-tr', 'Türkçe (Türkiye)'), ('uk', 'українська')], default=None, help_text='Specifies the default language for learners of the organization.', max_length=25, null=True, verbose_name='Learner default language'), + ), + ] From 724529ca41ad0619d6b43dd65a5d130dcc9c369d Mon Sep 17 00:00:00 2001 From: Alexander J Sheehan Date: Thu, 28 Mar 2024 18:35:21 +0000 Subject: [PATCH 158/164] feat: allowing for sorting and filtering of the enterprise group learner endpoints --- CHANGELOG.rst | 4 + enterprise/__init__.py | 2 +- enterprise/api/v1/serializers.py | 29 ++-- enterprise/api/v1/views/enterprise_group.py | 37 +++- enterprise/constants.py | 9 + .../management/commands/manufacture_data.py | 10 +- .../migrations/0206_auto_20240408_1344.py | 33 ++++ enterprise/models.py | 160 +++++++++++++++--- enterprise/utils.py | 7 + tests/test_enterprise/api/test_views.py | 152 ++++++++++++++++- tests/test_enterprise/test_signals.py | 3 + 11 files changed, 391 insertions(+), 55 deletions(-) create mode 100644 enterprise/migrations/0206_auto_20240408_1344.py diff --git a/CHANGELOG.rst b/CHANGELOG.rst index ab73f25b51..edef324f71 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -15,6 +15,10 @@ Change Log Unreleased ---------- +[4.15.1] +-------- +* feat: allowing for sorting and filtering of the enterprise group learner endpoints + [4.15.0] --------- * feat: Add new languages in enterprise customer admin diff --git a/enterprise/__init__.py b/enterprise/__init__.py index dff3a96b8f..bbd36b5529 100644 --- a/enterprise/__init__.py +++ b/enterprise/__init__.py @@ -2,4 +2,4 @@ Your project description goes here. """ -__version__ = "4.15.0" +__version__ = "4.15.1" diff --git a/enterprise/api/v1/serializers.py b/enterprise/api/v1/serializers.py index c95cbbce1a..a212b2278c 100644 --- a/enterprise/api/v1/serializers.py +++ b/enterprise/api/v1/serializers.py @@ -603,7 +603,7 @@ class EnterpriseGroupMembershipSerializer(serializers.ModelSerializer): member_details = serializers.SerializerMethodField() recent_action = serializers.SerializerMethodField() - member_status = serializers.SerializerMethodField() + status = serializers.CharField(required=False) class Meta: model = models.EnterpriseGroupMembership @@ -613,7 +613,7 @@ class Meta: 'enterprise_group_membership_uuid', 'member_details', 'recent_action', - 'member_status', + 'status', ) def get_member_details(self, obj): @@ -634,16 +634,6 @@ def get_recent_action(self, obj): return f"Accepted: {obj.activated_at.strftime('%B %d, %Y')}" return f"Invited: {obj.created.strftime('%B %d, %Y')}" - def get_member_status(self, obj): - """ - Return the status related to the membership. - """ - if obj.is_removed: - return "removed" - if obj.enterprise_customer_user: - return "accepted" - return "pending" - class EnterpriseCustomerUserReadOnlySerializer(serializers.ModelSerializer): """ @@ -1700,3 +1690,18 @@ class Meta: client_secret = serializers.CharField(read_only=True, default=generate_client_secret()) redirect_uris = serializers.CharField(required=False) updated = serializers.DateTimeField(required=False, read_only=True) + + +class EnterpriseGroupLearnersRequestQuerySerializer(serializers.Serializer): + """ + Serializer for the Enterprise Group Learners endpoint query filter + """ + user_query = serializers.CharField(required=False, max_length=320) + sort_by = serializers.ChoiceField( + choices=[ + ('member_details', 'member_details'), + ('status', 'status'), + ('recent_action', 'recent_action') + ], + required=False, + ) diff --git a/enterprise/api/v1/views/enterprise_group.py b/enterprise/api/v1/views/enterprise_group.py index 951aace252..e867c19ff5 100644 --- a/enterprise/api/v1/views/enterprise_group.py +++ b/enterprise/api/v1/views/enterprise_group.py @@ -12,7 +12,7 @@ from django.db.models import Q from django.http import Http404 -from enterprise import models, rules, utils +from enterprise import constants, models, rules, utils from enterprise.api.utils import get_enterprise_customer_from_enterprise_group_id from enterprise.api.v1 import serializers from enterprise.api.v1.views.base_views import EnterpriseReadWriteModelViewSet @@ -101,6 +101,13 @@ def get_learners(self, *args, **kwargs): Request Arguments: - ``group_uuid`` (URL location, required): The uuid of the group from which learners should be listed. + Optional query params: + - ``q`` (string, optional): Filter the returned members by user email and name with a provided sub-string + - ``sort_by`` (string, optional): Specify how the returned members should be ordered. Supported sorting values + are `memberDetails`, `memberStatus`, and `recentAction`. Ordering can be reversed by supplying a `-` at the + beginning of the sorting value ie `-memberStatus`. + - ``page`` (int, optional): Which page of paginated data to return. + Returns: Paginated list of learners that are associated with the enterprise group uuid:: { @@ -117,11 +124,23 @@ def get_learners(self, *args, **kwargs): } """ + query_params = self.request.query_params.copy() + is_reversed = bool(query_params.get('is_reversed', False)) + + param_serializers = serializers.EnterpriseGroupLearnersRequestQuerySerializer( + data=query_params + ) + + if not param_serializers.is_valid(): + return Response(param_serializers.errors, status=400) + + user_query = param_serializers.validated_data.get('user_query') + sort_by = param_serializers.validated_data.get('sort_by') group_uuid = kwargs.get('group_uuid') try: group_object = self.get_queryset().get(uuid=group_uuid) - members = group_object.get_all_learners() + members = group_object.get_all_learners(user_query, sort_by, desc_order=is_reversed) page = self.paginate_queryset(members) serializer = serializers.EnterpriseGroupMembershipSerializer(page, many=True) response = self.get_paginated_response(serializer.data) @@ -197,6 +216,7 @@ def assign_learners(self, request, group_uuid): ent_customer_users = [ models.EnterpriseGroupMembership( activated_at=localized_utcnow(), + status=constants.GROUP_MEMBERSHIP_ACCEPTED_STATUS, enterprise_customer_user=ecu, group=group ) @@ -275,8 +295,15 @@ def remove_learners(self, request, group_uuid): ) records_deleted += len(records_to_delete) records_to_delete.delete() - data = { - 'records_deleted': records_deleted, - } + + # Woohoo! Records removed! Now to update the soft deleted records + deleted_records = models.EnterpriseGroupMembership.all_objects.filter( + group_q & (ecu_in_q | pecu_in_q), + ) + deleted_records.update( + status=constants.GROUP_MEMBERSHIP_REMOVED_STATUS, + removed_at=localized_utcnow() + ) + data = {'records_deleted': records_deleted} return Response(data, status=200) return Response(data="Error: missing request data: `learner_emails`.", status=400) diff --git a/enterprise/constants.py b/enterprise/constants.py index 5efb678957..4d20fff79a 100644 --- a/enterprise/constants.py +++ b/enterprise/constants.py @@ -241,3 +241,12 @@ class FulfillmentTypes: # The maximum length of a text field in the database. MAX_ALLOWED_TEXT_LENGTH = 16_000_000 + +GROUP_MEMBERSHIP_PENDING_STATUS = 'pending' +GROUP_MEMBERSHIP_REMOVED_STATUS = 'removed' +GROUP_MEMBERSHIP_ACCEPTED_STATUS = 'accepted' +GROUP_MEMBERSHIP_STATUS_CHOICES = ( + (GROUP_MEMBERSHIP_REMOVED_STATUS, 'Removed'), + (GROUP_MEMBERSHIP_ACCEPTED_STATUS, 'Accepted'), + (GROUP_MEMBERSHIP_PENDING_STATUS, 'Pending'), +) diff --git a/enterprise/management/commands/manufacture_data.py b/enterprise/management/commands/manufacture_data.py index f1ce5e3ae8..7f922a4565 100644 --- a/enterprise/management/commands/manufacture_data.py +++ b/enterprise/management/commands/manufacture_data.py @@ -3,7 +3,6 @@ """ import logging -import re import sys import factory @@ -13,6 +12,8 @@ from django.core.management.base import BaseCommand, CommandError, SystemCheckError, handle_default_options from django.db import connections +from enterprise.utils import convert_to_snake + # We have to import the enterprise test factories to ensure it's loaded and found by __subclasses__ # To ensure factories outside of the enterprise package are loaded and found by the script, # add any additionally desired factories as an import to this file. Make sure to catch the ImportError @@ -53,13 +54,6 @@ def all_subclasses(cls): [s for c in cls.__subclasses__() for s in all_subclasses(c)]) -def convert_to_snake(string): - """ - Helper method to convert strings to snake case. - """ - return re.sub(r'(?/learners/' + url = settings.TEST_SERVER + reverse( + 'enterprise-group-learners', + kwargs={'group_uuid': self.group_1.uuid}, + ) + # The problematic child + filter_query_param = "?user_query=Robert`); DROP TABLE enterprise_enterprisecustomeruser;--" + sql_injection_protected_response = self.client.get(url + filter_query_param) + assert sql_injection_protected_response.status_code == 200 + assert not sql_injection_protected_response.json().get('results') + assert EnterpriseCustomerUser.objects.all() + def test_successful_post_group(self): """ Test creating a new group record @@ -7701,6 +7841,8 @@ def test_successful_remove_learners_from_group(self): assert response.status_code == 200 assert response.data == {'records_deleted': 10} for membership in memberships_to_delete: + assert EnterpriseGroupMembership.all_objects.get(pk=membership.pk).status == 'removed' + assert EnterpriseGroupMembership.all_objects.get(pk=membership.pk).removed_at with self.assertRaises(EnterpriseGroupMembership.DoesNotExist): EnterpriseGroupMembership.objects.get(pk=membership.pk) diff --git a/tests/test_enterprise/test_signals.py b/tests/test_enterprise/test_signals.py index ef5e6c47ff..e26ae4aa55 100644 --- a/tests/test_enterprise/test_signals.py +++ b/tests/test_enterprise/test_signals.py @@ -261,6 +261,8 @@ def test_handle_user_post_save_fulfills_pending_group_memberships(self): enterprise_customer_user=None ) assert not new_membership.activated_at + assert new_membership.status == 'pending' + parameters = {"instance": user, "created": False} handle_user_post_save(mock.Mock(), **parameters) # Should delete pending link @@ -272,6 +274,7 @@ def test_handle_user_post_save_fulfills_pending_group_memberships(self): assert membership.pending_enterprise_customer_user is None assert membership.enterprise_customer_user == new_enterprise_user assert membership.activated_at + assert membership.status == 'accepted' @mark.django_db From 4c1bd31d3919c32de2bd63a5d8e9e2ceab8d3258 Mon Sep 17 00:00:00 2001 From: Sameen Fatima <55431213+sameenfatima78@users.noreply.github.com> Date: Tue, 9 Apr 2024 18:27:41 +0500 Subject: [PATCH 159/164] feat: save cornerstone learner information (#2068) --- CHANGELOG.rst | 4 + enterprise/__init__.py | 2 +- enterprise/views.py | 17 +- .../api/v1/cornerstone/urls.py | 12 +- .../api/v1/cornerstone/views.py | 103 ++++++++++++ .../cornerstone/exporters/learner_data.py | 69 ++++---- integrated_channels/cornerstone/utils.py | 24 ++- .../test_api/test_cornerstone/test_views.py | 158 +++++++++++++++++- .../test_exporters/test_learner_data.py | 14 ++ .../test_cornerstone/test_utils.py | 74 +++++--- 10 files changed, 405 insertions(+), 72 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index edef324f71..2335dddf9d 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -15,6 +15,10 @@ Change Log Unreleased ---------- +[4.15.2] +-------- +* feat: save cornerstone learner's information received from frontend. + [4.15.1] -------- * feat: allowing for sorting and filtering of the enterprise group learner endpoints diff --git a/enterprise/__init__.py b/enterprise/__init__.py index bbd36b5529..b2368fa707 100644 --- a/enterprise/__init__.py +++ b/enterprise/__init__.py @@ -2,4 +2,4 @@ Your project description goes here. """ -__version__ = "4.15.1" +__version__ = "4.15.2" diff --git a/enterprise/views.py b/enterprise/views.py index 7c93bbb224..2516c7e2cf 100644 --- a/enterprise/views.py +++ b/enterprise/views.py @@ -2363,6 +2363,7 @@ def get(self, request, *args, **kwargs): - Look to see whether a request is eligible for direct audit enrollment, and if so, directly enroll the user. """ + user_id = request.user.id enterprise_customer_uuid, course_run_id, course_key, program_uuid = RouterView.get_path_variables(**kwargs) enterprise_customer = get_enterprise_customer_or_404(enterprise_customer_uuid) if course_key: @@ -2382,15 +2383,17 @@ def get(self, request, *args, **kwargs): 'CornerstoneEnterpriseCustomerConfiguration' ) with transaction.atomic(): - # The presense of a sessionToken and subdomain param indicates a Cornerstone redirect + # The presence of a sessionToken and subdomain param indicates a Cornerstone redirect # We need to store this sessionToken for api access + csod_user_guid = request.GET.get('userGuid') + csod_callback_url = request.GET.get('callbackUrl') csod_session_token = request.GET.get('sessionToken') csod_subdomain = request.GET.get("subdomain") if csod_session_token and csod_subdomain: LOGGER.info( f'integrated_channel=CSOD, ' f'integrated_channel_enterprise_customer_uuid={enterprise_customer.uuid}, ' - f'integrated_channel_lms_user={request.user.id}, ' + f'integrated_channel_lms_user={user_id}, ' f'integrated_channel_course_key={course_key}, ' 'enrollment redirect' ) @@ -2403,7 +2406,15 @@ def get(self, request, *args, **kwargs): cornerstone_customer_configuration.session_token = csod_session_token cornerstone_customer_configuration.session_token_modified = localized_utcnow() cornerstone_customer_configuration.save() - create_cornerstone_learner_data(request, cornerstone_customer_configuration, course_key) + create_cornerstone_learner_data( + user_id, + csod_user_guid, + csod_session_token, + csod_callback_url, + csod_subdomain, + cornerstone_customer_configuration, + course_key + ) else: LOGGER.error( f'integrated_channel=CSOD, ' diff --git a/integrated_channels/api/v1/cornerstone/urls.py b/integrated_channels/api/v1/cornerstone/urls.py index dbf68a8d86..951270ba1a 100644 --- a/integrated_channels/api/v1/cornerstone/urls.py +++ b/integrated_channels/api/v1/cornerstone/urls.py @@ -4,9 +4,17 @@ from rest_framework import routers -from .views import CornerstoneConfigurationViewSet +from django.urls import path + +from .views import CornerstoneConfigurationViewSet, CornerstoneLearnerInformationView app_name = 'cornerstone' router = routers.DefaultRouter() router.register(r'configuration', CornerstoneConfigurationViewSet, basename="configuration") -urlpatterns = router.urls +urlpatterns = [ + path('save-learner-information', CornerstoneLearnerInformationView.as_view(), + name='save-learner-information' + ), +] + +urlpatterns += router.urls diff --git a/integrated_channels/api/v1/cornerstone/views.py b/integrated_channels/api/v1/cornerstone/views.py index 821693a5f6..e54261d44f 100644 --- a/integrated_channels/api/v1/cornerstone/views.py +++ b/integrated_channels/api/v1/cornerstone/views.py @@ -1,17 +1,120 @@ """ Viewsets for integrated_channels/v1/cornerstone/ """ +from logging import getLogger + +from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthentication from rest_framework import permissions, viewsets +from rest_framework.authentication import SessionAuthentication +from rest_framework.response import Response +from rest_framework.status import HTTP_200_OK, HTTP_404_NOT_FOUND +from rest_framework.views import APIView + +from django.contrib import auth +from django.db import transaction +from enterprise.api.throttles import ServiceUserThrottle +from enterprise.utils import get_enterprise_customer_or_404, get_enterprise_customer_user, localized_utcnow from integrated_channels.api.v1.mixins import PermissionRequiredForIntegratedChannelMixin from integrated_channels.cornerstone.models import CornerstoneEnterpriseCustomerConfiguration +from integrated_channels.cornerstone.utils import create_cornerstone_learner_data from .serializers import CornerstoneConfigSerializer +LOGGER = getLogger(__name__) +User = auth.get_user_model() + class CornerstoneConfigurationViewSet(PermissionRequiredForIntegratedChannelMixin, viewsets.ModelViewSet): + """Viewset for CornerstoneEnterpriseCustomerConfiguration""" serializer_class = CornerstoneConfigSerializer permission_classes = (permissions.IsAuthenticated,) permission_required = 'enterprise.can_access_admin_dashboard' configuration_model = CornerstoneEnterpriseCustomerConfiguration + + +class CornerstoneLearnerInformationView(APIView): + """Viewset for saving information of a cornerstone learner""" + permission_classes = (permissions.IsAuthenticated,) + authentication_classes = (JwtAuthentication, SessionAuthentication,) + throttle_classes = (ServiceUserThrottle,) + + def post(self, request): + """ + An endpoint to save a cornerstone learner information received from frontend. + integrated_channels/api/v1/cornerstone/save-learner-information + Requires a JSON object in the following format: + { + "courseKey": "edX+DemoX", + "enterpriseUUID": "enterprise-uuid-goes-right-here", + "userGuid": "user-guid-from-csod", + "callbackUrl": "https://example.com/csod/callback/1", + "sessionToken": "123123123", + "subdomain": "edx.csod.com" + } + """ + user_id = request.user.id + enterprise_customer_uuid = request.data.get('enterpriseUUID') + enterprise_customer = get_enterprise_customer_or_404(enterprise_customer_uuid) + course_key = request.data.get('courseKey') + with transaction.atomic(): + csod_user_guid = request.data.get('userGuid') + csod_callback_url = request.data.get('callbackUrl') + csod_session_token = request.data.get('sessionToken') + csod_subdomain = request.data.get("subdomain") + + if csod_session_token and csod_subdomain: + LOGGER.info( + f'integrated_channel=CSOD, ' + f'integrated_channel_enterprise_customer_uuid={enterprise_customer_uuid}, ' + f'integrated_channel_lms_user={user_id}, ' + f'integrated_channel_course_key={course_key}, ' + 'saving CSOD learner information' + ) + cornerstone_customer_configuration = \ + CornerstoneEnterpriseCustomerConfiguration.get_by_customer_and_subdomain( + enterprise_customer=enterprise_customer, + customer_subdomain=csod_subdomain + ) + if cornerstone_customer_configuration: + # check if request user is linked as a learner with the given enterprise before savin anything + enterprise_customer_user = get_enterprise_customer_user(user_id, enterprise_customer_uuid) + if enterprise_customer_user: + # saving session token in enterprise config to access cornerstone apis + cornerstone_customer_configuration.session_token = csod_session_token + cornerstone_customer_configuration.session_token_modified = localized_utcnow() + cornerstone_customer_configuration.save() + # saving learner information received from cornerstone + create_cornerstone_learner_data( + user_id, + csod_user_guid, + csod_session_token, + csod_callback_url, + csod_subdomain, + cornerstone_customer_configuration, + course_key + ) + else: + LOGGER.error( + f'integrated_channel=CSOD, ' + f'integrated_channel_enterprise_customer_uuid={enterprise_customer_uuid}, ' + f'integrated_channel_lms_user={user_id}, ' + f'integrated_channel_course_key={course_key}, ' + f'user is not linked to the given enterprise' + ) + message = (f'Cornerstone information could not be saved for learner with user_id={user_id}' + f'because user is not linked to the given enterprise {enterprise_customer_uuid}') + return Response(data={'error': message}, status=HTTP_404_NOT_FOUND) + else: + LOGGER.error( + f'integrated_channel=CSOD, ' + f'integrated_channel_enterprise_customer_uuid={enterprise_customer_uuid}, ' + f'integrated_channel_lms_user={user_id}, ' + f'integrated_channel_course_key={course_key}, ' + f'unable to find cornerstone config matching subdomain {csod_subdomain}' + ) + message = (f'Cornerstone information could not be saved for learner with user_id={user_id}' + f'because no config exist with the subdomain {csod_subdomain}') + return Response(data={'error': message}, status=HTTP_404_NOT_FOUND) + return Response(status=HTTP_200_OK) diff --git a/integrated_channels/cornerstone/exporters/learner_data.py b/integrated_channels/cornerstone/exporters/learner_data.py index 9b5bcbf791..99ab1e7c2c 100644 --- a/integrated_channels/cornerstone/exporters/learner_data.py +++ b/integrated_channels/cornerstone/exporters/learner_data.py @@ -36,47 +36,44 @@ def get_learner_data_records( 'cornerstone', 'CornerstoneLearnerDataTransmissionAudit' ) - enterprise_customer_user = enterprise_enrollment.enterprise_customer_user - # get the proper internal representation of the course key - course_id = get_course_id_for_enrollment(enterprise_enrollment) - # because CornerstoneLearnerDataTransmissionAudit records are created with a click-through - # the internal edX course_id is always used on the CornerstoneLearnerDataTransmissionAudit records - # rather than the external_course_id mapped via CornerstoneCourseKey - transmission_exists = CornerstoneLearnerDataTransmissionAudit.objects.filter( - user_id=enterprise_enrollment.enterprise_customer_user.user.id, - course_id=course_id, - plugin_configuration_id=self.enterprise_configuration.id, - enterprise_customer_uuid=self.enterprise_configuration.enterprise_customer.uuid, - ).exists() - if transmission_exists or enterprise_customer_user.user_email is not None: - csod_transmission_record, __ = CornerstoneLearnerDataTransmissionAudit.objects.update_or_create( - user_id=enterprise_customer_user.user.id, + try: + # get the proper internal representation of the course key + course_id = get_course_id_for_enrollment(enterprise_enrollment) + # because CornerstoneLearnerDataTransmissionAudit records are created with a click-through + # the internal edX course_id is always used on the CornerstoneLearnerDataTransmissionAudit records + # rather than the external_course_id mapped via CornerstoneCourseKey + csod_learner_data_transmission = CornerstoneLearnerDataTransmissionAudit.objects.get( + user_id=enterprise_enrollment.enterprise_customer_user.user.id, course_id=course_id, plugin_configuration_id=self.enterprise_configuration.id, enterprise_customer_uuid=self.enterprise_configuration.enterprise_customer.uuid, - defaults={ - "enterprise_course_enrollment_id": enterprise_enrollment.id, - "grade": grade, - "course_completed": course_completed, - "completed_timestamp": completed_date, - "user_email": enterprise_customer_user.user_email, - }, ) - return [csod_transmission_record] - else: - LOGGER.info( - generate_formatted_log( - self.enterprise_configuration.channel_code(), - enterprise_customer_user.enterprise_customer.uuid, - enterprise_customer_user.user_id, - enterprise_enrollment.course_id, - ( - 'get_learner_data_records finished. No learner data was sent for this LMS User Id because ' - 'Cornerstone User ID not found for [{name}]'.format( - name=enterprise_customer_user.enterprise_customer.name - ) + csod_learner_data_transmission.enterprise_course_enrollment_id = enterprise_enrollment.id + csod_learner_data_transmission.grade = grade + csod_learner_data_transmission.course_completed = course_completed + csod_learner_data_transmission.completed_timestamp = completed_date + + # Used for api error reporting + csod_learner_data_transmission.user_email = enterprise_enrollment.enterprise_customer_user.user_email + + enterprise_customer = enterprise_enrollment.enterprise_customer_user.enterprise_customer + csod_learner_data_transmission.enterprise_customer_uuid = enterprise_customer.uuid + csod_learner_data_transmission.plugin_configuration_id = self.enterprise_configuration.id + return [ + csod_learner_data_transmission + ] + except CornerstoneLearnerDataTransmissionAudit.DoesNotExist: + LOGGER.info(generate_formatted_log( + self.enterprise_configuration.channel_code(), + enterprise_enrollment.enterprise_customer_user.enterprise_customer.uuid, + enterprise_enrollment.enterprise_customer_user.user_id, + enterprise_enrollment.course_id, + ( + 'get_learner_data_records finished. No learner data was sent for this LMS User Id {user_id} ' + 'because Cornerstone User ID not found'.format( + user_id=enterprise_enrollment.enterprise_customer_user.user_id ) ) - ) + )) return None diff --git a/integrated_channels/cornerstone/utils.py b/integrated_channels/cornerstone/utils.py index e6c1defbb9..7bae39d7cd 100644 --- a/integrated_channels/cornerstone/utils.py +++ b/integrated_channels/cornerstone/utils.py @@ -25,22 +25,30 @@ def cornerstone_course_key_model(): LOGGER = getLogger(__name__) -def create_cornerstone_learner_data(request, cornerstone_customer_configuration, course_id): +def create_cornerstone_learner_data( + user_id, + user_guid, + session_token, + callback_url, + subdomain, + cornerstone_customer_configuration, + course_id +): """ updates or creates CornerstoneLearnerDataTransmissionAudit """ enterprise_customer_uuid = cornerstone_customer_configuration.enterprise_customer.uuid try: defaults = { - 'user_guid': request.GET['userGuid'], - 'session_token': request.GET['sessionToken'], - 'callback_url': request.GET['callbackUrl'], - 'subdomain': request.GET['subdomain'], + 'user_guid': user_guid, + 'session_token': session_token, + 'callback_url': callback_url, + 'subdomain': subdomain, } cornerstone_learner_data_transmission_audit().objects.update_or_create( enterprise_customer_uuid=enterprise_customer_uuid, plugin_configuration_id=cornerstone_customer_configuration.id, - user_id=request.user.id, + user_id=user_id, course_id=course_id, defaults=defaults ) @@ -49,7 +57,7 @@ def create_cornerstone_learner_data(request, cornerstone_customer_configuration, LOGGER.exception( f'integrated_channel=CSOD, ' f'integrated_channel_enterprise_customer_uuid={enterprise_customer_uuid}, ' - f'integrated_channel_lms_user={request.user.id}, ' + f'integrated_channel_lms_user={user_id}, ' f'integrated_channel_course_key={course_id}, ' 'malformed cornerstone request missing a param' ) @@ -57,7 +65,7 @@ def create_cornerstone_learner_data(request, cornerstone_customer_configuration, LOGGER.exception( f'integrated_channel=CSOD, ' f'integrated_channel_enterprise_customer_uuid={enterprise_customer_uuid}, ' - f'integrated_channel_lms_user={request.user.id}, ' + f'integrated_channel_lms_user={user_id}, ' f'integrated_channel_course_key={course_id}, ' f'Unable to Create/Update CornerstoneLearnerDataTransmissionAudit.' ) diff --git a/tests/test_integrated_channels/test_api/test_cornerstone/test_views.py b/tests/test_integrated_channels/test_api/test_cornerstone/test_views.py index 00891152c6..037aa56f7d 100644 --- a/tests/test_integrated_channels/test_api/test_cornerstone/test_views.py +++ b/tests/test_integrated_channels/test_api/test_cornerstone/test_views.py @@ -5,11 +5,15 @@ from unittest import mock from uuid import uuid4 +from django.conf import settings from django.urls import reverse from enterprise.constants import ENTERPRISE_ADMIN_ROLE from enterprise.utils import localized_utcnow -from integrated_channels.cornerstone.models import CornerstoneEnterpriseCustomerConfiguration +from integrated_channels.cornerstone.models import ( + CornerstoneEnterpriseCustomerConfiguration, + CornerstoneLearnerDataTransmissionAudit, +) from test_utils import APITest, factories ENTERPRISE_ID = str(uuid4()) @@ -151,3 +155,155 @@ def test_is_valid_field(self, mock_current_request): data = json.loads(response.content.decode('utf-8')).get('results') missing, incorrect = data[0].get('is_valid') assert not missing.get('missing') and not incorrect.get('incorrect') + + +class CornerstoneLearnerInformationViewTests(APITest): + """ + Tests for CornerstoneLearnerInformationView API endpoints + """ + def setUp(self): + super().setUp() + self.enterprise_customer = factories.EnterpriseCustomerFactory(uuid=ENTERPRISE_ID) + self.enterprise_customer_user = factories.EnterpriseCustomerUserFactory( + enterprise_customer=self.enterprise_customer, + user_id=self.user.id, + ) + self.csod_subdomain = 'dummy_subdomain' + self.cornerstone_config = CornerstoneEnterpriseCustomerConfiguration( + enterprise_customer=self.enterprise_customer, + active=True, + cornerstone_base_url=f'https://{self.csod_subdomain}.com', + ) + self.cornerstone_config.save() + self.course_key = 'edX+DemoX' + self.path = settings.TEST_SERVER + '/integrated_channels/api/v1/cornerstone/save-learner-information' + + def test_save_learner_endpoint_happy_path(self): + """ + Test the happy path where csod information for a learner gets saved successfully. + """ + dummy_token = "123123123" + post_data = { + "courseKey": self.course_key, + "enterpriseUUID": self.enterprise_customer.uuid, + "userGuid": "24142313", + "callbackUrl": "https://example.com/csod/callback/1", + "sessionToken": dummy_token, + "subdomain": self.csod_subdomain + } + response = self.client.post(self.path, post_data) + assert response.status_code == 200 + self.cornerstone_config.refresh_from_db() + assert self.cornerstone_config.session_token == dummy_token + assert CornerstoneLearnerDataTransmissionAudit.objects.filter( + enterprise_customer_uuid=self.enterprise_customer.uuid, + plugin_configuration_id=self.cornerstone_config.id, + course_id=self.course_key, + user_id=self.user.id + ).exists() + + def test_save_learner_endpoint_enterprise_customer_does_not_exist(self): + """ + Test when enterprise customer does not exist. + """ + dummy_token = "123123123" + post_data = { + "courseKey": self.course_key, + "enterpriseUUID": 'invalid-uuid', + "userGuid": "24142313", + "callbackUrl": "https://example.com/csod/callback/1", + "sessionToken": dummy_token, + "subdomain": self.csod_subdomain + } + response = self.client.post(self.path, post_data) + self.cornerstone_config.refresh_from_db() + assert self.cornerstone_config.session_token != dummy_token + assert response.status_code == 404 + assert CornerstoneLearnerDataTransmissionAudit.objects.filter( + enterprise_customer_uuid=self.enterprise_customer.uuid, + plugin_configuration_id=self.cornerstone_config.id, + course_id=self.course_key, + user_id=self.user.id + ).count() == 0 + + def test_save_learner_endpoint_cornerstone_config_does_not_exist(self): + """ + Test when cornerstone config is not found. + """ + dummy_token = "123123123" + post_data = { + "courseKey": self.course_key, + "enterpriseUUID": self.enterprise_customer.uuid, + "userGuid": "24142313", + "callbackUrl": "https://example.com/csod/callback/1", + "sessionToken": dummy_token, + "subdomain": 'invalid-subdomain' + } + response = self.client.post(self.path, post_data) + self.cornerstone_config.refresh_from_db() + assert self.cornerstone_config.session_token != dummy_token + assert response.status_code == 404 + assert CornerstoneLearnerDataTransmissionAudit.objects.filter( + enterprise_customer_uuid=self.enterprise_customer.uuid, + plugin_configuration_id=self.cornerstone_config.id, + course_id=self.course_key, + user_id=self.user.id + ).count() == 0 + + def test_save_learner_endpoint_learner_not_linked(self): + """ + Test when learner is not linked to the given enterprise. We should not be saving anything in that case. + """ + # Delete EnterpriseCustomerUser record. + self.enterprise_customer_user.delete() + dummy_token = "123123123" + post_data = { + "courseKey": self.course_key, + "enterpriseUUID": self.enterprise_customer.uuid, + "userGuid": "24142313", + "callbackUrl": "https://example.com/csod/callback/1", + "sessionToken": dummy_token, + "subdomain": self.csod_subdomain + } + response = self.client.post(self.path, post_data) + self.cornerstone_config.refresh_from_db() + assert self.cornerstone_config.session_token != dummy_token + assert response.status_code == 404 + assert CornerstoneLearnerDataTransmissionAudit.objects.filter( + enterprise_customer_uuid=self.enterprise_customer.uuid, + plugin_configuration_id=self.cornerstone_config.id, + course_id=self.course_key, + user_id=self.user.id + ).count() == 0 + + def test_save_learner_endpoint_update_existing_record(self): + """ + When an existing transmisison record is found, we should update that one instead of creating a duplicate. + """ + # Create transmission record + CornerstoneLearnerDataTransmissionAudit.objects.create( + enterprise_customer_uuid=self.enterprise_customer.uuid, + plugin_configuration_id=self.cornerstone_config.id, + user_id=self.user.id, + course_id=self.course_key, + session_token='123456' + ) + dummy_token = "123123123" + post_data = { + "courseKey": self.course_key, + "enterpriseUUID": self.enterprise_customer.uuid, + "userGuid": "24142313", + "callbackUrl": "https://example.com/csod/callback/1", + "sessionToken": dummy_token, + "subdomain": self.csod_subdomain + } + response = self.client.post(self.path, post_data) + assert response.status_code == 200 + self.cornerstone_config.refresh_from_db() + assert self.cornerstone_config.session_token == dummy_token + assert CornerstoneLearnerDataTransmissionAudit.objects.filter( + enterprise_customer_uuid=self.enterprise_customer.uuid, + plugin_configuration_id=self.cornerstone_config.id, + course_id=self.course_key, + user_id=self.user.id + ).count() == 1 diff --git a/tests/test_integrated_channels/test_cornerstone/test_exporters/test_learner_data.py b/tests/test_integrated_channels/test_cornerstone/test_exporters/test_learner_data.py index 39e10b469a..6f8ea97ea6 100644 --- a/tests/test_integrated_channels/test_cornerstone/test_exporters/test_learner_data.py +++ b/tests/test_integrated_channels/test_cornerstone/test_exporters/test_learner_data.py @@ -131,6 +131,20 @@ def test_retrieve_same_learner_data_record(self, mock_course_catalog_api): assert learner_data_records_1.id == learner_data_records_2.id + def test_get_learner_data_record_not_exist(self): + """ + If learner data does not already exist, nothing is returned. + """ + exporter = CornerstoneLearnerExporter('fake-user', self.config) + enterprise_course_enrollment = factories.EnterpriseCourseEnrollmentFactory( + enterprise_customer_user=factories.EnterpriseCustomerUserFactory( + user_id=self.other_user.id, + enterprise_customer=self.enterprise_customer, + ), + course_id=self.course_id, + ) + assert exporter.get_learner_data_records(enterprise_course_enrollment) is None + @responses.activate @mock.patch('integrated_channels.cornerstone.client.requests.post') @mock.patch('integrated_channels.integrated_channel.exporters.learner_data.get_course_certificate') diff --git a/tests/test_integrated_channels/test_cornerstone/test_utils.py b/tests/test_integrated_channels/test_cornerstone/test_utils.py index 38c572ae6a..a161260843 100644 --- a/tests/test_integrated_channels/test_cornerstone/test_utils.py +++ b/tests/test_integrated_channels/test_cornerstone/test_utils.py @@ -45,19 +45,19 @@ def setUp(self): super().setUp() @staticmethod - def _assert_learner_data_transmission_audit(transmission_audit, user, course_id, querystring): + def _assert_learner_data_transmission_audit(transmission_audit, user, course_id, csod_params): """ Asserts CornerstoneLearnerDataTransmissionAudit values""" assert transmission_audit.user == user assert transmission_audit.course_id == course_id - assert transmission_audit.user_guid == querystring['userGuid'] - assert transmission_audit.session_token == querystring['sessionToken'] - assert transmission_audit.callback_url == querystring['callbackUrl'] - assert transmission_audit.subdomain == querystring['subdomain'] + assert transmission_audit.user_guid == csod_params['userGuid'] + assert transmission_audit.session_token == csod_params['sessionToken'] + assert transmission_audit.callback_url == csod_params['callbackUrl'] + assert transmission_audit.subdomain == csod_params['subdomain'] @staticmethod - def _get_request(querystring, user=None): + def _get_request(csod_params, user=None): """ returns mocked request """ - request = RequestFactory().get(path='/', data=querystring) + request = RequestFactory().get(path='/', data=csod_params) request.user = user if user else UserFactory() return request @@ -88,10 +88,18 @@ def _get_request(querystring, user=None): ) @ddt.unpack @mark.django_db - def test_update_cornerstone_learner_data_transmission_audit(self, querystring, course_id, expected_result): + def test_update_cornerstone_learner_data_transmission_audit(self, csod_params, course_id, expected_result): """ test creating records """ - request = self._get_request(querystring) - create_cornerstone_learner_data(request, self.config, course_id) + request = self._get_request(csod_params) + create_cornerstone_learner_data( + request.user.id, + csod_params.get('userGuid'), + csod_params.get('sessionToken'), + csod_params.get('callbackUrl'), + csod_params.get('subdomain'), + self.config, + course_id + ) actual_result = request.user.cornerstone_transmission_audit.filter(course_id=course_id).exists() assert actual_result == expected_result if expected_result: @@ -104,7 +112,7 @@ def test_update_cornerstone_learner_data_transmission_audit_with_existing_data(s """ test updating audit records """ user = UserFactory() course_id = 'dummy_courseId' - querystring = { + csod_params = { 'userGuid': 'dummy_id', 'sessionToken': 'dummy_session_token', 'callbackUrl': 'dummy_callbackUrl', @@ -112,24 +120,48 @@ def test_update_cornerstone_learner_data_transmission_audit_with_existing_data(s } # creating data for first time - request = self._get_request(querystring, user) - create_cornerstone_learner_data(request, self.config, course_id) + request = self._get_request(csod_params, user) + create_cornerstone_learner_data( + request.user.id, + csod_params.get('userGuid'), + csod_params.get('sessionToken'), + csod_params.get('callbackUrl'), + csod_params.get('subdomain'), + self.config, + course_id + ) records = CornerstoneLearnerDataTransmissionAudit.objects.all() assert records.count() == 1 - self._assert_learner_data_transmission_audit(records.first(), user, course_id, querystring) + self._assert_learner_data_transmission_audit(records.first(), user, course_id, csod_params) # Updating just sessionToken Should NOT create new records, instead update old one. - querystring['sessionToken'] = 'updated_dummy_session_token' - request = self._get_request(querystring, user) - create_cornerstone_learner_data(request, self.config, course_id) + csod_params['sessionToken'] = 'updated_dummy_session_token' + request = self._get_request(csod_params, user) + create_cornerstone_learner_data( + request.user.id, + csod_params.get('userGuid'), + csod_params.get('sessionToken'), + csod_params.get('callbackUrl'), + csod_params.get('subdomain'), + self.config, + course_id + ) records = CornerstoneLearnerDataTransmissionAudit.objects.all() assert records.count() == 1 - self._assert_learner_data_transmission_audit(records.first(), user, course_id, querystring) + self._assert_learner_data_transmission_audit(records.first(), user, course_id, csod_params) # But updating courseId Should create fresh record. course_id = 'updated_dummy_courseId' - request = self._get_request(querystring, user) - create_cornerstone_learner_data(request, self.config, course_id) + request = self._get_request(csod_params, user) + create_cornerstone_learner_data( + request.user.id, + csod_params.get('userGuid'), + csod_params.get('sessionToken'), + csod_params.get('callbackUrl'), + csod_params.get('subdomain'), + self.config, + course_id + ) records = CornerstoneLearnerDataTransmissionAudit.objects.all() assert records.count() == 2 - self._assert_learner_data_transmission_audit(records[1], user, course_id, querystring) + self._assert_learner_data_transmission_audit(records[1], user, course_id, csod_params) From a5654936893a73077f5444ffda399a60e6d4b31f Mon Sep 17 00:00:00 2001 From: edX requirements bot Date: Mon, 8 Apr 2024 20:27:38 -0400 Subject: [PATCH 160/164] chore: Updating Python Requirements --- requirements/celery53.txt | 4 +- requirements/ci.txt | 8 +- requirements/common_constraints.txt | 11 +- requirements/dev.txt | 98 +++++++-------- requirements/django.txt | 2 +- requirements/doc.txt | 73 +++++------ requirements/edx-platform-constraints.txt | 146 ++++++++++++---------- requirements/js_test.txt | 51 ++++---- requirements/test-master.txt | 59 ++++----- requirements/test.txt | 59 ++++----- 10 files changed, 258 insertions(+), 253 deletions(-) diff --git a/requirements/celery53.txt b/requirements/celery53.txt index 71b29858a8..74a9f410ef 100644 --- a/requirements/celery53.txt +++ b/requirements/celery53.txt @@ -2,8 +2,8 @@ amqp==5.2.0 billiard==4.2.0 celery==5.3.6 click==8.1.7 -click-didyoumean==0.3.0 +click-didyoumean==0.3.1 click-repl==0.3.0 -kombu==5.3.5 +kombu==5.3.6 prompt-toolkit==3.0.43 vine==5.1.0 diff --git a/requirements/ci.txt b/requirements/ci.txt index 05665c77e4..5e50065c94 100644 --- a/requirements/ci.txt +++ b/requirements/ci.txt @@ -6,13 +6,13 @@ # distlib==0.3.8 # via virtualenv -filelock==3.13.1 +filelock==3.13.3 # via # tox # virtualenv -packaging==23.2 +packaging==24.0 # via tox -platformdirs==4.1.0 +platformdirs==4.2.0 # via virtualenv pluggy==1.4.0 # via tox @@ -26,5 +26,5 @@ tox==3.28.0 # via # -c requirements/constraints.txt # -r requirements/ci.in -virtualenv==20.25.0 +virtualenv==20.25.1 # via tox diff --git a/requirements/common_constraints.txt b/requirements/common_constraints.txt index be61b7e0ed..6eb7bb76ff 100644 --- a/requirements/common_constraints.txt +++ b/requirements/common_constraints.txt @@ -14,10 +14,19 @@ # using LTS django version -Django<4.0 +Django<5.0 # elasticsearch>=7.14.0 includes breaking changes in it which caused issues in discovery upgrade process. # elastic search changelog: https://www.elastic.co/guide/en/enterprise-search/master/release-notes-7.14.0.html elasticsearch<7.14.0 # django-simple-history>3.0.0 adds indexing and causes a lot of migrations to be affected + +# opentelemetry requires version 6.x at the moment: +# https://github.com/open-telemetry/opentelemetry-python/issues/3570 +# Normally this could be added as a constraint in edx-django-utils, where we're +# adding the opentelemetry dependency. However, when we compile pip-tools.txt, +# that uses version 7.x, and then there's no undoing that when compiling base.txt. +# So we need to pin it globally, for now. +# Ticket for unpinning: https://github.com/openedx/edx-lint/issues/407 +importlib-metadata<7 diff --git a/requirements/dev.txt b/requirements/dev.txt index ceaed3644b..c6205babfb 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -8,7 +8,7 @@ accessible-pygments==0.0.4 # via # -r requirements/doc.txt # pydata-sphinx-theme -aiohttp==3.9.1 +aiohttp==3.9.3 # via # -r requirements/doc.txt # -r requirements/test-master.txt @@ -49,7 +49,7 @@ asn1crypto==1.5.1 # -r requirements/test-master.txt # -r requirements/test.txt # snowflake-connector-python -astroid==3.0.2 +astroid==3.1.0 # via # pylint # pylint-celery @@ -78,6 +78,7 @@ backports-zoneinfo[tzdata]==0.2.1 # -r requirements/test.txt # backports-zoneinfo # celery + # django # kombu beautifulsoup4==4.12.3 # via @@ -94,7 +95,7 @@ bleach==6.1.0 # -r requirements/doc.txt # -r requirements/test-master.txt # -r requirements/test.txt -build==1.0.3 +build==1.2.1 # via pip-tools celery==5.3.6 # via @@ -102,7 +103,7 @@ celery==5.3.6 # -r requirements/doc.txt # -r requirements/test-master.txt # -r requirements/test.txt -certifi==2023.11.17 +certifi==2024.2.2 # via # -r requirements/doc.txt # -r requirements/test-master.txt @@ -142,7 +143,7 @@ click==8.1.7 # edx-django-utils # edx-lint # pip-tools -click-didyoumean==0.3.0 +click-didyoumean==0.3.1 # via # -r requirements/doc.txt # -r requirements/test-master.txt @@ -162,7 +163,7 @@ click-repl==0.3.0 # -r requirements/test-master.txt # -r requirements/test.txt # celery -code-annotations==1.5.0 +code-annotations==1.6.0 # via # -r requirements/doc.txt # -r requirements/test-master.txt @@ -182,7 +183,7 @@ coreschema==0.0.4 # -r requirements/test.txt # coreapi # drf-yasg -coverage[toml]==7.4.1 +coverage[toml]==7.4.4 # via # -r requirements/test.txt # coverage @@ -206,19 +207,13 @@ defusedxml==0.7.1 # -r requirements/test-master.txt # -r requirements/test.txt # djangorestframework-xml -deprecated==1.2.14 - # via - # -r requirements/doc.txt - # -r requirements/test-master.txt - # -r requirements/test.txt - # jwcrypto diff-cover==8.0.3 # via -r requirements/test.txt dill==0.3.8 # via pylint distlib==0.3.8 # via virtualenv -django==3.2.23 +django==4.2.10 # via # -c requirements/common_constraints.txt # -r requirements/doc.txt @@ -247,7 +242,7 @@ django-cache-memoize==0.2.0 # -r requirements/doc.txt # -r requirements/test-master.txt # -r requirements/test.txt -django-config-models==2.5.1 +django-config-models==2.7.0 # via # -r requirements/doc.txt # -r requirements/test-master.txt @@ -275,12 +270,12 @@ django-filter==23.5 # -r requirements/doc.txt # -r requirements/test-master.txt # -r requirements/test.txt -django-ipware==6.0.3 +django-ipware==6.0.4 # via # -r requirements/doc.txt # -r requirements/test-master.txt # -r requirements/test.txt -django-model-utils==4.3.1 +django-model-utils==4.4.0 # via # -r requirements/doc.txt # -r requirements/test-master.txt @@ -352,17 +347,17 @@ drf-yasg==1.21.5 # -r requirements/test-master.txt # -r requirements/test.txt # edx-api-doc-tools -edx-api-doc-tools==1.7.0 +edx-api-doc-tools==1.8.0 # via # -r requirements/doc.txt # -r requirements/test-master.txt # -r requirements/test.txt -edx-braze-client==0.2.1 +edx-braze-client==0.2.2 # via # -r requirements/doc.txt # -r requirements/test-master.txt # -r requirements/test.txt -edx-django-utils==5.10.1 +edx-django-utils==5.12.0 # via # -r requirements/doc.txt # -r requirements/test-master.txt @@ -371,7 +366,7 @@ edx-django-utils==5.10.1 # edx-drf-extensions # edx-rest-api-client # edx-toggles -edx-drf-extensions==10.1.0 +edx-drf-extensions==10.2.0 # via # -r requirements/doc.txt # -r requirements/test-master.txt @@ -403,7 +398,7 @@ edx-tincan-py35==1.0.0 # -r requirements/doc.txt # -r requirements/test-master.txt # -r requirements/test.txt -edx-toggles==5.1.0 +edx-toggles==5.1.1 # via # -r requirements/doc.txt # -r requirements/test-master.txt @@ -413,7 +408,7 @@ factory-boy==3.3.0 # -c requirements/constraints.txt # -r requirements/doc.txt # -r requirements/test.txt -faker==22.6.0 +faker==24.7.1 # via # -r requirements/doc.txt # -r requirements/test.txt @@ -449,8 +444,9 @@ imagesize==1.4.1 # via # -r requirements/doc.txt # sphinx -importlib-metadata==7.0.1 +importlib-metadata==6.11.0 # via + # -c requirements/common_constraints.txt # -r requirements/doc.txt # build # sphinx @@ -494,21 +490,21 @@ jsonfield==3.1.0 # -r requirements/doc.txt # -r requirements/test-master.txt # -r requirements/test.txt -jwcrypto==1.5.1 +jwcrypto==1.5.4 # via # -r requirements/doc.txt # -r requirements/test-master.txt # -r requirements/test.txt # django-oauth-toolkit -kombu==5.3.5 +kombu==5.3.6 # via # -r requirements/doc.txt # -r requirements/test-master.txt # -r requirements/test.txt # celery -lxml==5.1.0 +lxml==5.2.1 # via edx-i18n-tools -markupsafe==2.1.4 +markupsafe==2.1.5 # via # -r requirements/doc.txt # -r requirements/test-master.txt @@ -520,7 +516,7 @@ mock==3.0.5 # via # -c requirements/constraints.txt # -r requirements/test.txt -multidict==6.0.4 +multidict==6.0.5 # via # -r requirements/doc.txt # -r requirements/test-master.txt @@ -533,7 +529,7 @@ newrelic==9.6.0 # -r requirements/test-master.txt # -r requirements/test.txt # edx-django-utils -nh3==0.2.15 +nh3==0.2.17 # via # -r requirements/doc.txt # readme-renderer @@ -560,7 +556,7 @@ packaging==23.2 # snowflake-connector-python # sphinx # tox -path==16.9.0 +path==16.10.0 # via # -r requirements/doc.txt # -r requirements/test-master.txt @@ -588,9 +584,9 @@ pillow==10.2.0 # -r requirements/doc.txt # -r requirements/test-master.txt # -r requirements/test.txt -pip-tools==7.3.0 +pip-tools==7.4.1 # via -r requirements/dev.in -pkginfo==1.9.6 +pkginfo==1.10.0 # via twine platformdirs==3.11.0 # via @@ -667,7 +663,7 @@ pyjwt[crypto]==2.8.0 # edx-rest-api-client # pyjwt # snowflake-connector-python -pylint==3.0.3 +pylint==3.1.0 # via # edx-lint # pylint-celery @@ -700,7 +696,9 @@ pyopenssl==22.0.0 # -r requirements/test.txt # snowflake-connector-python pyproject-hooks==1.0.0 - # via build + # via + # build + # pip-tools pytest==6.2.5 # via # -c requirements/constraints.txt @@ -708,7 +706,7 @@ pytest==6.2.5 # -r requirements/test.txt # pytest-cov # pytest-django -pytest-cov==4.1.0 +pytest-cov==5.0.0 # via -r requirements/test.txt pytest-django==4.5.2 # via -r requirements/test.txt @@ -726,19 +724,18 @@ python-ipware==2.0.1 # -r requirements/test-master.txt # -r requirements/test.txt # django-ipware -python-slugify==8.0.2 +python-slugify==8.0.4 # via # -r requirements/doc.txt # -r requirements/test-master.txt # -r requirements/test.txt # code-annotations -pytz==2023.3.post1 +pytz==2024.1 # via # -r requirements/doc.txt # -r requirements/test-master.txt # -r requirements/test.txt # babel - # django # djangorestframework # drf-yasg # edx-tincan-py35 @@ -750,7 +747,7 @@ pyyaml==6.0.1 # -r requirements/test.txt # code-annotations # edx-i18n-tools -readme-renderer==42.0 +readme-renderer==43.0 # via -r requirements/doc.txt requests==2.31.0 # via @@ -778,7 +775,7 @@ restructuredtext-lint==1.4.0 # via # -r requirements/doc.txt # doc8 -ruamel-yaml==0.18.5 +ruamel-yaml==0.18.6 # via # -r requirements/doc.txt # -r requirements/test-master.txt @@ -886,7 +883,7 @@ stevedore==5.1.0 # doc8 # edx-django-utils # edx-opaque-keys -testfixtures==7.2.2 +testfixtures==8.0.0 # via # -r requirements/dev.in # -r requirements/doc.txt @@ -925,7 +922,7 @@ tox==3.28.0 # via # -c requirements/constraints.txt # -r requirements/dev.in -tqdm==4.66.1 +tqdm==4.66.2 # via # -r requirements/doc.txt # -r requirements/test-master.txt @@ -944,11 +941,12 @@ typing-extensions==4.9.0 # django-countries # edx-opaque-keys # faker + # jwcrypto # kombu # pydata-sphinx-theme # pylint # snowflake-connector-python -tzdata==2023.4 +tzdata==2024.1 # via # -r requirements/doc.txt # -r requirements/test-master.txt @@ -982,7 +980,7 @@ vine==5.1.0 # amqp # celery # kombu -virtualenv==20.25.0 +virtualenv==20.25.1 # via tox wcwidth==0.2.13 # via @@ -996,23 +994,17 @@ webencodings==0.5.1 # -r requirements/test-master.txt # -r requirements/test.txt # bleach -wheel==0.42.0 +wheel==0.43.0 # via # -r requirements/dev.in # pip-tools -wrapt==1.16.0 - # via - # -r requirements/doc.txt - # -r requirements/test-master.txt - # -r requirements/test.txt - # deprecated yarl==1.9.4 # via # -r requirements/doc.txt # -r requirements/test-master.txt # -r requirements/test.txt # aiohttp -zipp==3.17.0 +zipp==3.18.1 # via # -r requirements/doc.txt # importlib-metadata diff --git a/requirements/django.txt b/requirements/django.txt index d296127a53..1facfe28b1 100644 --- a/requirements/django.txt +++ b/requirements/django.txt @@ -1 +1 @@ -django==3.2.23 +django==4.2.10 diff --git a/requirements/doc.txt b/requirements/doc.txt index 491ea9d85f..e9dde8a155 100644 --- a/requirements/doc.txt +++ b/requirements/doc.txt @@ -6,7 +6,7 @@ # accessible-pygments==0.0.4 # via pydata-sphinx-theme -aiohttp==3.9.1 +aiohttp==3.9.3 # via # -r requirements/test-master.txt # openai @@ -51,6 +51,7 @@ backports-zoneinfo[tzdata]==0.2.1 # -r requirements/test-master.txt # backports-zoneinfo # celery + # django # kombu beautifulsoup4==4.12.3 # via pydata-sphinx-theme @@ -64,7 +65,7 @@ celery==5.3.6 # via # -c requirements/constraints.txt # -r requirements/test-master.txt -certifi==2023.11.17 +certifi==2024.2.2 # via # -r requirements/test-master.txt # requests @@ -89,7 +90,7 @@ click==8.1.7 # click-repl # code-annotations # edx-django-utils -click-didyoumean==0.3.0 +click-didyoumean==0.3.1 # via # -r requirements/test-master.txt # celery @@ -101,7 +102,7 @@ click-repl==0.3.0 # via # -r requirements/test-master.txt # celery -code-annotations==1.5.0 +code-annotations==1.6.0 # via # -r requirements/test-master.txt # edx-toggles @@ -127,11 +128,7 @@ defusedxml==0.7.1 # via # -r requirements/test-master.txt # djangorestframework-xml -deprecated==1.2.14 - # via - # -r requirements/test-master.txt - # jwcrypto -django==3.2.23 +django==4.2.10 # via # -c requirements/common_constraints.txt # -r requirements/test-master.txt @@ -154,7 +151,7 @@ django==3.2.23 # jsonfield django-cache-memoize==0.2.0 # via -r requirements/test-master.txt -django-config-models==2.5.1 +django-config-models==2.7.0 # via -r requirements/test-master.txt django-countries==7.5.1 # via -r requirements/test-master.txt @@ -168,9 +165,9 @@ django-fernet-fields-v2==0.9 # via -r requirements/test-master.txt django-filter==23.5 # via -r requirements/test-master.txt -django-ipware==6.0.3 +django-ipware==6.0.4 # via -r requirements/test-master.txt -django-model-utils==4.3.1 +django-model-utils==4.4.0 # via # -r requirements/test-master.txt # edx-rbac @@ -218,18 +215,18 @@ drf-yasg==1.21.5 # via # -r requirements/test-master.txt # edx-api-doc-tools -edx-api-doc-tools==1.7.0 +edx-api-doc-tools==1.8.0 # via -r requirements/test-master.txt -edx-braze-client==0.2.1 +edx-braze-client==0.2.2 # via -r requirements/test-master.txt -edx-django-utils==5.10.1 +edx-django-utils==5.12.0 # via # -r requirements/test-master.txt # django-config-models # edx-drf-extensions # edx-rest-api-client # edx-toggles -edx-drf-extensions==10.1.0 +edx-drf-extensions==10.2.0 # via # -r requirements/test-master.txt # edx-rbac @@ -244,13 +241,13 @@ edx-rest-api-client==5.6.1 # via -r requirements/test-master.txt edx-tincan-py35==1.0.0 # via -r requirements/test-master.txt -edx-toggles==5.1.0 +edx-toggles==5.1.1 # via -r requirements/test-master.txt factory-boy==3.3.0 # via # -c requirements/constraints.txt # -r requirements/doc.in -faker==22.6.0 +faker==24.7.1 # via factory-boy filelock==3.13.1 # via @@ -269,8 +266,10 @@ idna==3.6 # yarl imagesize==1.4.1 # via sphinx -importlib-metadata==7.0.1 - # via sphinx +importlib-metadata==6.11.0 + # via + # -c requirements/common_constraints.txt + # sphinx inflection==0.5.1 # via # -r requirements/test-master.txt @@ -291,19 +290,19 @@ jsondiff==2.0.0 # via -r requirements/test-master.txt jsonfield==3.1.0 # via -r requirements/test-master.txt -jwcrypto==1.5.1 +jwcrypto==1.5.4 # via # -r requirements/test-master.txt # django-oauth-toolkit -kombu==5.3.5 +kombu==5.3.6 # via # -r requirements/test-master.txt # celery -markupsafe==2.1.4 +markupsafe==2.1.5 # via # -r requirements/test-master.txt # jinja2 -multidict==6.0.4 +multidict==6.0.5 # via # -r requirements/test-master.txt # aiohttp @@ -312,7 +311,7 @@ newrelic==9.6.0 # via # -r requirements/test-master.txt # edx-django-utils -nh3==0.2.15 +nh3==0.2.17 # via readme-renderer oauthlib==3.2.2 # via @@ -328,7 +327,7 @@ packaging==23.2 # pytest # snowflake-connector-python # sphinx -path==16.9.0 +path==16.10.0 # via # -r requirements/test-master.txt # path-py @@ -408,15 +407,14 @@ python-ipware==2.0.1 # via # -r requirements/test-master.txt # django-ipware -python-slugify==8.0.2 +python-slugify==8.0.4 # via # -r requirements/test-master.txt # code-annotations -pytz==2023.3.post1 +pytz==2024.1 # via # -r requirements/test-master.txt # babel - # django # djangorestframework # drf-yasg # edx-tincan-py35 @@ -425,7 +423,7 @@ pyyaml==6.0.1 # via # -r requirements/test-master.txt # code-annotations -readme-renderer==42.0 +readme-renderer==43.0 # via -r requirements/doc.in requests==2.31.0 # via @@ -440,7 +438,7 @@ requests==2.31.0 # sphinx restructuredtext-lint==1.4.0 # via doc8 -ruamel-yaml==0.18.5 +ruamel-yaml==0.18.6 # via # -r requirements/test-master.txt # drf-yasg @@ -504,7 +502,7 @@ stevedore==5.1.0 # doc8 # edx-django-utils # edx-opaque-keys -testfixtures==7.2.2 +testfixtures==8.0.0 # via -r requirements/test-master.txt text-unidecode==1.3 # via @@ -518,7 +516,7 @@ tomlkit==0.12.3 # via # -r requirements/test-master.txt # snowflake-connector-python -tqdm==4.66.1 +tqdm==4.66.2 # via # -r requirements/test-master.txt # openai @@ -529,10 +527,11 @@ typing-extensions==4.9.0 # django-countries # edx-opaque-keys # faker + # jwcrypto # kombu # pydata-sphinx-theme # snowflake-connector-python -tzdata==2023.4 +tzdata==2024.1 # via # -r requirements/test-master.txt # backports-zoneinfo @@ -563,13 +562,9 @@ webencodings==0.5.1 # via # -r requirements/test-master.txt # bleach -wrapt==1.16.0 - # via - # -r requirements/test-master.txt - # deprecated yarl==1.9.4 # via # -r requirements/test-master.txt # aiohttp -zipp==3.17.0 +zipp==3.18.1 # via importlib-metadata diff --git a/requirements/edx-platform-constraints.txt b/requirements/edx-platform-constraints.txt index e3c6847679..8cea7fb2f6 100644 --- a/requirements/edx-platform-constraints.txt +++ b/requirements/edx-platform-constraints.txt @@ -6,9 +6,9 @@ # make upgrade # # via -r requirements/edx/github.in -acid-xblock==0.2.1 +acid-xblock==0.3.0 # via -r requirements/edx/kernel.in -aiohttp==3.9.1 +aiohttp==3.9.3 # via # geoip2 # openai @@ -21,6 +21,8 @@ analytics-python==1.4.post1 # via -r requirements/edx/kernel.in aniso8601==9.0.1 # via edx-tincan-py35 +annotated-types==0.6.0 + # via pydantic appdirs==1.4.4 # via fs asgiref==3.7.2 @@ -55,6 +57,7 @@ backoff==1.10.0 backports-zoneinfo[tzdata]==0.2.1 # via # celery + # django # icalendar # kombu beautifulsoup4==4.12.3 @@ -71,19 +74,21 @@ bleach[css]==6.1.0 # xblock-poll boto==2.49.0 # via -r requirements/edx/kernel.in -boto3==1.34.28 +boto3==1.34.45 # via # -r requirements/edx/kernel.in # django-ses # fs-s3fs # ora2 -botocore==1.34.28 +botocore==1.34.45 # via # -r requirements/edx/kernel.in # boto3 # s3transfer bridgekeeper==0.9 # via -r requirements/edx/kernel.in +camel-converter[pydantic]==3.1.1 + # via meilisearch # via # -c requirements/edx/../constraints.txt # -r requirements/edx/kernel.in @@ -93,7 +98,7 @@ bridgekeeper==0.9 # edx-enterprise # event-tracking # openedx-learning -certifi==2023.11.17 +certifi==2024.2.2 # via # -r requirements/edx/paver.txt # elasticsearch @@ -129,7 +134,7 @@ chem==1.2.0 click-plugins==1.1.1 # via celery # via celery -code-annotations==1.5.0 +code-annotations==1.6.0 # via # edx-enterprise # edx-toggles @@ -165,11 +170,10 @@ defusedxml==0.7.1 # ora2 # python3-openid # social-auth-core -deprecated==1.2.14 - # via jwcrypto -django==3.2.23 +django==4.2.10 # via # -c requirements/edx/../common_constraints.txt + # -c requirements/edx/../constraints.txt # -r requirements/edx/kernel.in # django-appconf # django-celery-results @@ -246,7 +250,7 @@ django-celery-results==2.5.1 # via -r requirements/edx/kernel.in django-classy-tags==4.1.0 # via django-sekizai -django-config-models==2.5.1 +django-config-models==2.7.0 # via # -r requirements/edx/kernel.in # edx-enterprise @@ -276,7 +280,7 @@ django-filter==23.5 # edx-enterprise # lti-consumer-xblock # openedx-blockstore -django-ipware==6.0.3 +django-ipware==6.0.4 # via # -r requirements/edx/kernel.in # edx-enterprise @@ -285,7 +289,7 @@ django-js-asset==2.2.0 # via django-mptt django-method-override==1.0.4 # via -r requirements/edx/kernel.in -django-model-utils==4.3.1 +django-model-utils==4.4.0 # via # -r requirements/edx/kernel.in # django-user-tasks @@ -327,6 +331,7 @@ django-sekizai==4.1.0 django-ses==3.5.2 # via -r requirements/edx/bundled.in # via + # -c requirements/edx/../constraints.txt # -r requirements/edx/kernel.in # edx-enterprise # edx-name-affirmation @@ -344,7 +349,7 @@ django-storages==1.14.2 # via # -r requirements/edx/kernel.in # edxval -django-user-tasks==3.1.0 +django-user-tasks==3.2.0 # via -r requirements/edx/kernel.in django-waffle==4.1.0 # via @@ -383,7 +388,7 @@ djangorestframework==3.14.0 # ora2 # super-csv djangorestframework-xml==2.0.0 -done-xblock==2.2.0 +done-xblock==2.3.0 # via -r requirements/edx/bundled.in drf-jwt==1.19.2 # via edx-drf-extensions @@ -398,16 +403,16 @@ drf-yasg==1.21.5 # edx-api-doc-tools edx-ace==1.7.0 # via -r requirements/edx/kernel.in -edx-api-doc-tools==1.7.0 +edx-api-doc-tools==1.8.0 # via # -r requirements/edx/kernel.in # edx-name-affirmation # openedx-blockstore -edx-auth-backends==4.2.0 +edx-auth-backends==4.3.0 # via # -r requirements/edx/kernel.in # openedx-blockstore -edx-braze-client==0.2.1 +edx-braze-client==0.2.2 # via # -r requirements/edx/bundled.in # edx-enterprise @@ -419,23 +424,23 @@ edx-ccx-keys==1.2.1 # via # -r requirements/edx/kernel.in # lti-consumer-xblock -edx-celeryutils==1.2.3 +edx-celeryutils==1.3.0 # via # -r requirements/edx/kernel.in # edx-name-affirmation # super-csv edx-codejail==3.3.3 # via -r requirements/edx/kernel.in -edx-completion==4.4.0 +edx-completion==4.5.0 # via -r requirements/edx/kernel.in -edx-django-release-util==1.3.0 +edx-django-release-util==1.4.0 # via # -r requirements/edx/kernel.in # edxval # openedx-blockstore edx-django-sites-extensions==4.0.2 # via -r requirements/edx/kernel.in -edx-django-utils==5.10.1 +edx-django-utils==5.12.0 # via # -r requirements/edx/kernel.in # django-config-models @@ -449,9 +454,10 @@ edx-django-utils==5.10.1 # edx-when # event-tracking # openedx-blockstore + # openedx-events # ora2 # super-csv -edx-drf-extensions==10.1.0 +edx-drf-extensions==10.2.0 # via # -r requirements/edx/kernel.in # edx-completion @@ -463,16 +469,18 @@ edx-drf-extensions==10.1.0 # edx-when # edxval # openedx-learning -edx-enterprise==4.10.11 +edx-enterprise==4.15.1 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/kernel.in edx-event-bus-kafka==5.6.0 # via -r requirements/edx/kernel.in -edx-event-bus-redis==0.3.2 +edx-event-bus-redis==0.3.3 # via -r requirements/edx/kernel.in edx-i18n-tools==1.3.0 - # via ora2 + # via + # -r requirements/edx/bundled.in + # ora2 edx-milestones==0.5.0 # via -r requirements/edx/kernel.in edx-name-affirmation==2.3.7 @@ -493,7 +501,7 @@ edx-opaque-keys[django]==2.5.1 # lti-consumer-xblock # openedx-events # ora2 -edx-organizations==6.12.1 +edx-organizations==6.13.0 # via -r requirements/edx/kernel.in edx-proctoring==4.16.1 # via @@ -505,16 +513,16 @@ edx-rest-api-client==5.6.1 # -r requirements/edx/kernel.in # edx-enterprise # edx-proctoring -edx-search==3.8.2 +edx-search==3.9.1 # via -r requirements/edx/kernel.in -edx-sga==0.23.1 +edx-sga==0.24.1 # via -r requirements/edx/bundled.in -edx-submissions==3.6.0 +edx-submissions==3.7.0 # via # -r requirements/edx/kernel.in # ora2 edx-tincan-py35==1.0.0 -edx-toggles==5.1.0 +edx-toggles==5.1.1 # via # -r requirements/edx/kernel.in # edx-completion @@ -524,6 +532,7 @@ edx-toggles==5.1.0 # edx-name-affirmation # edx-search # edxval + # event-tracking # ora2 edx-token-utils==0.2.1 # via -r requirements/edx/kernel.in @@ -541,13 +550,13 @@ enmerkar==0.7.1 # via enmerkar-underscore enmerkar-underscore==2.2.0 # via -r requirements/edx/kernel.in -event-tracking==2.2.0 +event-tracking==2.3.0 # via # -r requirements/edx/kernel.in # edx-completion # edx-proctoring # edx-search -fastavro==1.9.3 +fastavro==1.9.4 # via openedx-events filelock==3.13.1 # via snowflake-connector-python @@ -588,8 +597,10 @@ idna==3.6 # requests # snowflake-connector-python # yarl -importlib-metadata==7.0.1 - # via markdown +importlib-metadata==6.11.0 + # via + # -c requirements/edx/../common_constraints.txt + # markdown importlib-resources==5.13.0 # via # jsonschema @@ -633,7 +644,7 @@ jsonschema==4.21.1 # optimizely-sdk jsonschema-specifications==2023.12.1 # via jsonschema -jwcrypto==1.5.1 +jwcrypto==1.5.4 # via # django-oauth-toolkit # pylti1p3 @@ -670,7 +681,7 @@ lxml==4.9.4 # xmlsec mailsnake==1.6.4 # via -r requirements/edx/bundled.in -mako==1.3.0 +mako==1.3.2 # via # -r requirements/edx/kernel.in # acid-xblock @@ -686,7 +697,7 @@ markdown==3.3.7 # xblock-poll markey==0.8 # via enmerkar-underscore -markupsafe==2.1.4 +markupsafe==2.1.5 # via # -r requirements/edx/paver.txt # chem @@ -696,6 +707,8 @@ markupsafe==2.1.4 # xblock maxminddb==2.5.2 # via geoip2 +meilisearch==0.30.0 + # via -r requirements/edx/kernel.in mock==5.1.0 # via -r requirements/edx/paver.txt mongoengine==0.27.0 @@ -706,11 +719,11 @@ monotonic==1.6 # py2neo mpmath==1.3.0 # via sympy -multidict==6.0.4 +multidict==6.0.5 # via # aiohttp # yarl -mysqlclient==2.2.1 +mysqlclient==2.2.4 # via # -r requirements/edx/kernel.in # openedx-blockstore @@ -747,7 +760,7 @@ openedx-blockstore==1.4.0 # via -r requirements/edx/kernel.in openedx-calc==3.0.1 # via -r requirements/edx/kernel.in -openedx-django-pyfs==3.4.1 +openedx-django-pyfs==3.6.0 # via # lti-consumer-xblock # xblock @@ -755,16 +768,17 @@ openedx-django-require==2.1.0 # via -r requirements/edx/kernel.in openedx-django-wiki==2.0.3 # via -r requirements/edx/kernel.in -openedx-events==9.3.0 +openedx-events==9.5.2 # via # -r requirements/edx/kernel.in # edx-event-bus-kafka # edx-event-bus-redis + # event-tracking openedx-filters==1.6.0 # via # -r requirements/edx/kernel.in # lti-consumer-xblock -openedx-learning==0.4.4 +openedx-learning==0.8.0 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/kernel.in @@ -774,7 +788,7 @@ optimizely-sdk==4.1.1 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/bundled.in -ora2==6.0.30 +ora2==6.6.1 # via -r requirements/edx/bundled.in packaging==23.2 # via @@ -784,7 +798,7 @@ packaging==23.2 # snowflake-connector-python pansi==2020.7.3 # via py2neo -path==16.9.0 +path==16.10.0 # via # -r requirements/edx/kernel.in # -r requirements/edx/paver.txt @@ -837,6 +851,10 @@ pycryptodomex==3.20.0 # edx-proctoring # lti-consumer-xblock # pyjwkest +pydantic==2.6.3 + # via camel-converter +pydantic-core==2.16.3 + # via pydantic pygments==2.17.2 # via # -r requirements/edx/bundled.in @@ -908,7 +926,7 @@ python-ipware==2.0.1 # via django-ipware python-memcached==1.62 # via -r requirements/edx/paver.txt -python-slugify==8.0.2 +python-slugify==8.0.4 # via code-annotations python-swiftclient==4.4.0 # via ora2 @@ -918,11 +936,10 @@ python3-openid==3.2.0 ; python_version >= "3" # social-auth-core python3-saml==1.16.0 # via -r requirements/edx/kernel.in -pytz==2023.3.post1 +pytz==2024.1 # via # -r requirements/edx/kernel.in # babel - # django # django-ses # djangorestframework # drf-yasg @@ -952,13 +969,13 @@ pyyaml==6.0.1 # xblock random2==1.0.2 # via -r requirements/edx/kernel.in -recommender-xblock==2.1.1 +recommender-xblock==2.2.0 # via -r requirements/edx/bundled.in redis==5.0.1 # via # -r requirements/edx/kernel.in # walrus -referencing==0.32.1 +referencing==0.33.0 # via # jsonschema # jsonschema-specifications @@ -977,6 +994,7 @@ requests==2.31.0 # edx-rest-api-client # geoip2 # mailsnake + # meilisearch # openai # optimizely-sdk # pyjwkest @@ -992,11 +1010,11 @@ requests-oauthlib==1.3.1 # via # -r requirements/edx/kernel.in # social-auth-core -rpds-py==0.17.1 +rpds-py==0.18.0 # via # jsonschema # referencing -ruamel-yaml==0.18.5 +ruamel-yaml==0.18.6 # via drf-yasg ruamel-yaml-clib==0.2.8 # via ruamel-yaml @@ -1017,7 +1035,7 @@ scipy==1.7.3 # openedx-calc semantic-version==2.10.0 # via edx-drf-extensions -shapely==2.0.2 +shapely==2.0.3 # via -r requirements/edx/kernel.in simplejson==3.19.2 # via @@ -1088,7 +1106,7 @@ sqlparse==0.4.4 # -r requirements/edx/kernel.in # django # openedx-blockstore -staff-graded-xblock==2.2.0 +staff-graded-xblock==2.3.0 # via -r requirements/edx/bundled.in stevedore==5.1.0 # via @@ -1103,28 +1121,32 @@ super-csv==3.1.0 # via edx-bulk-grades sympy==1.12 # via openedx-calc -testfixtures==7.2.2 +testfixtures==8.0.0 text-unidecode==1.3 # via python-slugify tinycss2==1.2.1 # via bleach tomlkit==0.12.3 # via snowflake-connector-python -tqdm==4.66.1 +tqdm==4.66.2 # via # nltk # openai typing-extensions==4.9.0 # via # -r requirements/edx/paver.txt + # annotated-types # asgiref # django-countries # drf-spectacular # edx-opaque-keys + # jwcrypto # kombu + # pydantic + # pydantic-core # pylti1p3 # snowflake-connector-python -tzdata==2023.4 +tzdata==2024.1 # via # backports-zoneinfo # celery @@ -1152,15 +1174,15 @@ user-util==1.0.0 # amqp # celery # kombu -voluptuous==0.14.1 +voluptuous==0.14.2 # via ora2 walrus==0.9.3 # via edx-event-bus-redis -watchdog==3.0.0 +watchdog==4.0.0 # via -r requirements/edx/paver.txt wcwidth==0.2.13 # via prompt-toolkit -web-fragments==2.1.0 +web-fragments==2.2.0 # via # -r requirements/edx/kernel.in # crowdsourcehinter-xblock @@ -1178,10 +1200,8 @@ webob==1.8.7 # -r requirements/edx/kernel.in # xblock wrapt==1.16.0 - # via - # -r requirements/edx/paver.txt - # deprecated -xblock[django]==1.10.0 + # via -r requirements/edx/paver.txt +xblock[django]==2.0.0 # via # -r requirements/edx/kernel.in # acid-xblock diff --git a/requirements/js_test.txt b/requirements/js_test.txt index 343cfaf576..88ec1e4611 100644 --- a/requirements/js_test.txt +++ b/requirements/js_test.txt @@ -4,15 +4,15 @@ # # make upgrade # -annotated-types==0.6.0 - # via pydantic attrs==23.2.0 # via # outcome # trio autocommand==2.2.2 # via jaraco-text -certifi==2023.11.17 +backports-tarfile==1.0.0 + # via jaraco-context +certifi==2024.2.2 # via selenium cheroot==10.0.0 # via cherrypy @@ -28,17 +28,21 @@ h11==0.14.0 # via wsproto idna==3.6 # via trio -importlib-resources==6.1.1 +importlib-metadata==6.11.0 + # via + # -c requirements/common_constraints.txt + # typeguard +importlib-resources==6.4.0 # via jaraco-text -inflect==7.0.0 +inflect==7.2.0 # via jaraco-text -jaraco-classes==3.3.0 +jaraco-classes==3.4.0 # via -r requirements/js_test.in jaraco-collections==5.0.0 # via # -r requirements/js_test.in # cherrypy -jaraco-context==4.3.0 +jaraco-context==5.3.0 # via jaraco-text jaraco-functools==4.0.0 # via @@ -54,12 +58,13 @@ jasmine-core==3.99.0 # via jasmine jinja2==2.11.3 # via jasmine -markupsafe==2.1.4 +markupsafe==2.1.5 # via jinja2 more-itertools==10.2.0 # via # cheroot # cherrypy + # inflect # jaraco-classes # jaraco-functools # jaraco-text @@ -69,40 +74,36 @@ outcome==1.3.0.post0 # via trio portend==3.2.0 # via cherrypy -pydantic==2.6.0 - # via inflect -pydantic-core==2.16.1 - # via pydantic pysocks==1.7.1 # via urllib3 -pytz==2023.4 +pytz==2024.1 # via tempora pyyaml==6.0.1 # via jasmine -selenium==4.17.2 +selenium==4.19.0 # via jasmine -sniffio==1.3.0 +sniffio==1.3.1 # via trio sortedcontainers==2.4.0 # via trio -tempora==5.5.0 +tempora==5.5.1 # via # -r requirements/js_test.in # portend -trio==0.24.0 +trio==0.25.0 # via # selenium # trio-websocket trio-websocket==0.11.1 # via selenium -typing-extensions==4.9.0 +typeguard==4.2.1 + # via inflect +typing-extensions==4.11.0 # via - # annotated-types # inflect - # pydantic - # pydantic-core # selenium -urllib3[socks]==2.1.0 + # typeguard +urllib3[socks]==2.2.1 # via # selenium # urllib3 @@ -110,8 +111,10 @@ wsproto==1.2.0 # via trio-websocket zc-lockfile==3.0.post1 # via cherrypy -zipp==3.17.0 - # via importlib-resources +zipp==3.18.1 + # via + # importlib-metadata + # importlib-resources # The following packages are considered to be unsafe in a requirements file: # setuptools diff --git a/requirements/test-master.txt b/requirements/test-master.txt index f74ef54a78..4531ca5dda 100644 --- a/requirements/test-master.txt +++ b/requirements/test-master.txt @@ -4,7 +4,7 @@ # # make upgrade # -aiohttp==3.9.1 +aiohttp==3.9.3 # via # -c requirements/edx-platform-constraints.txt # openai @@ -39,6 +39,7 @@ backports-zoneinfo[tzdata]==0.2.1 # via # -c requirements/edx-platform-constraints.txt # celery + # django # kombu billiard==4.2.0 # via celery @@ -50,7 +51,7 @@ celery==5.3.6 # via # -c requirements/constraints.txt # -r requirements/base.in -certifi==2023.11.17 +certifi==2024.2.2 # via # -c requirements/edx-platform-constraints.txt # requests @@ -74,7 +75,7 @@ click==8.1.7 # click-repl # code-annotations # edx-django-utils -click-didyoumean==0.3.0 +click-didyoumean==0.3.1 # via celery click-plugins==1.1.1 # via @@ -82,7 +83,7 @@ click-plugins==1.1.1 # celery click-repl==0.3.0 # via celery -code-annotations==1.5.0 +code-annotations==1.6.0 # via # -c requirements/edx-platform-constraints.txt # -r requirements/base.in @@ -110,11 +111,7 @@ defusedxml==0.7.1 # via # -c requirements/edx-platform-constraints.txt # djangorestframework-xml -deprecated==1.2.14 - # via - # -c requirements/edx-platform-constraints.txt - # jwcrypto -django==3.2.23 +django==4.2.10 # via # -c requirements/common_constraints.txt # -c requirements/edx-platform-constraints.txt @@ -140,7 +137,7 @@ django-cache-memoize==0.2.0 # via # -c requirements/edx-platform-constraints.txt # -r requirements/base.in -django-config-models==2.5.1 +django-config-models==2.7.0 # via # -c requirements/edx-platform-constraints.txt # -r requirements/base.in @@ -163,11 +160,11 @@ django-filter==23.5 # via # -c requirements/edx-platform-constraints.txt # -r requirements/base.in -django-ipware==6.0.3 +django-ipware==6.0.4 # via # -c requirements/edx-platform-constraints.txt # -r requirements/base.in -django-model-utils==4.3.1 +django-model-utils==4.4.0 # via # -c requirements/edx-platform-constraints.txt # -r requirements/base.in @@ -216,15 +213,15 @@ drf-yasg==1.21.5 # via # -c requirements/edx-platform-constraints.txt # edx-api-doc-tools -edx-api-doc-tools==1.7.0 +edx-api-doc-tools==1.8.0 # via # -c requirements/edx-platform-constraints.txt # -r requirements/test-master.in -edx-braze-client==0.2.1 +edx-braze-client==0.2.2 # via # -c requirements/edx-platform-constraints.txt # -r requirements/base.in -edx-django-utils==5.10.1 +edx-django-utils==5.12.0 # via # -c requirements/edx-platform-constraints.txt # -r requirements/base.in @@ -232,7 +229,7 @@ edx-django-utils==5.10.1 # edx-drf-extensions # edx-rest-api-client # edx-toggles -edx-drf-extensions==10.1.0 +edx-drf-extensions==10.2.0 # via # -c requirements/edx-platform-constraints.txt # -r requirements/base.in @@ -254,7 +251,7 @@ edx-tincan-py35==1.0.0 # via # -c requirements/edx-platform-constraints.txt # -r requirements/base.in -edx-toggles==5.1.0 +edx-toggles==5.1.1 # via # -c requirements/edx-platform-constraints.txt # -r requirements/base.in @@ -294,17 +291,17 @@ jsonfield==3.1.0 # via # -c requirements/edx-platform-constraints.txt # -r requirements/base.in -jwcrypto==1.5.1 +jwcrypto==1.5.4 # via # -c requirements/edx-platform-constraints.txt # django-oauth-toolkit -kombu==5.3.5 +kombu==5.3.6 # via celery -markupsafe==2.1.4 +markupsafe==2.1.5 # via # -c requirements/edx-platform-constraints.txt # jinja2 -multidict==6.0.4 +multidict==6.0.5 # via # -c requirements/edx-platform-constraints.txt # aiohttp @@ -326,7 +323,7 @@ packaging==23.2 # -c requirements/edx-platform-constraints.txt # drf-yasg # snowflake-connector-python -path==16.9.0 +path==16.10.0 # via # -c requirements/edx-platform-constraints.txt # path-py @@ -393,15 +390,14 @@ python-ipware==2.0.1 # via # -c requirements/edx-platform-constraints.txt # django-ipware -python-slugify==8.0.2 +python-slugify==8.0.4 # via # -c requirements/edx-platform-constraints.txt # code-annotations -pytz==2023.3.post1 +pytz==2024.1 # via # -c requirements/edx-platform-constraints.txt # -r requirements/base.in - # django # djangorestframework # drf-yasg # edx-tincan-py35 @@ -421,7 +417,7 @@ requests==2.31.0 # openai # slumber # snowflake-connector-python -ruamel-yaml==0.18.5 +ruamel-yaml==0.18.6 # via # -c requirements/edx-platform-constraints.txt # drf-yasg @@ -467,7 +463,7 @@ stevedore==5.1.0 # code-annotations # edx-django-utils # edx-opaque-keys -testfixtures==7.2.2 +testfixtures==8.0.0 # via # -c requirements/edx-platform-constraints.txt # -r requirements/base.in @@ -479,7 +475,7 @@ tomlkit==0.12.3 # via # -c requirements/edx-platform-constraints.txt # snowflake-connector-python -tqdm==4.66.1 +tqdm==4.66.2 # via # -c requirements/edx-platform-constraints.txt # openai @@ -489,9 +485,10 @@ typing-extensions==4.9.0 # asgiref # django-countries # edx-opaque-keys + # jwcrypto # kombu # snowflake-connector-python -tzdata==2023.4 +tzdata==2024.1 # via # -c requirements/edx-platform-constraints.txt # backports-zoneinfo @@ -523,10 +520,6 @@ webencodings==0.5.1 # via # -c requirements/edx-platform-constraints.txt # bleach -wrapt==1.16.0 - # via - # -c requirements/edx-platform-constraints.txt - # deprecated yarl==1.9.4 # via # -c requirements/edx-platform-constraints.txt diff --git a/requirements/test.txt b/requirements/test.txt index a8aa6b188c..24cb6717d3 100644 --- a/requirements/test.txt +++ b/requirements/test.txt @@ -4,7 +4,7 @@ # # make upgrade # -aiohttp==3.9.1 +aiohttp==3.9.3 # via # -r requirements/test-master.txt # openai @@ -43,6 +43,7 @@ backports-zoneinfo[tzdata]==0.2.1 # -r requirements/test.in # backports-zoneinfo # celery + # django # kombu # via # -r requirements/test-master.txt @@ -52,7 +53,7 @@ bleach==6.1.0 # via # -c requirements/constraints.txt # -r requirements/test-master.txt -certifi==2023.11.17 +certifi==2024.2.2 # via # -r requirements/test-master.txt # requests @@ -88,7 +89,7 @@ click-plugins==1.1.1 # via # -r requirements/test-master.txt # celery -code-annotations==1.5.0 +code-annotations==1.6.0 # via # -r requirements/test-master.txt # edx-toggles @@ -101,7 +102,7 @@ coreschema==0.0.4 # -r requirements/test-master.txt # coreapi # drf-yasg -coverage[toml]==7.4.1 +coverage[toml]==7.4.4 # via # coverage # pytest-cov @@ -120,10 +121,6 @@ defusedxml==0.7.1 # via # -r requirements/test-master.txt # djangorestframework-xml -deprecated==1.2.14 - # via - # -r requirements/test-master.txt - # jwcrypto diff-cover==8.0.3 # via -r requirements/test.in # via @@ -148,7 +145,7 @@ diff-cover==8.0.3 # jsonfield django-cache-memoize==0.2.0 # via -r requirements/test-master.txt -django-config-models==2.5.1 +django-config-models==2.7.0 # via -r requirements/test-master.txt django-countries==7.5.1 # via -r requirements/test-master.txt @@ -162,9 +159,9 @@ django-fernet-fields-v2==0.9 # via -r requirements/test-master.txt django-filter==23.5 # via -r requirements/test-master.txt -django-ipware==6.0.3 +django-ipware==6.0.4 # via -r requirements/test-master.txt -django-model-utils==4.3.1 +django-model-utils==4.4.0 # via # -r requirements/test-master.txt # -r requirements/test.in @@ -203,18 +200,18 @@ drf-yasg==1.21.5 # via # -r requirements/test-master.txt # edx-api-doc-tools -edx-api-doc-tools==1.7.0 +edx-api-doc-tools==1.8.0 # via -r requirements/test-master.txt -edx-braze-client==0.2.1 +edx-braze-client==0.2.2 # via -r requirements/test-master.txt -edx-django-utils==5.10.1 +edx-django-utils==5.12.0 # via # -r requirements/test-master.txt # django-config-models # edx-drf-extensions # edx-rest-api-client # edx-toggles -edx-drf-extensions==10.1.0 +edx-drf-extensions==10.2.0 # via # -r requirements/test-master.txt # edx-rbac @@ -229,13 +226,13 @@ edx-rest-api-client==5.6.1 # via -r requirements/test-master.txt edx-tincan-py35==1.0.0 # via -r requirements/test-master.txt -edx-toggles==5.1.0 +edx-toggles==5.1.1 # via -r requirements/test-master.txt factory-boy==3.3.0 # via # -c requirements/constraints.txt # -r requirements/test.in -faker==22.6.0 +faker==24.7.1 # via factory-boy filelock==3.13.1 # via @@ -276,14 +273,14 @@ jsondiff==2.0.0 # via -r requirements/test-master.txt jsonfield==3.1.0 # via -r requirements/test-master.txt -jwcrypto==1.5.1 +jwcrypto==1.5.4 # via # -r requirements/test-master.txt # django-oauth-toolkit # via # -r requirements/test-master.txt # celery -markupsafe==2.1.4 +markupsafe==2.1.5 # via # -r requirements/test-master.txt # jinja2 @@ -291,7 +288,7 @@ mock==3.0.5 # via # -c requirements/constraints.txt # -r requirements/test.in -multidict==6.0.4 +multidict==6.0.5 # via # -r requirements/test-master.txt # aiohttp @@ -312,7 +309,7 @@ packaging==23.2 # drf-yasg # pytest # snowflake-connector-python -path==16.9.0 +path==16.10.0 # via # -r requirements/test-master.txt # path-py @@ -378,7 +375,7 @@ pytest==6.2.5 # -c requirements/constraints.txt # pytest-cov # pytest-django -pytest-cov==4.1.0 +pytest-cov==5.0.0 # via -r requirements/test.in pytest-django==4.5.2 # via -r requirements/test.in @@ -392,14 +389,13 @@ python-ipware==2.0.1 # via # -r requirements/test-master.txt # django-ipware -python-slugify==8.0.2 +python-slugify==8.0.4 # via # -r requirements/test-master.txt # code-annotations -pytz==2023.3.post1 +pytz==2024.1 # via # -r requirements/test-master.txt - # django # djangorestframework # drf-yasg # edx-tincan-py35 @@ -423,7 +419,7 @@ responses==0.10.15 # via # -c requirements/constraints.txt # -r requirements/test.in -ruamel-yaml==0.18.5 +ruamel-yaml==0.18.6 # via # -r requirements/test-master.txt # drf-yasg @@ -466,7 +462,7 @@ stevedore==5.1.0 # code-annotations # edx-django-utils # edx-opaque-keys -testfixtures==7.2.2 +testfixtures==8.0.0 # via # -r requirements/test-master.txt # -r requirements/test.in @@ -482,7 +478,7 @@ tomlkit==0.12.3 # via # -r requirements/test-master.txt # snowflake-connector-python -tqdm==4.66.1 +tqdm==4.66.2 # via # -r requirements/test-master.txt # openai @@ -493,9 +489,10 @@ typing-extensions==4.9.0 # django-countries # edx-opaque-keys # faker + # jwcrypto # kombu # snowflake-connector-python -tzdata==2023.4 +tzdata==2024.1 # via # -r requirements/test-master.txt # backports-zoneinfo @@ -525,10 +522,6 @@ webencodings==0.5.1 # via # -r requirements/test-master.txt # bleach -wrapt==1.16.0 - # via - # -r requirements/test-master.txt - # deprecated yarl==1.9.4 # via # -r requirements/test-master.txt From b37ea9f537c158bb3df8d8baf19fe3adc3373829 Mon Sep 17 00:00:00 2001 From: Troy Sankey Date: Tue, 9 Apr 2024 11:03:34 -0700 Subject: [PATCH 161/164] fix: linter error --- enterprise/management/commands/monthly_impact_report.py | 3 +-- .../commands/nudge_dormant_enrolled_enterprise_learners.py | 3 +-- integrated_channels/integrated_channel/models.py | 2 +- 3 files changed, 3 insertions(+), 5 deletions(-) diff --git a/enterprise/management/commands/monthly_impact_report.py b/enterprise/management/commands/monthly_impact_report.py index eacd7dce0a..1ba85f7912 100644 --- a/enterprise/management/commands/monthly_impact_report.py +++ b/enterprise/management/commands/monthly_impact_report.py @@ -926,8 +926,7 @@ def get_query_results_from_snowflake(self): try: cs.execute(QUERY) rows = cs.fetchall() - for row in rows: - yield row + yield from rows finally: cs.close() ctx.close() diff --git a/enterprise/management/commands/nudge_dormant_enrolled_enterprise_learners.py b/enterprise/management/commands/nudge_dormant_enrolled_enterprise_learners.py index 87f95cbd5c..396bf9fe09 100644 --- a/enterprise/management/commands/nudge_dormant_enrolled_enterprise_learners.py +++ b/enterprise/management/commands/nudge_dormant_enrolled_enterprise_learners.py @@ -145,8 +145,7 @@ def get_query_results_from_snowflake(self): try: cs.execute(QUERY) rows = cs.fetchall() - for row in rows: - yield row + yield from rows finally: cs.close() ctx.close() diff --git a/integrated_channels/integrated_channel/models.py b/integrated_channels/integrated_channel/models.py index 05ddf5c99e..6bdec9b378 100644 --- a/integrated_channels/integrated_channel/models.py +++ b/integrated_channels/integrated_channel/models.py @@ -269,7 +269,7 @@ def fetch_orphaned_content_audits(self): enterprise_customer=self.enterprise_customer, remote_deleted_at__isnull=True, remote_created_at__isnull=False, - ).filter(~non_existent_catalogs_filter | null_catalogs_filter) + ).filter(~non_existent_catalogs_filter | null_catalogs_filter) # pylint: disable=unsupported-binary-operation def update_content_synced_at(self, action_happened_at, was_successful): """ From a48b6709c1647b4f43b97294a0f874466955f791 Mon Sep 17 00:00:00 2001 From: edX requirements bot Date: Mon, 15 Apr 2024 20:26:34 -0400 Subject: [PATCH 162/164] chore: Updating Python Requirements --- requirements/celery53.txt | 2 +- requirements/ci.txt | 2 +- requirements/dev.txt | 18 +++++++----------- requirements/doc.txt | 13 +++++-------- requirements/edx-platform-constraints.txt | 22 +++++++++++----------- requirements/js_test.txt | 8 +++----- requirements/test-master.txt | 9 ++++----- requirements/test.txt | 17 ++++++----------- 8 files changed, 38 insertions(+), 53 deletions(-) diff --git a/requirements/celery53.txt b/requirements/celery53.txt index 74a9f410ef..b3a9bf658d 100644 --- a/requirements/celery53.txt +++ b/requirements/celery53.txt @@ -4,6 +4,6 @@ celery==5.3.6 click==8.1.7 click-didyoumean==0.3.1 click-repl==0.3.0 -kombu==5.3.6 +kombu==5.3.7 prompt-toolkit==3.0.43 vine==5.1.0 diff --git a/requirements/ci.txt b/requirements/ci.txt index 5e50065c94..33562f47e6 100644 --- a/requirements/ci.txt +++ b/requirements/ci.txt @@ -6,7 +6,7 @@ # distlib==0.3.8 # via virtualenv -filelock==3.13.3 +filelock==3.13.4 # via # tox # virtualenv diff --git a/requirements/dev.txt b/requirements/dev.txt index c6205babfb..028e609557 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -76,7 +76,6 @@ backports-zoneinfo[tzdata]==0.2.1 # -r requirements/doc.txt # -r requirements/test-master.txt # -r requirements/test.txt - # backports-zoneinfo # celery # django # kombu @@ -163,7 +162,7 @@ click-repl==0.3.0 # -r requirements/test-master.txt # -r requirements/test.txt # celery -code-annotations==1.6.0 +code-annotations==1.8.0 # via # -r requirements/doc.txt # -r requirements/test-master.txt @@ -186,7 +185,6 @@ coreschema==0.0.4 coverage[toml]==7.4.4 # via # -r requirements/test.txt - # coverage # pytest-cov cryptography==38.0.4 # via @@ -207,7 +205,7 @@ defusedxml==0.7.1 # -r requirements/test-master.txt # -r requirements/test.txt # djangorestframework-xml -diff-cover==8.0.3 +diff-cover==9.0.0 # via -r requirements/test.txt dill==0.3.8 # via pylint @@ -372,7 +370,7 @@ edx-drf-extensions==10.2.0 # -r requirements/test-master.txt # -r requirements/test.txt # edx-rbac -edx-i18n-tools==1.3.0 +edx-i18n-tools==1.5.0 # via -r requirements/dev.in edx-lint==5.3.6 # via -r requirements/dev.in @@ -382,13 +380,12 @@ edx-opaque-keys[django]==2.5.1 # -r requirements/test-master.txt # -r requirements/test.txt # edx-drf-extensions - # edx-opaque-keys edx-rbac==1.8.0 # via # -r requirements/doc.txt # -r requirements/test-master.txt # -r requirements/test.txt -edx-rest-api-client==5.6.1 +edx-rest-api-client==5.7.0 # via # -r requirements/doc.txt # -r requirements/test-master.txt @@ -398,7 +395,7 @@ edx-tincan-py35==1.0.0 # -r requirements/doc.txt # -r requirements/test-master.txt # -r requirements/test.txt -edx-toggles==5.1.1 +edx-toggles==5.2.0 # via # -r requirements/doc.txt # -r requirements/test-master.txt @@ -408,7 +405,7 @@ factory-boy==3.3.0 # -c requirements/constraints.txt # -r requirements/doc.txt # -r requirements/test.txt -faker==24.7.1 +faker==24.9.0 # via # -r requirements/doc.txt # -r requirements/test.txt @@ -496,7 +493,7 @@ jwcrypto==1.5.4 # -r requirements/test-master.txt # -r requirements/test.txt # django-oauth-toolkit -kombu==5.3.6 +kombu==5.3.7 # via # -r requirements/doc.txt # -r requirements/test-master.txt @@ -661,7 +658,6 @@ pyjwt[crypto]==2.8.0 # drf-jwt # edx-drf-extensions # edx-rest-api-client - # pyjwt # snowflake-connector-python pylint==3.1.0 # via diff --git a/requirements/doc.txt b/requirements/doc.txt index e9dde8a155..c03288db57 100644 --- a/requirements/doc.txt +++ b/requirements/doc.txt @@ -49,7 +49,6 @@ babel==2.14.0 backports-zoneinfo[tzdata]==0.2.1 # via # -r requirements/test-master.txt - # backports-zoneinfo # celery # django # kombu @@ -102,7 +101,7 @@ click-repl==0.3.0 # via # -r requirements/test-master.txt # celery -code-annotations==1.6.0 +code-annotations==1.8.0 # via # -r requirements/test-master.txt # edx-toggles @@ -234,20 +233,19 @@ edx-opaque-keys[django]==2.5.1 # via # -r requirements/test-master.txt # edx-drf-extensions - # edx-opaque-keys edx-rbac==1.8.0 # via -r requirements/test-master.txt -edx-rest-api-client==5.6.1 +edx-rest-api-client==5.7.0 # via -r requirements/test-master.txt edx-tincan-py35==1.0.0 # via -r requirements/test-master.txt -edx-toggles==5.1.1 +edx-toggles==5.2.0 # via -r requirements/test-master.txt factory-boy==3.3.0 # via # -c requirements/constraints.txt # -r requirements/doc.in -faker==24.7.1 +faker==24.9.0 # via factory-boy filelock==3.13.1 # via @@ -294,7 +292,7 @@ jwcrypto==1.5.4 # via # -r requirements/test-master.txt # django-oauth-toolkit -kombu==5.3.6 +kombu==5.3.7 # via # -r requirements/test-master.txt # celery @@ -380,7 +378,6 @@ pyjwt[crypto]==2.8.0 # drf-jwt # edx-drf-extensions # edx-rest-api-client - # pyjwt # snowflake-connector-python pymongo==3.13.0 # via diff --git a/requirements/edx-platform-constraints.txt b/requirements/edx-platform-constraints.txt index 8cea7fb2f6..d35dc09ade 100644 --- a/requirements/edx-platform-constraints.txt +++ b/requirements/edx-platform-constraints.txt @@ -134,7 +134,7 @@ chem==1.2.0 click-plugins==1.1.1 # via celery # via celery -code-annotations==1.6.0 +code-annotations==1.8.0 # via # edx-enterprise # edx-toggles @@ -146,7 +146,7 @@ coreschema==0.0.4 # via # coreapi # drf-yasg -crowdsourcehinter-xblock==0.6 +crowdsourcehinter-xblock==0.7 # via -r requirements/edx/bundled.in cryptography==38.0.4 # via @@ -469,7 +469,7 @@ edx-drf-extensions==10.2.0 # edx-when # edxval # openedx-learning -edx-enterprise==4.15.1 +edx-enterprise==4.15.2 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/kernel.in @@ -477,7 +477,7 @@ edx-event-bus-kafka==5.6.0 # via -r requirements/edx/kernel.in edx-event-bus-redis==0.3.3 # via -r requirements/edx/kernel.in -edx-i18n-tools==1.3.0 +edx-i18n-tools==1.5.0 # via # -r requirements/edx/bundled.in # ora2 @@ -508,7 +508,7 @@ edx-proctoring==4.16.1 # -r requirements/edx/kernel.in # edx-proctoring-proctortrack edx-rbac==1.8.0 -edx-rest-api-client==5.6.1 +edx-rest-api-client==5.7.0 # via # -r requirements/edx/kernel.in # edx-enterprise @@ -522,7 +522,7 @@ edx-submissions==3.7.0 # -r requirements/edx/kernel.in # ora2 edx-tincan-py35==1.0.0 -edx-toggles==5.1.1 +edx-toggles==5.2.0 # via # -r requirements/edx/kernel.in # edx-completion @@ -536,7 +536,7 @@ edx-toggles==5.1.1 # ora2 edx-token-utils==0.2.1 # via -r requirements/edx/kernel.in -edx-when==2.4.0 +edx-when==2.5.0 # via # -r requirements/edx/kernel.in # edx-proctoring @@ -550,7 +550,7 @@ enmerkar==0.7.1 # via enmerkar-underscore enmerkar-underscore==2.2.0 # via -r requirements/edx/kernel.in -event-tracking==2.3.0 +event-tracking==2.4.0 # via # -r requirements/edx/kernel.in # edx-completion @@ -758,7 +758,7 @@ openedx-atlas==0.6.0 # via -r requirements/edx/kernel.in openedx-blockstore==1.4.0 # via -r requirements/edx/kernel.in -openedx-calc==3.0.1 +openedx-calc==3.1.0 # via -r requirements/edx/kernel.in openedx-django-pyfs==3.6.0 # via @@ -1117,7 +1117,7 @@ stevedore==5.1.0 # edx-django-utils # edx-enterprise # edx-opaque-keys -super-csv==3.1.0 +super-csv==3.2.0 # via edx-bulk-grades sympy==1.12 # via openedx-calc @@ -1168,7 +1168,7 @@ urllib3==1.26.18 # py2neo # requests # snowflake-connector-python -user-util==1.0.0 +user-util==1.1.0 # via -r requirements/edx/kernel.in # via # amqp diff --git a/requirements/js_test.txt b/requirements/js_test.txt index 88ec1e4611..24c1609778 100644 --- a/requirements/js_test.txt +++ b/requirements/js_test.txt @@ -26,7 +26,7 @@ glob2==0.7 # via jasmine-core h11==0.14.0 # via wsproto -idna==3.6 +idna==3.7 # via trio importlib-metadata==6.11.0 # via @@ -38,7 +38,7 @@ inflect==7.2.0 # via jaraco-text jaraco-classes==3.4.0 # via -r requirements/js_test.in -jaraco-collections==5.0.0 +jaraco-collections==5.0.1 # via # -r requirements/js_test.in # cherrypy @@ -104,9 +104,7 @@ typing-extensions==4.11.0 # selenium # typeguard urllib3[socks]==2.2.1 - # via - # selenium - # urllib3 + # via selenium wsproto==1.2.0 # via trio-websocket zc-lockfile==3.0.post1 diff --git a/requirements/test-master.txt b/requirements/test-master.txt index 4531ca5dda..021ba539de 100644 --- a/requirements/test-master.txt +++ b/requirements/test-master.txt @@ -83,7 +83,7 @@ click-plugins==1.1.1 # celery click-repl==0.3.0 # via celery -code-annotations==1.6.0 +code-annotations==1.8.0 # via # -c requirements/edx-platform-constraints.txt # -r requirements/base.in @@ -243,7 +243,7 @@ edx-rbac==1.8.0 # via # -c requirements/edx-platform-constraints.txt # -r requirements/base.in -edx-rest-api-client==5.6.1 +edx-rest-api-client==5.7.0 # via # -c requirements/edx-platform-constraints.txt # -r requirements/base.in @@ -251,7 +251,7 @@ edx-tincan-py35==1.0.0 # via # -c requirements/edx-platform-constraints.txt # -r requirements/base.in -edx-toggles==5.1.1 +edx-toggles==5.2.0 # via # -c requirements/edx-platform-constraints.txt # -r requirements/base.in @@ -295,7 +295,7 @@ jwcrypto==1.5.4 # via # -c requirements/edx-platform-constraints.txt # django-oauth-toolkit -kombu==5.3.6 +kombu==5.3.7 # via celery markupsafe==2.1.5 # via @@ -367,7 +367,6 @@ pyjwt[crypto]==2.8.0 # drf-jwt # edx-drf-extensions # edx-rest-api-client - # pyjwt # snowflake-connector-python pymongo==3.13.0 # via diff --git a/requirements/test.txt b/requirements/test.txt index 24cb6717d3..7339904323 100644 --- a/requirements/test.txt +++ b/requirements/test.txt @@ -41,7 +41,6 @@ backports-zoneinfo[tzdata]==0.2.1 # via # -r requirements/test-master.txt # -r requirements/test.in - # backports-zoneinfo # celery # django # kombu @@ -89,7 +88,7 @@ click-plugins==1.1.1 # via # -r requirements/test-master.txt # celery -code-annotations==1.6.0 +code-annotations==1.8.0 # via # -r requirements/test-master.txt # edx-toggles @@ -103,9 +102,7 @@ coreschema==0.0.4 # coreapi # drf-yasg coverage[toml]==7.4.4 - # via - # coverage - # pytest-cov + # via pytest-cov cryptography==38.0.4 # via # -r requirements/test-master.txt @@ -121,7 +118,7 @@ defusedxml==0.7.1 # via # -r requirements/test-master.txt # djangorestframework-xml -diff-cover==8.0.3 +diff-cover==9.0.0 # via -r requirements/test.in # via # -c requirements/common_constraints.txt @@ -219,20 +216,19 @@ edx-opaque-keys[django]==2.5.1 # via # -r requirements/test-master.txt # edx-drf-extensions - # edx-opaque-keys edx-rbac==1.8.0 # via -r requirements/test-master.txt -edx-rest-api-client==5.6.1 +edx-rest-api-client==5.7.0 # via -r requirements/test-master.txt edx-tincan-py35==1.0.0 # via -r requirements/test-master.txt -edx-toggles==5.1.1 +edx-toggles==5.2.0 # via -r requirements/test-master.txt factory-boy==3.3.0 # via # -c requirements/constraints.txt # -r requirements/test.in -faker==24.7.1 +faker==24.9.0 # via factory-boy filelock==3.13.1 # via @@ -356,7 +352,6 @@ pyjwt[crypto]==2.8.0 # drf-jwt # edx-drf-extensions # edx-rest-api-client - # pyjwt # snowflake-connector-python pymongo==3.13.0 # via From 4738bab774ab93a7b4c00afd866f9f762d754c0a Mon Sep 17 00:00:00 2001 From: MueezKhan246 <93375917+MueezKhan246@users.noreply.github.com> Date: Tue, 16 Apr 2024 22:00:20 +0500 Subject: [PATCH 163/164] Encryption fields added to degreed2 config (#2060) * feat: added fields for holding degreed2 encrypted data in database (ENT 8009) --- .../migrations/0026_auto_20240329_1537.py | 24 +++++++++++++++++ .../migrations/0027_auto_20240329_1537.py | 26 +++++++++++++++++++ integrated_channels/degreed2/models.py | 26 +++++++++++++++++++ 3 files changed, 76 insertions(+) create mode 100644 integrated_channels/degreed2/migrations/0026_auto_20240329_1537.py create mode 100644 integrated_channels/degreed2/migrations/0027_auto_20240329_1537.py diff --git a/integrated_channels/degreed2/migrations/0026_auto_20240329_1537.py b/integrated_channels/degreed2/migrations/0026_auto_20240329_1537.py new file mode 100644 index 0000000000..08c673b6b8 --- /dev/null +++ b/integrated_channels/degreed2/migrations/0026_auto_20240329_1537.py @@ -0,0 +1,24 @@ +# Generated by Django 3.2.23 on 2024-03-29 15:37 + +from django.db import migrations +import fernet_fields.fields + + +class Migration(migrations.Migration): + + dependencies = [ + ('degreed2', '0025_delete_historicaldegreed2enterprisecustomerconfiguration'), + ] + + operations = [ + migrations.AddField( + model_name='degreed2enterprisecustomerconfiguration', + name='decrypted_client_id', + field=fernet_fields.fields.EncryptedCharField(blank=True, default='', help_text='The encrypted API Client ID provided to edX by the enterprise customer to be used to make API calls to Degreed on behalf of the customer.', max_length=255, null=True, verbose_name='Encrypted API Client ID'), + ), + migrations.AddField( + model_name='degreed2enterprisecustomerconfiguration', + name='decrypted_client_secret', + field=fernet_fields.fields.EncryptedCharField(blank=True, default='', help_text='The encrypted API Client Secret provided to edX by the enterprise customer to be used to make API calls to Degreed on behalf of the customer.', max_length=255, null=True, verbose_name='Encrypted API Client Secret'), + ), + ] diff --git a/integrated_channels/degreed2/migrations/0027_auto_20240329_1537.py b/integrated_channels/degreed2/migrations/0027_auto_20240329_1537.py new file mode 100644 index 0000000000..d43287f0ef --- /dev/null +++ b/integrated_channels/degreed2/migrations/0027_auto_20240329_1537.py @@ -0,0 +1,26 @@ +# Generated by Django 3.2.23 on 2024-03-29 15:37 + +from django.db import migrations + + +def populate_decrypted_fields(apps, schema_editor): + """ + Populates the encryption fields with the data previously stored in database. + """ + Degreed2EnterpriseCustomerConfiguration = apps.get_model('degreed2', 'Degreed2EnterpriseCustomerConfiguration') + + for degreed2_enterprise_configuration in Degreed2EnterpriseCustomerConfiguration.objects.all(): + degreed2_enterprise_configuration.decrypted_client_id = degreed2_enterprise_configuration.client_id + degreed2_enterprise_configuration.decrypted_client_secret = degreed2_enterprise_configuration.client_secret + degreed2_enterprise_configuration.save() + + +class Migration(migrations.Migration): + + dependencies = [ + ('degreed2', '0026_auto_20240329_1537'), + ] + + operations = [ + migrations.RunPython(populate_decrypted_fields, reverse_code=migrations.RunPython.noop), + ] diff --git a/integrated_channels/degreed2/models.py b/integrated_channels/degreed2/models.py index 01a4d17dd6..41367b74c1 100644 --- a/integrated_channels/degreed2/models.py +++ b/integrated_channels/degreed2/models.py @@ -6,6 +6,8 @@ import json from logging import getLogger +from fernet_fields import EncryptedCharField + from django.db import models from integrated_channels.degreed2.exporters.content_metadata import Degreed2ContentMetadataExporter @@ -39,6 +41,18 @@ class Degreed2EnterpriseCustomerConfiguration(EnterpriseCustomerPluginConfigurat ) ) + decrypted_client_id = EncryptedCharField( + max_length=255, + blank=True, + default='', + verbose_name="Encrypted API Client ID", + help_text=( + "The encrypted API Client ID provided to edX by the enterprise customer to be used to make API " + "calls to Degreed on behalf of the customer." + ), + null=True + ) + client_secret = models.CharField( max_length=255, blank=True, @@ -50,6 +64,18 @@ class Degreed2EnterpriseCustomerConfiguration(EnterpriseCustomerPluginConfigurat ) ) + decrypted_client_secret = EncryptedCharField( + max_length=255, + blank=True, + default='', + verbose_name="Encrypted API Client Secret", + help_text=( + "The encrypted API Client Secret provided to edX by the enterprise customer to be used to make API " + "calls to Degreed on behalf of the customer." + ), + null=True + ) + degreed_base_url = models.CharField( max_length=255, blank=True, From 43ac63e977b732f1ee03d7f8ae283336d69f6b30 Mon Sep 17 00:00:00 2001 From: MueezKhan246 <93375917+MueezKhan246@users.noreply.github.com> Date: Wed, 17 Apr 2024 06:56:57 +0500 Subject: [PATCH 164/164] Degreed replacing encrypted fields with non encrypted (#2063) * feat: replacing non encrypted fields of degreed config model with encrypted ones --- CHANGELOG.rst | 4 ++ enterprise/__init__.py | 2 +- .../api/v1/degreed2/serializers.py | 6 ++ integrated_channels/degreed2/client.py | 5 +- integrated_channels/degreed2/models.py | 69 +++++++++++++++++++ 5 files changed, 83 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 2335dddf9d..6723a4619d 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -15,6 +15,10 @@ Change Log Unreleased ---------- +[4.15.3] +-------- +* feat: replacing non encrypted fields of degreed config model with encrypted ones + [4.15.2] -------- * feat: save cornerstone learner's information received from frontend. diff --git a/enterprise/__init__.py b/enterprise/__init__.py index b2368fa707..0612e907e0 100644 --- a/enterprise/__init__.py +++ b/enterprise/__init__.py @@ -2,4 +2,4 @@ Your project description goes here. """ -__version__ = "4.15.2" +__version__ = "4.15.3" diff --git a/integrated_channels/api/v1/degreed2/serializers.py b/integrated_channels/api/v1/degreed2/serializers.py index 914a2c8a40..d93213793a 100644 --- a/integrated_channels/api/v1/degreed2/serializers.py +++ b/integrated_channels/api/v1/degreed2/serializers.py @@ -1,6 +1,8 @@ """ Serializer for Degreed2 configuration. """ +from rest_framework import serializers + from integrated_channels.api.serializers import EnterpriseCustomerPluginConfigSerializer from integrated_channels.degreed2.models import Degreed2EnterpriseCustomerConfiguration @@ -11,7 +13,11 @@ class Meta: extra_fields = ( 'client_id', 'client_secret', + 'encrypted_client_id', + 'encrypted_client_secret', 'degreed_base_url', 'degreed_token_fetch_base_url', ) fields = EnterpriseCustomerPluginConfigSerializer.Meta.fields + extra_fields + encrypted_client_id = serializers.CharField(required=False, allow_blank=False, read_only=False) + encrypted_client_secret = serializers.CharField(required=False, allow_blank=False, read_only=False) diff --git a/integrated_channels/degreed2/client.py b/integrated_channels/degreed2/client.py index b495f5e447..768bc00d10 100644 --- a/integrated_channels/degreed2/client.py +++ b/integrated_channels/degreed2/client.py @@ -674,11 +674,12 @@ def _get_oauth_access_token(self, scope): """ config = self.enterprise_configuration url = self.get_oauth_url() + use_encrypted_user_data = getattr(settings, 'FEATURES', {}).get('USE_ENCRYPTED_USER_DATA', False) data = { 'grant_type': 'client_credentials', 'scope': scope, - 'client_id': config.client_id, - 'client_secret': config.client_secret, + 'client_id': config.decrypted_client_id if use_encrypted_user_data else config.client_id, + 'client_secret': config.decrypted_client_secret if use_encrypted_user_data else config.client_secret, } start_time = time.time() response = requests.post( diff --git a/integrated_channels/degreed2/models.py b/integrated_channels/degreed2/models.py index 41367b74c1..b148b18f8e 100644 --- a/integrated_channels/degreed2/models.py +++ b/integrated_channels/degreed2/models.py @@ -9,6 +9,7 @@ from fernet_fields import EncryptedCharField from django.db import models +from django.utils.encoding import force_bytes, force_str from integrated_channels.degreed2.exporters.content_metadata import Degreed2ContentMetadataExporter from integrated_channels.degreed2.exporters.learner_data import Degreed2LearnerExporter @@ -53,6 +54,40 @@ class Degreed2EnterpriseCustomerConfiguration(EnterpriseCustomerPluginConfigurat null=True ) + decrypted_client_id = EncryptedCharField( + max_length=255, + blank=True, + default='', + verbose_name="Encrypted API Client ID", + help_text=( + "The encrypted API Client ID provided to edX by the enterprise customer to be used to make API " + "calls to Degreed on behalf of the customer." + ), + null=True + ) + + @property + def encrypted_client_id(self): + """ + Return encrypted client_id as a string. + The data is encrypted in the DB at rest, but is unencrypted in the app when retrieved through the + decrypted_client_id field. This method will encrypt the client_id again before sending. + """ + if self.decrypted_client_id: + return force_str( + self._meta.get_field('decrypted_client_id').fernet.encrypt( + force_bytes(self.decrypted_client_id) + ) + ) + return self.decrypted_client_id + + @encrypted_client_id.setter + def encrypted_client_id(self, value): + """ + Set the encrypted client_id. + """ + self.decrypted_client_id = value + client_secret = models.CharField( max_length=255, blank=True, @@ -76,6 +111,40 @@ class Degreed2EnterpriseCustomerConfiguration(EnterpriseCustomerPluginConfigurat null=True ) + decrypted_client_secret = EncryptedCharField( + max_length=255, + blank=True, + default='', + verbose_name="Encrypted API Client Secret", + help_text=( + "The encrypted API Client Secret provided to edX by the enterprise customer to be used to make API " + "calls to Degreed on behalf of the customer." + ), + null=True + ) + + @property + def encrypted_client_secret(self): + """ + Return encrypted client_secret as a string. + The data is encrypted in the DB at rest, but is unencrypted in the app when retrieved through the + decrypted_client_secret field. This method will encrypt the client_secret again before sending. + """ + if self.decrypted_client_secret: + return force_str( + self._meta.get_field('decrypted_client_secret').fernet.encrypt( + force_bytes(self.decrypted_client_secret) + ) + ) + return self.decrypted_client_secret + + @encrypted_client_secret.setter + def encrypted_client_secret(self, value): + """ + Set the encrypted client_secret. + """ + self.decrypted_client_secret = value + degreed_base_url = models.CharField( max_length=255, blank=True,