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 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() %>

- % if enterprise_customer_link: - - + % if enterprise_learner_portal_link: + + % if settings.LOGO_IMAGE_EXTRA_TEXT == 'edge': | EDGE % endif diff --git a/lms/templates/header/user_dropdown.html b/lms/templates/header/user_dropdown.html index b4b22e0e32be..dbfaeca3a77c 100644 --- a/lms/templates/header/user_dropdown.html +++ b/lms/templates/header/user_dropdown.html @@ -10,8 +10,8 @@ from django.urls import reverse from django.utils.translation import gettext as _ +from lms.djangoapps.branding.api import get_enterprise_learner_portal_link, get_learner_generic_name from openedx.core.djangoapps.user_api.accounts.image_helpers import get_profile_image_urls_for_user -from openedx.features.enterprise_support.utils import get_enterprise_learner_generic_name, get_enterprise_learner_portal %> <% @@ -19,11 +19,11 @@ self.real_user = getattr(user, 'real_user', user) profile_image_url = get_profile_image_urls_for_user(self.real_user)['medium'] username = self.real_user.username -displayname = get_enterprise_learner_generic_name(request) or username -enterprise_customer_portal = get_enterprise_learner_portal(request) -## Enterprises with the learner portal enabled should not show order history, as it does -## not apply to the learner's method of purchasing content. -should_show_order_history = not enterprise_customer_portal +displayname = get_learner_generic_name(user=self.real_user) +enterprise_learner_portal_link = get_enterprise_learner_portal_link() +## Learners sent to a separate portal should not see order history, as it does not +## apply to their method of purchasing content. +should_show_order_history = not enterprise_learner_portal_link %>