From 8d4eb32bccebf29cd62d5205d7440d92e4839ea4 Mon Sep 17 00:00:00 2001 From: Adam Stankiewicz Date: Wed, 20 Sep 2023 13:21:26 -0400 Subject: [PATCH 1/5] feat: add features dict to EnterpriseCustomerViewSet (#1877) --- CHANGELOG.rst | 5 ++ .../0012-enterprise-feature-flags-waffle.rst | 43 ++++++++++++ enterprise/__init__.py | 2 +- enterprise/api/pagination.py | 31 +++++++++ .../api/v1/views/enterprise_customer.py | 2 + enterprise/toggles.py | 36 ++++++++++ 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 +++++++-- tests/test_enterprise/api/test_filters.py | 14 +++- tests/test_enterprise/api/test_views.py | 65 +++++++++++++------ 17 files changed, 323 insertions(+), 70 deletions(-) create mode 100644 docs/decisions/0012-enterprise-feature-flags-waffle.rst create mode 100644 enterprise/toggles.py diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 7a5838c58a..4df06c54d2 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -16,6 +16,11 @@ Change Log Unreleased ---------- +[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] -------- feat: enterprise sso orchestrator api client implementation 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..068483fed4 --- /dev/null +++ b/docs/decisions/0012-enterprise-feature-flags-waffle.rst @@ -0,0 +1,43 @@ +Enterprise subsidy enrollments and entitlements +=============================================== + +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..1fe6030c63 100644 --- a/enterprise/__init__.py +++ b/enterprise/__init__.py @@ -2,4 +2,4 @@ Your project description goes here. """ -__version__ = "4.1.15" +__version__ = "4.2.0" 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/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/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/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/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..89d410155c 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, @@ -1463,50 +1466,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 +1529,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 +1575,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] == { @@ -1610,7 +1625,19 @@ def test_enterprise_customer_with_access_to( 'enable_generation_of_api_credentials': False, } 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): """ From 3eb07dc6d4a2785d5d85afe968666d3bbb66e90b Mon Sep 17 00:00:00 2001 From: IrfanUddinAhmad Date: Thu, 14 Sep 2023 18:22:39 +0500 Subject: [PATCH 2/5] feat: Added enable_career_engagement_network_on_learner_portal field to EnterpriseCustomer --- CHANGELOG.rst | 3 ++ enterprise/__init__.py | 2 +- enterprise/admin/__init__.py | 4 ++- enterprise/admin/forms.py | 2 ++ enterprise/api/v1/serializers.py | 3 +- .../migrations/0185_auto_20230921_1007.py | 33 +++++++++++++++++++ enterprise/models.py | 15 +++++++++ test_utils/factories.py | 1 + tests/test_enterprise/api/test_views.py | 10 ++++++ tests/test_utilities.py | 2 ++ 10 files changed, 72 insertions(+), 3 deletions(-) create mode 100644 enterprise/migrations/0185_auto_20230921_1007.py diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 4df06c54d2..ba624dcb8f 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -15,6 +15,9 @@ Change Log Unreleased ---------- +[4.3.0] +-------- +feat: Added the ``enable_career_engagement_network_on_learner_portal`` field for EnterpriseCustomer [4.2.0] -------- diff --git a/enterprise/__init__.py b/enterprise/__init__.py index 1fe6030c63..ce723d5ecb 100644 --- a/enterprise/__init__.py +++ b/enterprise/__init__.py @@ -2,4 +2,4 @@ Your project description goes here. """ -__version__ = "4.2.0" +__version__ = "4.3.0" 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/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/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/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_views.py b/tests/test_enterprise/api/test_views.py index 89d410155c..7d0a10bcb7 100644 --- a/tests/test_enterprise/api/test_views.py +++ b/tests/test_enterprise/api/test_views.py @@ -1180,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': [], @@ -1192,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', }], ), ( @@ -1237,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', @@ -1245,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': [], @@ -1313,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': [ @@ -1330,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', }], ), ( @@ -1379,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': [], @@ -1391,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', }], ), ( @@ -1611,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': [], @@ -1623,6 +1632,7 @@ 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: mock_empty_200_success_response = { 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 1dd38453839b0a971b3d093df1031694bd90cd8a Mon Sep 17 00:00:00 2001 From: Emily Aquin Date: Tue, 25 Jul 2023 16:22:17 +0000 Subject: [PATCH 3/5] chore: use lms_update_or_create_enrollment without feature flag --- enterprise/utils.py | 34 +- tests/test_enterprise/api/test_views.py | 356 +++++++-------- tests/test_enterprise/test_utils.py | 549 +++++++++++------------- 3 files changed, 421 insertions(+), 518 deletions(-) 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/tests/test_enterprise/api/test_views.py b/tests/test_enterprise/api/test_views.py index 7d0a10bcb7..5de69468b6 100644 --- a/tests/test_enterprise/api/test_views.py +++ b/tests/test_enterprise/api/test_views.py @@ -4458,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, @@ -4474,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') @@ -4618,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 @@ -4632,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( { @@ -4740,7 +4718,6 @@ def test_bulk_enrollment_enroll_after_cancel( ] }, 'fulfillment_source': LearnerCreditEnterpriseCourseEnrollment, - 'setting_value': True, }, { 'body': { @@ -4754,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, ): @@ -4774,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): """ From cfc757cf0511d2bea2724e4e44b6efdedc2167d6 Mon Sep 17 00:00:00 2001 From: Emily Aquin Date: Thu, 21 Sep 2023 15:55:35 +0000 Subject: [PATCH 4/5] chore: bump version --- CHANGELOG.rst | 4 ++++ enterprise/__init__.py | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index ba624dcb8f..18e791ccbf 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -15,6 +15,10 @@ 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 diff --git a/enterprise/__init__.py b/enterprise/__init__.py index ce723d5ecb..27abfebdc4 100644 --- a/enterprise/__init__.py +++ b/enterprise/__init__.py @@ -2,4 +2,4 @@ Your project description goes here. """ -__version__ = "4.3.0" +__version__ = "4.3.1" From 7fbfa8d262c7c641e02e1ac011a31dae31adbbc1 Mon Sep 17 00:00:00 2001 From: Adam Stankiewicz Date: Thu, 21 Sep 2023 16:59:14 -0400 Subject: [PATCH 5/5] docs: update ADR 0012 to make its title correct (#1880) --- docs/decisions/0012-enterprise-feature-flags-waffle.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/decisions/0012-enterprise-feature-flags-waffle.rst b/docs/decisions/0012-enterprise-feature-flags-waffle.rst index 068483fed4..9e2d7cd226 100644 --- a/docs/decisions/0012-enterprise-feature-flags-waffle.rst +++ b/docs/decisions/0012-enterprise-feature-flags-waffle.rst @@ -1,5 +1,5 @@ -Enterprise subsidy enrollments and entitlements -=============================================== +Waffle-based feature flags for Enterprise +========================================= Status ------