clone cms with menus, make menus standalone app

This commit is contained in:
bruks 2026-09-30 15:27:04 +01:00
commit afb096b7a3
1153 changed files with 318897 additions and 37 deletions

0
cms/forms/__init__.py Normal file
View file

116
cms/forms/fields.py Normal file
View file

@ -0,0 +1,116 @@
from django import forms
from django.contrib.admin.widgets import RelatedFieldWidgetWrapper
from django.core.validators import EMPTY_VALUES
from django.forms import ChoiceField
from django.utils.translation import gettext_lazy as _
from cms.forms.utils import get_page_choices, get_site_choices
from cms.forms.validators import validate_url
from cms.forms.widgets import PageSelectWidget, PageSmartLinkWidget
from cms.models.pagemodel import Page
class PageSelectFormField(forms.MultiValueField):
"""
Behaves like a :class:`django.forms.ModelChoiceField` field for the
:class:`cms.models.pagemodel.Page` model, but displays itself as a split
field with a select drop-down for the site and one for the page. It also
indents the page names based on what level they're on, so that the page
select drop-down is easier to use. This takes the same arguments as
:class:`django.forms.ModelChoiceField`.
"""
widget = PageSelectWidget
default_error_messages = {
'invalid_site': _('Select a valid site'),
'invalid_page': _('Select a valid page'),
}
def __init__(self, queryset=None, empty_label="---------", cache_choices=False,
required=True, widget=None, to_field_name=None, limit_choices_to=None, *args, **kwargs):
errors = self.default_error_messages.copy()
if 'error_messages' in kwargs:
errors.update(kwargs['error_messages'])
self.limit_choices_to = limit_choices_to
kwargs['required'] = required
fields = (
ChoiceField(choices=get_site_choices, required=False, error_messages={'invalid': errors['invalid_site']}),
ChoiceField(choices=get_page_choices, required=False, error_messages={'invalid': errors['invalid_page']}),
)
# Remove the unexpected blank kwarg if it's supplied,
# causes an error where the MultiValueField doesn't expect it
# https://github.com/django/django/commit/da79ee472d803963dc3ea81ee67767dc06068aac
if 'blank' in kwargs:
del kwargs['blank']
super().__init__(fields, *args, **kwargs)
def compress(self, data_list):
if data_list:
page_id = data_list[1]
if page_id in EMPTY_VALUES:
if not self.required:
return None
raise forms.ValidationError(self.error_messages['invalid_page'])
return Page.objects.get(pk=page_id)
return None
def has_changed(self, initial, data):
is_empty = data and (len(data) >= 2 and data[1] in [None, ''])
if isinstance(self.widget, RelatedFieldWidgetWrapper):
self.widget.decompress = self.widget.widget.decompress
if is_empty and initial is None:
# when empty data will have [u'1', u'', u''] as value
# this will cause django to always return True because of the '1'
# so we simply follow django's default behavior when initial is None and data is "empty"
data = ['' for x in range(0, len(data))]
return super().has_changed(initial, data)
def _has_changed(self, initial, data):
return self.has_changed(initial, data)
class PageSmartLinkField(forms.CharField):
"""
A field making use of ``cms.forms.widgets.PageSmartLinkWidget``.
This field will offer you a list of matching internal pages as you type.
You can either pick one or enter an arbitrary URL to create a non-existing entry.
Takes a `placeholder_text` argument to define the text displayed inside the
input before you type.
The widget uses an ajax request to try to find pages match. It will try to find
case-insensitive matches amongst public and published pages on the `title`, `path`,
`page_title`, `menu_title` fields.
.. note::
``PageSmartLinkField`` stores its value as a plain URL string, so links
do not follow pages that are later moved or renamed. For dynamically
linking to pages, other CMS content, or external URLs, the preferred
approach is the ``LinkField`` provided by
`djangocms-link <https://github.com/django-cms/djangocms-link>`_, which
keeps references stable across moves and supports a configurable set of
link targets.
"""
widget = PageSmartLinkWidget
default_validators = [validate_url]
def __init__(self, max_length=None, min_length=None, placeholder_text=None,
ajax_view=None, *args, **kwargs):
self.placeholder_text = placeholder_text
widget = self.widget(ajax_view=ajax_view)
super().__init__(
max_length=max_length, min_length=min_length, widget=widget, *args, **kwargs
)
def widget_attrs(self, widget):
attrs = super().widget_attrs(widget)
attrs.update({'placeholder_text': self.placeholder_text})
return attrs
def clean(self, value):
value = self.to_python(value).strip()
return super().clean(value)

