diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ec5249932..6b658341f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ Versioning](https://semver.org/spec/v2.0.0.html). ### Added +- Added new page extension `MainMenuEntry`. - Grid options for the Section plugin - Make user waive its right of withdrawal when purchasing a course product relation with `is_withdrawable` set to `false` diff --git a/cookiecutter/{{cookiecutter.organization}}-richie-site-factory/template/{{cookiecutter.site}}/src/backend/{{cookiecutter.site}}/settings.py b/cookiecutter/{{cookiecutter.organization}}-richie-site-factory/template/{{cookiecutter.site}}/src/backend/{{cookiecutter.site}}/settings.py index b54d9ce7c2..518a0fff2b 100644 --- a/cookiecutter/{{cookiecutter.organization}}-richie-site-factory/template/{{cookiecutter.site}}/src/backend/{{cookiecutter.site}}/settings.py +++ b/cookiecutter/{{cookiecutter.organization}}-richie-site-factory/template/{{cookiecutter.site}}/src/backend/{{cookiecutter.site}}/settings.py @@ -659,6 +659,13 @@ class Base(StyleguideMixin, DRFMixin, RichieCoursesConfigurationMixin, Configura "body > svg, #main-menu, .body-footer, .body-mentions" ) + # Wheither you can create PageIndex extension on page through toolbar if true or + # just editing existing extension if false + RICHIE_MAINMENUENTRY_ALLOW_CREATION = False + + # Define which node level can be processed to search for pageindex extension + RICHIE_MAINMENUENTRY_MENU_ALLOWED_LEVEL = 0 + # pylint: disable=invalid-name @property def ENVIRONMENT(self): diff --git a/sandbox/settings.py b/sandbox/settings.py index b584e5a304..787e3edd07 100644 --- a/sandbox/settings.py +++ b/sandbox/settings.py @@ -598,6 +598,13 @@ class Base(StyleguideMixin, DRFMixin, RichieCoursesConfigurationMixin, Configura environ_prefix=None, ) + # Wheither you can create PageIndex extension on page through toolbar if true or + # just editing existing extension if false + RICHIE_MAINMENUENTRY_ALLOW_CREATION = False + + # Define which node level can be processed to search for pageindex extension + RICHIE_MAINMENUENTRY_MENU_ALLOWED_LEVEL = 0 + @classmethod def _get_environment(cls): """Environment in which the application is launched.""" diff --git a/src/frontend/scss/components/_header.scss b/src/frontend/scss/components/_header.scss index c96a37181d..93f0a654ed 100644 --- a/src/frontend/scss/components/_header.scss +++ b/src/frontend/scss/components/_header.scss @@ -208,6 +208,18 @@ @include sv-flex(0, 0, auto); } + // Define color variable for default item color on hover then available variants + @if r-theme-val(topbar, item-hover-color) { + --r--menu--item--hover--color: #{r-theme-val(topbar, item-hover-color)}; + } + // WARNING: Those are currently some variant samples during development + &--primary { + --r--menu--item--hover--color: #{$primary}; + } + &--warning { + --r--menu--item--hover--color: #{$warning}; + } + & > a { @include sv-flex(1, 0, 100%); display: flex; @@ -228,6 +240,7 @@ @include media-breakpoint-up($r-topbar-breakpoint) { position: relative; + // If there is no default hover color we assume there is also no variant @if r-theme-val(topbar, item-hover-color) { &::after { content: ''; @@ -236,7 +249,7 @@ left: 0; right: 0; height: 8px; - background-color: r-theme-val(topbar, item-hover-color); + background-color: var(--r--menu--item--hover--color); border-top-left-radius: 0.2rem; border-top-right-radius: 0.2rem; } diff --git a/src/richie/apps/core/templates/menu/header_menu.html b/src/richie/apps/core/templates/menu/header_menu.html index 96bb7d72a4..9289d9e7a7 100644 --- a/src/richie/apps/core/templates/menu/header_menu.html +++ b/src/richie/apps/core/templates/menu/header_menu.html @@ -1,19 +1,23 @@ -{% load cms_tags %}{% spaceless %} +{% load menu_tags %}{% spaceless %} {% for child in children %} - {% with children_slug=child.get_menu_title|slugify %} - {% endwith %} {% endfor %} diff --git a/src/richie/apps/core/templates/richie/base.html b/src/richie/apps/core/templates/richie/base.html index daacadf5ba..d38c7c2757 100644 --- a/src/richie/apps/core/templates/richie/base.html +++ b/src/richie/apps/core/templates/richie/base.html @@ -135,7 +135,7 @@ diff --git a/src/richie/apps/courses/admin.py b/src/richie/apps/courses/admin.py index 1378b8972d..4b7c3029ba 100644 --- a/src/richie/apps/courses/admin.py +++ b/src/richie/apps/courses/admin.py @@ -280,6 +280,21 @@ def snapshot(self, request, course_id, *args, **kwargs): return JsonResponse({"id": new_page.course.id}) +class MainMenuEntryAdmin(PageExtensionAdmin): + """ + Admin class for the MainMenuEntry model + """ + + list_display = ["title", "allow_submenu"] + + # pylint: disable=no-self-use + def title(self, obj): + """ + Get the page title from the related page + """ + return obj.extended_object.get_title() + + class OrganizationAdmin(PageExtensionAdmin): """ Admin class for the Organization model @@ -349,6 +364,7 @@ class LicenceAdmin(TranslatableAdmin): admin.site.register(models.Course, CourseAdmin) admin.site.register(models.CourseRun, CourseRunAdmin) admin.site.register(models.Licence, LicenceAdmin) +admin.site.register(models.MainMenuEntry, MainMenuEntryAdmin) admin.site.register(models.Organization, OrganizationAdmin) admin.site.register(models.PageRole, PageRoleAdmin) admin.site.register(models.Person, PersonAdmin) diff --git a/src/richie/apps/courses/cms_menus.py b/src/richie/apps/courses/cms_menus.py new file mode 100644 index 0000000000..37c5d0a66f --- /dev/null +++ b/src/richie/apps/courses/cms_menus.py @@ -0,0 +1,79 @@ +""" +Menu modifier to add feature for menu template context. +""" + +from django.conf import settings + +from menus.base import Modifier +from menus.menu_pool import menu_pool + +from .models import MainMenuEntry + + +class MenuWithMainMenuEntry(Modifier): + """ + Menu modifier to include MainMenuEntry extension data in menu template context. + + In menu template you will be able to reach possible extension data from node + attribute ``menu_extension``. If node page has no extension it will have an empty + dict. Only a specific node level is processed and nodes with a different level + won't have the attribute ``menu_extension`` at all. + """ + + # pylint: disable=too-many-arguments,too-many-positional-arguments + def modify(self, request, nodes, namespace, root_id, post_cut, breadcrumb): + """ + Patch navigation nodes to include data from possible extension + ``MainMenuEntry``. + + For performance: + + * This does not work for breadcrumb navigation (all extension options are mean + for menu only); + * This works only on the menu top level, it means the one defined as first + argument from tag ``{% show_menu .. %}``; + + Then to avoid making a query for each node item to retrieve its possible + extension object, we get the extensions in bulk as values instead of objects. + + Finally we add the data on nodes so they can used from menu template. + """ + # We are not altering breadcrumb menu, this is only for navigation menu and + # only for the visible menu (not the whole processed tree) + if not nodes or breadcrumb or not post_cut: + return nodes + + # Get the page ids to process, only for the allowed node level + page_ids = [ + node.id + for node in nodes + if node.level == settings.RICHIE_MAINMENUENTRY_MENU_ALLOWED_LEVEL + ] + + # No need to continue if we don't have any valid node + if not page_ids: + return nodes + + # We directly get the extensions from their related page id and serialized + # as a dict instead of model object + extension_queryset = MainMenuEntry.objects.filter( + extended_object_id__in=page_ids + ).values("extended_object_id", "allow_submenu", "menu_color") + + # Pack extensions data into proper structure + extension_datas = { + item["extended_object_id"]: { + "allow_submenu": item["allow_submenu"], + "menu_color": item["menu_color"], + } + for item in extension_queryset + } + + # Attach each possible extension data to its relative node + for node in nodes: + node.menu_extension = extension_datas.get(node.id, {}) + + return nodes + + +menu_pool.register_modifier(MenuWithMainMenuEntry) diff --git a/src/richie/apps/courses/cms_toolbars.py b/src/richie/apps/courses/cms_toolbars.py index 66dac30f43..1797282c18 100644 --- a/src/richie/apps/courses/cms_toolbars.py +++ b/src/richie/apps/courses/cms_toolbars.py @@ -2,6 +2,7 @@ Toolbar extension for the courses application """ +from django.conf import settings from django.utils.text import capfirst from django.utils.translation import gettext_lazy as _ @@ -12,7 +13,7 @@ from cms.utils.urlutils import admin_reverse from .defaults import PAGE_EXTENSION_TOOLBAR_ITEM_POSITION -from .models import Category, Course, Organization, Person +from .models import Category, Course, MainMenuEntry, Organization, Person class BaseExtensionToolbar(ExtensionToolbar): @@ -131,3 +132,52 @@ class PersonExtensionToolbar(BaseExtensionToolbar): """ model = Person + + +@toolbar_pool.register +class MainMenuEntryExtensionToolbar(BaseExtensionToolbar): + """ + This extension class customizes the toolbar for the MainMenuEntry page extension. + """ + + model = MainMenuEntry + + def populate(self): + """ + Specific extension populate method. + + This extension entry only appears in toolbar if page already have extension or + if setting ``RICHIE_MAINMENUENTRY_ALLOW_CREATION`` is true. Finally the page + level must also match the allowed level from setting + ``RICHIE_MAINMENUENTRY_MENU_ALLOWED_LEVEL``. + """ + # always use draft if we have a page + self.page = get_page_draft(self.request.current_page) + if not self.page: + # Nothing to do + return + + # setup the extension toolbar with permissions and sanity checks + page_menu = self._setup_extension_toolbar() + + if user_can_change_page(user=self.request.user, page=self.page): + # Retrieves extension instance (if any) and toolbar URL + page_extension, admin_url = self.get_page_extension_admin() + # Get the page node level + level = self.page.node.get_depth() - 1 + allowed = page_extension is not None or ( + page_extension is None + and settings.RICHIE_MAINMENUENTRY_ALLOW_CREATION is True + ) + if ( + allowed + and level == settings.RICHIE_MAINMENUENTRY_MENU_ALLOWED_LEVEL + and admin_url + ): + # Adds a toolbar item in position 0 (at the top of the menu) + page_menu.add_modal_item( + _("Main menu settings"), + url=admin_url, + disabled=not self.toolbar.edit_mode_active, + position=PAGE_EXTENSION_TOOLBAR_ITEM_POSITION, + ) diff --git a/src/richie/apps/courses/defaults.py b/src/richie/apps/courses/defaults.py index bea2f7d631..ae542ac420 100644 --- a/src/richie/apps/courses/defaults.py +++ b/src/richie/apps/courses/defaults.py @@ -312,6 +312,10 @@ "reverse_id": "organizations", "template": "courses/cms/organization_detail.html", } +MENUENTRIES_PAGE = { + "reverse_id": None, + "template": "richie/single_column.html", +} PERSONS_PAGE = {"reverse_id": "persons", "template": "courses/cms/person_detail.html"} PROGRAMS_PAGE = { "reverse_id": "programs", @@ -382,3 +386,11 @@ # Maximum number of archived course runs displayed by default on course detail page. # The additional runs can be viewed by clicking on `View more` link. RICHIE_MAX_ARCHIVED_COURSE_RUNS = 10 + +# Define possible hover color that can be choosen for an MainMenuEntry and to apply on +# its menu item +MENU_ENTRY_COLOR_CLASSES = getattr( + settings, + "RICHIE_MENU_ENTRY_COLOR_CLASSES", + (("", _("None")),), +) diff --git a/src/richie/apps/courses/factories.py b/src/richie/apps/courses/factories.py index be9b319d05..d428151442 100644 --- a/src/richie/apps/courses/factories.py +++ b/src/richie/apps/courses/factories.py @@ -910,3 +910,26 @@ def fill_excerpt(self, create, extracted, **kwargs): plugin_type="PlainTextPlugin", body=text, ) + + +class MainMenuEntryFactory(BLDPageExtensionDjangoModelFactory): + """ + A factory to automatically generate random yet meaningful menu entry page extensions + and their related page in our tests. + """ + + class Meta: + model = models.MainMenuEntry + exclude = [ + "page_in_navigation", + "page_languages", + "page_parent", + "page_reverse_id", + "page_template", + "page_title", + ] + + # fields concerning the related page + page_template = models.MainMenuEntry.PAGE["template"] + allow_submenu = False + menu_color = "" diff --git a/src/richie/apps/courses/migrations/0035_add_menuentry.py b/src/richie/apps/courses/migrations/0035_add_menuentry.py new file mode 100644 index 0000000000..2f3b8eec5b --- /dev/null +++ b/src/richie/apps/courses/migrations/0035_add_menuentry.py @@ -0,0 +1,74 @@ +# Generated by Django 4.2.16 on 2024-09-19 00:24 + +import django.db.models.deletion +from django.db import migrations, models + +from ..defaults import MENU_ENTRY_COLOR_CLASSES + + +class Migration(migrations.Migration): + + dependencies = [ + ("cms", "0022_auto_20180620_1551"), + ("courses", "0034_auto_20230817_1736"), + ] + + operations = [ + migrations.CreateModel( + name="MainMenuEntry", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "allow_submenu", + models.BooleanField( + default=False, + help_text="If enabled the page entry in menu will be a dropdown for its possible children.", + verbose_name="Allow submenu", + ), + ), + ( + "menu_color", + models.CharField( + blank=True, + choices=MENU_ENTRY_COLOR_CLASSES, + default="", + help_text="A color used to display page entry in menu.", + max_length=10, + verbose_name="Color in menu", + ), + ), + ( + "extended_object", + models.OneToOneField( + editable=False, + on_delete=django.db.models.deletion.CASCADE, + to="cms.page", + ), + ), + ( + "public_extension", + models.OneToOneField( + editable=False, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="draft_extension", + to="courses.mainmenuentry", + ), + ), + ], + options={ + "verbose_name": "main menu entry", + "verbose_name_plural": "main menu entries", + "db_table": "richie_menuentry", + "ordering": ["-pk"], + }, + ), + ] diff --git a/src/richie/apps/courses/models/__init__.py b/src/richie/apps/courses/models/__init__.py index 15b1903e68..9edc58aa4f 100644 --- a/src/richie/apps/courses/models/__init__.py +++ b/src/richie/apps/courses/models/__init__.py @@ -6,6 +6,7 @@ from .blog import * from .category import * from .course import * +from .menuentry import * from .organization import * from .person import * from .program import * diff --git a/src/richie/apps/courses/models/menuentry.py b/src/richie/apps/courses/models/menuentry.py new file mode 100644 index 0000000000..cdbccf2ece --- /dev/null +++ b/src/richie/apps/courses/models/menuentry.py @@ -0,0 +1,51 @@ +""" +Declare and configure the models for the menu entry part +""" + +from django.db import models +from django.utils.translation import gettext_lazy as _ + +from cms.extensions.extension_pool import extension_pool + +from ...core.models import BasePageExtension +from .. import defaults + + +class MainMenuEntry(BasePageExtension): + """ + The MainMenuEntry extension defines some options for a page entry in the main menu. + """ + + PAGE = defaults.MENUENTRIES_PAGE + + class Meta: + db_table = "richie_menuentry" + ordering = ["-pk"] + verbose_name = _("main menu entry") + verbose_name_plural = _("main menu entries") + + def __str__(self): + """Human representation of an main menu entry page""" + model = self._meta.verbose_name.title() + name = self.extended_object.get_title() + return f"{model:s}: {name:s}" + + allow_submenu = models.BooleanField( + _("Allow submenu"), + default=False, + help_text=_( + "If enabled the page entry in menu will be a dropdown for its possible " + "children." + ), + ) + menu_color = models.CharField( + _("Color in menu"), + max_length=10, + default="", + blank=True, + choices=defaults.MENU_ENTRY_COLOR_CLASSES, + help_text=_("A color used to display page entry in menu."), + ) + + +extension_pool.register(MainMenuEntry) diff --git a/src/richie/apps/courses/settings/__init__.py b/src/richie/apps/courses/settings/__init__.py index 877e0f10b4..d9203c74e2 100644 --- a/src/richie/apps/courses/settings/__init__.py +++ b/src/richie/apps/courses/settings/__init__.py @@ -578,3 +578,14 @@ def richie_placeholder_conf(name): "sizes": "60px", }, } + +# If true the toolbar item will already be showed. If false only a page which already +# have the extension will have the toolbar item and users won't be able to add +# MainMenuEntry extension on existing page, only create new page with index extension +# through the wizard. +RICHIE_MAINMENUENTRY_ALLOW_CREATION = False + +# Define which node level can be processed to search for MainMenuEntry extension. You +# can set it to 'None' for never processing any node. +# This is a limit against performance issues to avoid making querysets for nothing. +RICHIE_MAINMENUENTRY_MENU_ALLOWED_LEVEL = 0 diff --git a/tests/apps/courses/test_cms_toolbars.py b/tests/apps/courses/test_cms_toolbars.py index 2bdc03fd0f..b08fc411a7 100644 --- a/tests/apps/courses/test_cms_toolbars.py +++ b/tests/apps/courses/test_cms_toolbars.py @@ -14,6 +14,7 @@ from richie.apps.core.factories import UserFactory from richie.apps.courses.factories import ( CourseFactory, + MainMenuEntryFactory, OrganizationFactory, PersonFactory, ) @@ -211,6 +212,114 @@ def test_cms_toolbars_no_page_extension(self): results = page_menu.find_items(ModalItem, name="Person settings...") self.assertEqual(results, []) + # Check that the index page item is absent with default settings + results = page_menu.find_items(ModalItem, name="Main menu settings...") + self.assertEqual(results, []) + + @override_settings(CMS_PERMISSION=False) + def test_cms_toolbars_menuentry_extension_availability(self): + """ + MainMenuEntry extension has advanced toolbar behaviors depending from settings. + """ + # Testing with a superuser proves our point + superuser = UserFactory(is_staff=True, is_superuser=True) + + # Create a page not related to any page extension + page = create_page( + "A page", template="richie/single_column.html", language="en" + ) + # Create a page on level 0 and with existing extension + menuentry = MainMenuEntryFactory(page_parent=page) + + cases = [[False, False], [False, True], [True, False]] + + for args in cases: + # Extension should not be created from toolbar for any level but could be + # edited from tree level 0 + with self.settings( + RICHIE_MAINMENUENTRY_ALLOW_CREATION=False, + RICHIE_MAINMENUENTRY_MENU_ALLOWED_LEVEL=0, + ): + toolbar = self.get_toolbar_for_page(page, superuser, *args) + page_menu = toolbar.find_items(Menu, name="Page")[0].item + + # menuentry page entry is present + results = page_menu.find_items(ModalItem, name="Main menu settings...") + self.assertEqual(results, []) + + toolbar = self.get_toolbar_for_page( + menuentry.extended_object, superuser, *args + ) + page_menu = toolbar.find_items(Menu, name="Page")[0].item + + # menuentry page entry is present + results = page_menu.find_items(ModalItem, name="Main menu settings...") + self.assertEqual(results, []) + + # Extension should not be created from toolbar for any level but could be + # edited from tree level 1 + with self.settings( + RICHIE_MAINMENUENTRY_ALLOW_CREATION=False, + RICHIE_MAINMENUENTRY_MENU_ALLOWED_LEVEL=1, + ): + toolbar = self.get_toolbar_for_page(page, superuser, *args) + page_menu = toolbar.find_items(Menu, name="Page")[0].item + + # menuentry page entry is present + results = page_menu.find_items(ModalItem, name="Main menu settings...") + self.assertEqual(results, []) + + toolbar = self.get_toolbar_for_page( + menuentry.extended_object, superuser, *args + ) + page_menu = toolbar.find_items(Menu, name="Page")[0].item + + # menuentry page entry is present + results = page_menu.find_items(ModalItem, name="Main menu settings...") + self.assertEqual(len(results), 1) + + # Extension should be created or edited from toolbar for tree level 0 + with self.settings( + RICHIE_MAINMENUENTRY_ALLOW_CREATION=True, + RICHIE_MAINMENUENTRY_MENU_ALLOWED_LEVEL=0, + ): + toolbar = self.get_toolbar_for_page(page, superuser, *args) + page_menu = toolbar.find_items(Menu, name="Page")[0].item + + # menuentry page entry is present + results = page_menu.find_items(ModalItem, name="Main menu settings...") + self.assertEqual(len(results), 1) + + toolbar = self.get_toolbar_for_page( + menuentry.extended_object, superuser, *args + ) + page_menu = toolbar.find_items(Menu, name="Page")[0].item + + # menuentry page entry is present + results = page_menu.find_items(ModalItem, name="Main menu settings...") + self.assertEqual(results, []) + + # Extension should be created or edited from toolbar for tree level 1 + with self.settings( + RICHIE_MAINMENUENTRY_ALLOW_CREATION=True, + RICHIE_MAINMENUENTRY_MENU_ALLOWED_LEVEL=1, + ): + toolbar = self.get_toolbar_for_page(page, superuser, *args) + page_menu = toolbar.find_items(Menu, name="Page")[0].item + + # menuentry page entry is present + results = page_menu.find_items(ModalItem, name="Main menu settings...") + self.assertEqual(results, []) + + toolbar = self.get_toolbar_for_page( + menuentry.extended_object, superuser, *args + ) + page_menu = toolbar.find_items(Menu, name="Page")[0].item + + # menuentry page entry is present + results = page_menu.find_items(ModalItem, name="Main menu settings...") + self.assertEqual(len(results), 1) + @override_settings(CMS_PERMISSION=False) # pylint: disable=too-many-locals def test_cms_toolbars_person_has_page_extension_settings_item(self): @@ -262,3 +371,18 @@ def test_cms_toolbars_person_has_page_extension_settings_item(self): item = method(toolbar, "Person settings...") if item: self.assertEqual(item.url, url) + + @override_settings(CMS_PERMISSION=False) + def test_cms_toolbars_menuentry_has_page_extension_settings_item(self): + """ + Validate that a new item to edit the menu entry is available only when + visiting the page in edit mode and for users with permission to edit the page. + """ + menuentry = MainMenuEntryFactory() + url = f"/en/admin/courses/mainmenuentry/{menuentry.id:d}/change/" + + for args, method in self.get_cases_for_page_change(): + toolbar = self.get_toolbar_for_page(menuentry.extended_object, *args) + item = method(toolbar, "Main menu settings...") + if item: + self.assertEqual(item.url, url)