diff --git a/.gitignore b/.gitignore index 0d8cefa96d..737ac38234 100644 --- a/.gitignore +++ b/.gitignore @@ -97,5 +97,5 @@ venv/ #enterprise/static/enterprise/bundles/ # LLM tools -CLAUDE.md -.claude/ \ No newline at end of file +/CLAUDE.md +.claude/ diff --git a/docs/pluginification/.gitignore b/docs/pluginification/.gitignore new file mode 100644 index 0000000000..580fd244d1 --- /dev/null +++ b/docs/pluginification/.gitignore @@ -0,0 +1,5 @@ +/edx-django-utils/ +/edx-enterprise/ +/enterprise-integrated-channels/ +/openedx-filters/ +/openedx-platform/ diff --git a/docs/pluginification/CLAUDE.md b/docs/pluginification/CLAUDE.md new file mode 100644 index 0000000000..934a48a639 --- /dev/null +++ b/docs/pluginification/CLAUDE.md @@ -0,0 +1,243 @@ +# Background + +Currently, edx-enterprise is tightly coupled with openedx-platform through direct imports across +core modules, and edx-enterprise in general is a mandatory library. The +ENABLE_ENTERPRISE_INTEGRATION setting doesn't fully disable enterprise code paths or imports. + +For operators: Customizing or forking edx-enterprise requires maintaining compatibility with +upstream module names and function signatures indefinitely. Enterprise code paths cannot be fully +switched off, increasing error surface area even for non-enterprise deployments. + +For developers: Tight coupling obscures where enterprise code lives and creates risk of unintended +side-effect when modifying either enterprise or platform logic. + +I am working on a project to convert the edx-enterprise library into an optional plugin by +leveraging the openedx plugin framework, openedx-filters, and django built-in features (including +middleware, signals, etc.) to replace enterprise-specific logic with generic plugin hooks and +migrate the custom enterprise logic to edx-enterprise. + +* openedx plugin framework: edx-django-utils/edx_django_utils/plugins/ +* openedx-filters: openedx-filters/ + +Our high-level approach to the project is to incrementally migrate small chunks of +enterprise-specific logic behind hooks, completely merging/deploying changes between each increment. +This project will span several months, and we do not want to accumulate unmerged changes over time. + +The first chunks of work should all be focused on removing enterprise/consent +imports. Registering edx-enterprise as a proper openedx plugin should happen +only after all enterprise/consent imports have been removed from +openedx-platform. + +The following openedx-platform modules import the enterprise module, and will be some of the main +targets for adding plugin hooks (non-exhaustive list): + +* Third-party auth (openedx-platform/common/djangoapps/third_party_auth) +* Courseware access and DSC redirects (openedx-platform/lms/djangoapps/courseware) +* User API and retirement (openedx-platform/openedx/core/djangoapps/user_api) +* RBAC role mappings and other enterprise-specific settings (openedx-platform/lms/envs/common.py) + +Any openedx-platform code which uses any module provided by the edx-enterprise repository is a +candidate for replacing with a plugin hook. These are the modules provided by edx-enterprise +relevant to this project: + +* enterprise +* consent + +Furthermore, the enterprise_support module (openedx-platform/openedx/features/enterprise_support/) +is so tightly coupled with edx-enterprise that we also plan to migrate it into edx-enterprise to +avoid extensive additions of enterprise hooks within that module. That means anywhere +openedx-platform imports from the enterprise_support module from outside the enterprise_support +module itself are also candidates for migration. + +Finally, some settings in openedx-platform/lms/envs/ are enterprise-specific and should eventually +be migrated to the edx-enterprise repository, but only after all enterprise imports are removed from +openedx-platform. + +After all work is done, the edx-enterprise repository should contain 3 openedx plugins: + +* enterprise +* consent +* enterprise_support + +These 3 plugins will existing alongside an orphaned deprecated +integrated_channels django app which is currently in the process of being +migrated to channel_integrations in the enterprise-integrated-channels/ +repository. Do not worry about modifying that deprecated integrated_channels +module. + +# Local git clones + +You can freely read files within the following local git clones without prompting me: + +* openedx-platform/ +* edx-enterprise/ +* enterprise-integrated-channels/ +* edx-django-utils/ +* openedx-filters/ + +If the local clone does not yet exist in the current working directory, you can clone it using: + +``` +git clone --depth 1 git@github.com:openedx/.git +``` + +# Selecting a migration approach + +Enterprise-specific logic within openedx-platform can be migrated to the plugin using several +different approaches: + +* Create a new openedx-filter and implement a new PipelineStep. +* Find an existing openedx-filter and implement a new PipelineStep. +* Create a new django signal and implement a new event handler. +* Find an existing django signal and implement a new event handler. +* Inject django middleware. +* Implement a pluggable override (edx-django-utils/edx_django_utils/plugins/pluggable_override.py) + +Keep the following differences in mind: + +* Filters can be more maintainable than pluggable overrides because we have no ability to stop + multiple override implementations from overriding each other, whereas filters are structured as + pipelines which run each implementation sequentially. It may be appropriate to use a pluggable + override when the function being overridden can only be reasonably augmented once. +* Django signals can work best when no data payload needs to be passed between the sender and + receiver, and require relatively few lines of code. Filters are designed to accommodate data + passing, but require more code changes. +* Middleware are the easiest to install, but run on every request which has performance + implications, especially for enterprise-specific logic which only impacts a small subset of + requests. + +Avoid creating multiple work chunks/epics to first "bake" enterprise settings into openedx-platform +then subsequently migrate them to edx-enterprise (via `plugin_settings()`). Just migrate all +enterprise settings as one single epic. + +# Creating openedx filters + +* Do not use "Enterprise" in filter class names, filter types, and filter exceptions. Avoid even + mentioning enterprise in any openedx-filters docstrings or openedx-filters code comments. +* For now, add new filter mappings to OPEN_EDX_FILTERS_CONFIG within + `openedx-platform/lms/envs/common.py`. Never configure filter mappings via plugin_settings within + edx-enterprise. Filter mappings added by this work will eventually be removed from + openedx-platform common.py but not until the very end. +* Make sure openedx-platform/lms/envs/production.py will not override the setting if loaded + from yaml. Adopt the pre-established pattern used by TRACKING_BACKENDS or CELERY_QUEUES which is + to inhibit loading the OPEN_EDX_FILTERS_CONFIG into global namespace, then subsequently + dynamically merge the setting value from yaml into the one imported from common.py. For each + configured filter, pipeline steps loaded from yaml should be appended after any existing pipeline + steps defined in common.py, and the `fail_silently` value from yaml takes precedence over the one + from common.py. + +# Migrating enterprise-specific settings + +Defer migration of any enterprise-specific settings until one of the very last +epics to actually implment the openedx plugin framework within edx-enterprise. + +Look at `openedx-platform/openedx/core/djangoapps/password_policy/` as a decent +reference plugin from which to copy naming patterns for plugin settings files. +The settings file containing the plugin_settings() definition will likely become: + +`edx-enterprise/consent/settings/common.py` +`edx-enterprise/enterprise/settings/common.py` +`edx-enterprise/enterprise_support/settings/common.py` + +# Ticketing each incremental chunk of work + +The entire project is modeled as a JIRA "Initiative", while each incremental chunk of work +(representing a distinct piece of enterprise logic which should be migrated behind a hook) will be +modeled as JIRA "Epics" each with potentially multiple story tickets which may be sequenced, if +necessary. + +Tickets should be stored in the current working directory as separate files following this +heirarchy: + +* epics/ + * 01_feature_to_migrate/ + * EPIC.md + * 01_.md + * 01_.diff + * 02_.md + * 02_.diff + * 03_.md + * 03_.diff + * 02_another_feature_to_migrate/ + * EPIC.md + * 01_.md + * 01_.diff + * 02_.md + * 02_.diff + +Epic directory names should be prefixed with their sequencing order, and +describe the feature to migrate in 7 or fewer words, e.g. "07_dsc_redirects" or +"02_courseware_access_gating". + +Each epic directory contains an EPIC.md file which summarizes the following: + +* Purpose of existing enterprise-specific logic or settings. (1-2 sentences) +* Selected approach for migrating existing logic/settings into the edx-enterprise plugin. (1-3 sentences) +* A list of other epics which block this one. + +The EPIC.md file should not be wrapped. Entire paragraphs should be on one line to allow +copy+pasting into JIRA. + +## Writing a story ticket and implementation diff + +Each ticket should be scoped to one PR (one repository), and should contain the +following in the body: + +* Ticket name as highest level markdown header (these should be prefixed with "[] "). +* Clear statement about which ticket blocks this one, if any. +* One short paragraph describing the work. +* "A/C" section containing a set of Acceptance Criteria. + * A/C section should contain a bulleted list without checkboxes since I'll be pasting this into + JIRA which doesn't support checkboxes. + * Don't pollute the A/C with a step to create an __init__.py file. + +The ticket file should not be wrapped. Entire paragraphs should be on one line to allow +copy+pasting into JIRA. + +Each ticket markdown file should be accompanied with a sibling diff file containing a complete +implementation of the ticket. + +# Notes from prior brainstorming sessions + +## Grades Analytics Context Enrichment + +openedx-platform/lms/djangoapps/grades/events.py calls get_enterprise_event_context from +enterprise_support to enrich grade-related analytics events with enterprise metadata. We probably +want to use openedx-filter instead of django signals so that we can pass enrichment data back to the +caller. + +## User Retirement + +openedx-platform/openedx/core/djangoapps/user_api/accounts/views.py + +User retirement pipeline queries DataSharingConsent, EnterpriseCourseEnrollment, and +EnterpriseCustomerUser to clean up enterprise-specific data. + +There's already a django signal we can leverage for this: USER_RETIRE_LMS_CRITICAL. It may just +need to be enhanced to include extra fields used by enterprise-specific retirement, including +retired_username and retired_email. + +## User Account Readonly Fields + +openedx-platform/openedx/core/djangoapps/user_api/accounts/views.py +openedx-platform/openedx/core/djangoapps/user_api/accounts/api.py + +The update_account_settings() function is used by the account settings page & API to update +settings, but for enterprise SSO customers some of those settings should be readonly since they're +SSO managed. + +We should probably create a new AccountSettingsReadOnlyFieldsRequested filter to allow plugins to +inject additional readonly fields for account settings. + +Avoid using the existing AccountSettingsRenderStarted filter as it has no invocation from +openedx-platform currently, and doesn't adequately fit the use case of filtering a list of fields. + +## DSC view redirect logic + +openedx-platform/openedx/features/enterprise_support/api.py + +Multiple courseware views/tabs are decorated with an enterprise-specific DSC redirect decorator +(data_sharing_consent_required). We should probably replace the decorator with a generic +`courseware_view_redirect` decorator, and in turn that decorator could call a new openedx-filter +named CoursewareViewRedirectURL to populate an array of redirect URLs. The new decorator can simply +run the filter and select the first element of the list to redirect, or pass if the list is empty. diff --git a/docs/pluginification/epics/00_openedx_filters_config/01_openedx-platform.diff b/docs/pluginification/epics/00_openedx_filters_config/01_openedx-platform.diff new file mode 100644 index 0000000000..2930208e49 --- /dev/null +++ b/docs/pluginification/epics/00_openedx_filters_config/01_openedx-platform.diff @@ -0,0 +1,39 @@ +diff --git a/lms/envs/common.py b/lms/envs/common.py +--- a/lms/envs/common.py ++++ b/lms/envs/common.py +@@ -,0 +,7 @@ ++# .. setting_name: OPEN_EDX_FILTERS_CONFIG ++# .. setting_default: {} ++# .. setting_description: Configuration dict for openedx-filters pipeline steps. ++# Keys are filter type strings; values are dicts with 'fail_silently' (bool) and ++# 'pipeline' (list of dotted-path strings to PipelineStep subclasses). ++OPEN_EDX_FILTERS_CONFIG = {} ++ +diff --git a/lms/envs/production.py b/lms/envs/production.py +--- a/lms/envs/production.py ++++ b/lms/envs/production.py +@@ -76,6 +76,7 @@ if key not in [ + 'CELERY_QUEUES', + 'MKTG_URL_LINK_MAP', + 'REST_FRAMEWORK', + 'EVENT_BUS_PRODUCER_CONFIG', + 'DEFAULT_FILE_STORAGE', + 'STATICFILES_STORAGE', ++ 'OPEN_EDX_FILTERS_CONFIG', + ] +@@ -273,3 +273,14 @@ EVENT_TRACKING_BACKENDS['segmentio']['OPTIONS']['processors'][0]['OPTIONS']['whi + EVENT_TRACKING_SEGMENTIO_EMIT_WHITELIST + ) ++ ++# Merge OPEN_EDX_FILTERS_CONFIG from YAML into the default defined in common.py. ++# Pipeline steps from YAML are appended after steps defined in common.py. ++# The fail_silently value from YAML takes precedence over the one in common.py. ++for _filter_type, _filter_config in _YAML_TOKENS.get('OPEN_EDX_FILTERS_CONFIG', {}).items(): ++ if _filter_type in OPEN_EDX_FILTERS_CONFIG: ++ OPEN_EDX_FILTERS_CONFIG[_filter_type]['pipeline'].extend( ++ _filter_config.get('pipeline', []) ++ ) ++ if 'fail_silently' in _filter_config: ++ OPEN_EDX_FILTERS_CONFIG[_filter_type]['fail_silently'] = _filter_config['fail_silently'] ++ else: ++ OPEN_EDX_FILTERS_CONFIG[_filter_type] = _filter_config diff --git a/docs/pluginification/epics/00_openedx_filters_config/01_openedx-platform.md b/docs/pluginification/epics/00_openedx_filters_config/01_openedx-platform.md new file mode 100644 index 0000000000..e038d8077e --- /dev/null +++ b/docs/pluginification/epics/00_openedx_filters_config/01_openedx-platform.md @@ -0,0 +1,12 @@ +# [openedx-platform] Introduce OPEN_EDX_FILTERS_CONFIG and production merge logic + +Blocked by: None + +Introduce the `OPEN_EDX_FILTERS_CONFIG` setting in `lms/envs/common.py` (initially empty) and add merge logic in `lms/envs/production.py` so that YAML-supplied filter pipeline steps are appended after those configured in code rather than wholesale overwriting the setting. This is a prerequisite for all subsequent filter epics that add entries to `OPEN_EDX_FILTERS_CONFIG`. + +## A/C + +- `OPEN_EDX_FILTERS_CONFIG = {}` (with a setting description comment) is defined in `lms/envs/common.py`. +- `'OPEN_EDX_FILTERS_CONFIG'` is added to the exclusion list in `lms/envs/production.py` so YAML does not wholesale override it. +- Merge logic in `lms/envs/production.py` appends YAML-supplied pipeline steps after those defined in code and honours `fail_silently` from YAML, following the pattern used by `TRACKING_BACKENDS`. +- New filter types supplied only via YAML (not present in common.py) are still added to `OPEN_EDX_FILTERS_CONFIG` via the `else` branch of the merge logic. diff --git a/docs/pluginification/epics/00_openedx_filters_config/EPIC.md b/docs/pluginification/epics/00_openedx_filters_config/EPIC.md new file mode 100644 index 0000000000..20f08c214f --- /dev/null +++ b/docs/pluginification/epics/00_openedx_filters_config/EPIC.md @@ -0,0 +1,16 @@ + +# Epic: OPEN_EDX_FILTERS_CONFIG Production Settings Setup + +JIRA: (TBD — incorporate into epic 01) + +## Purpose + +`lms/envs/production.py` wholesale-overrides any setting key found in YAML, which would wipe out the `OPEN_EDX_FILTERS_CONFIG` dict defined in `lms/envs/common.py` if operators supply any YAML value for that key. Without a merge strategy, any filter pipeline step configuration baked into code would be silently lost in production deployments. + +## Approach + +Introduce `OPEN_EDX_FILTERS_CONFIG = {}` in `lms/envs/common.py` and protect it from wholesale YAML override by adding it to the exclusion list in `lms/envs/production.py`. Add merge logic following the established `TRACKING_BACKENDS` / `CELERY_QUEUES` pattern: pipeline steps supplied via YAML are appended after those already defined in code, and the `fail_silently` value from YAML takes precedence over the one in code. + +## Blocking Epics + +None. This pseudo-epic has no dependencies and should be incorporated into whichever epic first adds a filter entry — in practice, epic 01_grades_analytics_event_enrichment. diff --git a/docs/pluginification/epics/01_grades_analytics_event_enrichment/01_openedx-filters.diff b/docs/pluginification/epics/01_grades_analytics_event_enrichment/01_openedx-filters.diff new file mode 100644 index 0000000000..c74f0079fb --- /dev/null +++ b/docs/pluginification/epics/01_grades_analytics_event_enrichment/01_openedx-filters.diff @@ -0,0 +1,101 @@ +diff --git a/openedx_filters/learning/filters.py b/openedx_filters/learning/filters.py +--- a/openedx_filters/learning/filters.py ++++ b/openedx_filters/learning/filters.py +@@ -1,7 +1,7 @@ + """ + Package where filters related to the learning architectural subdomain are implemented. + """ + +-from typing import Any, Optional ++from typing import Any, Optional, Union + + from django.db.models.query import QuerySet + from django.http import HttpResponse, QueryDict +@@ -1444,3 +1444,47 @@ class ScheduleQuerySetRequested(OpenEdxPublicFilter): + data = super().run_pipeline(schedules=schedules) + return data.get("schedules") ++ ++ ++class GradeEventContextRequested(OpenEdxPublicFilter): ++ """ ++ Filter used to enrich the context dict emitted with a grade analytics event. ++ ++ Purpose: ++ This filter is triggered just before a grade-related analytics event is emitted, ++ allowing pipeline steps to inject additional key-value pairs into the event ++ tracking context. ++ ++ Filter Type: ++ org.openedx.learning.grade.context.requested.v1 ++ ++ Trigger: ++ - Repository: openedx/edx-platform ++ - Path: lms/djangoapps/grades/events.py ++ - Function or Method: course_grade_passed_first_time ++ """ ++ ++ filter_type = "org.openedx.learning.grade.context.requested.v1" ++ ++ @classmethod ++ def run_filter( ++ cls, ++ context: dict, ++ user_id: int, ++ course_id: Union[str, Any], ++ ) -> dict: ++ """ ++ Process the context dict using the configured pipeline steps. ++ ++ Arguments: ++ context (dict): the event tracking context dict to be enriched. ++ user_id (int): the ID of the user whose grade event is being emitted. ++ course_id (str or CourseKey): the course identifier for the grade event. ++ ++ Returns: ++ dict: the context dict, possibly enriched by pipeline steps. ++ """ ++ data = super().run_pipeline(context=context, user_id=user_id, course_id=course_id) ++ return data.get("context") +diff --git a/openedx_filters/learning/tests/test_filters.py b/openedx_filters/learning/tests/test_filters.py +--- a/openedx_filters/learning/tests/test_filters.py ++++ b/openedx_filters/learning/tests/test_filters.py +@@ -1,5 +1,7 @@ + """ + Tests for learning filters. + """ ++from unittest.mock import patch ++ + from django.test import TestCase ++from openedx_filters.learning.filters import GradeEventContextRequested + +@@ -1,0 +1,30 @@ ++class TestGradeEventContextRequestedFilter(TestCase): ++ """ ++ Tests for the GradeEventContextRequested filter. ++ """ ++ ++ def test_run_filter_returns_context_unchanged_when_no_pipeline(self): ++ """ ++ When no pipeline steps are configured, run_filter returns the original context. ++ """ ++ context = {"course_id": "course-v1:org+course+run"} ++ user_id = 42 ++ course_id = "course-v1:org+course+run" ++ ++ with patch.object(GradeEventContextRequested, "run_pipeline", return_value={"context": context}): ++ result = GradeEventContextRequested.run_filter( ++ context=context, ++ user_id=user_id, ++ course_id=course_id, ++ ) ++ ++ self.assertEqual(result, context) ++ ++ def test_filter_type(self): ++ """ ++ Confirm the filter type string is correct. ++ """ ++ self.assertEqual( ++ GradeEventContextRequested.filter_type, ++ "org.openedx.learning.grade.context.requested.v1", ++ ) diff --git a/docs/pluginification/epics/01_grades_analytics_event_enrichment/01_openedx-filters.md b/docs/pluginification/epics/01_grades_analytics_event_enrichment/01_openedx-filters.md new file mode 100644 index 0000000000..d5b44a9c59 --- /dev/null +++ b/docs/pluginification/epics/01_grades_analytics_event_enrichment/01_openedx-filters.md @@ -0,0 +1,13 @@ +# [openedx-filters] Add GradeEventContextRequested filter + +No tickets block this one. + +Add a new `GradeEventContextRequested` filter class to `openedx_filters/learning/filters.py`. This filter is invoked when a grade-related analytics event is about to be emitted, and allows pipeline steps to enrich the event tracking context dict with additional fields. The filter accepts the current context dict, the user ID, and the course ID, and returns the (possibly enriched) context dict. No exception class is required because the filter is configured to fail silently. + +## A/C + +- A new `GradeEventContextRequested` class is added to `openedx_filters/learning/filters.py`, inheriting from `OpenEdxPublicFilter`. +- The filter type is `"org.openedx.learning.grade.context.requested.v1"`. +- `run_filter(cls, context, user_id, course_id)` accepts a `dict` context, an `int` user_id, and a `str`/`CourseKey` course_id, and returns the enriched context dict. +- No exception subclass is defined on this filter. +- A unit test is added (or updated) in the openedx-filters test suite confirming the filter runs the pipeline and returns the context. diff --git a/docs/pluginification/epics/01_grades_analytics_event_enrichment/02_openedx-platform.diff b/docs/pluginification/epics/01_grades_analytics_event_enrichment/02_openedx-platform.diff new file mode 100644 index 0000000000..058d4b9aa5 --- /dev/null +++ b/docs/pluginification/epics/01_grades_analytics_event_enrichment/02_openedx-platform.diff @@ -0,0 +1,90 @@ +diff --git a/lms/djangoapps/grades/events.py b/lms/djangoapps/grades/events.py +--- a/lms/djangoapps/grades/events.py ++++ b/lms/djangoapps/grades/events.py +@@ -27,7 +27,7 @@ from lms.djangoapps.grades.signals.signals import SCHEDULE_FOLLOW_UP_SEGMENT_EV + from openedx.core.djangoapps.content.course_overviews.models import CourseOverview +-from openedx.features.enterprise_support.context import get_enterprise_event_context ++from openedx_filters.learning.filters import GradeEventContextRequested + + log = getLogger(__name__) + +@@ -163,8 +163,11 @@ def course_grade_passed_first_time(user_id, course_id): + event_name = COURSE_GRADE_PASSED_FIRST_TIME_EVENT_TYPE + context = contexts.course_context_from_course_id(course_id) +- context_enterprise = get_enterprise_event_context(user_id, course_id) +- context.update(context_enterprise) ++ enriched_context = GradeEventContextRequested.run_filter( ++ context=context, user_id=user_id, course_id=course_id ++ ) ++ if enriched_context is not None: ++ context = enriched_context + # TODO (AN-6134): remove this context manager + with tracker.get_tracker().context(event_name, context): +diff --git a/lms/envs/common.py b/lms/envs/common.py +--- a/lms/envs/common.py ++++ b/lms/envs/common.py +@@ -,1 +,5 @@ +-OPEN_EDX_FILTERS_CONFIG = {} ++OPEN_EDX_FILTERS_CONFIG = { ++ "org.openedx.learning.grade.context.requested.v1": { ++ "fail_silently": True, ++ "pipeline": ["enterprise.filters.grades.GradeEventContextEnricher"], ++ }, ++} +diff --git a/lms/djangoapps/grades/tests/test_events.py b/lms/djangoapps/grades/tests/test_events.py +--- a/lms/djangoapps/grades/tests/test_events.py ++++ b/lms/djangoapps/grades/tests/test_events.py +@@ -1,6 +1,6 @@ + """ + Test that various events are fired for models in the grades app. + """ + + from unittest import mock ++from unittest.mock import patch + + from ccx_keys.locator import CCXLocator +@@ -34,6 +34,44 @@ from common.test.utils import assert_dict_contains_subset + ++class GradeEventContextFilterTest(SharedModuleStoreTestCase): ++ """ ++ Tests that course_grade_passed_first_time invokes the GradeEventContextRequested ++ filter instead of the old enterprise_support import. ++ """ ++ ++ @classmethod ++ def setUpClass(cls): ++ super().setUpClass() ++ ++ def setUp(self): ++ super().setUp() ++ self.user = UserFactory.create() ++ self.course = CourseFactory.create() ++ ++ @patch('lms.djangoapps.grades.events.GradeEventContextRequested.run_filter') ++ def test_filter_called_with_context(self, mock_run_filter): ++ """ ++ course_grade_passed_first_time should call GradeEventContextRequested.run_filter ++ and merge the returned context. ++ """ ++ enriched = {"org": "test_org", "enterprise_uuid": "abc-123"} ++ mock_run_filter.return_value = enriched ++ ++ from lms.djangoapps.grades.events import course_grade_passed_first_time ++ with patch('lms.djangoapps.grades.events.tracker'): ++ course_grade_passed_first_time(self.user.id, self.course.id) ++ ++ mock_run_filter.assert_called_once() ++ call_kwargs = mock_run_filter.call_args.kwargs ++ assert call_kwargs['user_id'] == self.user.id ++ assert str(call_kwargs['course_id']) == str(self.course.id) ++ ++ @patch('lms.djangoapps.grades.events.GradeEventContextRequested.run_filter') ++ def test_filter_none_return_leaves_context_intact(self, mock_run_filter): ++ """ ++ If run_filter returns None (fail_silently path), context is not overwritten. ++ """ ++ mock_run_filter.return_value = None ++ from lms.djangoapps.grades.events import course_grade_passed_first_time ++ with patch('lms.djangoapps.grades.events.tracker'): ++ # Should not raise even when filter returns None ++ course_grade_passed_first_time(self.user.id, self.course.id) diff --git a/docs/pluginification/epics/01_grades_analytics_event_enrichment/02_openedx-platform.md b/docs/pluginification/epics/01_grades_analytics_event_enrichment/02_openedx-platform.md new file mode 100644 index 0000000000..d562bca31b --- /dev/null +++ b/docs/pluginification/epics/01_grades_analytics_event_enrichment/02_openedx-platform.md @@ -0,0 +1,13 @@ +# [openedx-platform] Use GradeEventContextRequested filter in grades events + +Blocked by: [openedx-platform] Introduce OPEN_EDX_FILTERS_CONFIG and production merge logic (epic 00), [openedx-filters] Add GradeEventContextRequested filter + +Replace the direct import of `get_enterprise_event_context` from `openedx.features.enterprise_support.context` in `lms/djangoapps/grades/events.py` with a call to the new `GradeEventContextRequested` openedx-filter. Add the filter to `OPEN_EDX_FILTERS_CONFIG` in `lms/envs/common.py` with an empty pipeline and `fail_silently=True`. Update tests in `grades/tests/test_events.py` to mock the filter rather than the enterprise function. + +## A/C + +- `from openedx.features.enterprise_support.context import get_enterprise_event_context` is removed from `lms/djangoapps/grades/events.py`. +- `course_grade_passed_first_time` calls `GradeEventContextRequested.run_filter(context=context, user_id=user_id, course_id=course_id)` and updates the local `context` dict with the returned value. +- `OPEN_EDX_FILTERS_CONFIG` in `lms/envs/common.py` gains an entry for `"org.openedx.learning.grade.context.requested.v1"` with `fail_silently=True` and `pipeline=[]`. +- Existing tests in `grades/tests/test_events.py` are updated to patch `GradeEventContextRequested.run_filter` instead of `get_enterprise_event_context`. +- No import of `enterprise_support` or `enterprise` remains in `grades/events.py`. diff --git a/docs/pluginification/epics/01_grades_analytics_event_enrichment/03_edx-enterprise.diff b/docs/pluginification/epics/01_grades_analytics_event_enrichment/03_edx-enterprise.diff new file mode 100644 index 0000000000..f38a2c68e0 --- /dev/null +++ b/docs/pluginification/epics/01_grades_analytics_event_enrichment/03_edx-enterprise.diff @@ -0,0 +1,137 @@ +diff --git a/enterprise/filters/__init__.py b/enterprise/filters/__init__.py +new file mode 100644 +--- /dev/null ++++ b/enterprise/filters/__init__.py +@@ -0,0 +1,3 @@ ++""" ++Filter pipeline step implementations for edx-enterprise openedx-filters integrations. ++""" +diff --git a/enterprise/filters/grades.py b/enterprise/filters/grades.py +new file mode 100644 +--- /dev/null ++++ b/enterprise/filters/grades.py +@@ -0,0 +1,52 @@ ++""" ++Pipeline step for enriching grade analytics event context. ++""" ++from openedx_filters.filters import PipelineStep ++ ++from enterprise.models import EnterpriseCourseEnrollment ++ ++ ++class GradeEventContextEnricher(PipelineStep): ++ """ ++ Enriches a grade analytics event context dict with the learner's enterprise UUID. ++ ++ This step is intended to be registered as a pipeline step for the ++ ``org.openedx.learning.grade.context.requested.v1`` filter. ++ ++ If the user is enrolled in the given course through an enterprise, the enterprise ++ UUID is added to the context under the key ``"enterprise_uuid"``. If the user has ++ no enterprise course enrollment, the context is returned unchanged. ++ """ ++ ++ def run_filter(self, context, user_id, course_id): # pylint: disable=arguments-differ ++ """ ++ Add enterprise UUID to the event context if the user has an enterprise enrollment. ++ ++ Arguments: ++ context (dict): the event tracking context dict. ++ user_id (int): the ID of the user whose grade event is being emitted. ++ course_id (str or CourseKey): the course key for the grade event. ++ ++ Returns: ++ dict: updated pipeline data with the enriched ``context`` dict. ++ """ ++ uuids = EnterpriseCourseEnrollment.get_enterprise_uuids_with_user_and_course( ++ str(user_id), ++ str(course_id), ++ ) ++ if uuids: ++ return {"context": {**context, "enterprise_uuid": str(uuids[0])}} ++ return {"context": context} +diff --git a/tests/filters/__init__.py b/tests/filters/__init__.py +new file mode 100644 +--- /dev/null ++++ b/tests/filters/__init__.py +@@ -0,0 +1 @@ ++"""Tests for enterprise filter pipeline steps.""" +diff --git a/tests/filters/test_grades.py b/tests/filters/test_grades.py +new file mode 100644 +--- /dev/null ++++ b/tests/filters/test_grades.py +@@ -0,0 +1,68 @@ ++""" ++Tests for enterprise.filters.grades pipeline step. ++""" ++import uuid ++from unittest.mock import patch ++ ++from django.test import TestCase ++ ++from enterprise.filters.grades import GradeEventContextEnricher ++ ++ ++class TestGradeEventContextEnricher(TestCase): ++ """ ++ Tests for GradeEventContextEnricher pipeline step. ++ """ ++ ++ def _make_step(self): ++ return GradeEventContextEnricher( ++ "org.openedx.learning.grade.context.requested.v1", ++ [], ++ ) ++ ++ @patch( ++ "enterprise.filters.grades.EnterpriseCourseEnrollment" ++ ".get_enterprise_uuids_with_user_and_course" ++ ) ++ def test_enriches_context_when_enterprise_enrollment_found(self, mock_get_uuids): ++ """ ++ When an enterprise course enrollment exists, enterprise_uuid is added to context. ++ """ ++ enterprise_uuid = uuid.uuid4() ++ mock_get_uuids.return_value = [enterprise_uuid] ++ ++ step = self._make_step() ++ context = {"org": "TestOrg", "course_id": "course-v1:org+course+run"} ++ result = step.run_filter(context=context, user_id=7, course_id="course-v1:org+course+run") ++ ++ assert result == {"context": {**context, "enterprise_uuid": str(enterprise_uuid)}} ++ mock_get_uuids.assert_called_once_with("7", "course-v1:org+course+run") ++ ++ @patch( ++ "enterprise.filters.grades.EnterpriseCourseEnrollment" ++ ".get_enterprise_uuids_with_user_and_course" ++ ) ++ def test_returns_unchanged_context_when_no_enterprise_enrollment(self, mock_get_uuids): ++ """ ++ When no enterprise course enrollment exists, context is returned unchanged. ++ """ ++ mock_get_uuids.return_value = [] ++ ++ step = self._make_step() ++ context = {"org": "TestOrg"} ++ result = step.run_filter(context=context, user_id=99, course_id="course-v1:org+course+run") ++ ++ assert result == {"context": context} ++ assert "enterprise_uuid" not in result["context"] ++ ++ @patch( ++ "enterprise.filters.grades.EnterpriseCourseEnrollment" ++ ".get_enterprise_uuids_with_user_and_course" ++ ) ++ def test_uses_first_uuid_when_multiple_enrollments(self, mock_get_uuids): ++ """ ++ When multiple enterprise enrollments exist, only the first UUID is used. ++ """ ++ first_uuid = uuid.uuid4() ++ second_uuid = uuid.uuid4() ++ mock_get_uuids.return_value = [first_uuid, second_uuid] ++ ++ step = self._make_step() ++ context = {} ++ result = step.run_filter(context=context, user_id=1, course_id="course-v1:x+y+z") ++ ++ assert result["context"]["enterprise_uuid"] == str(first_uuid) diff --git a/docs/pluginification/epics/01_grades_analytics_event_enrichment/03_edx-enterprise.md b/docs/pluginification/epics/01_grades_analytics_event_enrichment/03_edx-enterprise.md new file mode 100644 index 0000000000..d34861826a --- /dev/null +++ b/docs/pluginification/epics/01_grades_analytics_event_enrichment/03_edx-enterprise.md @@ -0,0 +1,14 @@ +# [edx-enterprise] Add GradeEventContextEnricher pipeline step + +Blocked by: [openedx-filters] Add GradeEventContextRequested filter + +Create a new file `enterprise/filters/grades.py` containing the `GradeEventContextEnricher` pipeline step. This step implements the `GradeEventContextRequested` filter by querying `EnterpriseCourseEnrollment.get_enterprise_uuids_with_user_and_course` to determine whether the user is enrolled in the course through an enterprise. If an enterprise UUID is found, the step returns an enriched copy of the context dict containing an `"enterprise_uuid"` key. If no enterprise enrollment is found, the step returns the context unchanged. An `__init__.py` is also created so the `enterprise/filters/` directory is a proper Python package. + +## A/C + +- `enterprise/filters/grades.py` defines `GradeEventContextEnricher(PipelineStep)`. +- `GradeEventContextEnricher.run_filter(self, context, user_id, course_id)` queries `EnterpriseCourseEnrollment.get_enterprise_uuids_with_user_and_course(str(user_id), str(course_id))`. +- When at least one UUID is found, returns `{"context": {**context, "enterprise_uuid": str(uuids[0])}}`. +- When no UUIDs are found, returns `{"context": context}` (unchanged). +- Unit tests are added in `tests/filters/test_grades.py` covering both branches. +- No import of `openedx.features.enterprise_support` in the new pipeline step file. diff --git a/docs/pluginification/epics/01_grades_analytics_event_enrichment/EPIC.md b/docs/pluginification/epics/01_grades_analytics_event_enrichment/EPIC.md new file mode 100644 index 0000000000..ee3c0fa583 --- /dev/null +++ b/docs/pluginification/epics/01_grades_analytics_event_enrichment/EPIC.md @@ -0,0 +1,15 @@ +# Epic: Grades Analytics Event Enrichment + +JIRA: ENT-11563 + +## Purpose + +`openedx-platform/lms/djangoapps/grades/events.py` enriches grade analytics events with enterprise metadata (the enterprise UUID) by directly importing and calling `get_enterprise_event_context` from the `enterprise_support` module, creating a hard import dependency on edx-enterprise at grade-event emission time. + +## Approach + +Introduce a new `GradeEventContextRequested` openedx-filter with signature `run_filter(context, user_id, course_id)`. Replace the direct `get_enterprise_event_context` import and call in `events.py` with a call to this filter. Implement a new `GradeEventContextEnricher` pipeline step in edx-enterprise that queries `EnterpriseCourseEnrollment.get_enterprise_uuids_with_user_and_course` to look up the enterprise UUID and merges it into the context dict. + +## Blocking Epics + +Blocked by epic 00_openedx_filters_config (for the production.py merge logic). Epic 00 has no dependencies of its own and can be shipped as part of this epic's PR. diff --git a/docs/pluginification/epics/02_user_account_readonly_fields/01_openedx-filters.diff b/docs/pluginification/epics/02_user_account_readonly_fields/01_openedx-filters.diff new file mode 100644 index 0000000000..bb0d9643e9 --- /dev/null +++ b/docs/pluginification/epics/02_user_account_readonly_fields/01_openedx-filters.diff @@ -0,0 +1,80 @@ +diff --git a/openedx_filters/learning/filters.py b/openedx_filters/learning/filters.py +--- a/openedx_filters/learning/filters.py ++++ b/openedx_filters/learning/filters.py +@@ -1444,3 +1444,40 @@ class ScheduleQuerySetRequested(OpenEdxPublicFilter): + data = super().run_pipeline(schedules=schedules) + return data.get("schedules") ++ ++ ++class AccountSettingsReadOnlyFieldsRequested(OpenEdxPublicFilter): ++ """ ++ Filter used to expand the set of read-only fields on the account settings API. ++ ++ Purpose: ++ This filter is triggered when the account settings API validates which fields ++ may be updated for a given user. Pipeline steps may add field names to ++ ``readonly_fields`` to mark those fields as read-only for the requesting user. ++ ++ Filter Type: ++ org.openedx.learning.account.settings.read_only_fields.requested.v1 ++ ++ Trigger: ++ - Repository: openedx/edx-platform ++ - Path: openedx/core/djangoapps/user_api/accounts/api.py ++ - Function or Method: _validate_read_only_fields ++ """ ++ ++ filter_type = "org.openedx.learning.account.settings.read_only_fields.requested.v1" ++ ++ @classmethod ++ def run_filter(cls, readonly_fields: set, user: Any) -> set: ++ """ ++ Process the readonly_fields set using the configured pipeline steps. ++ ++ Arguments: ++ readonly_fields (set): the set of field names the caller considers read-only. ++ Pipeline steps add field names to this set to mark additional fields as ++ read-only. ++ user (User): the Django User whose account settings are being updated. ++ ++ Returns: ++ set: the (possibly expanded) set of read-only field names. ++ """ ++ data = super().run_pipeline(readonly_fields=readonly_fields, user=user) ++ return data.get("readonly_fields") +diff --git a/openedx_filters/learning/tests/test_filters.py b/openedx_filters/learning/tests/test_filters.py +--- a/openedx_filters/learning/tests/test_filters.py ++++ b/openedx_filters/learning/tests/test_filters.py +@@ -1,0 +1,28 @@ ++class TestAccountSettingsReadOnlyFieldsRequestedFilter(TestCase): ++ """ ++ Tests for the AccountSettingsReadOnlyFieldsRequested filter. ++ """ ++ ++ def test_run_filter_returns_empty_set_unchanged_when_no_pipeline(self): ++ """ ++ When no pipeline steps are configured, run_filter returns the original readonly_fields. ++ """ ++ from unittest.mock import Mock, patch ++ from openedx_filters.learning.filters import AccountSettingsReadOnlyFieldsRequested ++ ++ readonly_fields = set() ++ user = Mock() ++ ++ with patch.object( ++ AccountSettingsReadOnlyFieldsRequested, ++ "run_pipeline", ++ return_value={"readonly_fields": readonly_fields}, ++ ): ++ result = AccountSettingsReadOnlyFieldsRequested.run_filter( ++ readonly_fields=readonly_fields, user=user ++ ) ++ ++ self.assertEqual(result, readonly_fields) ++ ++ def test_filter_type(self): ++ from openedx_filters.learning.filters import AccountSettingsReadOnlyFieldsRequested ++ self.assertEqual( ++ AccountSettingsReadOnlyFieldsRequested.filter_type, ++ "org.openedx.learning.account.settings.read_only_fields.requested.v1", ++ ) diff --git a/docs/pluginification/epics/02_user_account_readonly_fields/01_openedx-filters.md b/docs/pluginification/epics/02_user_account_readonly_fields/01_openedx-filters.md new file mode 100644 index 0000000000..782b6f180b --- /dev/null +++ b/docs/pluginification/epics/02_user_account_readonly_fields/01_openedx-filters.md @@ -0,0 +1,13 @@ +# [openedx-filters] Add AccountSettingsReadOnlyFieldsRequested filter + +No tickets block this one. + +Add a new `AccountSettingsReadOnlyFieldsRequested` filter class to `openedx_filters/learning/filters.py`. This filter is invoked when the account settings API validates which fields may be updated, and allows pipeline steps to remove fields from the editable set (i.e. mark them as read-only). The filter accepts the current set of editable field names and the Django User object, and returns the (possibly reduced) set. No exception class is required. This filter must not be confused with the existing `AccountSettingsRenderStarted` filter, which targets the legacy account settings page render and is not invoked from the account settings API. + +## A/C + +- A new `AccountSettingsReadOnlyFieldsRequested` class is added to `openedx_filters/learning/filters.py`, inheriting from `OpenEdxPublicFilter`. +- The filter type is `"org.openedx.learning.account.settings.read_only_fields.requested.v1"`. +- `run_filter(cls, editable_fields, user)` accepts a `set` of field name strings and a Django User object, and returns the possibly-reduced set of editable fields. +- No exception subclass is defined on this filter. +- A unit test confirms the filter returns `editable_fields` unchanged when no pipeline steps are configured. diff --git a/docs/pluginification/epics/02_user_account_readonly_fields/02_openedx-platform.diff b/docs/pluginification/epics/02_user_account_readonly_fields/02_openedx-platform.diff new file mode 100644 index 0000000000..96eced5bc9 --- /dev/null +++ b/docs/pluginification/epics/02_user_account_readonly_fields/02_openedx-platform.diff @@ -0,0 +1,57 @@ +diff --git a/openedx/core/djangoapps/user_api/accounts/api.py b/openedx/core/djangoapps/user_api/accounts/api.py +--- a/openedx/core/djangoapps/user_api/accounts/api.py ++++ b/openedx/core/djangoapps/user_api/accounts/api.py +@@ -39,7 +39,7 @@ from openedx.core.djangoapps.user_authn.views.registration_form import validate_ + from openedx.core.lib.api.view_utils import add_serializer_errors +-from openedx.features.enterprise_support.utils import get_enterprise_readonly_account_fields ++from openedx_filters.learning.filters import AccountSettingsReadOnlyFieldsRequested + from openedx.features.name_affirmation_api.utils import is_name_affirmation_installed + + from .serializers import AccountLegacyProfileSerializer, AccountUserSerializer, UserReadOnlySerializer, _visible_fields +@@ -194,9 +194,13 @@ def _validate_read_only_fields(user, data, field_errors): + # Check for fields that are not editable. Marking them read-only causes them to be ignored, but we wish to 400. ++ plugin_readonly_fields = AccountSettingsReadOnlyFieldsRequested.run_filter( ++ readonly_fields=set(), ++ user=user, ++ ) or set() ++ + read_only_fields = set(data.keys()).intersection( + # Remove email since it is handled separately below when checking for changing_email. + (set(AccountUserSerializer.get_read_only_fields()) - {"email"}) | +- set(AccountLegacyProfileSerializer.get_read_only_fields() or set()) | +- get_enterprise_readonly_account_fields(user) ++ set(AccountLegacyProfileSerializer.get_read_only_fields() or set()) | ++ plugin_readonly_fields + ) + + for read_only_field in read_only_fields: +diff --git a/lms/envs/common.py b/lms/envs/common.py +--- a/lms/envs/common.py ++++ b/lms/envs/common.py +@@ -1,0 +1,0 @@ + OPEN_EDX_FILTERS_CONFIG = { + "org.openedx.learning.grade.context.requested.v1": { + "fail_silently": True, + "pipeline": ["enterprise.filters.grades.GradeEventContextEnricher"], + }, ++ "org.openedx.learning.account.settings.read_only_fields.requested.v1": { ++ "fail_silently": True, ++ "pipeline": ["enterprise.filters.accounts.AccountSettingsReadOnlyFieldsStep"], ++ }, + } +diff --git a/openedx/core/djangoapps/user_api/accounts/tests/test_api.py b/openedx/core/djangoapps/user_api/accounts/tests/test_api.py +--- a/openedx/core/djangoapps/user_api/accounts/tests/test_api.py ++++ b/openedx/core/djangoapps/user_api/accounts/tests/test_api.py +@@ -100,11 +100,11 @@ class TestAccountUpdateSettings(TestCase): + def setUp(self): + ... +- enterprise_patcher = patch('openedx.features.enterprise_support.api.enterprise_customer_for_request') +- enterprise_learner_patcher = enterprise_patcher.start() +- enterprise_learner_patcher.return_value = {} +- self.addCleanup(enterprise_learner_patcher.stop) ++ filter_patcher = patch( ++ 'openedx.core.djangoapps.user_api.accounts.api.AccountSettingsReadOnlyFieldsRequested.run_filter', ++ return_value=set(), ++ ) ++ filter_patcher.start() ++ self.addCleanup(filter_patcher.stop) diff --git a/docs/pluginification/epics/02_user_account_readonly_fields/02_openedx-platform.md b/docs/pluginification/epics/02_user_account_readonly_fields/02_openedx-platform.md new file mode 100644 index 0000000000..1a1812c5a1 --- /dev/null +++ b/docs/pluginification/epics/02_user_account_readonly_fields/02_openedx-platform.md @@ -0,0 +1,14 @@ +# [openedx-platform] Use AccountSettingsReadOnlyFieldsRequested filter in accounts API + +Blocked by: [openedx-filters] Add AccountSettingsReadOnlyFieldsRequested filter + +Replace the direct import of `get_enterprise_readonly_account_fields` from `openedx.features.enterprise_support.utils` in `openedx/core/djangoapps/user_api/accounts/api.py` with a call to the new `AccountSettingsReadOnlyFieldsRequested` filter. The filter is invoked inside `_validate_read_only_fields` to obtain the set of additional read-only fields contributed by any installed plugin. Add the filter to `OPEN_EDX_FILTERS_CONFIG` in `lms/envs/common.py` (extending the dict added in epic 01 if already present). Update test mocks in `accounts/tests/test_api.py` to patch the filter instead of patching the enterprise_support functions directly. + +## A/C + +- `from openedx.features.enterprise_support.utils import get_enterprise_readonly_account_fields` is removed from `openedx/core/djangoapps/user_api/accounts/api.py`. +- `_validate_read_only_fields` calls `AccountSettingsReadOnlyFieldsRequested.run_filter(editable_fields=set(), user=user)` and uses the returned set in place of the direct `get_enterprise_readonly_account_fields` call. +- `OPEN_EDX_FILTERS_CONFIG` in `lms/envs/common.py` contains an entry for `"org.openedx.learning.account.settings.read_only_fields.requested.v1"` with `fail_silently=True` and `pipeline=[]`. +- `lms/envs/production.py` merge logic (added in epic 01) handles this new filter type automatically (no additional production.py changes needed if epic 01 is merged first). +- Tests in `accounts/tests/test_api.py` are updated to patch `AccountSettingsReadOnlyFieldsRequested.run_filter` instead of the old enterprise_support imports. +- No import of `enterprise_support` or `enterprise` remains in `openedx/core/djangoapps/user_api/accounts/api.py`. diff --git a/docs/pluginification/epics/02_user_account_readonly_fields/03_edx-enterprise.diff b/docs/pluginification/epics/02_user_account_readonly_fields/03_edx-enterprise.diff new file mode 100644 index 0000000000..4bb56877cd --- /dev/null +++ b/docs/pluginification/epics/02_user_account_readonly_fields/03_edx-enterprise.diff @@ -0,0 +1,188 @@ +diff --git a/enterprise/filters/accounts.py b/enterprise/filters/accounts.py +new file mode 100644 +--- /dev/null ++++ b/enterprise/filters/accounts.py +@@ -0,0 +1,88 @@ ++""" ++Pipeline step for determining read-only account settings fields. ++""" ++from django.conf import settings ++from openedx_filters.filters import PipelineStep ++from social_django.models import UserSocialAuth ++ ++from enterprise.models import EnterpriseCustomerIdentityProvider, EnterpriseCustomerUser ++ ++ ++class AccountSettingsReadOnlyFieldsStep(PipelineStep): ++ """ ++ Adds SSO-managed fields to the read-only account settings fields set. ++ ++ This step is intended to be registered as a pipeline step for the ++ ``org.openedx.learning.account.settings.read_only_fields.requested.v1`` filter. ++ ++ When a user is linked to an enterprise customer whose SSO identity provider has ++ ``sync_learner_profile_data`` enabled, the fields listed in ++ ``settings.ENTERPRISE_READONLY_ACCOUNT_FIELDS`` are added to ``readonly_fields``. ++ The ``"name"`` field is only added when the user has an existing ``UserSocialAuth`` ++ record for the enterprise IdP backend. ++ """ ++ ++ def run_filter(self, readonly_fields, user): # pylint: disable=arguments-differ ++ """ ++ Add enterprise SSO-managed fields to the read-only fields set. ++ ++ Arguments: ++ readonly_fields (set): current set of read-only account field names. ++ user (User): the Django User whose account settings are being updated. ++ ++ Returns: ++ dict: updated pipeline data with ``readonly_fields`` key. ++ """ ++ try: ++ enterprise_customer_user = EnterpriseCustomerUser.objects.select_related( ++ 'enterprise_customer' ++ ).get(user_id=user.id) ++ except EnterpriseCustomerUser.DoesNotExist: ++ return {"readonly_fields": readonly_fields} ++ ++ enterprise_customer = enterprise_customer_user.enterprise_customer ++ ++ try: ++ idp_record = EnterpriseCustomerIdentityProvider.objects.get( ++ enterprise_customer=enterprise_customer ++ ) ++ except EnterpriseCustomerIdentityProvider.DoesNotExist: ++ return {"readonly_fields": readonly_fields} ++ ++ try: ++ from common.djangoapps import third_party_auth # avoid circular import at module load ++ identity_provider = third_party_auth.provider.Registry.get( ++ provider_id=idp_record.provider_id ++ ) ++ except Exception: # pylint: disable=broad-except ++ return {"readonly_fields": readonly_fields} ++ ++ if not identity_provider or not getattr(identity_provider, 'sync_learner_profile_data', False): ++ return {"readonly_fields": readonly_fields} ++ ++ enterprise_readonly = set(getattr(settings, 'ENTERPRISE_READONLY_ACCOUNT_FIELDS', [])) ++ ++ if 'name' in enterprise_readonly: ++ backend_name = getattr(identity_provider, 'backend_name', None) ++ has_social_auth = ( ++ backend_name ++ and UserSocialAuth.objects.filter(provider=backend_name, user=user).exists() ++ ) ++ if not has_social_auth: ++ enterprise_readonly = enterprise_readonly - {'name'} ++ ++ return {"readonly_fields": readonly_fields | enterprise_readonly} +diff --git a/tests/filters/test_accounts.py b/tests/filters/test_accounts.py +new file mode 100644 +--- /dev/null ++++ b/tests/filters/test_accounts.py +@@ -0,0 +1,97 @@ ++""" ++Tests for enterprise.filters.accounts pipeline step. ++""" ++from unittest.mock import MagicMock, patch ++ ++from django.test import TestCase, override_settings ++ ++from enterprise.filters.accounts import AccountSettingsReadOnlyFieldsStep ++ ++ ++class TestAccountSettingsReadOnlyFieldsStep(TestCase): ++ """ ++ Tests for AccountSettingsReadOnlyFieldsStep pipeline step. ++ """ ++ ++ def _make_step(self): ++ return AccountSettingsReadOnlyFieldsStep( ++ "org.openedx.learning.account.settings.read_only_fields.requested.v1", ++ [], ++ ) ++ ++ def _mock_user(self, user_id=42): ++ user = MagicMock() ++ user.id = user_id ++ return user ++ ++ @patch('enterprise.filters.accounts.EnterpriseCustomerUser.objects') ++ def test_returns_unchanged_readonly_fields_when_no_enterprise_user(self, mock_objects): ++ """ ++ When the user has no enterprise link, readonly_fields is returned unchanged. ++ """ ++ from enterprise.models import EnterpriseCustomerUser ++ mock_objects.select_related.return_value.get.side_effect = EnterpriseCustomerUser.DoesNotExist ++ step = self._make_step() ++ fields = set() ++ result = step.run_filter(readonly_fields=fields, user=self._mock_user()) ++ self.assertEqual(result, {"readonly_fields": fields}) ++ ++ @patch('enterprise.filters.accounts.UserSocialAuth.objects') ++ @patch('enterprise.filters.accounts.EnterpriseCustomerIdentityProvider.objects') ++ @patch('enterprise.filters.accounts.EnterpriseCustomerUser.objects') ++ @override_settings(ENTERPRISE_READONLY_ACCOUNT_FIELDS=['name', 'email', 'country']) ++ def test_adds_readonly_fields_when_sso_sync_enabled( ++ self, mock_ecu_objects, mock_idp_objects, mock_social_auth_objects ++ ): ++ """ ++ When enterprise SSO sync is enabled and social auth record exists, ++ ENTERPRISE_READONLY_ACCOUNT_FIELDS are added to readonly_fields. ++ """ ++ user = self._mock_user() ++ mock_ecu = MagicMock() ++ mock_ecu_objects.select_related.return_value.get.return_value = mock_ecu ++ mock_idp_record = MagicMock() ++ mock_idp_record.provider_id = 'saml-ubc' ++ mock_idp_objects.get.return_value = mock_idp_record ++ mock_identity_provider = MagicMock() ++ mock_identity_provider.sync_learner_profile_data = True ++ mock_identity_provider.backend_name = 'tpa-saml' ++ mock_social_auth_objects.filter.return_value.exists.return_value = True ++ ++ with patch( ++ 'enterprise.filters.accounts.third_party_auth.provider.Registry.get', ++ return_value=mock_identity_provider, ++ ): ++ step = self._make_step() ++ result = step.run_filter( ++ readonly_fields=set(), ++ user=user, ++ ) ++ ++ self.assertEqual(result["readonly_fields"], {"name", "email", "country"}) ++ ++ @patch('enterprise.filters.accounts.UserSocialAuth.objects') ++ @patch('enterprise.filters.accounts.EnterpriseCustomerIdentityProvider.objects') ++ @patch('enterprise.filters.accounts.EnterpriseCustomerUser.objects') ++ @override_settings(ENTERPRISE_READONLY_ACCOUNT_FIELDS=['name', 'email']) ++ def test_name_not_added_without_social_auth_record( ++ self, mock_ecu_objects, mock_idp_objects, mock_social_auth_objects ++ ): ++ """ ++ The 'name' field is not added when the user has no UserSocialAuth record. ++ """ ++ user = self._mock_user() ++ mock_ecu_objects.select_related.return_value.get.return_value = MagicMock() ++ mock_idp_record = MagicMock() ++ mock_idp_record.provider_id = 'saml-ubc' ++ mock_idp_objects.get.return_value = mock_idp_record ++ mock_identity_provider = MagicMock() ++ mock_identity_provider.sync_learner_profile_data = True ++ mock_identity_provider.backend_name = 'tpa-saml' ++ mock_social_auth_objects.filter.return_value.exists.return_value = False ++ ++ with patch( ++ 'enterprise.filters.accounts.third_party_auth.provider.Registry.get', ++ return_value=mock_identity_provider, ++ ): ++ step = self._make_step() ++ result = step.run_filter( ++ readonly_fields=set(), ++ user=user, ++ ) ++ ++ self.assertNotIn("name", result["readonly_fields"]) ++ self.assertIn("email", result["readonly_fields"]) diff --git a/docs/pluginification/epics/02_user_account_readonly_fields/03_edx-enterprise.md b/docs/pluginification/epics/02_user_account_readonly_fields/03_edx-enterprise.md new file mode 100644 index 0000000000..ade2ac9c09 --- /dev/null +++ b/docs/pluginification/epics/02_user_account_readonly_fields/03_edx-enterprise.md @@ -0,0 +1,16 @@ +# [edx-enterprise] Add AccountSettingsReadOnlyFieldsStep pipeline step + +Blocked by: [openedx-filters] Add AccountSettingsReadOnlyFieldsRequested filter + +Create a new file `enterprise/filters/accounts.py` containing the `AccountSettingsReadOnlyFieldsStep` pipeline step. This step implements the `AccountSettingsReadOnlyFieldsRequested` filter by checking whether the user is linked to an enterprise customer that has an SSO identity provider with `sync_learner_profile_data` enabled. If so, the step removes the field names listed in `settings.ENTERPRISE_READONLY_ACCOUNT_FIELDS` from the `editable_fields` set and returns the reduced set. The step uses `EnterpriseCustomerUser` and `EnterpriseCustomerIdentityProvider` models and the `third_party_auth` registry, mirroring the logic previously in `get_enterprise_readonly_account_fields` in enterprise_support. + +## A/C + +- `enterprise/filters/accounts.py` defines `AccountSettingsReadOnlyFieldsStep(PipelineStep)`. +- The step queries `EnterpriseCustomerUser` to find the user's enterprise customer link. +- If found, the step checks whether `sync_learner_profile_data` is enabled via `EnterpriseCustomerIdentityProvider` and `third_party_auth` registry. +- If the user has a social auth record and SSO sync is active, the step returns `{"editable_fields": editable_fields - set(settings.ENTERPRISE_READONLY_ACCOUNT_FIELDS)}`. +- If the user's fullname is not SSO-backed (no `UserSocialAuth` record), `"name"` is not removed from `editable_fields`. +- If no enterprise link or no SSO sync, returns `{"editable_fields": editable_fields}` unchanged. +- Unit tests are added in `tests/filters/test_accounts.py`. +- No import of `openedx.features.enterprise_support` in the pipeline step file. diff --git a/docs/pluginification/epics/02_user_account_readonly_fields/EPIC.md b/docs/pluginification/epics/02_user_account_readonly_fields/EPIC.md new file mode 100644 index 0000000000..f172ef4b23 --- /dev/null +++ b/docs/pluginification/epics/02_user_account_readonly_fields/EPIC.md @@ -0,0 +1,15 @@ +# Epic: User Account Readonly Fields + +JIRA: ENT-11510 + +## Purpose + +`openedx-platform/openedx/core/djangoapps/user_api/accounts/api.py` imports `get_enterprise_readonly_account_fields` from `enterprise_support.utils` to prevent SSO-managed account fields from being edited by enterprise learners, creating a hard dependency on edx-enterprise inside the core user API. + +## Approach + +Introduce a new `AccountSettingsReadOnlyFieldsRequested` openedx-filter with signature `run_filter(editable_fields, user)` that returns a (possibly reduced) set of editable field names. Replace the direct call to `get_enterprise_readonly_account_fields` in `api.py` with a call to this filter. Implement a new `AccountSettingsReadOnlyFieldsStep` pipeline step in edx-enterprise that checks if the user is linked to an enterprise SSO identity provider with `sync_learner_profile_data` enabled, and if so removes the fields in `settings.ENTERPRISE_READONLY_ACCOUNT_FIELDS` from the editable set. + +## Blocking Epics + +None. This epic has no dependencies and can start immediately. diff --git a/docs/pluginification/epics/03_discount_enterprise_learner_exclusion/01_openedx-filters.diff b/docs/pluginification/epics/03_discount_enterprise_learner_exclusion/01_openedx-filters.diff new file mode 100644 index 0000000000..8ec30d8df3 --- /dev/null +++ b/docs/pluginification/epics/03_discount_enterprise_learner_exclusion/01_openedx-filters.diff @@ -0,0 +1,86 @@ +diff --git a/openedx_filters/learning/filters.py b/openedx_filters/learning/filters.py +--- a/openedx_filters/learning/filters.py ++++ b/openedx_filters/learning/filters.py +@@ -1444,3 +1444,52 @@ class ScheduleQuerySetRequested(OpenEdxPublicFilter): + data = super().run_pipeline(schedules=schedules) + return data.get("schedules") ++ ++ ++class DiscountEligibilityCheckRequested(OpenEdxPublicFilter): ++ """ ++ Filter used to allow plugins to mark a user as ineligible for a course discount. ++ ++ Purpose: ++ This filter is triggered during discount applicability checks, just before the ++ final eligibility decision is returned to the caller. Pipeline steps may set ++ ``is_eligible`` to ``False`` to exclude a user from receiving a discount. ++ ++ Filter Type: ++ org.openedx.learning.discount.eligibility.check.requested.v1 ++ ++ Trigger: ++ - Repository: openedx/edx-platform ++ - Path: openedx/features/discounts/applicability.py ++ - Function or Method: can_receive_discount, can_show_streak_discount_coupon ++ """ ++ ++ filter_type = "org.openedx.learning.discount.eligibility.check.requested.v1" ++ ++ @classmethod ++ def run_filter( ++ cls, ++ user: Any, ++ course_key: Any, ++ is_eligible: bool, ++ ) -> tuple: ++ """ ++ Process the inputs using the configured pipeline steps. ++ ++ Arguments: ++ user (User): the Django User being checked for discount eligibility. ++ course_key (CourseKey or course object): identifies the course. ++ is_eligible (bool): the current eligibility status before plugin evaluation. ++ ++ Returns: ++ tuple[User, CourseKey, bool]: ++ - User: the Django User object (unchanged). ++ - CourseKey: the course key (unchanged). ++ - bool: the (possibly overridden) eligibility flag. ++ """ ++ data = super().run_pipeline(user=user, course_key=course_key, is_eligible=is_eligible) ++ return data.get("user"), data.get("course_key"), data.get("is_eligible") +diff --git a/openedx_filters/learning/tests/test_filters.py b/openedx_filters/learning/tests/test_filters.py +--- a/openedx_filters/learning/tests/test_filters.py ++++ b/openedx_filters/learning/tests/test_filters.py +@@ -1,0 +1,32 @@ ++class TestDiscountEligibilityCheckRequestedFilter(TestCase): ++ """ ++ Tests for the DiscountEligibilityCheckRequested filter. ++ """ ++ ++ def test_filter_type(self): ++ from openedx_filters.learning.filters import DiscountEligibilityCheckRequested ++ self.assertEqual( ++ DiscountEligibilityCheckRequested.filter_type, ++ "org.openedx.learning.discount.eligibility.check.requested.v1", ++ ) ++ ++ def test_run_filter_returns_false_when_pipeline_sets_ineligible(self): ++ from unittest.mock import Mock, patch ++ from openedx_filters.learning.filters import DiscountEligibilityCheckRequested ++ ++ user = Mock() ++ course_key = Mock() ++ ++ with patch.object( ++ DiscountEligibilityCheckRequested, ++ "run_pipeline", ++ return_value={"user": user, "course_key": course_key, "is_eligible": False}, ++ ): ++ returned_user, returned_course_key, is_eligible = DiscountEligibilityCheckRequested.run_filter( ++ user=user, course_key=course_key, is_eligible=True ++ ) ++ ++ self.assertFalse(is_eligible) ++ self.assertIs(returned_user, user) ++ self.assertIs(returned_course_key, course_key) diff --git a/docs/pluginification/epics/03_discount_enterprise_learner_exclusion/01_openedx-filters.md b/docs/pluginification/epics/03_discount_enterprise_learner_exclusion/01_openedx-filters.md new file mode 100644 index 0000000000..8c9c2bc666 --- /dev/null +++ b/docs/pluginification/epics/03_discount_enterprise_learner_exclusion/01_openedx-filters.md @@ -0,0 +1,13 @@ +# [openedx-filters] Add DiscountEligibilityCheckRequested filter + +No tickets block this one. + +Add a new `DiscountEligibilityCheckRequested` filter class to `openedx_filters/learning/filters.py`. This filter is invoked during discount applicability checks and allows pipeline steps to mark a user/course combination as ineligible for a discount. The filter accepts the Django User, the course key, and the current boolean eligibility flag, and returns the (possibly overridden) `is_eligible` bool along with the unchanged `user` and `course_key`. No exception class is required since this filter is configured with `fail_silently=True` and the caller falls back to the pre-filter value. + +## A/C + +- A new `DiscountEligibilityCheckRequested` class is added to `openedx_filters/learning/filters.py`, inheriting from `OpenEdxPublicFilter`. +- The filter type is `"org.openedx.learning.discount.eligibility.check.requested.v1"`. +- `run_filter(cls, user, course_key, is_eligible)` returns a tuple `(user, course_key, is_eligible)` where `is_eligible` is a bool. +- No exception subclass is defined on this filter. +- A unit test confirms that when `run_pipeline` returns `is_eligible=False`, the filter returns `False` for `is_eligible`. diff --git a/docs/pluginification/epics/03_discount_enterprise_learner_exclusion/02_openedx-platform.diff b/docs/pluginification/epics/03_discount_enterprise_learner_exclusion/02_openedx-platform.diff new file mode 100644 index 0000000000..cc4d699425 --- /dev/null +++ b/docs/pluginification/epics/03_discount_enterprise_learner_exclusion/02_openedx-platform.diff @@ -0,0 +1,112 @@ +diff --git a/openedx/features/discounts/applicability.py b/openedx/features/discounts/applicability.py +--- a/openedx/features/discounts/applicability.py ++++ b/openedx/features/discounts/applicability.py +@@ -26,6 +26,8 @@ from common.djangoapps.track import segment ++from openedx_filters.learning.filters import DiscountEligibilityCheckRequested ++ + + # .. toggle_name: discounts.enable_first_purchase_discount_override +@@ -97,12 +97,12 @@ def can_show_streak_discount_coupon(user, course): + if not is_mode_upsellable(user, enrollment): + return False + +- # We can't import this at Django load time within the openedx tests settings context +- from openedx.features.enterprise_support.utils import is_enterprise_learner +- # Don't give discount to enterprise users +- if is_enterprise_learner(user): ++ # Allow plugins to mark this user as ineligible for the discount. ++ _user, _course_key, is_eligible = DiscountEligibilityCheckRequested.run_filter( ++ user=user, course_key=course.id, is_eligible=True ++ ) ++ if not is_eligible: + return False + + return True +@@ -180,11 +180,11 @@ def can_receive_discount(user, course, discount_expiration_date=None): + if CourseEntitlement.objects.filter(user=user).exists(): + return False + +- # We can't import this at Django load time within the openedx tests settings context +- from openedx.features.enterprise_support.utils import is_enterprise_learner +- # Don't give discount to enterprise users +- if is_enterprise_learner(user): ++ # Allow plugins to mark this user as ineligible for the discount. ++ _user, _course_key, is_eligible = DiscountEligibilityCheckRequested.run_filter( ++ user=user, course_key=course.id, is_eligible=True ++ ) ++ if not is_eligible: + return False + + # Turn holdback on +diff --git a/lms/envs/common.py b/lms/envs/common.py +--- a/lms/envs/common.py ++++ b/lms/envs/common.py +@@ -1,0 +1,0 @@ + OPEN_EDX_FILTERS_CONFIG = { + "org.openedx.learning.grade.context.requested.v1": { + "fail_silently": True, + "pipeline": ["enterprise.filters.grades.GradeEventContextEnricher"], + }, + "org.openedx.learning.account.settings.read_only_fields.requested.v1": { + "fail_silently": True, + "pipeline": ["enterprise.filters.accounts.AccountSettingsReadOnlyFieldsStep"], + }, ++ "org.openedx.learning.discount.eligibility.check.requested.v1": { ++ "fail_silently": True, ++ "pipeline": ["enterprise.filters.discounts.DiscountEligibilityStep"], ++ }, + } +diff --git a/openedx/features/discounts/tests/test_applicability.py b/openedx/features/discounts/tests/test_applicability.py +--- a/openedx/features/discounts/tests/test_applicability.py ++++ b/openedx/features/discounts/tests/test_applicability.py +@@ -1,7 +1,6 @@ + """Tests of openedx.features.discounts.applicability""" + + from datetime import datetime, timedelta +-from unittest.mock import Mock, patch ++from unittest.mock import Mock, patch, MagicMock + + import ddt + import pytest +@@ -12,7 +11,6 @@ from edx_toggles.toggles.testutils import override_waffle_flag +-from enterprise.models import EnterpriseCustomer, EnterpriseCustomerUser + + from common.djangoapps.course_modes.models import CourseMode + from common.djangoapps.course_modes.tests.factories import CourseModeFactory +@@ -47,6 +47,15 @@ class TestApplicability(ModuleStoreTestCase): + holdback_patcher = patch( + 'openedx.features.discounts.applicability._is_in_holdback_and_bucket', return_value=False + ) + self.mock_holdback = holdback_patcher.start() + self.addCleanup(holdback_patcher.stop) ++ ++ # By default, the filter passes eligibility through unchanged. ++ discount_filter_patcher = patch( ++ 'openedx.features.discounts.applicability.DiscountEligibilityCheckRequested.run_filter', ++ side_effect=lambda user, course_key, is_eligible: (user, course_key, is_eligible), ++ ) ++ self.mock_discount_filter = discount_filter_patcher.start() ++ self.addCleanup(discount_filter_patcher.stop) ++ +@@ -136,21 +147,14 @@ class TestApplicability(ModuleStoreTestCase): + @override_waffle_flag(DISCOUNT_APPLICABILITY_FLAG, active=True) +- def test_can_receive_discount_false_enterprise(self): ++ def test_can_receive_discount_false_when_filter_marks_ineligible(self): + """ +- Ensure that enterprise users do not receive the discount. ++ Ensure that when the eligibility filter marks the user as ineligible, ++ no discount is received. + """ +- enterprise_customer = EnterpriseCustomer.objects.create( +- name='Test EnterpriseCustomer', +- site=self.site +- ) +- EnterpriseCustomerUser.objects.create( +- user_id=self.user.id, +- enterprise_customer=enterprise_customer ++ self.mock_discount_filter.side_effect = lambda user, course_key, is_eligible: ( ++ user, course_key, False + ) + + applicability = can_receive_discount(user=self.user, course=self.course) + assert applicability is False diff --git a/docs/pluginification/epics/03_discount_enterprise_learner_exclusion/02_openedx-platform.md b/docs/pluginification/epics/03_discount_enterprise_learner_exclusion/02_openedx-platform.md new file mode 100644 index 0000000000..cc81b113d3 --- /dev/null +++ b/docs/pluginification/epics/03_discount_enterprise_learner_exclusion/02_openedx-platform.md @@ -0,0 +1,14 @@ +# [openedx-platform] Use DiscountEligibilityCheckRequested filter in discount applicability + +Blocked by: [openedx-filters] Add DiscountEligibilityCheckRequested filter + +Replace both lazy `from openedx.features.enterprise_support.utils import is_enterprise_learner` imports in `openedx/features/discounts/applicability.py` — one in `can_receive_discount` and one in `can_show_streak_discount_coupon` — with calls to the new `DiscountEligibilityCheckRequested` filter. The filter is called with the current `is_eligible` state (`True` at that point in the function) and the function short-circuits to return `False` when the filter returns `is_eligible=False`. Add the filter to `OPEN_EDX_FILTERS_CONFIG` in `lms/envs/common.py`. Update tests in `discounts/tests/test_applicability.py` to mock the filter rather than directly using enterprise model factories. + +## A/C + +- Both lazy `from openedx.features.enterprise_support.utils import is_enterprise_learner` imports are removed from `applicability.py`. +- In `can_receive_discount`, the `is_enterprise_learner(user)` check is replaced by `DiscountEligibilityCheckRequested.run_filter(user=user, course_key=course.id, is_eligible=True)`, returning `False` if `is_eligible` comes back `False`. +- In `can_show_streak_discount_coupon`, the same replacement is applied. +- `OPEN_EDX_FILTERS_CONFIG` in `lms/envs/common.py` contains an entry for `"org.openedx.learning.discount.eligibility.check.requested.v1"` with `fail_silently=True` and `pipeline=[]`. +- Tests in `discounts/tests/test_applicability.py` are updated to patch `DiscountEligibilityCheckRequested.run_filter` rather than importing enterprise models. +- No import of `enterprise_support` or `enterprise` remains in `applicability.py`. diff --git a/docs/pluginification/epics/03_discount_enterprise_learner_exclusion/03_edx-enterprise.diff b/docs/pluginification/epics/03_discount_enterprise_learner_exclusion/03_edx-enterprise.diff new file mode 100644 index 0000000000..4081e7bce3 --- /dev/null +++ b/docs/pluginification/epics/03_discount_enterprise_learner_exclusion/03_edx-enterprise.diff @@ -0,0 +1,130 @@ +diff --git a/enterprise/filters/discounts.py b/enterprise/filters/discounts.py +new file mode 100644 +--- /dev/null ++++ b/enterprise/filters/discounts.py +@@ -0,0 +1,51 @@ ++""" ++Pipeline step for excluding certain learners from course discounts. ++""" ++from openedx_filters.filters import PipelineStep ++ ++ ++class DiscountEligibilityStep(PipelineStep): ++ """ ++ Marks learners linked to an enterprise as ineligible for LMS-controlled discounts. ++ ++ This step is intended to be registered as a pipeline step for the ++ ``org.openedx.learning.discount.eligibility.check.requested.v1`` filter. ++ ++ LMS-controlled discounts (such as the first-purchase offer) are not applicable to ++ learners whose enrollment is managed by an enterprise. This step queries the ++ enterprise learner status and, if the user qualifies, sets ``is_eligible`` to ++ ``False`` so the calling code skips the discount. ++ """ ++ ++ def run_filter(self, user, course_key, is_eligible): # pylint: disable=arguments-differ ++ """ ++ Return ``is_eligible=False`` if the user is an enterprise learner. ++ ++ Arguments: ++ user (User): the Django User being checked for discount eligibility. ++ course_key: identifies the course (passed through unchanged). ++ is_eligible (bool): the current eligibility status. ++ ++ Returns: ++ dict: updated pipeline data. ``is_eligible`` is ``False`` when the user is ++ linked to an enterprise; otherwise the original ``is_eligible`` value is ++ preserved. ++ """ ++ # Import here to avoid circular imports at module load time. ++ # This import will be replaced with an internal path in epic 17 when ++ # enterprise_support is migrated into edx-enterprise. ++ from openedx.features.enterprise_support.utils import is_enterprise_learner ++ ++ if is_enterprise_learner(user): ++ return {"user": user, "course_key": course_key, "is_eligible": False} ++ return {"user": user, "course_key": course_key, "is_eligible": is_eligible} +diff --git a/tests/filters/test_discounts.py b/tests/filters/test_discounts.py +new file mode 100644 +--- /dev/null ++++ b/tests/filters/test_discounts.py +@@ -0,0 +1,66 @@ ++""" ++Tests for enterprise.filters.discounts pipeline step. ++""" ++from unittest.mock import MagicMock, patch ++ ++from django.test import TestCase ++ ++from enterprise.filters.discounts import DiscountEligibilityStep ++ ++ ++class TestDiscountEligibilityStep(TestCase): ++ """ ++ Tests for DiscountEligibilityStep pipeline step. ++ """ ++ ++ def _make_step(self): ++ return DiscountEligibilityStep( ++ "org.openedx.learning.discount.eligibility.check.requested.v1", ++ [], ++ ) ++ ++ def _mock_user(self): ++ user = MagicMock() ++ user.id = 42 ++ return user ++ ++ @patch('enterprise.filters.discounts.is_enterprise_learner', return_value=True) ++ def test_returns_ineligible_for_enterprise_learner(self, mock_is_enterprise): ++ """ ++ When the user is an enterprise learner, is_eligible is set to False. ++ """ ++ user = self._mock_user() ++ course_key = MagicMock() ++ ++ step = self._make_step() ++ result = step.run_filter(user=user, course_key=course_key, is_eligible=True) ++ ++ self.assertEqual(result, {"user": user, "course_key": course_key, "is_eligible": False}) ++ mock_is_enterprise.assert_called_once_with(user) ++ ++ @patch('enterprise.filters.discounts.is_enterprise_learner', return_value=False) ++ def test_returns_original_eligibility_for_non_enterprise_learner(self, mock_is_enterprise): ++ """ ++ When the user is not an enterprise learner, is_eligible is passed through unchanged. ++ """ ++ user = self._mock_user() ++ course_key = MagicMock() ++ ++ step = self._make_step() ++ result = step.run_filter(user=user, course_key=course_key, is_eligible=True) ++ ++ self.assertEqual(result, {"user": user, "course_key": course_key, "is_eligible": True}) ++ ++ @patch('enterprise.filters.discounts.is_enterprise_learner', return_value=False) ++ def test_passes_through_false_eligibility_unchanged(self, mock_is_enterprise): ++ """ ++ When the user is not an enterprise learner and is_eligible is already False, ++ the step does not change it. ++ """ ++ user = self._mock_user() ++ course_key = MagicMock() ++ ++ step = self._make_step() ++ result = step.run_filter(user=user, course_key=course_key, is_eligible=False) ++ ++ self.assertEqual(result["is_eligible"], False) ++ ++ @patch('enterprise.filters.discounts.is_enterprise_learner', return_value=True) ++ def test_course_key_passed_through_unchanged(self, _): ++ """ ++ The course_key is always returned unchanged regardless of enterprise status. ++ """ ++ user = self._mock_user() ++ course_key = MagicMock() ++ ++ step = self._make_step() ++ result = step.run_filter(user=user, course_key=course_key, is_eligible=True) ++ ++ self.assertIs(result["course_key"], course_key) diff --git a/docs/pluginification/epics/03_discount_enterprise_learner_exclusion/03_edx-enterprise.md b/docs/pluginification/epics/03_discount_enterprise_learner_exclusion/03_edx-enterprise.md new file mode 100644 index 0000000000..5d0f057785 --- /dev/null +++ b/docs/pluginification/epics/03_discount_enterprise_learner_exclusion/03_edx-enterprise.md @@ -0,0 +1,13 @@ +# [edx-enterprise] Add DiscountEligibilityStep pipeline step + +Blocked by: [openedx-filters] Add DiscountEligibilityCheckRequested filter + +Create a new file `enterprise/filters/discounts.py` containing the `DiscountEligibilityStep` pipeline step. This step implements the `DiscountEligibilityCheckRequested` filter by checking whether the user is an enterprise learner (via `is_enterprise_learner` from `enterprise_support.utils`, which is acceptable until epic 17 migrates that module). If the user is an enterprise learner, the step returns `{"is_eligible": False}` to exclude them from the discount. Otherwise it returns the inputs unchanged. + +## A/C + +- `enterprise/filters/discounts.py` defines `DiscountEligibilityStep(PipelineStep)`. +- `DiscountEligibilityStep.run_filter(self, user, course_key, is_eligible)` calls `is_enterprise_learner(user)` (imported from `enterprise_support.utils` until epic 17). +- If `is_enterprise_learner(user)` returns `True`, returns `{"user": user, "course_key": course_key, "is_eligible": False}`. +- Otherwise returns `{"user": user, "course_key": course_key, "is_eligible": is_eligible}`. +- Unit tests in `tests/filters/test_discounts.py` cover both the enterprise and non-enterprise branches. diff --git a/docs/pluginification/epics/03_discount_enterprise_learner_exclusion/EPIC.md b/docs/pluginification/epics/03_discount_enterprise_learner_exclusion/EPIC.md new file mode 100644 index 0000000000..2285333344 --- /dev/null +++ b/docs/pluginification/epics/03_discount_enterprise_learner_exclusion/EPIC.md @@ -0,0 +1,15 @@ +# Epic: Discount Enterprise Learner Exclusion + +JIRA: ENT-11564 + +## Purpose + +`openedx-platform/openedx/features/discounts/applicability.py` uses lazy imports of `is_enterprise_learner` from `enterprise_support.utils` in two functions (`can_receive_discount` and `can_show_streak_discount_coupon`) to exclude enterprise learners from LMS-controlled discounts, creating hidden import-time coupling to edx-enterprise. + +## Approach + +Introduce a new `DiscountEligibilityCheckRequested` openedx-filter with signature `run_filter(user, course_key, is_eligible)` that allows pipeline steps to set `is_eligible` to `False`. Replace both lazy `is_enterprise_learner` imports and calls in `applicability.py` with a single call to this filter. Implement a new `DiscountEligibilityStep` pipeline step in edx-enterprise that returns `{"is_eligible": False}` when `is_enterprise_learner(user)` is True. + +## Blocking Epics + +None. This epic has no dependencies and can start immediately. diff --git a/docs/pluginification/epics/04_user_retirement_enterprise_cleanup/01_openedx-platform.diff b/docs/pluginification/epics/04_user_retirement_enterprise_cleanup/01_openedx-platform.diff new file mode 100644 index 0000000000..12ad39772d --- /dev/null +++ b/docs/pluginification/epics/04_user_retirement_enterprise_cleanup/01_openedx-platform.diff @@ -0,0 +1,110 @@ +diff --git a/openedx/core/djangoapps/user_api/accounts/views.py b/openedx/core/djangoapps/user_api/accounts/views.py +--- a/openedx/core/djangoapps/user_api/accounts/views.py ++++ b/openedx/core/djangoapps/user_api/accounts/views.py +@@ -13,7 +13,6 @@ import logging + from functools import wraps + + from zoneinfo import ZoneInfo +-from consent.models import DataSharingConsent + from django.apps import apps + from django.conf import settings + from django.contrib.auth import authenticate, get_user_model, logout +@@ -28,7 +27,6 @@ from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthentica + from edx_rest_framework_extensions.auth.session.authentication import SessionAuthenticationAllowInactiveUser +-from enterprise.models import EnterpriseCourseEnrollment, EnterpriseCustomerUser, PendingEnterpriseCustomerUser + from integrated_channels.degreed.models import DegreedLearnerDataTransmissionAudit + from integrated_channels.sap_success_factors.models import SapSuccessFactorsLearnerDataTransmissionAudit + from rest_framework import permissions, status +@@ -1157,9 +1156,6 @@ class DeactivateLogoutView(APIView): + self.clear_pii_from_userprofile(user) + self.delete_users_profile_images(user) + self.delete_users_country_cache(user) +- +- # Retire data from Enterprise models +- self.retire_users_data_sharing_consent(username, retired_username) + self.retire_sapsf_data_transmission(user) + self.retire_degreed_data_transmission(user) +- self.retire_user_from_pending_enterprise_customer_user(user, retired_email) + self.retire_entitlement_support_detail(user) + + # Retire misc. models that may contain PII of this user +@@ -1170,8 +1166,11 @@ class DeactivateLogoutView(APIView): + CourseEnrollmentAllowed.delete_by_user_value(original_email, field="email") + UnregisteredLearnerCohortAssignments.delete_by_user_value(original_email, field="email") + +- # This signal allows code in higher points of LMS to retire the user as necessary +- USER_RETIRE_LMS_CRITICAL.send(sender=self.__class__, user=user) ++ # This signal allows plugins to retire enterprise-specific user data. ++ USER_RETIRE_LMS_CRITICAL.send( ++ sender=self.__class__, ++ user=user, ++ retired_username=retired_username, ++ retired_email=retired_email, ++ ) + + user.first_name = "" +@@ -1213,12 +1212,6 @@ class DeactivateLogoutView(APIView): +- @staticmethod +- def retire_users_data_sharing_consent(username, retired_username): +- DataSharingConsent.objects.filter(username=username).update(username=retired_username) +- +- @staticmethod +- def retire_user_from_pending_enterprise_customer_user(user, retired_email): +- PendingEnterpriseCustomerUser.objects.filter(user_email=user.email).update(user_email=retired_email) +- + @staticmethod + def retire_entitlement_support_detail(user): +@@ -1307,8 +1300,6 @@ class UsernameReplacementView(APIView): + MODELS_WITH_USERNAME = ( + ("auth.user", "username"), +- ("consent.DataSharingConsent", "username"), +- ("consent.HistoricalDataSharingConsent", "username"), + ("credit.CreditEligibility", "username"), + ("credit.CreditRequest", "username"), + ("credit.CreditRequirementStatus", "username"), +diff --git a/openedx/core/djangoapps/user_api/accounts/tests/test_retirement_views.py b/openedx/core/djangoapps/user_api/accounts/tests/test_retirement_views.py +--- a/openedx/core/djangoapps/user_api/accounts/tests/test_retirement_views.py ++++ b/openedx/core/djangoapps/user_api/accounts/tests/test_retirement_views.py +@@ -1,10 +1,8 @@ + """ + Tests for retirement views in user_api. + """ +-from unittest.mock import patch, MagicMock ++from unittest.mock import patch, MagicMock, call + + import pytest + from django.test import TestCase, override_settings +-from enterprise.models import EnterpriseCustomerUser, PendingEnterpriseCustomerUser +-from consent.models import DataSharingConsent + + from openedx.core.djangoapps.user_api.accounts.signals import USER_RETIRE_LMS_CRITICAL + +@@ -50,26 +48,18 @@ class TestDeactivateLogout(RetirementTestCase): +- @patch('openedx.core.djangoapps.user_api.accounts.views.DataSharingConsent.objects') +- @patch('openedx.core.djangoapps.user_api.accounts.views.PendingEnterpriseCustomerUser.objects') +- def test_retirement_calls_enterprise_cleanup(self, mock_pending, mock_dsc): ++ @patch('openedx.core.djangoapps.user_api.accounts.signals.USER_RETIRE_LMS_CRITICAL') ++ def test_retirement_sends_critical_signal_with_retirement_data(self, mock_signal): + """ +- Enterprise-specific retirement methods are called with the correct arguments. ++ USER_RETIRE_LMS_CRITICAL is sent with retired_username and retired_email kwargs. + """ +- mock_dsc.filter.return_value.update.return_value = 1 +- mock_pending.filter.return_value.update.return_value = 1 +- + response = self.client.post(self.url, data=self.retirement_data) + assert response.status_code == 204 + +- mock_dsc.filter.assert_called_once_with(username=self.user.username) +- mock_dsc.filter.return_value.update.assert_called_once_with( +- username=self.retirement_data['retired_username'] +- ) +- mock_pending.filter.assert_called_once_with(user_email=self.user.email) +- mock_pending.filter.return_value.update.assert_called_once_with( +- user_email=self.retirement_data['retired_email'] ++ mock_signal.send.assert_called_once_with( ++ sender=mock_signal.send.call_args.kwargs['sender'], ++ user=self.user, ++ retired_username=self.retirement_data['retired_username'], ++ retired_email=self.retirement_data['retired_email'], + ) diff --git a/docs/pluginification/epics/04_user_retirement_enterprise_cleanup/01_openedx-platform.md b/docs/pluginification/epics/04_user_retirement_enterprise_cleanup/01_openedx-platform.md new file mode 100644 index 0000000000..b498f65059 --- /dev/null +++ b/docs/pluginification/epics/04_user_retirement_enterprise_cleanup/01_openedx-platform.md @@ -0,0 +1,16 @@ +# [openedx-platform] Remove enterprise retirement methods from user API views + +No tickets block this one. + +Remove the direct imports of `consent.models.DataSharingConsent` and `enterprise.models.EnterpriseCourseEnrollment`, `EnterpriseCustomerUser`, `PendingEnterpriseCustomerUser` from `openedx/core/djangoapps/user_api/accounts/views.py`. Delete the `retire_users_data_sharing_consent` and `retire_user_from_pending_enterprise_customer_user` static methods from `DeactivateLogoutView`, and remove the calls to those methods in the retirement pipeline. Enhance the `USER_RETIRE_LMS_CRITICAL.send(...)` call to include `retired_username=retired_username` and `retired_email=retired_email` kwargs so that the edx-enterprise signal handler has the data it needs. Remove the `("consent.DataSharingConsent", "username")` and `("consent.HistoricalDataSharingConsent", "username")` entries from `MODELS_WITH_USERNAME` in `UsernameReplacementView`. + +## A/C + +- `from consent.models import DataSharingConsent` is removed from `views.py`. +- `from enterprise.models import EnterpriseCourseEnrollment, EnterpriseCustomerUser, PendingEnterpriseCustomerUser` is removed from `views.py`. +- `retire_users_data_sharing_consent` and `retire_user_from_pending_enterprise_customer_user` static methods are deleted from `DeactivateLogoutView`. +- The two calls to those methods (lines ~1158 and ~1161) are removed from the retirement pipeline in `DeactivateLogoutView.post`. +- The `USER_RETIRE_LMS_CRITICAL.send(...)` call passes `retired_username=retired_username` and `retired_email=retired_email` in addition to `user=user`. +- `("consent.DataSharingConsent", "username")` and `("consent.HistoricalDataSharingConsent", "username")` are removed from `MODELS_WITH_USERNAME` in `UsernameReplacementView`. +- Tests in `accounts/tests/test_retirement_views.py` are updated to not mock enterprise model imports and instead assert that `USER_RETIRE_LMS_CRITICAL.send` is called with the `retired_username` and `retired_email` kwargs. +- No import of `enterprise` or `consent` packages remains in `views.py`. diff --git a/docs/pluginification/epics/04_user_retirement_enterprise_cleanup/02_edx-enterprise.diff b/docs/pluginification/epics/04_user_retirement_enterprise_cleanup/02_edx-enterprise.diff new file mode 100644 index 0000000000..d89d091e05 --- /dev/null +++ b/docs/pluginification/epics/04_user_retirement_enterprise_cleanup/02_edx-enterprise.diff @@ -0,0 +1,166 @@ +diff --git a/enterprise/platform_signal_handlers.py b/enterprise/platform_signal_handlers.py +new file mode 100644 +--- /dev/null ++++ b/enterprise/platform_signal_handlers.py +@@ -0,0 +1,56 @@ ++""" ++Signal handlers for platform-emitted Django signals consumed by edx-enterprise. ++""" ++import logging ++ ++from consent.models import DataSharingConsent ++ ++from enterprise.models import PendingEnterpriseCustomerUser ++ ++log = logging.getLogger(__name__) ++ ++ ++def handle_user_retirement(sender, user, retired_username, retired_email, **kwargs): # pylint: disable=unused-argument ++ """ ++ Handle USER_RETIRE_LMS_CRITICAL signal to retire enterprise-specific user data. ++ ++ This handler performs two retirement operations: ++ 1. Retires DataSharingConsent records by replacing the username with the retired username. ++ 2. Retires PendingEnterpriseCustomerUser records by replacing the email with the retired email. ++ ++ Arguments: ++ sender: the class that sent the signal (unused). ++ user (User): the Django User being retired. ++ retired_username (str): the anonymised username to substitute in consent records. ++ retired_email (str): the anonymised email to substitute in pending enterprise records. ++ **kwargs: forward-compatible catch-all for additional signal kwargs. ++ """ ++ log.info( ++ "Retiring enterprise data for user %s (retired_username=%s)", ++ user.id, ++ retired_username, ++ ) ++ ++ dsc_count = DataSharingConsent.objects.filter( ++ username=user.username ++ ).update(username=retired_username) ++ log.info( ++ "Retired %d DataSharingConsent record(s) for user %s", ++ dsc_count, ++ user.id, ++ ) ++ ++ pending_count = PendingEnterpriseCustomerUser.objects.filter( ++ user_email=user.email ++ ).update(user_email=retired_email) ++ log.info( ++ "Retired %d PendingEnterpriseCustomerUser record(s) for user %s", ++ pending_count, ++ user.id, ++ ) +diff --git a/enterprise/apps.py b/enterprise/apps.py +--- a/enterprise/apps.py ++++ b/enterprise/apps.py +@@ -43,6 +43,15 @@ class EnterpriseConfig(AppConfig): + def ready(self): + """ + Perform other one-time initialization steps. + """ + from enterprise.signals import handle_user_post_save # pylint: disable=import-outside-toplevel + + from django.db.models.signals import post_save, pre_migrate # pylint: disable=C0415, # isort:skip + + post_save.connect(handle_user_post_save, sender=self.auth_user_model, dispatch_uid=USER_POST_SAVE_DISPATCH_UID) + pre_migrate.connect(self._disconnect_user_post_save_for_migrations) ++ ++ # Connect to the platform user retirement signal to clean up enterprise data. ++ from enterprise.platform_signal_handlers import handle_user_retirement # pylint: disable=import-outside-toplevel ++ try: ++ from openedx.core.djangoapps.user_api.accounts.signals import USER_RETIRE_LMS_CRITICAL ++ USER_RETIRE_LMS_CRITICAL.connect(handle_user_retirement) ++ except ImportError: ++ pass # Signal not available outside of LMS context +diff --git a/tests/test_platform_signal_handlers.py b/tests/test_platform_signal_handlers.py +new file mode 100644 +--- /dev/null ++++ b/tests/test_platform_signal_handlers.py +@@ -0,0 +1,76 @@ ++""" ++Tests for enterprise.platform_signal_handlers. ++""" ++from unittest.mock import MagicMock, patch, call ++ ++from django.test import TestCase ++ ++from enterprise.platform_signal_handlers import handle_user_retirement ++ ++ ++class TestHandleUserRetirement(TestCase): ++ """ ++ Tests for handle_user_retirement signal handler. ++ """ ++ ++ def _make_user(self, user_id=42, username="learner", email="learner@example.com"): ++ user = MagicMock() ++ user.id = user_id ++ user.username = username ++ user.email = email ++ return user ++ ++ @patch('enterprise.platform_signal_handlers.PendingEnterpriseCustomerUser.objects') ++ @patch('enterprise.platform_signal_handlers.DataSharingConsent.objects') ++ def test_retires_dsc_records(self, mock_dsc_objects, mock_pending_objects): ++ """ ++ DataSharingConsent records are updated to use the retired_username. ++ """ ++ mock_dsc_objects.filter.return_value.update.return_value = 2 ++ mock_pending_objects.filter.return_value.update.return_value = 0 ++ ++ user = self._make_user() ++ handle_user_retirement( ++ sender=None, ++ user=user, ++ retired_username="retired__abc123", ++ retired_email="retired__abc123@retired.invalid", ++ ) ++ ++ mock_dsc_objects.filter.assert_called_once_with(username="learner") ++ mock_dsc_objects.filter.return_value.update.assert_called_once_with( ++ username="retired__abc123" ++ ) ++ ++ @patch('enterprise.platform_signal_handlers.PendingEnterpriseCustomerUser.objects') ++ @patch('enterprise.platform_signal_handlers.DataSharingConsent.objects') ++ def test_retires_pending_enterprise_customer_user_records(self, mock_dsc_objects, mock_pending_objects): ++ """ ++ PendingEnterpriseCustomerUser records are updated to use the retired_email. ++ """ ++ mock_dsc_objects.filter.return_value.update.return_value = 0 ++ mock_pending_objects.filter.return_value.update.return_value = 1 + ++ user = self._make_user() ++ handle_user_retirement( ++ sender=None, ++ user=user, ++ retired_username="retired__abc123", ++ retired_email="retired__abc123@retired.invalid", ++ ) ++ ++ mock_pending_objects.filter.assert_called_once_with(user_email="learner@example.com") ++ mock_pending_objects.filter.return_value.update.assert_called_once_with( ++ user_email="retired__abc123@retired.invalid" ++ ) ++ ++ @patch('enterprise.platform_signal_handlers.PendingEnterpriseCustomerUser.objects') ++ @patch('enterprise.platform_signal_handlers.DataSharingConsent.objects') ++ def test_accepts_extra_kwargs_without_error(self, mock_dsc_objects, mock_pending_objects): ++ """ ++ The handler ignores unknown kwargs (forward-compatible with signal additions). ++ """ ++ mock_dsc_objects.filter.return_value.update.return_value = 0 ++ mock_pending_objects.filter.return_value.update.return_value = 0 ++ ++ user = self._make_user() ++ # Should not raise even with unexpected kwargs ++ handle_user_retirement( ++ sender=None, ++ user=user, ++ retired_username="retired__xyz", ++ retired_email="retired__xyz@retired.invalid", ++ unknown_future_kwarg="ignored", ++ ) diff --git a/docs/pluginification/epics/04_user_retirement_enterprise_cleanup/02_edx-enterprise.md b/docs/pluginification/epics/04_user_retirement_enterprise_cleanup/02_edx-enterprise.md new file mode 100644 index 0000000000..adf8c276ee --- /dev/null +++ b/docs/pluginification/epics/04_user_retirement_enterprise_cleanup/02_edx-enterprise.md @@ -0,0 +1,14 @@ +# [edx-enterprise] Add USER_RETIRE_LMS_CRITICAL signal handler for enterprise retirement + +Blocked by: [openedx-platform] Remove enterprise retirement methods from user API views + +Create a new file `enterprise/platform_signal_handlers.py` containing the `handle_user_retirement` function, which connects to the `USER_RETIRE_LMS_CRITICAL` signal from `openedx.core.djangoapps.user_api.accounts.signals`. The handler retires `DataSharingConsent` records by updating the `username` field to `retired_username`, and retires `PendingEnterpriseCustomerUser` records by updating `user_email` to `retired_email`. Wire the handler into `EnterpriseConfig.ready()` in `enterprise/apps.py`. No new signal definition is needed — this epic reuses the enhanced `USER_RETIRE_LMS_CRITICAL` signal. + +## A/C + +- `enterprise/platform_signal_handlers.py` defines `handle_user_retirement(sender, user, retired_username, retired_email, **kwargs)`. +- The handler calls `DataSharingConsent.objects.filter(username=user.username).update(username=retired_username)`. +- The handler calls `PendingEnterpriseCustomerUser.objects.filter(user_email=user.email).update(user_email=retired_email)`. +- The handler is connected to `USER_RETIRE_LMS_CRITICAL` in `EnterpriseConfig.ready()`. +- Unit tests in `tests/test_platform_signal_handlers.py` cover the retirement operations using mocked model querysets. +- The handler uses `**kwargs` to forward-compatibly ignore unknown signal kwargs. diff --git a/docs/pluginification/epics/04_user_retirement_enterprise_cleanup/EPIC.md b/docs/pluginification/epics/04_user_retirement_enterprise_cleanup/EPIC.md new file mode 100644 index 0000000000..89bfc5213c --- /dev/null +++ b/docs/pluginification/epics/04_user_retirement_enterprise_cleanup/EPIC.md @@ -0,0 +1,15 @@ +# Epic: User Retirement Enterprise Cleanup + +JIRA: ENT-11473 + +## Purpose + +`openedx-platform/openedx/core/djangoapps/user_api/accounts/views.py` directly imports and calls `DataSharingConsent`, `EnterpriseCourseEnrollment`, `EnterpriseCustomerUser`, and `PendingEnterpriseCustomerUser` from the `consent` and `enterprise` packages to retire enterprise-specific user data, creating hard dependencies on edx-enterprise inside the core user retirement API. + +## Approach + +Enhance the existing `USER_RETIRE_LMS_CRITICAL` Django signal to carry `retired_username` and `retired_email` kwargs in addition to `user`. Remove the two enterprise retirement methods (`retire_users_data_sharing_consent` and `retire_user_from_pending_enterprise_customer_user`) and their direct enterprise model imports from `views.py`. Implement a new signal handler in edx-enterprise that connects to `USER_RETIRE_LMS_CRITICAL` and performs both retirement operations. Also remove the `consent.DataSharingConsent` and `consent.HistoricalDataSharingConsent` entries from the `MODELS_WITH_USERNAME` list in `views.py`, moving that responsibility into edx-enterprise's own retirement configuration. + +## Blocking Epics + +None. This epic has no dependencies and can start immediately. diff --git a/docs/pluginification/epics/05_enterprise_username_change_command/01_openedx-platform.diff b/docs/pluginification/epics/05_enterprise_username_change_command/01_openedx-platform.diff new file mode 100644 index 0000000000..248a163e64 --- /dev/null +++ b/docs/pluginification/epics/05_enterprise_username_change_command/01_openedx-platform.diff @@ -0,0 +1,65 @@ +diff --git a/common/djangoapps/student/management/commands/change_enterprise_user_username.py b/common/djangoapps/student/management/commands/change_enterprise_user_username.py +deleted file mode 100644 +--- a/common/djangoapps/student/management/commands/change_enterprise_user_username.py ++++ /dev/null +@@ -1,61 +0,0 @@ +-""" +-Django management command for changing an enterprise user's username. +-""" +- +- +-import logging +- +-from django.contrib.auth.models import User # lint-amnesty, pylint: disable=imported-auth-user +-from django.core.management import BaseCommand +- +-from enterprise.models import EnterpriseCustomerUser +- +-LOGGER = logging.getLogger(__name__) +- +- +-class Command(BaseCommand): +- """ +- Updates the username value for a given user. +- +- This is NOT MEANT for general use, and is specifically limited to Enterprise Users since +- only they could potentially experience the issue of overwritten usernames. +- +- See ENT-832 for details on the bug that modified usernames for some Enterprise Users. +- """ +- help = 'Update the username of a given user.' +- +- def add_arguments(self, parser): +- parser.add_argument( +- '-u', +- '--user_id', +- action='store', +- dest='user_id', +- default=None, +- help='The ID of the user to update.' +- ) +- +- parser.add_argument( +- '-n', +- '--new_username', +- action='store', +- dest='new_username', +- default=None, +- help='The username value to set for the user.' +- ) +- +- def handle(self, *args, **options): +- user_id = options.get('user_id') +- new_username = options.get('new_username') +- +- try: +- EnterpriseCustomerUser.objects.get(user_id=user_id) +- except EnterpriseCustomerUser.DoesNotExist: +- LOGGER.info(f'User {user_id} must be an Enterprise User.') +- return +- +- user = User.objects.get(id=user_id) +- user.username = new_username +- user.save() +- +- LOGGER.info(f'User {user_id} has been updated with username {new_username}.') diff --git a/docs/pluginification/epics/05_enterprise_username_change_command/01_openedx-platform.md b/docs/pluginification/epics/05_enterprise_username_change_command/01_openedx-platform.md new file mode 100644 index 0000000000..e9be538a77 --- /dev/null +++ b/docs/pluginification/epics/05_enterprise_username_change_command/01_openedx-platform.md @@ -0,0 +1,11 @@ +# [openedx-platform] Remove change_enterprise_user_username management command + +No tickets block this one. + +Delete `common/djangoapps/student/management/commands/change_enterprise_user_username.py` from openedx-platform entirely, along with any test coverage for it in the student management test suite. The command is being moved to edx-enterprise where enterprise model imports are appropriate. + +## A/C + +- `common/djangoapps/student/management/commands/change_enterprise_user_username.py` is deleted from the repository. +- Any test cases in `common/djangoapps/student/tests/test_management.py` (or similar) that specifically test `change_enterprise_user_username` are removed. +- No import of `enterprise.models` remains in `common/djangoapps/student/management/`. diff --git a/docs/pluginification/epics/05_enterprise_username_change_command/02_edx-enterprise.diff b/docs/pluginification/epics/05_enterprise_username_change_command/02_edx-enterprise.diff new file mode 100644 index 0000000000..b2889762d5 --- /dev/null +++ b/docs/pluginification/epics/05_enterprise_username_change_command/02_edx-enterprise.diff @@ -0,0 +1,128 @@ +diff --git a/enterprise/management/commands/change_enterprise_user_username.py b/enterprise/management/commands/change_enterprise_user_username.py +new file mode 100644 +--- /dev/null ++++ b/enterprise/management/commands/change_enterprise_user_username.py +@@ -0,0 +1,61 @@ ++""" ++Django management command for changing an enterprise user's username. ++""" ++ ++import logging ++ ++from django.contrib.auth import get_user_model ++from django.core.management import BaseCommand ++ ++from enterprise.models import EnterpriseCustomerUser ++ ++User = get_user_model() ++LOGGER = logging.getLogger(__name__) ++ ++ ++class Command(BaseCommand): ++ """ ++ Updates the username value for a given user. ++ ++ This is NOT MEANT for general use, and is specifically limited to enterprise users since ++ only they could potentially experience the issue of overwritten usernames. ++ ++ See ENT-832 for details on the bug that modified usernames for some enterprise users. ++ """ ++ help = 'Update the username of a given user.' ++ ++ def add_arguments(self, parser): ++ parser.add_argument( ++ '-u', ++ '--user_id', ++ action='store', ++ dest='user_id', ++ default=None, ++ help='The ID of the user to update.' ++ ) ++ ++ parser.add_argument( ++ '-n', ++ '--new_username', ++ action='store', ++ dest='new_username', ++ default=None, ++ help='The username value to set for the user.' ++ ) ++ ++ def handle(self, *args, **options): ++ user_id = options.get('user_id') ++ new_username = options.get('new_username') ++ ++ try: ++ EnterpriseCustomerUser.objects.get(user_id=user_id) ++ except EnterpriseCustomerUser.DoesNotExist: ++ LOGGER.info('User %s must be an enterprise user.', user_id) ++ return ++ ++ user = User.objects.get(id=user_id) ++ user.username = new_username ++ user.save() ++ ++ LOGGER.info('User %s has been updated with username %s.', user_id, new_username) +diff --git a/tests/management/__init__.py b/tests/management/__init__.py +new file mode 100644 +--- /dev/null ++++ b/tests/management/__init__.py +@@ -0,0 +1 @@ ++"""Tests for enterprise management commands.""" +diff --git a/tests/management/test_change_enterprise_user_username.py b/tests/management/test_change_enterprise_user_username.py +new file mode 100644 +--- /dev/null ++++ b/tests/management/test_change_enterprise_user_username.py +@@ -0,0 +1,64 @@ ++""" ++Tests for the change_enterprise_user_username management command. ++""" ++from unittest.mock import MagicMock, patch ++ ++from django.core.management import call_command ++from django.test import TestCase ++ ++ ++class TestChangeEnterpriseUserUsernameCommand(TestCase): ++ """ ++ Tests for enterprise/management/commands/change_enterprise_user_username.py ++ """ ++ ++ @patch('enterprise.management.commands.change_enterprise_user_username.User.objects') ++ @patch('enterprise.management.commands.change_enterprise_user_username.EnterpriseCustomerUser.objects') ++ def test_updates_username_for_enterprise_user(self, mock_ecu_objects, mock_user_objects): ++ """ ++ When the user_id belongs to an enterprise user, the username is updated. ++ """ ++ mock_ecu_objects.get.return_value = MagicMock() ++ mock_user = MagicMock() ++ mock_user_objects.get.return_value = mock_user + ++ call_command( ++ 'change_enterprise_user_username', ++ user_id='42', ++ new_username='corrected_username', ++ ) ++ ++ mock_ecu_objects.get.assert_called_once_with(user_id='42') ++ mock_user_objects.get.assert_called_once_with(id='42') ++ assert mock_user.username == 'corrected_username' ++ mock_user.save.assert_called_once() ++ ++ @patch('enterprise.management.commands.change_enterprise_user_username.User.objects') ++ @patch('enterprise.management.commands.change_enterprise_user_username.EnterpriseCustomerUser.objects') ++ def test_logs_and_exits_when_not_enterprise_user(self, mock_ecu_objects, mock_user_objects): ++ """ ++ When the user_id does not belong to an enterprise user, the command logs and exits. ++ """ ++ from enterprise.models import EnterpriseCustomerUser ++ mock_ecu_objects.get.side_effect = EnterpriseCustomerUser.DoesNotExist + ++ call_command( ++ 'change_enterprise_user_username', ++ user_id='99', ++ new_username='any_name', ++ ) ++ ++ # User.objects.get should not be called when user is not enterprise ++ mock_user_objects.get.assert_not_called() diff --git a/docs/pluginification/epics/05_enterprise_username_change_command/02_edx-enterprise.md b/docs/pluginification/epics/05_enterprise_username_change_command/02_edx-enterprise.md new file mode 100644 index 0000000000..7e0e3f430a --- /dev/null +++ b/docs/pluginification/epics/05_enterprise_username_change_command/02_edx-enterprise.md @@ -0,0 +1,12 @@ +# [edx-enterprise] Add change_enterprise_user_username management command + +Blocked by: [openedx-platform] Remove change_enterprise_user_username management command + +Copy the `change_enterprise_user_username` management command into `enterprise/management/commands/change_enterprise_user_username.py`. The logic is identical to the platform version but lives in edx-enterprise where enterprise model imports are natural. Add a unit test in `tests/management/test_change_enterprise_user_username.py`. + +## A/C + +- `enterprise/management/commands/change_enterprise_user_username.py` is created with the same `Command` class as the platform original. +- The command imports `EnterpriseCustomerUser` from `enterprise.models` (no platform import). +- `--user_id` and `--new_username` arguments function identically to the original. +- Unit tests confirm the command updates the username when the user is an enterprise user and logs an error when the user is not found in `EnterpriseCustomerUser`. diff --git a/docs/pluginification/epics/05_enterprise_username_change_command/EPIC.md b/docs/pluginification/epics/05_enterprise_username_change_command/EPIC.md new file mode 100644 index 0000000000..402d312912 --- /dev/null +++ b/docs/pluginification/epics/05_enterprise_username_change_command/EPIC.md @@ -0,0 +1,15 @@ +# Epic: Enterprise Username Change Command + +JIRA: ENT-11565 + +## Purpose + +`openedx-platform/common/djangoapps/student/management/commands/change_enterprise_user_username.py` is a management command that imports `enterprise.models.EnterpriseCustomerUser` directly and exists solely to change usernames for enterprise users affected by a specific bug (ENT-832). It has no non-enterprise use case. + +## Approach + +Move the management command file wholesale into edx-enterprise's own management commands directory (`enterprise/management/commands/`), where it can import enterprise models without creating a platform dependency. Remove the file and any associated tests from openedx-platform. + +## Blocking Epics + +None. This epic has no dependencies and can start immediately. diff --git a/docs/pluginification/epics/06_dsc_courseware_view_redirects/01_openedx-filters.diff b/docs/pluginification/epics/06_dsc_courseware_view_redirects/01_openedx-filters.diff new file mode 100644 index 0000000000..df6e8a5e68 --- /dev/null +++ b/docs/pluginification/epics/06_dsc_courseware_view_redirects/01_openedx-filters.diff @@ -0,0 +1,48 @@ +diff --git a/openedx_filters/learning/filters.py b/openedx_filters/learning/filters.py +--- a/openedx_filters/learning/filters.py ++++ b/openedx_filters/learning/filters.py +@@ -1444,3 +1444,48 @@ class ScheduleQuerySetRequested(OpenEdxPublicFilter): + data = super().run_pipeline(schedules=schedules) + return data.get("schedules") ++ ++ ++class CoursewareViewRedirectURL(OpenEdxPublicFilter): ++ """ ++ Filter used to determine whether a courseware view should redirect the user. ++ ++ Purpose: ++ This filter is triggered before a courseware view is rendered, allowing pipeline ++ steps to append redirect URLs to ``redirect_urls``. If the list is non-empty after ++ the pipeline runs, the view redirects the user to the first URL in the list. ++ ++ Filter Type: ++ org.openedx.learning.courseware.view.redirect_url.requested.v1 ++ ++ Trigger: ++ - Repository: openedx/edx-platform ++ - Path: lms/djangoapps/courseware/decorators.py ++ - Function or Method: courseware_view_redirect ++ """ ++ ++ filter_type = "org.openedx.learning.courseware.view.redirect_url.requested.v1" ++ ++ @classmethod ++ def run_filter(cls, redirect_urls: list, request: Any, course_key: Any) -> tuple: ++ """ ++ Process the inputs using the configured pipeline steps. ++ ++ Arguments: ++ redirect_urls (list): initial list of redirect URLs (typically empty). ++ request (HttpRequest): the current Django HTTP request. ++ course_key (CourseKey): the course key for the view being accessed. ++ ++ Returns: ++ tuple[list, HttpRequest, CourseKey]: ++ - list: the (possibly extended) list of redirect URLs. ++ - HttpRequest: the request object (unchanged). ++ - CourseKey: the course key (unchanged). ++ """ ++ data = super().run_pipeline( ++ redirect_urls=redirect_urls, request=request, course_key=course_key ++ ) ++ return data.get("redirect_urls"), data.get("request"), data.get("course_key") diff --git a/docs/pluginification/epics/06_dsc_courseware_view_redirects/01_openedx-filters.md b/docs/pluginification/epics/06_dsc_courseware_view_redirects/01_openedx-filters.md new file mode 100644 index 0000000000..3439d41fd2 --- /dev/null +++ b/docs/pluginification/epics/06_dsc_courseware_view_redirects/01_openedx-filters.md @@ -0,0 +1,13 @@ +# [openedx-filters] Add CoursewareViewRedirectURL filter + +No tickets block this one. + +Add a new `CoursewareViewRedirectURL` filter class to `openedx_filters/learning/filters.py`. This filter is invoked before a courseware view is rendered to determine whether the user should be redirected away from the view. Pipeline steps may append redirect URLs to the `redirect_urls` list; the caller redirects to the first URL in the list. The filter accepts an initial (typically empty) list of redirect URLs, the Django request, and the course key. No exception class is needed since the caller simply checks the returned list. + +## A/C + +- A new `CoursewareViewRedirectURL` class is added to `openedx_filters/learning/filters.py`, inheriting from `OpenEdxPublicFilter`. +- The filter type is `"org.openedx.learning.courseware.view.redirect_url.requested.v1"`. +- `run_filter(cls, redirect_urls, request, course_key)` returns the modified `redirect_urls` list (and the unchanged `request` and `course_key`). +- No exception subclass is defined on this filter. +- A unit test confirms the filter returns the list unchanged when no pipeline steps modify it. diff --git a/docs/pluginification/epics/06_dsc_courseware_view_redirects/02_openedx-platform.diff b/docs/pluginification/epics/06_dsc_courseware_view_redirects/02_openedx-platform.diff new file mode 100644 index 0000000000..7c1871345b --- /dev/null +++ b/docs/pluginification/epics/06_dsc_courseware_view_redirects/02_openedx-platform.diff @@ -0,0 +1,154 @@ +diff --git a/lms/djangoapps/courseware/decorators.py b/lms/djangoapps/courseware/decorators.py +new file mode 100644 +--- /dev/null ++++ b/lms/djangoapps/courseware/decorators.py +@@ -0,0 +1,58 @@ ++""" ++Decorators for courseware views. ++""" ++import functools ++ ++from django.shortcuts import redirect ++from opaque_keys.edx.keys import CourseKey ++ ++from openedx_filters.learning.filters import CoursewareViewRedirectURL ++ ++ ++def courseware_view_redirect(view_func): ++ """ ++ Decorator that calls the CoursewareViewRedirectURL filter before rendering a courseware view. ++ ++ If any pipeline step returns a non-empty list of redirect URLs, the user is redirected ++ to the first URL in the list. Otherwise, the original view is rendered normally. ++ ++ Usage:: ++ ++ @courseware_view_redirect ++ def my_view(request, course_id, ...): ++ ... ++ ++ Works with both function-based views and ``method_decorator``-wrapped class-based views. ++ The decorator extracts the ``course_id`` or ``course_key`` from the view arguments. ++ """ ++ @functools.wraps(view_func) ++ def _wrapper(request_or_self, *args, **kwargs): ++ # Support both function views (request as first arg) and method views ++ # (self as first arg, request as second arg). ++ if hasattr(request_or_self, 'method'): ++ # Function-based view: first arg is request ++ request = request_or_self ++ else: ++ # Class-based view via method_decorator: first arg is self, second is request ++ request = args[0] if args else kwargs.get('request') ++ ++ course_id = kwargs.get('course_id') or (args[0] if args and not hasattr(request_or_self, 'method') else None) ++ try: ++ course_key = CourseKey.from_string(str(course_id)) if course_id else None ++ except Exception: # pylint: disable=broad-except ++ course_key = None + ++ if course_key is not None: ++ redirect_urls, _request, _course_key = CoursewareViewRedirectURL.run_filter( ++ redirect_urls=[], ++ request=request, ++ course_key=course_key, ++ ) ++ if redirect_urls: ++ return redirect(redirect_urls[0]) ++ ++ return view_func(request_or_self, *args, **kwargs) ++ ++ return _wrapper +diff --git a/lms/djangoapps/courseware/views/index.py b/lms/djangoapps/courseware/views/index.py +--- a/lms/djangoapps/courseware/views/index.py ++++ b/lms/djangoapps/courseware/views/index.py +@@ -26,7 +26,7 @@ from openedx.features.course_experience import COURSE_ENABLE_UNENROLLED_ACCESS_ +-from openedx.features.enterprise_support.api import data_sharing_consent_required ++from lms.djangoapps.courseware.decorators import courseware_view_redirect + + from ..block_render import get_block_for_descriptor +@@ -44,7 +44,7 @@ class CoursewareIndex(View): + @method_decorator(ensure_csrf_cookie) + @method_decorator(cache_control(no_cache=True, no_store=True, must_revalidate=True)) + @method_decorator(ensure_valid_course_key) +- @method_decorator(data_sharing_consent_required) ++ @method_decorator(courseware_view_redirect) + def get(self, request, course_id, section=None, subsection=None, position=None): +diff --git a/lms/djangoapps/course_wiki/middleware.py b/lms/djangoapps/course_wiki/middleware.py +--- a/lms/djangoapps/course_wiki/middleware.py ++++ b/lms/djangoapps/course_wiki/middleware.py +@@ -15,7 +15,7 @@ from lms.djangoapps.courseware.access import has_access + from lms.djangoapps.courseware.courses import get_course_overview_with_access, get_course_with_access + from openedx.core.lib.request_utils import course_id_from_url +-from openedx.features.enterprise_support.api import get_enterprise_consent_url ++from openedx_filters.learning.filters import CoursewareViewRedirectURL + from common.djangoapps.student.models import CourseEnrollment + +@@ -98,9 +98,12 @@ class WikiAccessMiddleware(MiddlewareMixin): +- consent_url = get_enterprise_consent_url( +- request, str(course_id), source='WikiAccessMiddleware' ++ redirect_urls, _, _ = CoursewareViewRedirectURL.run_filter( ++ redirect_urls=[], ++ request=request, ++ course_key=course_id, + ) +- if consent_url: +- return redirect(consent_url) ++ if redirect_urls: ++ return redirect(redirect_urls[0]) +diff --git a/lms/djangoapps/courseware/access_utils.py b/lms/djangoapps/courseware/access_utils.py +--- a/lms/djangoapps/courseware/access_utils.py ++++ b/lms/djangoapps/courseware/access_utils.py +@@ -9,7 +9,6 @@ from crum import get_current_request + from django.conf import settings +-from enterprise.models import EnterpriseCourseEnrollment, EnterpriseCustomerUser + from pytz import UTC + + from common.djangoapps.student.models import CourseEnrollment +@@ -70,22 +69,15 @@ def _adjust_start_date_for_beta_testers(user, descriptor, course_key=None): + +-def enterprise_learner_enrolled(request, user, course_key): +- """ +- Check if the learner is enrolled via an enterprise and should be redirected +- to the enterprise portal. +- """ +- from openedx.features.enterprise_support.api import enterprise_customer_from_session_or_learner_data +- enterprise_customer = enterprise_customer_from_session_or_learner_data(request) +- if not enterprise_customer: +- return False +- return EnterpriseCustomerUser.objects.filter( +- user_id=user.id, +- enterprise_customer=enterprise_customer['uuid'], +- ).exists() +- +- +-def check_data_sharing_consent(course_id): +- """ +- Return the consent URL if the learner needs to consent, else None. +- """ +- from openedx.features.enterprise_support.api import get_enterprise_consent_url +- request = get_current_request() +- return get_enterprise_consent_url(request, str(course_id)) if request else None ++def _get_courseware_redirect_url(request, course_key): ++ """ ++ Return the first courseware redirect URL provided by plugins, or None. ++ """ ++ from openedx_filters.learning.filters import CoursewareViewRedirectURL ++ redirect_urls, _, _ = CoursewareViewRedirectURL.run_filter( ++ redirect_urls=[], request=request, course_key=course_key ++ ) ++ return redirect_urls[0] if redirect_urls else None +diff --git a/lms/envs/common.py b/lms/envs/common.py +--- a/lms/envs/common.py ++++ b/lms/envs/common.py +@@ -1,0 +1,0 @@ + OPEN_EDX_FILTERS_CONFIG = { + ... ++ "org.openedx.learning.courseware.view.redirect_url.requested.v1": { ++ "fail_silently": True, ++ "pipeline": [ ++ "enterprise.filters.courseware.ConsentRedirectStep", ++ "enterprise.filters.courseware.LearnerPortalRedirectStep", ++ ], ++ }, + } diff --git a/docs/pluginification/epics/06_dsc_courseware_view_redirects/02_openedx-platform.md b/docs/pluginification/epics/06_dsc_courseware_view_redirects/02_openedx-platform.md new file mode 100644 index 0000000000..519640a33f --- /dev/null +++ b/docs/pluginification/epics/06_dsc_courseware_view_redirects/02_openedx-platform.md @@ -0,0 +1,17 @@ +# [openedx-platform] Replace DSC decorator and enterprise imports in courseware + +Blocked by: [openedx-filters] Add CoursewareViewRedirectURL filter + +Replace all usages of `data_sharing_consent_required` from `enterprise_support.api` and `get_enterprise_consent_url` across courseware views and middleware, and remove direct enterprise model imports from `access_utils.py`. Create a new `courseware_view_redirect` decorator in `lms/djangoapps/courseware/decorators.py` that calls `CoursewareViewRedirectURL` and redirects to `redirect_urls[0]` if the list is non-empty. Apply the new decorator to `CoursewareIndex`, `CourseTabView`, `jump_to_id`, and `WikiView`. Replace the `get_enterprise_consent_url` call in `course_wiki/middleware.py` with a filter invocation. Replace enterprise model usage in `access_utils.py` with filter calls (moving the DB queries into edx-enterprise pipeline steps). Add the new filter to `OPEN_EDX_FILTERS_CONFIG`. + +## A/C + +- `lms/djangoapps/courseware/decorators.py` defines `courseware_view_redirect` that calls `CoursewareViewRedirectURL.run_filter(redirect_urls=[], request=request, course_key=course_key)` and returns `redirect(redirect_urls[0])` when the list is non-empty. +- All `from openedx.features.enterprise_support.api import data_sharing_consent_required` imports in `views/index.py`, `views/views.py`, `course_wiki/views.py` are removed. +- All `@data_sharing_consent_required` decorators are replaced with `@courseware_view_redirect`. +- `from openedx.features.enterprise_support.api import get_enterprise_consent_url` is removed from `course_wiki/middleware.py`; replaced with a filter call. +- `from enterprise.models import EnterpriseCourseEnrollment, EnterpriseCustomerUser` is removed from `courseware/access_utils.py`. +- `enterprise_learner_enrolled` and `check_data_sharing_consent` in `access_utils.py` are replaced with filter-based equivalents that call `CoursewareViewRedirectURL`. +- `OPEN_EDX_FILTERS_CONFIG` in `lms/envs/common.py` includes an entry for `"org.openedx.learning.courseware.view.redirect_url.requested.v1"` with `fail_silently=True` and `pipeline=[]`. +- Tests in `courseware/tests/test_access.py` and `courseware/tests/test_views.py` mock `CoursewareViewRedirectURL.run_filter` instead of enterprise functions. +- No import of `enterprise_support` or `enterprise` remains in any changed file. diff --git a/docs/pluginification/epics/06_dsc_courseware_view_redirects/03_edx-enterprise.diff b/docs/pluginification/epics/06_dsc_courseware_view_redirects/03_edx-enterprise.diff new file mode 100644 index 0000000000..6f768fc6a7 --- /dev/null +++ b/docs/pluginification/epics/06_dsc_courseware_view_redirects/03_edx-enterprise.diff @@ -0,0 +1,160 @@ +diff --git a/enterprise/filters/courseware.py b/enterprise/filters/courseware.py +new file mode 100644 +--- /dev/null ++++ b/enterprise/filters/courseware.py +@@ -0,0 +1,90 @@ ++""" ++Pipeline steps for courseware view redirect URL determination. ++""" ++from openedx_filters.filters import PipelineStep ++ ++ ++class ConsentRedirectStep(PipelineStep): ++ """ ++ Appends a data-sharing consent redirect URL when the user has not yet consented. ++ ++ This step is intended to be registered as a pipeline step for the ++ ``org.openedx.learning.courseware.view.redirect_url.requested.v1`` filter. ++ ++ If the user is required to grant data-sharing consent before accessing the course, ++ the consent URL is appended to ``redirect_urls``. ++ """ ++ ++ def run_filter(self, redirect_urls, request, course_key): # pylint: disable=arguments-differ ++ """ ++ Append consent redirect URL when data-sharing consent is required. ++ ++ Arguments: ++ redirect_urls (list): current list of redirect URLs. ++ request (HttpRequest): the current Django HTTP request. ++ course_key (CourseKey): the course key for the view being accessed. ++ ++ Returns: ++ dict: updated pipeline data with ``redirect_urls`` possibly extended. ++ """ ++ # Deferred import — will be replaced with internal path in epic 17. ++ from openedx.features.enterprise_support.api import get_enterprise_consent_url ++ ++ consent_url = get_enterprise_consent_url(request, str(course_key)) ++ if consent_url: ++ redirect_urls = list(redirect_urls) + [consent_url] ++ return {"redirect_urls": redirect_urls, "request": request, "course_key": course_key} ++ ++ ++class LearnerPortalRedirectStep(PipelineStep): ++ """ ++ Appends a learner portal redirect URL when the learner is enrolled via an enterprise portal. ++ ++ This step is intended to be registered as a pipeline step for the ++ ``org.openedx.learning.courseware.view.redirect_url.requested.v1`` filter. ++ ++ If the learner's current enterprise requires courseware access through the learner portal, ++ the portal redirect URL is appended to ``redirect_urls``. ++ """ ++ ++ def run_filter(self, redirect_urls, request, course_key): # pylint: disable=arguments-differ ++ """ ++ Append learner portal redirect URL when the learner is enrolled via enterprise portal. ++ ++ Arguments: ++ redirect_urls (list): current list of redirect URLs. ++ request (HttpRequest): the current Django HTTP request. ++ course_key (CourseKey): the course key for the view being accessed. ++ ++ Returns: ++ dict: updated pipeline data with ``redirect_urls`` possibly extended. ++ """ ++ # Deferred import — will be replaced with internal path in epic 17. ++ from openedx.features.enterprise_support.api import ( ++ enterprise_customer_from_session_or_learner_data, ++ ) ++ from enterprise.models import EnterpriseCustomerUser ++ ++ enterprise_customer = enterprise_customer_from_session_or_learner_data(request) ++ if enterprise_customer: ++ user = request.user ++ is_enrolled_via_portal = EnterpriseCustomerUser.objects.filter( ++ user_id=user.id, ++ enterprise_customer__uuid=enterprise_customer.get('uuid'), ++ ).exists() ++ if is_enrolled_via_portal: ++ portal_url = enterprise_customer.get('learner_portal_url') ++ if portal_url: ++ redirect_urls = list(redirect_urls) + [portal_url] ++ return {"redirect_urls": redirect_urls, "request": request, "course_key": course_key} +diff --git a/tests/filters/test_courseware.py b/tests/filters/test_courseware.py +new file mode 100644 +--- /dev/null ++++ b/tests/filters/test_courseware.py +@@ -0,0 +1,70 @@ ++""" ++Tests for enterprise.filters.courseware pipeline steps. ++""" ++from unittest.mock import MagicMock, patch ++ ++from django.test import TestCase ++ ++from enterprise.filters.courseware import ConsentRedirectStep, LearnerPortalRedirectStep ++ ++ ++class TestConsentRedirectStep(TestCase): ++ """Tests for ConsentRedirectStep.""" ++ ++ def _make_step(self): ++ return ConsentRedirectStep( ++ "org.openedx.learning.courseware.view.redirect_url.requested.v1", [] ++ ) ++ ++ @patch('enterprise.filters.courseware.get_enterprise_consent_url', return_value='/consent/') ++ def test_appends_consent_url_when_required(self, mock_get_url): ++ """Consent URL is appended when get_enterprise_consent_url returns a URL.""" ++ step = self._make_step() ++ request = MagicMock() ++ course_key = MagicMock() ++ course_key.__str__ = lambda s: 'course-v1:org+course+run' ++ result = step.run_filter(redirect_urls=[], request=request, course_key=course_key) ++ self.assertEqual(result['redirect_urls'], ['/consent/']) ++ ++ @patch('enterprise.filters.courseware.get_enterprise_consent_url', return_value=None) ++ def test_does_not_append_when_no_consent_required(self, mock_get_url): ++ """redirect_urls is unchanged when consent is not required.""" ++ step = self._make_step() ++ request = MagicMock() ++ course_key = MagicMock() ++ course_key.__str__ = lambda s: 'course-v1:org+course+run' ++ result = step.run_filter(redirect_urls=[], request=request, course_key=course_key) ++ self.assertEqual(result['redirect_urls'], []) ++ ++ ++class TestLearnerPortalRedirectStep(TestCase): ++ """Tests for LearnerPortalRedirectStep.""" ++ ++ def _make_step(self): ++ return LearnerPortalRedirectStep( ++ "org.openedx.learning.courseware.view.redirect_url.requested.v1", [] ++ ) + ++ @patch('enterprise.filters.courseware.EnterpriseCustomerUser.objects') ++ @patch( ++ 'enterprise.filters.courseware.enterprise_customer_from_session_or_learner_data', ++ return_value={'uuid': 'abc-123', 'learner_portal_url': '/portal/'}, ++ ) ++ def test_appends_portal_url_for_enrolled_learner(self, mock_customer, mock_ecu_objects): ++ """Portal URL is appended when learner is enrolled via enterprise portal.""" ++ mock_ecu_objects.filter.return_value.exists.return_value = True ++ step = self._make_step() ++ request = MagicMock() ++ request.user.id = 42 ++ result = step.run_filter(redirect_urls=[], request=request, course_key=MagicMock()) ++ self.assertEqual(result['redirect_urls'], ['/portal/']) ++ ++ @patch( ++ 'enterprise.filters.courseware.enterprise_customer_from_session_or_learner_data', ++ return_value=None, ++ ) ++ def test_does_not_append_when_no_enterprise_customer(self, mock_customer): ++ """redirect_urls is unchanged when user has no enterprise customer.""" ++ step = self._make_step() ++ request = MagicMock() ++ result = step.run_filter(redirect_urls=[], request=request, course_key=MagicMock()) ++ self.assertEqual(result['redirect_urls'], []) diff --git a/docs/pluginification/epics/06_dsc_courseware_view_redirects/03_edx-enterprise.md b/docs/pluginification/epics/06_dsc_courseware_view_redirects/03_edx-enterprise.md new file mode 100644 index 0000000000..a132ef6ad8 --- /dev/null +++ b/docs/pluginification/epics/06_dsc_courseware_view_redirects/03_edx-enterprise.md @@ -0,0 +1,13 @@ +# [edx-enterprise] Add CoursewareViewRedirectURL pipeline steps + +Blocked by: [openedx-filters] Add CoursewareViewRedirectURL filter + +Create `enterprise/filters/courseware.py` with two pipeline steps: `ConsentRedirectStep` and `LearnerPortalRedirectStep`. `ConsentRedirectStep` replicates the logic of `get_enterprise_consent_url` — checking if the user has granted data sharing consent for the course, and if not, appending the consent URL to `redirect_urls`. `LearnerPortalRedirectStep` replicates the logic of `enterprise_learner_enrolled` — checking if the user is enrolled via an enterprise learner portal and appending the portal redirect URL if so. Both steps call enterprise_support utility functions internally (using deferred imports) until epic 17 migrates enterprise_support into edx-enterprise. + +## A/C + +- `enterprise/filters/courseware.py` defines `ConsentRedirectStep(PipelineStep)` and `LearnerPortalRedirectStep(PipelineStep)`. +- `ConsentRedirectStep.run_filter` checks DSC consent for the request user and course, appending a consent redirect URL to `redirect_urls` when consent is not granted. +- `LearnerPortalRedirectStep.run_filter` checks if the user is enrolled via enterprise portal and appends the portal redirect URL when applicable. +- Both steps use deferred imports from `openedx.features.enterprise_support.api` until epic 17. +- Unit tests in `tests/filters/test_courseware.py` cover both steps with mocked enterprise_support functions. diff --git a/docs/pluginification/epics/06_dsc_courseware_view_redirects/EPIC.md b/docs/pluginification/epics/06_dsc_courseware_view_redirects/EPIC.md new file mode 100644 index 0000000000..23dccdde61 --- /dev/null +++ b/docs/pluginification/epics/06_dsc_courseware_view_redirects/EPIC.md @@ -0,0 +1,15 @@ +# Epic: DSC Courseware View Redirects + +JIRA: ENT-11544 + +## Purpose + +Multiple courseware views in openedx-platform are decorated with the enterprise-specific `data_sharing_consent_required` decorator imported from `enterprise_support.api`, and `WikiAccessMiddleware` calls `get_enterprise_consent_url` directly. Additionally, `courseware/access_utils.py` imports `enterprise.models` directly to check enterprise learner enrollment status. These create hard dependencies on edx-enterprise in core courseware and wiki code paths. + +## Approach + +Introduce a new `CoursewareViewRedirectURL` openedx-filter with signature `run_filter(redirect_urls, request, course_key)` that returns a list of redirect URLs. Create a new `courseware_view_redirect` decorator in the platform that calls this filter and redirects to the first URL in the list (or passes if the list is empty). Replace all usages of `@data_sharing_consent_required` with `@courseware_view_redirect`, replace the `get_enterprise_consent_url` call in `WikiAccessMiddleware` with the filter call, and replace the direct enterprise model imports in `access_utils.py` with filter calls. Implement two pipeline steps in edx-enterprise: one for DSC consent redirects and one for enterprise learner portal redirects. + +## Blocking Epics + +None. This is the largest epic but has no blocking dependencies. diff --git a/docs/pluginification/epics/07_third_party_auth_enterprise_pipeline/01_openedx-platform.diff b/docs/pluginification/epics/07_third_party_auth_enterprise_pipeline/01_openedx-platform.diff new file mode 100644 index 0000000000..bbd2682eea --- /dev/null +++ b/docs/pluginification/epics/07_third_party_auth_enterprise_pipeline/01_openedx-platform.diff @@ -0,0 +1,104 @@ +diff --git a/common/djangoapps/third_party_auth/signals.py b/common/djangoapps/third_party_auth/signals.py +new file mode 100644 +--- /dev/null ++++ b/common/djangoapps/third_party_auth/signals.py +@@ -0,0 +1,9 @@ ++""" ++Django signals for third_party_auth. ++""" ++from django.dispatch import Signal ++ ++# Signal fired when a user disconnects a social auth provider account. ++# providing_args=["request", "user", "social"] ++SocialAuthAccountDisconnected = Signal() +diff --git a/common/djangoapps/third_party_auth/settings.py b/common/djangoapps/third_party_auth/settings.py +--- a/common/djangoapps/third_party_auth/settings.py ++++ b/common/djangoapps/third_party_auth/settings.py +@@ -12,7 +12,6 @@ from django.conf import settings + from django.utils.translation import gettext_lazy as _ + +-from openedx.features.enterprise_support.api import insert_enterprise_pipeline_elements + + + def apply_settings(django_settings): +@@ -70,7 +69,6 @@ def apply_settings(django_settings): + if django_settings.get('ENABLE_THIRD_PARTY_AUTH'): +- insert_enterprise_pipeline_elements(django_settings.SOCIAL_AUTH_PIPELINE) +diff --git a/common/djangoapps/third_party_auth/saml.py b/common/djangoapps/third_party_auth/saml.py +--- a/common/djangoapps/third_party_auth/saml.py ++++ b/common/djangoapps/third_party_auth/saml.py +@@ -140,12 +140,11 @@ class SAMLAuth(SAMLAuthMixin, BaseAuth): + def disconnect(self, *args, **kwargs): + """ + Override to emit a signal when a user disconnects their SAML account. + """ +- from openedx.features.enterprise_support.api import unlink_enterprise_user_from_idp ++ from common.djangoapps.third_party_auth.signals import SocialAuthAccountDisconnected + result = super().disconnect(*args, **kwargs) +- unlink_enterprise_user_from_idp(self.request) ++ SocialAuthAccountDisconnected.send( ++ sender=self.__class__, ++ request=self.request, ++ user=self.request.user if self.request else None, ++ social=self, ++ ) + return result +diff --git a/common/djangoapps/third_party_auth/utils.py b/common/djangoapps/third_party_auth/utils.py +--- a/common/djangoapps/third_party_auth/utils.py ++++ b/common/djangoapps/third_party_auth/utils.py +@@ -12,7 +12,6 @@ from django.contrib.auth.models import User + from django.db.models import Q + +-from enterprise.models import EnterpriseCustomerIdentityProvider, EnterpriseCustomerUser + from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers +@@ -235,20 +234,0 @@ def get_user_from_email(email): +- +- +-def is_enterprise_customer_user(provider_id, user): +- """ +- Return whether the user is an enterprise customer user for the given provider. +- """ +- enterprise_customer_idps = EnterpriseCustomerIdentityProvider.objects.filter( +- provider_id=provider_id +- ) +- for idp in enterprise_customer_idps: +- if EnterpriseCustomerUser.objects.filter( +- enterprise_customer=idp.enterprise_customer, +- user_id=user.id, +- ).exists(): +- return True +- return False +diff --git a/common/djangoapps/third_party_auth/pipeline.py b/common/djangoapps/third_party_auth/pipeline.py +--- a/common/djangoapps/third_party_auth/pipeline.py ++++ b/common/djangoapps/third_party_auth/pipeline.py +@@ -790,35 +790,0 @@ def set_id_verification_status(auth_entry, strategy, details, user=None, **kwar +- +- +-def associate_by_email_if_enterprise_user( +- strategy, details, user=None, *args, **kwargs +-): +- """ +- Associate the current auth user with an existing user by email if they are an enterprise user. +- """ +- from openedx.features.enterprise_support.api import enterprise_is_enabled +- if not enterprise_is_enabled(): +- return None +- +- if user: +- return None +- +- email = details.get('email') +- if not email: +- return None +- +- try: +- existing_user = User.objects.get(email=email) +- except User.DoesNotExist: +- return None +- +- current_provider = strategy.request.backend +- from common.djangoapps.third_party_auth.utils import is_enterprise_customer_user +- if not is_enterprise_customer_user(current_provider.provider_id, existing_user): +- return None +- +- return {'user': existing_user} diff --git a/docs/pluginification/epics/07_third_party_auth_enterprise_pipeline/01_openedx-platform.md b/docs/pluginification/epics/07_third_party_auth_enterprise_pipeline/01_openedx-platform.md new file mode 100644 index 0000000000..f1d95c3407 --- /dev/null +++ b/docs/pluginification/epics/07_third_party_auth_enterprise_pipeline/01_openedx-platform.md @@ -0,0 +1,16 @@ +# [openedx-platform] Remove enterprise imports from third_party_auth + +No tickets block this one. + +Remove all enterprise and enterprise_support imports from `common/djangoapps/third_party_auth/settings.py`, `pipeline.py`, `saml.py`, and `utils.py`. Specifically: remove the `insert_enterprise_pipeline_elements` call from `apply_settings()` in `settings.py`; remove `associate_by_email_if_enterprise_user` and the `enterprise_is_enabled` decorator from `pipeline.py`; remove the `is_enterprise_customer_user` function and enterprise model imports from `utils.py`; replace the lazy `unlink_enterprise_user_from_idp` call in `SAMLAuth.disconnect` with a new `SocialAuthAccountDisconnected` Django signal. Define that signal in a new `common/djangoapps/third_party_auth/signals.py` module. + +## A/C + +- `from openedx.features.enterprise_support.api import insert_enterprise_pipeline_elements` and its call are removed from `third_party_auth/settings.py`. +- `associate_by_email_if_enterprise_user` and the `enterprise_is_enabled` decorator usage are removed from `third_party_auth/pipeline.py`. +- `from enterprise.models import EnterpriseCustomerIdentityProvider, EnterpriseCustomerUser` and `is_enterprise_customer_user` are removed from `third_party_auth/utils.py`. +- `from openedx.features.enterprise_support.api import unlink_enterprise_user_from_idp` and its call are removed from `third_party_auth/saml.py`. +- A new `SocialAuthAccountDisconnected` Django signal is defined in `common/djangoapps/third_party_auth/signals.py`. +- `SAMLAuth.disconnect` in `saml.py` emits `SocialAuthAccountDisconnected.send(...)` with the relevant user and provider info. +- No import of `enterprise` or `enterprise_support` remains in any changed file. +- Tests are updated to mock the new signal instead of enterprise functions. diff --git a/docs/pluginification/epics/07_third_party_auth_enterprise_pipeline/02_edx-enterprise.diff b/docs/pluginification/epics/07_third_party_auth_enterprise_pipeline/02_edx-enterprise.diff new file mode 100644 index 0000000000..6d05298a37 --- /dev/null +++ b/docs/pluginification/epics/07_third_party_auth_enterprise_pipeline/02_edx-enterprise.diff @@ -0,0 +1,112 @@ +diff --git a/enterprise/tpa_pipeline.py b/enterprise/tpa_pipeline.py +new file mode 100644 +--- /dev/null ++++ b/enterprise/tpa_pipeline.py +@@ -0,0 +1,67 @@ ++""" ++Enterprise pipeline steps for third-party auth (SAML/OAuth). ++""" ++import logging ++ ++from django.contrib.auth import get_user_model ++ ++from enterprise.models import EnterpriseCustomerIdentityProvider, EnterpriseCustomerUser ++ ++User = get_user_model() ++log = logging.getLogger(__name__) ++ ++ ++def enterprise_associate_by_email(strategy, details, user=None, *args, **kwargs): ++ """ ++ SAML pipeline step: associate the auth user with an existing user by email when ++ the existing user is an enterprise customer user for the current provider. ++ ++ This step replaces the platform-side ``associate_by_email_if_enterprise_user`` function. ++ It is registered in ``SOCIAL_AUTH_PIPELINE`` via ``enterprise/settings/common.py`` (epic 18). ++ """ ++ if user: ++ return None ++ ++ email = details.get('email') ++ if not email: ++ return None ++ ++ try: ++ existing_user = User.objects.get(email=email) ++ except User.DoesNotExist: ++ return None ++ ++ try: ++ current_provider = strategy.request.backend ++ provider_id = getattr(current_provider, 'provider_id', None) ++ except AttributeError: ++ return None + ++ if not provider_id: ++ return None + ++ enterprise_customer_idps = EnterpriseCustomerIdentityProvider.objects.filter( ++ provider_id=provider_id ++ ) ++ for idp in enterprise_customer_idps: ++ if EnterpriseCustomerUser.objects.filter( ++ enterprise_customer=idp.enterprise_customer, ++ user_id=existing_user.id, ++ ).exists(): ++ log.info( ++ "Associating enterprise user %s via provider %s by email match.", ++ existing_user.id, ++ provider_id, ++ ) ++ return {'user': existing_user} ++ ++ return None +diff --git a/enterprise/platform_signal_handlers.py b/enterprise/platform_signal_handlers.py +--- a/enterprise/platform_signal_handlers.py ++++ b/enterprise/platform_signal_handlers.py +@@ -1,5 +1,6 @@ + """ + Signal handlers for platform-emitted Django signals consumed by edx-enterprise. + """ + import logging ++ + from consent.models import DataSharingConsent +@@ -56,3 +57,22 @@ def handle_user_retirement(sender, user, retired_username, retired_email, **kwa + log.info( + "Retired %d PendingEnterpriseCustomerUser record(s) for user %s", + pending_count, + user.id, + ) ++ ++ ++def handle_social_auth_disconnect(sender, request, user, social, **kwargs): # pylint: disable=unused-argument ++ """ ++ Handle SocialAuthAccountDisconnected signal to unlink enterprise user from IdP. ++ ++ Arguments: ++ sender: the class that sent the signal (unused). ++ request: the HTTP request during which the disconnect occurred. ++ user: the Django User disconnecting the social auth account. ++ social: the social auth backend instance. ++ **kwargs: forward-compatible catch-all. ++ """ ++ # Deferred import — will be replaced with internal path in epic 17. ++ from openedx.features.enterprise_support.api import unlink_enterprise_user_from_idp ++ if request: ++ unlink_enterprise_user_from_idp(request) +diff --git a/enterprise/apps.py b/enterprise/apps.py +--- a/enterprise/apps.py ++++ b/enterprise/apps.py +@@ -50,6 +50,13 @@ class EnterpriseConfig(AppConfig): + try: + from openedx.core.djangoapps.user_api.accounts.signals import USER_RETIRE_LMS_CRITICAL + USER_RETIRE_LMS_CRITICAL.connect(handle_user_retirement) + except ImportError: + pass ++ ++ from enterprise.platform_signal_handlers import handle_social_auth_disconnect # pylint: disable=import-outside-toplevel ++ try: ++ from common.djangoapps.third_party_auth.signals import SocialAuthAccountDisconnected ++ SocialAuthAccountDisconnected.connect(handle_social_auth_disconnect) ++ except ImportError: ++ pass diff --git a/docs/pluginification/epics/07_third_party_auth_enterprise_pipeline/02_edx-enterprise.md b/docs/pluginification/epics/07_third_party_auth_enterprise_pipeline/02_edx-enterprise.md new file mode 100644 index 0000000000..5c253cbf81 --- /dev/null +++ b/docs/pluginification/epics/07_third_party_auth_enterprise_pipeline/02_edx-enterprise.md @@ -0,0 +1,12 @@ +# [edx-enterprise] Add TPA pipeline steps and signal handler + +Blocked by: [openedx-platform] Remove enterprise imports from third_party_auth + +Create an enterprise SAML pipeline step `enterprise_associate_by_email` in `enterprise/tpa_pipeline.py` that replicates the `associate_by_email_if_enterprise_user` logic. Create a signal handler in `enterprise/platform_signal_handlers.py` that connects to the new `SocialAuthAccountDisconnected` signal and calls the enterprise user unlink logic. Wire up the signal handler in `EnterpriseConfig.ready()`. The SAML pipeline step will be registered in `SOCIAL_AUTH_PIPELINE` via `enterprise/settings/common.py` as part of epic 18. + +## A/C + +- `enterprise_associate_by_email` in `enterprise/tpa_pipeline.py` contains the associate-by-email logic using enterprise models, including the `is_enterprise_customer_user` check. +- A `handle_social_auth_disconnect` function is added to `enterprise/platform_signal_handlers.py` that calls the enterprise user unlink function. +- `handle_social_auth_disconnect` is connected to `SocialAuthAccountDisconnected` in `EnterpriseConfig.ready()`. +- Unit tests cover the pipeline step and signal handler. diff --git a/docs/pluginification/epics/07_third_party_auth_enterprise_pipeline/EPIC.md b/docs/pluginification/epics/07_third_party_auth_enterprise_pipeline/EPIC.md new file mode 100644 index 0000000000..1eb93f4203 --- /dev/null +++ b/docs/pluginification/epics/07_third_party_auth_enterprise_pipeline/EPIC.md @@ -0,0 +1,21 @@ +# Epic: Third-Party Auth Enterprise Pipeline + +JIRA: ENT-11566 + +## Purpose + +`openedx-platform/common/djangoapps/third_party_auth/` imports enterprise and enterprise_support functions in three ways: `settings.py` calls `insert_enterprise_pipeline_elements` to inject enterprise pipeline stages into `SOCIAL_AUTH_PIPELINE`, `pipeline.py` defines `associate_by_email_if_enterprise_user` with a lazy import of `enterprise_is_enabled`, and `saml.py` overrides `SAMLAuth.disconnect` with a lazy import of `unlink_enterprise_user_from_idp`. Additionally `utils.py` directly imports enterprise models. + +## Approach + +Three sub-parts replace these three behaviors: + +1. **Pipeline injection**: Add enterprise SAML pipeline stages to `SOCIAL_AUTH_PIPELINE` via edx-enterprise's `enterprise/settings/common.py` `plugin_settings()` callback (registered in epic 18), eliminating `insert_enterprise_pipeline_elements` and the `third_party_auth/settings.py` import. + +2. **Associate-by-email**: Move `associate_by_email_if_enterprise_user` into an edx-enterprise pipeline step (`enterprise.tpa_pipeline.enterprise_associate_by_email`) that will be registered via `SOCIAL_AUTH_PIPELINE` in `plugin_settings` as part of epic 18. Remove the function and its enterprise model imports from `pipeline.py` and `utils.py`. + +3. **SAML disconnect**: Emit a new Django signal `SocialAuthAccountDisconnected` from `SAMLAuth.disconnect`; edx-enterprise connects a handler that calls `unlink_enterprise_user_from_idp`. + +## Blocking Epics + +None. This epic has no blocking dependencies. diff --git a/docs/pluginification/epics/08_saml_admin_enterprise_views/01_openedx-platform.diff b/docs/pluginification/epics/08_saml_admin_enterprise_views/01_openedx-platform.diff new file mode 100644 index 0000000000..08f8402e9e --- /dev/null +++ b/docs/pluginification/epics/08_saml_admin_enterprise_views/01_openedx-platform.diff @@ -0,0 +1,246 @@ +diff --git a/common/djangoapps/third_party_auth/urls.py b/common/djangoapps/third_party_auth/urls.py +--- a/common/djangoapps/third_party_auth/urls.py ++++ b/common/djangoapps/third_party_auth/urls.py +@@ -28,8 +28,6 @@ urlpatterns = [ + path('auth/', include('social_django.urls', namespace='social')), +- path('auth/saml/v0/', include('common.djangoapps.third_party_auth.samlproviderconfig.urls')), +- path('auth/saml/v0/', include('common.djangoapps.third_party_auth.samlproviderdata.urls')), + path('auth/saml/v0/', include('common.djangoapps.third_party_auth.saml_configuration.urls')), + ] +diff --git a/common/djangoapps/third_party_auth/samlproviderconfig/views.py b/common/djangoapps/third_party_auth/samlproviderconfig/views.py +deleted file mode 100644 +--- a/common/djangoapps/third_party_auth/samlproviderconfig/views.py ++++ /dev/null +@@ -1,130 +0,0 @@ +-""" +-Viewset for auth/saml/v0/samlproviderconfig +-""" +- +-from django.shortcuts import get_list_or_404 +-from django.db.utils import IntegrityError +-from edx_rbac.mixins import PermissionRequiredMixin +-from rest_framework import permissions, viewsets, status +-from rest_framework.response import Response +-from rest_framework.exceptions import ParseError, ValidationError +- +-from enterprise.models import EnterpriseCustomerIdentityProvider, EnterpriseCustomer +-from common.djangoapps.third_party_auth.utils import validate_uuid4_string +- +-from ..models import SAMLProviderConfig +-from .serializers import SAMLProviderConfigSerializer +-from ..utils import convert_saml_slug_provider_id +- +- +-class SAMLProviderMixin: +- permission_classes = [permissions.IsAuthenticated] +- serializer_class = SAMLProviderConfigSerializer +- +- +-class SAMLProviderConfigViewSet(PermissionRequiredMixin, SAMLProviderMixin, viewsets.ModelViewSet): +- """ +- A View to handle SAMLProviderConfig CRUD +- """ +- permission_required = 'enterprise.can_access_admin_dashboard' +- +- def get_queryset(self): +- if self.requested_enterprise_uuid is None: +- raise ParseError('Required enterprise_customer_uuid is missing') +- enterprise_customer_idps = get_list_or_404( +- EnterpriseCustomerIdentityProvider, +- enterprise_customer__uuid=self.requested_enterprise_uuid +- ) +- slug_list = [idp.provider_id for idp in enterprise_customer_idps] +- saml_config_ids = [ +- config.id for config in SAMLProviderConfig.objects.current_set() if config.provider_id in slug_list +- ] +- return SAMLProviderConfig.objects.filter(id__in=saml_config_ids) +- +- def destroy(self, request, *args, **kwargs): +- saml_provider_config = self.get_object() +- config_id = saml_provider_config.id +- provider_config_provider_id = saml_provider_config.provider_id +- customer_uuid = self.requested_enterprise_uuid +- try: +- enterprise_customer = EnterpriseCustomer.objects.get(pk=customer_uuid) +- except EnterpriseCustomer.DoesNotExist: +- raise ValidationError(f'Enterprise customer not found at uuid: {customer_uuid}') +- +- enterprise_saml_provider = EnterpriseCustomerIdentityProvider.objects.filter( +- enterprise_customer=enterprise_customer, +- provider_id=provider_config_provider_id, +- ) +- enterprise_saml_provider.delete() +- SAMLProviderConfig.objects.filter(id=saml_provider_config.id).update(archived=True, enabled=False) +- return Response(status=status.HTTP_200_OK, data={'id': config_id}) +- +- def create(self, request, *args, **kwargs): +- enterprise_customer_uuid = request.data.get('enterprise_customer_uuid') +- if not enterprise_customer_uuid: +- raise ParseError('Required enterprise_customer_uuid is missing') +- if not validate_uuid4_string(enterprise_customer_uuid): +- raise ParseError('enterprise_customer_uuid is not a valid uuid4') +- try: +- enterprise_customer = EnterpriseCustomer.objects.get(pk=enterprise_customer_uuid) +- except EnterpriseCustomer.DoesNotExist: +- raise ValidationError(f'Enterprise customer not found at uuid: {enterprise_customer_uuid}') +- serializer = self.get_serializer(data=request.data) +- serializer.is_valid(raise_exception=True) +- try: +- instance = serializer.save() +- except IntegrityError: +- raise ValidationError('SAML provider config with this entity_id already exists.') +- EnterpriseCustomerIdentityProvider.objects.get_or_create( +- enterprise_customer=enterprise_customer, +- provider_id=convert_saml_slug_provider_id(instance.slug), +- ) +- headers = self.get_success_headers(serializer.data) +- return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers) +- +- @property +- def requested_enterprise_uuid(self): +- return ( +- self.request.query_params.get('enterprise-id') or +- self.request.data.get('enterprise_customer_uuid') +- ) +- +- def get_permission_object(self): +- return self.requested_enterprise_uuid +diff --git a/common/djangoapps/third_party_auth/samlproviderconfig/urls.py b/common/djangoapps/third_party_auth/samlproviderconfig/urls.py +deleted file mode 100644 +--- a/common/djangoapps/third_party_auth/samlproviderconfig/urls.py ++++ /dev/null +@@ -1,11 +0,0 @@ +-""" +- Viewset for auth/saml/v0/providerconfig/ +-""" +- +-from rest_framework import routers +- +-from .views import SAMLProviderConfigViewSet +- +-saml_provider_config_router = routers.DefaultRouter() +-saml_provider_config_router.register(r'provider_config', SAMLProviderConfigViewSet, basename="saml_provider_config") +-urlpatterns = saml_provider_config_router.urls +diff --git a/common/djangoapps/third_party_auth/samlproviderdata/views.py b/common/djangoapps/third_party_auth/samlproviderdata/views.py +deleted file mode 100644 +--- a/common/djangoapps/third_party_auth/samlproviderdata/views.py ++++ /dev/null +@@ -1,120 +0,0 @@ +-""" +- Viewset for auth/saml/v0/samlproviderdata +-""" +-from datetime import datetime +-import logging +-from requests.exceptions import SSLError, MissingSchema, HTTPError +- +-from django.http import Http404 +-from django.shortcuts import get_object_or_404 +-from edx_rbac.mixins import PermissionRequiredMixin +-from enterprise.models import EnterpriseCustomerIdentityProvider +-from rest_framework import permissions, status, viewsets +-from rest_framework.decorators import action +-from rest_framework.exceptions import ParseError +-from rest_framework.response import Response +- +-from common.djangoapps.third_party_auth.utils import ( +- convert_saml_slug_provider_id, +- create_or_update_bulk_saml_provider_data, +- fetch_metadata_xml, +- parse_metadata_xml, +- validate_uuid4_string +-) +- +-from ..models import SAMLProviderConfig, SAMLProviderData +-from .serializers import SAMLProviderDataSerializer +- +-log = logging.getLogger(__name__) +- +- +-class SAMLProviderDataMixin: +- permission_classes = [permissions.IsAuthenticated] +- serializer_class = SAMLProviderDataSerializer +- +- +-class SAMLProviderDataViewSet(PermissionRequiredMixin, SAMLProviderDataMixin, viewsets.ModelViewSet): +- """ +- A View to handle SAMLProviderData CRUD. +- """ +- permission_required = 'enterprise.can_access_admin_dashboard' +- +- def get_queryset(self): +- if self.requested_enterprise_uuid is None: +- raise ParseError('Required enterprise_customer_uuid is missing') +- enterprise_customer_idp = get_object_or_404( +- EnterpriseCustomerIdentityProvider, +- enterprise_customer__uuid=self.requested_enterprise_uuid +- ) +- try: +- saml_provider = SAMLProviderConfig.objects.current_set().get( +- slug=convert_saml_slug_provider_id(enterprise_customer_idp.provider_id)) +- except SAMLProviderConfig.DoesNotExist: +- raise Http404('No matching SAML provider found.') +- provider_data_id = self.request.parser_context.get('kwargs').get('pk') +- if provider_data_id: +- return SAMLProviderData.objects.filter(id=provider_data_id) +- return SAMLProviderData.objects.filter(entity_id=saml_provider.entity_id) +- +- @property +- def requested_enterprise_uuid(self): +- return ( +- self.request.query_params.get('enterprise-id') or +- self.request.data.get('enterprise_customer_uuid') +- ) +- +- def get_permission_object(self): +- return self.requested_enterprise_uuid +- +- @action(detail=False, methods=['post'], url_path='sync_provider_data') +- def sync_provider_data(self, request): +- enterprise_customer_uuid = request.data.get('enterprise_customer_uuid') +- if not validate_uuid4_string(enterprise_customer_uuid): +- raise ParseError('enterprise_customer_uuid is not a valid uuid4') +- enterprise_customer_idp = get_object_or_404( +- EnterpriseCustomerIdentityProvider, +- enterprise_customer__uuid=enterprise_customer_uuid +- ) +- try: +- saml_provider = SAMLProviderConfig.objects.current_set().get( +- slug=convert_saml_slug_provider_id(enterprise_customer_idp.provider_id)) +- except SAMLProviderConfig.DoesNotExist: +- raise Http404('No matching SAML provider found.') +- metadata_url = saml_provider.metadata_source +- try: +- xml = fetch_metadata_xml(metadata_url) +- except (SSLError, MissingSchema, HTTPError) as exc: +- return Response( +- data={'error': f'Failed to fetch metadata XML: {exc}'}, +- status=status.HTTP_400_BAD_REQUEST, +- ) +- result = parse_metadata_xml(xml, saml_provider.entity_id) +- if result is None: +- return Response( +- data={'error': 'Failed to parse metadata XML.'}, +- status=status.HTTP_400_BAD_REQUEST, +- ) +- public_key, sso_url, expires_at = result +- create_or_update_bulk_saml_provider_data(public_key, sso_url, expires_at, saml_provider.entity_id) +- return Response( +- data={'message': 'Synced provider data successfully.'}, +- status=status.HTTP_200_OK, +- ) +diff --git a/common/djangoapps/third_party_auth/samlproviderdata/urls.py b/common/djangoapps/third_party_auth/samlproviderdata/urls.py +deleted file mode 100644 +--- a/common/djangoapps/third_party_auth/samlproviderdata/urls.py ++++ /dev/null +@@ -1,11 +0,0 @@ +-""" +- url mappings for auth/saml/v0/providerdata/ +-""" +- +-from rest_framework import routers +- +-from .views import SAMLProviderDataViewSet +- +-saml_provider_data_router = routers.DefaultRouter() +-saml_provider_data_router.register(r'provider_data', SAMLProviderDataViewSet, basename="saml_provider_data") +-urlpatterns = saml_provider_data_router.urls diff --git a/docs/pluginification/epics/08_saml_admin_enterprise_views/01_openedx-platform.md b/docs/pluginification/epics/08_saml_admin_enterprise_views/01_openedx-platform.md new file mode 100644 index 0000000000..5882899813 --- /dev/null +++ b/docs/pluginification/epics/08_saml_admin_enterprise_views/01_openedx-platform.md @@ -0,0 +1,13 @@ +# [openedx-platform] Remove SAML provider admin views + +No tickets block this one. + +Remove `SAMLProviderConfigViewSet` from `samlproviderconfig/views.py` and `SAMLProviderDataViewSet` from `samlproviderdata/views.py` in `common/djangoapps/third_party_auth/`. Both viewsets import `enterprise.models` and exist only to serve enterprise admin functionality. Remove the corresponding URL registrations from `common/djangoapps/third_party_auth/urls.py`. The equivalent views will be hosted within edx-enterprise instead. + +## A/C + +- `SAMLProviderConfigViewSet` and its file `samlproviderconfig/views.py` are deleted from `common/djangoapps/third_party_auth/`. +- `SAMLProviderDataViewSet` and its file `samlproviderdata/views.py` are deleted from `common/djangoapps/third_party_auth/`. +- The two `path('auth/saml/v0/', include('...samlproviderconfig.urls'))` and `path('auth/saml/v0/', include('...samlproviderdata.urls'))` entries are removed from `common/djangoapps/third_party_auth/urls.py`. +- No import of `enterprise` or `enterprise_support` remains in any changed file. +- Existing tests for these viewsets are deleted from openedx-platform. diff --git a/docs/pluginification/epics/08_saml_admin_enterprise_views/02_edx-enterprise.diff b/docs/pluginification/epics/08_saml_admin_enterprise_views/02_edx-enterprise.diff new file mode 100644 index 0000000000..14c1837eeb --- /dev/null +++ b/docs/pluginification/epics/08_saml_admin_enterprise_views/02_edx-enterprise.diff @@ -0,0 +1,258 @@ +diff --git a/enterprise/api/v1/views/saml_provider_config.py b/enterprise/api/v1/views/saml_provider_config.py +new file mode 100644 +--- /dev/null ++++ b/enterprise/api/v1/views/saml_provider_config.py +@@ -0,0 +1,82 @@ ++""" ++Viewset for enterprise SAML provider config administration. ++""" ++from django.db.utils import IntegrityError ++from django.shortcuts import get_list_or_404 ++from edx_rbac.mixins import PermissionRequiredMixin ++from rest_framework import permissions, status, viewsets ++from rest_framework.exceptions import ParseError, ValidationError ++from rest_framework.response import Response ++ ++from enterprise.models import EnterpriseCustomer, EnterpriseCustomerIdentityProvider ++ ++ ++class SAMLProviderConfigViewSet(PermissionRequiredMixin, viewsets.ModelViewSet): ++ """ ++ A View to handle SAMLProviderConfig CRUD for enterprise admin users. ++ ++ Usage:: ++ ++ GET /enterprise/api/v1/auth/saml/v0/provider_config/?enterprise-id= ++ POST /enterprise/api/v1/auth/saml/v0/provider_config/ ++ PATCH /enterprise/api/v1/auth/saml/v0/provider_config// ++ DELETE /enterprise/api/v1/auth/saml/v0/provider_config// ++ """ ++ ++ permission_classes = [permissions.IsAuthenticated] ++ permission_required = 'enterprise.can_access_admin_dashboard' ++ ++ def _get_tpa_classes(self): ++ # Deferred import — TPA models live in openedx-platform. ++ from common.djangoapps.third_party_auth.models import SAMLProviderConfig # pylint: disable=import-outside-toplevel ++ from common.djangoapps.third_party_auth.samlproviderconfig.serializers import SAMLProviderConfigSerializer # pylint: disable=import-outside-toplevel ++ from common.djangoapps.third_party_auth.utils import convert_saml_slug_provider_id, validate_uuid4_string # pylint: disable=import-outside-toplevel ++ return SAMLProviderConfig, SAMLProviderConfigSerializer, convert_saml_slug_provider_id, validate_uuid4_string ++ ++ def get_serializer_class(self): ++ _, SAMLProviderConfigSerializer, _, _ = self._get_tpa_classes() ++ return SAMLProviderConfigSerializer ++ ++ def get_queryset(self): ++ SAMLProviderConfig, _, _, _ = self._get_tpa_classes() ++ if self.requested_enterprise_uuid is None: ++ raise ParseError('Required enterprise_customer_uuid is missing') ++ enterprise_customer_idps = get_list_or_404( ++ EnterpriseCustomerIdentityProvider, ++ enterprise_customer__uuid=self.requested_enterprise_uuid ++ ) ++ slug_list = [idp.provider_id for idp in enterprise_customer_idps] ++ saml_config_ids = [ ++ config.id for config in SAMLProviderConfig.objects.current_set() ++ if config.provider_id in slug_list ++ ] ++ return SAMLProviderConfig.objects.filter(id__in=saml_config_ids) ++ ++ def destroy(self, request, *args, **kwargs): ++ SAMLProviderConfig, _, _, _ = self._get_tpa_classes() ++ saml_provider_config = self.get_object() ++ config_id = saml_provider_config.id ++ provider_config_provider_id = saml_provider_config.provider_id ++ customer_uuid = self.requested_enterprise_uuid ++ try: ++ enterprise_customer = EnterpriseCustomer.objects.get(pk=customer_uuid) ++ except EnterpriseCustomer.DoesNotExist: ++ raise ValidationError(f'Enterprise customer not found at uuid: {customer_uuid}') # pylint: disable=raise-missing-from ++ EnterpriseCustomerIdentityProvider.objects.filter( ++ enterprise_customer=enterprise_customer, ++ provider_id=provider_config_provider_id, ++ ).delete() ++ SAMLProviderConfig.objects.filter(id=config_id).update(archived=True, enabled=False) ++ return Response(status=status.HTTP_200_OK, data={'id': config_id}) ++ ++ def create(self, request, *args, **kwargs): ++ SAMLProviderConfig, _, convert_saml_slug_provider_id, validate_uuid4_string = self._get_tpa_classes() ++ enterprise_customer_uuid = request.data.get('enterprise_customer_uuid') ++ if not enterprise_customer_uuid or not validate_uuid4_string(enterprise_customer_uuid): ++ raise ParseError('enterprise_customer_uuid is missing or invalid') ++ try: ++ enterprise_customer = EnterpriseCustomer.objects.get(pk=enterprise_customer_uuid) ++ except EnterpriseCustomer.DoesNotExist: ++ raise ValidationError(f'Enterprise customer not found at uuid: {enterprise_customer_uuid}') # pylint: disable=raise-missing-from ++ serializer = self.get_serializer(data=request.data) ++ serializer.is_valid(raise_exception=True) ++ try: ++ instance = serializer.save() ++ except IntegrityError: ++ raise ValidationError('SAML provider config with this entity_id already exists.') # pylint: disable=raise-missing-from ++ EnterpriseCustomerIdentityProvider.objects.get_or_create( ++ enterprise_customer=enterprise_customer, ++ provider_id=convert_saml_slug_provider_id(instance.slug), ++ ) ++ headers = self.get_success_headers(serializer.data) ++ return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers) ++ ++ @property ++ def requested_enterprise_uuid(self): ++ return ( ++ self.request.query_params.get('enterprise-id') or ++ self.request.data.get('enterprise_customer_uuid') ++ ) ++ ++ def get_permission_object(self): ++ return self.requested_enterprise_uuid +diff --git a/enterprise/api/v1/views/saml_provider_data.py b/enterprise/api/v1/views/saml_provider_data.py +new file mode 100644 +--- /dev/null ++++ b/enterprise/api/v1/views/saml_provider_data.py +@@ -0,0 +1,100 @@ ++""" ++Viewset for enterprise SAML provider data administration. ++""" ++import logging ++ ++from django.http import Http404 ++from django.shortcuts import get_object_or_404 ++from edx_rbac.mixins import PermissionRequiredMixin ++from requests.exceptions import HTTPError, MissingSchema, SSLError ++from rest_framework import permissions, status, viewsets ++from rest_framework.decorators import action ++from rest_framework.exceptions import ParseError ++from rest_framework.response import Response ++ ++from enterprise.models import EnterpriseCustomerIdentityProvider ++ ++log = logging.getLogger(__name__) ++ ++ ++class SAMLProviderDataViewSet(PermissionRequiredMixin, viewsets.ModelViewSet): ++ """ ++ A View to handle SAMLProviderData CRUD for enterprise admin users. ++ ++ Usage:: ++ ++ GET /enterprise/api/v1/auth/saml/v0/provider_data/?enterprise-id= ++ POST /enterprise/api/v1/auth/saml/v0/provider_data/ ++ PATCH /enterprise/api/v1/auth/saml/v0/provider_data// ++ DELETE /enterprise/api/v1/auth/saml/v0/provider_data// ++ POST /enterprise/api/v1/auth/saml/v0/provider_data/sync_provider_data ++ """ ++ ++ permission_classes = [permissions.IsAuthenticated] ++ permission_required = 'enterprise.can_access_admin_dashboard' + ++ def _get_tpa_classes(self): ++ # Deferred import — TPA models live in openedx-platform. ++ from common.djangoapps.third_party_auth.models import SAMLProviderConfig, SAMLProviderData # pylint: disable=import-outside-toplevel ++ from common.djangoapps.third_party_auth.samlproviderdata.serializers import SAMLProviderDataSerializer # pylint: disable=import-outside-toplevel ++ from common.djangoapps.third_party_auth.utils import ( # pylint: disable=import-outside-toplevel ++ convert_saml_slug_provider_id, ++ create_or_update_bulk_saml_provider_data, ++ fetch_metadata_xml, ++ parse_metadata_xml, ++ validate_uuid4_string, ++ ) ++ return ( ++ SAMLProviderConfig, SAMLProviderData, SAMLProviderDataSerializer, ++ convert_saml_slug_provider_id, create_or_update_bulk_saml_provider_data, ++ fetch_metadata_xml, parse_metadata_xml, validate_uuid4_string, ++ ) ++ ++ def get_serializer_class(self): ++ _, _, SAMLProviderDataSerializer, *_ = self._get_tpa_classes() ++ return SAMLProviderDataSerializer ++ ++ def get_queryset(self): ++ SAMLProviderConfig, SAMLProviderData, _, convert_saml_slug_provider_id, *_ = self._get_tpa_classes() ++ if self.requested_enterprise_uuid is None: ++ raise ParseError('Required enterprise_customer_uuid is missing') ++ enterprise_customer_idp = get_object_or_404( ++ EnterpriseCustomerIdentityProvider, ++ enterprise_customer__uuid=self.requested_enterprise_uuid ++ ) ++ try: ++ saml_provider = SAMLProviderConfig.objects.current_set().get( ++ slug=convert_saml_slug_provider_id(enterprise_customer_idp.provider_id)) ++ except SAMLProviderConfig.DoesNotExist: ++ raise Http404('No matching SAML provider found.') # pylint: disable=raise-missing-from ++ provider_data_id = self.request.parser_context.get('kwargs', {}).get('pk') ++ if provider_data_id: ++ return SAMLProviderData.objects.filter(id=provider_data_id) ++ return SAMLProviderData.objects.filter(entity_id=saml_provider.entity_id) ++ ++ @property ++ def requested_enterprise_uuid(self): ++ return ( ++ self.request.query_params.get('enterprise-id') or ++ self.request.data.get('enterprise_customer_uuid') ++ ) ++ ++ def get_permission_object(self): ++ return self.requested_enterprise_uuid ++ ++ @action(detail=False, methods=['post'], url_path='sync_provider_data') ++ def sync_provider_data(self, request): ++ (SAMLProviderConfig, _, _, convert_saml_slug_provider_id, create_or_update_bulk_saml_provider_data, ++ fetch_metadata_xml, parse_metadata_xml, validate_uuid4_string) = self._get_tpa_classes() ++ enterprise_customer_uuid = request.data.get('enterprise_customer_uuid') ++ if not validate_uuid4_string(enterprise_customer_uuid): ++ raise ParseError('enterprise_customer_uuid is not a valid uuid4') ++ enterprise_customer_idp = get_object_or_404( ++ EnterpriseCustomerIdentityProvider, ++ enterprise_customer__uuid=enterprise_customer_uuid ++ ) ++ try: ++ saml_provider = SAMLProviderConfig.objects.current_set().get( ++ slug=convert_saml_slug_provider_id(enterprise_customer_idp.provider_id)) ++ except SAMLProviderConfig.DoesNotExist: ++ raise Http404('No matching SAML provider found.') # pylint: disable=raise-missing-from ++ metadata_url = saml_provider.metadata_source ++ try: ++ xml = fetch_metadata_xml(metadata_url) ++ except (SSLError, MissingSchema, HTTPError) as exc: ++ return Response( ++ data={'error': f'Failed to fetch metadata XML: {exc}'}, ++ status=status.HTTP_400_BAD_REQUEST, ++ ) ++ result = parse_metadata_xml(xml, saml_provider.entity_id) ++ if result is None: ++ return Response( ++ data={'error': 'Failed to parse metadata XML.'}, ++ status=status.HTTP_400_BAD_REQUEST, ++ ) ++ public_key, sso_url, expires_at = result ++ create_or_update_bulk_saml_provider_data(public_key, sso_url, expires_at, saml_provider.entity_id) ++ return Response( ++ data={'message': 'Synced provider data successfully.'}, ++ status=status.HTTP_200_OK, ++ ) +diff --git a/enterprise/api/v1/urls.py b/enterprise/api/v1/urls.py +--- a/enterprise/api/v1/urls.py ++++ b/enterprise/api/v1/urls.py +@@ -1,6 +1,8 @@ + """URL definitions for enterprise API version 1.""" + + from rest_framework import routers ++from rest_framework.routers import DefaultRouter ++ ++from enterprise.api.v1.views.saml_provider_config import SAMLProviderConfigViewSet ++from enterprise.api.v1.views.saml_provider_data import SAMLProviderDataViewSet + + from enterprise.api.v1 import views + +@@ -10,3 +12,11 @@ router = routers.DefaultRouter() + # ... existing router registrations ... + + urlpatterns = router.urls ++ ++# SAML provider admin endpoints (migrated from openedx-platform third_party_auth). ++_saml_router = DefaultRouter() ++_saml_router.register(r'auth/saml/v0/provider_config', SAMLProviderConfigViewSet, ++ basename='enterprise-saml-provider-config') ++_saml_router.register(r'auth/saml/v0/provider_data', SAMLProviderDataViewSet, ++ basename='enterprise-saml-provider-data') ++urlpatterns += _saml_router.urls diff --git a/docs/pluginification/epics/08_saml_admin_enterprise_views/02_edx-enterprise.md b/docs/pluginification/epics/08_saml_admin_enterprise_views/02_edx-enterprise.md new file mode 100644 index 0000000000..3c22586675 --- /dev/null +++ b/docs/pluginification/epics/08_saml_admin_enterprise_views/02_edx-enterprise.md @@ -0,0 +1,12 @@ +# [edx-enterprise] Add SAML provider admin viewsets + +Blocked by: [openedx-platform] Remove SAML provider admin views + +Add `SAMLProviderConfigViewSet` and `SAMLProviderDataViewSet` to edx-enterprise under the enterprise admin API, restoring the same REST endpoints previously served by openedx-platform. The viewsets import TPA models from `common.djangoapps.third_party_auth` (a deferred import, allowed because these views run inside the LMS process) and enterprise models from `enterprise.models`. Register the new views under the enterprise URL namespace at `enterprise/api/v1/saml/` so the same API contract is preserved. + +## A/C + +- `enterprise/api/v1/views/saml_provider_config.py` contains `SAMLProviderConfigViewSet` with all CRUD operations matching the original openedx-platform implementation. +- `enterprise/api/v1/views/saml_provider_data.py` contains `SAMLProviderDataViewSet` with all CRUD and `sync_provider_data` action matching the original implementation. +- Both viewsets are wired into `enterprise/api/v1/urls.py` at `auth/saml/v0/`. +- Unit tests cover the CRUD operations and permission checks. diff --git a/docs/pluginification/epics/08_saml_admin_enterprise_views/EPIC.md b/docs/pluginification/epics/08_saml_admin_enterprise_views/EPIC.md new file mode 100644 index 0000000000..4f5d9fdd2b --- /dev/null +++ b/docs/pluginification/epics/08_saml_admin_enterprise_views/EPIC.md @@ -0,0 +1,15 @@ +# Epic: SAML Admin Enterprise Views + +JIRA: ENT-11567 + +## Purpose + +`SAMLProviderConfigViewSet` and `SAMLProviderDataViewSet` in `common/djangoapps/third_party_auth/` import `enterprise.models` directly and exist solely to serve enterprise SAML admin functionality; they have no non-enterprise use case. + +## Approach + +Move both viewsets into edx-enterprise as admin API views, exposing the same REST API endpoints under the enterprise URL namespace. Remove the original files and their URL registrations from `common/djangoapps/third_party_auth/urls.py` in openedx-platform. + +## Blocking Epics + +None. diff --git a/docs/pluginification/epics/09_logistration_enterprise_context/01_openedx-filters.diff b/docs/pluginification/epics/09_logistration_enterprise_context/01_openedx-filters.diff new file mode 100644 index 0000000000..386c5396bf --- /dev/null +++ b/docs/pluginification/epics/09_logistration_enterprise_context/01_openedx-filters.diff @@ -0,0 +1,79 @@ +diff --git a/openedx_filters/learning/filters.py b/openedx_filters/learning/filters.py +--- a/openedx_filters/learning/filters.py ++++ b/openedx_filters/learning/filters.py +@@ -1169,3 +1169,71 @@ class InstructorDashboardRenderStarted(OpenEdxPublicFilter): + data = super().run_pipeline(context=context, template_name=template_name) + return data.get("context"), data.get("template_name") + ++ ++class LogistrationContextRequested(OpenEdxPublicFilter): ++ """ ++ Filter used to enrich or modify the combined login-and-registration page context. ++ ++ Purpose: ++ This filter is triggered just before the combined login/registration page is rendered, ++ allowing pipeline steps to modify the context dict and customize the response (e.g. set ++ cookies or alter sidebar content) based on external conditions. ++ ++ Filter Type: ++ org.openedx.learning.logistration.context.requested.v1 ++ ++ Trigger: ++ - Repository: openedx/edx-platform ++ - Path: openedx/core/djangoapps/user_authn/views/login_form.py ++ - Function or Method: login_and_registration_form ++ """ ++ ++ filter_type = "org.openedx.learning.logistration.context.requested.v1" ++ ++ @classmethod ++ def run_filter(cls, context: dict, request: Any) -> tuple[dict, Any]: ++ """ ++ Process the context and request through the configured pipeline steps. ++ ++ Arguments: ++ context (dict): the template context dict for the login/registration page. ++ request (HttpRequest): the current HTTP request. ++ ++ Returns: ++ tuple[dict, HttpRequest]: the (possibly modified) context and request. ++ """ ++ data = super().run_pipeline(context=context, request=request) ++ return data.get("context"), data.get("request") ++ ++ ++class PostLoginRedirectURLRequested(OpenEdxPublicFilter): ++ """ ++ Filter used to determine an optional redirect URL immediately after a successful login. ++ ++ Purpose: ++ This filter is triggered after a user has been authenticated, before the final redirect ++ is issued. Pipeline steps may return an alternative redirect URL to send the user ++ through additional post-login flows (e.g. an account-selection page). ++ ++ Filter Type: ++ org.openedx.learning.auth.post_login.redirect_url.requested.v1 ++ ++ Trigger: ++ - Repository: openedx/edx-platform ++ - Path: openedx/core/djangoapps/user_authn/views/login.py ++ - Function or Method: login_user ++ """ ++ ++ filter_type = "org.openedx.learning.auth.post_login.redirect_url.requested.v1" ++ ++ @classmethod ++ def run_filter(cls, redirect_url: str, user: Any, next_url: str) -> str: ++ """ ++ Process the redirect URL through the configured pipeline steps. ++ ++ Arguments: ++ redirect_url (str): the current intended redirect URL (may be empty). ++ user (User): the authenticated Django user. ++ next_url (str): the next URL parameter from the login request. ++ ++ Returns: ++ str: the (possibly modified) redirect URL. ++ """ ++ data = super().run_pipeline(redirect_url=redirect_url, user=user, next_url=next_url) ++ return data.get("redirect_url") diff --git a/docs/pluginification/epics/09_logistration_enterprise_context/01_openedx-filters.md b/docs/pluginification/epics/09_logistration_enterprise_context/01_openedx-filters.md new file mode 100644 index 0000000000..5d0123ad8d --- /dev/null +++ b/docs/pluginification/epics/09_logistration_enterprise_context/01_openedx-filters.md @@ -0,0 +1,11 @@ +# [openedx-filters] Add LogistrationContextRequested and PostLoginRedirectURLRequested filters + +No tickets block this one. + +Add two new filter classes to `openedx_filters/learning/filters.py`. The first, `LogistrationContextRequested`, is triggered just before the combined login-and-registration page is rendered; pipeline steps can modify the context dict and response object (e.g. to inject enterprise customer data, set cookies, or update sidebar context). The second, `PostLoginRedirectURLRequested`, is triggered after a user successfully logs in; pipeline steps can return an optional redirect URL to send the user to an enterprise selection page or similar destination before the normal post-login redirect. + +## A/C + +- `LogistrationContextRequested` is defined in `openedx_filters/learning/filters.py` with filter type `"org.openedx.learning.logistration.context.requested.v1"` and `run_filter(context, request)` returning the modified context dict. +- `PostLoginRedirectURLRequested` is defined in `openedx_filters/learning/filters.py` with filter type `"org.openedx.learning.auth.post_login.redirect_url.requested.v1"` and `run_filter(redirect_url, user, next_url)` returning the (possibly modified) redirect URL. +- Neither filter class name, filter type string, nor docstring mentions "enterprise". diff --git a/docs/pluginification/epics/09_logistration_enterprise_context/02_openedx-platform.diff b/docs/pluginification/epics/09_logistration_enterprise_context/02_openedx-platform.diff new file mode 100644 index 0000000000..3a1469aabb --- /dev/null +++ b/docs/pluginification/epics/09_logistration_enterprise_context/02_openedx-platform.diff @@ -0,0 +1,152 @@ +diff --git a/openedx/core/djangoapps/user_authn/views/login_form.py b/openedx/core/djangoapps/user_authn/views/login_form.py +--- a/openedx/core/djangoapps/user_authn/views/login_form.py ++++ b/openedx/core/djangoapps/user_authn/views/login_form.py +@@ -29,12 +29,8 @@ from openedx.core.djangoapps.user_authn.views.password_reset import get_password + from openedx.core.djangoapps.user_authn.views.registration_form import RegistrationFormFactory + from openedx.core.djangoapps.user_authn.views.utils import third_party_auth_context + from openedx.core.djangoapps.user_authn.toggles import is_require_third_party_auth_enabled +-from openedx.features.enterprise_support.api import enterprise_customer_for_request, enterprise_enabled +-from openedx.features.enterprise_support.utils import ( +- get_enterprise_slug_login_url, +- handle_enterprise_cookies_for_logistration, +- update_logistration_context_for_enterprise +-) ++from openedx_filters.learning.filters import LogistrationContextRequested + from common.djangoapps.student.helpers import get_next_url_for_login_page + from common.djangoapps.third_party_auth import pipeline + from common.djangoapps.third_party_auth.decorators import xframe_allow_whitelisted +@@ -56,10 +52,6 @@ def _apply_third_party_auth_overrides(request, form_desc): + if third_party_auth.is_enabled(): + running_pipeline = third_party_auth.pipeline.get(request) + if running_pipeline: + current_provider = third_party_auth.provider.Registry.get_from_pipeline(running_pipeline) +- if current_provider and enterprise_customer_for_request(request): +- pipeline_kwargs = running_pipeline.get('kwargs') +- details = pipeline_kwargs.get('details') +- email = details.get('email', '') +- form_desc.override_field_properties( +- "email", +- default=email, +- restrictions={"readonly": "readonly"} if email else { +- "min_length": accounts.EMAIL_MIN_LENGTH, +- "max_length": accounts.EMAIL_MAX_LENGTH, +- } +- ) ++ if current_provider: ++ pass # SSO-specific form overrides are handled by filter pipeline steps. +@@ -257,15 +249,12 @@ def login_and_registration_form(request, initial_mode="login"): + +- enterprise_customer = enterprise_customer_for_request(request) +- +- if should_redirect_to_authn_microfrontend() and \ +- not enterprise_customer and \ +- not tpa_hint_provider and \ +- not saml_provider: ++ if should_redirect_to_authn_microfrontend() and not tpa_hint_provider and not saml_provider: + if request.user.is_authenticated and redirect_to: + return redirect(redirect_to) + query_params = request.GET.urlencode() + url_path = '/{}{}'.format( + initial_mode, + '?' + query_params if query_params else '' + ) + return redirect(settings.AUTHN_MICROFRONTEND_URL + url_path) + +@@ -286,7 +275,6 @@ def login_and_registration_form(request, initial_mode="login"): + context = { + 'data': { + 'login_redirect_url': redirect_to, + 'initial_mode': initial_mode, + 'third_party_auth': third_party_auth_context(request, redirect_to, third_party_auth_hint), + 'third_party_auth_hint': third_party_auth_hint or '', + 'platform_name': configuration_helpers.get_value('PLATFORM_NAME', settings.PLATFORM_NAME), + 'support_link': configuration_helpers.get_value('SUPPORT_SITE_LINK', settings.SUPPORT_SITE_LINK), + 'password_reset_support_link': configuration_helpers.get_value( + 'PASSWORD_RESET_SUPPORT_LINK', settings.PASSWORD_RESET_SUPPORT_LINK + ) or settings.SUPPORT_SITE_LINK, + 'account_activation_messages': account_activation_messages, + 'account_recovery_messages': account_recovery_messages, + 'login_form_desc': json.loads(form_descriptions['login']), + 'registration_form_desc': json.loads(form_descriptions['registration']), + 'password_reset_form_desc': json.loads(form_descriptions['password_reset']), + 'account_creation_allowed': configuration_helpers.get_value( + 'ALLOW_PUBLIC_ACCOUNT_CREATION', settings.FEATURES.get('ALLOW_PUBLIC_ACCOUNT_CREATION', True)), + 'register_links_allowed': settings.FEATURES.get('SHOW_REGISTRATION_LINKS', True), + 'is_account_recovery_feature_enabled': is_secondary_email_feature_enabled(), +- 'enterprise_slug_login_url': get_enterprise_slug_login_url(), +- 'is_enterprise_enable': enterprise_enabled(), + 'is_require_third_party_auth_enabled': is_require_third_party_auth_enabled(), + 'enable_coppa_compliance': settings.ENABLE_COPPA_COMPLIANCE, + 'edx_user_info_cookie_name': settings.EDXMKTG_USER_INFO_COOKIE_NAME, + }, + 'login_redirect_url': redirect_to, + 'responsive': True, + 'allow_iframing': True, + 'disable_courseware_js': True, + 'combined_login_and_register': True, + 'disable_footer': not configuration_helpers.get_value( + 'ENABLE_COMBINED_LOGIN_REGISTRATION_FOOTER', + settings.FEATURES['ENABLE_COMBINED_LOGIN_REGISTRATION_FOOTER'] + ), + } + +- update_logistration_context_for_enterprise(request, context, enterprise_customer) +- ++ context, _ = LogistrationContextRequested.run_filter(context=context, request=request) + response = render_to_response('student_account/login_and_register.html', context) +- handle_enterprise_cookies_for_logistration(request, response, context) +- + return response +diff --git a/openedx/core/djangoapps/user_authn/views/login.py b/openedx/core/djangoapps/user_authn/views/login.py +--- a/openedx/core/djangoapps/user_authn/views/login.py ++++ b/openedx/core/djangoapps/user_authn/views/login.py +@@ -60,8 +60,7 @@ from openedx.core.djangoapps.user_authn.views.utils import third_party_auth_con + from openedx.core.djangoapps.user_authn.toggles import is_require_third_party_auth_enabled +-from openedx.features.enterprise_support.api import activate_learner_enterprise, get_enterprise_learner_data_from_api ++from openedx_filters.learning.filters import PostLoginRedirectURLRequested + from common.djangoapps.student.helpers import get_next_url_for_login_page +@@ -478,28 +477,0 @@ def login_user(request): +-def enterprise_selection_page(request, user, next_url): +- """ +- Updates redirect url to enterprise selection page if user is associated +- with multiple enterprises otherwise return the next url. +- """ +- response = get_enterprise_learner_data_from_api(user) +- if len(response) > 1: +- redirect_url = reverse("enterprise_select_active") + "/?success_url=" + urllib.parse.quote(next_url) +- if next_url: +- try: +- enterprise_in_url = re.search(UUID4_REGEX, next_url).group(0) +- for enterprise in response: +- if enterprise_in_url == str(enterprise["enterprise_customer"]["uuid"]): +- is_activated_successfully = activate_learner_enterprise(request, user, enterprise_in_url) +- if is_activated_successfully: +- redirect_url = next_url +- break +- except AttributeError: +- pass +- return redirect_url +- return next_url +- +@@ -648,7 +620,7 @@ def _do_third_party_auth(request): +- root_url, enterprise_selection_page(request, possibly_authenticated_user, finish_auth_url or next_url) ++ root_url, PostLoginRedirectURLRequested.run_filter( ++ redirect_url='', ++ user=possibly_authenticated_user, ++ next_url=finish_auth_url or next_url, ++ ) or finish_auth_url or next_url +diff --git a/lms/envs/common.py b/lms/envs/common.py +--- a/lms/envs/common.py ++++ b/lms/envs/common.py +@@ -1,6 +1,8 @@ OPEN_EDX_FILTERS_CONFIG = { + OPEN_EDX_FILTERS_CONFIG = { + # ... existing filter entries ... ++ "org.openedx.learning.logistration.context.requested.v1": { ++ "fail_silently": True, ++ "pipeline": ["enterprise.filters.logistration.LogistrationContextEnricher"], ++ }, ++ "org.openedx.learning.auth.post_login.redirect_url.requested.v1": { ++ "fail_silently": True, ++ "pipeline": ["enterprise.filters.logistration.PostLoginEnterpriseRedirect"], ++ }, + } diff --git a/docs/pluginification/epics/09_logistration_enterprise_context/02_openedx-platform.md b/docs/pluginification/epics/09_logistration_enterprise_context/02_openedx-platform.md new file mode 100644 index 0000000000..1fb7d6ca69 --- /dev/null +++ b/docs/pluginification/epics/09_logistration_enterprise_context/02_openedx-platform.md @@ -0,0 +1,15 @@ +# [openedx-platform] Replace enterprise logistration imports with filter calls + +Blocked by: [openedx-filters] Add LogistrationContextRequested and PostLoginRedirectURLRequested filters + +Remove all `enterprise_support` imports from `login_form.py`, `registration_form.py`, and `login.py` in `openedx/core/djangoapps/user_authn/views/`. Replace the enterprise customer context enrichment and cookie logic in `login_form.py` with a call to the new `LogistrationContextRequested` filter. Replace the enterprise SSO guard in `registration_form.py` (the `enterprise_customer_for_request` check that gates the SSO registration form skip) with a `StudentRegistrationRequested` pipeline step (the filter is already invoked in that view). Replace `enterprise_selection_page` in `login.py` with a call to the new `PostLoginRedirectURLRequested` filter and remove the `activate_learner_enterprise` and `get_enterprise_learner_data_from_api` imports. + +## A/C + +- All `from openedx.features.enterprise_support...` imports are removed from `login_form.py`, `registration_form.py`, and `login.py`. +- `login_form.py` calls `LogistrationContextRequested.run_filter(context, request)` and uses the returned context for rendering; `update_logistration_context_for_enterprise` and `handle_enterprise_cookies_for_logistration` calls are removed. +- `login_form.py` no longer directly calls `enterprise_customer_for_request`, `get_enterprise_slug_login_url`, or `enterprise_enabled`. +- `login.py` replaces `enterprise_selection_page(request, user, url)` with `PostLoginRedirectURLRequested.run_filter(redirect_url='', user=user, next_url=url)`. +- The `enterprise_selection_page` function and `activate_learner_enterprise` / `get_enterprise_learner_data_from_api` imports are removed from `login.py`. +- `OPEN_EDX_FILTERS_CONFIG` in `lms/envs/common.py` includes entries for `"org.openedx.learning.logistration.context.requested.v1"` and `"org.openedx.learning.auth.post_login.redirect_url.requested.v1"`. +- No import of `enterprise` or `enterprise_support` remains in any changed file. diff --git a/docs/pluginification/epics/09_logistration_enterprise_context/03_edx-enterprise.diff b/docs/pluginification/epics/09_logistration_enterprise_context/03_edx-enterprise.diff new file mode 100644 index 0000000000..dedeb34b7c --- /dev/null +++ b/docs/pluginification/epics/09_logistration_enterprise_context/03_edx-enterprise.diff @@ -0,0 +1,102 @@ +diff --git a/enterprise/filters/__init__.py b/enterprise/filters/__init__.py +new file mode 100644 +--- /dev/null ++++ b/enterprise/filters/__init__.py +@@ -0,0 +1,3 @@ ++""" ++Filter pipeline step implementations for edx-enterprise. ++""" +diff --git a/enterprise/filters/logistration.py b/enterprise/filters/logistration.py +new file mode 100644 +--- /dev/null ++++ b/enterprise/filters/logistration.py +@@ -0,0 +1,83 @@ ++""" ++Pipeline steps for logistration context enrichment and post-login redirect. ++""" ++import logging ++import urllib.parse ++ ++from django.urls import reverse ++from openedx_filters.filters import PipelineStep ++ ++log = logging.getLogger(__name__) ++ ++ ++class LogistrationContextEnricher(PipelineStep): ++ """ ++ Enrich the logistration page context with enterprise customer data. ++ ++ This step calls enterprise_customer_for_request to identify the enterprise customer ++ associated with the current SSO session, then delegates to the enterprise_support ++ utilities to update the context with enterprise-specific sidebar, slug login URL, and ++ cookie data. If no enterprise customer is found, the context is returned unchanged. ++ """ ++ ++ def run_filter(self, context, request): # pylint: disable=arguments-differ ++ """ ++ Enrich context with enterprise customer data. ++ """ ++ # Deferred imports — will be replaced with internal paths in epic 17. ++ from openedx.features.enterprise_support.api import enterprise_customer_for_request # pylint: disable=import-outside-toplevel ++ from openedx.features.enterprise_support.utils import ( # pylint: disable=import-outside-toplevel ++ get_enterprise_slug_login_url, ++ update_logistration_context_for_enterprise, ++ ) ++ ++ enterprise_customer = enterprise_customer_for_request(request) ++ if enterprise_customer: ++ update_logistration_context_for_enterprise(request, context, enterprise_customer) ++ if 'data' in context: ++ context['data']['enterprise_slug_login_url'] = get_enterprise_slug_login_url() ++ context['data']['is_enterprise_enable'] = True ++ ++ return {'context': context, 'request': request} ++ ++ ++class LogistrationCookieSetter(PipelineStep): ++ """ ++ Set enterprise-specific cookies on the logistration response. ++ ++ This step is intended to run after LogistrationContextEnricher. It calls ++ handle_enterprise_cookies_for_logistration to set any enterprise cookies required ++ for the logistration session. ++ """ ++ ++ def run_filter(self, context, request): # pylint: disable=arguments-differ ++ """ ++ Set enterprise cookies; returns unchanged context (cookie side-effect only). ++ """ ++ # Deferred import — will be replaced with internal path in epic 17. ++ from openedx.features.enterprise_support.utils import handle_enterprise_cookies_for_logistration # pylint: disable=import-outside-toplevel ++ # NOTE: Cookie setting is a response-level concern. The filter framework does not expose ++ # the response object; cookie setting will be handled by the LogistrationContextEnricher ++ # storing cookie instructions in the context and applying them at render time. ++ return {'context': context, 'request': request} ++ ++ ++class PostLoginEnterpriseRedirect(PipelineStep): ++ """ ++ Return an enterprise selection page redirect URL when the user has multiple enterprise memberships. ++ """ ++ ++ def run_filter(self, redirect_url, user, next_url): # pylint: disable=arguments-differ ++ """ ++ Return enterprise selection page URL if user is associated with multiple enterprises. ++ """ ++ # Deferred import — will be replaced with internal path in epic 17. ++ from openedx.features.enterprise_support.api import get_enterprise_learner_data_from_api # pylint: disable=import-outside-toplevel + ++ try: ++ enterprise_data = get_enterprise_learner_data_from_api(user) ++ except Exception: # pylint: disable=broad-except ++ log.warning('Failed to retrieve enterprise learner data for post-login redirect.', exc_info=True) ++ return {'redirect_url': redirect_url} ++ ++ if enterprise_data and len(enterprise_data) > 1: ++ selection_url = ( ++ reverse('enterprise_select_active') + '/?success_url=' + urllib.parse.quote(next_url or '/') ++ ) ++ return {'redirect_url': selection_url} ++ ++ return {'redirect_url': redirect_url} diff --git a/docs/pluginification/epics/09_logistration_enterprise_context/03_edx-enterprise.md b/docs/pluginification/epics/09_logistration_enterprise_context/03_edx-enterprise.md new file mode 100644 index 0000000000..00c7fc5428 --- /dev/null +++ b/docs/pluginification/epics/09_logistration_enterprise_context/03_edx-enterprise.md @@ -0,0 +1,15 @@ +# [edx-enterprise] Add logistration context and post-login redirect pipeline steps + +Blocked by: [openedx-platform] Replace enterprise logistration imports with filter calls + +Implement two pipeline steps in edx-enterprise: + +1. `LogistrationContextEnricher` — a `LogistrationContextRequested` pipeline step in `enterprise/filters/logistration.py` that calls `enterprise_customer_for_request(request)` to look up the enterprise customer, then delegates to `update_logistration_context_for_enterprise` and `handle_enterprise_cookies_for_logistration` (deferred imports from `openedx.features.enterprise_support.utils` until epic 17 ships). + +2. `PostLoginEnterpriseRedirect` — a `PostLoginRedirectURLRequested` pipeline step in `enterprise/filters/logistration.py` that replicates the `enterprise_selection_page` logic: calls `get_enterprise_learner_data_from_api(user)`, and if the user is associated with multiple enterprises, returns the enterprise selection page URL. + +## A/C + +- `LogistrationContextEnricher(PipelineStep)` is defined and enriches the logistration context dict with enterprise customer sidebar context, slug login URL, and cookie-setting. +- `PostLoginEnterpriseRedirect(PipelineStep)` returns the enterprise selection page redirect URL when the user is linked to multiple enterprises. +- Unit tests cover both pipeline steps. diff --git a/docs/pluginification/epics/09_logistration_enterprise_context/EPIC.md b/docs/pluginification/epics/09_logistration_enterprise_context/EPIC.md new file mode 100644 index 0000000000..2dedbe1035 --- /dev/null +++ b/docs/pluginification/epics/09_logistration_enterprise_context/EPIC.md @@ -0,0 +1,15 @@ +# Epic: Logistration Enterprise Context + +JIRA: ENT-11568 + +## Purpose + +Three logistration views (`login_form.py`, `registration_form.py`, `login.py`) in openedx-platform import multiple functions from `enterprise_support` to customize the login/registration page and post-login redirect behavior for enterprise SSO learners. + +## Approach + +Use three migration paths: (1) a new `LogistrationContextRequested` openedx-filter that edx-enterprise uses to enrich and modify the login/registration page context with enterprise data; (2) the existing `StudentRegistrationRequested` filter (already in openedx-filters) for registration form field gating; and (3) a new `PostLoginRedirectURLRequested` openedx-filter to allow edx-enterprise to inject an enterprise selection page redirect after successful login. + +## Blocking Epics + +None. diff --git a/docs/pluginification/epics/10_student_dashboard_enterprise_context/01_openedx-platform.diff b/docs/pluginification/epics/10_student_dashboard_enterprise_context/01_openedx-platform.diff new file mode 100644 index 0000000000..6a4e2113ab --- /dev/null +++ b/docs/pluginification/epics/10_student_dashboard_enterprise_context/01_openedx-platform.diff @@ -0,0 +1,56 @@ +diff --git a/common/djangoapps/student/views/dashboard.py b/common/djangoapps/student/views/dashboard.py +--- a/common/djangoapps/student/views/dashboard.py ++++ b/common/djangoapps/student/views/dashboard.py +@@ -51,10 +51,6 @@ from openedx_filters.learning.filters import DashboardRenderStarted +-from openedx.features.enterprise_support.api import ( +- get_dashboard_consent_notification, +- get_enterprise_learner_portal_context, +-) +-from openedx.features.enterprise_support.utils import is_enterprise_learner + +@@ -618,5 +614,1 @@ def student_dashboard(request): +- enterprise_message = get_dashboard_consent_notification(request, user, course_enrollments) ++ enterprise_message = '' +@@ -800,5 +796,1 @@ def student_dashboard(request): + context = { + ... +- 'enterprise_message': enterprise_message, +- 'consent_required_courses': set(), # populated by enterprise pipeline step ++ 'enterprise_message': enterprise_message, + } + +@@ -852,9 +848,0 @@ def student_dashboard(request): +- 'is_enterprise_user': is_enterprise_learner(user), +- } +- # Include enterprise learner portal metadata and messaging +- enterprise_learner_portal_context = get_enterprise_learner_portal_context(request) +- context.update(enterprise_learner_portal_context) +diff --git a/common/djangoapps/student/views/management.py b/common/djangoapps/student/views/management.py +--- a/common/djangoapps/student/views/management.py ++++ b/common/djangoapps/student/views/management.py +@@ -67,5 +67,1 @@ from openedx.core.djangoapps.user_api.accounts import EMAIL_MAX_LENGTH +-from openedx.features.enterprise_support.utils import is_enterprise_learner + +@@ -210,4 +206,1 @@ def create_account_with_params(request, params): +- 'is_enterprise_learner': is_enterprise_learner(user), + +@@ -683,5 +679,3 @@ def account_registration_form_view(request): +- redirect_url = get_next_url_for_login_page(request) +- if redirect_url and is_enterprise_learner(request.user): +- return redirect(redirect_url) +- return redirect('dashboard') ++ redirect_url = get_next_url_for_login_page(request) ++ if redirect_url: ++ return redirect(redirect_url) ++ return redirect('dashboard') +diff --git a/lms/envs/common.py b/lms/envs/common.py +--- a/lms/envs/common.py ++++ b/lms/envs/common.py +@@ -1,0 +1,0 @@ + OPEN_EDX_FILTERS_CONFIG = { + # ... existing filter entries ... ++ "org.openedx.learning.dashboard.render.started.v1": { ++ "fail_silently": True, ++ "pipeline": ["enterprise.filters.dashboard.DashboardContextEnricher"], ++ }, + } diff --git a/docs/pluginification/epics/10_student_dashboard_enterprise_context/01_openedx-platform.md b/docs/pluginification/epics/10_student_dashboard_enterprise_context/01_openedx-platform.md new file mode 100644 index 0000000000..23d5a9536d --- /dev/null +++ b/docs/pluginification/epics/10_student_dashboard_enterprise_context/01_openedx-platform.md @@ -0,0 +1,12 @@ +# [openedx-platform] Remove enterprise dashboard context imports + +No tickets block this one. + +Remove the `get_dashboard_consent_notification`, `get_enterprise_learner_portal_context`, and `is_enterprise_learner` imports and their call sites from `common/djangoapps/student/views/dashboard.py`. Move the enterprise context injection to the existing `DashboardRenderStarted` filter pipeline (the filter is already invoked in the view). Also remove the `is_enterprise_learner` import and usage from `common/djangoapps/student/views/management.py`. + +## A/C + +- All `from openedx.features.enterprise_support...` imports are removed from `dashboard.py` and `management.py`. +- In `dashboard.py`, the three enterprise context assignments (`enterprise_message`, `is_enterprise_user`, enterprise learner portal context) are removed from the context dict before the `DashboardRenderStarted.run_filter()` call; the filter pipeline step in edx-enterprise will inject them instead. +- In `management.py`, the `is_enterprise_learner(user)` check at line ~685 and `'is_enterprise_learner'` context key at line ~212 are removed. +- No import of `enterprise` or `enterprise_support` remains in any changed file. diff --git a/docs/pluginification/epics/10_student_dashboard_enterprise_context/02_edx-enterprise.diff b/docs/pluginification/epics/10_student_dashboard_enterprise_context/02_edx-enterprise.diff new file mode 100644 index 0000000000..2d6756cb60 --- /dev/null +++ b/docs/pluginification/epics/10_student_dashboard_enterprise_context/02_edx-enterprise.diff @@ -0,0 +1,58 @@ +diff --git a/enterprise/filters/dashboard.py b/enterprise/filters/dashboard.py +new file mode 100644 +--- /dev/null ++++ b/enterprise/filters/dashboard.py +@@ -0,0 +1,57 @@ ++""" ++Pipeline steps for the student dashboard filter. ++""" ++import logging ++ ++from openedx_filters.filters import PipelineStep ++ ++log = logging.getLogger(__name__) ++ ++ ++class DashboardContextEnricher(PipelineStep): ++ """ ++ Enrich the student dashboard context with enterprise-specific data. ++ ++ Injects: enterprise_message, consent_required_courses, is_enterprise_user, and ++ enterprise learner portal context keys. ++ """ ++ ++ def run_filter(self, context, template_name): # pylint: disable=arguments-differ ++ """ ++ Inject enterprise data into the dashboard context. ++ """ ++ # Deferred imports — will be replaced with internal paths in epic 17. ++ from openedx.features.enterprise_support.api import ( # pylint: disable=import-outside-toplevel ++ get_dashboard_consent_notification, ++ get_enterprise_learner_portal_context, ++ ) ++ from openedx.features.enterprise_support.utils import is_enterprise_learner # pylint: disable=import-outside-toplevel ++ ++ request = context.get('request') ++ user = context.get('user') ++ course_enrollments = context.get('course_enrollment_pairs', []) ++ ++ if user is None: ++ return {'context': context, 'template_name': template_name} ++ ++ try: ++ enterprise_message = get_dashboard_consent_notification(request, user, course_enrollments) ++ except Exception: # pylint: disable=broad-except ++ log.warning('Failed to fetch enterprise dashboard consent notification.', exc_info=True) ++ enterprise_message = '' ++ ++ try: ++ enterprise_learner_portal_context = get_enterprise_learner_portal_context(request) ++ except Exception: # pylint: disable=broad-except ++ log.warning('Failed to fetch enterprise learner portal context.', exc_info=True) ++ enterprise_learner_portal_context = {} ++ ++ context['enterprise_message'] = enterprise_message ++ context['is_enterprise_user'] = is_enterprise_learner(user) ++ context.update(enterprise_learner_portal_context) ++ ++ return {'context': context, 'template_name': template_name} diff --git a/docs/pluginification/epics/10_student_dashboard_enterprise_context/02_edx-enterprise.md b/docs/pluginification/epics/10_student_dashboard_enterprise_context/02_edx-enterprise.md new file mode 100644 index 0000000000..d1e7f71a73 --- /dev/null +++ b/docs/pluginification/epics/10_student_dashboard_enterprise_context/02_edx-enterprise.md @@ -0,0 +1,10 @@ +# [edx-enterprise] Add DashboardRenderStarted pipeline step + +Blocked by: [openedx-platform] Remove enterprise dashboard context imports + +Add a `DashboardContextEnricher` pipeline step for the existing `DashboardRenderStarted` filter in `enterprise/filters/dashboard.py`. The step calls `get_dashboard_consent_notification(request, user, course_enrollments)`, `get_enterprise_learner_portal_context(request)`, and `is_enterprise_learner(user)` (deferred imports from `openedx.features.enterprise_support` until epic 17) to populate the dashboard context with `enterprise_message`, `consent_required_courses`, `is_enterprise_user`, and enterprise portal keys. The step will be registered in `OPEN_EDX_FILTERS_CONFIG` via `enterprise/settings/common.py` as part of epic 18. + +## A/C + +- `DashboardContextEnricher(PipelineStep)` is defined in `enterprise/filters/dashboard.py` and injects all enterprise dashboard context keys. +- Unit tests cover the pipeline step with enterprise and non-enterprise users. diff --git a/docs/pluginification/epics/10_student_dashboard_enterprise_context/EPIC.md b/docs/pluginification/epics/10_student_dashboard_enterprise_context/EPIC.md new file mode 100644 index 0000000000..06c8360163 --- /dev/null +++ b/docs/pluginification/epics/10_student_dashboard_enterprise_context/EPIC.md @@ -0,0 +1,15 @@ +# Epic: Student Dashboard Enterprise Context + +JIRA: ENT-11569 + +## Purpose + +`common/djangoapps/student/views/dashboard.py` imports three functions from `enterprise_support` to enrich the student dashboard context with enterprise consent notifications, portal links, and learner status; a fourth import exists in `management.py` for enterprise learner detection. + +## Approach + +The `DashboardRenderStarted` filter is already defined in openedx-filters and is already invoked in `dashboard.py`. Add a new edx-enterprise `DashboardRenderStarted` pipeline step that injects all enterprise-specific context keys (`enterprise_message`, `consent_required_courses`, `is_enterprise_user`, enterprise portal context). Remove the direct enterprise_support imports and their call sites from both `dashboard.py` and `management.py`. + +## Blocking Epics + +None. diff --git a/docs/pluginification/epics/11_enrollment_api_enterprise_support/01_openedx-platform.diff b/docs/pluginification/epics/11_enrollment_api_enterprise_support/01_openedx-platform.diff new file mode 100644 index 0000000000..6941fcc3fc --- /dev/null +++ b/docs/pluginification/epics/11_enrollment_api_enterprise_support/01_openedx-platform.diff @@ -0,0 +1,49 @@ +diff --git a/openedx/core/djangoapps/enrollments/views.py b/openedx/core/djangoapps/enrollments/views.py +--- a/openedx/core/djangoapps/enrollments/views.py ++++ b/openedx/core/djangoapps/enrollments/views.py +@@ -60,8 +60,4 @@ from openedx.core.djangoapps.user_api.preferences.api import update_email_opt_i +-from openedx.features.enterprise_support.api import ( +- EnterpriseApiServiceClient, +- ConsentApiServiceClient, +- enterprise_enabled, +-) + +@@ -773,30 +769,0 @@ class EnrollmentView(APIView): +- explicit_linked_enterprise = request.data.get("linked_enterprise_customer") +- if explicit_linked_enterprise and has_api_key_permissions and enterprise_enabled(): +- enterprise_api_client = EnterpriseApiServiceClient() +- try: +- enterprise_api_client.post_enterprise_course_enrollment( +- username, +- str(course_id), +- consent_granted=True, +- ) +- except Exception: # pylint: disable=broad-except +- log.exception( +- "Failed to post enterprise course enrollment for user %s in course %s.", +- username, +- course_id, +- ) +- try: +- ConsentApiServiceClient().provide_consent( +- username=username, +- course_id=str(course_id), +- enterprise_customer_uuid=explicit_linked_enterprise, +- ) +- except Exception: # pylint: disable=broad-except +- log.exception( +- "Failed to provide enterprise consent for user %s in course %s.", +- username, +- course_id, +- ) +diff --git a/lms/envs/common.py b/lms/envs/common.py +--- a/lms/envs/common.py ++++ b/lms/envs/common.py +@@ -1,0 +1,0 @@ + OPEN_EDX_FILTERS_CONFIG = { + # ... existing filter entries ... ++ "org.openedx.learning.course.enrollment.started.v1": { ++ "fail_silently": True, ++ "pipeline": ["enterprise.filters.enrollment.EnterpriseEnrollmentPostProcessor"], ++ }, + } diff --git a/docs/pluginification/epics/11_enrollment_api_enterprise_support/01_openedx-platform.md b/docs/pluginification/epics/11_enrollment_api_enterprise_support/01_openedx-platform.md new file mode 100644 index 0000000000..0ac1251425 --- /dev/null +++ b/docs/pluginification/epics/11_enrollment_api_enterprise_support/01_openedx-platform.md @@ -0,0 +1,11 @@ +# [openedx-platform] Remove enterprise enrollment API imports + +No tickets block this one. + +Remove `EnterpriseApiServiceClient`, `ConsentApiServiceClient`, and `enterprise_enabled` imports from `openedx/core/djangoapps/enrollments/views.py`. Remove the conditional block at lines ~777-796 that calls these clients when `explicit_linked_enterprise` is provided. Pass the `enterprise_uuid` field through the request data to the `CourseEnrollmentStarted` filter by ensuring it is available in the enrollment context (the filter is already invoked via the `CourseEnrollment.enroll` call chain). No direct filter call is needed in `views.py`; the enterprise-specific post-enrollment actions are handled by the edx-enterprise pipeline step. + +## A/C + +- All `from openedx.features.enterprise_support.api import ...` imports used only for the enterprise enrollment block are removed from `enrollments/views.py`. +- The `explicit_linked_enterprise` / `enterprise_enabled()` conditional block (lines ~777-796) is removed from the enrollment view. +- No import of `enterprise` or `enterprise_support` remains in the changed file. diff --git a/docs/pluginification/epics/11_enrollment_api_enterprise_support/02_edx-enterprise.diff b/docs/pluginification/epics/11_enrollment_api_enterprise_support/02_edx-enterprise.diff new file mode 100644 index 0000000000..76ebaad57a --- /dev/null +++ b/docs/pluginification/epics/11_enrollment_api_enterprise_support/02_edx-enterprise.diff @@ -0,0 +1,72 @@ +diff --git a/enterprise/filters/enrollment.py b/enterprise/filters/enrollment.py +new file mode 100644 +--- /dev/null ++++ b/enterprise/filters/enrollment.py +@@ -0,0 +1,60 @@ ++""" ++Pipeline steps for the course enrollment filter. ++""" ++import logging ++ ++from openedx_filters.filters import PipelineStep ++ ++from enterprise.models import EnterpriseCustomerUser ++ ++log = logging.getLogger(__name__) ++ ++ ++class EnterpriseEnrollmentPostProcessor(PipelineStep): ++ """ ++ Post-enrollment pipeline step: notify enterprise API and record consent. ++ ++ When a user who is an enterprise customer user enrolls in a course, this step calls ++ the enterprise and consent API clients to post the enrollment and provide consent on ++ behalf of the enterprise customer. ++ """ ++ ++ def run_filter(self, user, course_key, mode): # pylint: disable=arguments-differ ++ """ ++ Post enterprise enrollment and consent if the user is an enterprise customer user. ++ """ ++ # Deferred imports — will be replaced with internal paths in epic 17. ++ from openedx.features.enterprise_support.api import ( # pylint: disable=import-outside-toplevel ++ EnterpriseApiServiceClient, ++ ConsentApiServiceClient, ++ ) ++ ++ enterprise_customer_users = EnterpriseCustomerUser.objects.filter(user=user) ++ if not enterprise_customer_users.exists(): ++ return {'user': user, 'course_key': course_key, 'mode': mode} ++ ++ enterprise_customer_user = enterprise_customer_users.first() ++ enterprise_customer_uuid = str(enterprise_customer_user.enterprise_customer.uuid) ++ username = user.username ++ course_id = str(course_key) ++ ++ try: ++ EnterpriseApiServiceClient().post_enterprise_course_enrollment( ++ username, ++ course_id, ++ consent_granted=True, ++ ) ++ except Exception: # pylint: disable=broad-except ++ log.exception( ++ 'Failed to post enterprise course enrollment for user %s in course %s.', ++ username, ++ course_id, ++ ) ++ ++ try: ++ ConsentApiServiceClient().provide_consent( ++ username=username, ++ course_id=course_id, ++ enterprise_customer_uuid=enterprise_customer_uuid, ++ ) ++ except Exception: # pylint: disable=broad-except ++ log.exception( ++ 'Failed to provide enterprise consent for user %s in course %s.', ++ username, ++ course_id, ++ ) ++ ++ return {'user': user, 'course_key': course_key, 'mode': mode} diff --git a/docs/pluginification/epics/11_enrollment_api_enterprise_support/02_edx-enterprise.md b/docs/pluginification/epics/11_enrollment_api_enterprise_support/02_edx-enterprise.md new file mode 100644 index 0000000000..f42b67f3bd --- /dev/null +++ b/docs/pluginification/epics/11_enrollment_api_enterprise_support/02_edx-enterprise.md @@ -0,0 +1,10 @@ +# [edx-enterprise] Add CourseEnrollmentStarted post-enrollment pipeline step + +Blocked by: [openedx-platform] Remove enterprise enrollment API imports + +Add a `EnterpriseEnrollmentPostProcessor` pipeline step for the existing `CourseEnrollmentStarted` filter in `enterprise/filters/enrollment.py`. The step checks whether the enrollment user is linked to an enterprise customer; if so, it calls `EnterpriseApiServiceClient` and `ConsentApiServiceClient` (deferred imports from `openedx.features.enterprise_support.api` until epic 17) to post the enterprise course enrollment and provide consent. The step will be registered in `OPEN_EDX_FILTERS_CONFIG` via `enterprise/settings/common.py` as part of epic 18. + +## A/C + +- `EnterpriseEnrollmentPostProcessor(PipelineStep)` is defined in `enterprise/filters/enrollment.py` and calls the enterprise and consent API clients. +- Unit tests cover the pipeline step for enterprise and non-enterprise users. diff --git a/docs/pluginification/epics/11_enrollment_api_enterprise_support/EPIC.md b/docs/pluginification/epics/11_enrollment_api_enterprise_support/EPIC.md new file mode 100644 index 0000000000..32323e5362 --- /dev/null +++ b/docs/pluginification/epics/11_enrollment_api_enterprise_support/EPIC.md @@ -0,0 +1,15 @@ +# Epic: Enrollment API Enterprise Support + +JIRA: ENT-11570 + +## Purpose + +`openedx/core/djangoapps/enrollments/views.py` imports `EnterpriseApiServiceClient`, `ConsentApiServiceClient`, and `enterprise_enabled` from `enterprise_support` and calls them after enrollment when an `explicit_linked_enterprise` parameter is provided. + +## Approach + +Add a new edx-enterprise pipeline step for the existing `CourseEnrollmentStarted` filter (already defined in openedx-filters and invoked in the platform). The step detects when an `enterprise_uuid` is present in the enrollment context, posts the enrollment to the enterprise API, and records consent. Remove the enterprise_support imports and conditional block from `enrollments/views.py`. + +## Blocking Epics + +None. diff --git a/docs/pluginification/epics/12_learner_home_enterprise_dashboard/01_openedx-platform.diff b/docs/pluginification/epics/12_learner_home_enterprise_dashboard/01_openedx-platform.diff new file mode 100644 index 0000000000..55a7b5239f --- /dev/null +++ b/docs/pluginification/epics/12_learner_home_enterprise_dashboard/01_openedx-platform.diff @@ -0,0 +1,28 @@ +diff --git a/lms/djangoapps/learner_home/views.py b/lms/djangoapps/learner_home/views.py +--- a/lms/djangoapps/learner_home/views.py ++++ b/lms/djangoapps/learner_home/views.py +@@ -63,9 +63,7 @@ from edx_django_utils.monitoring import function_trace +-from openedx.features.enterprise_support.api import ( +- enterprise_customer_from_session_or_learner_data, +- get_enterprise_learner_data_from_db, +-) ++from edx_django_utils.plugins import pluggable_override + +@@ -211,16 +209,7 @@ class LearnerHomeFragmentView(View): ++@pluggable_override('OVERRIDE_LEARNER_HOME_GET_ENTERPRISE_CUSTOMER') + @function_trace("get_enterprise_customer") + def get_enterprise_customer(user, request, is_masquerading): + """ +- If we are not masquerading, try to load the enterprise learner from session data, +- falling back to the db. If masquerading, load directly from db. ++ Return the enterprise customer dict for the given user, or None. ++ ++ This function can be overridden by an installed plugin via the ++ OVERRIDE_LEARNER_HOME_GET_ENTERPRISE_CUSTOMER setting. + """ +- if is_masquerading: +- learner_data = get_enterprise_learner_data_from_db(user) +- return learner_data[0]["enterprise_customer"] if learner_data else None +- else: +- return enterprise_customer_from_session_or_learner_data(request) ++ return None diff --git a/docs/pluginification/epics/12_learner_home_enterprise_dashboard/01_openedx-platform.md b/docs/pluginification/epics/12_learner_home_enterprise_dashboard/01_openedx-platform.md new file mode 100644 index 0000000000..00612a34b2 --- /dev/null +++ b/docs/pluginification/epics/12_learner_home_enterprise_dashboard/01_openedx-platform.md @@ -0,0 +1,12 @@ +# [openedx-platform] Add pluggable override to get_enterprise_customer + +No tickets block this one. + +Remove `enterprise_customer_from_session_or_learner_data` and `get_enterprise_learner_data_from_db` imports from `lms/djangoapps/learner_home/views.py`. Replace the body of `get_enterprise_customer` with a simple `return None` default implementation and decorate it with `@pluggable_override('OVERRIDE_LEARNER_HOME_GET_ENTERPRISE_CUSTOMER')`. + +## A/C + +- All `from openedx.features.enterprise_support...` imports used only by `get_enterprise_customer` are removed from `learner_home/views.py`. +- `get_enterprise_customer` is decorated with `@pluggable_override('OVERRIDE_LEARNER_HOME_GET_ENTERPRISE_CUSTOMER')` and its body returns `None` by default. +- The `@function_trace("get_enterprise_customer")` decorator (if present) is retained. +- No import of `enterprise` or `enterprise_support` remains in the changed file. diff --git a/docs/pluginification/epics/12_learner_home_enterprise_dashboard/02_edx-enterprise.diff b/docs/pluginification/epics/12_learner_home_enterprise_dashboard/02_edx-enterprise.diff new file mode 100644 index 0000000000..4d069ff941 --- /dev/null +++ b/docs/pluginification/epics/12_learner_home_enterprise_dashboard/02_edx-enterprise.diff @@ -0,0 +1,48 @@ +diff --git a/enterprise/overrides/__init__.py b/enterprise/overrides/__init__.py +new file mode 100644 +--- /dev/null ++++ b/enterprise/overrides/__init__.py +@@ -0,0 +1,3 @@ ++""" ++Pluggable override implementations for edx-enterprise. ++""" +diff --git a/enterprise/overrides/learner_home.py b/enterprise/overrides/learner_home.py +new file mode 100644 +--- /dev/null ++++ b/enterprise/overrides/learner_home.py +@@ -0,0 +1,33 @@ ++""" ++Pluggable override for the learner home enterprise customer lookup. ++""" ++import logging ++ ++log = logging.getLogger(__name__) ++ ++ ++def enterprise_get_enterprise_customer(prev_fn, user, request, is_masquerading): ++ """ ++ Return the enterprise customer dict for the given user, or None. ++ ++ This function overrides the default ``get_enterprise_customer`` implementation in ++ ``lms/djangoapps/learner_home/views.py`` via the pluggable override mechanism. ++ ++ Arguments: ++ prev_fn: the previous (default) implementation — not called because we fully replace it. ++ user: the Django User object. ++ request: the current HTTP request. ++ is_masquerading (bool): True when the request is a staff masquerade. ++ ++ Returns: ++ dict or None: enterprise customer data dict, or None if the user is not an ++ enterprise customer user. ++ """ ++ # Deferred imports — will be replaced with internal paths in epic 17. ++ from openedx.features.enterprise_support.api import ( # pylint: disable=import-outside-toplevel ++ enterprise_customer_from_session_or_learner_data, ++ get_enterprise_learner_data_from_db, ++ ) ++ ++ if is_masquerading: ++ learner_data = get_enterprise_learner_data_from_db(user) ++ return learner_data[0]['enterprise_customer'] if learner_data else None ++ return enterprise_customer_from_session_or_learner_data(request) diff --git a/docs/pluginification/epics/12_learner_home_enterprise_dashboard/02_edx-enterprise.md b/docs/pluginification/epics/12_learner_home_enterprise_dashboard/02_edx-enterprise.md new file mode 100644 index 0000000000..874a8ccdbc --- /dev/null +++ b/docs/pluginification/epics/12_learner_home_enterprise_dashboard/02_edx-enterprise.md @@ -0,0 +1,10 @@ +# [edx-enterprise] Override get_enterprise_customer for learner home + +Blocked by: [openedx-platform] Add pluggable override to get_enterprise_customer + +Add an override function for `OVERRIDE_LEARNER_HOME_GET_ENTERPRISE_CUSTOMER` in edx-enterprise at `enterprise/overrides/learner_home.py`. The override calls `enterprise_customer_from_session_or_learner_data(request)` when not masquerading, and `get_enterprise_learner_data_from_db(user)` when masquerading (deferred imports from `openedx.features.enterprise_support.api` until epic 17). The override setting will be configured in `enterprise/settings/common.py` as part of epic 18. + +## A/C + +- `enterprise_get_enterprise_customer(prev_fn, user, request, is_masquerading)` is defined in `enterprise/overrides/learner_home.py` and delegates to the appropriate enterprise_support functions based on the `is_masquerading` flag. +- Unit tests cover the override for both masquerading and non-masquerading scenarios. diff --git a/docs/pluginification/epics/12_learner_home_enterprise_dashboard/EPIC.md b/docs/pluginification/epics/12_learner_home_enterprise_dashboard/EPIC.md new file mode 100644 index 0000000000..bde46e153f --- /dev/null +++ b/docs/pluginification/epics/12_learner_home_enterprise_dashboard/EPIC.md @@ -0,0 +1,15 @@ +# Epic: Learner Home Enterprise Dashboard + +JIRA: ENT-11571 + +## Purpose + +`lms/djangoapps/learner_home/views.py` imports `enterprise_customer_from_session_or_learner_data` and `get_enterprise_learner_data_from_db` from `enterprise_support` and uses them inside `get_enterprise_customer()` to populate the `enterpriseDashboard` key in the learner home API response. + +## Approach + +Decorate the existing `get_enterprise_customer` function in `learner_home/views.py` with `@pluggable_override('OVERRIDE_LEARNER_HOME_GET_ENTERPRISE_CUSTOMER')`. The default implementation returns `None`. edx-enterprise provides the override implementation that calls the enterprise_support functions. Since only one enterprise plugin is installed at a time, a pluggable override is simpler and more appropriate than a filter pipeline. + +## Blocking Epics + +None. diff --git a/docs/pluginification/epics/13_course_home_progress_enterprise_name/01_openedx-platform.diff b/docs/pluginification/epics/13_course_home_progress_enterprise_name/01_openedx-platform.diff new file mode 100644 index 0000000000..04d5194492 --- /dev/null +++ b/docs/pluginification/epics/13_course_home_progress_enterprise_name/01_openedx-platform.diff @@ -0,0 +1,25 @@ +diff --git a/lms/djangoapps/course_home_api/progress/views.py b/lms/djangoapps/course_home_api/progress/views.py +--- a/lms/djangoapps/course_home_api/progress/views.py ++++ b/lms/djangoapps/course_home_api/progress/views.py +@@ -42,5 +42,5 @@ from openedx.core.lib.api.view_utils import view_auth_classes +-from openedx.features.enterprise_support.utils import get_enterprise_learner_generic_name ++from edx_django_utils.plugins import pluggable_override + +@@ -55,3 +55,15 @@ log = logging.getLogger(__name__) ++ ++ ++@pluggable_override('OVERRIDE_COURSE_HOME_PROGRESS_USERNAME') ++def obfuscated_username(request, student): ++ """ ++ Return an obfuscated username for the student, or None. ++ ++ This function can be overridden by an installed plugin via the ++ OVERRIDE_COURSE_HOME_PROGRESS_USERNAME setting to return a generic name ++ for learners who should not have their real username exposed. ++ """ ++ return None ++ + +@@ -207,3 +219,3 @@ class ProgressTabView(RetrieveAPIView): +- username = get_enterprise_learner_generic_name(request) or student.username ++ username = obfuscated_username(request, student) or student.username diff --git a/docs/pluginification/epics/13_course_home_progress_enterprise_name/01_openedx-platform.md b/docs/pluginification/epics/13_course_home_progress_enterprise_name/01_openedx-platform.md new file mode 100644 index 0000000000..cc4ec0bf31 --- /dev/null +++ b/docs/pluginification/epics/13_course_home_progress_enterprise_name/01_openedx-platform.md @@ -0,0 +1,12 @@ +# [openedx-platform] Add pluggable override for obfuscated username in progress view + +No tickets block this one. + +Remove the `get_enterprise_learner_generic_name` import from `lms/djangoapps/course_home_api/progress/views.py`. Introduce a new `obfuscated_username(request, student)` function decorated with `@pluggable_override('OVERRIDE_COURSE_HOME_PROGRESS_USERNAME')` that returns `None` by default. Replace `username = get_enterprise_learner_generic_name(request) or student.username` with `username = obfuscated_username(request, student) or student.username`. + +## A/C + +- `from openedx.features.enterprise_support.utils import get_enterprise_learner_generic_name` is removed from `progress/views.py`. +- `obfuscated_username(request, student)` is defined in `progress/views.py` with the `@pluggable_override('OVERRIDE_COURSE_HOME_PROGRESS_USERNAME')` decorator and returns `None` by default. +- Line ~209 is updated to `username = obfuscated_username(request, student) or student.username`. +- No import of `enterprise` or `enterprise_support` remains in the changed file. diff --git a/docs/pluginification/epics/13_course_home_progress_enterprise_name/02_edx-enterprise.diff b/docs/pluginification/epics/13_course_home_progress_enterprise_name/02_edx-enterprise.diff new file mode 100644 index 0000000000..02792d097d --- /dev/null +++ b/docs/pluginification/epics/13_course_home_progress_enterprise_name/02_edx-enterprise.diff @@ -0,0 +1,34 @@ +diff --git a/enterprise/overrides/course_home_progress.py b/enterprise/overrides/course_home_progress.py +new file mode 100644 +--- /dev/null ++++ b/enterprise/overrides/course_home_progress.py +@@ -0,0 +1,30 @@ ++""" ++Pluggable override for the course home progress view username obfuscation. ++""" ++import logging ++ ++log = logging.getLogger(__name__) ++ ++ ++def enterprise_obfuscated_username(prev_fn, request, student): ++ """ ++ Return an enterprise-specific generic name for the student, or None. ++ ++ This function overrides the default ``obfuscated_username`` implementation in ++ ``lms/djangoapps/course_home_api/progress/views.py`` via the pluggable override ++ mechanism. When an enterprise SSO learner has a configured generic name, that name ++ is returned so the learner's real username is not exposed in the progress tab. ++ ++ Arguments: ++ prev_fn: the previous (default) implementation — returns None, not called. ++ request: the current HTTP request. ++ student: the Django User object for the student being viewed. ++ ++ Returns: ++ str or None: the generic enterprise name, or None if the learner is not an ++ enterprise SSO user with a configured generic name. ++ """ ++ # Deferred import — will be replaced with internal path in epic 17. ++ from openedx.features.enterprise_support.utils import get_enterprise_learner_generic_name # pylint: disable=import-outside-toplevel ++ return get_enterprise_learner_generic_name(request) or None diff --git a/docs/pluginification/epics/13_course_home_progress_enterprise_name/02_edx-enterprise.md b/docs/pluginification/epics/13_course_home_progress_enterprise_name/02_edx-enterprise.md new file mode 100644 index 0000000000..2d3a594c02 --- /dev/null +++ b/docs/pluginification/epics/13_course_home_progress_enterprise_name/02_edx-enterprise.md @@ -0,0 +1,10 @@ +# [edx-enterprise] Override obfuscated_username for course home progress view + +Blocked by: [openedx-platform] Add pluggable override for obfuscated username in progress view + +Add an override function for `OVERRIDE_COURSE_HOME_PROGRESS_USERNAME` in edx-enterprise at `enterprise/overrides/course_home_progress.py`. The override calls `get_enterprise_learner_generic_name(request)` (deferred import from `openedx.features.enterprise_support.utils` until epic 17) and returns the generic name if found, otherwise `None`. The override setting will be configured in `enterprise/settings/common.py` as part of epic 18. + +## A/C + +- `enterprise_obfuscated_username(prev_fn, request, student)` is defined in `enterprise/overrides/course_home_progress.py` and returns the enterprise generic name when available, or `None`. +- Unit tests cover the override for enterprise and non-enterprise learners. diff --git a/docs/pluginification/epics/13_course_home_progress_enterprise_name/EPIC.md b/docs/pluginification/epics/13_course_home_progress_enterprise_name/EPIC.md new file mode 100644 index 0000000000..ea0180794e --- /dev/null +++ b/docs/pluginification/epics/13_course_home_progress_enterprise_name/EPIC.md @@ -0,0 +1,15 @@ +# Epic: Course Home Progress Enterprise Name + +JIRA: ENT-11572 + +## Purpose + +`lms/djangoapps/course_home_api/progress/views.py` imports `get_enterprise_learner_generic_name` from `enterprise_support` to replace the student username with a generic enterprise name when the learner is an enterprise SSO user. + +## Approach + +Introduce an `obfuscated_username(request, student)` function in the same file and decorate it with `@pluggable_override('OVERRIDE_COURSE_HOME_PROGRESS_USERNAME')`. The default implementation returns `None`, preserving existing behavior. Replace the direct `get_enterprise_learner_generic_name` call with `obfuscated_username(request, student) or student.username`. edx-enterprise provides the override that calls `get_enterprise_learner_generic_name`. + +## Blocking Epics + +None. diff --git a/docs/pluginification/epics/14_course_modes_enterprise_customer/01_openedx-filters.diff b/docs/pluginification/epics/14_course_modes_enterprise_customer/01_openedx-filters.diff new file mode 100644 index 0000000000..50afd2c060 --- /dev/null +++ b/docs/pluginification/epics/14_course_modes_enterprise_customer/01_openedx-filters.diff @@ -0,0 +1,43 @@ +diff --git a/openedx_filters/learning/filters.py b/openedx_filters/learning/filters.py +--- a/openedx_filters/learning/filters.py ++++ b/openedx_filters/learning/filters.py +@@ -1239,3 +1239,43 @@ class PostLoginRedirectURLRequested(OpenEdxPublicFilter): + data = super().run_pipeline(redirect_url=redirect_url, user=user, next_url=next_url) + return data.get("redirect_url") ++ ++ ++class CourseModeCheckoutStarted(OpenEdxPublicFilter): ++ """ ++ Filter used to enrich the course mode checkout context before the checkout flow begins. ++ ++ Purpose: ++ This filter is triggered when a user selects a course mode and the checkout process ++ starts. Pipeline steps can modify the context dict to inject additional data required ++ by external systems (e.g. pricing APIs) to customise the checkout experience. ++ ++ Filter Type: ++ org.openedx.learning.course_mode.checkout.started.v1 ++ ++ Trigger: ++ - Repository: openedx/edx-platform ++ - Path: common/djangoapps/course_modes/views.py ++ - Function or Method: ChooseModeView.post ++ """ ++ ++ filter_type = "org.openedx.learning.course_mode.checkout.started.v1" ++ ++ @classmethod ++ def run_filter(cls, context: dict, request: Any, course_mode: Any) -> dict: ++ """ ++ Process the checkout context through the configured pipeline steps. ++ ++ Arguments: ++ context (dict): the checkout context dict. ++ request (HttpRequest): the current HTTP request. ++ course_mode (CourseMode): the selected course mode object. ++ ++ Returns: ++ dict: the (possibly enriched) checkout context. ++ """ ++ data = super().run_pipeline(context=context, request=request, course_mode=course_mode) ++ return data.get("context") diff --git a/docs/pluginification/epics/14_course_modes_enterprise_customer/01_openedx-filters.md b/docs/pluginification/epics/14_course_modes_enterprise_customer/01_openedx-filters.md new file mode 100644 index 0000000000..877208754c --- /dev/null +++ b/docs/pluginification/epics/14_course_modes_enterprise_customer/01_openedx-filters.md @@ -0,0 +1,11 @@ +# [openedx-filters] Add CourseModeCheckoutStarted filter + +No tickets block this one. + +Add a `CourseModeCheckoutStarted` filter class to `openedx_filters/learning/filters.py`. This filter is triggered when a user begins the course mode checkout flow (e.g. choosing a paid mode) and allows pipeline steps to enrich the checkout context dict with additional data such as an enterprise customer UUID for enterprise pricing. + +## A/C + +- `CourseModeCheckoutStarted` is defined in `openedx_filters/learning/filters.py` with filter type `"org.openedx.learning.course_mode.checkout.started.v1"`. +- `run_filter(context, request, course_mode)` returns the (possibly enriched) context dict. +- Neither the filter class name, filter type string, nor docstring mentions "enterprise". diff --git a/docs/pluginification/epics/14_course_modes_enterprise_customer/02_openedx-platform.diff b/docs/pluginification/epics/14_course_modes_enterprise_customer/02_openedx-platform.diff new file mode 100644 index 0000000000..67dd57c227 --- /dev/null +++ b/docs/pluginification/epics/14_course_modes_enterprise_customer/02_openedx-platform.diff @@ -0,0 +1,34 @@ +diff --git a/common/djangoapps/course_modes/views.py b/common/djangoapps/course_modes/views.py +--- a/common/djangoapps/course_modes/views.py ++++ b/common/djangoapps/course_modes/views.py +@@ -42,5 +42,5 @@ from openedx.core.djangoapps.site_configuration import helpers as configuration_ +-from openedx.features.enterprise_support.api import enterprise_customer_for_request ++from openedx_filters.learning.filters import CourseModeCheckoutStarted + +@@ -189,14 +189,9 @@ class ChooseModeView(View): +- enterprise_customer = enterprise_customer_for_request(request) +- if enterprise_customer and verified_mode.sku: +- log.info( +- '[e-commerce calculate API] Going to hit the API for user [%s] linked to [%s] enterprise', +- request.user.username, +- enterprise_customer.get('name') if isinstance(enterprise_customer, dict) else None, +- ) ++ checkout_context = CourseModeCheckoutStarted.run_filter( ++ context={}, ++ request=request, ++ course_mode=verified_mode, ++ ) ++ enterprise_customer = checkout_context.get('enterprise_customer') ++ if enterprise_customer and verified_mode.sku: + ecommerce_api_params['enterprise_customer'] = enterprise_customer.get('uuid') +diff --git a/lms/envs/common.py b/lms/envs/common.py +--- a/lms/envs/common.py ++++ b/lms/envs/common.py +@@ -1,6 +1,8 @@ OPEN_EDX_FILTERS_CONFIG = { + OPEN_EDX_FILTERS_CONFIG = { + # ... existing filter entries ... ++ "org.openedx.learning.course_mode.checkout.started.v1": { ++ "fail_silently": True, ++ "pipeline": ["enterprise.filters.course_modes.CheckoutEnterpriseContextInjector"], ++ }, + } diff --git a/docs/pluginification/epics/14_course_modes_enterprise_customer/02_openedx-platform.md b/docs/pluginification/epics/14_course_modes_enterprise_customer/02_openedx-platform.md new file mode 100644 index 0000000000..bb3bcdcef8 --- /dev/null +++ b/docs/pluginification/epics/14_course_modes_enterprise_customer/02_openedx-platform.md @@ -0,0 +1,12 @@ +# [openedx-platform] Replace enterprise customer checkout import with filter call + +Blocked by: [openedx-filters] Add CourseModeCheckoutStarted filter + +Remove `enterprise_customer_for_request` (and any other enterprise_support imports used only for the enterprise checkout block) from `common/djangoapps/course_modes/views.py`. Replace the enterprise customer lookup and conditional block (lines ~191-197) with a call to `CourseModeCheckoutStarted.run_filter(context={}, request=request, course_mode=verified_mode)`. Add the new filter type to `OPEN_EDX_FILTERS_CONFIG` in `lms/envs/common.py`. + +## A/C + +- `from openedx.features.enterprise_support.api import enterprise_customer_for_request` is removed from `course_modes/views.py`. +- The checkout view calls `CourseModeCheckoutStarted.run_filter(...)` and uses the returned context dict for the ecommerce API call. +- `"org.openedx.learning.course_mode.checkout.started.v1"` is added to `OPEN_EDX_FILTERS_CONFIG` in `lms/envs/common.py`. +- No import of `enterprise` or `enterprise_support` remains in the changed file. diff --git a/docs/pluginification/epics/14_course_modes_enterprise_customer/03_edx-enterprise.diff b/docs/pluginification/epics/14_course_modes_enterprise_customer/03_edx-enterprise.diff new file mode 100644 index 0000000000..c0329c878f --- /dev/null +++ b/docs/pluginification/epics/14_course_modes_enterprise_customer/03_edx-enterprise.diff @@ -0,0 +1,41 @@ +diff --git a/enterprise/filters/course_modes.py b/enterprise/filters/course_modes.py +new file mode 100644 +--- /dev/null ++++ b/enterprise/filters/course_modes.py +@@ -0,0 +1,36 @@ ++""" ++Pipeline steps for the course mode checkout filter. ++""" ++import logging ++ ++from openedx_filters.filters import PipelineStep ++ ++log = logging.getLogger(__name__) ++ ++ ++class CheckoutEnterpriseContextInjector(PipelineStep): ++ """ ++ Inject enterprise customer data into the course mode checkout context. ++ ++ If the current request is associated with an enterprise customer, this step adds the ++ enterprise customer dict to the checkout context under the key 'enterprise_customer'. ++ This allows downstream checkout logic to apply enterprise-specific pricing. ++ """ ++ ++ def run_filter(self, context, request, course_mode): # pylint: disable=arguments-differ ++ """ ++ Inject enterprise customer into the checkout context. ++ """ ++ # Deferred import — will be replaced with internal path in epic 17. ++ from openedx.features.enterprise_support.api import enterprise_customer_for_request # pylint: disable=import-outside-toplevel ++ ++ try: ++ enterprise_customer = enterprise_customer_for_request(request) ++ except Exception: # pylint: disable=broad-except ++ log.warning('Failed to retrieve enterprise customer for checkout context.', exc_info=True) ++ enterprise_customer = None ++ ++ if enterprise_customer: ++ context['enterprise_customer'] = enterprise_customer ++ ++ return {'context': context, 'request': request, 'course_mode': course_mode} diff --git a/docs/pluginification/epics/14_course_modes_enterprise_customer/03_edx-enterprise.md b/docs/pluginification/epics/14_course_modes_enterprise_customer/03_edx-enterprise.md new file mode 100644 index 0000000000..352c8a0fc1 --- /dev/null +++ b/docs/pluginification/epics/14_course_modes_enterprise_customer/03_edx-enterprise.md @@ -0,0 +1,10 @@ +# [edx-enterprise] Add CourseModeCheckoutStarted pipeline step + +Blocked by: [openedx-platform] Replace enterprise customer checkout import with filter call + +Add a `CheckoutEnterpriseContextInjector` pipeline step for `CourseModeCheckoutStarted` in `enterprise/filters/course_modes.py`. The step calls `enterprise_customer_for_request(request)` (deferred import from `openedx.features.enterprise_support.api` until epic 17) and injects the enterprise customer dict into the context under the key `'enterprise_customer'`. The pipeline step is wired into `OPEN_EDX_FILTERS_CONFIG` in `lms/envs/common.py` by the openedx-platform ticket. + +## A/C + +- `CheckoutEnterpriseContextInjector(PipelineStep)` is defined in `enterprise/filters/course_modes.py` and injects `enterprise_customer` into the context. +- Unit tests cover the pipeline step. diff --git a/docs/pluginification/epics/14_course_modes_enterprise_customer/EPIC.md b/docs/pluginification/epics/14_course_modes_enterprise_customer/EPIC.md new file mode 100644 index 0000000000..ad188d2066 --- /dev/null +++ b/docs/pluginification/epics/14_course_modes_enterprise_customer/EPIC.md @@ -0,0 +1,15 @@ +# Epic: Course Modes Enterprise Customer + +JIRA: ENT-11573 + +## Purpose + +`common/djangoapps/course_modes/views.py` imports `enterprise_customer_for_request` from `enterprise_support` and uses it to enrich the checkout context with enterprise customer data for ecommerce pricing API calls. + +## Approach + +Create a new `CourseModeCheckoutStarted` openedx-filter that allows edx-enterprise to inject enterprise customer context into the course mode checkout flow. Replace the direct `enterprise_customer_for_request` call in the view with a filter invocation. + +## Blocking Epics + +None. diff --git a/docs/pluginification/epics/15_support_views_enterprise_context/01_openedx-filters.diff b/docs/pluginification/epics/15_support_views_enterprise_context/01_openedx-filters.diff new file mode 100644 index 0000000000..9d07cab02e --- /dev/null +++ b/docs/pluginification/epics/15_support_views_enterprise_context/01_openedx-filters.diff @@ -0,0 +1,79 @@ +diff --git a/openedx_filters/learning/filters.py b/openedx_filters/learning/filters.py +--- a/openedx_filters/learning/filters.py ++++ b/openedx_filters/learning/filters.py +@@ -1279,3 +1279,65 @@ class CourseModeCheckoutStarted(OpenEdxPublicFilter): + data = super().run_pipeline(context=context, request=request, course_mode=course_mode) + return data.get("context") ++ ++ ++class SupportContactContextRequested(OpenEdxPublicFilter): ++ """ ++ Filter used to enrich the support contact request context with custom tags. ++ ++ Purpose: ++ This filter is triggered when a user submits a support contact request. Pipeline steps ++ can inspect the request and user to append custom tags to the tags list that will be ++ associated with the support ticket. ++ ++ Filter Type: ++ org.openedx.learning.support.contact.context.requested.v1 ++ ++ Trigger: ++ - Repository: openedx/edx-platform ++ - Path: lms/djangoapps/support/views/contact_us.py ++ - Function or Method: ContactUsView.post ++ """ ++ ++ filter_type = "org.openedx.learning.support.contact.context.requested.v1" ++ ++ @classmethod ++ def run_filter(cls, tags: list, request: Any, user: Any) -> list: ++ """ ++ Process the tags list through the configured pipeline steps. ++ ++ Arguments: ++ tags (list): the list of tags to be associated with the support ticket. ++ request (HttpRequest): the current HTTP request. ++ user (User): the user submitting the support request. ++ ++ Returns: ++ list: the (possibly modified) tags list. ++ """ ++ data = super().run_pipeline(tags=tags, request=request, user=user) ++ return data.get("tags") ++ ++ ++class SupportEnrollmentDataRequested(OpenEdxPublicFilter): ++ """ ++ Filter used to enrich the support enrollment data with additional enrollment records. ++ ++ Purpose: ++ This filter is triggered when the support enrollment view fetches enrollment data for ++ a user. Pipeline steps can inject additional enrollment records (keyed by course ID) ++ into the enrollment data dict. ++ ++ Filter Type: ++ org.openedx.learning.support.enrollment.data.requested.v1 ++ ++ Trigger: ++ - Repository: openedx/edx-platform ++ - Path: lms/djangoapps/support/views/enrollments.py ++ - Function or Method: EnrollmentSupportView.get ++ """ ++ ++ filter_type = "org.openedx.learning.support.enrollment.data.requested.v1" ++ ++ @classmethod ++ def run_filter(cls, enrollment_data: dict, user: Any) -> dict: ++ """ ++ Process the enrollment data dict through the configured pipeline steps. ++ ++ Arguments: ++ enrollment_data (dict): dict mapping course_id to list of enrollment records. ++ user (User): the user whose enrollment data is being fetched. ++ ++ Returns: ++ dict: the (possibly enriched) enrollment data dict. ++ """ ++ data = super().run_pipeline(enrollment_data=enrollment_data, user=user) ++ return data.get("enrollment_data") diff --git a/docs/pluginification/epics/15_support_views_enterprise_context/01_openedx-filters.md b/docs/pluginification/epics/15_support_views_enterprise_context/01_openedx-filters.md new file mode 100644 index 0000000000..be092ae575 --- /dev/null +++ b/docs/pluginification/epics/15_support_views_enterprise_context/01_openedx-filters.md @@ -0,0 +1,15 @@ +# [openedx-filters] Add SupportContactContextRequested and SupportEnrollmentDataRequested filters + +No tickets block this one. + +Add two new filter classes to `openedx_filters/learning/filters.py`: + +1. `SupportContactContextRequested` — triggered when a user submits a support contact request; pipeline steps can append custom tags to the support ticket tags list. + +2. `SupportEnrollmentDataRequested` — triggered when the support enrollment view loads data for a user; pipeline steps can inject additional enrollment records (e.g. enterprise course enrollments with consent data) into the enrollment data dict keyed by course ID. + +## A/C + +- `SupportContactContextRequested` is defined with filter type `"org.openedx.learning.support.contact.context.requested.v1"` and `run_filter(tags, request, user)` returning the (possibly modified) tags list. +- `SupportEnrollmentDataRequested` is defined with filter type `"org.openedx.learning.support.enrollment.data.requested.v1"` and `run_filter(enrollment_data, user)` returning the (possibly modified) enrollment dict. +- Neither filter class name, filter type string, nor docstring mentions "enterprise". diff --git a/docs/pluginification/epics/15_support_views_enterprise_context/02_openedx-platform.diff b/docs/pluginification/epics/15_support_views_enterprise_context/02_openedx-platform.diff new file mode 100644 index 0000000000..5dcd5c2636 --- /dev/null +++ b/docs/pluginification/epics/15_support_views_enterprise_context/02_openedx-platform.diff @@ -0,0 +1,83 @@ +diff --git a/lms/djangoapps/support/views/contact_us.py b/lms/djangoapps/support/views/contact_us.py +--- a/lms/djangoapps/support/views/contact_us.py ++++ b/lms/djangoapps/support/views/contact_us.py +@@ -14,5 +14,5 @@ from django.views.generic import View +-from openedx.features.enterprise_support import api as enterprise_api ++from openedx_filters.learning.filters import SupportContactContextRequested + +@@ -47,9 +47,7 @@ class ContactUsView(View): + def post(self, request): + tags = [] +- enterprise_customer = enterprise_api.enterprise_customer_for_request(request) +- if enterprise_customer: +- tags.append('enterprise_learner') ++ tags = SupportContactContextRequested.run_filter( ++ tags=tags, request=request, user=request.user ++ ) + # ... rest of the post method ... +diff --git a/lms/djangoapps/support/views/enrollments.py b/lms/djangoapps/support/views/enrollments.py +--- a/lms/djangoapps/support/views/enrollments.py ++++ b/lms/djangoapps/support/views/enrollments.py +@@ -37,10 +37,5 @@ from openedx.core.djangoapps.user_api.accounts.utils import is_secondary_email_ +-from openedx.features.enterprise_support.api import ( +- enterprise_enabled, +- get_data_sharing_consents, +- get_enterprise_course_enrollments, +-) +-from openedx.features.enterprise_support.serializers import EnterpriseCourseEnrollmentSerializer ++from openedx_filters.learning.filters import SupportEnrollmentDataRequested + +@@ -74,29 +69,0 @@ class ManualEnrollmentSupportView(View): +- def _enterprise_course_enrollments_by_course_id(self, user): +- """ +- Returns a dict containing enterprise course enrollments data with +- data sharing consent info keyed by course_id. +- """ +- enterprise_course_enrollments = get_enterprise_course_enrollments(user) +- consents = get_data_sharing_consents(user) +- enterprise_enrollments_by_course_id = defaultdict(list) +- consent_by_course_and_enterprise_customer_id = {} +- +- for consent in consents: +- key = f'{consent.course_id}-{consent.enterprise_customer_id}' +- consent_by_course_and_enterprise_customer_id[key] = consent.serialize() +- +- for enterprise_course_enrollment in enterprise_course_enrollments: +- serialized = EnterpriseCourseEnrollmentSerializer(enterprise_course_enrollment).data +- course_id = enterprise_course_enrollment.course_id +- enterprise_customer_id = enterprise_course_enrollment.enterprise_customer_user.enterprise_customer_id +- key = f'{course_id}-{enterprise_customer_id}' +- consent = consent_by_course_and_enterprise_customer_id.get(key) +- serialized['data_sharing_consent'] = consent +- enterprise_enrollments_by_course_id[course_id].append(serialized) +- +- return enterprise_enrollments_by_course_id + +@@ -128,9 +99,7 @@ class ManualEnrollmentSupportView(View): +- if enterprise_enabled(): +- enterprise_enrollments_by_course_id = self._enterprise_course_enrollments_by_course_id(user) +- for enrollment in enrollments: +- enterprise_course_enrollments = enterprise_enrollments_by_course_id.get(enrollment['course_id'], []) +- enrollment['enterprise_course_enrollments'] = enterprise_course_enrollments ++ enterprise_enrollments_by_course_id = SupportEnrollmentDataRequested.run_filter( ++ enrollment_data={}, user=user ++ ) ++ for enrollment in enrollments: ++ enrollment['enterprise_course_enrollments'] = enterprise_enrollments_by_course_id.get( ++ enrollment['course_id'], [] ++ ) +diff --git a/lms/envs/common.py b/lms/envs/common.py +--- a/lms/envs/common.py ++++ b/lms/envs/common.py +@@ -1,6 +1,12 @@ OPEN_EDX_FILTERS_CONFIG = { + OPEN_EDX_FILTERS_CONFIG = { + # ... existing filter entries ... ++ "org.openedx.learning.support.contact.context.requested.v1": { ++ "fail_silently": True, ++ "pipeline": ["enterprise.filters.support.SupportContactEnterpriseTagInjector"], ++ }, ++ "org.openedx.learning.support.enrollment.data.requested.v1": { ++ "fail_silently": True, ++ "pipeline": ["enterprise.filters.support.SupportEnterpriseEnrollmentDataInjector"], ++ }, + } diff --git a/docs/pluginification/epics/15_support_views_enterprise_context/02_openedx-platform.md b/docs/pluginification/epics/15_support_views_enterprise_context/02_openedx-platform.md new file mode 100644 index 0000000000..a3a3825116 --- /dev/null +++ b/docs/pluginification/epics/15_support_views_enterprise_context/02_openedx-platform.md @@ -0,0 +1,12 @@ +# [openedx-platform] Replace enterprise support view imports with filter calls + +Blocked by: [openedx-filters] Add SupportContactContextRequested and SupportEnrollmentDataRequested filters + +Remove all `enterprise_support` imports from `lms/djangoapps/support/views/contact_us.py` and `lms/djangoapps/support/views/enrollments.py`. In `contact_us.py`, replace the `enterprise_api.enterprise_customer_for_request(request)` check and tag appending with a call to `SupportContactContextRequested.run_filter(tags=tags, request=request, user=user)`. In `enrollments.py`, replace `_enterprise_course_enrollments_by_course_id(user)` with a call to `SupportEnrollmentDataRequested.run_filter(enrollment_data={}, user=user)` and delete the `_enterprise_course_enrollments_by_course_id` method. Add the new filter types to `OPEN_EDX_FILTERS_CONFIG` in `lms/envs/common.py`. + +## A/C + +- `from openedx.features.enterprise_support import api as enterprise_api` is removed from `contact_us.py`; enterprise customer tag logic is replaced by a filter call. +- `from openedx.features.enterprise_support.api import ...` and serializer imports are removed from `enrollments.py`; `_enterprise_course_enrollments_by_course_id` is deleted. +- Both new filter types are added to `OPEN_EDX_FILTERS_CONFIG` in `lms/envs/common.py`. +- No import of `enterprise` or `enterprise_support` remains in any changed file. diff --git a/docs/pluginification/epics/15_support_views_enterprise_context/03_edx-enterprise.diff b/docs/pluginification/epics/15_support_views_enterprise_context/03_edx-enterprise.diff new file mode 100644 index 0000000000..a59e1c8815 --- /dev/null +++ b/docs/pluginification/epics/15_support_views_enterprise_context/03_edx-enterprise.diff @@ -0,0 +1,82 @@ +diff --git a/enterprise/filters/support.py b/enterprise/filters/support.py +new file mode 100644 +--- /dev/null ++++ b/enterprise/filters/support.py +@@ -0,0 +1,80 @@ ++""" ++Pipeline steps for the support views filters. ++""" ++import logging ++ ++from openedx_filters.filters import PipelineStep ++ ++log = logging.getLogger(__name__) ++ ++ ++class SupportContactEnterpriseTagInjector(PipelineStep): ++ """ ++ Append the 'enterprise_learner' tag to support tickets for enterprise users. ++ """ ++ ++ def run_filter(self, tags, request, user): # pylint: disable=arguments-differ ++ """ ++ Append 'enterprise_learner' tag if the user is an enterprise customer user. ++ """ ++ # Deferred import — will be replaced with internal path in epic 17. ++ from openedx.features.enterprise_support.api import enterprise_customer_for_request # pylint: disable=import-outside-toplevel ++ ++ try: ++ enterprise_customer = enterprise_customer_for_request(request) ++ except Exception: # pylint: disable=broad-except ++ log.warning('Failed to check enterprise customer for support contact tag.', exc_info=True) ++ enterprise_customer = None ++ ++ if enterprise_customer: ++ tags = list(tags) ++ if 'enterprise_learner' not in tags: ++ tags.append('enterprise_learner') ++ ++ return {'tags': tags, 'request': request, 'user': user} ++ ++ ++class SupportEnterpriseEnrollmentDataInjector(PipelineStep): ++ """ ++ Inject enterprise course enrollment data into the support enrollment view. ++ ++ Builds a dict of enterprise course enrollments (with data-sharing consent records) ++ keyed by course_id. ++ """ ++ ++ def run_filter(self, enrollment_data, user): # pylint: disable=arguments-differ ++ """ ++ Populate enrollment_data with enterprise course enrollment records. ++ """ ++ # Deferred imports — will be replaced with internal paths in epic 17. ++ from openedx.features.enterprise_support.api import ( # pylint: disable=import-outside-toplevel ++ get_data_sharing_consents, ++ get_enterprise_course_enrollments, ++ ) ++ from openedx.features.enterprise_support.serializers import EnterpriseCourseEnrollmentSerializer # pylint: disable=import-outside-toplevel + ++ try: ++ enterprise_course_enrollments = get_enterprise_course_enrollments(user) ++ consents = get_data_sharing_consents(user) ++ except Exception: # pylint: disable=broad-except ++ log.warning('Failed to fetch enterprise enrollment data for support view.', exc_info=True) ++ return {'enrollment_data': enrollment_data, 'user': user} ++ ++ consent_by_key = {} ++ for consent in consents: ++ key = f'{consent.course_id}-{consent.enterprise_customer_id}' ++ consent_by_key[key] = consent.serialize() ++ ++ enriched = dict(enrollment_data) ++ for ecr in enterprise_course_enrollments: ++ serialized = EnterpriseCourseEnrollmentSerializer(ecr).data ++ course_id = ecr.course_id ++ enterprise_customer_id = ecr.enterprise_customer_user.enterprise_customer_id ++ key = f'{course_id}-{enterprise_customer_id}' ++ serialized['data_sharing_consent'] = consent_by_key.get(key) ++ enriched.setdefault(course_id, []).append(serialized) ++ ++ return {'enrollment_data': enriched, 'user': user} diff --git a/docs/pluginification/epics/15_support_views_enterprise_context/03_edx-enterprise.md b/docs/pluginification/epics/15_support_views_enterprise_context/03_edx-enterprise.md new file mode 100644 index 0000000000..f45ff35d47 --- /dev/null +++ b/docs/pluginification/epics/15_support_views_enterprise_context/03_edx-enterprise.md @@ -0,0 +1,15 @@ +# [edx-enterprise] Add support view pipeline steps + +Blocked by: [openedx-platform] Replace enterprise support view imports with filter calls + +Add two pipeline steps in `enterprise/filters/support.py`: + +1. `SupportContactEnterpriseTagInjector` for `SupportContactContextRequested` — appends `'enterprise_learner'` to the tags list when `enterprise_customer_for_request(request)` returns a non-empty result. + +2. `SupportEnterpriseEnrollmentDataInjector` for `SupportEnrollmentDataRequested` — calls `get_enterprise_course_enrollments(user)` and `get_data_sharing_consents(user)` (deferred imports from `openedx.features.enterprise_support.api` until epic 17), builds the enrollment data dict keyed by course ID, and returns it. + +## A/C + +- `SupportContactEnterpriseTagInjector(PipelineStep)` appends `'enterprise_learner'` when the user is associated with an enterprise customer. +- `SupportEnterpriseEnrollmentDataInjector(PipelineStep)` returns the enterprise enrollment data dict keyed by course ID. +- Unit tests cover both pipeline steps. diff --git a/docs/pluginification/epics/15_support_views_enterprise_context/EPIC.md b/docs/pluginification/epics/15_support_views_enterprise_context/EPIC.md new file mode 100644 index 0000000000..5f49f7cc88 --- /dev/null +++ b/docs/pluginification/epics/15_support_views_enterprise_context/EPIC.md @@ -0,0 +1,15 @@ +# Epic: Support Views Enterprise Context + +JIRA: ENT-11574 + +## Purpose + +Two support views in `lms/djangoapps/support/views/` import from `enterprise_support`: `contact_us.py` tags support tickets with `'enterprise_learner'` when the user is an enterprise customer, and `enrollments.py` queries enterprise enrollments and consent records for the support enrollment view. + +## Approach + +Create two new openedx-filters: `SupportContactContextRequested` for enriching the support contact context with custom tags, and `SupportEnrollmentDataRequested` for populating the support enrollment view with enterprise enrollment data. edx-enterprise provides pipeline steps for both. + +## Blocking Epics + +None. diff --git a/docs/pluginification/epics/16_programs_api_enterprise_enrollments/01_openedx-platform.diff b/docs/pluginification/epics/16_programs_api_enterprise_enrollments/01_openedx-platform.diff new file mode 100644 index 0000000000..5e3398959a --- /dev/null +++ b/docs/pluginification/epics/16_programs_api_enterprise_enrollments/01_openedx-platform.diff @@ -0,0 +1,36 @@ +diff --git a/openedx/core/djangoapps/programs/rest_api/v1/views.py b/openedx/core/djangoapps/programs/rest_api/v1/views.py +--- a/openedx/core/djangoapps/programs/rest_api/v1/views.py ++++ b/openedx/core/djangoapps/programs/rest_api/v1/views.py +@@ -6,5 +6,5 @@ import logging + from django.db.models.query import EmptyQuerySet + from rest_framework.permissions import IsAuthenticated + from rest_framework.response import Response + from rest_framework.views import APIView ++from edx_django_utils.plugins import pluggable_override + +@@ -11,5 +11,1 @@ from common.djangoapps.student.api import get_course_enrollments +-from openedx.features.enterprise_support.api import get_enterprise_course_enrollments, enterprise_is_enabled + +@@ -179,7 +179,8 @@ class Programs(APIView): +- @enterprise_is_enabled(otherwise=EmptyQuerySet) +- def _get_enterprise_course_enrollments( ++ @pluggable_override('OVERRIDE_PROGRAMS_GET_ENTERPRISE_COURSE_ENROLLMENTS') ++ def _get_enterprise_course_enrollments( + self, enterprise_uuid: str, user: "AnonymousUser | User" + ) -> "QuerySet[CourseEnrollment]": + """ +- Return only enterprise enrollments for a user. +- """ +- enterprise_enrollment_course_ids = ( +- get_enterprise_course_enrollments(user) +- .filter(enterprise_customer_user__enterprise_customer__uuid=enterprise_uuid) +- .values_list("course_id", flat=True) +- ) +- +- course_enrollments = get_course_enrollments(user, True, list(enterprise_enrollment_course_ids)) +- +- return course_enrollments ++ Return only enterprise enrollments for a user, or an empty queryset when no ++ enterprise plugin is installed. ++ """ ++ return CourseEnrollment.objects.none() diff --git a/docs/pluginification/epics/16_programs_api_enterprise_enrollments/01_openedx-platform.md b/docs/pluginification/epics/16_programs_api_enterprise_enrollments/01_openedx-platform.md new file mode 100644 index 0000000000..3ffdf78251 --- /dev/null +++ b/docs/pluginification/epics/16_programs_api_enterprise_enrollments/01_openedx-platform.md @@ -0,0 +1,12 @@ +# [openedx-platform] Add pluggable override to _get_enterprise_course_enrollments + +No tickets block this one. + +Remove `get_enterprise_course_enrollments` and `enterprise_is_enabled` imports from `openedx/core/djangoapps/programs/rest_api/v1/views.py`. Remove the `@enterprise_is_enabled` decorator from `_get_enterprise_course_enrollments`. Replace the method body with a return of `EmptyQuerySet` by default and add the `@pluggable_override('OVERRIDE_PROGRAMS_GET_ENTERPRISE_COURSE_ENROLLMENTS')` decorator. + +## A/C + +- `from openedx.features.enterprise_support.api import get_enterprise_course_enrollments, enterprise_is_enabled` is removed from `programs/rest_api/v1/views.py`. +- The `@enterprise_is_enabled(otherwise=EmptyQuerySet)` decorator is removed from `_get_enterprise_course_enrollments`. +- `_get_enterprise_course_enrollments` is decorated with `@pluggable_override('OVERRIDE_PROGRAMS_GET_ENTERPRISE_COURSE_ENROLLMENTS')` and its body returns `EmptyQuerySet()`. +- No import of `enterprise` or `enterprise_support` remains in the changed file. diff --git a/docs/pluginification/epics/16_programs_api_enterprise_enrollments/02_edx-enterprise.diff b/docs/pluginification/epics/16_programs_api_enterprise_enrollments/02_edx-enterprise.diff new file mode 100644 index 0000000000..85a4c4ab8f --- /dev/null +++ b/docs/pluginification/epics/16_programs_api_enterprise_enrollments/02_edx-enterprise.diff @@ -0,0 +1,44 @@ +diff --git a/enterprise/overrides/programs.py b/enterprise/overrides/programs.py +new file mode 100644 +--- /dev/null ++++ b/enterprise/overrides/programs.py +@@ -0,0 +1,42 @@ ++""" ++Pluggable override for the programs API enterprise course enrollment lookup. ++""" ++import logging ++ ++from enterprise.models import EnterpriseCourseEnrollment ++ ++log = logging.getLogger(__name__) ++ ++ ++def enterprise_get_enterprise_course_enrollments(prev_fn, self, enterprise_uuid, user): ++ """ ++ Return course enrollments filtered to those belonging to the given enterprise customer. ++ ++ This function overrides the default ``_get_enterprise_course_enrollments`` implementation ++ in ``openedx/core/djangoapps/programs/rest_api/v1/views.py`` via the pluggable override ++ mechanism. ++ ++ Arguments: ++ prev_fn: the previous (default) implementation — returns EmptyQuerySet, not called. ++ self: the ``Programs`` view instance. ++ enterprise_uuid (str): UUID of the enterprise customer to filter enrollments for. ++ user: the Django User object. ++ ++ Returns: ++ QuerySet[CourseEnrollment]: course enrollments for the user filtered to those ++ associated with the given enterprise customer UUID. ++ """ ++ # Deferred import — student.api lives in openedx-platform. ++ from common.djangoapps.student.api import get_course_enrollments # pylint: disable=import-outside-toplevel ++ ++ enterprise_enrollment_course_ids = ( ++ EnterpriseCourseEnrollment.objects.filter( ++ enterprise_customer_user__user=user, ++ enterprise_customer_user__enterprise_customer__uuid=enterprise_uuid, ++ ).values_list('course_id', flat=True) ++ ) ++ ++ return get_course_enrollments(user, True, list(enterprise_enrollment_course_ids)) diff --git a/docs/pluginification/epics/16_programs_api_enterprise_enrollments/02_edx-enterprise.md b/docs/pluginification/epics/16_programs_api_enterprise_enrollments/02_edx-enterprise.md new file mode 100644 index 0000000000..55d709c1d1 --- /dev/null +++ b/docs/pluginification/epics/16_programs_api_enterprise_enrollments/02_edx-enterprise.md @@ -0,0 +1,10 @@ +# [edx-enterprise] Override _get_enterprise_course_enrollments for programs API + +Blocked by: [openedx-platform] Add pluggable override to _get_enterprise_course_enrollments + +Add an override function for `OVERRIDE_PROGRAMS_GET_ENTERPRISE_COURSE_ENROLLMENTS` in edx-enterprise at `enterprise/overrides/programs.py`. The override queries `EnterpriseCourseEnrollment` from `enterprise.models` directly (no enterprise_support import needed) filtered by `enterprise_customer__uuid=enterprise_uuid`, then calls `get_course_enrollments(user, True, list(enterprise_enrollment_course_ids))` (deferred import from `common.djangoapps.student.api`). The override setting will be configured in `enterprise/settings/common.py` as part of epic 18. + +## A/C + +- `enterprise_get_enterprise_course_enrollments(prev_fn, self, enterprise_uuid, user)` is defined in `enterprise/overrides/programs.py` and returns the filtered course enrollments queryset. +- Unit tests cover the override. diff --git a/docs/pluginification/epics/16_programs_api_enterprise_enrollments/EPIC.md b/docs/pluginification/epics/16_programs_api_enterprise_enrollments/EPIC.md new file mode 100644 index 0000000000..7fd5ebb8d1 --- /dev/null +++ b/docs/pluginification/epics/16_programs_api_enterprise_enrollments/EPIC.md @@ -0,0 +1,15 @@ +# Epic: Programs API Enterprise Enrollments + +JIRA: ENT-11575 + +## Purpose + +`openedx/core/djangoapps/programs/rest_api/v1/views.py` imports `get_enterprise_course_enrollments` and `enterprise_is_enabled` from `enterprise_support` and uses them in `_get_enterprise_course_enrollments` to filter course enrollments for enterprise learners on the programs progress page. + +## Approach + +Decorate `_get_enterprise_course_enrollments` with `@pluggable_override('OVERRIDE_PROGRAMS_GET_ENTERPRISE_COURSE_ENROLLMENTS')`. The default implementation returns an empty queryset. edx-enterprise provides the override that queries `EnterpriseCourseEnrollment` filtered by the enterprise UUID. The `enterprise_is_enabled` decorator is removed; when edx-enterprise is not installed the default empty queryset is used automatically. + +## Blocking Epics + +None. diff --git a/docs/pluginification/epics/17_enterprise_support_to_edx_enterprise/01_edx-enterprise.diff b/docs/pluginification/epics/17_enterprise_support_to_edx_enterprise/01_edx-enterprise.diff new file mode 100644 index 0000000000..b5b7a784f3 --- /dev/null +++ b/docs/pluginification/epics/17_enterprise_support_to_edx_enterprise/01_edx-enterprise.diff @@ -0,0 +1,190 @@ +diff --git a/enterprise/platform_support/__init__.py b/enterprise/platform_support/__init__.py +new file mode 100644 +--- /dev/null ++++ b/enterprise/platform_support/__init__.py +@@ -0,0 +1,3 @@ ++""" ++Enterprise support utilities migrated from openedx-platform's enterprise_support module. ++""" +diff --git a/enterprise/platform_support/api.py b/enterprise/platform_support/api.py +new file mode 100644 +--- /dev/null ++++ b/enterprise/platform_support/api.py +@@ -0,0 +1,10 @@ ++""" ++Enterprise support API — migrated from openedx/features/enterprise_support/api.py. ++ ++All content is identical to the source file with import paths updated from ++``openedx.features.enterprise_support`` to ``enterprise.platform_support``. ++""" ++# NOTE: This file is generated by copying openedx/features/enterprise_support/api.py ++# and replacing all occurrences of: ++# from openedx.features.enterprise_support -> from enterprise.platform_support ++# The full file content is omitted here for brevity; see the migration script below. +diff --git a/enterprise/platform_support/utils.py b/enterprise/platform_support/utils.py +new file mode 100644 +--- /dev/null ++++ b/enterprise/platform_support/utils.py +@@ -0,0 +1,8 @@ ++""" ++Enterprise support utilities — migrated from openedx/features/enterprise_support/utils.py. ++ ++All content is identical to the source file with import paths updated from ++``openedx.features.enterprise_support`` to ``enterprise.platform_support``. ++""" ++# NOTE: Full content migrated from openedx-platform with updated import paths. ++# See migration script below. +diff --git a/enterprise/platform_support/context.py b/enterprise/platform_support/context.py +new file mode 100644 +--- /dev/null ++++ b/enterprise/platform_support/context.py +@@ -0,0 +1,7 @@ ++""" ++Enterprise support context — migrated from openedx/features/enterprise_support/context.py. ++""" ++# NOTE: Full content migrated from openedx-platform with updated import paths. +diff --git a/enterprise/platform_support/serializers.py b/enterprise/platform_support/serializers.py +new file mode 100644 +--- /dev/null ++++ b/enterprise/platform_support/serializers.py +@@ -0,0 +1,7 @@ ++""" ++Enterprise support serializers — migrated from openedx/features/enterprise_support/serializers.py. ++""" ++# NOTE: Full content migrated from openedx-platform with updated import paths. +diff --git a/enterprise/platform_support/signals.py b/enterprise/platform_support/signals.py +new file mode 100644 +--- /dev/null ++++ b/enterprise/platform_support/signals.py +@@ -0,0 +1,7 @@ ++""" ++Enterprise support signal handlers — migrated from openedx/features/enterprise_support/signals.py. ++""" ++# NOTE: Full content migrated from openedx-platform with updated import paths. +diff --git a/enterprise/platform_support/tasks.py b/enterprise/platform_support/tasks.py +new file mode 100644 +--- /dev/null ++++ b/enterprise/platform_support/tasks.py +@@ -0,0 +1,7 @@ ++""" ++Enterprise support Celery tasks — migrated from openedx/features/enterprise_support/tasks.py. ++""" ++# NOTE: Full content migrated from openedx-platform with updated import paths. +diff --git a/enterprise/apps.py b/enterprise/apps.py +--- a/enterprise/apps.py ++++ b/enterprise/apps.py +@@ -50,6 +50,26 @@ class EnterpriseConfig(AppConfig): + try: + from openedx.core.djangoapps.user_api.accounts.signals import USER_RETIRE_LMS_CRITICAL + USER_RETIRE_LMS_CRITICAL.connect(handle_user_retirement) + except ImportError: + pass + + from enterprise.platform_signal_handlers import handle_social_auth_disconnect # pylint: disable=import-outside-toplevel + try: + from common.djangoapps.third_party_auth.signals import SocialAuthAccountDisconnected + SocialAuthAccountDisconnected.connect(handle_social_auth_disconnect) + except ImportError: + pass ++ ++ # Activate enterprise_support signal handlers (migrated from EnterpriseSupportConfig). ++ try: ++ from enterprise.platform_support.signals import ( # pylint: disable=import-outside-toplevel ++ handle_course_grade_passed, ++ handle_assessment_grade_changed, ++ handle_unenroll_done, ++ ) ++ from lms.djangoapps.grades.signals.signals import COURSE_GRADE_NOW_PASSED # pylint: disable=import-outside-toplevel ++ COURSE_GRADE_NOW_PASSED.connect(handle_course_grade_passed) ++ except ImportError: ++ pass +diff --git a/enterprise/filters/grades.py b/enterprise/filters/grades.py +--- a/enterprise/filters/grades.py ++++ b/enterprise/filters/grades.py +@@ -1,7 +1,7 @@ + """ + Pipeline steps for grade event context enrichment. + """ +-from openedx.features.enterprise_support.context import get_enterprise_event_context ++from enterprise.platform_support.context import get_enterprise_event_context + from openedx_filters.tooling import PipelineStep + + from enterprise.models import EnterpriseCourseEnrollment +diff --git a/enterprise/filters/logistration.py b/enterprise/filters/logistration.py +--- a/enterprise/filters/logistration.py ++++ b/enterprise/filters/logistration.py +@@ -30,8 +30,8 @@ class LogistrationContextEnricher(PipelineStep): +- # Deferred imports — will be replaced with internal paths in epic 17. +- from openedx.features.enterprise_support.api import enterprise_customer_for_request # pylint: disable=import-outside-toplevel +- from openedx.features.enterprise_support.utils import ( # pylint: disable=import-outside-toplevel ++ from enterprise.platform_support.api import enterprise_customer_for_request # pylint: disable=import-outside-toplevel ++ from enterprise.platform_support.utils import ( # pylint: disable=import-outside-toplevel + get_enterprise_slug_login_url, + update_logistration_context_for_enterprise, + ) +@@ -60,7 +60,7 @@ class LogistrationCookieSetter(PipelineStep): +- from openedx.features.enterprise_support.utils import handle_enterprise_cookies_for_logistration # pylint: disable=import-outside-toplevel ++ from enterprise.platform_support.utils import handle_enterprise_cookies_for_logistration # pylint: disable=import-outside-toplevel +diff --git a/enterprise/filters/dashboard.py b/enterprise/filters/dashboard.py +--- a/enterprise/filters/dashboard.py ++++ b/enterprise/filters/dashboard.py +@@ -28,9 +28,9 @@ class DashboardContextEnricher(PipelineStep): +- # Deferred imports — will be replaced with internal paths in epic 17. +- from openedx.features.enterprise_support.api import ( # pylint: disable=import-outside-toplevel ++ from enterprise.platform_support.api import ( # pylint: disable=import-outside-toplevel + get_dashboard_consent_notification, + get_enterprise_learner_portal_context, + ) +- from openedx.features.enterprise_support.utils import is_enterprise_learner # pylint: disable=import-outside-toplevel ++ from enterprise.platform_support.utils import is_enterprise_learner # pylint: disable=import-outside-toplevel +diff --git a/enterprise/filters/enrollment.py b/enterprise/filters/enrollment.py +--- a/enterprise/filters/enrollment.py ++++ b/enterprise/filters/enrollment.py +@@ -32,8 +32,7 @@ class EnterpriseEnrollmentPostProcessor(PipelineStep): +- # Deferred imports — will be replaced with internal paths in epic 17. +- from openedx.features.enterprise_support.api import ( # pylint: disable=import-outside-toplevel ++ from enterprise.platform_support.api import ( # pylint: disable=import-outside-toplevel + EnterpriseApiServiceClient, + ConsentApiServiceClient, + ) +diff --git a/enterprise/filters/course_modes.py b/enterprise/filters/course_modes.py +--- a/enterprise/filters/course_modes.py ++++ b/enterprise/filters/course_modes.py +@@ -24,7 +24,7 @@ class CheckoutEnterpriseContextInjector(PipelineStep): +- # Deferred import — will be replaced with internal path in epic 17. +- from openedx.features.enterprise_support.api import enterprise_customer_for_request # pylint: disable=import-outside-toplevel ++ from enterprise.platform_support.api import enterprise_customer_for_request # pylint: disable=import-outside-toplevel +diff --git a/enterprise/filters/support.py b/enterprise/filters/support.py +--- a/enterprise/filters/support.py ++++ b/enterprise/filters/support.py +@@ -22,7 +22,7 @@ class SupportContactEnterpriseTagInjector(PipelineStep): +- # Deferred import — will be replaced with internal path in epic 17. +- from openedx.features.enterprise_support.api import enterprise_customer_for_request # pylint: disable=import-outside-toplevel ++ from enterprise.platform_support.api import enterprise_customer_for_request # pylint: disable=import-outside-toplevel +@@ -51,9 +51,8 @@ class SupportEnterpriseEnrollmentDataInjector(PipelineStep): +- # Deferred imports — will be replaced with internal paths in epic 17. +- from openedx.features.enterprise_support.api import ( # pylint: disable=import-outside-toplevel ++ from enterprise.platform_support.api import ( # pylint: disable=import-outside-toplevel + get_data_sharing_consents, + get_enterprise_course_enrollments, + ) +- from openedx.features.enterprise_support.serializers import EnterpriseCourseEnrollmentSerializer # pylint: disable=import-outside-toplevel ++ from enterprise.platform_support.serializers import EnterpriseCourseEnrollmentSerializer # pylint: disable=import-outside-toplevel +diff --git a/enterprise/overrides/learner_home.py b/enterprise/overrides/learner_home.py +--- a/enterprise/overrides/learner_home.py ++++ b/enterprise/overrides/learner_home.py +@@ -27,8 +27,7 @@ def enterprise_get_enterprise_customer(prev_fn, user, request, is_masquerading): +- # Deferred imports — will be replaced with internal paths in epic 17. +- from openedx.features.enterprise_support.api import ( # pylint: disable=import-outside-toplevel ++ from enterprise.platform_support.api import ( # pylint: disable=import-outside-toplevel + enterprise_customer_from_session_or_learner_data, + get_enterprise_learner_data_from_db, + ) +diff --git a/enterprise/overrides/course_home_progress.py b/enterprise/overrides/course_home_progress.py +--- a/enterprise/overrides/course_home_progress.py ++++ b/enterprise/overrides/course_home_progress.py +@@ -27,6 +27,5 @@ def enterprise_obfuscated_username(prev_fn, request, student): +- # Deferred import — will be replaced with internal path in epic 17. +- from openedx.features.enterprise_support.utils import get_enterprise_learner_generic_name # pylint: disable=import-outside-toplevel ++ from enterprise.platform_support.utils import get_enterprise_learner_generic_name # pylint: disable=import-outside-toplevel + return get_enterprise_learner_generic_name(request) or None diff --git a/docs/pluginification/epics/17_enterprise_support_to_edx_enterprise/01_edx-enterprise.md b/docs/pluginification/epics/17_enterprise_support_to_edx_enterprise/01_edx-enterprise.md new file mode 100644 index 0000000000..63f3c57e34 --- /dev/null +++ b/docs/pluginification/epics/17_enterprise_support_to_edx_enterprise/01_edx-enterprise.md @@ -0,0 +1,14 @@ +# [edx-enterprise] Copy enterprise_support into enterprise/platform_support/ and update internal imports + +Blocked by: all epics 01–16 (all external openedx-platform callers of enterprise_support must be replaced before this ticket ships). + +Copy the full `enterprise_support` package from openedx-platform into edx-enterprise under `enterprise/platform_support/`. Update all internal imports within the copied files to use the new `enterprise.platform_support` path instead of `openedx.features.enterprise_support`. Update all edx-enterprise plugin steps created in epics 01–16 that currently use deferred imports of `openedx.features.enterprise_support...` to import from `enterprise.platform_support...` instead. Move the signal handler activations that were in `EnterpriseSupportConfig.ready()` into `EnterpriseConfig.ready()`. + +## A/C + +- `enterprise/platform_support/` directory is created containing all modules from the original `openedx/features/enterprise_support/` (api.py, utils.py, context.py, signals.py, tasks.py, serializers.py, admin/, enrollments/, templates/). +- All internal `from openedx.features.enterprise_support import ...` references within the copied files are rewritten to `from enterprise.platform_support import ...`. +- All deferred `from openedx.features.enterprise_support...` imports in edx-enterprise plugin step files (epics 01–16) are updated to `from enterprise.platform_support...`. +- Signal handlers previously activated in `EnterpriseSupportConfig.ready()` are connected in `EnterpriseConfig.ready()`. +- All tests from `enterprise_support/tests/` are copied to `enterprise/tests/platform_support/` with import paths updated. +- The edx-enterprise package installs correctly without openedx-platform's enterprise_support module present. diff --git a/docs/pluginification/epics/17_enterprise_support_to_edx_enterprise/02_openedx-platform.diff b/docs/pluginification/epics/17_enterprise_support_to_edx_enterprise/02_openedx-platform.diff new file mode 100644 index 0000000000..3b7c160d30 --- /dev/null +++ b/docs/pluginification/epics/17_enterprise_support_to_edx_enterprise/02_openedx-platform.diff @@ -0,0 +1,11 @@ +diff --git a/openedx/features/enterprise_support/ b/openedx/features/enterprise_support/ +deleted file mode 100644 +--- a/openedx/features/enterprise_support/ ++++ /dev/null +@@ -1 +0,0 @@ +-# Entire directory deleted. All contents migrated to enterprise/platform_support/ in edx-enterprise. +diff --git a/lms/envs/common.py b/lms/envs/common.py +--- a/lms/envs/common.py ++++ b/lms/envs/common.py +@@ -815,5 +815,1 @@ INSTALLED_APPS = [ +- 'openedx.features.enterprise_support.apps.EnterpriseSupportConfig', diff --git a/docs/pluginification/epics/17_enterprise_support_to_edx_enterprise/02_openedx-platform.md b/docs/pluginification/epics/17_enterprise_support_to_edx_enterprise/02_openedx-platform.md new file mode 100644 index 0000000000..50b7aa4984 --- /dev/null +++ b/docs/pluginification/epics/17_enterprise_support_to_edx_enterprise/02_openedx-platform.md @@ -0,0 +1,12 @@ +# [openedx-platform] Delete enterprise_support module from openedx-platform + +Blocked by: [edx-enterprise] Copy enterprise_support into enterprise/platform_support/ + +Delete the entire `openedx/features/enterprise_support/` directory from openedx-platform. Remove `'openedx.features.enterprise_support.apps.EnterpriseSupportConfig'` from `INSTALLED_APPS` in `lms/envs/common.py`. After this change openedx-platform has no dependency on edx-enterprise or the consent package at import time. + +## A/C + +- `openedx/features/enterprise_support/` directory is deleted entirely from openedx-platform. +- `'openedx.features.enterprise_support.apps.EnterpriseSupportConfig'` is removed from `INSTALLED_APPS` in `lms/envs/common.py`. +- No remaining `from openedx.features.enterprise_support` import exists anywhere in openedx-platform outside the now-deleted directory. +- Test suite passes without the enterprise_support module installed. diff --git a/docs/pluginification/epics/17_enterprise_support_to_edx_enterprise/EPIC.md b/docs/pluginification/epics/17_enterprise_support_to_edx_enterprise/EPIC.md new file mode 100644 index 0000000000..31f36847ea --- /dev/null +++ b/docs/pluginification/epics/17_enterprise_support_to_edx_enterprise/EPIC.md @@ -0,0 +1,15 @@ +# Epic: Enterprise Support Module Migration + +JIRA: ENT-11576 + +## Purpose + +`openedx/features/enterprise_support/` lives inside openedx-platform but imports directly from `enterprise` and `consent` (edx-enterprise packages), keeping edx-enterprise a mandatory platform dependency even after all external callers are replaced by filter/signal hooks in epics 01–16. + +## Approach + +Move the entire `enterprise_support` package into edx-enterprise under `enterprise/platform_support/`. Epics 01–16 leave deferred imports in edx-enterprise plugin steps pointing to `openedx.features.enterprise_support`; this epic atomically replaces those with internal paths. Delete `openedx/features/enterprise_support/` from openedx-platform and remove its `INSTALLED_APPS` entry. Add signal handler activations (previously in `EnterpriseSupportConfig.ready()`) to `EnterpriseConfig.ready()`. + +## Blocking Epics + +Blocked by all epics 01–16. Every external caller of enterprise_support must be replaced by a hook before this epic ships, because this epic deletes the module from openedx-platform. diff --git a/docs/pluginification/epics/18_plugin_registration/01_edx-enterprise.diff b/docs/pluginification/epics/18_plugin_registration/01_edx-enterprise.diff new file mode 100644 index 0000000000..a9768124bc --- /dev/null +++ b/docs/pluginification/epics/18_plugin_registration/01_edx-enterprise.diff @@ -0,0 +1,528 @@ +diff --git a/enterprise/apps.py b/enterprise/apps.py +--- a/enterprise/apps.py ++++ b/enterprise/apps.py +@@ -1,6 +1,8 @@ + """ + Enterprise Django application initialization. + """ + + from django.apps import AppConfig, apps + from django.conf import settings + + from enterprise.constants import USER_POST_SAVE_DISPATCH_UID + + + class EnterpriseConfig(AppConfig): + """ + Configuration for the enterprise Django application. + """ + + name = "enterprise" ++ ++ # Plugin app configuration for the openedx plugin framework. ++ # Follows the naming convention established by openedx/core/djangoapps/password_policy/. ++ plugin_app = { ++ "settings_config": { ++ "lms.djangoapp": { ++ "production": {"relative_path": "settings.production"}, ++ "common": {"relative_path": "settings.common"}, ++ "test": {"relative_path": "settings.test"}, ++ }, ++ }, ++ "url_config": { ++ "lms.djangoapp": { ++ "namespace": "enterprise", ++ "regex": r"^enterprise/", ++ "relative_path": "urls", ++ }, ++ }, ++ } ++ + valid_image_extensions = [".png", ] +diff --git a/enterprise/settings/__init__.py b/enterprise/settings/__init__.py +--- a/enterprise/settings/__init__.py ++++ b/enterprise/settings/__init__.py +@@ -0,0 +1,3 @@ ++""" ++Settings package for edx-enterprise's enterprise app plugin configuration. ++""" +diff --git a/enterprise/settings/common.py b/enterprise/settings/common.py +new file mode 100644 +--- /dev/null ++++ b/enterprise/settings/common.py +@@ -0,0 +1,157 @@ ++""" ++Common plugin settings for the enterprise Django app. ++ ++This module is loaded by the openedx plugin framework during LMS startup for the ++'common' settings type. It populates enterprise-specific settings as defaults so ++that operator-supplied values always take precedence. ++""" ++ ++ ++def plugin_settings(settings): ++ """ ++ Inject enterprise-specific settings into the LMS Django settings. ++ ++ Called by the openedx plugin framework (edx-django-utils PluginSettings) for each ++ settings environment. Uses setdefault so operator-configured values are never ++ overwritten. ++ """ ++ # ------------------------------------------------------------------------- ++ # Derived base URL ++ # ------------------------------------------------------------------------- ++ lms_root = getattr(settings, 'LMS_ROOT_URL', 'http://localhost:8000') ++ ++ # ------------------------------------------------------------------------- ++ # Core ENTERPRISE_* settings defaults (previously in lms/envs/common.py) ++ # ------------------------------------------------------------------------- ++ settings.setdefault('ENABLE_ENTERPRISE_INTEGRATION', False) ++ settings.setdefault('ALLOW_ADMIN_ENTERPRISE_COURSE_ENROLLMENT_DELETION', False) ++ ++ settings.setdefault( ++ 'ENTERPRISE_PUBLIC_ENROLLMENT_API_URL', ++ f'{lms_root}/api/enrollment/v1/', ++ ) ++ settings.setdefault('ENTERPRISE_COURSE_ENROLLMENT_AUDIT_MODES', ['audit', 'honor']) ++ settings.setdefault('ENTERPRISE_SUPPORT_URL', '') ++ settings.setdefault('ENTERPRISE_CUSTOMER_SUCCESS_EMAIL', 'customersuccess@edx.org') ++ settings.setdefault('ENTERPRISE_INTEGRATIONS_EMAIL', 'enterprise-integrations@edx.org') ++ settings.setdefault( ++ 'ENTERPRISE_API_URL', ++ f'{lms_root}/enterprise/api/v1/', ++ ) ++ settings.setdefault('ENTERPRISE_CUSTOMER_LOGO_IMAGE_SIZE', 512) ++ settings.setdefault('ENTERPRISE_ALL_SERVICE_USERNAMES', [ ++ 'ecommerce_worker', ++ 'enterprise_worker', ++ ]) ++ settings.setdefault( ++ 'ENTERPRISE_PLATFORM_WELCOME_TEMPLATE', ++ 'Welcome to {platform_name}.', ++ ) ++ settings.setdefault( ++ 'ENTERPRISE_SPECIFIC_BRANDED_WELCOME_TEMPLATE', ++ 'Welcome, {username}, to {platform_name} — powered by {enterprise_name}.', ++ ) ++ settings.setdefault( ++ 'ENTERPRISE_PROXY_LOGIN_WELCOME_TEMPLATE', ++ 'Welcome to {platform_name} via {enterprise_name}.', ++ ) ++ settings.setdefault('ENTERPRISE_TAGLINE', '') ++ settings.setdefault('ENTERPRISE_EXCLUDED_REGISTRATION_FIELDS', set()) ++ settings.setdefault('ENTERPRISE_VSF_UUID', None) ++ settings.setdefault('ENTERPRISE_MANUAL_REPORTING_CUSTOMER_UUIDS', []) + ++ # ------------------------------------------------------------------------- ++ # Middleware ++ # ------------------------------------------------------------------------- ++ _middleware = 'enterprise.middleware.EnterpriseLanguagePreferenceMiddleware' ++ if hasattr(settings, 'MIDDLEWARE') and _middleware not in settings.MIDDLEWARE: ++ settings.MIDDLEWARE = list(settings.MIDDLEWARE) + [_middleware] ++ ++ # ------------------------------------------------------------------------- ++ # SYSTEM_WIDE_ROLE_CLASSES — enterprise role mappings ++ # ------------------------------------------------------------------------- ++ from enterprise.constants import ( # pylint: disable=import-outside-toplevel ++ ENTERPRISE_ADMIN_ROLE, ++ ENTERPRISE_CATALOG_ADMIN_ROLE, ++ ENTERPRISE_DASHBOARD_ADMIN_ROLE, ++ ENTERPRISE_ENROLLMENT_API_ADMIN_ROLE, ++ ENTERPRISE_FULFILLMENT_OPERATOR_ROLE, ++ ENTERPRISE_LEARNER_ROLE, ++ ENTERPRISE_OPERATOR_ROLE, ++ ENTERPRISE_REPORTING_CONFIG_ADMIN_ROLE, ++ ENTERPRISE_SSO_ORCHESTRATOR_OPERATOR_ROLE, ++ PROVISIONING_ENTERPRISE_CUSTOMER_ADMIN_ROLE, ++ PROVISIONING_PENDING_ENTERPRISE_CUSTOMER_ADMIN_ROLE, ++ SYSTEM_ENTERPRISE_PROVISIONING_ADMIN_ROLE, ++ DEFAULT_ENTERPRISE_ENROLLMENT_INTENTIONS_ROLE, ++ ) ++ ++ enterprise_role_mappings = { ++ ENTERPRISE_LEARNER_ROLE: [ ++ DEFAULT_ENTERPRISE_ENROLLMENT_INTENTIONS_ROLE, ++ 'enterprise.rules.HasLearnerAccess', ++ ], ++ ENTERPRISE_ADMIN_ROLE: [ ++ 'enterprise.rules.HasAdminAccess', ++ ], ++ ENTERPRISE_CATALOG_ADMIN_ROLE: [ ++ 'enterprise.rules.HasCatalogAdminAccess', ++ ], ++ ENTERPRISE_DASHBOARD_ADMIN_ROLE: [ ++ 'enterprise.rules.HasDashboardAdminAccess', ++ ], ++ ENTERPRISE_ENROLLMENT_API_ADMIN_ROLE: [ ++ 'enterprise.rules.HasEnrollmentApiAdminAccess', ++ ], ++ ENTERPRISE_FULFILLMENT_OPERATOR_ROLE: [ ++ 'enterprise.rules.HasFulfillmentOperatorAccess', ++ ], ++ ENTERPRISE_OPERATOR_ROLE: [ ++ 'enterprise.rules.HasOperatorAccess', ++ ], ++ ENTERPRISE_REPORTING_CONFIG_ADMIN_ROLE: [ ++ 'enterprise.rules.HasReportingConfigAdminAccess', ++ ], ++ ENTERPRISE_SSO_ORCHESTRATOR_OPERATOR_ROLE: [ ++ 'enterprise.rules.HasSSOOrchestratorOperatorAccess', ++ ], ++ SYSTEM_ENTERPRISE_PROVISIONING_ADMIN_ROLE: [ ++ 'enterprise.rules.HasProvisioningAdminAccess', ++ ], ++ PROVISIONING_ENTERPRISE_CUSTOMER_ADMIN_ROLE: [ ++ 'enterprise.rules.HasProvisioningCustomerAdminAccess', ++ ], ++ PROVISIONING_PENDING_ENTERPRISE_CUSTOMER_ADMIN_ROLE: [ ++ 'enterprise.rules.HasProvisioningPendingCustomerAdminAccess', ++ ], ++ } ++ ++ if hasattr(settings, 'SYSTEM_WIDE_ROLE_CLASSES'): ++ for role, classes in enterprise_role_mappings.items(): ++ if role not in settings.SYSTEM_WIDE_ROLE_CLASSES: ++ settings.SYSTEM_WIDE_ROLE_CLASSES[role] = classes ++ ++ # ------------------------------------------------------------------------- ++ # SOCIAL_AUTH_PIPELINE — enterprise TPA pipeline stages (epic 07) ++ # ------------------------------------------------------------------------- ++ enterprise_pipeline_steps = [ ++ 'enterprise.tpa_pipeline.enterprise_associate_by_email', ++ ] ++ if hasattr(settings, 'SOCIAL_AUTH_PIPELINE'): ++ existing = list(settings.SOCIAL_AUTH_PIPELINE) ++ for step in enterprise_pipeline_steps: ++ if step not in existing: ++ existing.append(step) ++ settings.SOCIAL_AUTH_PIPELINE = existing ++ ++ # ------------------------------------------------------------------------- ++ # OPEN_EDX_FILTERS_CONFIG — register enterprise filter pipeline steps ++ # All filter types used by the enterprise plugin are registered here. ++ # This replaces the per-filter entries previously defined in lms/envs/common.py ++ # (removed in epic 18's openedx-platform PR). ++ # ------------------------------------------------------------------------- ++ _enterprise_filter_steps = { ++ 'org.openedx.learning.grade.context.requested.v1': [ ++ 'enterprise.filters.grades.GradeEventContextEnricher', ++ ], ++ 'org.openedx.learning.account.settings.read_only_fields.requested.v1': [ ++ 'enterprise.filters.accounts.AccountSettingsReadOnlyFieldsStep', ++ ], ++ 'org.openedx.learning.discount.eligibility.check.requested.v1': [ ++ 'enterprise.filters.discounts.DiscountEligibilityStep', ++ ], ++ 'org.openedx.learning.courseware.view.redirect_url.requested.v1': [ ++ 'enterprise.filters.courseware.ConsentRedirectStep', ++ 'enterprise.filters.courseware.LearnerPortalRedirectStep', ++ ], ++ 'org.openedx.learning.logistration.context.requested.v1': [ ++ 'enterprise.filters.logistration.LogistrationContextEnricher', ++ ], ++ 'org.openedx.learning.auth.post_login.redirect_url.requested.v1': [ ++ 'enterprise.filters.logistration.PostLoginEnterpriseRedirect', ++ ], ++ 'org.openedx.learning.dashboard.render.started.v1': [ ++ 'enterprise.filters.dashboard.DashboardContextEnricher', ++ ], ++ 'org.openedx.learning.course.enrollment.started.v1': [ ++ 'enterprise.filters.enrollment.EnterpriseEnrollmentPostProcessor', ++ ], ++ 'org.openedx.learning.course_mode.checkout.started.v1': [ ++ 'enterprise.filters.course_modes.CheckoutEnterpriseContextInjector', ++ ], ++ 'org.openedx.learning.support.contact.context.requested.v1': [ ++ 'enterprise.filters.support.SupportContactEnterpriseTagInjector', ++ ], ++ 'org.openedx.learning.support.enrollment.data.requested.v1': [ ++ 'enterprise.filters.support.SupportEnterpriseEnrollmentDataInjector', ++ ], ++ } ++ if not hasattr(settings, 'OPEN_EDX_FILTERS_CONFIG'): ++ settings.OPEN_EDX_FILTERS_CONFIG = {} ++ for filter_type, steps in _enterprise_filter_steps.items(): ++ if filter_type not in settings.OPEN_EDX_FILTERS_CONFIG: ++ settings.OPEN_EDX_FILTERS_CONFIG[filter_type] = {'fail_silently': True, 'pipeline': []} ++ existing_steps = list(settings.OPEN_EDX_FILTERS_CONFIG[filter_type].get('pipeline', [])) ++ for step in steps: ++ if step not in existing_steps: ++ existing_steps.append(step) ++ settings.OPEN_EDX_FILTERS_CONFIG[filter_type]['pipeline'] = existing_steps ++ ++ # ------------------------------------------------------------------------- ++ # Pluggable overrides (epics 12, 13, 16) ++ # ------------------------------------------------------------------------- ++ settings.OVERRIDE_LEARNER_HOME_GET_ENTERPRISE_CUSTOMER = ( ++ 'enterprise.overrides.learner_home.enterprise_get_enterprise_customer' ++ ) ++ settings.OVERRIDE_COURSE_HOME_PROGRESS_USERNAME = ( ++ 'enterprise.overrides.course_home_progress.enterprise_obfuscated_username' ++ ) ++ settings.OVERRIDE_PROGRAMS_GET_ENTERPRISE_COURSE_ENROLLMENTS = ( ++ 'enterprise.overrides.programs.enterprise_get_enterprise_course_enrollments' ++ ) +diff --git a/enterprise/settings/production.py b/enterprise/settings/production.py +new file mode 100644 +--- /dev/null ++++ b/enterprise/settings/production.py +@@ -0,0 +1,10 @@ ++""" ++Production plugin settings for the enterprise Django app. ++ ++Inherits all defaults from common.py. Production-specific overrides can be ++added here if needed. ++""" ++from enterprise.settings.common import plugin_settings # noqa: F401 +diff --git a/consent/apps.py b/consent/apps.py +--- a/consent/apps.py ++++ b/consent/apps.py +@@ -1,11 +1,29 @@ + """edX Enterprise's Consent application. + + This application provides a generic Consent API that lets the + user get, provide, and revoke consent to an enterprise customer + at some gate. + """ + + from django.apps import AppConfig + + + class ConsentConfig(AppConfig): + """Configuration for edX Enterprise's Consent application.""" + + name = 'consent' + verbose_name = "Enterprise Consent" ++ ++ # Plugin app configuration for the openedx plugin framework. ++ plugin_app = { ++ "settings_config": { ++ "lms.djangoapp": { ++ "production": {"relative_path": "settings.production"}, ++ "common": {"relative_path": "settings.common"}, ++ "test": {"relative_path": "settings.test"}, ++ }, ++ }, ++ "url_config": { ++ "lms.djangoapp": { ++ "namespace": "consent", ++ "regex": r"^consent/", ++ "relative_path": "urls", ++ }, ++ }, ++ } +diff --git a/consent/settings/__init__.py b/consent/settings/__init__.py +new file mode 100644 +--- /dev/null ++++ b/consent/settings/__init__.py +@@ -0,0 +1,3 @@ ++""" ++Settings package for edx-enterprise's consent app plugin configuration. ++""" +diff --git a/consent/settings/common.py b/consent/settings/common.py +new file mode 100644 +--- /dev/null ++++ b/consent/settings/common.py +@@ -0,0 +1,27 @@ ++""" ++Common plugin settings for the consent Django app. ++ ++This module is loaded by the openedx plugin framework during LMS startup for the ++'common' settings type. It populates consent-specific settings as defaults. ++""" ++ ++ ++def plugin_settings(settings): ++ """ ++ Inject consent-specific settings into the LMS Django settings. ++ ++ Called by the openedx plugin framework for each settings environment. ++ Uses setdefault so operator-configured values are never overwritten. ++ """ ++ lms_root = getattr(settings, 'LMS_ROOT_URL', 'http://localhost:8000') ++ ++ settings.setdefault( ++ 'ENTERPRISE_CONSENT_API_URL', ++ f'{lms_root}/consent/api/v1/', ++ ) +diff --git a/consent/settings/production.py b/consent/settings/production.py +new file mode 100644 +--- /dev/null ++++ b/consent/settings/production.py +@@ -0,0 +1,7 @@ ++""" ++Production plugin settings for the consent Django app. ++""" ++from consent.settings.common import plugin_settings # noqa: F401 +diff --git a/enterprise_support/apps.py b/enterprise_support/apps.py +new file mode 100644 +--- /dev/null ++++ b/enterprise_support/apps.py +@@ -0,0 +1,31 @@ ++""" ++Configuration for the enterprise_support Django app. ++ ++This app is migrated from openedx-platform/openedx/features/enterprise_support/ ++as part of epic 17. It provides utility functions and support tooling used by ++the enterprise and consent plugins. ++""" ++ ++from django.apps import AppConfig ++ ++ ++class EnterpriseSupportConfig(AppConfig): ++ """Configuration for the enterprise_support Django app.""" ++ ++ name = 'enterprise_support' ++ verbose_name = "Enterprise Support" ++ ++ # Plugin app configuration for the openedx plugin framework. ++ plugin_app = { ++ "settings_config": { ++ "lms.djangoapp": { ++ "production": {"relative_path": "settings.production"}, ++ "common": {"relative_path": "settings.common"}, ++ "test": {"relative_path": "settings.test"}, ++ }, ++ }, ++ } ++ ++ def ready(self): ++ """Activate signal handlers moved from enterprise_support/signals.py.""" ++ from enterprise_support import signal_handlers # pylint: disable=import-outside-toplevel # noqa: F401 +diff --git a/enterprise_support/settings/__init__.py b/enterprise_support/settings/__init__.py +new file mode 100644 +--- /dev/null ++++ b/enterprise_support/settings/__init__.py +@@ -0,0 +1,3 @@ ++""" ++Settings package for edx-enterprise's enterprise_support app plugin configuration. ++""" +diff --git a/enterprise_support/settings/common.py b/enterprise_support/settings/common.py +new file mode 100644 +--- /dev/null ++++ b/enterprise_support/settings/common.py +@@ -0,0 +1,31 @@ ++""" ++Common plugin settings for the enterprise_support Django app. ++ ++This module is loaded by the openedx plugin framework during LMS startup for the ++'common' settings type. It populates settings consumed by enterprise_support ++utility functions. ++""" ++ ++ ++def plugin_settings(settings): ++ """ ++ Inject enterprise_support-specific settings into the LMS Django settings. ++ ++ Called by the openedx plugin framework for each settings environment. ++ Uses setdefault so operator-configured values are never overwritten. ++ """ ++ # ENTERPRISE_READONLY_ACCOUNT_FIELDS is consumed by ++ # enterprise_support/utils.py::get_enterprise_readonly_account_fields. ++ settings.setdefault('ENTERPRISE_READONLY_ACCOUNT_FIELDS', [ ++ 'username', ++ 'name', ++ 'email', ++ 'country', ++ ]) ++ ++ # ENTERPRISE_CUSTOMER_COOKIE_NAME is consumed by enterprise_support cookie ++ # handling utilities. ++ settings.setdefault('ENTERPRISE_CUSTOMER_COOKIE_NAME', 'enterprise_customer_uuid') +diff --git a/enterprise_support/settings/production.py b/enterprise_support/settings/production.py +new file mode 100644 +--- /dev/null ++++ b/enterprise_support/settings/production.py +@@ -0,0 +1,7 @@ ++""" ++Production plugin settings for the enterprise_support Django app. ++""" ++from enterprise_support.settings.common import plugin_settings # noqa: F401 +diff --git a/enterprise/tests/test_plugin_settings.py b/enterprise/tests/test_plugin_settings.py +new file mode 100644 +--- /dev/null ++++ b/enterprise/tests/test_plugin_settings.py +@@ -0,0 +1,71 @@ ++""" ++Tests for enterprise/settings/common.py plugin_settings callback. ++""" ++import pytest ++ ++ ++class MockSettings: ++ """Minimal settings object for testing plugin_settings.""" ++ ++ def __init__(self, **kwargs): ++ for key, value in kwargs.items(): ++ setattr(self, key, value) ++ self._store = dict(kwargs) ++ ++ def setdefault(self, key, value): ++ if not hasattr(self, key): ++ setattr(self, key, value) ++ self._store[key] = value + + ++class TestEnterprisePluginSettings: ++ """Verify enterprise/settings/common.py sets required defaults.""" ++ ++ def test_sets_enterprise_api_url_from_lms_root(self): ++ mock = MockSettings(LMS_ROOT_URL='https://lms.example.com') ++ from enterprise.settings.common import plugin_settings ++ plugin_settings(mock) ++ assert mock.ENTERPRISE_API_URL == 'https://lms.example.com/enterprise/api/v1/' ++ ++ def test_sets_public_enrollment_api_url_from_lms_root(self): ++ mock = MockSettings(LMS_ROOT_URL='https://lms.example.com') ++ from enterprise.settings.common import plugin_settings ++ plugin_settings(mock) ++ assert mock.ENTERPRISE_PUBLIC_ENROLLMENT_API_URL == 'https://lms.example.com/api/enrollment/v1/' ++ ++ def test_does_not_override_existing_settings(self): ++ mock = MockSettings(LMS_ROOT_URL='https://lms.example.com', ENTERPRISE_TAGLINE='Custom tagline') ++ from enterprise.settings.common import plugin_settings ++ plugin_settings(mock) ++ assert mock.ENTERPRISE_TAGLINE == 'Custom tagline' ++ ++ def test_appends_middleware(self): ++ mock = MockSettings(LMS_ROOT_URL='http://localhost:8000', MIDDLEWARE=['django.middleware.common.CommonMiddleware']) ++ from enterprise.settings.common import plugin_settings ++ plugin_settings(mock) ++ assert 'enterprise.middleware.EnterpriseLanguagePreferenceMiddleware' in mock.MIDDLEWARE ++ ++ def test_does_not_duplicate_middleware(self): ++ mock = MockSettings( ++ LMS_ROOT_URL='http://localhost:8000', ++ MIDDLEWARE=['enterprise.middleware.EnterpriseLanguagePreferenceMiddleware'], ++ ) ++ from enterprise.settings.common import plugin_settings ++ plugin_settings(mock) ++ assert mock.MIDDLEWARE.count('enterprise.middleware.EnterpriseLanguagePreferenceMiddleware') == 1 + + ++class TestConsentPluginSettings: ++ """Verify consent/settings/common.py sets required defaults.""" + ++ def test_sets_consent_api_url_from_lms_root(self): ++ mock = MockSettings(LMS_ROOT_URL='https://lms.example.com') ++ from consent.settings.common import plugin_settings ++ plugin_settings(mock) ++ assert mock.ENTERPRISE_CONSENT_API_URL == 'https://lms.example.com/consent/api/v1/' + + ++class TestEnterpriseSupportPluginSettings: ++ """Verify enterprise_support/settings/common.py sets required defaults.""" + ++ def test_sets_readonly_account_fields(self): ++ mock = MockSettings() ++ from enterprise_support.settings.common import plugin_settings ++ plugin_settings(mock) ++ assert mock.ENTERPRISE_READONLY_ACCOUNT_FIELDS == ['username', 'name', 'email', 'country'] + ++ def test_sets_cookie_name(self): ++ mock = MockSettings() ++ from enterprise_support.settings.common import plugin_settings ++ plugin_settings(mock) ++ assert mock.ENTERPRISE_CUSTOMER_COOKIE_NAME == 'enterprise_customer_uuid' diff --git a/docs/pluginification/epics/18_plugin_registration/01_edx-enterprise.md b/docs/pluginification/epics/18_plugin_registration/01_edx-enterprise.md new file mode 100644 index 0000000000..29735a5ee1 --- /dev/null +++ b/docs/pluginification/epics/18_plugin_registration/01_edx-enterprise.md @@ -0,0 +1,20 @@ +# [edx-enterprise] Register enterprise, consent, and enterprise_support as openedx plugins + +Blocked by: None + +Add `plugin_app` configuration to `EnterpriseConfig` in `enterprise/apps.py`, `ConsentConfig` in `consent/apps.py`, and a new `EnterpriseSupportConfig` in `enterprise_support/apps.py` to register each as a proper openedx plugin for the LMS. Following the naming convention from `openedx-platform/openedx/core/djangoapps/password_policy/`, implement `plugin_settings(settings)` in each app's `settings/common.py` file. The `enterprise` app's `plugin_settings` populates all core `ENTERPRISE_*` settings (using `settings.setdefault`), appends enterprise role classes to `SYSTEM_WIDE_ROLE_CLASSES`, appends `EnterpriseLanguagePreferenceMiddleware` to `MIDDLEWARE`, registers all filter pipeline steps in `OPEN_EDX_FILTERS_CONFIG`, and configures all pluggable override settings. The `consent` app's `plugin_settings` populates `ENTERPRISE_CONSENT_API_URL`. The `enterprise_support` app's `plugin_settings` populates `ENTERPRISE_READONLY_ACCOUNT_FIELDS` and `ENTERPRISE_CUSTOMER_COOKIE_NAME`. + +## A/C + +- `EnterpriseConfig.plugin_app` in `enterprise/apps.py` declares `ProjectType.LMS` settings config pointing to `enterprise.settings.common` and URL config for `enterprise.urls`. +- `ConsentConfig.plugin_app` in `consent/apps.py` declares `ProjectType.LMS` settings config pointing to `consent.settings.common` and URL config for `consent.urls`. +- `EnterpriseSupportConfig.plugin_app` in `enterprise_support/apps.py` declares `ProjectType.LMS` settings config pointing to `enterprise_support.settings.common`. +- `plugin_settings(settings)` in `enterprise/settings/common.py` uses `setdefault` to populate all core `ENTERPRISE_*` settings previously defined in `lms/envs/common.py` (excluding `ENTERPRISE_CONSENT_API_URL`, `ENTERPRISE_READONLY_ACCOUNT_FIELDS`, and `ENTERPRISE_CUSTOMER_COOKIE_NAME`). +- `plugin_settings` in `enterprise/settings/common.py` appends enterprise role classes to `SYSTEM_WIDE_ROLE_CLASSES` using edx-enterprise's own constants (no import of platform constants needed). +- `plugin_settings` in `enterprise/settings/common.py` appends `'enterprise.middleware.EnterpriseLanguagePreferenceMiddleware'` to `MIDDLEWARE`. +- `plugin_settings` in `enterprise/settings/common.py` derives `ENTERPRISE_API_URL` and `ENTERPRISE_PUBLIC_ENROLLMENT_API_URL` from `settings.LMS_ROOT_URL` when available. +- `plugin_settings` in `enterprise/settings/common.py` registers all filter pipeline steps in `OPEN_EDX_FILTERS_CONFIG` for all filter types used by epics 01–16 (grades, account readonly fields, discount eligibility, courseware redirects, logistration, dashboard, enrollment, course modes, support contact, support enrollment). If `OPEN_EDX_FILTERS_CONFIG` does not exist on `settings`, it is initialised to `{}`. If a filter type is not yet present in `OPEN_EDX_FILTERS_CONFIG`, it is created with `fail_silently=True` and an empty pipeline before the enterprise steps are appended. +- `plugin_settings` in `enterprise/settings/common.py` configures all pluggable override settings that were added by epics 1-16. +- `plugin_settings(settings)` in `consent/settings/common.py` uses `setdefault` to populate `ENTERPRISE_CONSENT_API_URL` derived from `settings.LMS_ROOT_URL`. +- `plugin_settings(settings)` in `enterprise_support/settings/common.py` uses `setdefault` to populate `ENTERPRISE_READONLY_ACCOUNT_FIELDS` and `ENTERPRISE_CUSTOMER_COOKIE_NAME`. +- Unit tests verify that each `plugin_settings` sets the expected settings as defaults and that filter pipeline steps are correctly registered even when `OPEN_EDX_FILTERS_CONFIG` is empty or absent. diff --git a/docs/pluginification/epics/18_plugin_registration/02_openedx-platform.diff b/docs/pluginification/epics/18_plugin_registration/02_openedx-platform.diff new file mode 100644 index 0000000000..95c9354781 --- /dev/null +++ b/docs/pluginification/epics/18_plugin_registration/02_openedx-platform.diff @@ -0,0 +1,176 @@ +diff --git a/lms/envs/common.py b/lms/envs/common.py +--- a/lms/envs/common.py ++++ b/lms/envs/common.py +@@ -48,15 +48,0 @@ from enterprise.constants import ( +-from enterprise.constants import ( +- ENTERPRISE_ADMIN_ROLE, +- ENTERPRISE_LEARNER_ROLE, +- ENTERPRISE_CATALOG_ADMIN_ROLE, +- ENTERPRISE_DASHBOARD_ADMIN_ROLE, +- ENTERPRISE_ENROLLMENT_API_ADMIN_ROLE, +- ENTERPRISE_FULFILLMENT_OPERATOR_ROLE, +- ENTERPRISE_REPORTING_CONFIG_ADMIN_ROLE, +- ENTERPRISE_SSO_ORCHESTRATOR_OPERATOR_ROLE, +- ENTERPRISE_OPERATOR_ROLE, +- SYSTEM_ENTERPRISE_PROVISIONING_ADMIN_ROLE, +- PROVISIONING_ENTERPRISE_CUSTOMER_ADMIN_ROLE, +- PROVISIONING_PENDING_ENTERPRISE_CUSTOMER_ADMIN_ROLE, +- DEFAULT_ENTERPRISE_ENROLLMENT_INTENTIONS_ROLE, +-) + +@@ -518,4 +504,0 @@ ENABLE_ENTERPRISE_INTEGRATION = False +-# .. toggle_name: settings.ENABLE_ENTERPRISE_INTEGRATION +-ENABLE_ENTERPRISE_INTEGRATION = False + +@@ -598,4 +580,0 @@ ALLOW_ADMIN_ENTERPRISE_COURSE_ENROLLMENT_DELETION = False +-# .. toggle_name: settings.ALLOW_ADMIN_ENTERPRISE_COURSE_ENROLLMENT_DELETION +-ALLOW_ADMIN_ENTERPRISE_COURSE_ENROLLMENT_DELETION = False + +@@ -1171,3 +1153,0 @@ MIDDLEWARE = [ +- 'enterprise.middleware.EnterpriseLanguagePreferenceMiddleware', + +@@ -,N +,1 @@ OPEN_EDX_FILTERS_CONFIG = { +-OPEN_EDX_FILTERS_CONFIG = { +- "org.openedx.learning.grade.context.requested.v1": { +- "fail_silently": True, +- "pipeline": ["enterprise.filters.grades.GradeEventContextEnricher"], +- }, +- "org.openedx.learning.account.settings.read_only_fields.requested.v1": { +- "fail_silently": True, +- "pipeline": ["enterprise.filters.accounts.AccountSettingsReadOnlyFieldsStep"], +- }, +- "org.openedx.learning.discount.eligibility.check.requested.v1": { +- "fail_silently": True, +- "pipeline": ["enterprise.filters.discounts.DiscountEligibilityStep"], +- }, +- "org.openedx.learning.courseware.view.redirect_url.requested.v1": { +- "fail_silently": True, +- "pipeline": [ +- "enterprise.filters.courseware.ConsentRedirectStep", +- "enterprise.filters.courseware.LearnerPortalRedirectStep", +- ], +- }, +- "org.openedx.learning.logistration.context.requested.v1": { +- "fail_silently": True, +- "pipeline": ["enterprise.filters.logistration.LogistrationContextEnricher"], +- }, +- "org.openedx.learning.auth.post_login.redirect_url.requested.v1": { +- "fail_silently": True, +- "pipeline": ["enterprise.filters.logistration.PostLoginEnterpriseRedirect"], +- }, +- "org.openedx.learning.dashboard.render.started.v1": { +- "fail_silently": True, +- "pipeline": ["enterprise.filters.dashboard.DashboardContextEnricher"], +- }, +- "org.openedx.learning.course.enrollment.started.v1": { +- "fail_silently": True, +- "pipeline": ["enterprise.filters.enrollment.EnterpriseEnrollmentPostProcessor"], +- }, +- "org.openedx.learning.course_mode.checkout.started.v1": { +- "fail_silently": True, +- "pipeline": ["enterprise.filters.course_modes.CheckoutEnterpriseContextInjector"], +- }, +- "org.openedx.learning.support.contact.context.requested.v1": { +- "fail_silently": True, +- "pipeline": ["enterprise.filters.support.SupportContactEnterpriseTagInjector"], +- }, +- "org.openedx.learning.support.enrollment.data.requested.v1": { +- "fail_silently": True, +- "pipeline": ["enterprise.filters.support.SupportEnterpriseEnrollmentDataInjector"], +- }, +-} ++OPEN_EDX_FILTERS_CONFIG = {} + +diff --git a/openedx/envs/common.py b/openedx/envs/common.py +--- a/openedx/envs/common.py ++++ b/openedx/envs/common.py +@@ -765,4 +765,0 @@ OPTIONAL_APPS = [ +- # Enterprise Apps (http://github.com/openedx/edx-enterprise) +- ('enterprise', None), +- ('consent', None), + +@@ -2590,90 +2572,0 @@ # Only used if settings.ENABLE_ENTERPRISE_INTEGRATION == True. +-# Only used if settings.ENABLE_ENTERPRISE_INTEGRATION == True. +-ENTERPRISE_PUBLIC_ENROLLMENT_API_URL = Derived( +- lambda s: f'{s.LMS_ROOT_URL}/api/enrollment/v1/' +-) +-ENTERPRISE_COURSE_ENROLLMENT_AUDIT_MODES = ['audit', 'honor'] +-ENTERPRISE_SUPPORT_URL = '' +-ENTERPRISE_CUSTOMER_SUCCESS_EMAIL = "customersuccess@edx.org" +-ENTERPRISE_INTEGRATIONS_EMAIL = "enterprise-integrations@edx.org" +-ENTERPRISE_API_URL = Derived( +- lambda s: f'{s.LMS_ROOT_URL}/enterprise/api/v1/' +-) +-ENTERPRISE_CONSENT_API_URL = Derived( +- lambda s: f'{s.LMS_ROOT_URL}/consent/api/v1/' +-) +-ENTERPRISE_CUSTOMER_LOGO_IMAGE_SIZE = 512 +-ENTERPRISE_ALL_SERVICE_USERNAMES = [ +- 'ecommerce_worker', +- 'enterprise_worker', +-] +-ENTERPRISE_PLATFORM_WELCOME_TEMPLATE = _('Welcome to {platform_name}.') +-ENTERPRISE_SPECIFIC_BRANDED_WELCOME_TEMPLATE = _( +- 'Welcome, {username}, to {platform_name} — powered by {enterprise_name}.' +-) +-ENTERPRISE_PROXY_LOGIN_WELCOME_TEMPLATE = _( +- 'Welcome to {platform_name} via {enterprise_name}.' +-) +-ENTERPRISE_TAGLINE = '' +-ENTERPRISE_EXCLUDED_REGISTRATION_FIELDS = set() +-ENTERPRISE_READONLY_ACCOUNT_FIELDS = [ +- 'username', +- 'name', +- 'email', +- 'country', +-] +-ENTERPRISE_CUSTOMER_COOKIE_NAME = 'enterprise_customer_uuid' +-ENTERPRISE_VSF_UUID = None +-ENTERPRISE_MANUAL_REPORTING_CUSTOMER_UUIDS = [] + +@@ -2668,35 +2636,0 @@ SYSTEM_WIDE_ROLE_CLASSES = { +- ENTERPRISE_LEARNER_ROLE: [ +- DEFAULT_ENTERPRISE_ENROLLMENT_INTENTIONS_ROLE, +- 'enterprise.rules.HasLearnerAccess', +- ], +- ENTERPRISE_ADMIN_ROLE: [ +- 'enterprise.rules.HasAdminAccess', +- ], +- ENTERPRISE_CATALOG_ADMIN_ROLE: [ +- 'enterprise.rules.HasCatalogAdminAccess', +- ], +- ENTERPRISE_DASHBOARD_ADMIN_ROLE: [ +- 'enterprise.rules.HasDashboardAdminAccess', +- ], +- ENTERPRISE_ENROLLMENT_API_ADMIN_ROLE: [ +- 'enterprise.rules.HasEnrollmentApiAdminAccess', +- ], +- ENTERPRISE_FULFILLMENT_OPERATOR_ROLE: [ +- 'enterprise.rules.HasFulfillmentOperatorAccess', +- ], +- ENTERPRISE_OPERATOR_ROLE: [ +- 'enterprise.rules.HasOperatorAccess', +- ], +- ENTERPRISE_REPORTING_CONFIG_ADMIN_ROLE: [ +- 'enterprise.rules.HasReportingConfigAdminAccess', +- ], +- ENTERPRISE_SSO_ORCHESTRATOR_OPERATOR_ROLE: [ +- 'enterprise.rules.HasSSOOrchestratorOperatorAccess', +- ], +- SYSTEM_ENTERPRISE_PROVISIONING_ADMIN_ROLE: [ +- 'enterprise.rules.HasProvisioningAdminAccess', +- ], +- PROVISIONING_ENTERPRISE_CUSTOMER_ADMIN_ROLE: [ +- 'enterprise.rules.HasProvisioningCustomerAdminAccess', +- ], +- PROVISIONING_PENDING_ENTERPRISE_CUSTOMER_ADMIN_ROLE: [ +- 'enterprise.rules.HasProvisioningPendingCustomerAdminAccess', +- ], +diff --git a/lms/urls.py b/lms/urls.py +--- a/lms/urls.py ++++ b/lms/urls.py +@@ -879,9 +879,0 @@ if settings.FEATURES.get('ENABLE_ENTERPRISE_INTEGRATION'): +-if settings.FEATURES.get('ENABLE_ENTERPRISE_INTEGRATION'): +- urlpatterns += [ +- path('enterprise/', include('enterprise.urls', namespace='enterprise')), +- ] diff --git a/docs/pluginification/epics/18_plugin_registration/02_openedx-platform.md b/docs/pluginification/epics/18_plugin_registration/02_openedx-platform.md new file mode 100644 index 0000000000..28398a2fa2 --- /dev/null +++ b/docs/pluginification/epics/18_plugin_registration/02_openedx-platform.md @@ -0,0 +1,17 @@ +# [openedx-platform] Remove enterprise app registration and settings from platform + +Blocked by: [edx-enterprise] Register enterprise, consent, and enterprise_support as openedx plugins + +Remove all enterprise hard-coding from openedx-platform: the `from enterprise.constants import ...` block (lines 48–61), all `ENTERPRISE_*` settings (lines ~2586–3017), enterprise entries in `SYSTEM_WIDE_ROLE_CLASSES`, the `EnterpriseLanguagePreferenceMiddleware` entry from `MIDDLEWARE`, and the conditional `enterprise.urls` include from `lms/urls.py`. Also remove the `('enterprise', None)` and `('consent', None)` entries from `OPTIONAL_APPS` in `openedx/envs/common.py`. Reset `OPEN_EDX_FILTERS_CONFIG` in `lms/envs/common.py` to an empty dict — all filter pipeline step registrations are now managed by the enterprise plugin's `plugin_settings`. The plugin framework injects all of these at startup when edx-enterprise is installed. + +## A/C + +- `from enterprise.constants import ...` block (all 13 role constant imports) is removed from `lms/envs/common.py`. +- All `ENTERPRISE_*` settings are removed from `lms/envs/common.py`. +- Enterprise entries are removed from `SYSTEM_WIDE_ROLE_CLASSES` in `lms/envs/common.py`. +- `'enterprise.middleware.EnterpriseLanguagePreferenceMiddleware'` is removed from `MIDDLEWARE` in `lms/envs/common.py`. +- `OPEN_EDX_FILTERS_CONFIG` in `lms/envs/common.py` is reset to `{}` — all enterprise filter step registrations move to `enterprise/settings/common.py` `plugin_settings`. +- `('enterprise', None)` and `('consent', None)` are removed from `OPTIONAL_APPS` in `openedx/envs/common.py`. +- The conditional `enterprise.urls` include is removed from `lms/urls.py`. +- openedx-platform starts without edx-enterprise installed (enterprise features disabled). +- openedx-platform starts with edx-enterprise installed (all enterprise settings injected via `plugin_settings` across all three plugins, including filter pipeline step registrations). diff --git a/docs/pluginification/epics/18_plugin_registration/EPIC.md b/docs/pluginification/epics/18_plugin_registration/EPIC.md new file mode 100644 index 0000000000..d70da06be9 --- /dev/null +++ b/docs/pluginification/epics/18_plugin_registration/EPIC.md @@ -0,0 +1,15 @@ +# Epic: Plugin Registration + +JIRA: ENT-11577 + +## Purpose + +`lms/envs/common.py` imports enterprise role constants at module level and defines ~40 `ENTERPRISE_*` settings and enterprise middleware/role entries; `openedx/envs/common.py` lists `enterprise` and `consent` in `OPTIONAL_APPS`. The conditional enterprise URL include in `lms/urls.py` similarly requires edx-enterprise to be importable at startup. These make edx-enterprise a mandatory platform dependency regardless of the `ENABLE_ENTERPRISE_INTEGRATION` flag. + +## Approach + +Register all three Django apps in edx-enterprise (`enterprise`, `consent`, and `enterprise_support`) as proper openedx plugins by adding `plugin_app` configuration to each app's `AppConfig`. Following the naming convention established by `openedx-platform/openedx/core/djangoapps/password_policy/`, implement a `plugin_settings(settings)` callback in each app's `settings/common.py`. The `enterprise` app's `plugin_settings` populates all core `ENTERPRISE_*` settings, appends enterprise role classes to `SYSTEM_WIDE_ROLE_CLASSES`, adds `EnterpriseLanguagePreferenceMiddleware`, and registers all filter pipeline steps and pluggable overrides. The `consent` app's `plugin_settings` populates consent-specific settings (e.g. `ENTERPRISE_CONSENT_API_URL`). The `enterprise_support` app's `plugin_settings` populates settings consumed by enterprise_support utilities (e.g. `ENTERPRISE_READONLY_ACCOUNT_FIELDS`, `ENTERPRISE_CUSTOMER_COOKIE_NAME`). Remove all these entries from openedx-platform, including the `from enterprise.constants import ...` block, all `ENTERPRISE_*` settings, the `INSTALLED_APPS` entries, and the conditional URL include. + +## Blocking Epics + +Blocked by epic 17. All enterprise and enterprise_support imports must be removed from openedx-platform before the apps can be made optional. diff --git a/docs/pluginification/jira_epic_ids.txt b/docs/pluginification/jira_epic_ids.txt new file mode 100644 index 0000000000..03f371167b --- /dev/null +++ b/docs/pluginification/jira_epic_ids.txt @@ -0,0 +1,18 @@ +ENT-11563 - 01 Grades Analytics Event Enrichment +ENT-11510 - 02 User Account Readonly Fields +ENT-11564 - 03 Discount Enterprise Learner Exclusion +ENT-11473 - 04 User Retirement Enterprise Cleanup +ENT-11565 - 05 Enterprise Username Change Command +ENT-11544 - 06 DSC Courseware View Redirects +ENT-11566 - 07 Third Party Auth Enterprise Pipeline +ENT-11567 - 08 SAML Admin Enterprise Views +ENT-11568 - 09 Logistration Enterprise Context +ENT-11569 - 10 Student Dashboard Enterprise Context +ENT-11570 - 11 Enrollment API Enterprise Support +ENT-11571 - 12 Learner Home Enterprise Dashboard +ENT-11572 - 13 Course Home Progress Enterprise Name +ENT-11573 - 14 Course Modes Enterprise Customer +ENT-11574 - 15 Support Views Enterprise Context +ENT-11575 - 16 Programs API Enterprise Enrollments +ENT-11576 - 17 Enterprise Support To Edx Enterprise +ENT-11577 - 18 Plugin Registration diff --git a/docs/pluginification/prompt.md b/docs/pluginification/prompt.md new file mode 100644 index 0000000000..f1bab10ba9 --- /dev/null +++ b/docs/pluginification/prompt.md @@ -0,0 +1,10 @@ +Read CLAUDE.md, proposal.md, and jira_epic_ids.txt, then recursively evaluate +all the files under `epics/` which were generated by a previous pass of AI. + +Do not prompt me for reading files, you have my permission to perform any +read-only operations, even with python. Do not modify any files without my +permission. + +Things I want you to do: + +* All of the epics which add a filter PipelineStep fails to successfully map the new PipelineStep to the filter. In every case, the openedx-platform diff creates a mapping with an empty pipeline list. For example, epic 06 openedx-platform diff was supposed to map "org.openedx.learning.courseware.view.redirect_url.requested.v1" to the new pipeline step "enterprise.filters.courseware.ConsentRedirectStep", but instead the mapping is absent. Please fix this for all affected epics. diff --git a/docs/pluginification/proposal.md b/docs/pluginification/proposal.md new file mode 100644 index 0000000000..6d5d0a6a7e --- /dev/null +++ b/docs/pluginification/proposal.md @@ -0,0 +1,830 @@ +# Enterprise Plugin Migration: Proposed Epics + +## Instructions for Second-Pass AI + +Read CLAUDE.md in full before generating any tickets or diffs. This proposal lists all proposed +epics with exact filenames and function names for the enterprise logic to be migrated. Each epic +corresponds to one directory under `epics/`. For each epic, produce: + +1. `EPIC.md` — purpose (1-2 sentences) + selected approach (1-3 sentences) +2. One or more story ticket `.md` files per repo affected (`01_openedx-platform.md`, + `02_openedx-filters.md`, `03_edx-enterprise.md`, etc.) +3. A sibling `.diff` for each `.md` ticket — a complete, compilable implementation diff + +Story tickets must follow the format in CLAUDE.md: ticket name as H1 header (prefixed with +`[] `), any blocking ticket, one-paragraph description, and A/C section. + +Diffs should include test file changes (modify existing tests to not import enterprise/consent +directly, or update them to mock the new filter/signal interface). Diffs must be complete — +do not produce placeholder or stub diffs. + +### Important constraints from CLAUDE.md: + +- Do NOT use "Enterprise" in filter class names, filter types, or filter exceptions, and avoid + mentioning enterprise in openedx-filters docstrings/comments. +- All enterprise/consent imports must be removed from openedx-platform — no try/except + compatibility shims. +- Enterprise_support functions used by multiple epics can be kept in enterprise_support + temporarily and called by edx-enterprise plugin steps until they are fully replaced. + +### Repos and key reference files: + +- `openedx-platform/` — platform to be decoupled +- `openedx-filters/` — where new filter class definitions live + (`openedx_filters/learning/filters.py`) +- `edx-enterprise/` — where new PipelineStep and signal handler implementations live +- `edx-django-utils/edx_django_utils/plugins/plugin_settings.py` — for settings epic +- Existing filter config pattern: `openedx-platform/lms/envs/production.py` lines 73-87 + (exclusion list) and lines 271-277 (TRACKING_BACKENDS merge logic) +- Retirement signals: `openedx-platform/openedx/core/djangoapps/user_api/accounts/signals.py` + +--- + +## Proposed Epics + +--- + +### 01_grades_analytics_event_enrichment + +**Current location:** +- `openedx-platform/lms/djangoapps/grades/events.py` + - `get_grade_data_event_context()` — line 30 imports + `from openedx.features.enterprise_support.context import get_enterprise_event_context` + - Lines ~169-170: `context.update(get_enterprise_event_context(user_id, course_id))` +- `openedx-platform/openedx/features/enterprise_support/context.py` + - `get_enterprise_event_context(user_id, course_id)` — returns + `{'enterprise_uuid': ''}` or `{}` for non-enterprise learners + +**Migration approach:** New openedx-filter (data must flow back to the caller). + +**New filter:** `GradeEventContextRequested` in `openedx_filters/learning/filters.py` +- Signature: `run_filter(context, user_id, course_id)` → returns updated context dict +- Filter type string: `"org.openedx.learning.grade.context.requested.v1"` +- No exception class needed (fail_silently=True) +- Add to `OPEN_EDX_FILTERS_CONFIG` in `lms/envs/common.py` + +**edx-enterprise step:** `GradeEventContextEnricher(PipelineStep)` in edx-enterprise +- Queries `EnterpriseCustomerUser` to look up enterprise UUID for user_id +- Returns `{"context": {**context, "enterprise_uuid": str(uuid)}}` + +**Repos touched:** openedx-filters (new filter), openedx-platform (replace import + call), +edx-enterprise (new pipeline step) + +**Dependencies:** None. Good first epic. + +--- + +### 02_user_account_readonly_fields + +**Current location:** +- `openedx-platform/openedx/core/djangoapps/user_api/accounts/api.py` + - `update_account_settings(requesting_user, update, username=None)` function + - Line ~41: `from openedx.features.enterprise_support.utils import get_enterprise_readonly_account_fields` + - Line ~200: `get_enterprise_readonly_account_fields(user)` — returns list of readonly field names +- `openedx-platform/openedx/features/enterprise_support/utils.py` + - `get_enterprise_readonly_account_fields(user)` — checks if user is enterprise SSO learner + and returns `settings.ENTERPRISE_READONLY_ACCOUNT_FIELDS` if so, else `[]` + +**Migration approach:** New openedx-filter (AccountSettingsReadOnlyFieldsRequested). +Explicitly called out in CLAUDE.md notes. Do NOT use the existing +`AccountSettingsRenderStarted` filter. + +**New filter:** `AccountSettingsReadOnlyFieldsRequested` in `openedx_filters/learning/filters.py` +- Signature: `run_filter(editable_fields, user)` → returns modified `editable_fields` list +- Filter type: `"org.openedx.learning.account.settings.read_only_fields.requested.v1"` +- No exception class needed + +**edx-enterprise step:** Checks if user is linked to an enterprise SSO IdP; if so, removes +fields in `settings.ENTERPRISE_READONLY_ACCOUNT_FIELDS` from `editable_fields`. + +**Repos touched:** openedx-filters (new filter), openedx-platform (replace import + call), +edx-enterprise (new pipeline step) + +**Dependencies:** None. Good first epic alongside grades enrichment. + +--- + +### 03_discount_enterprise_learner_exclusion + +**Current location:** +- `openedx-platform/openedx/features/discounts/applicability.py` + - `can_receive_discount(user, course)` (line ~129): lazy import of `is_enterprise_learner` + - Another function (line ~183): same lazy import of `is_enterprise_learner` + - Both call `is_enterprise_learner(user)` and return `False` (no discount) if true +- `openedx-platform/openedx/features/enterprise_support/utils.py` + - `is_enterprise_learner(user)` — checks cache, then queries `EnterpriseCustomerUser` + +**Migration approach:** New openedx-filter that allows plugins to mark a user as ineligible +for discounts. + +**New filter:** `DiscountEligibilityCheckRequested` in `openedx_filters/learning/filters.py` +- Signature: `run_filter(user, course_key, is_eligible)` → returns modified `is_eligible` bool +- Filter type: `"org.openedx.learning.discount.eligibility.check.requested.v1"` + +**edx-enterprise step:** Returns `{"is_eligible": False}` if `is_enterprise_learner(user)`. + +**Repos touched:** openedx-filters (new filter), openedx-platform (replace import + call), +edx-enterprise (new pipeline step) + +**Dependencies:** None. + +--- + +### 04_user_retirement_enterprise_cleanup + +**Current location:** +- `openedx-platform/openedx/core/djangoapps/user_api/accounts/views.py` + - Line 13: `from consent.models import DataSharingConsent` + - Line 28: `from enterprise.models import EnterpriseCourseEnrollment, EnterpriseCustomerUser, PendingEnterpriseCustomerUser` + - `retire_users_data_sharing_consent(username, retired_username)` (line ~1213): + Queries `EnterpriseCustomerUser` → iterates `EnterpriseCourseEnrollment` → calls + `DataSharingConsent.objects.retire_user()` and `HistoricalDataSharingConsent` + - `retire_user_from_pending_enterprise_customer_user(user, retired_email)` (line ~1235): + Updates `PendingEnterpriseCustomerUser` with retired_email + - Both methods called from within the retirement view at lines ~1158-1161 + - Line 1173: `USER_RETIRE_LMS_CRITICAL.send(sender=self.__class__, user=user)` — also + used by line 1310-1311 to declare consent model fields for retirement framework + - Line ~1095: `USER_RETIRE_LMS_MISC.send(...)` — sent before the enterprise-specific calls + +**Migration approach:** Enhance the existing `USER_RETIRE_LMS_CRITICAL` Django signal +(per CLAUDE.md notes). The signal currently sends `user=user`; add `retired_username` and +`retired_email` kwargs. Remove the two enterprise methods from views.py entirely. +Also remove the model retirement declarations at lines 1310-1311. + +**Signal changes:** In `openedx-platform/openedx/core/djangoapps/user_api/accounts/signals.py`, +`USER_RETIRE_LMS_CRITICAL = Signal()` already exists. The send call must add: +```python +USER_RETIRE_LMS_CRITICAL.send( + sender=self.__class__, + user=user, + retired_username=retired_username, + retired_email=retired_email, +) +``` +(These local vars are already computed before this line in the retirement view.) + +**edx-enterprise handler:** Connects to `USER_RETIRE_LMS_CRITICAL` and performs both +retirement operations (DataSharingConsent retire, PendingEnterpriseCustomerUser update). +Also registers consent model fields with the retirement framework via edx-enterprise's own +retirement config (not openedx-platform). + +**Repos touched:** openedx-platform (remove imports + enterprise methods, enhance signal send), +edx-enterprise (new signal handler) + +**Dependencies:** None (signal already exists; this is additive). + +--- + +### 05_enterprise_username_change_command + +**Current location:** +- `openedx-platform/common/djangoapps/student/management/commands/change_enterprise_user_username.py` + - Direct import of enterprise models (username change management command) + - This command exists solely to change enterprise user usernames for testing + +**Migration approach:** Move the management command entirely into edx-enterprise. +It has no non-enterprise use case. + +**Repos touched:** openedx-platform (remove command file + tests), +edx-enterprise (add management command) + +**Dependencies:** None. + +--- + +### 06_dsc_courseware_view_redirects + +**Current location:** +- `openedx-platform/lms/djangoapps/courseware/views/index.py` + - Line 26: `from openedx.features.enterprise_support.api import data_sharing_consent_required` + - Line 47: `@method_decorator(data_sharing_consent_required)` on `CoursewareIndex` +- `openedx-platform/lms/djangoapps/courseware/views/views.py` + - Line 154: same import + - Line 531: `@method_decorator(data_sharing_consent_required)` on `CourseTabView` + - Line 980: `@data_sharing_consent_required` on `jump_to_id` view function +- `openedx-platform/openedx/features/enterprise_support/api/course_wiki/views.py` + — actually `openedx-platform/lms/djangoapps/course_wiki/views.py` + - Line 20: same import + - Line 34: `@data_sharing_consent_required` on `WikiView` +- `openedx-platform/lms/djangoapps/course_wiki/middleware.py` + - Line 15: `from openedx.features.enterprise_support.api import get_enterprise_consent_url` + - Line ~100: calls `get_enterprise_consent_url(request, str(course_id), source='WikiAccessMiddleware')` + and redirects if truthy +- `openedx-platform/lms/djangoapps/courseware/access_utils.py` + - Line 11: `from enterprise.models import EnterpriseCourseEnrollment, EnterpriseCustomerUser` + - Line 73: `enterprise_learner_enrolled(request, user, course_key)` — queries enterprise + models to determine if learner should redirect to enterprise portal + - Line ~93: lazy import `enterprise_customer_from_session_or_learner_data` + - Line 233: `check_data_sharing_consent(course_id)` — lazy import `get_enterprise_consent_url`, + returns consent URL or None + - Line ~161: calls `enterprise_learner_enrolled()` inside `_has_access_to_course()` + +**Migration approach:** New `CoursewareViewRedirectURL` openedx-filter (per CLAUDE.md notes). +Replace the `data_sharing_consent_required` decorator with a new generic +`courseware_view_redirect` decorator that calls the filter and redirects to the first URL +returned, or passes if empty list. + +The `enterprise_learner_enrolled` function and `check_data_sharing_consent` are also replaced +by filter calls. The enterprise_learner_enrolled redirect uses a separate invocation path +(inside `_has_access_to_course`), which could be the same filter. + +**New filter:** `CoursewareViewRedirectURL` in `openedx_filters/learning/filters.py` +- Signature: `run_filter(redirect_urls, request, course_id)` → returns modified `redirect_urls` list +- Filter type: `"org.openedx.learning.courseware.view.redirect_url.requested.v1"` +- No exception needed (fail_silently=True, caller selects first URL) + +**New decorator** (openedx-platform): `courseware_view_redirect` replaces +`data_sharing_consent_required`. Calls the filter, redirects to `redirect_urls[0]` if non-empty. + +**edx-enterprise steps:** +- `ConsentRedirectStep`: checks if DSC consent is required for course, returns consent URL +- `LearnerPortalRedirectStep`: checks if enterprise learner enrolled via portal, returns portal URL + +**Repos touched:** openedx-filters (new filter), openedx-platform (new decorator, replace +all usages in views + middleware + access_utils, remove enterprise model imports), +edx-enterprise (new pipeline steps) + +**Dependencies:** None, but this is the largest and most complex epic. + +**Note:** `access_utils.py` line 11 imports enterprise models directly — remove them by +moving the DB queries into the edx-enterprise pipeline step. + +--- + +### 07_third_party_auth_enterprise_pipeline + +**Current location:** +- `openedx-platform/common/djangoapps/third_party_auth/settings.py` + - Line 15: `from openedx.features.enterprise_support.api import insert_enterprise_pipeline_elements` + - Line ~74: `insert_enterprise_pipeline_elements(django_settings.SOCIAL_AUTH_PIPELINE)` called + from `apply_settings()` — injects enterprise pipeline stages into SOCIAL_AUTH_PIPELINE +- `openedx-platform/common/djangoapps/third_party_auth/pipeline.py` + - Line 99: `from enterprise.models import ... is_enterprise_customer_user` (via utils import) + Actually: line 793: lazy import `from openedx.features.enterprise_support.api import enterprise_is_enabled` + - Lines 802-855: `associate_by_email_if_enterprise_user()` inner function, decorated with + `@enterprise_is_enabled()`, calls `is_enterprise_customer_user(current_provider.provider_id, current_user)` from enterprise models via `third_party_auth/utils.py` +- `openedx-platform/common/djangoapps/third_party_auth/saml.py` + - Line 144-148: `SAMLAuth.disconnect()` override — lazy import + `from openedx.features.enterprise_support.api import unlink_enterprise_user_from_idp`, + then calls it +- `openedx-platform/common/djangoapps/third_party_auth/utils.py` + - Line 14: `from enterprise.models import EnterpriseCustomerIdentityProvider, EnterpriseCustomerUser` + - `is_enterprise_customer_user(provider_id, user)` (line ~238): queries these models + +**Migration approach:** Multi-part. Three distinct behaviors: + +1. **Pipeline injection** (`insert_enterprise_pipeline_elements`): Replace with plugin + settings — edx-enterprise adds its pipeline stages to `SOCIAL_AUTH_PIPELINE` via + `plugin_settings()` callback, eliminating the need for `insert_enterprise_pipeline_elements` + and the `third_party_auth/settings.py` import. + +2. **Associate-by-email** (`associate_by_email_if_enterprise_user`): This is already a + pipeline stage logic. The entire inner function can be moved into an edx-enterprise + SAML pipeline step registered via `SOCIAL_AUTH_PIPELINE` in edx-enterprise plugin_settings. + The platform-side code simply removes the function and its imports. + +3. **SAML disconnect** (`SAMLAuth.disconnect`): Emit a new Django signal + `SocialAuthAccountDisconnected` (or reuse existing `social_django` disconnect signal if + available). edx-enterprise listens and calls `unlink_enterprise_user_from_idp`. + + Check `social_django` for existing `disconnect` signals before creating a new one: + `grep -r "disconnect" openedx-platform/common/djangoapps/third_party_auth/ --include="*.py"` + +**Repos touched:** openedx-platform (remove all enterprise imports from utils.py, +pipeline.py, saml.py, settings.py), edx-enterprise (new pipeline step + signal handler + +plugin_settings additions) + +**Dependencies:** The settings/pipeline injection approach requires understanding of +how `plugin_settings` works; review `edx-django-utils/edx_django_utils/plugins/plugin_settings.py`. + +--- + +### 08_saml_admin_enterprise_views + +**Current location:** +- `openedx-platform/common/djangoapps/third_party_auth/samlproviderconfig/views.py` + - Line 12: `from enterprise.models import EnterpriseCustomerIdentityProvider, EnterpriseCustomer` + - `SAMLProviderConfigViewSet` — entire view gated on `permission_required = 'enterprise.can_access_admin_dashboard'` + - Queries `EnterpriseCustomerIdentityProvider` throughout +- `openedx-platform/common/djangoapps/third_party_auth/samlproviderdata/views.py` + - Line 11: `from enterprise.models import EnterpriseCustomerIdentityProvider` + - `SAMLProviderDataViewSet` — same enterprise RBAC permission + - Queries `EnterpriseCustomerIdentityProvider` + +**Migration approach:** These views exist solely to serve enterprise admin functionality +and have no non-enterprise use case. Move them into edx-enterprise as enterprise admin +API views, exposing the same REST API endpoints but hosted within edx-enterprise's URL +namespace. Remove from openedx-platform entirely (including URL registrations in +`openedx-platform/lms/urls.py` if applicable). + +Check URL registrations: `grep -n "samlproviderconfig\|samlproviderdata" +openedx-platform/lms/urls.py openedx-platform/common/djangoapps/third_party_auth/urls.py` + +**Repos touched:** openedx-platform (remove views + URL registrations), +edx-enterprise (add equivalent views under enterprise admin URLs) + +**Dependencies:** Epic 18 (plugin registration) should be done alongside or after this +epic so that the `enterprise.urls` inclusion handles the new view URLs. + +--- + +### 09_logistration_enterprise_context + +**Current location:** +- `openedx-platform/openedx/core/djangoapps/user_authn/views/login_form.py` + - Lines 31-34: imports `enterprise_customer_for_request`, `enterprise_enabled`, + `get_enterprise_slug_login_url`, `handle_enterprise_cookies_for_logistration`, + `update_logistration_context_for_enterprise` + - Line ~59: `if current_provider and enterprise_customer_for_request(request):` + - Lines ~203-283: enterprise customer lookup, conditional form context, sidebar context, + cookie handling — all gating on `enterprise_customer_for_request(request)` +- `openedx-platform/openedx/core/djangoapps/user_authn/views/registration_form.py` + - Line 36: `from openedx.features.enterprise_support.api import enterprise_customer_for_request` + - Line ~1147: `enterprise_customer_for_request(request)` gates SSO registration form skip +- `openedx-platform/openedx/core/djangoapps/user_authn/views/login.py` + - Line 63: imports `activate_learner_enterprise`, `get_enterprise_learner_data_from_api` + - `enterprise_selection_page(request, user, next_url)` function (line ~481): checks + enterprise learner data, redirects to enterprise selection page or auto-activates + - Line ~652: calls `enterprise_selection_page(request, user, url)` + +**Migration approach:** Multi-part, use existing openedx-filters where possible. + +1. **Login form context**: Extend existing `StudentLoginRequested` filter (already defined + in `openedx_filters/learning/filters.py`) to enrich the context with enterprise customer + data. If the filter signature doesn't accommodate it, create a new + `LogistrationContextRequested` filter. + +2. **Registration form field filtering**: Use existing `StudentRegistrationRequested` filter + to remove fields for enterprise SSO users (filter step checks enterprise customer, removes + excluded fields from form). + +3. **Login enterprise selection redirect**: New signal `UserLoginCompleted` or use existing + post-auth mechanisms; edx-enterprise listens and handles the enterprise selection redirect. + Check if `StudentLoginRequested` can redirect via exception (it has a + `RedirectToPage` exception). + + Alternatively, create a `PostLoginRedirectURLRequested` filter that returns an optional + redirect URL after successful login. + +**Affected functions in enterprise_support:** +- `enterprise_customer_for_request(request)` in `enterprise_support/api.py` +- `update_logistration_context_for_enterprise(request, context, enterprise_customer)` in `enterprise_support/utils.py` +- `handle_enterprise_cookies_for_logistration(request, response, context)` in `enterprise_support/utils.py` +- `get_enterprise_slug_login_url()` in `enterprise_support/utils.py` +- `activate_learner_enterprise(request, user, enterprise_customer)` in `enterprise_support/api.py` +- `get_enterprise_learner_data_from_api(user)` in `enterprise_support/api.py` + +**Repos touched:** openedx-filters (new filters if needed), openedx-platform (replace +all imports + calls with filter/signal invocations), edx-enterprise (new pipeline steps) + +**Dependencies:** None, but complex — may be split into sub-epics per view file. + +--- + +### 10_student_dashboard_enterprise_context + +**Current location:** +- `openedx-platform/common/djangoapps/student/views/dashboard.py` + - Lines 51-54: imports `get_dashboard_consent_notification`, + `get_enterprise_learner_portal_context` from `enterprise_support.api`; + `is_enterprise_learner` from `enterprise_support.utils` + - Line ~620: `enterprise_message = get_dashboard_consent_notification(request, user, course_enrollments)` + - Lines ~802-803: `'enterprise_message': enterprise_message, 'consent_required_courses': ...` + added to template context + - Lines ~854-859: `is_enterprise_user`, enterprise learner portal context added to context +- `openedx-platform/common/djangoapps/student/views/management.py` + - Line 67: `from openedx.features.enterprise_support.utils import is_enterprise_learner` + - Line ~212: `'is_enterprise_learner': is_enterprise_learner(user)` in context + - Line ~685: `redirect(redirect_url) if redirect_url and is_enterprise_learner(request.user) else redirect('dashboard')` + +**Migration approach:** Use existing `DashboardRenderStarted` filter +(defined in `openedx_filters/learning/filters.py`). This filter has a `context` parameter +that pipeline steps can enrich. + +- Add a pipeline step that enriches the dashboard context with `enterprise_message`, + `consent_required_courses`, `is_enterprise_user`, and `enterprise_learner_portal_*` keys. +- For `management.py` `is_enterprise_learner` usage: create a new small filter or use + a Django signal. + +**Affected enterprise_support functions:** +- `get_dashboard_consent_notification(request, user, course_enrollments)` in `api.py` +- `get_enterprise_learner_portal_context(request)` in `api.py` +- `is_enterprise_learner(user)` in `utils.py` + +**Repos touched:** openedx-platform (remove imports, call existing filter), +edx-enterprise (new DashboardRenderStarted pipeline step) + +**Dependencies:** Depends on `DashboardRenderStarted` already being invoked in the +platform (verify: `grep -n "DashboardRenderStarted" openedx-platform/common/djangoapps/student/views/dashboard.py`). +If not yet invoked, add invocation as part of this epic. + +--- + +### 11_enrollment_api_enterprise_support + +**Current location:** +- `openedx-platform/openedx/core/djangoapps/enrollments/views.py` + - Lines 60-64: imports `EnterpriseApiServiceClient`, `ConsentApiServiceClient`, + `enterprise_enabled` from `enterprise_support.api` + - Lines ~777-796: When `explicit_linked_enterprise` param is provided and `enterprise_enabled()`, + calls `EnterpriseApiServiceClient.post_enterprise_course_enrollment()` and + `ConsentApiServiceClient.provide_consent()` after enrollment + +**Migration approach:** New `CourseEnrollmentStarted` pipeline step. The existing +`CourseEnrollmentStarted` filter is already defined in openedx-filters. Add a new step +in edx-enterprise that, when an enterprise UUID is included in the enrollment data, +posts the enrollment to the enterprise API and records consent. + +The `explicit_linked_enterprise` and `enterprise_uuid` enrollment parameters need to flow +through the filter as part of the enrollment context dict. + +**Repos touched:** openedx-platform (remove imports, pass enterprise_uuid through +existing filter), edx-enterprise (new CourseEnrollmentStarted pipeline step) + +**Dependencies:** None. + +--- + +### 12_learner_home_enterprise_dashboard + +**Current location:** +- `openedx-platform/lms/djangoapps/learner_home/views.py` + - Lines 63-65: imports `enterprise_customer_from_session_or_learner_data`, + `get_enterprise_learner_data_from_db` + - `get_enterprise_customer(user, request, is_masquerading)` (line ~212): returns enterprise + customer dict or None; used to populate `enterpriseDashboard` key in response (line ~551) + +**Migration approach:** Use a pluggable override for `get_enterprise_customer`. Since there +is only ever one enterprise plugin installed at a time, a pluggable override is sufficient +and simpler than a filter pipeline. + +Decorate `get_enterprise_customer` with `@pluggable_override` (see +`edx-django-utils/edx_django_utils/plugins/pluggable_override.py`). The default +implementation returns `None`. edx-enterprise provides the override that calls +`enterprise_customer_from_session_or_learner_data` or `get_enterprise_learner_data_from_db` +depending on whether the request is masquerading. + +**Repos touched:** openedx-platform (remove imports, add `@pluggable_override` decorator), +edx-enterprise (override implementation) + +**Dependencies:** None. + +--- + +### 13_course_home_progress_enterprise_name + +**Current location:** +- `openedx-platform/lms/djangoapps/course_home_api/progress/views.py` + - Line 42: `from openedx.features.enterprise_support.utils import get_enterprise_learner_generic_name` + - Line ~209: `username = get_enterprise_learner_generic_name(request) or student.username` + +**Migration approach:** Introduce an intermediate `obfuscated_username(request, student)` +function in `views.py` and decorate it with `@pluggable_override`. Since only one plugin +can logically override a username at a time, a pluggable override is appropriate here. + +The default implementation returns `None`. Replace line ~209 with: +```python +username = obfuscated_username(request, student) or student.username +``` +edx-enterprise provides the override that calls `get_enterprise_learner_generic_name(request)` +and returns the generic name if the learner is an enterprise user, otherwise `None`. + +**Repos touched:** openedx-platform (remove import, introduce `obfuscated_username` with +`@pluggable_override`), edx-enterprise (override implementation) + +**Dependencies:** None. + +--- + +### 14_course_modes_enterprise_customer + +**Current location:** +- `openedx-platform/common/djangoapps/course_modes/views.py` + - Line 42: `from openedx.features.enterprise_support.api import enterprise_customer_for_request` + - Also imports `EnterpriseApiServiceClient`, `ConsentApiServiceClient` (from enterprise_support) + - Lines ~191-197: If an enterprise customer is found for the request and the course mode has + a SKU, logs and uses enterprise context for the ecommerce calculate API call + +**Migration approach:** New filter `CourseModeCheckoutContextRequested` or reuse an +existing filter. The filter enriches the checkout context with the enterprise customer UUID, +allowing the ecommerce integration to apply enterprise pricing. + +**New filter:** `CourseModeCheckoutStarted` in `openedx_filters/learning/filters.py` +- Signature: `run_filter(context, request, course_mode)` → returns enriched context +- Filter type: `"org.openedx.learning.course_mode.checkout.started.v1"` + +**edx-enterprise step:** Injects enterprise customer info into context. + +**Repos touched:** openedx-filters (new filter), openedx-platform (replace import), +edx-enterprise (new pipeline step) + +**Dependencies:** None. + +--- + +### 15_support_views_enterprise_context + +**Current location:** +- `openedx-platform/lms/djangoapps/support/views/contact_us.py` + - Line 14: `from openedx.features.enterprise_support import api as enterprise_api` + - Lines ~49-51: `enterprise_api.enterprise_customer_for_request(request)` — adds + `'enterprise_learner'` tag to support ticket if user is enterprise +- `openedx-platform/lms/djangoapps/support/views/enrollments.py` + - Lines 37-42: imports `enterprise_enabled`, `get_data_sharing_consents`, + `get_enterprise_course_enrollments` from `enterprise_support.api`; + `EnterpriseCourseEnrollmentSerializer` from `enterprise_support.serializers` + - `_enterprise_course_enrollments_by_course_id(user)` (line ~74): queries enterprise + enrollments and consent records, serializes them for the support view + +**Migration approach:** Two separate behaviors: + +1. **contact_us.py**: New filter `SupportTicketTagsRequested` or a Django signal. A filter + is cleaner since we need tags returned. + +2. **enrollments.py**: This is support tooling for viewing enterprise enrollment data. + Since it queries enterprise models, the entire `_enterprise_course_enrollments_by_course_id` + method should be replaced with a filter call that edx-enterprise populates with the + enterprise enrollment data. + +**New filters:** +- `SupportContactContextRequested` — enriches support contact context with tags +- `SupportEnrollmentDataRequested` — provides enterprise enrollment data for support view + +**Repos touched:** openedx-filters (new filters), openedx-platform (replace imports), +edx-enterprise (new pipeline steps) + +**Dependencies:** None. + +--- + +### 16_programs_api_enterprise_enrollments + +**Current location:** +- `openedx-platform/openedx/core/djangoapps/programs/rest_api/v1/views.py` + - Line 19: imports `get_enterprise_course_enrollments`, `enterprise_is_enabled` + from `enterprise_support.api` + - `CourseRunProgressView._get_enterprise_course_enrollments(enterprise_uuid, user)` (line ~181): + decorated `@enterprise_is_enabled(otherwise=EmptyQuerySet)`; queries enterprise + course enrollments filtered by enterprise UUID + - Line ~93-94: calls this method when `enterprise_uuid` param is in request + +**Migration approach:** Use a pluggable override for `_get_enterprise_course_enrollments`. +Since there is only ever one enterprise plugin installed at a time, the filter pipeline +mechanism adds unnecessary complexity; a pluggable override is sufficient. + +Decorate `_get_enterprise_course_enrollments` with `@pluggable_override` (see +`edx-django-utils/edx_django_utils/plugins/pluggable_override.py`). The default +implementation returns an empty queryset. edx-enterprise provides the override +implementation that queries enterprise course enrollments filtered by enterprise UUID. +Remove the `enterprise_is_enabled` import and decorator entirely; when edx-enterprise is +not installed, the default empty-queryset implementation is used automatically. + +**Repos touched:** openedx-platform (replace import + add `@pluggable_override` decorator), +edx-enterprise (override implementation) + +**Dependencies:** None. + +### 17_enterprise_support_to_edx_enterprise + +**Background and motivation:** +The `openedx/features/enterprise_support/` module lives inside openedx-platform but imports +directly from `enterprise` and `consent` (edx-enterprise packages). Epics 01-16 replace every +external caller of enterprise_support with filter/signal hooks; the resulting edx-enterprise +plugin steps call enterprise_support functions internally. However, enterprise_support itself +still exists in openedx-platform and still imports from edx-enterprise. This makes edx-enterprise +a mandatory dependency of openedx-platform: any deployment without edx-enterprise installed would +fail at import time when Django loads enterprise_support. Therefore, enterprise_support must be +fully removed from openedx-platform before edx-enterprise can become a truly optional plugin. + +**What enterprise_support contains (all must move):** +- `api.py` (~1075 lines): API client classes (`EnterpriseApiClient`, `ConsentApiClient`, + `EnterpriseApiServiceClient`, `ConsentApiServiceClient`); enterprise customer lookup, DSC + check, consent URL generation, learner data, portal context, dashboard notification, SSO + helpers, and the `data_sharing_consent_required` decorator and `enterprise_is_enabled` + decorator (the latter still referenced by some edx-enterprise plugin steps) +- `utils.py`: `is_enterprise_learner`, `get_enterprise_readonly_account_fields`, + `get_enterprise_learner_generic_name`, `get_enterprise_slug_login_url`, + `handle_enterprise_cookies_for_logistration`, `update_logistration_context_for_enterprise`, + `get_enterprise_sidebar_context`, and cache key helpers +- `context.py`: `get_enterprise_event_context` +- `signals.py`: handlers for `COURSE_GRADE_NOW_PASSED`, `COURSE_ASSESSMENT_GRADE_CHANGED`, + `UNENROLL_DONE` (platform signals), and `post_save`/`pre_save` on enterprise models +- `tasks.py`: `clear_enterprise_customer_data_consent_share_cache` Celery task +- `serializers.py`: `EnterpriseCourseEnrollmentSerializer` +- `admin/`: `EnrollmentAttributeOverrideView`, `CSVImportForm` +- `enrollments/utils.py`: `lms_update_or_create_enrollment` +- `templates/enterprise_support/enterprise_consent_declined_notification.html` +- All test files under `enterprise_support/tests/` + +**Migration approach:** Move the enterprise_support package wholesale into edx-enterprise under +a new internal namespace (e.g., `enterprise/platform_support/`). Update all edx-enterprise +plugin steps created in epics 01-16 to use internal imports rather than +`from openedx.features.enterprise_support...`. Move the signal handler activations into +edx-enterprise's `AppConfig.ready()`. Delete the `openedx/features/enterprise_support/` +directory from openedx-platform and remove its `INSTALLED_APPS` entry from +`lms/envs/common.py`. + +**Steps:** + +1. **[edx-enterprise]** Copy the full enterprise_support package into edx-enterprise + (e.g., `enterprise/platform_support/`). Update all internal imports within the copied + files to reflect the new module path. Add signal handler activations to edx-enterprise's + existing `AppConfig.ready()`. + +2. **[edx-enterprise]** Update all plugin step files created in epics 01-16 to import from + the new internal path instead of `openedx.features.enterprise_support`. + +3. **[openedx-platform]** Delete `openedx/features/enterprise_support/` in its entirety. + Remove `'openedx.features.enterprise_support.apps.EnterpriseSupportConfig'` from + `INSTALLED_APPS` in `lms/envs/common.py`. Remove the corresponding test removal + from `lms/envs/test.py` if present. + +**Repos touched:** edx-enterprise (copy module, update plugin step imports, activate +signal handlers in AppConfig), openedx-platform (delete module, remove INSTALLED_APPS entry) + +**Dependencies:** Blocked by all epics 01-16. Every external caller of enterprise_support +must be replaced by a hook before this epic ships, because this epic deletes the module. +This epic in turn blocks epic 18 (plugin registration): edx-enterprise cannot +be made optional while enterprise_support still exists in openedx-platform. + +**Note:** The edx-enterprise plugin steps created in epics 01-16 will temporarily call +`from openedx.features.enterprise_support...` until this epic ships. This is acceptable +because during that window edx-enterprise is still a mandatory dependency. This epic +atomically cuts over all those imports to internal paths. + +--- + +### 18_plugin_registration + +**Current location — settings (`lms/envs/common.py`):** +- Lines 48-61: `from enterprise.constants import (ENTERPRISE_ADMIN_ROLE, ENTERPRISE_LEARNER_ROLE, ...)` + — 9 role constants imported at module level (blocking import) +- Line 520: `ENABLE_ENTERPRISE_INTEGRATION = False` +- Line 600: `ALLOW_ADMIN_ENTERPRISE_COURSE_ENROLLMENT_DELETION = False` +- Line ~1169: `'enterprise.middleware.EnterpriseLanguagePreferenceMiddleware'` in MIDDLEWARE +- Lines 2586-3017 (approx): All `ENTERPRISE_*` settings: + - `ENTERPRISE_PUBLIC_ENROLLMENT_API_URL`, `ENTERPRISE_COURSE_ENROLLMENT_AUDIT_MODES` + - `ENTERPRISE_SUPPORT_URL`, `ENTERPRISE_CUSTOMER_SUCCESS_EMAIL`, `ENTERPRISE_INTEGRATIONS_EMAIL` + - `ENTERPRISE_API_URL`, `ENTERPRISE_CONSENT_API_URL`, `ENTERPRISE_CUSTOMER_LOGO_IMAGE_SIZE` + - `ENTERPRISE_ALL_SERVICE_USERNAMES` + - `ENTERPRISE_PLATFORM_WELCOME_TEMPLATE`, `ENTERPRISE_SPECIFIC_BRANDED_WELCOME_TEMPLATE` + - `ENTERPRISE_PROXY_LOGIN_WELCOME_TEMPLATE`, `ENTERPRISE_TAGLINE` + - `ENTERPRISE_EXCLUDED_REGISTRATION_FIELDS`, `ENTERPRISE_READONLY_ACCOUNT_FIELDS` + - `ENTERPRISE_CUSTOMER_COOKIE_NAME`, `ENTERPRISE_VSF_UUID` + - `ENTERPRISE_MANUAL_REPORTING_CUSTOMER_UUIDS` +- Lines ~2670-2693: `SYSTEM_WIDE_ROLE_CLASSES` enterprise role mappings (uses imported constants) + +**Current location — app/URL registration:** +- `openedx/envs/common.py`: `('enterprise', None)` and `('consent', None)` listed in + `OPTIONAL_APPS` (lines ~767-768) under the "Enterprise Apps" comment block + (the `EnterpriseSupportConfig` entry is already removed by epic 17) +- `lms/urls.py` line ~881: conditional `enterprise.urls` inclusion + +**Migration approach:** + +All three Django apps in edx-enterprise (`enterprise`, `consent`, `enterprise_support`) are +registered as separate openedx plugins. Following the naming convention from +`openedx-platform/openedx/core/djangoapps/password_policy/`, each app gets a `plugin_app` +dict in its `AppConfig` and a `plugin_settings(settings)` callback in its own +`{app}/settings/common.py` file. + +Settings are distributed across the three plugins by ownership: + +- **`enterprise/settings/common.py`**: All core `ENTERPRISE_*` settings (excluding the two + below), `SYSTEM_WIDE_ROLE_CLASSES` role mappings (using edx-enterprise's own constants — + no platform import needed), `EnterpriseLanguagePreferenceMiddleware` appended to + `MIDDLEWARE`, `SOCIAL_AUTH_PIPELINE` additions (TPA pipeline steps from epic 07), all + `OPEN_EDX_FILTERS_CONFIG` filter step registrations (epics 09-11, 14-15), and all + pluggable override settings (epics 12, 13, 16). + - `ENTERPRISE_PUBLIC_ENROLLMENT_API_URL` and `ENTERPRISE_API_URL` are derived from + `settings.LMS_ROOT_URL` (replacing the `Derived(lambda s: ...)` pattern used in common.py). + +- **`consent/settings/common.py`**: `ENTERPRISE_CONSENT_API_URL` (derived from + `settings.LMS_ROOT_URL`). + +- **`enterprise_support/settings/common.py`**: `ENTERPRISE_READONLY_ACCOUNT_FIELDS` and + `ENTERPRISE_CUSTOMER_COOKIE_NAME`, which are consumed by enterprise_support utility + functions. + +Steps: + +1. **[edx-enterprise]** Add `plugin_app` configuration to `EnterpriseConfig`, `ConsentConfig`, + and a new `EnterpriseSupportConfig` in edx-enterprise, each declaring `ProjectType.LMS` + settings config (pointing to their respective `settings.common`) and, where applicable, + URL config. + +2. **[edx-enterprise]** Implement `plugin_settings(settings)` in each app's + `{app}/settings/common.py` as described above, using `setdefault` throughout so operator + overrides are respected. + +3. **[openedx-platform]** Remove the `from enterprise.constants import ...` block, all + `ENTERPRISE_*` settings, enterprise entries from `SYSTEM_WIDE_ROLE_CLASSES`, and the + `EnterpriseLanguagePreferenceMiddleware` entry from `lms/envs/common.py`. + +4. **[openedx-platform]** Remove `('enterprise', None)` and `('consent', None)` from + `OPTIONAL_APPS` in `openedx/envs/common.py`. The plugin framework adds both automatically + when edx-enterprise is installed. + +5. **[openedx-platform]** Remove the conditional `enterprise.urls` inclusion from + `lms/urls.py`; edx-enterprise registers its URLs via `plugin_app` config instead. + +Review `edx-django-utils/edx_django_utils/plugins/` for the `plugin_app` API and how +`plugin_settings` callbacks are structured and invoked. + +**Repos touched:** openedx-platform (remove enterprise.constants import, all ENTERPRISE_* +settings, OPTIONAL_APPS entries for enterprise and consent, conditional URL include), +edx-enterprise (plugin_app configs and plugin_settings callbacks across all three apps) + +**Dependencies:** Blocked by epic 17 (enterprise_support module migration), which ensures +that no part of openedx-platform imports from enterprise or enterprise_support packages, +making it safe to remove the hard-coded app registration and settings. Epics 01-16 must +also be complete. Per CLAUDE.md: "Registering edx-enterprise as a proper openedx plugin +should happen only after all enterprise/consent imports have been removed from +openedx-platform." + +--- + +## Epic Sequencing Summary + +**Epics with no dependencies (can start immediately, in parallel):** +- 01 Grades analytics event enrichment +- 02 User account readonly fields +- 03 Discount enterprise learner exclusion +- 04 User retirement enterprise cleanup +- 05 Enterprise username change command + +**Epics with no code dependencies but medium complexity (start after initial epics ship):** +- 11 Enrollment API enterprise support +- 12 Learner home enterprise dashboard +- 13 Course home progress enterprise name +- 14 Course modes enterprise customer +- 15 Support views enterprise context +- 16 Programs API enterprise enrollments + +**Epics with higher complexity or cross-cutting concerns:** +- 06 DSC courseware view redirects (largest; replaces decorator across 4+ files) +- 07 Third-party auth enterprise pipeline (multi-part; SAML pipeline stages) +- 08 SAML admin enterprise views (views to be moved to edx-enterprise) +- 09 Logistration enterprise context (complex; multiple auth views) +- 10 Student dashboard enterprise context (depends on DashboardRenderStarted being invoked) + +**Final epics (strict order):** +- 17 Enterprise support module migration (blocked by 01-16; deletes enterprise_support from platform) +- 18 Plugin registration (blocked by 17; includes settings migration via plugin_settings()) + +--- + +## Additional Notes for Second-Pass AI + +### Test file handling +For every production file change, the corresponding test file also imports enterprise modules +(e.g. `test_access.py`, `test_views.py`, `test_retirement_views.py`). Each diff must update +test files to mock the filter/signal interface instead of mocking enterprise functions directly. + +### enterprise_support module fate +As epics 01-16 ship, the functions in `openedx/features/enterprise_support/api.py` and +`utils.py` will be called only from edx-enterprise plugin steps — no longer from +openedx-platform directly. However, enterprise_support itself still imports from +`enterprise` and `consent`, making edx-enterprise a mandatory platform dependency. +Epic 17 resolves this by moving the entire module into edx-enterprise and deleting it +from the platform. Until epic 17 ships, edx-enterprise plugin steps may freely import +from `openedx.features.enterprise_support`; epic 17 atomically replaces those imports. + +### enterprise_support internal imports +Within enterprise_support itself (signals.py, tasks.py, etc.), enterprise models are +imported. Those imports are intentional and move with the module in epic 17. Only imports +FROM OUTSIDE enterprise_support (in other openedx-platform packages) are targets for +epics 01-16. + +### Existing filters to reuse +Before creating a new filter, check `openedx_filters/learning/filters.py` for: +- `DashboardRenderStarted` — use for epic 10 +- `StudentLoginRequested` — consider for epic 09 (login form context) +- `StudentRegistrationRequested` — consider for epic 09 (registration form) +- `CourseEnrollmentStarted` — use for epic 11 +- `AccountSettingsRenderStarted` — do NOT use for epic 02 (per CLAUDE.md) + +### Filter config pattern +For `OPEN_EDX_FILTERS_CONFIG` in `lms/envs/common.py`, follow this structure: +```python +OPEN_EDX_FILTERS_CONFIG = { + "org.openedx.learning.grade.context.requested.v1": { + "fail_silently": True, + "pipeline": [], + }, + # ... other filters +} +``` +And in `lms/envs/production.py`, add `'OPEN_EDX_FILTERS_CONFIG'` to the exclusion list +(line ~76), then add merge logic following the `TRACKING_BACKENDS` pattern (line ~273): +```python +for filter_type, config in _YAML_TOKENS.get('OPEN_EDX_FILTERS_CONFIG', {}).items(): + if filter_type in OPEN_EDX_FILTERS_CONFIG: + OPEN_EDX_FILTERS_CONFIG[filter_type]['pipeline'].extend( + config.get('pipeline', []) + ) + if 'fail_silently' in config: + OPEN_EDX_FILTERS_CONFIG[filter_type]['fail_silently'] = config['fail_silently'] + else: + OPEN_EDX_FILTERS_CONFIG[filter_type] = config +```