15
cms/forms/login.py Normal file
View file

@ -0,0 +1,15 @@
from django import forms
from django.contrib.auth.forms import AuthenticationForm
class CMSToolbarLoginForm(AuthenticationForm):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
kwargs['prefix'] = kwargs.get('prefix', 'cms')
self.fields['username'].widget = forms.TextInput(
attrs={'required': 'required'}
)
self.fields['password'].widget = forms.PasswordInput(
attrs={'required': 'required'}
)

107
cms/forms/utils.py Normal file
View file

@ -0,0 +1,107 @@
from django.contrib.sites.models import Site
from django.db.models import Prefetch
from django.db.models.signals import post_delete, post_save
from django.utils.html import escape
from django.utils.safestring import mark_safe
from cms.cache.choices import (
_page_cache_key,
_site_cache_key,
clean_page_choices_cache,
clean_site_choices_cache,
)
from cms.models import Page
from cms.utils import i18n
def get_sites():
sites = (
Site
.objects
.filter(djangocms_pages__isnull=False)
.order_by('name')
.distinct()
)
return sites
def get_page_choices_for_site(site, language):
from cms.models import PageContent
fallbacks = i18n.get_fallback_languages(language, site_id=site.pk)
languages = [language] + fallbacks
translation_lookup = Prefetch(
'pagecontent_set',
to_attr='filtered_translations',
queryset=PageContent.objects.filter(language__in=languages).only('pk', 'page', 'language', 'title')
)
pages = (
Page
.objects
.on_site(site)
.prefetch_related(translation_lookup)
.order_by('path')
.only('pk')
)
for page in pages:
translations = page.filtered_translations
pagecontent_by_language = {trans.language: trans.title for trans in translations}
for lang in languages:
# EmptyPageContent is used to prevent the cms from trying
# to find a translation in the database
if lang in pagecontent_by_language:
title = pagecontent_by_language[lang]
indent = "&nbsp;&nbsp;" * (page.depth - 1)
label = mark_safe(f"{indent}{escape(title)}")
yield (page.pk, label)
break
def update_site_and_page_choices(language=None):
if language is None:
language = i18n.get_current_language()
site_choices = []
page_choices = [('', '----')]
site_choices_key = _site_cache_key(language)
page_choices_key = _page_cache_key(language)
for site in get_sites():
_page_choices = list(get_page_choices_for_site(site, language))
site_choices.append((site.pk, site.name))
page_choices.append((site.name, _page_choices))
from django.core.cache import cache
# We set it to 1 day here because we actively invalidate this cache.
cache.set(site_choices_key, site_choices, 86400)
cache.set(page_choices_key, page_choices, 86400)
return site_choices, page_choices
def get_site_choices(lang=None):
from django.core.cache import cache
lang = lang or i18n.get_current_language()
site_choices = cache.get(_site_cache_key(lang))
if site_choices is None:
site_choices, _ = update_site_and_page_choices(lang)
return site_choices
def get_page_choices(lang=None):
from django.core.cache import cache
lang = lang or i18n.get_current_language()
page_choices = cache.get(_page_cache_key(lang))
if page_choices is None:
_, page_choices = update_site_and_page_choices(lang)
return page_choices
post_save.connect(clean_page_choices_cache, sender=Page)
post_save.connect(clean_site_choices_cache, sender=Site)
post_delete.connect(clean_page_choices_cache, sender=Page)
post_delete.connect(clean_site_choices_cache, sender=Site)

117
cms/forms/validators.py Normal file
View file

