diff --git a/lms/djangoapps/branding/api.py b/lms/djangoapps/branding/api.py
index 52aeb9b3e1b8..48de591bf946 100644
--- a/lms/djangoapps/branding/api.py
+++ b/lms/djangoapps/branding/api.py
@@ -14,12 +14,15 @@
"""
import logging
+from typing import Optional, TypedDict
import six
from django.conf import settings
+from django.contrib.auth.base_user import AbstractBaseUser
from django.contrib.staticfiles.storage import staticfiles_storage
from django.urls import reverse
from django.utils.translation import gettext as _
+from edx_django_utils.plugins import pluggable_override
from six.moves.urllib.parse import urljoin
from common.djangoapps.edxmako.shortcuts import marketing_link
@@ -645,6 +648,66 @@ def get_home_url():
return reverse('dashboard')
+@pluggable_override('OVERRIDE_GET_LEARNER_GENERIC_NAME')
+def get_learner_generic_name(user: AbstractBaseUser) -> str:
+ """
+ Return the name to display for ``user``.
+
+ The platform displays the learner's own username, so callers can render the result
+ directly with no fallback of their own.
+
+ ``user`` is required and cannot be derived from the current request: the callers do
+ not agree on whose name is displayed. The progress page shows the student being
+ viewed and the user dropdowns show the real user behind a masquerade, neither of
+ which is necessarily the logged-in user.
+
+ This function can be overridden by an installed plugin via the
+ OVERRIDE_GET_LEARNER_GENERIC_NAME setting, to substitute a generic name for learners
+ whose real username should not be exposed. An override that does not apply to a given
+ learner must return ``prev_fn(user=user)`` rather than None, so that the username is
+ still displayed. An override that needs the current request should obtain it from
+ ``crum.get_current_request()``, and must tolerate that returning None outside of a
+ request cycle.
+
+ It is called while rendering the logged-in header and the progress page, so an
+ implementation should be cheap or cached.
+ """
+ return user.username
+
+
+class EnterpriseLearnerPortalLink(TypedDict):
+ """Contract for the OVERRIDE_GET_ENTERPRISE_LEARNER_PORTAL_LINK return value."""
+ url: str # Absolute URL of the portal home page.
+ logo: str # URL of the logo image to render in place of the site logo.
+ name: str # Display name of the portal, used in link alternative text.
+
+
+@pluggable_override('OVERRIDE_GET_ENTERPRISE_LEARNER_PORTAL_LINK')
+def get_enterprise_learner_portal_link() -> Optional[EnterpriseLearnerPortalLink]:
+ """
+ Return a link to the enterprise learner portal that stands in for the platform
+ dashboard for the current viewer.
+
+ The platform hosts no such portal of its own -- it belongs to whichever enterprise
+ the learner is affiliated with -- so this returns None and pages render the site
+ logo, the platform dashboard links and the order history link as usual. Returning a
+ link instead points both the header logo and the user dropdown's dashboard link at
+ the portal, and hides the order history link, which does not apply to a learner who
+ obtains content through the portal.
+
+ This takes no arguments: it describes the current viewer, so an override should
+ obtain the request from ``crum.get_current_request()``. That returns None outside of
+ a request cycle, and an override must tolerate it, as well as a request whose user is
+ anonymous.
+
+ This function can be overridden by an installed plugin via the
+ OVERRIDE_GET_ENTERPRISE_LEARNER_PORTAL_LINK setting. An override that does not apply
+ to the current viewer must return ``prev_fn()``. It is called on every page render,
+ logged in or not, so an implementation should be cheap or cached.
+ """
+ return None
+
+
def get_logo_url_for_email():
"""
Returns the url for the branded logo image for embedding in email templates.
diff --git a/lms/djangoapps/branding/tests/test_api.py b/lms/djangoapps/branding/tests/test_api.py
index 71819a98d940..c85152d73144 100644
--- a/lms/djangoapps/branding/tests/test_api.py
+++ b/lms/djangoapps/branding/tests/test_api.py
@@ -1,16 +1,29 @@
"""Tests of Branding API """
+import re
+from typing import Callable, Optional
from unittest import mock
from django.conf import settings
-from django.test import TestCase
+from django.contrib.auth.base_user import AbstractBaseUser
+from django.test import RequestFactory, TestCase
from django.test.utils import override_settings
from django.urls import reverse
+from common.djangoapps.edxmako.shortcuts import render_to_string
+from common.djangoapps.student.tests.factories import UserFactory
from openedx.core.djangoapps.site_configuration.tests.test_util import with_site_configuration
-from ..api import _footer_business_links, get_footer, get_home_url, get_logo_url
+from ..api import (
+ EnterpriseLearnerPortalLink,
+ _footer_business_links,
+ get_enterprise_learner_portal_link,
+ get_footer,
+ get_home_url,
+ get_learner_generic_name,
+ get_logo_url
+)
test_config_disabled_contact_us = { # pylint: disable=invalid-name
"CONTACT_US_ENABLE": False,
@@ -21,6 +34,44 @@
"CONTACT_US_CUSTOM_LINK": "https://open.edx.org/",
}
+TEST_PASSWORD = "Password1234"
+PORTAL_USERNAME = "portal_learner"
+
+
+OVERRIDDEN_GENERIC_NAME = "Test Org Learner"
+
+OVERRIDDEN_PORTAL_LINK = {
+ "url": "https://portal.example.com/test-org",
+ "logo": "https://portal.example.com/test-org/logo.png",
+ "name": "Test Org",
+}
+
+
+def override_learner_generic_name(
+ prev_fn: Callable[..., str], # pylint: disable=unused-argument
+ user: AbstractBaseUser, # pylint: disable=unused-argument
+) -> str:
+ """
+ Alternative implementation of ``get_learner_generic_name`` used by the tests below.
+
+ A real override is expected to return ``prev_fn(user=user)`` for any learner it does not
+ claim; this one claims every learner, because whether the chain falls through correctly is
+ ``pluggable_override``'s behavior rather than this repo's.
+ """
+ return OVERRIDDEN_GENERIC_NAME
+
+
+def override_enterprise_learner_portal_link(
+ prev_fn: Callable[..., Optional[EnterpriseLearnerPortalLink]], # pylint: disable=unused-argument
+) -> Optional[EnterpriseLearnerPortalLink]:
+ """
+ Alternative implementation of ``get_enterprise_learner_portal_link`` used by the tests below.
+
+ Takes no arguments, reading the current viewer from crum the way a real plugin override
+ does. Claims unconditionally, for the reason given above.
+ """
+ return OVERRIDDEN_PORTAL_LINK
+
class TestHeader(TestCase):
"""Test API end-point for retrieving the header. """
@@ -196,3 +247,135 @@ def test_get_footer_custom_contact_url(self):
navigation_link_contact_us = [l for l in actual_footer['navigation_links'] if l['name'] == 'contact'][0]
assert navigation_link_contact_us['url'] == test_config_custom_url_contact_us['CONTACT_US_CUSTOM_LINK']
+
+
+GENERIC_NAME_OVERRIDE = "lms.djangoapps.branding.tests.test_api.override_learner_generic_name"
+PORTAL_LINK_OVERRIDE = "lms.djangoapps.branding.tests.test_api.override_enterprise_learner_portal_link"
+
+
+class TestLearnerHeaderHelpers(TestCase):
+ """Test the pluggable header helpers consumed by the navigation templates."""
+
+ def setUp(self):
+ super().setUp()
+ self.portal_user = UserFactory.create(username=PORTAL_USERNAME)
+
+ def test_learner_generic_name_default(self):
+ """Without an override the helper returns the learner's own username."""
+ assert get_learner_generic_name(user=self.portal_user) == PORTAL_USERNAME
+
+ @override_settings(OVERRIDE_GET_LEARNER_GENERIC_NAME=GENERIC_NAME_OVERRIDE)
+ def test_learner_generic_name_overridden(self):
+ assert get_learner_generic_name(user=self.portal_user) == OVERRIDDEN_GENERIC_NAME
+
+ def test_enterprise_learner_portal_link_default(self):
+ assert get_enterprise_learner_portal_link() is None
+
+ @override_settings(OVERRIDE_GET_ENTERPRISE_LEARNER_PORTAL_LINK=PORTAL_LINK_OVERRIDE)
+ def test_enterprise_learner_portal_link_overridden(self):
+ assert get_enterprise_learner_portal_link() == OVERRIDDEN_PORTAL_LINK
+
+
+class TestLearnerHeaderTemplates(TestCase):
+ """Test that the header templates render the values returned by the pluggable helpers."""
+
+ def setUp(self):
+ super().setUp()
+ self.portal_user = UserFactory.create(username=PORTAL_USERNAME, password=TEST_PASSWORD)
+
+ def _render(self, url):
+ """Return the markup of a page that renders the site header, failing if it did not render."""
+ response = self.client.get(url)
+ assert response.status_code == 200
+ return response.content.decode("utf-8")
+
+ def _render_authenticated_header(self, user):
+ """Return the markup of a logged-in page, which renders both the logo header and the dropdown."""
+ assert self.client.login(username=user.username, password=TEST_PASSWORD)
+ return self._render(url=reverse("dashboard"))
+
+ def _displayed_name(self, content):
+ """Return the name the user dropdown displays, which is what the helper feeds."""
+ match = re.search(r'([^<]*)', content)
+ assert match, "the user dropdown did not render"
+ return match.group(1)
+
+ def _assert_platform_header(self, content, username):
+ """Assert the header renders the way it does with no portal in play."""
+ # The learner's own username is the name displayed in the dropdown. Keyed off the
+ # dropdown's own markup, because the username also appears in the page's JS config
+ # and in the profile link, neither of which goes through the helper.
+ assert self._displayed_name(content=content) == username
+ assert OVERRIDDEN_GENERIC_NAME not in content
+ # The site logo links to the platform home page.
+ assert f'' in content
+ # The dropdown's dashboard link points at the platform dashboard.
+ assert f'' in content
+ # Order history is offered.
+ assert "Order History" in content
+ assert OVERRIDDEN_PORTAL_LINK["url"] not in content
+
+ def test_header_without_overrides(self):
+ self._assert_platform_header(
+ content=self._render_authenticated_header(user=self.portal_user),
+ username=PORTAL_USERNAME,
+ )
+
+ def test_anonymous_header_without_overrides(self):
+ """The logo header renders for logged-out visitors too, where there is no learner at all."""
+ content = self._render(url=reverse("root"))
+ assert f'' in content
+ assert OVERRIDDEN_PORTAL_LINK["url"] not in content
+
+ @override_settings(
+ OVERRIDE_GET_LEARNER_GENERIC_NAME=GENERIC_NAME_OVERRIDE,
+ OVERRIDE_GET_ENTERPRISE_LEARNER_PORTAL_LINK=PORTAL_LINK_OVERRIDE,
+ )
+ def test_header_with_overrides(self):
+ content = self._render_authenticated_header(user=self.portal_user)
+ # The generic name is displayed in place of the username.
+ assert self._displayed_name(content=content) == OVERRIDDEN_GENERIC_NAME
+ # The site logo is replaced by the portal logo and links to the portal.
+ assert f'' in content
+ assert OVERRIDDEN_PORTAL_LINK["logo"] in content
+ assert f'{OVERRIDDEN_PORTAL_LINK["name"]} Dashboard' in content
+ assert f'' not in content
+ # The dropdown's dashboard link points at the portal instead of the platform dashboard.
+ assert f'' in content
+ assert f'' not in content
+ # Order history is suppressed.
+ assert "Order History" not in content
+
+
+class TestDeprecatedUserDropdownTemplate(TestCase):
+ """
+ Test the deprecated ``lms/templates/user_dropdown.html``.
+
+ Nothing in this repo includes it -- it is reachable only through
+ ``navigation/navigation.html``, which is deprecated and included by nothing -- so it is
+ rendered directly here rather than through a view. Only its Bootstrap arm is rendered,
+ because the other arm calls a ``navigation_dropdown_menu_links()`` def supplied by the
+ parent template.
+ """
+
+ def setUp(self):
+ super().setUp()
+ self.portal_user = UserFactory.create(username=PORTAL_USERNAME)
+
+ def _render(self, user):
+ """Return the markup of the deprecated dropdown as rendered for ``user``."""
+ request = RequestFactory().get("/")
+ request.user = user
+ return render_to_string(
+ template_name="user_dropdown.html",
+ dictionary={"uses_bootstrap": True, "user": user, "request": request},
+ )
+
+ def test_dropdown_without_override(self):
+ assert PORTAL_USERNAME in self._render(user=self.portal_user)
+
+ @override_settings(OVERRIDE_GET_LEARNER_GENERIC_NAME=GENERIC_NAME_OVERRIDE)
+ def test_dropdown_with_override(self):
+ markup = self._render(user=self.portal_user)
+ assert OVERRIDDEN_GENERIC_NAME in markup
+ assert PORTAL_USERNAME not in markup
diff --git a/lms/templates/courseware/progress.html b/lms/templates/courseware/progress.html
index 3ee4044fcbbf..1aa18a482bbe 100644
--- a/lms/templates/courseware/progress.html
+++ b/lms/templates/courseware/progress.html
@@ -12,15 +12,15 @@
from pytz import UTC
from common.djangoapps.course_modes.models import CourseMode
+from lms.djangoapps.branding.api import get_learner_generic_name
from lms.djangoapps.certificates.data import CertificateStatuses
from lms.djangoapps.grades.api import constants as grades_constants
from openedx.core.djangolib.markup import HTML, Text
-from openedx.features.enterprise_support.utils import get_enterprise_learner_generic_name
from xmodule.graders import ShowCorrectness
%>
<%
-username = get_enterprise_learner_generic_name(request) or student.username
+username = get_learner_generic_name(user=student)
%>
<%block name="bodyclass">view-in-course view-progress%block>
diff --git a/lms/templates/header/navbar-logo-header.html b/lms/templates/header/navbar-logo-header.html
index adb12deec16f..a6db05d3d713 100644
--- a/lms/templates/header/navbar-logo-header.html
+++ b/lms/templates/header/navbar-logo-header.html
@@ -8,20 +8,19 @@
from django.utils.translation import gettext as _
from lms.djangoapps.ccx.overrides import get_current_ccx
from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers
-from openedx.features.enterprise_support.utils import get_enterprise_learner_generic_name, get_enterprise_learner_portal
# App that handles subdomain specific branding
from lms.djangoapps.branding import api as branding_api
%>
<%
-enterprise_customer_link = get_enterprise_learner_portal(request)
+enterprise_learner_portal_link = branding_api.get_enterprise_learner_portal_link()
%>