Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 63 additions & 0 deletions lms/djangoapps/branding/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
187 changes: 185 additions & 2 deletions lms/djangoapps/branding/tests/test_api.py
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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. """
Expand Down Expand Up @@ -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'<span data-hj-suppress class="username">([^<]*)</span>', 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'<a href="{get_home_url()}">' in content
# The dropdown's dashboard link points at the platform dashboard.
assert f'<a href="{get_home_url()}" role="menuitem">' 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'<a href="{get_home_url()}">' 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'<a href="{OVERRIDDEN_PORTAL_LINK["url"]}">' in content
assert OVERRIDDEN_PORTAL_LINK["logo"] in content
assert f'{OVERRIDDEN_PORTAL_LINK["name"]} Dashboard' in content
assert f'<a href="{get_home_url()}">' not in content
# The dropdown's dashboard link points at the portal instead of the platform dashboard.
assert f'<a href="{OVERRIDDEN_PORTAL_LINK["url"]}" role="menuitem">' in content
assert f'<a href="{get_home_url()}" role="menuitem">' 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
4 changes: 2 additions & 2 deletions lms/templates/courseware/progress.html
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Expand Down
9 changes: 4 additions & 5 deletions lms/templates/header/navbar-logo-header.html
Original file line number Diff line number Diff line change
Expand Up @@ -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()
%>

<h1 class="header-logo">
% if enterprise_customer_link:
<a href="${settings.ENTERPRISE_LEARNER_PORTAL_BASE_URL}/${enterprise_customer_link.get('slug')}">
<img class="logo" src="${enterprise_customer_link.get('logo')}" alt="${_('{name} Dashboard').format(name=enterprise_customer_link.get('name'))}"/>
% if enterprise_learner_portal_link:
<a href="${enterprise_learner_portal_link.get('url')}">
<img class="logo" src="${enterprise_learner_portal_link.get('logo')}" alt="${_('{name} Dashboard').format(name=enterprise_learner_portal_link.get('name'))}"/>
% if settings.LOGO_IMAGE_EXTRA_TEXT == 'edge':
<span class="font-italic"> | EDGE</span>
% endif
Expand Down
16 changes: 8 additions & 8 deletions lms/templates/header/user_dropdown.html
Original file line number Diff line number Diff line change
Expand Up @@ -10,20 +10,20 @@
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
%>

<%
## This template should not use the target student's details when masquerading, see TNL-4895
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
%>

<div class="nav-item hidden-mobile">
Expand All @@ -38,10 +38,10 @@
<span class="fa fa-caret-down" aria-hidden="true"></span>
</div>
<div class="dropdown-user-menu hidden" aria-label=${_("More Options")} role="menu" id="user-menu" tabindex="-1">
% if not enterprise_customer_portal:
% if not enterprise_learner_portal_link:
<div class="mobile-nav-item dropdown-item dropdown-nav-item"><a href="${reverse('dashboard')}" role="menuitem">${_("Dashboard")}</a></div>
% else:
<div class="mobile-nav-item dropdown-item dropdown-nav-item"><a href="${settings.ENTERPRISE_LEARNER_PORTAL_BASE_URL}/${enterprise_customer_portal.get('slug')}" role="menuitem">${_("Dashboard")}</a></div>
<div class="mobile-nav-item dropdown-item dropdown-nav-item"><a href="${enterprise_learner_portal_link.get('url')}" role="menuitem">${_("Dashboard")}</a></div>
% endif

<div class="mobile-nav-item dropdown-item dropdown-nav-item"><a href="${urljoin(settings.PROFILE_MICROFRONTEND_URL, f'/u/{user.username}')}" role="menuitem">${_("Profile")}</a></div>
Expand Down
4 changes: 2 additions & 2 deletions lms/templates/user_dropdown.html
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,14 @@
from django.urls import reverse
from django.utils.translation import gettext as _

from lms.djangoapps.branding.api import 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
%>

<%
## This template should not use the target student's details when masquerading, see TNL-4895
self.real_user = getattr(user, 'real_user', user)
username = get_enterprise_learner_generic_name(request) or self.real_user.username
username = get_learner_generic_name(user=self.real_user)
profile_image_url = get_profile_image_urls_for_user(self.real_user)['medium']
%>

Expand Down
Loading