From 48df722785f920a63d45397da1053d69376190f7 Mon Sep 17 00:00:00 2001 From: hamzawaleed01 Date: Fri, 22 Sep 2023 16:34:50 +0500 Subject: [PATCH 01/12] feat: hide course on moodle instead of true delete --- CHANGELOG.rst | 12 + .../0012-enterprise-feature-flags-waffle.rst | 43 ++ enterprise/__init__.py | 2 +- enterprise/admin/__init__.py | 4 +- enterprise/admin/forms.py | 2 + enterprise/api/pagination.py | 31 + enterprise/api/v1/serializers.py | 3 +- .../api/v1/views/enterprise_customer.py | 2 + .../migrations/0185_auto_20230921_1007.py | 33 ++ enterprise/models.py | 15 + enterprise/toggles.py | 36 ++ enterprise/utils.py | 34 +- integrated_channels/moodle/client.py | 19 +- requirements/base.in | 3 +- requirements/ci.txt | 4 +- requirements/common_constraints.txt | 3 + requirements/dev.txt | 44 +- requirements/doc.txt | 35 +- requirements/edx-platform-constraints.txt | 41 +- requirements/js_test.txt | 2 +- requirements/test-master.txt | 31 +- requirements/test.txt | 32 +- test_utils/factories.py | 1 + tests/test_enterprise/api/test_filters.py | 14 +- tests/test_enterprise/api/test_views.py | 431 +++++++------- tests/test_enterprise/test_utils.py | 549 ++++++++---------- .../test_moodle/test_client.py | 56 +- tests/test_utilities.py | 2 + 28 files changed, 888 insertions(+), 596 deletions(-) create mode 100644 docs/decisions/0012-enterprise-feature-flags-waffle.rst create mode 100644 enterprise/migrations/0185_auto_20230921_1007.py create mode 100644 enterprise/toggles.py diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 7a5838c58a..18e791ccbf 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -15,6 +15,18 @@ Change Log Unreleased ---------- +[4.3.1] +-------- +chore: use lms_update_or_create_enrollment without feature flag + +[4.3.0] +-------- +feat: Added the ``enable_career_engagement_network_on_learner_portal`` field for EnterpriseCustomer + +[4.2.0] +-------- +feat: create generic ``PaginationWithFeatureFlags`` to add a ``features`` property to DRF's default pagination response containing Waffle-based feature flags. +feat: integrate ``PaginationWithFeatureFlags`` with ``EnterpriseCustomerViewSet``. [4.1.15] -------- diff --git a/docs/decisions/0012-enterprise-feature-flags-waffle.rst b/docs/decisions/0012-enterprise-feature-flags-waffle.rst new file mode 100644 index 0000000000..9e2d7cd226 --- /dev/null +++ b/docs/decisions/0012-enterprise-feature-flags-waffle.rst @@ -0,0 +1,43 @@ +Waffle-based feature flags for Enterprise +========================================= + +Status +------ + +Accepted (September 2023) + +Context +------- + +Enterprise typically uses environment configuration to control feature flags for soft/dark releases. For micro-frontends, this control involves environment variables that are set at build time and used to compile JavaScript source or configuration settings derived from the runtime MFE configuration API in edx-platform. For backend APIs, this control typically either involves configuration settings or, on occasion, a Waffle flag. + +By solely relying on environment configuration, we are unable to dynamically control feature flags in production based on the user's context. + +For example, we may want to enable a feature for all staff users but keep it disabled for customers/users while it's in development. Similarly, we may want to enable a feature for a subset of specific users (e.g., members of a specific engineering squad) in production to QA before enabling it for all users. + +However, neither of these are really possible with environment configuration. + + +Decisions +--------- + +We will adopt the Waffle-based approach to feature flags for Enterprise micro-frontends in favor of environment variables or the MFE runtime configuration API. This approach will allow us to have more dynamic and granual control over feature flags in production based on the user's context (e.g., all staff users have a feature enabled, a subset of users have a feature enabled, etc.). + + +Consequences +------------ + +* We are introducing a third mechanism by which we control feature flags in the Enterprise micro-frontends. We may want to consider migrating other feature flags to this Waffle-based approach in the future. Similarly, such an exercise may be a good opportunity to revisit what feature flags exist today and what can be removed now that the associate features are stable in production. +* The majority of the feature flag setup for Enterprise systems lives in configuration settings. By moving more towards Waffle, the feature flag setup will live in databases and modified via Django Admin instead of via code changes. + + +Further Improvements +-------------------- + +* To further expand on the capabilities of Waffle-based feature flags, we may want to invest in the ability to enable such feature flags at the **enterprise customer** layer, where we may be able to enable soft-launch features for all users linked to an enterprise customer without needing to introduce new boolean fields on the ``EnterpriseCustomer`` model. + +Alternatives Considered +----------------------- + +* Continue using the environment variable to enabling feature flags like Enterprise has been doing the past few years. In order to test a disabled feature in production, this approach requires developers to allow the environment variable to be intentionally overridden by a `?feature=` query parameter in the URL. Without the query parameter in the URL, there is no alternative ways to temporarily enable the feature without impacting actual users and customers. Waffle-based feature flags give much more flexibility to developers to test features in production without impacting actual users and customers. +* Exposes a net-new API endpoint specific to returning feature flags for Enterprise needs. This approach was not adopted as it would require new API integrations within both the enterprise administrator and learner portal micro-frontends, requiring users to wait for additional network requests to resolve. Instead, we are preferring to include Waffle-based feature flags on existing API endpoints made by both micro-frontends. diff --git a/enterprise/__init__.py b/enterprise/__init__.py index 68827b1fe3..27abfebdc4 100644 --- a/enterprise/__init__.py +++ b/enterprise/__init__.py @@ -2,4 +2,4 @@ Your project description goes here. """ -__version__ = "4.1.15" +__version__ = "4.3.1" diff --git a/enterprise/admin/__init__.py b/enterprise/admin/__init__.py index f2dd11d9c6..2d3cc8baa7 100644 --- a/enterprise/admin/__init__.py +++ b/enterprise/admin/__init__.py @@ -226,7 +226,9 @@ class EnterpriseCustomerAdmin(DjangoObjectActions, SimpleHistoryAdmin): 'enable_integrated_customer_learner_portal_search', 'enable_analytics_screen', 'enable_audit_enrollment', 'enable_audit_data_reporting', 'enable_learner_portal_offers', - 'enable_executive_education_2U_fulfillment'), + 'enable_executive_education_2U_fulfillment', + 'enable_career_engagement_network_on_learner_portal', + 'career_engagement_network_message'), '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 288a6b65d6..0d9474be54 100644 --- a/enterprise/admin/forms.py +++ b/enterprise/admin/forms.py @@ -403,6 +403,8 @@ class Meta: "enable_executive_education_2U_fulfillment", "hide_labor_market_data", "enable_integrated_customer_learner_portal_search", + "enable_career_engagement_network_on_learner_portal", + "career_engagement_network_message", "enable_analytics_screen", "enable_portal_reporting_config_screen", "enable_portal_saml_configuration_screen", diff --git a/enterprise/api/pagination.py b/enterprise/api/pagination.py index 3b12f1a69d..f8febfe01f 100644 --- a/enterprise/api/pagination.py +++ b/enterprise/api/pagination.py @@ -5,8 +5,39 @@ from collections import OrderedDict from urllib.parse import urlparse +from edx_rest_framework_extensions.paginators import DefaultPagination from rest_framework.response import Response +from enterprise.toggles import enterprise_features + + +class PaginationWithFeatureFlags(DefaultPagination): + """ + Adds a ``features`` dictionary to the default paginated response + provided by edx_rest_framework_extensions. The ``features`` dict + represents a collection of Waffle-based feature flags/samples/switches + that may be used to control whether certain aspects of the system are + enabled or disabled (e.g., feature flag turned on for all staff users but + not turned on for real customers/learners). + """ + + def get_paginated_response(self, data): + """ + Modifies the default paginated response to include ``enterprise_features`` dict. + + Arguments: + self: PaginationWithFeatureFlags instance. + data (dict): Results for current page. + + Returns: + (Response): DRF response object containing ``enterprise_features`` dict. + """ + paginated_response = super().get_paginated_response(data) + paginated_response.data.update({ + 'enterprise_features': enterprise_features(), + }) + return paginated_response + def get_paginated_response(data, request): """ diff --git a/enterprise/api/v1/serializers.py b/enterprise/api/v1/serializers.py index c7b83dbee2..36ecd555e0 100644 --- a/enterprise/api/v1/serializers.py +++ b/enterprise/api/v1/serializers.py @@ -220,7 +220,8 @@ class Meta: 'enable_integrated_customer_learner_portal_search', 'enable_generation_of_api_credentials', 'enable_portal_lms_configurations_screen', 'sender_alias', 'identity_providers', 'enterprise_customer_catalogs', 'reply_to', 'enterprise_notification_banner', 'hide_labor_market_data', - 'modified', 'enable_universal_link', 'enable_browse_and_request', 'admin_users' + 'modified', 'enable_universal_link', 'enable_browse_and_request', 'admin_users', + 'enable_career_engagement_network_on_learner_portal', 'career_engagement_network_message' ) identity_providers = EnterpriseCustomerIdentityProviderSerializer(many=True, read_only=True) diff --git a/enterprise/api/v1/views/enterprise_customer.py b/enterprise/api/v1/views/enterprise_customer.py index 097e17cca8..1685304d17 100644 --- a/enterprise/api/v1/views/enterprise_customer.py +++ b/enterprise/api/v1/views/enterprise_customer.py @@ -26,6 +26,7 @@ from enterprise import models from enterprise.api.filters import EnterpriseLinkedUserFilterBackend +from enterprise.api.pagination import PaginationWithFeatureFlags from enterprise.api.throttles import HighServiceUserThrottle from enterprise.api.v1 import serializers from enterprise.api.v1.decorators import require_at_least_one_query_parameter @@ -54,6 +55,7 @@ class EnterpriseCustomerViewSet(EnterpriseReadWriteModelViewSet): queryset = models.EnterpriseCustomer.active_customers.all() serializer_class = serializers.EnterpriseCustomerSerializer filter_backends = EnterpriseReadWriteModelViewSet.filter_backends + (EnterpriseLinkedUserFilterBackend,) + pagination_class = PaginationWithFeatureFlags USER_ID_FILTER = 'enterprise_customer_users__user_id' FIELDS = ( diff --git a/enterprise/migrations/0185_auto_20230921_1007.py b/enterprise/migrations/0185_auto_20230921_1007.py new file mode 100644 index 0000000000..5a190399f9 --- /dev/null +++ b/enterprise/migrations/0185_auto_20230921_1007.py @@ -0,0 +1,33 @@ +# Generated by Django 3.2.21 on 2023-09-21 10:07 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('enterprise', '0184_auto_20230914_2057'), + ] + + operations = [ + migrations.AddField( + model_name='enterprisecustomer', + name='career_engagement_network_message', + field=models.TextField(blank=True, help_text='Message text shown on the learner portal dashboard for career engagement network.'), + ), + migrations.AddField( + model_name='enterprisecustomer', + name='enable_career_engagement_network_on_learner_portal', + field=models.BooleanField(default=False, help_text='If checked, the learners will be able to see the link to CEN on the learner portal dashboard.', verbose_name='Allow navigation to career engagement network from learner portal dashboard'), + ), + migrations.AddField( + model_name='historicalenterprisecustomer', + name='career_engagement_network_message', + field=models.TextField(blank=True, help_text='Message text shown on the learner portal dashboard for career engagement network.'), + ), + migrations.AddField( + model_name='historicalenterprisecustomer', + name='enable_career_engagement_network_on_learner_portal', + field=models.BooleanField(default=False, help_text='If checked, the learners will be able to see the link to CEN on the learner portal dashboard.', verbose_name='Allow navigation to career engagement network from learner portal dashboard'), + ), + ] diff --git a/enterprise/models.py b/enterprise/models.py index 59ddc0bf69..9803282c6b 100644 --- a/enterprise/models.py +++ b/enterprise/models.py @@ -366,6 +366,14 @@ class Meta: ) ) + enable_career_engagement_network_on_learner_portal = models.BooleanField( + verbose_name="Allow navigation to career engagement network from learner portal dashboard", + default=False, + help_text=_( + "If checked, the learners will be able to see the link to CEN on the learner portal dashboard." + ) + ) + enable_analytics_screen = models.BooleanField( verbose_name="Display analytics page", default=True, @@ -445,6 +453,13 @@ class Meta: default=False, ) + career_engagement_network_message = models.TextField( + blank=True, + help_text=_( + 'Message text shown on the learner portal dashboard for career engagement network.' + ), + ) + @property def enterprise_customer_identity_provider(self): """ diff --git a/enterprise/toggles.py b/enterprise/toggles.py new file mode 100644 index 0000000000..70b00233b3 --- /dev/null +++ b/enterprise/toggles.py @@ -0,0 +1,36 @@ +""" +Waffle toggles for enterprise features within the LMS. +""" + +from edx_toggles.toggles import WaffleFlag + +ENTERPRISE_NAMESPACE = 'enterprise' +ENTERPRISE_LOG_PREFIX = 'Enterprise: ' + +# .. toggle_name: enterprise.TOP_DOWN_ASSIGNMENT_REAL_TIME_LCM +# .. toggle_implementation: WaffleFlag +# .. toggle_default: False +# .. toggle_description: Enables top-down assignment +# .. toggle_use_cases: open_edx +# .. toggle_creation_date: 2023-09-15 +TOP_DOWN_ASSIGNMENT_REAL_TIME_LCM = WaffleFlag( + f'{ENTERPRISE_NAMESPACE}.top_down_assignment_real_time_lcm', + __name__, + ENTERPRISE_LOG_PREFIX, +) + + +def top_down_assignment_real_time_lcm(): + """ + Returns whether top-down assignment and real time LCM feature flag is enabled. + """ + return TOP_DOWN_ASSIGNMENT_REAL_TIME_LCM.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(), + } diff --git a/enterprise/utils.py b/enterprise/utils.py index 2ca8e4ffa5..c278f9a4fa 100644 --- a/enterprise/utils.py +++ b/enterprise/utils.py @@ -46,12 +46,8 @@ from enterprise.logging import getEnterpriseLogger try: - from openedx.features.enterprise_support.enrollments.utils import ( - lms_enroll_user_in_course, - lms_update_or_create_enrollment, - ) + from openedx.features.enterprise_support.enrollments.utils import lms_update_or_create_enrollment except ImportError: - lms_enroll_user_in_course = None lms_update_or_create_enrollment = None try: @@ -1824,30 +1820,16 @@ def customer_admin_enroll_user_with_status( succeeded = False new_enrollment = False enterprise_fulfillment_source_uuid = None - emet_enable_auto_upgrade_enrollment_mode = getattr( - settings, - 'ENABLE_ENTERPRISE_BACKEND_EMET_AUTO_UPGRADE_ENROLLMENT_MODE', - False, - ) try: # enrolls a user in a course per LMS flow, but this method doesn't create enterprise records # yet so we need to create it immediately after calling lms_update_or_create_enrollment. - if emet_enable_auto_upgrade_enrollment_mode: - new_enrollment = lms_update_or_create_enrollment( - user.username, - course_id, - course_mode, - is_active=True, - enterprise_uuid=enterprise_customer.uuid, - ) - else: - new_enrollment = lms_enroll_user_in_course( - user.username, - course_id, - course_mode, - enterprise_customer.uuid, - is_active=True, - ) + new_enrollment = lms_update_or_create_enrollment( + user.username, + course_id, + course_mode, + is_active=True, + enterprise_uuid=enterprise_customer.uuid, + ) succeeded = True LOGGER.info("Successfully enrolled user %s in course %s", user.id, course_id) except (CourseEnrollmentError, CourseUserGroup.DoesNotExist) as error: diff --git a/integrated_channels/moodle/client.py b/integrated_channels/moodle/client.py index dbd7d405ae..799f09ed20 100644 --- a/integrated_channels/moodle/client.py +++ b/integrated_channels/moodle/client.py @@ -354,8 +354,17 @@ def create_content_metadata(self, serialized_data): # check to see if more than 1 course is being passed more_than_one_course = serialized_data.get('courses[1][shortname]') serialized_data['wsfunction'] = 'core_course_create_courses' + LOGGER.info("CREATING:") try: - response = self._wrapped_post(serialized_data) + moodle_course_id = self._get_course_id(serialized_data['courses[0][idnumber]']) + # Course already exists but is hidden - make it visible + if(moodle_course_id): + LOGGER.info("Existing course found - updating it now") + serialized_data['courses[0][visible]'] = 1 + return self.update_content_metadata(serialized_data) + else: # create a new course + LOGGER.info("No existing course found - creating it now") + response = self._wrapped_post(serialized_data) except MoodleClientError as error: # treat duplicate as successful, but only if its a single course # set chunk size settings to 1 if youre seeing a lot of these errors @@ -369,6 +378,8 @@ def create_content_metadata(self, serialized_data): def update_content_metadata(self, serialized_data): moodle_course_id = self._get_course_id(serialized_data['courses[0][idnumber]']) + LOGGER.info("UPDATING:") + # if we cannot find the course, lets create it if moodle_course_id: serialized_data['courses[0][id]'] = moodle_course_id @@ -381,6 +392,7 @@ def update_content_metadata(self, serialized_data): def delete_content_metadata(self, serialized_data): response = self._get_courses(serialized_data['courses[0][idnumber]']) parsed_response = json.loads(response.text) + LOGGER.info("DELETING:") if not parsed_response.get('courses'): LOGGER.info( generate_formatted_log( @@ -398,8 +410,9 @@ def delete_content_metadata(self, serialized_data): return rsp moodle_course_id = parsed_response['courses'][0]['id'] params = { - 'wsfunction': 'core_course_delete_courses', - 'courseids[]': moodle_course_id + 'wsfunction': 'core_course_update_courses', + 'courses[0][id]': moodle_course_id, + 'courses[0][visible]': 0 # hide a course rather than doing a true delete } response = self._wrapped_post(params) return response.status_code, response.text diff --git a/requirements/base.in b/requirements/base.in index 6cda181f55..b54f17646e 100644 --- a/requirements/base.in +++ b/requirements/base.in @@ -26,8 +26,9 @@ edx-django-utils>=3.12.0 edx-drf-extensions edx-opaque-keys[django] edx-rest-api-client -edx-tincan-py35 edx-rbac +edx-tincan-py35 +edx-toggles jsondiff jsonfield path.py diff --git a/requirements/ci.txt b/requirements/ci.txt index db667cb2e9..cca733262c 100644 --- a/requirements/ci.txt +++ b/requirements/ci.txt @@ -6,7 +6,7 @@ # distlib==0.3.7 # via virtualenv -filelock==3.12.3 +filelock==3.12.4 # via # tox # virtualenv @@ -30,7 +30,5 @@ tox==3.28.0 # tox-battery tox-battery==0.6.1 # via -r requirements/ci.in -typing-extensions==4.7.1 - # via filelock virtualenv==20.24.5 # via tox diff --git a/requirements/common_constraints.txt b/requirements/common_constraints.txt index 08e94f34dd..601b0ae550 100644 --- a/requirements/common_constraints.txt +++ b/requirements/common_constraints.txt @@ -1,4 +1,7 @@ + + + # 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 651ca9ee59..f1c86e6f3a 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -95,7 +95,6 @@ bleach==6.0.0 # -r requirements/test-master.txt # -r requirements/test.txt build==1.0.3 - # readme-renderer # via pip-tools celery==5.3.4 # via @@ -170,6 +169,7 @@ code-annotations==1.5.0 # -r requirements/test-master.txt # -r requirements/test.txt # edx-lint + # edx-toggles coverage[toml]==7.3.1 # via # -r requirements/test.txt @@ -180,6 +180,7 @@ cryptography==38.0.4 # -r requirements/test-master.txt # -r requirements/test.txt # django-fernet-fields-v2 + # jwcrypto # pgpy # pyjwt # pyopenssl @@ -192,6 +193,12 @@ 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==7.7.0 # via -r requirements/test.txt dill==0.3.7 @@ -218,6 +225,7 @@ django==3.2.21 # edx-drf-extensions # edx-i18n-tools # edx-rbac + # edx-toggles # jsonfield django-cache-memoize==0.1.10 # via @@ -241,6 +249,7 @@ django-crum==0.7.9 # -r requirements/test.txt # edx-django-utils # edx-rbac + # edx-toggles django-fernet-fields-v2==0.9 # via # -r requirements/doc.txt @@ -251,7 +260,7 @@ django-filter==23.2 # -r requirements/doc.txt # -r requirements/test-master.txt # -r requirements/test.txt -django-ipware==4.0.2 +django-ipware==5.0.0 # via # -r requirements/doc.txt # -r requirements/test-master.txt @@ -267,12 +276,12 @@ django-multi-email-field==0.7.0 # -r requirements/doc.txt # -r requirements/test-master.txt # -r requirements/test.txt -django-oauth-toolkit==1.4.1 +django-oauth-toolkit==1.5.0 # via # -r requirements/doc.txt # -r requirements/test-master.txt # -r requirements/test.txt -django-object-actions==4.1.0 +django-object-actions==4.2.0 # via # -r requirements/doc.txt # -r requirements/test-master.txt @@ -290,6 +299,7 @@ django-waffle==4.0.0 # -r requirements/test.txt # edx-django-utils # edx-drf-extensions + # edx-toggles djangorestframework==3.14.0 # via # -r requirements/doc.txt @@ -332,13 +342,14 @@ edx-django-utils==5.7.0 # django-config-models # edx-drf-extensions # edx-rest-api-client + # edx-toggles edx-drf-extensions==8.9.2 # via # -r requirements/doc.txt # -r requirements/test-master.txt # -r requirements/test.txt # edx-rbac -edx-i18n-tools==1.1.0 +edx-i18n-tools==1.2.0 # via -r requirements/dev.in edx-lint==5.3.4 # via -r requirements/dev.in @@ -363,6 +374,11 @@ edx-tincan-py35==1.0.0 # -r requirements/doc.txt # -r requirements/test-master.txt # -r requirements/test.txt +edx-toggles==5.1.0 + # via + # -r requirements/doc.txt + # -r requirements/test-master.txt + # -r requirements/test.txt factory-boy==3.3.0 # via # -c requirements/constraints.txt @@ -436,6 +452,12 @@ jsonfield==3.1.0 # -r requirements/doc.txt # -r requirements/test-master.txt # -r requirements/test.txt +jwcrypto==1.5.0 + # via + # -r requirements/doc.txt + # -r requirements/test-master.txt + # -r requirements/test.txt + # django-oauth-toolkit kombu==5.3.2 # via # -r requirements/doc.txt @@ -588,7 +610,7 @@ pycryptodomex==3.18.0 # -r requirements/test-master.txt # -r requirements/test.txt # snowflake-connector-python -pydata-sphinx-theme==0.13.3 +pydata-sphinx-theme==0.14.0 # via # -r requirements/doc.txt # sphinx-book-theme @@ -734,6 +756,7 @@ six==1.16.0 # -r requirements/test-master.txt # -r requirements/test.txt # bleach + # django-oauth-toolkit # edx-drf-extensions # edx-lint # edx-rbac @@ -753,7 +776,7 @@ snowballstemmer==2.2.0 # -r requirements/doc.txt # pydocstyle # sphinx -snowflake-connector-python==3.1.1 +snowflake-connector-python==3.2.0 # via # -r requirements/doc.txt # -r requirements/test-master.txt @@ -927,7 +950,12 @@ wheel==0.41.2 # -r requirements/dev.in # pip-tools wrapt==1.15.0 - # via astroid + # via + # -r requirements/doc.txt + # -r requirements/test-master.txt + # -r requirements/test.txt + # astroid + # deprecated yarl==1.9.2 # via # -r requirements/doc.txt diff --git a/requirements/doc.txt b/requirements/doc.txt index cc8d5326f7..79578958ea 100644 --- a/requirements/doc.txt +++ b/requirements/doc.txt @@ -60,7 +60,6 @@ billiard==4.1.0 # celery bleach==6.0.0 # via -r requirements/test-master.txt - # readme-renderer celery==5.3.4 # via # -c requirements/constraints.txt @@ -104,11 +103,14 @@ click-repl==0.3.0 # -r requirements/test-master.txt # celery code-annotations==1.5.0 - # via -r requirements/test-master.txt + # via + # -r requirements/test-master.txt + # edx-toggles cryptography==38.0.4 # via # -r requirements/test-master.txt # django-fernet-fields-v2 + # jwcrypto # pgpy # pyjwt # pyopenssl @@ -117,6 +119,10 @@ 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.21 # via # -c requirements/common_constraints.txt @@ -134,6 +140,7 @@ django==3.2.21 # edx-django-utils # edx-drf-extensions # edx-rbac + # edx-toggles # jsonfield django-cache-memoize==0.1.10 # via -r requirements/test-master.txt @@ -146,11 +153,12 @@ django-crum==0.7.9 # -r requirements/test-master.txt # edx-django-utils # edx-rbac + # edx-toggles django-fernet-fields-v2==0.9 # via -r requirements/test-master.txt django-filter==23.2 # via -r requirements/test-master.txt -django-ipware==4.0.2 +django-ipware==5.0.0 # via -r requirements/test-master.txt django-model-utils==4.3.1 # via @@ -158,9 +166,9 @@ 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.4.1 +django-oauth-toolkit==1.5.0 # via -r requirements/test-master.txt -django-object-actions==4.1.0 +django-object-actions==4.2.0 # via -r requirements/test-master.txt django-simple-history==3.1.1 # via @@ -171,6 +179,7 @@ django-waffle==4.0.0 # -r requirements/test-master.txt # edx-django-utils # edx-drf-extensions + # edx-toggles djangorestframework==3.14.0 # via # -r requirements/test-master.txt @@ -201,6 +210,7 @@ edx-django-utils==5.7.0 # django-config-models # edx-drf-extensions # edx-rest-api-client + # edx-toggles edx-drf-extensions==8.9.2 # via # -r requirements/test-master.txt @@ -215,6 +225,8 @@ 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 +edx-toggles==5.1.0 + # via -r requirements/test-master.txt factory-boy==3.3.0 # via # -c requirements/constraints.txt @@ -251,6 +263,10 @@ jsondiff==2.0.0 # via -r requirements/test-master.txt jsonfield==3.1.0 # via -r requirements/test-master.txt +jwcrypto==1.5.0 + # via + # -r requirements/test-master.txt + # django-oauth-toolkit kombu==5.3.2 # via # -r requirements/test-master.txt @@ -329,7 +345,7 @@ pycryptodomex==3.18.0 # via # -r requirements/test-master.txt # snowflake-connector-python -pydata-sphinx-theme==0.13.3 +pydata-sphinx-theme==0.14.0 # via sphinx-book-theme pygments==2.16.1 # via @@ -407,6 +423,7 @@ six==1.16.0 # via # -r requirements/test-master.txt # bleach + # django-oauth-toolkit # edx-drf-extensions # edx-rbac # python-dateutil @@ -416,7 +433,7 @@ slumber==0.7.1 # edx-rest-api-client snowballstemmer==2.2.0 # via sphinx -snowflake-connector-python==3.1.1 +snowflake-connector-python==3.2.0 # via -r requirements/test-master.txt sortedcontainers==2.4.0 # via @@ -509,6 +526,10 @@ webencodings==0.5.1 # via # -r requirements/test-master.txt # bleach +wrapt==1.15.0 + # via + # -r requirements/test-master.txt + # deprecated yarl==1.9.2 # via # -r requirements/test-master.txt diff --git a/requirements/edx-platform-constraints.txt b/requirements/edx-platform-constraints.txt index 09f089352e..3ac9c705d4 100644 --- a/requirements/edx-platform-constraints.txt +++ b/requirements/edx-platform-constraints.txt @@ -282,9 +282,8 @@ django-filter==23.2 # edx-enterprise # lti-consumer-xblock # openedx-blockstore -django-ipware==4.0.2 +django-ipware==5.0.0 # via - # -c requirements/edx/../constraints.txt # -r requirements/edx/kernel.in # edx-enterprise # edx-proctoring @@ -317,12 +316,12 @@ 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.4.1 +django-oauth-toolkit==1.5.0 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/kernel.in # edx-enterprise -django-object-actions==4.1.0 +django-object-actions==4.2.0 django-pipeline==2.1.0 # via -r requirements/edx/kernel.in django-ratelimit==4.1.0 @@ -334,7 +333,6 @@ django-sekizai==4.1.0 django-ses==3.5.0 # via -r requirements/edx/bundled.in # via - # -c requirements/edx/../constraints.txt # -r requirements/edx/kernel.in # edx-enterprise # edx-name-affirmation @@ -418,7 +416,9 @@ edx-auth-backends==4.2.0 # -r requirements/edx/kernel.in # openedx-blockstore edx-braze-client==0.1.7 - # via -r requirements/edx/bundled.in + # via + # -r requirements/edx/bundled.in + # edx-enterprise edx-bulk-grades==1.0.2 # via # -r requirements/edx/kernel.in @@ -471,7 +471,7 @@ edx-drf-extensions==8.9.2 # edx-when # edxval # openedx-learning -edx-enterprise==4.1.11 +edx-enterprise==4.1.14 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/kernel.in @@ -479,7 +479,7 @@ edx-event-bus-kafka==5.4.0 # via -r requirements/edx/kernel.in edx-event-bus-redis==0.3.1 # via -r requirements/edx/kernel.in -edx-i18n-tools==1.1.0 +edx-i18n-tools==1.2.0 # via ora2 edx-milestones==0.5.0 # via -r requirements/edx/kernel.in @@ -497,7 +497,6 @@ edx-opaque-keys[django]==2.5.0 # edx-milestones # edx-organizations # edx-proctoring - # edx-user-state-client # edx-when # lti-consumer-xblock # openedx-events @@ -518,7 +517,7 @@ edx-rest-api-client==5.6.0 # edx-proctoring edx-search==3.6.0 # via -r requirements/edx/kernel.in -edx-sga==0.22.0 +edx-sga==0.23.0 # via -r requirements/edx/bundled.in edx-submissions==3.6.0 # via @@ -537,8 +536,6 @@ edx-toggles==5.1.0 # ora2 edx-token-utils==0.2.1 # via -r requirements/edx/kernel.in -edx-user-state-client==1.3.2 - # via -r requirements/edx/kernel.in edx-when==2.4.0 # via # -r requirements/edx/kernel.in @@ -558,7 +555,7 @@ event-tracking==2.2.0 # -r requirements/edx/kernel.in # edx-proctoring # edx-search -fastavro==1.8.2 +fastavro==1.8.3 # via openedx-events filelock==3.12.3 # via snowflake-connector-python @@ -640,7 +637,9 @@ jsonschema==4.19.0 jsonschema-specifications==2023.7.1 # via jsonschema jwcrypto==1.5.0 - # via pylti1p3 + # via + # django-oauth-toolkit + # pylti1p3 # via celery laboratory==1.0.2 # via -r requirements/edx/kernel.in @@ -740,6 +739,8 @@ oauthlib==3.2.2 olxcleaner==0.2.1 # via -r requirements/edx/kernel.in openai==0.28.0 +openedx-atlas==0.5.0 + # via -r requirements/edx/kernel.in openedx-blockstore==1.4.0 # via -r requirements/edx/kernel.in openedx-calc==3.0.1 @@ -752,8 +753,9 @@ openedx-django-require==2.1.0 # via -r requirements/edx/kernel.in openedx-django-wiki==2.0.1 # via -r requirements/edx/kernel.in -openedx-events==8.6.0 +openedx-events==8.5.0 # via + # -c requirements/edx/../constraints.txt # -r requirements/edx/kernel.in # edx-event-bus-kafka # edx-event-bus-redis @@ -767,7 +769,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.3.0 +ora2==5.4.0 # via -r requirements/edx/bundled.in oscrypto==1.3.0 # via snowflake-connector-python @@ -950,9 +952,8 @@ random2==1.0.1 # via -r requirements/edx/kernel.in recommender-xblock==2.0.1 # via -r requirements/edx/bundled.in -redis==4.6.0 +redis==5.0.0 # via - # -c requirements/edx/../constraints.txt # -r requirements/edx/kernel.in # walrus referencing==0.30.2 @@ -1030,6 +1031,7 @@ six==1.16.0 # chem # codejail-includes # crowdsourcehinter-xblock + # django-oauth-toolkit # edx-ace # edx-auth-backends # edx-ccx-keys @@ -1057,7 +1059,7 @@ slumber==0.7.1 # edx-bulk-grades # edx-enterprise # edx-rest-api-client -snowflake-connector-python==3.1.1 +snowflake-connector-python==3.2.0 social-auth-app-django==5.0.0 # via # -c requirements/edx/../constraints.txt @@ -1183,7 +1185,6 @@ xblock[django]==1.7.0 # done-xblock # edx-completion # edx-sga - # edx-user-state-client # edx-when # lti-consumer-xblock # ora2 diff --git a/requirements/js_test.txt b/requirements/js_test.txt index b1710d25c6..ec64f8fc82 100644 --- a/requirements/js_test.txt +++ b/requirements/js_test.txt @@ -95,7 +95,7 @@ trio==0.22.2 # trio-websocket trio-websocket==0.10.4 # via selenium -typing-extensions==4.7.1 +typing-extensions==4.8.0 # via # annotated-types # inflect diff --git a/requirements/test-master.txt b/requirements/test-master.txt index 9d1eecd265..64a5d89599 100644 --- a/requirements/test-master.txt +++ b/requirements/test-master.txt @@ -88,11 +88,13 @@ code-annotations==1.5.0 # via # -c requirements/edx-platform-constraints.txt # -r requirements/base.in + # edx-toggles cryptography==38.0.4 # via # -c requirements/edx-platform-constraints.txt # -r requirements/base.in # django-fernet-fields-v2 + # jwcrypto # pgpy # pyjwt # pyopenssl @@ -101,6 +103,10 @@ 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.21 # via # -c requirements/common_constraints.txt @@ -119,6 +125,7 @@ django==3.2.21 # edx-django-utils # edx-drf-extensions # edx-rbac + # edx-toggles # jsonfield django-cache-memoize==0.1.10 # via @@ -138,6 +145,7 @@ django-crum==0.7.9 # -r requirements/base.in # edx-django-utils # edx-rbac + # edx-toggles django-fernet-fields-v2==0.9 # via # -c requirements/edx-platform-constraints.txt @@ -146,7 +154,7 @@ django-filter==23.2 # via # -c requirements/edx-platform-constraints.txt # -r requirements/base.in -django-ipware==4.0.2 +django-ipware==5.0.0 # via # -c requirements/edx-platform-constraints.txt # -r requirements/base.in @@ -159,11 +167,11 @@ django-multi-email-field==0.7.0 # via # -c requirements/edx-platform-constraints.txt # -r requirements/base.in -django-oauth-toolkit==1.4.1 +django-oauth-toolkit==1.5.0 # via # -c requirements/edx-platform-constraints.txt # -r requirements/base.in -django-object-actions==4.1.0 +django-object-actions==4.2.0 # via # -c requirements/edx-platform-constraints.txt # -r requirements/base.in @@ -177,6 +185,7 @@ django-waffle==4.0.0 # -r requirements/base.in # edx-django-utils # edx-drf-extensions + # edx-toggles djangorestframework==3.14.0 # via # -c requirements/edx-platform-constraints.txt @@ -203,6 +212,7 @@ edx-django-utils==5.7.0 # django-config-models # edx-drf-extensions # edx-rest-api-client + # edx-toggles edx-drf-extensions==8.9.2 # via # -c requirements/edx-platform-constraints.txt @@ -225,6 +235,10 @@ edx-tincan-py35==1.0.0 # via # -c requirements/edx-platform-constraints.txt # -r requirements/base.in +edx-toggles==5.1.0 + # via + # -c requirements/edx-platform-constraints.txt + # -r requirements/base.in filelock==3.12.3 # via # -c requirements/edx-platform-constraints.txt @@ -252,6 +266,10 @@ jsonfield==3.1.0 # via # -c requirements/edx-platform-constraints.txt # -r requirements/base.in +jwcrypto==1.5.0 + # via + # -c requirements/edx-platform-constraints.txt + # django-oauth-toolkit kombu==5.3.2 # via celery markupsafe==2.1.3 @@ -388,6 +406,7 @@ six==1.16.0 # via # -c requirements/edx-platform-constraints.txt # bleach + # django-oauth-toolkit # edx-drf-extensions # edx-rbac # python-dateutil @@ -396,7 +415,7 @@ slumber==0.7.1 # -c requirements/edx-platform-constraints.txt # -r requirements/base.in # edx-rest-api-client -snowflake-connector-python==3.1.1 +snowflake-connector-python==3.2.0 # via # -c requirements/edx-platform-constraints.txt # -r requirements/base.in @@ -467,6 +486,10 @@ webencodings==0.5.1 # via # -c requirements/edx-platform-constraints.txt # bleach +wrapt==1.15.0 + # via + # -c requirements/edx-platform-constraints.txt + # deprecated yarl==1.9.2 # via # -c requirements/edx-platform-constraints.txt diff --git a/requirements/test.txt b/requirements/test.txt index 8e60e5b1ee..1d80e77236 100644 --- a/requirements/test.txt +++ b/requirements/test.txt @@ -90,13 +90,16 @@ click-plugins==1.1.1 # -r requirements/test-master.txt # celery code-annotations==1.5.0 - # via -r requirements/test-master.txt + # via + # -r requirements/test-master.txt + # edx-toggles coverage[toml]==7.3.1 # via pytest-cov cryptography==38.0.4 # via # -r requirements/test-master.txt # django-fernet-fields-v2 + # jwcrypto # pgpy # pyjwt # pyopenssl @@ -107,6 +110,10 @@ 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==7.7.0 # via -r requirements/test.in # via @@ -125,6 +132,7 @@ diff-cover==7.7.0 # edx-django-utils # edx-drf-extensions # edx-rbac + # edx-toggles # jsonfield django-cache-memoize==0.1.10 # via -r requirements/test-master.txt @@ -137,11 +145,12 @@ django-crum==0.7.9 # -r requirements/test-master.txt # edx-django-utils # edx-rbac + # edx-toggles django-fernet-fields-v2==0.9 # via -r requirements/test-master.txt django-filter==23.2 # via -r requirements/test-master.txt -django-ipware==4.0.2 +django-ipware==5.0.0 # via -r requirements/test-master.txt django-model-utils==4.3.1 # via @@ -150,9 +159,9 @@ 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.4.1 +django-oauth-toolkit==1.5.0 # via -r requirements/test-master.txt -django-object-actions==4.1.0 +django-object-actions==4.2.0 # via -r requirements/test-master.txt django-simple-history==3.1.1 # via @@ -163,6 +172,7 @@ django-waffle==4.0.0 # -r requirements/test-master.txt # edx-django-utils # edx-drf-extensions + # edx-toggles djangorestframework==3.14.0 # via # -r requirements/test-master.txt @@ -183,6 +193,7 @@ edx-django-utils==5.7.0 # django-config-models # edx-drf-extensions # edx-rest-api-client + # edx-toggles edx-drf-extensions==8.9.2 # via # -r requirements/test-master.txt @@ -197,6 +208,8 @@ 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 +edx-toggles==5.1.0 + # via -r requirements/test-master.txt factory-boy==3.3.0 # via # -c requirements/constraints.txt @@ -233,6 +246,10 @@ jsondiff==2.0.0 # via -r requirements/test-master.txt jsonfield==3.1.0 # via -r requirements/test-master.txt +jwcrypto==1.5.0 + # via + # -r requirements/test-master.txt + # django-oauth-toolkit # via # -r requirements/test-master.txt # celery @@ -387,6 +404,7 @@ six==1.16.0 # via # -r requirements/test-master.txt # bleach + # django-oauth-toolkit # edx-drf-extensions # edx-rbac # freezegun @@ -397,7 +415,7 @@ slumber==0.7.1 # via # -r requirements/test-master.txt # edx-rest-api-client -snowflake-connector-python==3.1.1 +snowflake-connector-python==3.2.0 # via -r requirements/test-master.txt sortedcontainers==2.4.0 # via @@ -468,6 +486,10 @@ webencodings==0.5.1 # via # -r requirements/test-master.txt # bleach +wrapt==1.15.0 + # via + # -r requirements/test-master.txt + # deprecated yarl==1.9.2 # via # -r requirements/test-master.txt diff --git a/test_utils/factories.py b/test_utils/factories.py index 23d610f5d8..1d6998d92c 100644 --- a/test_utils/factories.py +++ b/test_utils/factories.py @@ -122,6 +122,7 @@ class Meta: hide_labor_market_data = False auth_org_id = factory.LazyAttribute(lambda x: FAKER.lexify(text='??????????')) enable_generation_of_api_credentials = False + career_engagement_network_message = 'Test message' class EnrollmentNotificationEmailTemplateFactory(factory.django.DjangoModelFactory): diff --git a/tests/test_enterprise/api/test_filters.py b/tests/test_enterprise/api/test_filters.py index c4a35d1510..b37b90790c 100644 --- a/tests/test_enterprise/api/test_filters.py +++ b/tests/test_enterprise/api/test_filters.py @@ -223,7 +223,19 @@ def test_filter(self, is_staff, is_linked_to_enterprise, has_access): for key, value in self.enterprise_customer_data.items(): assert enterprise_customer_response[key] == value else: - assert response == {'count': 0, 'next': None, 'previous': None, 'results': []} + mock_empty_200_success_response = { + 'next': None, + 'previous': None, + 'count': 0, + 'num_pages': 1, + 'current_page': 1, + 'start': 0, + 'results': [], + 'enterprise_features': { + 'top_down_assignment_real_time_lcm': False + } + } + assert response == mock_empty_200_success_response @ddt.ddt diff --git a/tests/test_enterprise/api/test_views.py b/tests/test_enterprise/api/test_views.py index 72a6cb7254..5de69468b6 100644 --- a/tests/test_enterprise/api/test_views.py +++ b/tests/test_enterprise/api/test_views.py @@ -15,6 +15,7 @@ import ddt import responses +from edx_toggles.toggles.testutils import override_waffle_flag from faker import Faker from oauth2_provider.models import get_application_model from pytest import mark, raises @@ -57,6 +58,7 @@ PendingEnrollment, PendingEnterpriseCustomerUser, ) +from enterprise.toggles import TOP_DOWN_ASSIGNMENT_REAL_TIME_LCM from enterprise.utils import ( NotConnectedToOpenEdX, get_sso_orchestrator_api_base_url, @@ -1135,6 +1137,7 @@ class TestEnterpriseCustomerViewSet(BaseTestEnterpriseAPIViews): """ Test enterprise customer view set. """ + @ddt.data( ( factories.EnterpriseCustomerFactory, @@ -1177,6 +1180,7 @@ class TestEnterpriseCustomerViewSet(BaseTestEnterpriseAPIViews): 'hide_course_original_price': False, 'enable_analytics_screen': True, 'enable_integrated_customer_learner_portal_search': True, + 'enable_career_engagement_network_on_learner_portal': False, 'enable_portal_lms_configurations_screen': False, 'sender_alias': 'Test Sender Alias', 'identity_providers': [], @@ -1189,6 +1193,7 @@ class TestEnterpriseCustomerViewSet(BaseTestEnterpriseAPIViews): 'enable_browse_and_request': False, 'admin_users': [], 'enable_generation_of_api_credentials': False, + 'career_engagement_network_message': 'Test message', }], ), ( @@ -1234,6 +1239,7 @@ class TestEnterpriseCustomerViewSet(BaseTestEnterpriseAPIViews): 'enable_portal_subscription_management_screen': False, 'hide_course_original_price': False, 'enable_analytics_screen': True, 'enable_integrated_customer_learner_portal_search': True, + 'enable_career_engagement_network_on_learner_portal': False, 'enable_portal_lms_configurations_screen': False, 'sender_alias': 'Test Sender Alias', 'identity_providers': [], 'enterprise_customer_catalogs': [], 'reply_to': 'fake_reply@example.com', @@ -1242,6 +1248,7 @@ class TestEnterpriseCustomerViewSet(BaseTestEnterpriseAPIViews): 'enable_universal_link': False, 'enable_browse_and_request': False, 'admin_users': [], 'enable_generation_of_api_credentials': False, + 'career_engagement_network_message': 'Test message', }, 'active': True, 'user_id': 0, 'user': None, 'data_sharing_consent_records': [], 'groups': [], @@ -1310,6 +1317,7 @@ class TestEnterpriseCustomerViewSet(BaseTestEnterpriseAPIViews): 'hide_course_original_price': False, 'enable_analytics_screen': True, 'enable_integrated_customer_learner_portal_search': True, + 'enable_career_engagement_network_on_learner_portal': False, 'enable_portal_lms_configurations_screen': False, 'sender_alias': 'Test Sender Alias', 'identity_providers': [ @@ -1327,6 +1335,7 @@ class TestEnterpriseCustomerViewSet(BaseTestEnterpriseAPIViews): 'enable_browse_and_request': False, 'admin_users': [], 'enable_generation_of_api_credentials': False, + 'career_engagement_network_message': 'Test message', }], ), ( @@ -1376,6 +1385,7 @@ class TestEnterpriseCustomerViewSet(BaseTestEnterpriseAPIViews): 'hide_course_original_price': False, 'enable_analytics_screen': True, 'enable_integrated_customer_learner_portal_search': True, + 'enable_career_engagement_network_on_learner_portal': False, 'enable_portal_lms_configurations_screen': False, 'sender_alias': 'Test Sender Alias', 'identity_providers': [], @@ -1388,6 +1398,7 @@ class TestEnterpriseCustomerViewSet(BaseTestEnterpriseAPIViews): 'enable_browse_and_request': False, 'admin_users': [], 'enable_generation_of_api_credentials': False, + 'career_engagement_network_message': 'Test message', }], ), ( @@ -1463,50 +1474,58 @@ 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.'}), + (True, False, [], {}, False, {'detail': 'User is not allowed to access the view.'}, 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.'}), + {'detail': 'User is not allowed to access the view.'}, False), # Staff user that does have the specified group permission. (True, False, ['enterprise_enrollment_api_access'], {'permissions': ['enterprise_enrollment_api_access']}, - True, None), + True, None, 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.'}), + {'detail': 'User is not allowed to access the view.'}, 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, {'count': 0, 'next': None, 'previous': None, 'results': []}), + False, None, 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.'}), + {'detail': 'User is not allowed to access the view.'}, 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), + True, None, 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), + {'permissions': ['enterprise_enrollment_api_access', 'enterprise_data_api_access']}, True, None, 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, - {'count': 0, 'next': None, 'previous': None, 'results': []}), + None, 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), + {'permissions': ['enterprise_enrollment_api_access'], 'enterprise_id': FAKE_UUIDS[0]}, True, + None, 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, - {'count': 0, 'next': None, 'previous': None, 'results': []}), + None, 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), + {'permissions': ['enterprise_enrollment_api_access'], 'search': 'test'}, True, + None, 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), + {'permissions': ['enterprise_enrollment_api_access'], 'slug': TEST_SLUG}, True, + None, 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, - {'count': 0, 'next': None, 'previous': None, 'results': []}), + None, False), + # Staff user with group permission filtering on slug with results, with + # top down assignment & real-time LCM feature enabled + (True, False, ['enterprise_enrollment_api_access'], + {'permissions': ['enterprise_enrollment_api_access'], 'slug': TEST_SLUG}, True, + None, True), ) @ddt.unpack @mock.patch('enterprise.utils.get_logo_url') @@ -1518,6 +1537,7 @@ def test_enterprise_customer_with_access_to( query_params, has_access_to_enterprise, expected_error, + is_top_down_assignment_real_time_lcm_enabled, mock_get_logo_url, ): """ @@ -1563,10 +1583,13 @@ def test_enterprise_customer_with_access_to( client = APIClient() client.login(username='test_user', password='test_password') - - response = client.get(settings.TEST_SERVER + - ENTERPRISE_CUSTOMER_WITH_ACCESS_TO_ENDPOINT + - '?' + urlencode(query_params, True)) + with override_waffle_flag( + 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)}" + ) response = self.load_json(response.content) if has_access_to_enterprise: assert response['results'][0] == { @@ -1596,6 +1619,7 @@ def test_enterprise_customer_with_access_to( 'hide_course_original_price': False, 'enable_analytics_screen': False, 'enable_integrated_customer_learner_portal_search': True, + 'enable_career_engagement_network_on_learner_portal': False, 'enable_portal_lms_configurations_screen': False, 'sender_alias': 'Test Sender Alias', 'identity_providers': [], @@ -1608,9 +1632,22 @@ def test_enterprise_customer_with_access_to( 'enable_browse_and_request': False, 'admin_users': [], 'enable_generation_of_api_credentials': False, + 'career_engagement_network_message': 'Test message', } else: - assert response == expected_error + mock_empty_200_success_response = { + 'next': None, + 'previous': None, + 'count': 0, + 'num_pages': 1, + 'current_page': 1, + 'start': 0, + 'results': [], + 'enterprise_features': { + 'top_down_assignment_real_time_lcm': is_top_down_assignment_real_time_lcm_enabled, + } + } + assert response in (expected_error, mock_empty_200_success_response) def test_enterprise_customer_branding_detail(self): """ @@ -4421,12 +4458,8 @@ def test_bulk_enrollment_in_bulk_courses_pending_licenses( @mock.patch('enterprise.api.v1.views.enterprise_customer.track_enrollment') @mock.patch('enterprise.models.EnterpriseCustomer.notify_enrolled_learners') @mock.patch('enterprise.utils.lms_update_or_create_enrollment') - @mock.patch('enterprise.utils.lms_enroll_user_in_course') - @ddt.data(True, False) def test_bulk_enrollment_in_bulk_courses_existing_users( self, - setting_value, - mock_enroll_user_in_course, mock_update_or_create_enrollment, mock_notify_task, mock_track_enroll, @@ -4437,84 +4470,76 @@ def test_bulk_enrollment_in_bulk_courses_existing_users( This tests the case where existing users are supplied, so the enrollments are fulfilled rather than pending. """ - if setting_value: - mock_customer_admin_enroll_user = mock_update_or_create_enrollment - else: - mock_customer_admin_enroll_user = mock_enroll_user_in_course + mock_update_or_create_enrollment.return_value = True - with override_settings(ENABLE_ENTERPRISE_BACKEND_EMET_AUTO_UPGRADE_ENROLLMENT_MODE=setting_value): - mock_customer_admin_enroll_user.return_value = True + user_one = factories.UserFactory(is_active=True) + user_two = factories.UserFactory(is_active=True) - user_one = factories.UserFactory(is_active=True) - user_two = factories.UserFactory(is_active=True) + factories.EnterpriseCustomerFactory( + uuid=FAKE_UUIDS[0], + name="test_enterprise" + ) - factories.EnterpriseCustomerFactory( - uuid=FAKE_UUIDS[0], - name="test_enterprise" - ) + permission = Permission.objects.get(name='Can add Enterprise Customer') + self.user.user_permissions.add(permission) + mock_get_course_mode.return_value = VERIFIED_SUBSCRIPTION_COURSE_MODE - permission = Permission.objects.get(name='Can add Enterprise Customer') - self.user.user_permissions.add(permission) - mock_get_course_mode.return_value = VERIFIED_SUBSCRIPTION_COURSE_MODE + self.assertEqual(len(PendingEnrollment.objects.all()), 0) + body = { + 'enrollments_info': [ + { + 'user_id': user_one.id, + 'course_run_key': 'course-v1:edX+DemoX+Demo_Course', + 'license_uuid': '5a88bdcade7c4ecb838f8111b68e18ac' + }, + { + 'email': user_two.email, + 'course_run_key': 'course-v1:edX+DemoX+Demo_Course', + 'license_uuid': '2c58acdade7c4ede838f7111b42e18ac' + }, + ] + } + response = self.client.post( + settings.TEST_SERVER + ENTERPRISE_CUSTOMER_BULK_ENROLL_LEARNERS_IN_COURSES_ENDPOINT, + data=json.dumps(body), + content_type='application/json', + ) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + response_json = response.json() + self.assertEqual({ + 'successes': [ + { + 'user_id': user_one.id, + 'email': user_one.email, + 'course_run_key': 'course-v1:edX+DemoX+Demo_Course', + 'created': True, + 'activation_link': None, + 'enterprise_fulfillment_source_uuid': str(EnterpriseCourseEnrollment.objects.filter( + enterprise_customer_user__user_id=user_one.id + ).first().licensedenterprisecourseenrollment_enrollment_fulfillment.uuid) + }, + { + 'user_id': user_two.id, + 'email': user_two.email, + 'course_run_key': 'course-v1:edX+DemoX+Demo_Course', + 'created': True, + 'activation_link': None, + 'enterprise_fulfillment_source_uuid': str(EnterpriseCourseEnrollment.objects.filter( + enterprise_customer_user__user_id=user_two.id + ).first().licensedenterprisecourseenrollment_enrollment_fulfillment.uuid) + }, + ], + 'pending': [], + 'failures': [], + }, response_json) + self.assertEqual(len(EnterpriseCourseEnrollment.objects.all()), 2) + # no notifications to be sent unless 'notify' specifically asked for in payload + mock_notify_task.assert_not_called() + mock_track_enroll.assert_has_calls([ + mock.call(PATHWAY_CUSTOMER_ADMIN_ENROLLMENT, 1, 'course-v1:edX+DemoX+Demo_Course'), + ]) - self.assertEqual(len(PendingEnrollment.objects.all()), 0) - body = { - 'enrollments_info': [ - { - 'user_id': user_one.id, - 'course_run_key': 'course-v1:edX+DemoX+Demo_Course', - 'license_uuid': '5a88bdcade7c4ecb838f8111b68e18ac' - }, - { - 'email': user_two.email, - 'course_run_key': 'course-v1:edX+DemoX+Demo_Course', - 'license_uuid': '2c58acdade7c4ede838f7111b42e18ac' - }, - ] - } - response = self.client.post( - settings.TEST_SERVER + ENTERPRISE_CUSTOMER_BULK_ENROLL_LEARNERS_IN_COURSES_ENDPOINT, - data=json.dumps(body), - content_type='application/json', - ) - self.assertEqual(response.status_code, status.HTTP_201_CREATED) - response_json = response.json() - self.assertEqual({ - 'successes': [ - { - 'user_id': user_one.id, - 'email': user_one.email, - 'course_run_key': 'course-v1:edX+DemoX+Demo_Course', - 'created': True, - 'activation_link': None, - 'enterprise_fulfillment_source_uuid': str(EnterpriseCourseEnrollment.objects.filter( - enterprise_customer_user__user_id=user_one.id - ).first().licensedenterprisecourseenrollment_enrollment_fulfillment.uuid) - }, - { - 'user_id': user_two.id, - 'email': user_two.email, - 'course_run_key': 'course-v1:edX+DemoX+Demo_Course', - 'created': True, - 'activation_link': None, - 'enterprise_fulfillment_source_uuid': str(EnterpriseCourseEnrollment.objects.filter( - enterprise_customer_user__user_id=user_two.id - ).first().licensedenterprisecourseenrollment_enrollment_fulfillment.uuid) - }, - ], - 'pending': [], - 'failures': [], - }, response_json) - self.assertEqual(len(EnterpriseCourseEnrollment.objects.all()), 2) - # no notifications to be sent unless 'notify' specifically asked for in payload - mock_notify_task.assert_not_called() - mock_track_enroll.assert_has_calls([ - mock.call(PATHWAY_CUSTOMER_ADMIN_ENROLLMENT, 1, 'course-v1:edX+DemoX+Demo_Course'), - ]) - if setting_value: - assert mock_update_or_create_enrollment.call_count == 2 - else: - assert mock_enroll_user_in_course.call_count == 2 + assert mock_update_or_create_enrollment.call_count == 2 @mock.patch('enterprise.api.v1.views.enterprise_customer.get_best_mode_from_course_key') @mock.patch('enterprise.api.v1.views.enterprise_customer.track_enrollment') @@ -4581,12 +4606,10 @@ def test_bulk_enrollment_in_bulk_courses_nonexisting_user_id( { 'old_transaction_id': FAKE_UUIDS[4], 'new_transaction_id': FAKE_UUIDS[4], - 'setting_value': True, }, { 'old_transaction_id': str(uuid.uuid4()), 'new_transaction_id': str(uuid.uuid4()), - 'setting_value': False, }, ) @ddt.unpack @@ -4595,100 +4618,92 @@ def test_bulk_enrollment_in_bulk_courses_nonexisting_user_id( 'enterprise.api.v1.views.enterprise_customer.get_best_mode_from_course_key' ) @mock.patch('enterprise.utils.lms_update_or_create_enrollment') - @mock.patch('enterprise.utils.lms_enroll_user_in_course') def test_bulk_enrollment_enroll_after_cancel( self, mock_platform_enrollment, mock_get_course_mode, mock_update_or_create_enrollment, - mock_enroll_user_in_course, old_transaction_id, new_transaction_id, - setting_value, ): """ Test that even after a cancelled enterprise enrollment, an attempt to re-enroll the same learner in content results in expected state and payload. """ - if setting_value: - mock_enrollment_api = mock_update_or_create_enrollment - else: - mock_enrollment_api = mock_enroll_user_in_course - with override_settings(ENABLE_ENTERPRISE_BACKEND_EMET_AUTO_UPGRADE_ENROLLMENT_MODE=setting_value): - mock_platform_enrollment.return_value = True - mock_get_course_mode.return_value = VERIFIED_SUBSCRIPTION_COURSE_MODE - # Needed for the cancel endpoint: - mock_enrollment_api.update_enrollment.return_value = mock.Mock() - - user, enterprise_user, enterprise_customer = \ - self._create_user_and_enterprise_customer('abc@test.com', 'test_password') - permission = Permission.objects.get(name='Can add Enterprise Customer') - user.user_permissions.add(permission) - - course_id = 'course-v1:edX+DemoX+Demo_Course' - enterprise_course_enrollment = factories.EnterpriseCourseEnrollmentFactory( - enterprise_customer_user=enterprise_user, - course_id=course_id, - ) - learner_credit_course_enrollment = factories.LearnerCreditEnterpriseCourseEnrollmentFactory( - enterprise_course_enrollment=enterprise_course_enrollment, - transaction_id=old_transaction_id, - ) - learner_credit_fulfillment_url = reverse( - 'enterprise-subsidy-fulfillment', - kwargs={'fulfillment_source_uuid': str(learner_credit_course_enrollment.uuid)} - ) - cancel_url = learner_credit_fulfillment_url + '/cancel-fulfillment' - enrollment_url = reverse( - 'enterprise-customer-enroll-learners-in-courses', - (str(enterprise_customer.uuid),) + mock_platform_enrollment.return_value = True + mock_get_course_mode.return_value = VERIFIED_SUBSCRIPTION_COURSE_MODE + # Needed for the cancel endpoint: + mock_update_or_create_enrollment.update_enrollment.return_value = mock.Mock() + + user, enterprise_user, enterprise_customer = \ + self._create_user_and_enterprise_customer('abc@test.com', 'test_password') + permission = Permission.objects.get(name='Can add Enterprise Customer') + user.user_permissions.add(permission) + + course_id = 'course-v1:edX+DemoX+Demo_Course' + enterprise_course_enrollment = factories.EnterpriseCourseEnrollmentFactory( + enterprise_customer_user=enterprise_user, + course_id=course_id, + ) + learner_credit_course_enrollment = factories.LearnerCreditEnterpriseCourseEnrollmentFactory( + enterprise_course_enrollment=enterprise_course_enrollment, + transaction_id=old_transaction_id, + ) + learner_credit_fulfillment_url = reverse( + 'enterprise-subsidy-fulfillment', + kwargs={'fulfillment_source_uuid': str(learner_credit_course_enrollment.uuid)} + ) + cancel_url = learner_credit_fulfillment_url + '/cancel-fulfillment' + enrollment_url = reverse( + 'enterprise-customer-enroll-learners-in-courses', + (str(enterprise_customer.uuid),) + ) + enroll_body = { + 'notify': 'true', + 'enrollments_info': [ + { + 'email': user.email, + 'course_run_key': course_id, + 'transaction_id': new_transaction_id, + }, + ] + } + with mock.patch('enterprise.api.v1.views.enterprise_customer.track_enrollment'): + with mock.patch("enterprise.models.EnterpriseCustomer.notify_enrolled_learners"): + cancel_response = self.client.post(settings.TEST_SERVER + cancel_url) + with LogCapture(level=logging.WARNING) as warn_logs: + second_enroll_response = self.client.post( + settings.TEST_SERVER + enrollment_url, + data=json.dumps(enroll_body), + content_type='application/json', + ) + + assert cancel_response.status_code == status.HTTP_200_OK + assert second_enroll_response.status_code == status.HTTP_201_CREATED + + if old_transaction_id == new_transaction_id: + assert any( + 'using the same transaction_id as before' + in log_record.getMessage() for log_record in warn_logs.records ) - enroll_body = { - 'notify': 'true', - 'enrollments_info': [ - { - 'email': user.email, - 'course_run_key': course_id, - 'transaction_id': new_transaction_id, - }, - ] - } - with mock.patch('enterprise.api.v1.views.enterprise_customer.track_enrollment'): - with mock.patch("enterprise.models.EnterpriseCustomer.notify_enrolled_learners"): - cancel_response = self.client.post(settings.TEST_SERVER + cancel_url) - with LogCapture(level=logging.WARNING) as warn_logs: - second_enroll_response = self.client.post( - settings.TEST_SERVER + enrollment_url, - data=json.dumps(enroll_body), - content_type='application/json', - ) - - assert cancel_response.status_code == status.HTTP_200_OK - assert second_enroll_response.status_code == status.HTTP_201_CREATED - - if old_transaction_id == new_transaction_id: - assert any( - 'using the same transaction_id as before' - in log_record.getMessage() for log_record in warn_logs.records - ) - # First, check that the bulk enrollment response looks good: - response_json = second_enroll_response.json() - assert len(response_json.get('successes')) == 1 - assert response_json['successes'][0]['user_id'] == user.id - assert response_json['successes'][0]['email'] == user.email - assert response_json['successes'][0]['course_run_key'] == course_id - assert response_json['successes'][0]['created'] is True - assert uuid.UUID(response_json['successes'][0]['enterprise_fulfillment_source_uuid']) == \ - learner_credit_course_enrollment.uuid - - # Then, check that the db records related to the enrollment look good: - enterprise_course_enrollment.refresh_from_db() - learner_credit_course_enrollment.refresh_from_db() - assert enterprise_course_enrollment.unenrolled_at is None - assert enterprise_course_enrollment.saved_for_later is False - assert learner_credit_course_enrollment.is_revoked is False - assert learner_credit_course_enrollment.transaction_id == uuid.UUID(new_transaction_id) + # First, check that the bulk enrollment response looks good: + response_json = second_enroll_response.json() + assert len(response_json.get('successes')) == 1 + assert response_json['successes'][0]['user_id'] == user.id + assert response_json['successes'][0]['email'] == user.email + assert response_json['successes'][0]['course_run_key'] == course_id + assert response_json['successes'][0]['created'] is True + assert uuid.UUID(response_json['successes'][0]['enterprise_fulfillment_source_uuid']) == \ + learner_credit_course_enrollment.uuid + + # Then, check that the db records related to the enrollment look good: + enterprise_course_enrollment.refresh_from_db() + learner_credit_course_enrollment.refresh_from_db() + assert enterprise_course_enrollment.unenrolled_at is None + assert enterprise_course_enrollment.saved_for_later is False + assert learner_credit_course_enrollment.is_revoked is False + assert learner_credit_course_enrollment.transaction_id == uuid.UUID(new_transaction_id) @ddt.data( { @@ -4703,7 +4718,6 @@ def test_bulk_enrollment_enroll_after_cancel( ] }, 'fulfillment_source': LearnerCreditEnterpriseCourseEnrollment, - 'setting_value': True, }, { 'body': { @@ -4717,19 +4731,15 @@ def test_bulk_enrollment_enroll_after_cancel( ] }, 'fulfillment_source': LicensedEnterpriseCourseEnrollment, - 'setting_value': False, }, ) @ddt.unpack @mock.patch('enterprise.api.v1.views.enterprise_customer.get_best_mode_from_course_key') @mock.patch('enterprise.utils.lms_update_or_create_enrollment') - @mock.patch('enterprise.utils.lms_enroll_user_in_course') def test_bulk_enrollment_includes_fulfillment_source_uuid( self, mock_get_course_mode, mock_update_or_create_enrollment, - mock_enroll_user_in_course, - setting_value, body, fulfillment_source, ): @@ -4737,41 +4747,36 @@ def test_bulk_enrollment_includes_fulfillment_source_uuid( Test that a successful bulk enrollment call to generate subsidy based enrollment records will return the newly generated subsidized enrollment uuid value as part of the response payload. """ - if setting_value: - mock_platform_enrollment = mock_update_or_create_enrollment - else: - mock_platform_enrollment = mock_enroll_user_in_course - with override_settings(ENABLE_ENTERPRISE_BACKEND_EMET_AUTO_UPGRADE_ENROLLMENT_MODE=setting_value): - mock_platform_enrollment.return_value = True + mock_update_or_create_enrollment.return_value = True - user, _, enterprise_customer = self._create_user_and_enterprise_customer( - body.get('enrollments_info')[0].get('email'), 'test_password' - ) + user, _, enterprise_customer = self._create_user_and_enterprise_customer( + body.get('enrollments_info')[0].get('email'), 'test_password' + ) - permission = Permission.objects.get(name='Can add Enterprise Customer') - user.user_permissions.add(permission) - mock_get_course_mode.return_value = VERIFIED_SUBSCRIPTION_COURSE_MODE + permission = Permission.objects.get(name='Can add Enterprise Customer') + user.user_permissions.add(permission) + mock_get_course_mode.return_value = VERIFIED_SUBSCRIPTION_COURSE_MODE - enrollment_url = reverse( - 'enterprise-customer-enroll-learners-in-courses', - (str(enterprise_customer.uuid),) - ) - with mock.patch('enterprise.api.v1.views.enterprise_customer.track_enrollment'): - with mock.patch("enterprise.models.EnterpriseCustomer.notify_enrolled_learners"): - response = self.client.post( - settings.TEST_SERVER + enrollment_url, - data=json.dumps(body), - content_type='application/json', - ) + enrollment_url = reverse( + 'enterprise-customer-enroll-learners-in-courses', + (str(enterprise_customer.uuid),) + ) + with mock.patch('enterprise.api.v1.views.enterprise_customer.track_enrollment'): + with mock.patch("enterprise.models.EnterpriseCustomer.notify_enrolled_learners"): + response = self.client.post( + settings.TEST_SERVER + enrollment_url, + data=json.dumps(body), + content_type='application/json', + ) - self.assertEqual(response.status_code, 201) + self.assertEqual(response.status_code, 201) - response_json = response.json() - self.assertEqual(len(response_json.get('successes')), 1) - self.assertEqual( - str(fulfillment_source.objects.first().uuid), - response_json.get('successes')[0].get('enterprise_fulfillment_source_uuid') - ) + response_json = response.json() + self.assertEqual(len(response_json.get('successes')), 1) + self.assertEqual( + str(fulfillment_source.objects.first().uuid), + response_json.get('successes')[0].get('enterprise_fulfillment_source_uuid') + ) @ddt.data( { diff --git a/tests/test_enterprise/test_utils.py b/tests/test_enterprise/test_utils.py index 21ddfe0c34..d98921e6c0 100644 --- a/tests/test_enterprise/test_utils.py +++ b/tests/test_enterprise/test_utils.py @@ -11,7 +11,6 @@ from django.conf import settings from django.forms.models import model_to_dict -from django.test import override_settings from enterprise.models import EnterpriseCourseEnrollment, LicensedEnterpriseCourseEnrollment from enterprise.utils import ( @@ -101,12 +100,8 @@ def test_get_platform_logo_url(self, logo_url, expected_logo_url, mock_get_logo_ @mock.patch('enterprise.utils.CourseEnrollmentError', new_callable=lambda: StubException) @mock.patch('enterprise.utils.CourseUserGroup', new_callable=lambda: StubModel) @mock.patch('enterprise.utils.lms_update_or_create_enrollment') - @mock.patch('enterprise.utils.lms_enroll_user_in_course') - @ddt.data(True, False) def test_enroll_subsidy_users_in_courses_fails( self, - setting_value, - mock_enroll_user_in_course, mock_update_or_create_enrollment, mock_model, mock_error, @@ -115,50 +110,40 @@ def test_enroll_subsidy_users_in_courses_fails( Test that `enroll_subsidy_users_in_courses` properly handles failure cases where something goes wrong with the user enrollment. """ - if setting_value: - mock_customer_admin_enroll_user_with_status = mock_update_or_create_enrollment - else: - mock_customer_admin_enroll_user_with_status = mock_enroll_user_in_course - - with override_settings(ENABLE_ENTERPRISE_BACKEND_EMET_AUTO_UPGRADE_ENROLLMENT_MODE=setting_value): - self.create_user() - ent_customer = factories.EnterpriseCustomerFactory( - uuid=FAKE_UUIDS[0], - name="test_enterprise" - ) - mock_model.DoesNotExist = Exception - mock_customer_admin_enroll_user_with_status.side_effect = [mock_error('mocked error')] - licensed_users_info = [{ - 'email': self.user.email, - 'course_run_key': 'course-key-v1', - 'course_mode': 'verified', - 'license_uuid': '5b77bdbade7b4fcb838f8111b68e18ae' - }] - result = enroll_subsidy_users_in_courses(ent_customer, licensed_users_info) - self.assertEqual( - { - "successes": [], - "failures": [ - { - "user_id": self.user.id, - "email": self.user.email, - "course_run_key": "course-key-v1", - } - ], - "pending": [], - }, - result, - ) + self.create_user() + ent_customer = factories.EnterpriseCustomerFactory( + uuid=FAKE_UUIDS[0], + name="test_enterprise" + ) + mock_model.DoesNotExist = Exception + mock_update_or_create_enrollment.side_effect = [mock_error('mocked error')] + licensed_users_info = [{ + 'email': self.user.email, + 'course_run_key': 'course-key-v1', + 'course_mode': 'verified', + 'license_uuid': '5b77bdbade7b4fcb838f8111b68e18ae' + }] + result = enroll_subsidy_users_in_courses(ent_customer, licensed_users_info) + self.assertEqual( + { + "successes": [], + "failures": [ + { + "user_id": self.user.id, + "email": self.user.email, + "course_run_key": "course-key-v1", + } + ], + "pending": [], + }, + result, + ) @mock.patch('enterprise.utils.CourseEnrollmentError', new_callable=lambda: StubException) @mock.patch('enterprise.utils.CourseUserGroup', new_callable=lambda: StubModel) @mock.patch('enterprise.utils.lms_update_or_create_enrollment') - @mock.patch('enterprise.utils.lms_enroll_user_in_course') - @ddt.data(True, False) def test_enroll_subsidy_users_in_courses_partially_fails( self, - setting_value, - mock_enroll_user_in_course, mock_update_or_create_enrollment, mock_model, mock_error, @@ -167,294 +152,262 @@ def test_enroll_subsidy_users_in_courses_partially_fails( Test that `enroll_subsidy_users_in_courses` properly handles partial failure states and still creates enrollments for the users that succeed. """ - if setting_value: - mock_customer_admin_enroll_user_with_status = mock_update_or_create_enrollment - else: - mock_customer_admin_enroll_user_with_status = mock_enroll_user_in_course - with override_settings(ENABLE_ENTERPRISE_BACKEND_EMET_AUTO_UPGRADE_ENROLLMENT_MODE=setting_value): - self.create_user() - failure_user = factories.UserFactory() - - ent_customer = factories.EnterpriseCustomerFactory( - uuid=FAKE_UUIDS[0], - name="test_enterprise" - ) - factories.EnterpriseCustomerUserFactory( - user_id=self.user.id, - enterprise_customer=ent_customer, - ) - - licensed_users_info = [ - { + self.create_user() + failure_user = factories.UserFactory() + + ent_customer = factories.EnterpriseCustomerFactory( + uuid=FAKE_UUIDS[0], + name="test_enterprise" + ) + factories.EnterpriseCustomerUserFactory( + user_id=self.user.id, + enterprise_customer=ent_customer, + ) + + licensed_users_info = [ + { + 'email': self.user.email, + 'course_run_key': 'course-key-v1', + 'course_mode': 'verified', + 'license_uuid': '5b77bdbade7b4fcb838f8111b68e18ae' + }, + { + 'email': failure_user.email, + 'course_run_key': 'course-key-v1', + 'course_mode': 'verified', + 'license_uuid': '5b77bdbade7b4fcb838f8111b68e18ae' + } + ] + mock_model.DoesNotExist = Exception + mock_update_or_create_enrollment.side_effect = [True, mock_error('mocked error'), None] + 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-v1', - 'course_mode': 'verified', - 'license_uuid': '5b77bdbade7b4fcb838f8111b68e18ae' - }, - { + 'user': self.user, + 'created': True, + 'activation_link': None, + 'enterprise_fulfillment_source_uuid': LicensedEnterpriseCourseEnrollment.objects.first().uuid, + }], + 'failures': [{ + 'user_id': failure_user.id, 'email': failure_user.email, 'course_run_key': 'course-key-v1', - 'course_mode': 'verified', - 'license_uuid': '5b77bdbade7b4fcb838f8111b68e18ae' - } - ] - mock_model.DoesNotExist = Exception - mock_customer_admin_enroll_user_with_status.side_effect = [True, mock_error('mocked error'), None] - 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-v1', - 'user': self.user, - 'created': True, - 'activation_link': None, - 'enterprise_fulfillment_source_uuid': LicensedEnterpriseCourseEnrollment.objects.first().uuid, - }], - 'failures': [{ - 'user_id': failure_user.id, - 'email': failure_user.email, - 'course_run_key': 'course-key-v1', - }], - }, - result - ) - self.assertEqual(len(EnterpriseCourseEnrollment.objects.all()), 1) + }], + }, + result + ) + self.assertEqual(len(EnterpriseCourseEnrollment.objects.all()), 1) @mock.patch('enterprise.utils.lms_update_or_create_enrollment') - @mock.patch('enterprise.utils.lms_enroll_user_in_course') - @ddt.data(True, False) def test_enroll_subsidy_users_in_courses_succeeds( self, - setting_value, - mock_enroll_user_in_course, mock_update_or_create_enrollment, ): """ Test that users that already exist are enrolled by enroll_subsidy_users_in_courses and returned under the `succeeded` field. """ - if setting_value: - mock_customer_admin_enroll_user = mock_update_or_create_enrollment - else: - mock_customer_admin_enroll_user = mock_enroll_user_in_course - with override_settings(ENABLE_ENTERPRISE_BACKEND_EMET_AUTO_UPGRADE_ENROLLMENT_MODE=setting_value): - self.create_user() - - ent_customer = factories.EnterpriseCustomerFactory( - uuid=FAKE_UUIDS[0], - name="test_enterprise" - ) - factories.EnterpriseCustomerUserFactory( - user_id=self.user.id, - enterprise_customer=ent_customer, - ) - licensed_users_info = [{ - 'email': self.user.email, - 'course_run_key': 'course-key-v1', - 'course_mode': 'verified', - 'license_uuid': '5b77bdbade7b4fcb838f8111b68e18ae' - }] - - mock_customer_admin_enroll_user.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-v1', - 'user': self.user, - 'created': True, - 'activation_link': None, - 'enterprise_fulfillment_source_uuid': LicensedEnterpriseCourseEnrollment.objects.first().uuid, - }], - 'failures': [] - }, - result - ) - self.assertEqual(len(EnterpriseCourseEnrollment.objects.all()), 1) + self.create_user() + + ent_customer = factories.EnterpriseCustomerFactory( + uuid=FAKE_UUIDS[0], + name="test_enterprise" + ) + factories.EnterpriseCustomerUserFactory( + user_id=self.user.id, + enterprise_customer=ent_customer, + ) + licensed_users_info = [{ + 'email': self.user.email, + 'course_run_key': 'course-key-v1', + 'course_mode': 'verified', + 'license_uuid': '5b77bdbade7b4fcb838f8111b68e18ae' + }] + + 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-v1', + 'user': self.user, + 'created': True, + 'activation_link': None, + 'enterprise_fulfillment_source_uuid': LicensedEnterpriseCourseEnrollment.objects.first().uuid, + }], + 'failures': [] + }, + result + ) + self.assertEqual(len(EnterpriseCourseEnrollment.objects.all()), 1) @mock.patch('enterprise.utils.lms_update_or_create_enrollment') - @mock.patch('enterprise.utils.lms_enroll_user_in_course') - @ddt.data(True, False) def test_enroll_subsidy_users_in_courses_with_user_id_succeeds( self, - setting_value, - mock_enroll_user_in_course, mock_update_or_create_enrollment, ): """ Test that users that already exist are enrolled by enroll_subsidy_users_in_courses and returned under the ``succeeded`` field. Specifically test when a ``user_id`` is supplied. """ - if setting_value: - mock_customer_admin_enroll_user = mock_update_or_create_enrollment - else: - mock_customer_admin_enroll_user = mock_enroll_user_in_course - with override_settings(ENABLE_ENTERPRISE_BACKEND_EMET_AUTO_UPGRADE_ENROLLMENT_MODE=setting_value): - self.create_user() - another_user = factories.UserFactory(is_active=True) - - ent_customer = factories.EnterpriseCustomerFactory( - uuid=FAKE_UUIDS[0], - name="test_enterprise" - ) - factories.EnterpriseCustomerUserFactory( - user_id=self.user.id, - enterprise_customer=ent_customer, - ) - licensed_users_info = [ - { - # Should succeed with only a user_id supplied. - 'user_id': self.user.id, - 'course_run_key': 'course-key-1', - 'course_mode': 'verified', - 'license_uuid': '5b77bdbade7b4fcb838f8111b68e18ae', - }, - { - # Should succeed even with both a user_id and email supplied. - 'user_id': another_user.id, - 'email': another_user.email, - 'course_run_key': 'course-key-2', - 'course_mode': 'verified', - 'license_uuid': '5b77bdbade7b4fcb838f8111b68e18ae', - }, - ] - - mock_customer_admin_enroll_user.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.id, - 'email': another_user.email, - 'course_run_key': 'course-key-2', - 'user': another_user, - 'created': True, - 'activation_link': None, - 'enterprise_fulfillment_source_uuid': EnterpriseCourseEnrollment.objects.filter( - enterprise_customer_user__user_id=another_user.id - ).first().licensedenterprisecourseenrollment_enrollment_fulfillment.uuid, - } - ], - 'failures': [], - }, - result - ) - self.assertEqual(len(EnterpriseCourseEnrollment.objects.all()), 2) + self.create_user() + another_user = factories.UserFactory(is_active=True) + + ent_customer = factories.EnterpriseCustomerFactory( + uuid=FAKE_UUIDS[0], + name="test_enterprise" + ) + factories.EnterpriseCustomerUserFactory( + user_id=self.user.id, + enterprise_customer=ent_customer, + ) + licensed_users_info = [ + { + # Should succeed with only a user_id supplied. + 'user_id': self.user.id, + 'course_run_key': 'course-key-1', + 'course_mode': 'verified', + 'license_uuid': '5b77bdbade7b4fcb838f8111b68e18ae', + }, + { + # Should succeed even with both a user_id and email supplied. + 'user_id': another_user.id, + 'email': another_user.email, + 'course_run_key': 'course-key-2', + 'course_mode': 'verified', + 'license_uuid': '5b77bdbade7b4fcb838f8111b68e18ae', + }, + ] + + 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.id, + 'email': another_user.email, + 'course_run_key': 'course-key-2', + 'user': another_user, + 'created': True, + 'activation_link': None, + 'enterprise_fulfillment_source_uuid': EnterpriseCourseEnrollment.objects.filter( + enterprise_customer_user__user_id=another_user.id + ).first().licensedenterprisecourseenrollment_enrollment_fulfillment.uuid, + } + ], + 'failures': [], + }, + result + ) + self.assertEqual(len(EnterpriseCourseEnrollment.objects.all()), 2) @mock.patch('enterprise.utils.lms_update_or_create_enrollment') - @mock.patch('enterprise.utils.lms_enroll_user_in_course') - @ddt.data(True, False) def test_enroll_subsidy_users_in_courses_user_identifier_failures( self, - setting_value, - mock_enroll_user_in_course, mock_update_or_create_enrollment, ): """ """ - if setting_value: - mock_customer_admin_enroll_user = mock_update_or_create_enrollment - else: - mock_customer_admin_enroll_user = mock_enroll_user_in_course - with override_settings(ENABLE_ENTERPRISE_BACKEND_EMET_AUTO_UPGRADE_ENROLLMENT_MODE=setting_value): - self.create_user() - another_user = factories.UserFactory(is_active=True) - - ent_customer = factories.EnterpriseCustomerFactory( - uuid=FAKE_UUIDS[0], - name="test_enterprise" - ) - factories.EnterpriseCustomerUserFactory( - user_id=self.user.id, - enterprise_customer=ent_customer, - ) - licensed_users_info = [ - { - # Should fail due to the user_id not matching the email of the same user. - 'user_id': self.user.id, - 'email': another_user.email, - 'course_run_key': 'course-key-1', - 'course_mode': 'verified', - 'license_uuid': '5b77bdbade7b4fcb838f8111b68e18ae', - }, - { - # Should fail due to the user_id not matching the email of the same user. Special case where the - # user_id does not exist. - 'user_id': self.user.id + 1000, - 'email': self.user.email, - 'course_run_key': 'course-key-2', - 'course_mode': 'verified', - 'license_uuid': '5b77bdbade7b4fcb838f8111b68e18ae', - }, - { - # Should fail due to the user_id not matching the email of the same user. Special case where the - # email does not exist. - 'user_id': self.user.id, - 'email': 'wrong+' + self.user.email, - 'course_run_key': 'course-key-3', - 'course_mode': 'verified', - 'license_uuid': '5b77bdbade7b4fcb838f8111b68e18ae', - }, - { - # Should fail due to providing neither `user_id` nor `email`. - 'course_run_key': 'course-key-4', - 'course_mode': 'verified', - 'license_uuid': '5b77bdbade7b4fcb838f8111b68e18ae', - }, - ] - - mock_customer_admin_enroll_user.return_value = True - - result = enroll_subsidy_users_in_courses(ent_customer, licensed_users_info) - self.assertEqual( - { - 'pending': [], - 'successes': [], - 'failures': [ - { - 'user_id': self.user.id, - 'email': another_user.email, - 'course_run_key': 'course-key-1', - }, - { - 'user_id': self.user.id + 1000, - 'email': self.user.email, - 'course_run_key': 'course-key-2', - }, - { - 'user_id': self.user.id, - 'email': 'wrong+' + self.user.email, - 'course_run_key': 'course-key-3', - }, - { - 'course_run_key': 'course-key-4', - }, - ], - }, - result - ) - self.assertEqual(len(EnterpriseCourseEnrollment.objects.all()), 0) + self.create_user() + another_user = factories.UserFactory(is_active=True) + + ent_customer = factories.EnterpriseCustomerFactory( + uuid=FAKE_UUIDS[0], + name="test_enterprise" + ) + factories.EnterpriseCustomerUserFactory( + user_id=self.user.id, + enterprise_customer=ent_customer, + ) + licensed_users_info = [ + { + # Should fail due to the user_id not matching the email of the same user. + 'user_id': self.user.id, + 'email': another_user.email, + 'course_run_key': 'course-key-1', + 'course_mode': 'verified', + 'license_uuid': '5b77bdbade7b4fcb838f8111b68e18ae', + }, + { + # Should fail due to the user_id not matching the email of the same user. Special case where the + # user_id does not exist. + 'user_id': self.user.id + 1000, + 'email': self.user.email, + 'course_run_key': 'course-key-2', + 'course_mode': 'verified', + 'license_uuid': '5b77bdbade7b4fcb838f8111b68e18ae', + }, + { + # Should fail due to the user_id not matching the email of the same user. Special case where the + # email does not exist. + 'user_id': self.user.id, + 'email': 'wrong+' + self.user.email, + 'course_run_key': 'course-key-3', + 'course_mode': 'verified', + 'license_uuid': '5b77bdbade7b4fcb838f8111b68e18ae', + }, + { + # Should fail due to providing neither `user_id` nor `email`. + 'course_run_key': 'course-key-4', + 'course_mode': 'verified', + 'license_uuid': '5b77bdbade7b4fcb838f8111b68e18ae', + }, + ] + + mock_update_or_create_enrollment.return_value = True + + result = enroll_subsidy_users_in_courses(ent_customer, licensed_users_info) + self.assertEqual( + { + 'pending': [], + 'successes': [], + 'failures': [ + { + 'user_id': self.user.id, + 'email': another_user.email, + 'course_run_key': 'course-key-1', + }, + { + 'user_id': self.user.id + 1000, + 'email': self.user.email, + 'course_run_key': 'course-key-2', + }, + { + 'user_id': self.user.id, + 'email': 'wrong+' + self.user.email, + 'course_run_key': 'course-key-3', + }, + { + 'course_run_key': 'course-key-4', + }, + ], + }, + result + ) + self.assertEqual(len(EnterpriseCourseEnrollment.objects.all()), 0) def test_enroll_pending_licensed_users_in_courses_succeeds(self): """ diff --git a/tests/test_integrated_channels/test_moodle/test_client.py b/tests/test_integrated_channels/test_moodle/test_client.py index c82637f68d..35686aa97b 100644 --- a/tests/test_integrated_channels/test_moodle/test_client.py +++ b/tests/test_integrated_channels/test_moodle/test_client.py @@ -70,6 +70,7 @@ def setUp(self): self.user_email = 'testemail@example.com' self.moodle_course_id = random.randint(1, 1000) self._get_courses_response = bytearray('{{"courses": [{{"id": {}}}]}}'.format(self.moodle_course_id), 'utf-8') + self._get_courses_response_empty = bytearray('{}', 'utf-8') self.empty_get_courses_response = bytearray('{"courses": []}', 'utf-8') self.moodle_module_id = random.randint(1, 1000) self.moodle_module_name = 'module' @@ -129,6 +130,11 @@ def test_successful_create_content_metadata(self): client = MoodleAPIClient(self.enterprise_config) client._post = unittest.mock.MagicMock(name='_post', return_value=SUCCESSFUL_RESPONSE) # pylint: disable=protected-access + 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 + 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 @@ -142,6 +148,11 @@ def test_duplicate_shortname_create_content_metadata(self): client = MoodleAPIClient(self.enterprise_config) client._post = unittest.mock.MagicMock(name='_post', return_value=SHORTNAMETAKEN_RESPONSE) # pylint: disable=protected-access + 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 + 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 @@ -152,9 +163,13 @@ def test_duplicate_courseidnumber_create_content_metadata(self): """ expected_data = SERIALIZED_DATA.copy() expected_data['wsfunction'] = 'core_course_create_courses' - client = MoodleAPIClient(self.enterprise_config) client._post = unittest.mock.MagicMock(name='_post', return_value=COURSEIDNUMBERTAKEN_RESPONSE) # pylint: disable=protected-access + 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 + 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 @@ -168,6 +183,11 @@ def test_multi_duplicate_create_content_metadata(self): client = MoodleAPIClient(self.enterprise_config) client._post = unittest.mock.MagicMock(name='_post', return_value=SHORTNAMETAKEN_RESPONSE) # pylint: disable=protected-access + 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 + client._get_courses.return_value = mock_response # pylint: disable=protected-access with self.assertRaises(MoodleClientError): client.create_content_metadata(MULTI_SERIALIZED_DATA) client._post.assert_called_once_with(expected_data) # pylint: disable=protected-access @@ -179,9 +199,14 @@ def test_multi_duplicate_courseidnumber_create_content_metadata(self): """ expected_data = MULTI_SERIALIZED_DATA.copy() expected_data['wsfunction'] = 'core_course_create_courses' - + client = MoodleAPIClient(self.enterprise_config) client._post = unittest.mock.MagicMock(name='_post', return_value=COURSEIDNUMBERTAKEN_RESPONSE) # pylint: disable=protected-access + 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 + client._get_courses.return_value = mock_response # pylint: disable=protected-access with self.assertRaises(MoodleClientError): client.create_content_metadata(MULTI_SERIALIZED_DATA) client._post.assert_called_once_with(expected_data) # pylint: disable=protected-access @@ -205,8 +230,9 @@ def test_update_content_metadata(self): def test_delete_content_metadata(self): """ Test core logic for formatting a delete request to Moodle. + Mark a course visible:0 rather than doing a true delete """ - expected_data = {'wsfunction': 'core_course_delete_courses', 'courseids[]': self.moodle_course_id} + expected_data = {'wsfunction': 'core_course_update_courses', 'courses[0][id]': self.moodle_course_id, 'courses[0][visible]': 0} client = MoodleAPIClient(self.enterprise_config) client._post = unittest.mock.MagicMock(name='_post', return_value=SUCCESSFUL_RESPONSE) # pylint: disable=protected-access @@ -352,3 +378,27 @@ def test_get_course_final_grade_module_custom_name(self): # The base transmitter expects the create course completion response to be a tuple of (code, body) assert client.get_course_final_grade_module(2) == (1337, 'foobar') + + def test_successful_update_existing_content_metadata(self): + """ + Test core logic of create_content_metadata to ensure + if a course already exists(hidden) then client sets its + visibility: 1 instead of creating a new course + """ + expected_data = SERIALIZED_DATA.copy() + expected_data['wsfunction'] = 'core_course_update_courses' + expected_data['courses[0][visible]'] = 1 + expected_data['courses[0][id]'] = self.moodle_course_id + + client = MoodleAPIClient(self.enterprise_config) + client._post = unittest.mock.MagicMock(name='_post', return_value=SUCCESSFUL_RESPONSE) # pylint: disable=protected-access + 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 + + client._get_course_id = unittest.mock.MagicMock(name='_get_course_id') # pylint: disable=protected-access + client._get_course_id.return_value = self.moodle_course_id # pylint: disable=protected-access + 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 diff --git a/tests/test_utilities.py b/tests/test_utilities.py index aa0bc0bad2..46fa8490e3 100644 --- a/tests/test_utilities.py +++ b/tests/test_utilities.py @@ -159,6 +159,7 @@ def setUp(self): "enable_portal_learner_credit_management_screen", "enable_executive_education_2U_fulfillment", "enable_integrated_customer_learner_portal_search", + "enable_career_engagement_network_on_learner_portal", "enable_analytics_screen", "enable_slug_login", "contact_email", @@ -170,6 +171,7 @@ def setUp(self): "hide_labor_market_data", "chat_gpt_prompts", "enable_generation_of_api_credentials", + "career_engagement_network_message", "sso_orchestration_records", ] ), From 7182b831646f3ff281e1131343b052259c33a4d4 Mon Sep 17 00:00:00 2001 From: hamzawaleed01 Date: Thu, 28 Sep 2023 12:45:34 +0500 Subject: [PATCH 02/12] feat: add apply_delete_transformation method in moodle exporters --- integrated_channels/moodle/client.py | 15 +++------------ .../moodle/exporters/content_metadata.py | 7 +++++++ .../test_moodle/test_client.py | 10 +++++++--- .../test_exporters/test_content_metadata.py | 14 ++++++++++++++ 4 files changed, 31 insertions(+), 15 deletions(-) diff --git a/integrated_channels/moodle/client.py b/integrated_channels/moodle/client.py index f6f5e152ab..ef62edcb9e 100644 --- a/integrated_channels/moodle/client.py +++ b/integrated_channels/moodle/client.py @@ -354,16 +354,13 @@ def create_content_metadata(self, serialized_data): # check to see if more than 1 course is being passed more_than_one_course = serialized_data.get('courses[1][shortname]') serialized_data['wsfunction'] = 'core_course_create_courses' - LOGGER.info("CREATING:") try: moodle_course_id = self._get_course_id(serialized_data['courses[0][idnumber]']) # Course already exists but is hidden - make it visible if moodle_course_id: - LOGGER.info("Existing course found - updating it now") serialized_data['courses[0][visible]'] = 1 return self.update_content_metadata(serialized_data) else: # create a new course - LOGGER.info("No existing course found - creating it now") response = self._wrapped_post(serialized_data) except MoodleClientError as error: # treat duplicate as successful, but only if its a single course @@ -378,8 +375,6 @@ def create_content_metadata(self, serialized_data): def update_content_metadata(self, serialized_data): moodle_course_id = self._get_course_id(serialized_data['courses[0][idnumber]']) - LOGGER.info("UPDATING:") - # if we cannot find the course, lets create it if moodle_course_id: serialized_data['courses[0][id]'] = moodle_course_id @@ -392,7 +387,6 @@ def update_content_metadata(self, serialized_data): def delete_content_metadata(self, serialized_data): response = self._get_courses(serialized_data['courses[0][idnumber]']) parsed_response = json.loads(response.text) - LOGGER.info("DELETING:") if not parsed_response.get('courses'): LOGGER.info( generate_formatted_log( @@ -409,12 +403,9 @@ def delete_content_metadata(self, serialized_data): rsp._content = bytearray('{"result": "Course not found."}', 'utf-8') # pylint: disable=protected-access return rsp moodle_course_id = parsed_response['courses'][0]['id'] - params = { - 'wsfunction': 'core_course_update_courses', - 'courses[0][id]': moodle_course_id, - 'courses[0][visible]': 0 # hide a course rather than doing a true delete - } - response = self._wrapped_post(params) + serialized_data['wsfunction'] = 'core_course_update_courses' + serialized_data['courses[0][id]'] = moodle_course_id + response = self._wrapped_post(serialized_data) return response.status_code, response.text def create_assessment_reporting(self, user_id, payload): diff --git a/integrated_channels/moodle/exporters/content_metadata.py b/integrated_channels/moodle/exporters/content_metadata.py index c3cab4475d..08da547948 100644 --- a/integrated_channels/moodle/exporters/content_metadata.py +++ b/integrated_channels/moodle/exporters/content_metadata.py @@ -114,3 +114,10 @@ def transform_end(self, content_metadata_item): if end_date: return int(parse(end_date).replace(tzinfo=timezone.utc).timestamp()) return None + + def _apply_delete_transformation(self, content_metadata_item): + """ + Specific transformations required for "deleting/hiding" a course on a Moodle. + """ + content_metadata_item['visible'] = 0 #hide a course on moodle - instead of true delete + return content_metadata_item \ No newline at end of file diff --git a/tests/test_integrated_channels/test_moodle/test_client.py b/tests/test_integrated_channels/test_moodle/test_client.py index eca9c7e762..645b726b9d 100644 --- a/tests/test_integrated_channels/test_moodle/test_client.py +++ b/tests/test_integrated_channels/test_moodle/test_client.py @@ -232,8 +232,10 @@ def test_delete_content_metadata(self): Test core logic for formatting a delete request to Moodle. Mark a course visible:0 rather than doing a true delete """ - expected_data = {'wsfunction': 'core_course_update_courses', - 'courses[0][id]': self.moodle_course_id, 'courses[0][visible]': 0} + expected_data = SERIALIZED_DATA.copy() + expected_data['wsfunction'] = 'core_course_update_courses' + expected_data['courses[0][visible]'] = 0 + expected_data['courses[0][id]'] = self.moodle_course_id client = MoodleAPIClient(self.enterprise_config) client._post = unittest.mock.MagicMock(name='_post', return_value=SUCCESSFUL_RESPONSE) # pylint: disable=protected-access @@ -244,7 +246,9 @@ def test_delete_content_metadata(self): mock_response._content = self._get_courses_response # pylint: disable=protected-access client._get_courses.return_value = mock_response # pylint: disable=protected-access - client.delete_content_metadata(SERIALIZED_DATA) + serialized_data = SERIALIZED_DATA.copy() + serialized_data['courses[0][visible]'] = 0 + client.delete_content_metadata(serialized_data) client._post.assert_called_once_with(expected_data) # pylint: disable=protected-access diff --git a/tests/test_integrated_channels/test_moodle/test_exporters/test_content_metadata.py b/tests/test_integrated_channels/test_moodle/test_exporters/test_content_metadata.py index d51a65b44e..aa2064697a 100644 --- a/tests/test_integrated_channels/test_moodle/test_exporters/test_content_metadata.py +++ b/tests/test_integrated_channels/test_moodle/test_exporters/test_content_metadata.py @@ -164,3 +164,17 @@ def test_transform_title(self): ) exporter = MoodleContentMetadataExporter('fake-user', self.config) assert exporter.transform_title(content_metadata_item) == expected_title + + @responses.activate + def test_apply_delete_transformation(self): + """ + `MoodleContentMetadataExporter``'s ``transform_title`` returns a str + featuring the title and partners/organizations + """ + content_metadata_item = { + 'title': 'edX Demonstration Course' + } + exporter = MoodleContentMetadataExporter('fake-user', self.config) + transformed_metada_data = exporter._apply_delete_transformation(content_metadata_item) + assert transformed_metada_data['visible'] == 0 + From 38247f67bb9788e8723fd22248a92e2220cfe235 Mon Sep 17 00:00:00 2001 From: Alexander J Sheehan Date: Thu, 28 Sep 2023 20:13:07 +0000 Subject: [PATCH 03/12] chore: adding a more flexible way of fetching api request data --- CHANGELOG.rst | 4 ++++ enterprise/__init__.py | 2 +- .../enterprise_customer_sso_configuration.py | 15 ++++++++++++--- 3 files changed, 17 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 336fe635fe..7e99933e27 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -15,6 +15,10 @@ Change Log Unreleased ---------- +[4.5.2] +------- +chore: adding a more flexible way of fetching api request data + [4.5.1] ------- fix: fix how we determine the value of active flag within schedule for SAP diff --git a/enterprise/__init__.py b/enterprise/__init__.py index 7861b44e1a..188f37d690 100644 --- a/enterprise/__init__.py +++ b/enterprise/__init__.py @@ -2,4 +2,4 @@ Your project description goes here. """ -__version__ = "4.5.1" +__version__ = "4.5.2" diff --git a/enterprise/api/v1/views/enterprise_customer_sso_configuration.py b/enterprise/api/v1/views/enterprise_customer_sso_configuration.py index b6eaad1adf..50df4e7606 100644 --- a/enterprise/api/v1/views/enterprise_customer_sso_configuration.py +++ b/enterprise/api/v1/views/enterprise_customer_sso_configuration.py @@ -104,12 +104,21 @@ def fetch_entity_id_from_metadata_xml(metadata_xml): raise EntityIdNotFoundError('Could not find entity ID in metadata xml') +def fetch_request_data_from_request(request): + """ + Helper method to fetch the request data dictionary from the request object. + """ + if hasattr(request.data, 'dict'): + return request.data.dict().copy() + return request.data.copy() + + class EnterpriseCustomerSsoConfigurationViewSet(viewsets.ModelViewSet): """ API views for the ``EnterpriseCustomerSsoConfiguration`` model. """ permission_classes = (permissions.IsAuthenticated,) - queryset = models.EnterpriseCustomerSsoConfiguration.all_objects.all() + queryset = models.EnterpriseCustomerSsoConfiguration.objects.all() serializer_class = serializers.EnterpriseCustomerSsoConfiguration @@ -199,7 +208,7 @@ def list(self, request, *args, **kwargs): ) def create(self, request, *args, **kwargs): # Force the enterprise customer to be the one associated with the user - request_data = request.data.dict().copy() + request_data = fetch_request_data_from_request(request) requesting_user_customer = request_data.get('enterprise_customer') if requesting_user_customer: try: @@ -264,7 +273,7 @@ def update(self, request, *args, **kwargs): return Response(status=HTTP_403_FORBIDDEN) # Parse the request data to see if the metadata url or xml has changed and update the entity id if so - request_data = request.data.dict() + request_data = fetch_request_data_from_request(request) sso_config_metadata_xml = None if request_metadata_url := request_data.get('metadata_url'): sso_config_metadata_url = sso_configuration_record.first().metadata_url From a5aae732c6ef8d470405178f5305e9df1990fdde Mon Sep 17 00:00:00 2001 From: Alexander J Sheehan Date: Tue, 3 Oct 2023 16:10:29 +0000 Subject: [PATCH 04/12] chore: updating read the docs yaml file --- .readthedocs.yaml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.readthedocs.yaml b/.readthedocs.yaml index e093fa93d3..8540be6baf 100644 --- a/.readthedocs.yaml +++ b/.readthedocs.yaml @@ -5,6 +5,11 @@ # Required: the version of this file's schema. version: 2 +build: + os: "ubuntu-20.04" + tools: + python: "3.8" + # Build documentation in the docs/ directory with Sphinx sphinx: configuration: docs/conf.py @@ -17,6 +22,5 @@ formats: # Optionally set the version of Python and requirements required to build your docs python: - version: "3.8" install: - requirements: requirements/doc.txt From d7b31f4c5533cfa543e1347b773d65581a16cee5 Mon Sep 17 00:00:00 2001 From: Zaman Afzal Date: Wed, 4 Oct 2023 14:44:57 +0500 Subject: [PATCH 05/12] feat: added dry-run mode for content_metadata transmitter (#1883) --- CHANGELOG.rst | 4 +++ enterprise/__init__.py | 2 +- .../transmitters/content_metadata.py | 20 +++++++++++- integrated_channels/utils.py | 15 +++++++++ .../test_content_metadata.py | 31 +++++++++++++++++++ 5 files changed, 70 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 7e99933e27..d3f1962c59 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -15,6 +15,10 @@ Change Log Unreleased ---------- +[4.5.3] +------- +feat: added dry run mode for content metadata transmission + [4.5.2] ------- chore: adding a more flexible way of fetching api request data diff --git a/enterprise/__init__.py b/enterprise/__init__.py index 188f37d690..2666f7f8f5 100644 --- a/enterprise/__init__.py +++ b/enterprise/__init__.py @@ -2,4 +2,4 @@ Your project description goes here. """ -__version__ = "4.5.2" +__version__ = "4.5.3" diff --git a/integrated_channels/integrated_channel/transmitters/content_metadata.py b/integrated_channels/integrated_channel/transmitters/content_metadata.py index 49bb9a5d01..297fe13e07 100644 --- a/integrated_channels/integrated_channel/transmitters/content_metadata.py +++ b/integrated_channels/integrated_channel/transmitters/content_metadata.py @@ -16,7 +16,7 @@ from integrated_channels.exceptions import ClientError from integrated_channels.integrated_channel.client import IntegratedChannelApiClient from integrated_channels.integrated_channel.transmitters import Transmitter -from integrated_channels.utils import chunks, generate_formatted_log +from integrated_channels.utils import chunks, encode_binary_data_for_logging, generate_formatted_log LOGGER = logging.getLogger(__name__) @@ -153,6 +153,24 @@ def _transmit_action(self, content_metadata_item_map, client_method, action_name for chunk in islice(chunk_items, transmission_limit): json_payloads = [item.channel_metadata for item in list(chunk.values())] serialized_chunk = self._serialize_items(json_payloads) + if self.enterprise_configuration.dry_run_mode_enabled: + enterprise_customer_uuid = self.enterprise_configuration.enterprise_customer.uuid + channel_code = self.enterprise_configuration.channel_code() + for key, item in chunk.items(): + payload = item.channel_metadata + serialized_payload = self._serialize_items([payload]) + encoded_serialized_payload = encode_binary_data_for_logging(serialized_payload) + LOGGER.info(generate_formatted_log( + channel_code, + enterprise_customer_uuid, + None, + key, + f'dry-run mode content metadata ' + f'skipping "{action_name}" action for content metadata transmission ' + f'integrated_channel_serialized_payload_base64={encoded_serialized_payload}' + )) + continue + response_status_code = None response_body = None try: diff --git a/integrated_channels/utils.py b/integrated_channels/utils.py index 9f1c3b17b9..b41c4b55f9 100644 --- a/integrated_channels/utils.py +++ b/integrated_channels/utils.py @@ -42,6 +42,21 @@ def encode_data_for_logging(data): return base64.urlsafe_b64encode(data.encode("utf-8")).decode('utf-8') +def encode_binary_data_for_logging(data): + """ + Converts binary input into URL-safe, utf-8 encoded, base64 encoded output. + If the input is binary (bytes), it is first decoded to utf-8, then dumped to JSON, + and finally, base64 encoded. + """ + if not isinstance(data, str): + try: + data = json.dumps(data.decode('utf-8')) + except (UnicodeDecodeError, AttributeError): + # Handle decoding errors or attribute errors (e.g., if 'data' is not bytes) + data = json.dumps(data) + return base64.urlsafe_b64encode(data.encode("utf-8")).decode('utf-8') + + def parse_datetime_to_epoch(datestamp, magnitude=1.0): """ Convert an ISO-8601 datetime string to a Unix epoch timestamp in some magnitude. diff --git a/tests/test_integrated_channels/test_integrated_channel/test_transmitters/test_content_metadata.py b/tests/test_integrated_channels/test_integrated_channel/test_transmitters/test_content_metadata.py index a60b523d0e..2db7d6d25d 100644 --- a/tests/test_integrated_channels/test_integrated_channel/test_transmitters/test_content_metadata.py +++ b/tests/test_integrated_channels/test_integrated_channel/test_transmitters/test_content_metadata.py @@ -490,3 +490,34 @@ def test_transmit_success_resolve_orphaned_content(self): orphaned_content_record.refresh_from_db() assert orphaned_content_record.resolved + + def test_content_data_transmission_dry_run_mode(self): + """ + Test that a customer's configuration can run in dry run mode + """ + # Set feature flag to true + self.enterprise_config.dry_run_mode_enabled = True + + content_id_1 = 'course:DemoX' + channel_metadata_1 = {'update': True} + content_1 = factories.ContentMetadataItemTransmissionFactory( + content_id=content_id_1, + enterprise_customer=self.enterprise_config.enterprise_customer, + plugin_configuration_id=self.enterprise_config.id, + integrated_channel_code=self.enterprise_config.channel_code(), + enterprise_customer_catalog_uuid=self.enterprise_catalog.uuid, + channel_metadata=channel_metadata_1, + remote_created_at=datetime.utcnow() + ) + + create_payload = {} + update_payload = {} + delete_payload = {content_id_1: content_1} + self.delete_content_metadata_mock.return_value = (self.success_response_code, self.success_response_body) + transmitter = ContentMetadataTransmitter(self.enterprise_config) + transmitter.transmit(create_payload, update_payload, delete_payload) + + # with dry_run_mode_enabled = True we shouldn't be able to call these methods + self.create_content_metadata_mock.assert_not_called() + self.update_content_metadata_mock.assert_not_called() + self.delete_content_metadata_mock.assert_not_called() From c5a3d5c97e1673ce4b43e554d7b65606006b8b80 Mon Sep 17 00:00:00 2001 From: hamzawaleed01 Date: Wed, 4 Oct 2023 16:42:22 +0500 Subject: [PATCH 06/12] 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 d3f1962c59..4938f434d0 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -15,6 +15,10 @@ Change Log Unreleased ---------- +[4.5.4] +------- +feat: inactive moodle course instead of true delete + [4.5.3] ------- feat: added dry run mode for content metadata transmission diff --git a/enterprise/__init__.py b/enterprise/__init__.py index 2666f7f8f5..7896301f45 100644 --- a/enterprise/__init__.py +++ b/enterprise/__init__.py @@ -2,4 +2,4 @@ Your project description goes here. """ -__version__ = "4.5.3" +__version__ = "4.5.4" From 3dff8d0bf67f317d0d55802ad82c59d966db6936 Mon Sep 17 00:00:00 2001 From: Alexander J Sheehan Date: Tue, 3 Oct 2023 20:27:21 +0000 Subject: [PATCH 07/12] 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 08/12] 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 09/12] 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 10/12] 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 11/12] 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 12/12] 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"