@ -0,0 +1,117 @@
from typing import TYPE_CHECKING, Optional
from urllib.parse import urlsplit
from django.core.exceptions import ValidationError
from django.core.validators import RegexValidator, URLValidator
from django.utils.html import escape
from django.utils.safestring import mark_safe
from django.utils.translation import gettext
from cms.utils.urlutils import admin_reverse, path_segment_regex, relative_url_regex
if TYPE_CHECKING:
# Only needed for type hinting - avoid circular import
from cms.models.pagemodel import Page
def validate_relative_url(value):
RegexValidator(regex=relative_url_regex)(value)
def validate_url(value):
if "<" in value or ">" in value:
# ``relative_url_regex`` excludes angle brackets, but the absolute fallback below
# is Django's ``URLValidator``, whose path pattern accepts them as long as the
# value carries no space (``https://example.com/<script>alert(1)</script>``).
# Reject them before either branch runs, so markup cannot enter through a scheme.
raise ValidationError(
gettext("Enter a valid relative or absolute URL."),
code="invalid",
)
try:
# Validate relative urls first
validate_relative_url(value)
except ValidationError:
# Fallback to absolute urls
URLValidator()(value)
else:
# ``relative_url_regex`` only constrains the *characters* of a relative
# path, not the part before the first slash. Values such as
# ``javascript:alert(1)/x``, ``data:text/html,...`` or ``vbscript:.../x``
# therefore match the relative branch while still carrying a scheme that
# executes script when the value is later rendered into an ``href``
# (CWE-79). A genuine relative URL has no scheme, so reject any value
# that does. (Off-site targets such as ``https://example.com`` or
# ``//example.com`` are intentionally permitted -- this field allows
# absolute redirects -- and navigate rather than execute script.)
if urlsplit(value).scheme:
raise ValidationError(
gettext("Enter a valid relative or absolute URL."),
code="invalid",
)
def validate_path_segment(value):
"""Validate a single path segment, i.e. a path that contains no "/"."""
RegexValidator(
regex=path_segment_regex,
message=gettext("Enter a valid relative or absolute URL."),
code="invalid",
)(value)
def validate_path(value):
"""Validate a page path.
Unlike :func:`validate_url` this also accepts a single path segment such as ``contact``,
which ``relative_url_regex`` -- and therefore :func:`validate_url` -- never matches.
"""
if "/" in value:
validate_url(value)
else:
validate_path_segment(value)
def validate_url_uniqueness(
site, path: str, language: str, user_language: str | None = None, exclude_page: Optional["Page"] = None
):
"""Checks for conflicting urls"""
from cms.models.pagemodel import PageUrl
# Single-segment paths used to skip validation entirely, so an "Overwrite URL" without a
# "/" could hold arbitrary characters.
validate_path(path)
path = path.strip("/")
page_urls = PageUrl.objects.get_for_site(site, language=language).filter(path=path)
if exclude_page:
page_urls = page_urls.exclude(page=exclude_page.pk)
try:
conflict_page = page_urls[0].page
except IndexError:
return True
conflict_translation = conflict_page.get_content_obj(language, fallback=False)
if conflict_translation: # No empty page content
change_url = admin_reverse("cms_pagecontent_change", args=[conflict_translation.pk])
else:
change_url = "" # Empty page has no slug
if user_language:
change_url += f"?language={user_language}"
conflict_url = f'<a href="{change_url}" target="_blank">{escape(str(conflict_translation.title))}</a>'
if exclude_page:
message = gettext("Page %(conflict_page)s has the same url '%(url)s' as current page \"%(instance)s\".")
else:
message = gettext("Page %(conflict_page)s has the same url '%(url)s' as current page.")
message = message % {
"conflict_page": conflict_url,
"url": escape(path),
"instance": escape(exclude_page.get_title(language)) if exclude_page else "",
}
raise ValidationError(mark_safe(message))

249
cms/forms/widgets.py Normal file
View file

