dynamicweb/cms/cms_toolbars.py

730 lines
29 KiB
Python

from django.conf import settings
from django.contrib import admin
from django.contrib.auth import get_permission_codename, get_user_model
from django.contrib.auth.models import AnonymousUser
from django.contrib.sites.models import Site
from django.urls import NoReverseMatch, Resolver404, resolve, reverse
from django.utils.translation import (
gettext_lazy as _,
override as force_language,
)
from cms.api import can_change_page
from cms.constants import TEMPLATE_INHERITANCE_MAGIC
from cms.models import Page, PageContent, PageType, Placeholder
from cms.toolbar.items import REFRESH_PAGE, ButtonList, SubMenu, TemplateItem
from cms.toolbar.utils import (
get_object_edit_url,
get_object_preview_url,
get_object_structure_url,
)
from cms.toolbar_base import CMSToolbar
from cms.toolbar_pool import toolbar_pool
from cms.utils import get_language_from_request, page_permissions
from cms.utils.conf import get_cms_setting
from cms.utils.i18n import get_language_dict, get_language_tuple
from cms.utils.page_permissions import (
user_can_change_page,
user_can_delete_page,
user_can_view_page,
)
from cms.utils.permissions import user_can_view_placeholder_source
from cms.utils.urlutils import add_url_parameters, admin_reverse
from menus.utils import DefaultLanguageChanger
# Identifiers for search
ADMIN_MENU_IDENTIFIER = "admin-menu"
"""
The *Site* menu (that usually shows the project's domain name, *example.com* by default).
``ADMIN_MENU_IDENTIFIER`` allows you to get hold of this object easily using
:meth:`cms.toolbar.toolbar.CMSToolbar.get_menu`.
"""
LANGUAGE_MENU_IDENTIFIER = "language-menu"
"""
The *Language* menu. ``LANGUAGE_MENU_IDENTIFIER`` allows you to get hold of this object
easily using :meth:`cms.toolbar.toolbar.CMSToolbar.get_menu`.
"""
HELP_MENU_IDENTIFIER = "help-menu"
"""
The *Help* menu. ``HELP_MENU_IDENTIFIER`` allows you to get hold of this object easily
using :meth:`cms.toolbar.toolbar.CMSToolbar.get_menu`.
"""
HELP_MENU_BREAK = "Help Menu Break"
TEMPLATE_MENU_BREAK = "Template Menu Break"
PAGE_MENU_IDENTIFIER = "page"
"""
The *Page* menu. ``PAGE_MENU_IDENTIFIER`` allows you to get hold of this object
easily using :meth:`cms.toolbar.toolbar.CMSToolbar.get_menu`.
"""
PAGE_MENU_ADD_IDENTIFIER = "add_page"
PAGE_MENU_FIRST_BREAK = "Page Menu First Break"
PAGE_MENU_SECOND_BREAK = "Page Menu Second Break"
PAGE_MENU_THIRD_BREAK = "Page Menu Third Break"
PAGE_MENU_FOURTH_BREAK = "Page Menu Fourth Break"
PAGE_MENU_LAST_BREAK = "Page Menu Last Break"
HISTORY_MENU_BREAK = "History Menu Break"
MANAGE_PAGES_BREAK = "Manage Pages Break"
ADMIN_SITES_BREAK = "Admin Sites Break"
ADMINISTRATION_BREAK = "Administration Break"
CLIPBOARD_BREAK = "Clipboard Break"
USER_SETTINGS_BREAK = "User Settings Break"
ADD_PAGE_LANGUAGE_BREAK = "Add page language Break"
REMOVE_PAGE_LANGUAGE_BREAK = "Remove page language Break"
COPY_PAGE_LANGUAGE_BREAK = "Copy page language Break"
TOOLBAR_DISABLE_BREAK = "Toolbar disable Break"
SHORTCUTS_BREAK = "Shortcuts Break"
DEFAULT_HELP_MENU_ITEMS = (
(_("Documentation"), "https://docs.django-cms.org/en/stable/"),
(_("User guide"), "https://user-guide.django-cms.org"),
(_("Discord community"), "https://www.django-cms.org/discord"),
(_("What's new"), "https://www.django-cms.org/en/blog/"),
)
@toolbar_pool.register
class PlaceholderToolbar(CMSToolbar):
"""
Adds placeholder edit buttons if placeholders or static placeholders are detected in the template
"""
def populate(self):
self.page = self.request.current_page
def post_template_populate(self):
super().post_template_populate()
self.add_wizard_button()
self.render_object_editable_buttons()
def add_wizard_button(self):
from cms.wizards.wizard_pool import entry_choices
title = _("Create")
user = self.request.user
page_pk = self.page.pk if self.page else ""
disabled = not list(entry_choices(user, self.page, site=self.current_site))
url = "{url}?page={page}&language={lang}&edit".format(
url=admin_reverse("cms_wizard_create"),
page=page_pk,
lang=self.toolbar.site_language,
)
self.toolbar.add_modal_button(title, url, side=self.toolbar.RIGHT, disabled=disabled, on_close=REFRESH_PAGE)
def render_object_editable_buttons(self):
self.init_placeholders()
if not self.toolbar.obj:
return
# Edit button
if self.toolbar.content_mode_active and self._can_add_button():
self.add_edit_button()
# Preview button
if not self.toolbar.preview_mode_active and get_cms_setting("TEMPLATES") and self._can_add_button():
# Only add preview button if there are templates available for previewing
self.add_preview_button()
# Structure mode
if self._can_add_structure_mode():
self.add_structure_mode()
def init_placeholders(self):
request = self.request
toolbar = self.toolbar
if toolbar._async and "placeholders[]" in request.GET:
# AJAX request to reload page structure.
placeholder_ids = request.GET.getlist("placeholders[]")
self.placeholders = Placeholder.objects.filter(pk__in=placeholder_ids)
else:
if toolbar.structure_mode_active and not toolbar.uses_legacy_structure_mode:
# User has explicitly requested structure mode.
# and the object (page, blog, etc..) allows for the non-legacy structure mode.
renderer = toolbar.structure_renderer
else:
renderer = toolbar.get_content_renderer()
self.placeholders = renderer.get_rendered_placeholders()
# Helpers to check whether buttons can be rendered
def _has_page_change_perm(self):
if self.page and user_can_change_page(self.request.user, page=self.page):
return True
return False
def _has_placeholder_change_perm(self):
if not self.placeholders:
return False
return any(ph for ph in self.placeholders if ph.has_change_permission(self.request.user))
def _has_page_view_perm(self):
if self.page and user_can_view_page(self.request.user, self.page):
return True
return False
def _has_placeholder_view_perm(self):
obj = self.toolbar.obj
if obj is None:
return False
return user_can_view_placeholder_source(self.request.user, obj)
def _can_add_button(self):
if self._has_page_change_perm():
return True
elif self._has_placeholder_change_perm():
return True
return False
def _can_add_structure_mode(self):
# Viewing the structure board only requires view permission: editing
# the plugins stays gated by change permission at the plugin endpoints.
# This lets headless reviewers (and view-only staff) inspect content
# structure without edit rights.
if not self.request.user.has_perm("cms.use_structure"):
return False
if self.page and not self.page.application_urls and self._has_page_view_perm():
return True
elif self._has_placeholder_view_perm():
return True
return False
# Buttons
def add_edit_button(self):
url = get_object_edit_url(self.toolbar.obj, language=self.toolbar.request_language, params=self.request.GET)
item = ButtonList(side=self.toolbar.RIGHT)
item.add_button(
_("Edit"),
url=url,
disabled=False,
extra_classes=["cms-btn", "cms-btn-action", "cms-btn-switch-edit"],
)
self.toolbar.add_item(item)
def add_preview_button(self):
url = get_object_preview_url(self.toolbar.obj, language=self.toolbar.request_language, params=self.request.GET)
if url:
item = ButtonList(side=self.toolbar.RIGHT)
item.add_button(
_("Preview"),
url=url,
disabled=False,
extra_classes=["cms-btn", "cms-btn-switch-save"],
)
self.toolbar.add_item(item)
def add_structure_mode(self, extra_classes=("cms-toolbar-item-cms-mode-switcher",)):
structure_active = self.toolbar.structure_mode_active
edit_mode_active = not structure_active and self.toolbar.edit_mode_active
build_url = get_object_structure_url(
self.toolbar.obj, language=self.toolbar.request_language, params=self.request.GET
)
edit_url = get_object_edit_url(
self.toolbar.obj, language=self.toolbar.request_language, params=self.request.GET
)
switcher = self.toolbar.add_button_list(
"Mode Switcher",
side=self.toolbar.RIGHT,
extra_classes=extra_classes,
)
switcher.add_button(
_("Structure"),
build_url,
active=structure_active,
disabled=False,
extra_classes="cms-structure-btn",
)
switcher.add_button(
_("Content"),
edit_url,
active=edit_mode_active,
disabled=False,
extra_classes="cms-content-btn",
)
@toolbar_pool.register
class AppearanceToolbar(CMSToolbar):
"""
Adds appearance switches, esp. for dark and light mode
"""
color_scheme_toggle = get_cms_setting("COLOR_SCHEME_TOGGLE")
def populate(self):
if self.color_scheme_toggle:
dark_mode_toggle = TemplateItem(
template="cms/toolbar/items/dark_mode_toggle.html",
side=self.toolbar.RIGHT,
)
self.toolbar.add_item(dark_mode_toggle)
@toolbar_pool.register
class HelpToolbar(CMSToolbar):
"""
Adds a help menu to the toolbar
"""
def populate(self):
if not get_cms_setting("ENABLE_HELP"):
return
help_menu = self._get_or_create_help_menu()
self._add_default_menu_items(help_menu)
self._add_extra_menu_items(help_menu)
def _get_or_create_help_menu(self) -> SubMenu:
help_menu = self.toolbar.get_or_create_menu(HELP_MENU_IDENTIFIER, _("Help"), position=-1)
help_menu.items = [] # reset the items so we don't duplicate
return help_menu
def _add_default_menu_items(self, help_menu: SubMenu):
for label, url in DEFAULT_HELP_MENU_ITEMS:
help_menu.add_link_item(label, url=url)
def _add_extra_menu_items(self, help_menu: SubMenu):
if extra_menu_items := get_cms_setting("EXTRA_HELP_MENU_ITEMS"):
help_menu.add_break(HELP_MENU_BREAK)
for label, url in extra_menu_items:
help_menu.add_link_item(label, url=url)
@toolbar_pool.register
class BasicToolbar(CMSToolbar):
"""
Basic Toolbar for site and languages menu
"""
page = None
_language_menu = None
_admin_menu = None
def init_from_request(self):
self.page = self.request.current_page
def populate(self):
if not self.page:
self.init_from_request()
self.clipboard = self.request.toolbar.user_settings.clipboard
self.add_admin_menu()
self.add_language_menu()
def add_admin_menu(self):
if not self._admin_menu:
self._admin_menu = self.toolbar.get_or_create_menu(ADMIN_MENU_IDENTIFIER, self.current_site.name)
# Users button
self.add_users_button(self._admin_menu)
# sites menu
sites_queryset = Site.objects.order_by("name")
if len(sites_queryset) > 1:
sites_menu = self._admin_menu.get_or_create_menu("sites", _("Sites"))
sites_menu.add_sideframe_item(_("Admin Sites"), url=admin_reverse("sites_site_changelist"))
sites_menu.add_break(ADMIN_SITES_BREAK)
for site in sites_queryset:
sites_menu.add_link_item(
site.name, url="//%s" % site.domain, active=site.pk == self.current_site.pk
)
# admin
self._admin_menu.add_sideframe_item(_("Administration"), url=admin_reverse("index"))
self._admin_menu.add_break(ADMINISTRATION_BREAK)
# cms users settings
self._admin_menu.add_sideframe_item(_("User settings"), url=admin_reverse("cms_usersettings_change"))
self._admin_menu.add_break(USER_SETTINGS_BREAK)
# clipboard
if self.toolbar.edit_mode_active:
# True if the clipboard exists and there's plugins in it.
clipboard_is_bound = self.toolbar.clipboard_plugin
self._admin_menu.add_link_item(
_("Clipboard..."),
url="#",
extra_classes=["cms-clipboard-trigger"],
disabled=not clipboard_is_bound,
)
self._admin_menu.add_link_item(
_("Clear clipboard"),
url="#",
extra_classes=["cms-clipboard-empty"],
disabled=not clipboard_is_bound,
)
self._admin_menu.add_break(CLIPBOARD_BREAK)
# Disable toolbar
self._admin_menu.add_link_item(
_("Disable toolbar"), url="?%s" % get_cms_setting("CMS_TOOLBAR_URL__DISABLE")
)
self._admin_menu.add_break(TOOLBAR_DISABLE_BREAK)
self._admin_menu.add_link_item(_("Shortcuts..."), url="#", extra_classes=("cms-show-shortcuts",))
self._admin_menu.add_break(SHORTCUTS_BREAK)
# logout
self.add_logout_button(self._admin_menu)
def add_users_button(self, parent):
User = get_user_model()
if User in admin.site._registry:
opts = User._meta
if self.request.user.has_perm("{}.{}".format(opts.app_label, get_permission_codename("change", opts))):
user_changelist_url = admin_reverse(f"{opts.app_label}_{opts.model_name}_changelist")
parent.add_sideframe_item(_("Users"), url=user_changelist_url)
def add_logout_button(self, parent):
if self.page and not self.page.login_required:
anon_can_access = page_permissions.user_can_view_page(
user=AnonymousUser(),
page=self.page,
site=self.current_site,
)
else:
anon_can_access = False
on_success = self.toolbar.REFRESH_PAGE if anon_can_access else "/"
# We'll show "Logout Joe Bloggs" if the name fields in auth.User are completed, else "Logout jbloggs". If
# anything goes wrong, it'll just be "Logout".
user_name = self.get_username()
logout_menu_text = _("Logout %s") % user_name if user_name else _("Logout")
parent.add_ajax_item(
logout_menu_text,
action=admin_reverse("logout"),
active=True,
on_success=on_success,
method="POST",
)
def add_language_menu(self):
if settings.USE_I18N and not self._language_menu:
languages = get_language_tuple(self.current_site.pk)
if len(languages) > 1:
# Menu only meaningful if more than one language is installed
self._language_menu = self.toolbar.get_or_create_menu(
LANGUAGE_MENU_IDENTIFIER, _("Language"), position=-1
)
language_changer = getattr(self.request, "_language_changer", DefaultLanguageChanger(self.request))
for code, name in languages:
try:
url = language_changer(code)
except NoReverseMatch:
url = DefaultLanguageChanger(self.request)(code)
if url:
self._language_menu.add_link_item(name, url=url, active=self.current_lang == code)
else:
# We do not have to check every time the toolbar is created
self._language_menu = True # Pretend the language menu is already there
def get_username(self, user=None, default=""):
user = user or self.request.user
try:
name = user.get_full_name()
if name:
return name
else:
return user.get_username()
except (AttributeError, NotImplementedError):
return default
@toolbar_pool.register
class PageToolbar(CMSToolbar):
_changed_admin_menu = None
watch_models = [Page, PageType]
def get_page_content(self):
if not getattr(self, "page", None):
# No page, no page content
return None
if hasattr(self, "obj") and isinstance(self.obj, PageContent):
# Toolbar object already set (e.g., in edit or preview mode)
return self.obj
# Get from db
page_content = self.page.get_admin_content(language=self.current_lang, fallback=False)
return page_content or None
def has_page_change_permission(self):
if not hasattr(self, "page_change_permission"):
self.page_change_permission = can_change_page(self.request) and self.toolbar.object_is_editable()
return self.page_change_permission
def in_apphook(self):
with force_language(self.toolbar.request_language):
try:
resolver = resolve(self.toolbar.request_path)
except Resolver404:
return False
else:
cms_views = ("render_object_edit", "render_object_preview", "render_object_structure", "details")
return resolver.func.__name__ not in cms_views
def in_apphook_root(self):
"""
Returns True if the request is for a page handled by an apphook, but
is also the page it is attached to.
:return: Boolean
"""
page = getattr(self.request, "current_page", False)
if page:
language = get_language_from_request(self.request)
return self.toolbar.request_path == page.get_absolute_url(language=language)
return False
def get_on_delete_redirect_url(self):
language = self.current_lang
parent_page = self.page.parent if self.page else None
# if the current page has a parent in the request's current language redirect to it
if parent_page and language in parent_page.get_languages():
return get_object_preview_url(
parent_page.pagecontent_set(manager="admin_manager").latest_content(language=language).first()
)
# else redirect to root, do not redirect to Page.objects.get_home() because user could have deleted the last
# page, if DEBUG == False this could cause a 404
try:
return reverse("pages-root")
except NoReverseMatch:
return admin_reverse("cms_pagecontent_changelist")
# Populate
def populate(self):
self.page = self.request.current_page
self.page_content = self.get_page_content()
self.permissions_activated = get_cms_setting("PERMISSION")
self.change_admin_menu()
self.add_page_menu()
self.change_language_menu()
# Menus
def change_language_menu(self):
if self.toolbar.edit_mode_active and self.page:
can_change = page_permissions.user_can_change_page(
user=self.request.user,
page=self.page,
site=self.current_site,
)
else:
can_change = False
if can_change:
language_menu = self.toolbar.get_menu(LANGUAGE_MENU_IDENTIFIER)
if not language_menu:
return None
languages = get_language_dict(self.current_site.pk)
remove = [(code, languages.get(code, code)) for code in self.page.get_languages() if code in languages]
add = [lang for lang in languages.items() if lang not in remove]
copy = [
(code, name)
for code, name in languages.items()
if code != self.current_lang and (code, name) in remove
]
if add or remove or copy:
language_menu.add_break(ADD_PAGE_LANGUAGE_BREAK)
if add:
add_plugins_menu = language_menu.get_or_create_menu(
f"{LANGUAGE_MENU_IDENTIFIER}-add", _("Add Translation")
)
page_add_url = admin_reverse("cms_pagecontent_add")
for code, name in add:
url = add_url_parameters(
page_add_url, cms_page=self.page.pk, parent_page=self.page.id, language=code
)
add_plugins_menu.add_modal_item(name, url=url)
if remove:
remove_plugins_menu = language_menu.get_or_create_menu(
f"{LANGUAGE_MENU_IDENTIFIER}-del", _("Delete Translation")
)
disabled = len(remove) == 1
for code, name in remove:
pagecontent = self.page.get_content_obj(code)
if pagecontent:
translation_delete_url = admin_reverse("cms_pagecontent_delete", args=(pagecontent.pk,))
url = add_url_parameters(translation_delete_url, language=code)
remove_plugins_menu.add_modal_item(name, url=url, disabled=disabled)
if copy:
copy_plugins_menu = language_menu.get_or_create_menu(
f"{LANGUAGE_MENU_IDENTIFIER}-copy", _("Copy all plugins")
)
title = _("from %s")
question = _("Are you sure you want to copy all plugins from %s?")
for code, name in copy:
pagecontent = self.page.get_content_obj(code)
page_copy_url = admin_reverse("cms_pagecontent_copy_language", args=(pagecontent.pk,))
copy_plugins_menu.add_ajax_item(
title % name,
action=page_copy_url,
data={"source_language": code, "target_language": self.current_lang},
question=question % name,
on_success=self.toolbar.REFRESH_PAGE,
)
def change_admin_menu(self):
can_change_page = self.has_page_change_permission()
if not can_change_page:
# Check if the user has permissions to change at least one page
can_change_page = page_permissions.user_can_change_at_least_one_page(
user=self.request.user,
site=self.current_site,
)
if not self._changed_admin_menu and can_change_page:
admin_menu = self.toolbar.get_or_create_menu(ADMIN_MENU_IDENTIFIER)
url = admin_reverse("cms_pagecontent_changelist") # cms page admin
params = {"language": self.toolbar.request_language}
if self.page:
params["page_id"] = self.page.pk
url = add_url_parameters(url, params)
admin_menu.add_sideframe_item(_("Pages"), url=url, position=0)
# Used to prevent duplicates
self._changed_admin_menu = True
def add_page_menu(self):
if self.page and self.page_content:
edit_mode = self.toolbar.edit_mode_active
refresh = self.toolbar.REFRESH_PAGE
can_change = user_can_change_page(
user=self.request.user,
page=self.page,
site=self.current_site,
)
# menu for current page
# NOTE: disabled if the current path is "deeper" into the
# application's url patterns than its root. This is because
# when the Content Manager is at the root of the app-hook,
# some of the page options still make sense.
current_page_menu = self.toolbar.get_or_create_menu(
PAGE_MENU_IDENTIFIER, _("Page"), position=1, disabled=self.in_apphook() and not self.in_apphook_root()
)
new_page_params = {"edit": 1, "site": self.page.site_id}
new_sub_page_params = {"edit": 1, "parent_page": self.page.id}
add_page_url = admin_reverse("cms_pagecontent_add")
advanced_url = admin_reverse("cms_page_advanced", args=(self.page.pk,))
page_settings_url = admin_reverse("cms_pagecontent_change", args=(self.page_content.pk,))
duplicate_page_url = admin_reverse("cms_pagecontent_duplicate", args=[self.page_content.pk])
can_add_root_page = page_permissions.user_can_add_page(
user=self.request.user,
site=self.current_site,
)
if self.page.parent:
new_page_params["parent_page"] = self.page.parent.id
can_add_sibling_page = page_permissions.user_can_add_subpage(
user=self.request.user,
target=self.page.parent,
)
else:
can_add_sibling_page = can_add_root_page
can_add_sub_page = page_permissions.user_can_add_subpage(
user=self.request.user,
target=self.page,
)
# page operations menu
add_page_menu = current_page_menu.get_or_create_menu(
PAGE_MENU_ADD_IDENTIFIER,
_("Create Page"),
)
add_page_menu_modal_items = (
(_("New Page"), new_page_params, can_add_sibling_page),
(_("New Sub Page"), new_sub_page_params, can_add_sub_page),
)
for title, params, has_perm in add_page_menu_modal_items:
params.update(language=self.toolbar.request_language)
add_page_menu.add_modal_item(
title,
url=add_url_parameters(add_page_url, params),
disabled=not has_perm,
)
add_page_menu.add_modal_item(
_("Duplicate this Page"),
url=add_url_parameters(duplicate_page_url, {"language": self.toolbar.request_language}),
disabled=not can_add_sibling_page,
)
# first break
current_page_menu.add_break(PAGE_MENU_FIRST_BREAK)
# page settings
page_settings_url = add_url_parameters(page_settings_url, language=self.toolbar.request_language)
settings_disabled = not can_change
current_page_menu.add_modal_item(
_("Page settings"), url=page_settings_url, disabled=settings_disabled, on_close=refresh
)
# advanced settings
advanced_url = add_url_parameters(advanced_url, language=self.toolbar.request_language)
can_change_advanced = self.page.has_advanced_settings_permission(self.request.user)
can_change_permissions = get_cms_setting("PERMISSION") and self.page.has_change_permissions_permission(
self.request.user
)
advanced_disabled = not can_change_advanced and not can_change_permissions
current_page_menu.add_modal_item(_("Advanced settings"), url=advanced_url, disabled=advanced_disabled)
# templates menu
if edit_mode:
action = admin_reverse("cms_pagecontent_change_template", args=(self.page_content.pk,))
if get_cms_setting("TEMPLATES"):
options = self.page_content.template_choices
template_menu = _("Templates")
else:
options = [(placeholders[0], placeholders[2]) for placeholders in get_cms_setting("PLACEHOLDERS")]
template_menu = _("Placeholders")
if options:
templates_menu = current_page_menu.get_or_create_menu(
"templates",
template_menu,
disabled=not can_change,
)
for path, name in options:
active = self.page_content.template == path
if path == TEMPLATE_INHERITANCE_MAGIC:
templates_menu.add_break(TEMPLATE_MENU_BREAK)
templates_menu.add_ajax_item(
name, action=action, data={"template": path}, active=active, on_success=refresh
)
# navigation toggle
in_navigation = self.page_content.in_navigation
nav_title = _("Hide in navigation") if in_navigation else _("Display in navigation")
nav_action = admin_reverse("cms_pagecontent_change_innavigation", args=(self.page_content.pk,))
current_page_menu.add_ajax_item(
nav_title,
action=nav_action,
disabled=(not edit_mode or not can_change),
on_success=refresh,
)
# delete
delete_url = admin_reverse("cms_page_delete", args=(self.page.pk,))
delete_disabled = not edit_mode or not user_can_delete_page(self.request.user, page=self.page)
on_delete_redirect_url = self.get_on_delete_redirect_url() if not delete_disabled else None
current_page_menu.add_modal_item(
_("Delete page"), url=delete_url, on_close=on_delete_redirect_url, disabled=delete_disabled
)