@ -0,0 +1,249 @@
from django.contrib.auth import get_permission_codename
from django.contrib.sites.models import Site
from django.forms.widgets import MultiWidget, Select, TextInput
from django.templatetags.static import static
from django.urls import NoReverseMatch, reverse_lazy
from django.utils.encoding import force_str
from django.utils.html import escape, format_html
from django.utils.safestring import mark_safe
from django.utils.translation import gettext as _
from cms.forms.utils import get_page_choices, get_site_choices
from cms.models import Page, PageUser
from cms.utils.urlutils import admin_reverse, static_with_version
class PageSelectWidget(MultiWidget):
"""A widget that allows selecting a page by first selecting a site and then
a page on that site in a two-step process.
"""
template_name = 'cms/widgets/pageselectwidget.html'
class Media:
js = (
static_with_version('cms/js/dist/bundle.forms.pageselectwidget.min.js'),
)
def __init__(self, site_choices=None, page_choices=None, attrs=None):
if attrs is not None:
self.attrs = attrs.copy()
else:
self.attrs = {}
self.choices = []
super().__init__((Select, Select, Select), attrs)
def decompress(self, value):
"""
receives a page_id in value and returns the site_id and page_id
of that page or the current site_id and None if no page_id is given.
"""
if value:
page = Page.objects.get(pk=value)
return [page.site_id, page.pk, page.pk]
site = Site.objects.first()
return [site.pk, None, None]
def _has_changed(self, initial, data):
# THIS IS A COPY OF django.forms.widgets.Widget._has_changed()
# (except for the first if statement)
"""
Return True if data differs from initial.
"""
# For purposes of seeing whether something has changed, None is
# the same as an empty string, if the data or initial value we get
# is None, replace it w/ ''.
if data is None or (len(data) >= 2 and data[1] in [None, '']):
data_value = ''
else:
data_value = data
if initial is None:
initial_value = ''
else:
initial_value = initial
if force_str(initial_value) != force_str(data_value):
return True
return False
def _build_widgets(self):
site_choices = get_site_choices()
page_choices = get_page_choices()
self.site_choices = site_choices
self.choices = page_choices
self.widgets = (
Select(choices=site_choices),
Select(choices=[('', '----')]),
Select(choices=self.choices, attrs={'style': "display:none;"}),
)
def get_context(self, name, value, attrs):
self._build_widgets()
context = super().get_context(name, value, attrs)
context['widget']['script_data'] = {"name": name}
return context
def format_output(self, rendered_widgets):
return ' '.join(rendered_widgets)
class PageSmartLinkWidget(TextInput):
"""Presents the user with a Select2 widget to select a page and returns the link to this page as a string."""
template_name = 'cms/widgets/pagesmartlinkwidget.html'
class Media:
css = {
'screen': (
'admin/css/vendor/select2/select2.css',
'admin/css/autocomplete.css',
),
}
js = (
'admin/js/vendor/jquery/jquery.js',
'admin/js/vendor/select2/select2.full.js',
'admin/js/jquery.init.js',
static_with_version('cms/js/dist/bundle.forms.pagesmartlinkwidget.min.js'),
)
def __init__(self, attrs=None, ajax_view=None):
super().__init__(attrs)
self.ajax_url = self.get_ajax_url(ajax_view=ajax_view)
def get_ajax_url(self, ajax_view):
try:
return reverse_lazy(ajax_view)
except NoReverseMatch:
raise Exception(
'You should provide an ajax_view argument that can be reversed to the PageSmartLinkWidget'
)
def _build_script_data(self, name, value, attrs):
return {
"id": attrs.get('id', ''),
"text": str(attrs.get('placeholder_text', '')),
"lang": self.language,
"url": force_str(self.ajax_url),
}
def get_context(self, name, value, attrs):
context = super().get_context(name, value, attrs)
context['widget']['script_data'] = self._build_script_data(name, value, context['widget']['attrs'])
return context
class UserSelectAdminWidget(Select):
"""Special widget used in page permission inlines, because we have to render
an add user (plus) icon, but point it somewhere else - to special user creation
view, which is accessible only if user haves "add user" permissions.
Current user should be assigned to widget in form constructor as an user
attribute.
"""
def render(self, name, value, attrs=None, choices=(), renderer=None):
output = [super().render(name, value, attrs, renderer=renderer)]
if hasattr(self, 'user') and (
self.user.is_superuser or self.user.has_perm(
PageUser._meta.app_label + '.' + get_permission_codename('add', PageUser._meta))
):
# append + icon
add_url = admin_reverse('cms_pageuser_add')
output.append(format_html(
'<a href="{}?_popup=1" class="related-widget-wrapper-link add-related"'
' id="add_id_{}" data-popup="yes" title="{}">'
'<img src="{}" alt="{}" width="20" height="20"></a>',
add_url,
name,
_('Add another'),
static('admin/img/icon-addlink.svg'),
_('Add'),
))
return mark_safe(''.join(output))
class AppHookSelect(Select):
"""Special widget used for the App Hook selector in the Advanced Settings
of the Page Admin. It adds support for a data attribute per option and
includes supporting JS into the page.
"""
class Media:
js = (
static_with_version('cms/js/dist/bundle.forms.apphookselect.min.js'),
)
def __init__(self, attrs=None, choices=(), app_namespaces={}):
self.app_namespaces = app_namespaces
super().__init__(attrs, choices)
def create_option(self, name, value, label, selected, index, subindex=None, attrs=None):
option = super().create_option(name, value, label, selected, index, subindex, attrs)
if value in self.app_namespaces:
option['attrs']['data-namespace'] = escape(self.app_namespaces[value])
return option
def _build_option(self, selected_choices, option_value, option_label):
if option_value is None:
option_value = ''
option_value = force_str(option_value)
if option_value in selected_choices:
selected_html = mark_safe(' selected="selected"')
if not self.allow_multiple_selected:
# Only allow for a single selection.
selected_choices.remove(option_value)
else:
selected_html = ''
if option_value in self.app_namespaces:
data_html = mark_safe(' data-namespace="%s"' % escape(self.app_namespaces[option_value]))
else:
data_html = ''
return option_value, selected_html, data_html, force_str(option_label)
def render_option(self, selected_choices, option_value, option_label):
option_data = self._build_option(selected_choices, option_value, option_label)
return '<option value="%s"%s%s>%s</option>' % option_data
class ApplicationConfigSelect(Select):
"""
Special widget -populate by javascript- that shows application configurations
depending on selected Apphooks.
Required data are injected in the page as JSON data that forms.apphookselect.js
uses to create the appropriate data structure.
A stub 'addlink' link is created and filled in with the correct URL by the same
javascript.
"""
template_name = 'cms/widgets/applicationconfigselect.html'
class Media:
js = (
static_with_version('cms/js/dist/bundle.forms.apphookselect.min.js'),
)
def __init__(self, attrs=None, choices=(), app_configs={}):
self.app_configs = app_configs
super().__init__(attrs, choices)
def _build_script_data(self, name, value, attrs):
configs = {
str(application): [[str(config.pk), str(config)] for config in cms_app.get_configs()]
for application, cms_app in self.app_configs.items()
}
urls = {
str(application): cms_app.get_config_add_url()
for application, cms_app in self.app_configs.items()
}
return {
"apphooks_configuration": configs,
"apphooks_configuration_url": urls,
"apphooks_configuration_value": str(value) if value is not None else "",
# apphook_configuration_value needs to correspond to correspond to str(config.pk)
}
def get_context(self, name, value, attrs):
context = super().get_context(name, value, attrs)
context['widget']['script_data'] = self._build_script_data(name, value, context['widget']['attrs'])
return context

176
cms/forms/wizards.py Normal file
View file

@ -0,0 +1,176 @@
from django import forms
from django.core.exceptions import ValidationError
from django.db import transaction
from django.utils.html import strip_tags
from django.utils.text import slugify
from django.utils.translation import gettext, gettext_lazy as _
from cms.admin.forms import AddPageForm
from cms.plugin_pool import plugin_pool
from cms.utils import permissions
from cms.utils.conf import get_cms_setting
from cms.utils.page import get_available_slug
from cms.utils.page_permissions import user_can_add_page, user_can_add_subpage
try:
# djangocms_text is not guaranteed to be available
from djangocms_text.widgets import TextEditorWidget
text_widget = TextEditorWidget
except ImportError:
text_widget = forms.Textarea
class CreateCMSPageForm(AddPageForm):
sub_page_form = False
# Field overrides
menu_title = None
page_title = None
meta_description = None
content = forms.CharField(
label=_('Content'), widget=text_widget, required=False,
help_text=_("Optional. If supplied, will be automatically added "
"within a new text plugin.")
)
class Media:
js = (
# This simply adds some JS for
# hiding/showing the content field based on the selection of this select.
'cms/js/widgets/wizard.pagetypeselect.js',
)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields['title'].help_text = _("Provide a title for the new page.")
self.fields['slug'].required = False
self.fields['slug'].help_text = _("Leave empty for automatic slug, or override as required.")
def get_placeholder(self, page_content, slot=None):
"""
Returns the named placeholder or, if no «slot» provided, the first
editable, non-static placeholder or None.
"""
placeholders = page_content.get_placeholders()
if slot:
placeholders = placeholders.filter(slot=slot)
for ph in placeholders:
if not ph.is_static and ph.is_editable:
return ph
return None
@property
def _language(self):
return self.language_code
def clean(self):
"""
Validates that either the slug is provided, or that slugification from
`title` produces a valid slug.
:return:
"""
data = self.cleaned_data
if self._errors:
return data
slug = slugify(data.get('slug')) or slugify(data['title'])
if not slug:
data["slug"] = ""
forms.ValidationError({
"slug": [_("Cannot automatically create slug. Please provide one manually.")],
})
parent_page = data.get('parent_page')
if parent_page:
base = parent_page.get_path(self._language)
path = f'{base}/{slug}' if base else slug
else:
base = ''
path = slug
data['slug'] = get_available_slug(self._site, path, self._language, suffix=None)
data['path'] = '{}/{}'.format(base, data['slug']) if base else data['slug']
if not data['slug']:
raise forms.ValidationError(_("Please provide a valid slug."))
return data
def clean_content(self):
# A rich-text editor submits an empty value as an empty paragraph
# (e.g. ``<p></p>`` or ``<p>&nbsp;</p>``) rather than an empty string.
# Treat any value without visible text as empty so the wizard does not
# create a blank text plugin on the new page.
content = self.cleaned_data.get("content")
if content and not strip_tags(content).replace("\xa0", "").replace("&nbsp;", "").strip():
return ""
return content
def clean_parent_page(self):
# Check to see if this user has permissions to make this page. We've
# already checked this when producing a list of wizard entries, but this
# is to prevent people from possible form-hacking.
if self._page and self.sub_page_form:
# User is adding a page which will be a direct
# child of the current page.
parent_page = self._page
elif self._page and self._page.parent:
# User is adding a page which will be a right
# sibling to the current page.
parent_page = self._page.parent
else:
parent_page = None
if parent_page:
if parent_page.site_id != self._site.pk:
raise ValidationError("Site doesn't match the parent's page site")
has_perm = user_can_add_subpage(self._user, target=parent_page, site=self._site)
else:
has_perm = user_can_add_page(self._user, site=self._site)
if not has_perm:
message = gettext('You don\'t have the permissions required to add a page.')
raise ValidationError(message)
return parent_page if parent_page else None
def get_template(self):
return get_cms_setting('PAGE_WIZARD_DEFAULT_TEMPLATE')
@transaction.atomic
def save(self, **kwargs):
from cms.api import add_plugin
new_translation = super().save(**kwargs)
new_page = new_translation.page
if self.cleaned_data.get("page_type"):
return new_page
# If the user provided content, then use that instead.
content = self.cleaned_data.get('content')
plugin_type = get_cms_setting('PAGE_WIZARD_CONTENT_PLUGIN')
plugin_body = get_cms_setting('PAGE_WIZARD_CONTENT_PLUGIN_BODY')
slot = get_cms_setting('PAGE_WIZARD_CONTENT_PLACEHOLDER')
if plugin_type in plugin_pool.plugins and plugin_body:
if content and permissions.has_plugin_permission(
self._user, plugin_type, "add"):
placeholder = self.get_placeholder(new_translation, slot=slot)
if placeholder:
opts = {
'placeholder': placeholder,
'plugin_type': plugin_type,
'language': self.language_code,
plugin_body: content,
}
add_plugin(**opts)
return new_page
class CreateCMSSubPageForm(CreateCMSPageForm):
sub_page_form = True