bruks #1

Closed
bruks wants to merge 13 commits from bruks into master
1311 changed files with 337168 additions and 167 deletions

View file

@ -1 +1 @@
3.4.3
3.14.2

View file

@ -3,6 +3,8 @@ from django.conf.urls import url
from .views import IndexView, LoginView, ContactView
app_name = "alplora"
urlpatterns = [
url(r'^$', IndexView.as_view(), name='index'),
url(r'login/', LoginView.as_view(), name='login'),

View file

@ -1,7 +1,7 @@
from django.views.generic import TemplateView
from django.utils.translation import get_language, get_language_info
from django.utils.translation import ugettext_lazy as _
from django.utils.translation import gettext_lazy as _
from django.views.generic.edit import FormView
from django.contrib import messages
from django.shortcuts import render

1
cms/__init__.py Normal file
View file

@ -0,0 +1 @@
__version__ = '5.2.0dev1'

9
cms/__main__.py Normal file
View file

@ -0,0 +1,9 @@
"""
Invokes djangocms when the cms module is run as a script.
Example: python -m cms mysite
"""
from cms.management import djangocms
if __name__ == "__main__":
djangocms.execute_from_command_line()

10
cms/admin/__init__.py Normal file
View file

@ -0,0 +1,10 @@
import cms.admin.pageadmin # noqa: F401
import cms.admin.permissionadmin # noqa: F401
import cms.admin.placeholderadmin # noqa: F401
import cms.admin.settingsadmin # noqa: F401
import cms.admin.useradmin # noqa: F401
# Piggyback off admin.autodiscover() to discover cms plugins
from cms import plugin_pool
plugin_pool.plugin_pool.discover_plugins()

1714
cms/admin/forms.py Normal file

File diff suppressed because it is too large Load diff

1513
cms/admin/pageadmin.py Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,232 @@
from copy import deepcopy
from django.contrib import admin
from django.contrib.admin import site
from django.contrib.auth import get_user_model
from django.contrib.auth.admin import UserAdmin
from django.contrib.sites.models import Site
from django.db import OperationalError, ProgrammingError
from django.utils.translation import gettext_lazy as _
from cms.admin.forms import (
GlobalPagePermissionAdminForm,
PagePermissionInlineAdminForm,
ViewRestrictionInlineAdminForm,
)
from cms.exceptions import NoPermissionsException
from cms.models import GlobalPagePermission, PagePermission
from cms.utils import page_permissions, permissions
from cms.utils.conf import get_cms_setting
from cms.utils.helpers import classproperty
PERMISSION_ADMIN_INLINES = []
user_model = get_user_model()
admin_class = UserAdmin
for model, admin_instance in site._registry.items():
if model == user_model:
admin_class = admin_instance.__class__
class TabularInline(admin.TabularInline):
pass
class PagePermissionInlineAdmin(TabularInline):
model = PagePermission
# use special form, so we can override of user and group field
form = PagePermissionInlineAdminForm
extra = 0 # edit page load time boost
show_with_view_permissions = False
def has_change_permission(self, request, obj=None):
if not obj:
return False
return page_permissions.user_can_change_page_permissions(
request.user,
page=obj,
site=obj.site,
)
def has_add_permission(self, request, obj=None):
return self.has_change_permission(request, obj)
@classproperty
def raw_id_fields(cls):
# Dynamically set raw_id_fields based on settings
threshold = get_cms_setting('RAW_ID_USERS')
# Given a fresh django-cms install and a django settings with the
# CMS_RAW_ID_USERS = CMS_PERMISSION = True
# django throws an OperationalError (sqlite) or ProgrammingError
# (postgres) when running ./manage migrate because the user table
# doesn't exist yet.
try:
threshold = threshold and get_user_model().objects.count() > threshold
except (OperationalError, ProgrammingError):
threshold = False
return ['user'] if threshold else []
def get_queryset(self, request):
"""
Queryset change, so user with global change permissions can see
all permissions. Otherwise user can see only permissions for
peoples which are under him (he can't see his permissions, because
this will lead to violation, when he can add more power to himself)
"""
site = Site.objects.get_current(request)
try:
# can see only permissions for users which are under him in tree
qs = self.model.objects.subordinate_to_user(request.user, site)
except NoPermissionsException:
return self.model.objects.none()
return qs.filter(can_view=self.show_with_view_permissions)
def get_formset(self, request, obj=None, **kwargs):
"""
Some fields may be excluded here. User can change only
permissions which are available for him. E.g. if user does not haves
can_change flag, he can't change assign can_change permissions.
"""
exclude = self.exclude or []
if obj:
user = request.user
if not obj.has_add_permission(user):
exclude.append('can_add')
if not obj.has_delete_permission(user):
exclude.append('can_delete')
if not obj.has_publish_permission(user):
exclude.append('can_publish')
if not obj.has_advanced_settings_permission(user):
exclude.append('can_change_advanced_settings')
if not obj.has_move_page_permission(user):
exclude.append('can_move_page')
kwargs['exclude'] = exclude
self.form._page = obj
formset_cls = super().get_formset(request, obj=obj, **kwargs)
qs = self.get_queryset(request)
if obj is not None:
qs = qs.filter(page=obj)
formset_cls._queryset = qs
return formset_cls
class ViewRestrictionInlineAdmin(PagePermissionInlineAdmin):
extra = 0 # edit page load time boost
form = ViewRestrictionInlineAdminForm
verbose_name = _("View restriction")
verbose_name_plural = _("View restrictions")
show_with_view_permissions = True
class GlobalPagePermissionAdmin(admin.ModelAdmin):
list_display = ['user', 'group', 'can_change', 'can_delete', 'can_publish', 'can_change_permissions']
list_filter = ['user', 'group', 'can_change', 'can_delete', 'can_publish', 'can_change_permissions']
form = GlobalPagePermissionAdminForm
search_fields = []
for field in admin_class.search_fields:
search_fields.append("user__%s" % field)
search_fields.append('group__name')
list_display.append('can_change_advanced_settings')
list_filter.append('can_change_advanced_settings')
def get_list_filter(self, request):
threshold = get_cms_setting('RAW_ID_USERS')
try:
threshold = threshold and get_user_model().objects.count() > threshold
except (OperationalError, ProgrammingError):
threshold = False
filter_copy = deepcopy(self.list_filter)
if threshold:
filter_copy.remove('user')
return filter_copy
def get_exclude(self, request, obj=None):
"""Offer only the ``can_*`` flags the acting user may hand out.
Mirrors ``PagePermissionInlineAdmin.get_formset``: a manager must not be
able to grant -- to themselves or anyone else -- a right they do not hold
(CWE-269). Excluding the field keeps it out of the rendered form *and*
out of a crafted POST. Whether a flag the manager holds on some site may
be granted for the sites actually picked is settled in the form's
``clean()``, once ``sites`` is known.
"""
exclude = list(super().get_exclude(request, obj) or [])
grantable = permissions.get_grantable_global_permissions(request.user)
exclude.extend(
field for field in GlobalPagePermission.get_all_permissions()
if field not in grantable and field not in exclude
)
return exclude
def get_form(self, request, obj=None, **kwargs):
form_class = super().get_form(request, obj, **kwargs)
# The form caps each granted flag to the sites the manager holds it on,
# so it needs to know who is acting. ``modelform_factory`` returns a
# fresh subclass per call, so this is per-request state rather than
# shared mutation of the declared form class.
form_class._current_user = request.user
return form_class
def has_add_permission(self, request):
site = Site.objects.get_current(request)
return permissions.user_can_add_global_permissions(request.user, site)
def has_change_permission(self, request, obj=None):
site = Site.objects.get_current(request)
return (
permissions.user_can_change_global_permissions(request.user, site)
and self._can_manage_grant(request, obj)
)
def has_delete_permission(self, request, obj=None):
site = Site.objects.get_current(request)
return (
permissions.user_can_delete_global_permissions(request.user, site)
and self._can_manage_grant(request, obj)
)
def _can_manage_grant(self, request, obj):
"""Only let a manager edit or delete a grant they could have created.
A grant for a site the manager may not manage permissions on, or holding
flags they may not hand out on its sites, is out of reach: its ``user``,
``group`` and ``sites`` would otherwise let them move those flags to
themselves or to other sites (CWE-269).
"""
if obj is None:
return True
held = {flag for flag in GlobalPagePermission.get_all_permissions() if getattr(obj, flag)}
site_ids = list(obj.sites.values_list("pk", flat=True))
grantable = permissions.get_grantable_global_permissions(request.user, site_ids)
return "can_change_permissions" in grantable and held <= grantable
@classproperty
def raw_id_fields(cls):
# Dynamically set raw_id_fields based on settings
threshold = get_cms_setting('RAW_ID_USERS')
# Given a fresh django-cms install and a django settings with the
# CMS_RAW_ID_USERS = CMS_PERMISSION = True
# django throws an OperationalError (sqlite) or ProgrammingError
# (postgres) when running ./manage migrate because the user table
# doesn't exist yet.
try:
threshold = threshold and get_user_model().objects.count() > threshold
except (OperationalError, ProgrammingError):
threshold = False
return ['user'] if threshold else []
if get_cms_setting('PERMISSION'):
admin.site.register(GlobalPagePermission, GlobalPagePermissionAdmin)
PERMISSION_ADMIN_INLINES.extend([
ViewRestrictionInlineAdmin,
PagePermissionInlineAdmin,
])

File diff suppressed because it is too large Load diff

156
cms/admin/settingsadmin.py Normal file
View file

@ -0,0 +1,156 @@
import copy
import json
from functools import update_wrapper
from urllib.parse import urlparse
from django.contrib import admin
from django.db import transaction
from django.http import (
HttpResponse,
HttpResponseBadRequest,
HttpResponseRedirect,
)
from django.http.request import QueryDict
from django.urls import Resolver404, path, re_path, resolve
from django.utils.decorators import method_decorator
from django.utils.html import conditional_escape
from django.utils.translation import override
from django.views.decorators.csrf import csrf_protect
from cms.admin.forms import RequestToolbarForm
from cms.models import UserSettings
from cms.models.contentmodels import PageContent
from cms.utils.page import get_page_from_request
from cms.utils.page_permissions import user_can_view_page
from cms.utils.permissions import user_can_view_placeholder_source
from cms.utils.urlutils import admin_reverse
@admin.register(UserSettings)
class SettingsAdmin(admin.ModelAdmin):
def get_urls(self):
def wrap(view):
def wrapper(*args, **kwargs):
return self.admin_site.admin_view(view)(*args, **kwargs)
return update_wrapper(wrapper, view)
info = self.model._meta.app_label, self.model._meta.model_name
return [
path('session_store/', self.session_store, name='%s_%s_session_store' % info),
path('cms-toolbar/', wrap(self.get_toolbar), name='%s_%s_get_toolbar' % info),
path('', wrap(self.change_view), name='%s_%s_change' % info),
re_path(r'^(.+)/$', wrap(self.change_view), name='%s_%s_change' % info),
]
@method_decorator(csrf_protect)
@transaction.atomic
def change_view(self, request, id=None):
model = self.model
try:
obj = model.objects.get(user=request.user)
except model.DoesNotExist:
return self.add_view(request)
return super().change_view(request, str(obj.pk))
def session_store(self, request):
"""
either POST or GET
POST should have a settings parameter
"""
if not request.user.is_staff:
return HttpResponse(json.dumps(""),
content_type="application/json")
if request.method == "POST":
request.session['cms_settings'] = conditional_escape(request.POST['settings'])
request.session.save()
return HttpResponse(
json.dumps(request.session.get('cms_settings', '')),
content_type="application/json"
)
def get_toolbar(self, request):
from cms.toolbar.toolbar import CMSToolbar
form = RequestToolbarForm(request.GET or None)
if not form.is_valid():
return HttpResponseBadRequest('Invalid parameters')
form_data = form.cleaned_data
cms_path = form_data.get('cms_path') or request.path_info
origin_url = urlparse(cms_path)
attached_obj = form_data.get('attached_obj')
if isinstance(attached_obj, PageContent):
current_page = attached_obj.page
else:
current_page = get_page_from_request(request, use_path=origin_url.path, clean_path=True)
# The object id/type come straight from the client, so enforce an
# object-level view check before rendering a toolbar for it. Without
# this, any staff user could read the title/breadcrumb/edit URLs of
# pages on other sites or of view-restricted pages they cannot access.
# Deny with the same generic response as an unknown object so this does
# not become an existence oracle.
if isinstance(attached_obj, PageContent):
if not user_can_view_page(request.user, current_page):
return HttpResponseBadRequest('Invalid parameters')
elif attached_obj is not None:
if not user_can_view_placeholder_source(request.user, attached_obj):
return HttpResponseBadRequest('Invalid parameters')
elif current_page is not None and not user_can_view_page(request.user, current_page):
return HttpResponseBadRequest('Invalid parameters')
data = QueryDict(query_string=origin_url.query, mutable=True)
placeholders = request.GET.getlist("placeholders[]")
if placeholders:
data.setlist('placeholders[]', placeholders)
request = copy.copy(request)
request.GET = data
request.current_page = current_page
try:
request.resolver_match = resolve(origin_url.path)
except Resolver404:
pass
request.toolbar = CMSToolbar(request, request_path=origin_url.path, _async=True)
request.toolbar.set_object(attached_obj or current_page)
return HttpResponse(request.toolbar.render())
def save_model(self, request, obj, form, change):
obj.user = request.user
obj.save()
def response_post_save_change(self, request, obj):
#
# When the user changes his language setting, we need to do two things:
# 1. Change the language-prefix for the sideframed admin view
# 2. Reload the whole window so that the new language affects the
# toolbar, etc.
#
# To do this, we first redirect the sideframe to the correct new, URL,
# but we pass a GET param 'reload_window', which instructs JS on that
# page to strip (to avoid infinite redirection loops) that param then
# reload the whole window again.
#
with override(obj.language):
post_url = admin_reverse(
'cms_usersettings_change',
args=[obj.id, ],
current_app=self.admin_site.name
)
return HttpResponseRedirect(f"{post_url}?reload_window")
def has_change_permission(self, request, obj=None):
if obj and obj.user == request.user:
return True
return False
def get_model_perms(self, request):
"""
Return empty perms dict thus hiding the model from admin index.
"""
return {}

171
cms/admin/useradmin.py Normal file
View file

@ -0,0 +1,171 @@
from copy import deepcopy
from django.contrib import admin
from django.contrib.admin import ModelAdmin, site
from django.contrib.admin.utils import unquote
from django.contrib.auth import get_user_model
from django.contrib.sites.models import Site
from django.core.exceptions import PermissionDenied
from django.utils.translation import gettext
from cms.admin.forms import PageUserChangeForm, PageUserGroupForm
from cms.exceptions import NoPermissionsException
from cms.models import Page, PagePermission, PageUser, PageUserGroup
from cms.utils.compat.forms import UserAdmin
from cms.utils.conf import get_cms_setting
from cms.utils.permissions import (
get_model_permission_codename,
get_subordinate_groups,
get_subordinate_users,
get_user_permission_level,
)
user_model = get_user_model()
admin_class: type[ModelAdmin] = UserAdmin
for model, admin_instance in site._registry.items():
if model == user_model:
admin_class = admin_instance.__class__
class GenericCmsPermissionAdmin:
def get_subordinates(self, user, site):
raise NotImplementedError
def _has_change_permissions_permission(self, request):
"""
User is able to add/change objects only if he haves can change
permission on some page.
"""
site = Site.objects.get_current(request)
try:
get_user_permission_level(request.user, site)
except NoPermissionsException:
return False
return True
def get_form(self, request, obj=None, **kwargs):
form_class = super().get_form(request, obj, **kwargs)
form_class._current_user = request.user
return form_class
def get_queryset(self, request):
queryset = super().get_queryset(request)
site = Site.objects.get_current(request)
user_ids = self.get_subordinates(request.user, site).values_list('pk', flat=True)
return queryset.filter(pk__in=user_ids)
def has_add_permission(self, request):
has_model_perm = super().has_add_permission(request)
if not has_model_perm:
return False
return self._has_change_permissions_permission(request)
def has_change_permission(self, request, obj=None):
has_model_perm = super().has_change_permission(request, obj)
if not has_model_perm:
return False
return self._has_change_permissions_permission(request)
def has_delete_permission(self, request, obj=None):
has_model_perm = super().has_delete_permission(request, obj)
if not has_model_perm:
return False
return self._has_change_permissions_permission(request)
def has_view_permission(self, request, obj=None):
# For django 2.1
# Default is to return True if user got `change` perm, but we have to
# get in consideration also cms permission system
return self.has_change_permission(request, obj)
class PageUserAdmin(GenericCmsPermissionAdmin, admin_class):
form = PageUserChangeForm
model = PageUser
def get_subordinates(self, user, site):
return get_subordinate_users(user, site).values_list('pk', flat=True)
def get_readonly_fields(self, request, obj=None):
fields = super().get_readonly_fields(request, obj)
if not request.user.is_superuser:
# Non superusers can't set superuser status on
# their subordinates.
fields = list(fields) + ['is_superuser']
return fields
def has_change_permission(self, request, obj=None):
if obj is not None and obj.is_superuser and not request.user.is_superuser:
# Superusers are nobody's subordinate. get_queryset() already hides
# them, this guards the views that resolve an object by other means.
return False
return super().has_change_permission(request, obj)
def has_delete_permission(self, request, obj=None):
if obj is not None and obj.is_superuser and not request.user.is_superuser:
return False
return super().has_delete_permission(request, obj)
def user_change_password(self, request, id, form_url=''):
# Django's password view only checks has_change_permission(), so a
# subordinate check has to happen before anything else.
user = self.get_object(request, unquote(id))
if user is not None and user.is_superuser and not request.user.is_superuser:
raise PermissionDenied
return super().user_change_password(request, id, form_url)
def save_model(self, request, obj, form, change):
if not change:
# By default set the staff flag to True
# when a PageUser is first created
obj.is_staff = True
# Set the created_by field to the current user
obj.created_by = request.user
super().save_model(request, obj, form, change)
class PageUserGroupAdmin(GenericCmsPermissionAdmin, admin.ModelAdmin):
form = PageUserGroupForm
list_display = ('name', 'created_by')
fieldsets = [
(None, {'fields': ('name',)}),
]
def get_fieldsets(self, request, obj=None):
"""
Nobody can grant more than he haves, so check for user permissions
to Page and User model and render fieldset depending on them.
"""
fieldsets = deepcopy(self.fieldsets)
perm_models = (
(Page, gettext('Page permissions')),
(PageUser, gettext('User & Group permissions')),
(PagePermission, gettext('Page permissions management')),
)
for i, perm_model in enumerate(perm_models):
fields = []
model, title = perm_model
name = model.__name__.lower()
for key in ('add', 'change', 'delete'):
perm_code = get_model_permission_codename(model, action=key)
if request.user.has_perm(perm_code):
fields.append(f'can_{key}_{name}')
if fields:
fieldsets.insert(2 + i, (title, {'fields': (fields,)}))
return fieldsets
def get_subordinates(self, user, site):
return get_subordinate_groups(user, site).values_list('pk', flat=True)
if get_cms_setting('PERMISSION'):
admin.site.register(PageUser, PageUserAdmin)
admin.site.register(PageUserGroup, PageUserGroupAdmin)

1192
cms/admin/utils.py Normal file

File diff suppressed because it is too large Load diff

728
cms/api.py Normal file
View file

@ -0,0 +1,728 @@
"""
Python APIs for creating CMS content. This is done in :mod:`cms.api` and not
on the models and managers, because the direct API via models and managers is
slightly counterintuitive for developers.
The api for both Pages and Plugins has changed significantly since django CMS
Version 4.
Also, the functions defined in this module do sanity checks on arguments.
.. warning:: None of the functions in this module does any security or permission
checks. They verify their input values to be sane wherever
possible, however permission checks should be implemented manually
before calling any of these functions.
.. info:: Due to potential circular dependency issues, it's recommended
to import the api in the functions that uses its function.
e.g. use:
::
def my_function():
from cms.api import api_function
api_function(...)
instead of:
::
from cms.api import api_function
def my_function():
api_function(...)
"""
import warnings
from collections import defaultdict
from django.contrib.auth import get_user_model
from django.contrib.sites.models import Site
from django.core.exceptions import FieldError, ValidationError
from django.db import IntegrityError, models, transaction
from django.template.defaultfilters import slugify
from django.template.loader import get_template
from cms import constants
from cms.app_base import CMSApp
from cms.apphook_pool import apphook_pool
from cms.constants import TEMPLATE_INHERITANCE_MAGIC
from cms.models import PageContent
from cms.models.pagemodel import Page
from cms.models.permissionmodels import (
ACCESS_PAGE_AND_DESCENDANTS,
GlobalPagePermission,
PagePermission,
PageUser,
)
from cms.models.placeholdermodel import Placeholder
from cms.models.pluginmodel import CMSPlugin
from cms.plugin_base import CMSPluginBase
from cms.plugin_pool import plugin_pool
from cms.utils.conf import get_cms_setting
from cms.utils.i18n import get_language_list
from cms.utils.page import get_available_slug, get_clean_username
from cms.utils.permissions import get_current_user, reset_current_user, set_current_user
from cms.utils.plugins import copy_plugins_to_placeholder, downcast_plugins
from menus.menu_pool import menu_pool
# ===============================================================================
# Helpers/Internals
# ===============================================================================
def _verify_apphook(apphook, namespace):
"""
Verifies the apphook given is valid and returns the normalized form (name)
"""
apphook_pool.discover_apps()
if isinstance(apphook, CMSApp):
try:
assert apphook.__class__ in [app.__class__ for app in apphook_pool.apps.values()]
except AssertionError:
raise
apphook_name = apphook.__class__.__name__
elif hasattr(apphook, "__module__") and issubclass(apphook, CMSApp):
return apphook.__name__
elif isinstance(apphook, str):
try:
assert apphook in apphook_pool.apps
except AssertionError:
raise
apphook_name = apphook
else:
raise TypeError("apphook must be string or CMSApp instance")
if apphook_pool.apps[apphook_name].app_name and not namespace:
raise ValidationError("apphook with app_name must define a namespace")
return apphook_name
def _get_default_slug(page, title, language):
candidate = slugify(title)
base = page.get_path_for_slug(candidate, language)
# without a reachable path (e.g. unpublished parent) check the bare slug
# for availability instead
return get_available_slug(page.site, base if base is not None else candidate, language)
def _validate_path_uniqueness(page, path, language):
if path is None:
# pages without a reachable path (e.g. unpublished parent) cannot
# conflict with any URL
return
from cms.forms.validators import validate_url_uniqueness
validate_url_uniqueness(page.site, path, language, exclude_page=page)
def _verify_plugin_type(plugin_type):
"""
Verifies the given plugin_type is valid and returns a tuple of
(plugin_model, plugin_type)
"""
if hasattr(plugin_type, "__module__") and issubclass(plugin_type, CMSPluginBase):
plugin_model = plugin_type.model
assert plugin_type in plugin_pool.plugins.values()
plugin_type = plugin_type.__name__
elif isinstance(plugin_type, str):
try:
plugin_model = plugin_pool.get_plugin(plugin_type).model
except KeyError:
raise TypeError("plugin_type must be CMSPluginBase subclass or string")
else:
raise TypeError("plugin_type must be CMSPluginBase subclass or string")
return plugin_model, plugin_type
# ===============================================================================
# Public API
# ===============================================================================
@transaction.atomic
def create_page(
title,
template,
language,
menu_title=None,
slug=None,
apphook=None,
apphook_namespace=None,
redirect=None,
meta_description=None,
created_by="python-api",
parent=None,
publication_date=None,
publication_end_date=None,
in_navigation=False,
soft_root=False,
reverse_id=None,
navigation_extenders=None,
published=None,
site=None,
login_required=False,
limit_visibility_in_menu=constants.VISIBILITY_ALL,
position="last-child",
overwrite_url=None,
xframe_options=constants.X_FRAME_OPTIONS_INHERIT,
page_title=None,
):
"""
Creates a :class:`cms.models.Page` instance and returns it. Also
creates a :class:`cms.models.PageContent` instance for the specified
language.
.. warning::
Since version 4 the parameters published, publication_date, and publication_end_date
do not change the behaviour of this function. If they are supplied a warning is raised.
:param str title: Title of the page
:param str template: Template to use for this page. Must be in :setting:`CMS_TEMPLATES`
:param str language: Language code for this page. Must be in :setting:`django:LANGUAGES`
:param str menu_title: Menu title for this page
:param str slug: Slug for the page, by default uses a slugified version of *title*
:param apphook: Application to hook on this page, must be a valid apphook
:type apphook: str or :class:`cms.app_base.CMSApp` sub-class
:param str apphook_namespace: Name of the apphook namespace
:param str redirect: URL redirect
:param str meta_description: Description of this page for SEO
:param created_by: User that is creating this page
:type created_by: str of :class:`django.contrib.auth.models.User` instance
:param parent: Parent page of this page
:type parent: :class:`cms.models.Page` instance
:param bool in_navigation: Whether this page should be in the navigation or not
:param bool soft_root: Whether this page is a soft root or not
:param str reverse_id: Reverse ID of this page (for template tags)
:param str navigation_extenders: Menu to attach to this page. Must be a valid menu
:param site: Site to put this page on
:type site: :class:`django.contrib.sites.models.Site` instance
:param bool login_required: Whether users must be logged in or not to view this page
:param limit_visibility_in_menu: Limits visibility of this page in the menu
:type limit_visibility_in_menu: :data:`VISIBILITY_ALL` or :data:`VISIBILITY_USERS` or :data:`VISIBILITY_ANONYMOUS`
:param str position: Where to insert this node if *parent* is given, must be ``'first-child'`` or ``'last-child'``
:param str overwrite_url: Overwritten path for this page
:param int xframe_options: X Frame Option value for Clickjacking protection
:param str page_title: Overridden page title for HTML title tag
"""
if published is not None or publication_date is not None or publication_end_date is not None:
warnings.warn(
'This API function no longer accepts a "published", "publication_date", or '
'"publication_end_date" argument',
UserWarning,
stacklevel=2,
)
# validate template
if not template == TEMPLATE_INHERITANCE_MAGIC:
assert template in [tpl[0] for tpl in get_cms_setting("TEMPLATES")]
get_template(template)
# validate parent
if parent:
assert isinstance(parent, Page)
# validate site
if not site:
if parent:
site = parent.site
else:
site = Site.objects.get_current() # Get the current site from settings
else:
assert isinstance(site, Site)
if parent:
assert parent.site == site, "Parent page must be on the same site as the new page"
# validate language:
assert language in get_language_list(site), get_cms_setting("LANGUAGES").get(site.pk)
if navigation_extenders:
raw_menus = menu_pool.get_menus_by_attribute("cms_enabled", True)
menus = [menu[0] for menu in raw_menus]
assert navigation_extenders in menus
# validate menu visibility
accepted_limitations = (constants.VISIBILITY_ALL, constants.VISIBILITY_USERS, constants.VISIBILITY_ANONYMOUS)
assert limit_visibility_in_menu in accepted_limitations
# validate position
assert position in ("last-child", "first-child", "left", "right")
# validate and normalize apphook
if apphook:
application_urls = _verify_apphook(apphook, apphook_namespace)
else:
application_urls = None
# ugly permissions hack: expose the creator through the current-user
# context variable for the duration of the creation, then restore the
# previous value. Capturing the token and resetting (instead of blindly
# setting None at the end) avoids clobbering a user set by
# CurrentUserMiddleware and prevents leaking the creator into the next
# operation on the same thread. See cms.utils.permissions.reset_current_user.
if created_by and isinstance(created_by, get_user_model()):
user_token = set_current_user(created_by)
created_by = get_clean_username(created_by)
else:
user_token = set_current_user(None)
try:
if reverse_id:
if Page.objects.filter(reverse_id=reverse_id, site=site).exists():
raise FieldError('A page with the reverse_id="%s" already exist.' % reverse_id)
page = Page(
parent=parent,
created_by=created_by,
changed_by=created_by,
reverse_id=reverse_id,
navigation_extenders=navigation_extenders,
application_urls=application_urls,
application_namespace=apphook_namespace,
login_required=login_required,
site=site,
)
page.add_to_tree(position=position)
page.save()
create_page_content(
language=language,
title=title,
menu_title=menu_title,
page_title=page_title,
slug=slug,
created_by=created_by,
redirect=redirect,
meta_description=meta_description,
page=page,
overwrite_url=overwrite_url,
soft_root=soft_root,
in_navigation=in_navigation,
template=template,
limit_visibility_in_menu=limit_visibility_in_menu,
xframe_options=xframe_options,
)
return page
finally:
reset_current_user(user_token)
@transaction.atomic
def create_page_content(
language,
title,
page,
menu_title=None,
slug=None,
redirect=None,
meta_description=None,
parent=None,
overwrite_url=None,
page_title=None,
path=None,
created_by="python-api",
soft_root=False,
in_navigation=False,
template=TEMPLATE_INHERITANCE_MAGIC,
limit_visibility_in_menu=constants.VISIBILITY_ALL,
xframe_options=constants.X_FRAME_OPTIONS_INHERIT,
):
"""
Creates a :class:`cms.models.PageContent` instance and returns it.
``parent`` is only used if slug=None.
:param str language: Language code for this page. Must be in :setting:`django:LANGUAGES`
:param str title: Title of the page
:param page: The page for which to create this title
:type page: :class:`cms.models.Page` instance
:param str menu_title: Menu title for this page
:param str slug: Slug for the page, by default uses a slugified version of *title*
:param str redirect: URL redirect
:param str meta_description: Description of this page for SEO
:param parent: Used for automated slug generation
:type parent: :class:`cms.models.Page` instance
:param str overwrite_url: Overwritten path for this page
:param str page_title: Overridden page title for HTML title tag
"""
# validate template
if not template == TEMPLATE_INHERITANCE_MAGIC:
assert template in [tpl[0] for tpl in get_cms_setting("TEMPLATES")]
get_template(template)
# validate page
assert isinstance(page, Page)
# validate language:
assert language in get_language_list(page.site_id)
# validate menu visibility
accepted_limitations = (constants.VISIBILITY_ALL, constants.VISIBILITY_USERS, constants.VISIBILITY_ANONYMOUS)
assert limit_visibility_in_menu in accepted_limitations
# set default slug:
if not slug:
slug = _get_default_slug(page, title, language)
if overwrite_url or path is None:
path = page.get_url_data(slug, overwrite_url, language)["path"]
# When called directly (not via create_page) with a user instance, expose
# it through the current-user context variable so signal handlers and
# PageContent.objects.with_user() attribute the creation correctly. Capture
# the token so we can restore the previous value at the end -- otherwise the
# user leaks into the next operation on the same thread when
# CurrentUserMiddleware is not installed (it only resets at the request
# boundary). See cms.utils.permissions.reset_current_user.
user_token = None
if created_by and isinstance(created_by, get_user_model()):
user_token = set_current_user(created_by)
created_by = get_clean_username(created_by)
try:
try:
_validate_path_uniqueness(page, path, language)
except ValidationError as e:
raise IntegrityError(e)
# E.g., djangocms-versioning needs an User object to be passed when creating a versioned Object
user = get_current_user()
page_content = PageContent.objects.with_user(user).create(
language=language,
title=title,
menu_title=menu_title,
page_title=page_title,
redirect=redirect,
meta_description=meta_description,
page=page,
created_by=created_by,
changed_by=created_by,
soft_root=soft_root,
in_navigation=in_navigation,
template=template,
limit_visibility_in_menu=limit_visibility_in_menu,
xframe_options=xframe_options,
slug=slug,
overwrite_url=overwrite_url.strip("/") if overwrite_url else None,
)
page_content.rescan_placeholders()
if page_content.is_public():
# The page URL is derived from public content only. With a versioning
# package installed the new content is created as a draft and gets
# its URL on first publish.
page.urls.update_or_create(
page=page,
language=language,
defaults=dict(
slug=slug,
path=path,
managed=not bool(overwrite_url),
site_id=page.site_id,
),
)
page._clear_internal_cache()
return page_content
finally:
if user_token is not None:
reset_current_user(user_token)
@transaction.atomic
def add_plugin(placeholder, plugin_type, language, position="last-child", target=None, **data):
"""
Adds a plugin to a placeholder and returns it.
:param placeholder: Placeholder to add the plugin to
:type placeholder: :class:`cms.models.placeholdermodel.Placeholder` instance
:param plugin_type: What type of plugin to add
:type plugin_type: str or :class:`cms.plugin_base.CMSPluginBase` sub-class, must be a valid plugin
:param str language: Language code for this plugin, must be in :setting:`django:LANGUAGES`
:param str position: Position to add this plugin to the placeholder. Allowed positions are ``"last-child"``
(default), ``"first-child"``, ``"left"``, ``"right"``.
:param target: Parent plugin. Must be plugin instance
:param data: Data for the plugin type instance
"""
# validate placeholder
assert isinstance(placeholder, Placeholder)
# validate and normalize plugin type
plugin_model, plugin_type = _verify_plugin_type(plugin_type)
if target:
if position == "last-child":
new_pos = placeholder.get_next_plugin_position(language, parent=target, insert_order="last")
parent_id = target.pk
elif position == "first-child":
new_pos = placeholder.get_next_plugin_position(language, parent=target, insert_order="first")
parent_id = target.pk
elif position == "left":
new_pos = target.position
parent_id = target.parent_id
elif position == "right":
new_pos = target.position + 1 + target._get_descendants_count()
parent_id = target.parent_id
else:
raise Exception("position not supported: %s" % position)
else:
assert position in ("first-child", "last-child")
if position == "last-child":
new_pos = placeholder.get_next_plugin_position(language, insert_order="last")
else:
new_pos = 1
parent_id = None
plugin_base = CMSPlugin(
plugin_type=plugin_type,
placeholder=placeholder,
position=new_pos,
language=language,
parent_id=parent_id,
)
plugin_base = placeholder.add_plugin(plugin_base)
plugin = plugin_model(**data)
plugin_base.set_base_attr(plugin)
plugin.save()
return plugin
def create_page_user(
created_by,
user,
can_add_page=True,
can_view_page=True,
can_change_page=True,
can_delete_page=True,
can_publish_page=True,
can_add_pageuser=True,
can_change_pageuser=True,
can_delete_pageuser=True,
can_add_pagepermission=True,
can_change_pagepermission=True,
can_delete_pagepermission=True,
grant_all=False,
):
"""
Creates a page user for the user provided and returns that page user.
:param created_by: The user that creates the page user
:type created_by: :class:`django.contrib.auth.models.User` instance
:param user: The user to create the page user from
:type user: :class:`django.contrib.auth.models.User` instance
:param bool can_*: Permissions to give the user
:param bool grant_all: Grant all permissions to the user
"""
from cms.admin.forms import save_permissions
if grant_all:
# just be lazy
return create_page_user(created_by, user, True, True, True, True, True, True, True, True, True, True, True)
# validate created_by
assert isinstance(created_by, get_user_model())
data = {
"can_add_page": can_add_page,
"can_view_page": can_view_page,
"can_change_page": can_change_page,
"can_delete_page": can_delete_page,
"can_publish_page": can_publish_page,
"can_add_pageuser": can_add_pageuser,
"can_change_pageuser": can_change_pageuser,
"can_delete_pageuser": can_delete_pageuser,
"can_add_pagepermission": can_add_pagepermission,
"can_change_pagepermission": can_change_pagepermission,
"can_delete_pagepermission": can_delete_pagepermission,
}
user.is_staff = True
user.is_active = True
page_user = PageUser(created_by=created_by)
for field in [f.name for f in get_user_model()._meta.local_fields]:
setattr(page_user, field, getattr(user, field))
user.save()
page_user.save()
save_permissions(data, page_user)
return user
def assign_user_to_page(
page,
user,
grant_on=ACCESS_PAGE_AND_DESCENDANTS,
can_add=False,
can_change=False,
can_delete=False,
can_change_advanced_settings=False,
can_publish=None,
can_change_permissions=False,
can_move_page=False,
can_recover_page=True,
can_view=False,
grant_all=False,
global_permission=False,
):
"""
Assigns a user to a page and gives them some permissions. Returns the
:class:`cms.models.PagePermission` object that gets
created.
:param page: The page to assign the user to
:type page: :class:`cms.models.Page` instance
:param user: The user to assign to the page
:type user: :class:`django.contrib.auth.models.User` instance
:param grant_on: Controls which pages are affected
:type grant_on: :data:`cms.models.ACCESS_PAGE`, :data:`cms.models.ACCESS_CHILDREN`,
:data:`cms.models.ACCESS_DESCENDANTS` or :data:`cms.models.ACCESS_PAGE_AND_DESCENDANTS`
:param can_*: Permissions to grant
:param bool grant_all: Grant all permissions to the user
"""
grant_all = grant_all and not global_permission
data = {
"can_add": can_add or grant_all,
"can_change": can_change or grant_all,
"can_delete": can_delete or grant_all,
"can_publish": can_publish or grant_all,
"can_change_advanced_settings": can_change_advanced_settings or grant_all,
"can_change_permissions": can_change_permissions or grant_all,
"can_move_page": can_move_page or grant_all,
"can_view": can_view or grant_all,
}
page_permission = PagePermission(page=page, user=user, grant_on=grant_on, **data)
page_permission.save()
if global_permission:
page_permission = GlobalPagePermission(user=user, can_recover_page=can_recover_page, **data)
page_permission.save()
page_permission.sites.add(page.site)
return page_permission
def publish_page(page, user, language):
"""
.. warning::
Publishing pages has been removed from django CMS core in version 4 onward.
For publishing functionality see `djangocms-versioning: <https://github.com/django-cms/djangocms-versioning>`_
"""
warnings.warn(
"This API function has been removed. For publishing functionality use a package that adds "
"publishing, such as: djangocms-versioning.",
UserWarning,
stacklevel=2,
)
def publish_pages(include_unpublished=False, language=None, site=None):
"""
.. warning::
Publishing pages has been removed from django CMS core in version 4 onward.
For publishing functionality see `djangocms-versioning: <https://github.com/django-cms/djangocms-versioning>`_
"""
warnings.warn(
"This API function has been removed. For publishing functionality use a package that adds "
"publishing, such as: djangocms-versioning.",
UserWarning,
stacklevel=2,
)
def get_page_draft(page):
"""
.. warning::
The concept of draft pages has been removed from django CMS core in version 4 onward.
For draft functionality see `djangocms-versioning: <https://github.com/django-cms/djangocms-versioning>`_
"""
warnings.warn(
"This API function has been removed. For publishing functionality use a package that adds "
"publishing, such as: djangocms-versioning.",
UserWarning,
stacklevel=2,
)
def copy_plugins_to_language(page, source_language, target_language, only_empty=True):
"""
Copy the plugins to another language in the same page for all the page
placeholders.
By default, plugins are copied only if placeholder has no plugin for the
target language; use ``only_empty=False`` to change this.
.. warning: This function skips permissions checks
:param page: the page to copy
:type page: :class:`cms.models.pagemodel.Page` instance
:param string source_language: The source language code,
must be in :setting:`django:LANGUAGES`
:param string target_language: The source language code,
must be in :setting:`django:LANGUAGES`
:param bool only_empty: if False, plugin are copied even if
plugins exists in the target language (on a placeholder basis).
:return int: number of copied plugins
"""
source_placeholders = list(page.get_placeholders(source_language))
placeholder_ids = [placeholder.pk for placeholder in source_placeholders]
source_plugins = downcast_plugins(
CMSPlugin.objects.filter(
placeholder_id__in=placeholder_ids,
language=source_language,
).order_by("position")
)
target_plugin_counts = dict(
CMSPlugin.objects.filter(
placeholder_id__in=placeholder_ids,
language=target_language,
)
.values("placeholder_id")
.annotate(count=models.Count("pk"))
.values_list("placeholder_id", "count")
)
plugins_by_placeholder = defaultdict(list)
for plugin in source_plugins:
plugins_by_placeholder[plugin.placeholder_id].append(plugin)
copied = 0
for placeholder in source_placeholders:
if not only_empty or not target_plugin_counts.get(placeholder.pk):
copied_plugins = copy_plugins_to_placeholder(
plugins_by_placeholder[placeholder.pk],
placeholder,
language=target_language,
plugins_are_downcast=True,
)
copied += len(copied_plugins)
return copied
def can_change_page(request):
"""
Check whether a user has the permission to change the page.
This will work across all permission-related setting, with a unified interface
to permission checking.
:param request: The request object from which the user will be taken.
:type request: :class:`HttpRequest` instance
"""
from cms.utils import page_permissions
user = request.user
current_page = request.current_page
if current_page:
return page_permissions.user_can_change_page(user, current_page)
site = Site.objects.get_current(request)
return page_permissions.user_can_change_all_pages(user, site)

259
cms/app_base.py Normal file
View file

@ -0,0 +1,259 @@
from abc import ABCMeta, abstractmethod
class CMSApp:
"""Base class for creating apphooks. Apphooks live in a file called ``cms_apps.py``.
To create an AppHook subclass ``CMSApp`` in ``cms_apps.py``
::
class MyAppHook(CMSApp):
name = "Problem solver"
"""
#: list of urlconfs: example: ``_urls = ["myapp.urls"]``
_urls = []
#: list of menu classes: example: ``_menus = [MyAppMenu]``
_menus = []
#: Human-readable name of the apphook (required). This name will be displayed
#: on the admin site.
name = None
#: Gives the system a unique way to refer to the apphook. This enables Django
#: namespaces support (optional)
app_name = None
#: configuration model (optional)
app_config = None
#: if set to true, apphook inherits permissions from the current page
permissions = True
#: list of application names to exclude from inheriting CMS permissions
exclude_permissions = []
def __new__(cls):
"""
We want to bind the CMSapp class to a specific AppHookConfig, but only one at a time
Checking for the runtime attribute should be a sane fix
"""
if cls.app_config:
if getattr(cls.app_config, 'cmsapp', None) and cls.app_config.cmsapp != cls:
raise RuntimeError(
'Only one AppHook per AppHookConfiguration must exists.\n'
'AppHook %s already defined for %s AppHookConfig' % (
cls.app_config.cmsapp.__name__, cls.app_config.__name__
)
)
cls.app_config.cmsapp = cls
return super().__new__(cls)
def get_configs(self):
"""
Returns all the apphook configuration instances.
To be implemented by apphook subclass.
"""
raise NotImplementedError('Configurable AppHooks must implement this method')
def get_config(self, namespace):
"""
Returns the apphook configuration instance linked to the given namespace
To be implemented by apphook subclass.
"""
raise NotImplementedError('Configurable AppHooks must implement this method')
def get_config_add_url(self):
"""
Returns the url to add a new apphook configuration instance
(usually the model admin add view)
To be implemented by apphook subclass.
"""
raise NotImplementedError('Configurable AppHooks must implement this method')
def get_menus(self, page=None, language=None, **kwargs):
"""
Returns the menus for the apphook instance, eventually selected according
to the given arguments.
By default, it returns the menus assigned to :py:attr:`CMSApp._menus`.
The method accepts page, language and generic keyword arguments:
you can customize this function to return different list of menu classes
according to the given arguments.
If no menus are returned, then the user will need to attach menus to pages
manually in the admin.
If no page and language are provided, this method **must** return **all the
menus used by this apphook**. Example::
if page and page.reverse_id == 'page1':
return [Menu1]
elif page and page.reverse_id == 'page2':
return [Menu2]
else:
return [Menu1, Menu2]
:param page: page the apphook is attached to
:param language: current site language
:return: list of menu classes
"""
return self._menus
def get_urls(self, page=None, language=None, **kwargs):
"""
Returns the urlconfs for the apphook instance, eventually selected
according to the given arguments.
By default, it returns the urls assigned to :py:attr:`CMSApp._urls`
The method accepts page, language and generic keyword arguments:
you can customize this function to return different urlconfs
according to the given arguments.
This method **must** return a non-empty list of urlconfs,
even if no argument is passed.
:param page: page the apphook is attached to
:param language: current site language
:return: list of urlconfs strings
"""
return self._urls
def get_root_template(self, page=None, language=None, **kwargs):
"""
.. versionadded:: 5.1
Returns the template name used by the apphook's root view.
Best-effort: walks the urlconfs from :meth:`get_urls` to locate the
pattern matching the empty path and returns ``template_name`` from its
view class (CBVs) or callback (FBVs that expose it). Returns ``None``
for views that compute the template dynamically — override this method
to return the template name in that case.
:return: template name or None
"""
from django.urls import URLPattern, URLResolver, get_resolver
def find_root(patterns):
for p in patterns:
if p.pattern.match('') is None:
continue
if isinstance(p, URLResolver):
inner = find_root(p.url_patterns)
if inner is not None:
return inner
elif isinstance(p, URLPattern):
return p
return None
for urlconf in self.get_urls(page=page, language=language, **kwargs):
try:
resolver = get_resolver(urlconf)
pattern = find_root(resolver.url_patterns)
except Exception:
continue
if pattern is None:
continue
callback = pattern.callback
view_class = getattr(callback, 'view_class', None)
view_initkwargs = getattr(callback, 'view_initkwargs', None) or {}
return (
view_initkwargs.get('template_name')
or getattr(view_class, 'template_name', None)
or getattr(callback, 'template_name', None)
)
return None
class CMSAppConfig:
"""
.. versionadded:: 4.0
Base class that all cms app configurations should inherit from.
CMSAppConfig live in a file called ``cms_config.py``.
Apps subclassing ``CMSAppConfig`` can set ``cms_enabled = True`` for their app config to
use django CMS' wizard functionality. Additional wizzwards are listed in the app config's
``cms_wizzards`` property.
The second functionality that django CMS offers is attaching Model objects to the toolbar. To use
this functionality, set list the Model classes in ``cms_toolbar_enabled_models`` and have
``cms_enabled = True``
"""
def __init__(self, django_app_config):
self.app_config = django_app_config
@staticmethod
def get_contract(contract_name: str) -> type:
"""
Retrieve a contract implementation from registered CMS extension apps.
Searches through all registered CMS extension applications for a contract
that matches the given contract name. A contract is a named interface that
allows apps to provide functionality that other apps can use.
This method enables an architecture where apps can query for
specific contracts and use the implementation provided by any registered
extension app that exports that contract.
:param contract_name: The name identifier of the contract to retrieve
:type contract_name: str
:return: The contract class if found, or None if no matching contract exists
:rtype: type or None
:raises: None
Example::
# Get a versioning contract implementation
versioning_contract = CMSAppConfig.get_contract('versioning')
if versioning_contract:
# Use the versioning functionality
version = versioning_contract.create_version(obj)
"""
from cms.app_registration import get_cms_extension_apps
for app in get_cms_extension_apps():
name, cls = getattr(app.cms_extension, "contract", (None, None))
if name == contract_name:
return cls
return None
class CMSAppExtension(metaclass=ABCMeta):
"""
.. versionadded:: 4.0
Base class that all cms app extensions should inherit from. App extensions allow
apps to offer their functionality to other apps, e.g., as done by djangocms-versioning.
CMSAppExtensions live in a file called ``cms_config.py``.
"""
@abstractmethod
def configure_app(self, cms_config):
"""
Implement this method if the app provides functionality that
other apps can use and configure.
This method will be run once for every app that defines an
attribute like ``<app_label>_enabled`` as ``True`` on its cms app
config class.
So for example, if app A with label "app_a" implements this
method and app B and app C define ``app_a_enabled = True`` on their
cms config classes, the method app A has defined will run twice,
once for app B and once for app C.
:param cms_config: the cms config class of the app registering for additional functionality
:type cms_config: :class:`CMSAppConfig` subclass
"""
pass
def ready(self):
"""Override this method to run code after all CMS extensions
have been configured.
This method will be run once, even if no cms app config sets
its ``<app_label>_enabled`` attribute to ``True``"""
pass

203
cms/app_registration.py Normal file
View file

@ -0,0 +1,203 @@
import inspect
from functools import cache
from importlib import import_module
from django.apps import apps
from django.core.exceptions import ImproperlyConfigured
from django.utils.module_loading import module_has_submodule
from cms.app_base import CMSAppConfig, CMSAppExtension
from cms.constants import CMS_CONFIG_NAME
def _find_subclasses(module, klass):
"""
Helper function.
Returns a list of classes in module which inherit from klass.
"""
classes = []
# Find all classes that inherit from klass
for name, obj in inspect.getmembers(module):
is_subclass = (
# Ignore the import of klass itself
inspect.isclass(obj) and issubclass(obj, klass) and obj != klass
)
if is_subclass:
classes.append(obj)
return classes
def _find_config(cms_module):
"""
Helper function.
Returns the class inheriting from CMSAppConfig in the given module.
If no such class exists in the module, returns None.
If multiple classes inherit from CMSAppConfig, raises
ImproperlyConfigured exception.
"""
cms_config_classes = _find_subclasses(cms_module, CMSAppConfig)
if len(cms_config_classes) == 1:
return cms_config_classes[0]
elif len(cms_config_classes) > 1:
raise ImproperlyConfigured(
"cms_config.py files can't define more than one "
"class which inherits from CMSAppConfig")
def _find_extension(cms_module):
"""
Helper function.
Returns the class inheriting from CMSAppExtension in the given module.
If no such class exists in the module, returns None.
If multiple classes inherit from CMSAppExtension, raises
ImproperlyConfigured exception.
"""
cms_extension_classes = _find_subclasses(cms_module, CMSAppExtension)
if len(cms_extension_classes) == 1:
return cms_extension_classes[0]
elif len(cms_extension_classes) > 1:
raise ImproperlyConfigured(
"cms_config.py files can't define more than one "
"class which inherits from CMSAppExtension")
def autodiscover_cms_configs():
"""
Find and import all cms_config.py files. Add a cms_app attribute
to django's app config with an instance of the cms config.
This runs in two passes: all CMSAppExtension instances are attached
first, then the CMSAppConfig objects are instantiated. A config's
``__init__`` may query registered extensions via
``CMSAppConfig.get_contract()`` (which caches the result of
``get_cms_extension_apps()``), so every extension must be registered
before any config is instantiated. Otherwise the order of apps in
INSTALLED_APPS could cause a config to cache an incomplete extension
list (e.g. djangocms_alias not finding djangocms_versioning).
"""
discovered = []
# First pass: import every cms_config module and attach the extensions.
for app_config in apps.get_app_configs():
try:
cms_module = import_module(
f'{app_config.name}.{CMS_CONFIG_NAME}')
except: # NOQA
# If something in cms_config.py raises an exception let that
# exception bubble up. Only catch the exception if
# cms_config.py doesn't exist
if module_has_submodule(app_config.module, CMS_CONFIG_NAME):
raise
else:
config = _find_config(cms_module)
extension = _find_extension(cms_module)
if not config and not extension:
raise ImproperlyConfigured(
f"The cms_config.py file of the '{app_config.label}' app "
"must define at least one class which inherits from "
"CMSAppConfig or CMSAppExtension")
# We are adding these attributes here rather than in
# django's app config definition because there are
# all kinds of limitations as to what can be imported
# in django's apps.py and leaving it to devs to define this
# there could cause issues
if extension:
app_config.cms_extension = extension()
discovered.append((app_config, config))
# All extensions are now attached; drop any extension lookup that was
# cached before every extension was registered, so the configs below
# see the complete list.
get_cms_extension_apps.cache_clear()
# Second pass: instantiate the configs, which may now safely look up
# any registered extension via get_contract().
for app_config, config in discovered:
if config:
app_config.cms_config = config(app_config)
@cache
def get_cms_extension_apps():
"""
Returns django app configs of apps with a cms extension
"""
# NOTE: The cms_extension attr is added by the autodiscover_cms_configs
# function if a cms_config.py file with a suitable class is found.
cms_apps = [app_config for app_config in apps.get_app_configs()
if hasattr(app_config, 'cms_extension')]
return cms_apps
@cache
def get_cms_config_apps():
"""
Returns django app configs of apps with a cms config
"""
# NOTE: The cms_config attr is added by the autodiscover_cms_configs
# function if a cms_config.py file with a suitable class is found.
cms_apps = [app_config for app_config in apps.get_app_configs()
if hasattr(app_config, 'cms_config')]
return cms_apps
def configure_cms_apps(apps_with_features):
"""
Check installed apps for apps that are configured to use cms addons
and run code to register them with their config
"""
for app_with_feature in apps_with_features:
contract = getattr(app_with_feature.cms_extension, "contract", None)
if contract is None:
contract_label = app_with_feature.label # default contract name
elif isinstance(contract, (tuple, list)) and len(contract) == 2:
contract_label = contract[0]
else:
raise ImproperlyConfigured("CMSExtension contract property needs to be a 2-tuple consisting of the form (contract_name, contract_object)")
enabled_property = f"{contract_label}_enabled"
configure_app = app_with_feature.cms_extension.configure_app
for app_config in get_cms_config_apps():
if getattr(app_config.cms_config, enabled_property, False):
# Feature enabled for this app so configure
configure_app(app_config.cms_config)
def ready_cms_apps(apps_with_features):
"""Run ready() methods on every registered cms extension,
so that final checks can happen after all apps have been configured
"""
for app_with_feature in apps_with_features:
app_with_feature.cms_extension.ready()
# TODO: Remove this function once backwards compatibility is deprecated
def backwards_compatibility_config():
"""
Ensure that old ways of configuring and setting up things (plugin
pools etc.) still work.
NOTE: The autodiscover code has been copied over from
django.utils.module_loading.autodiscover_modules
This is because django's autodiscover function imports
django.apps.apps from within the body of the function, which
interferes with backwards compatibility testing. The testing is
non-trivial and requires the patching of django.apps.apps (which
is impossible when imports are done inside of functions).
"""
# The old pools defined various discover methods that looked for
# specific files and ran the register code in them. Listing the
# names of the files here.
modules_to_autodiscover = ['cms_wizards']
for module in modules_to_autodiscover:
for app_config in apps.get_app_configs():
try:
import_module(f'{app_config.name}.{module}')
except Exception: # NOQA
# Decide whether to bubble up this error. If the app just
# doesn't have the module in question, we can ignore the error
# attempting to import it, otherwise we want it to bubble up.
if module_has_submodule(app_config.module, module):
raise

102
cms/apphook_pool.py Normal file
View file

@ -0,0 +1,102 @@
import warnings
from django.core.exceptions import ImproperlyConfigured
from django.utils.module_loading import autodiscover_modules, import_string
from django.utils.translation import gettext as _
from cms.app_base import CMSApp
from cms.exceptions import AppAlreadyRegistered
from cms.utils.conf import get_cms_setting
class ApphookPool:
def __init__(self):
self.apphooks = []
self.apps = {}
self.discovered = False
def clear(self):
# TODO: remove this method, it's Python, we don't need it.
self.apphooks = []
self.apps = {}
self.discovered = False
def register(self, app=None, discovering_apps=False):
# allow use as a decorator
if app is None:
return lambda app: self.register(app, discovering_apps)
if self.apphooks and not discovering_apps:
return app
if app.__name__ in self.apps:
raise AppAlreadyRegistered(
'A CMS application %r is already registered' % app.__name__
)
if not issubclass(app, CMSApp):
raise ImproperlyConfigured(
'CMS application must inherit from cms.app_base.CMSApp, but %r does not' % app.__name__
)
if not hasattr(app, 'menus') and hasattr(app, 'menu'):
warnings.warn(
"You define a 'menu' attribute on CMS application %r, but the 'menus' attribute is empty, "
"did you make a typo?" % app.__name__
)
self.apps[app.__name__] = app()
return app
def discover_apps(self):
self.apphooks = get_cms_setting('APPHOOKS')
if self.apphooks:
for path in self.apphooks:
cls = import_string(path)
try:
self.register(cls, discovering_apps=True)
except AppAlreadyRegistered:
pass
else:
autodiscover_modules('cms_apps')
self.discovered = True
def get_apphooks(self):
hooks = []
if not self.discovered:
self.discover_apps()
for app_name in self.apps:
app = self.apps[app_name]
if app.get_urls():
hooks.append((app_name, app.name or app_name))
# Unfortunately, we lose the ordering since we now have a list of
# tuples. Let's reorder by app_name:
hooks = sorted(hooks, key=lambda hook: hook[1])
return hooks
def get_apphook(self, app_name):
if not self.discovered:
self.discover_apps()
try:
return self.apps[app_name]
except KeyError:
# deprecated: return apphooks registered in db with urlconf name
# instead of apphook class name
for app in self.apps.values():
if app_name in app.get_urls():
return app
warnings.warn(_('No registered apphook "%r" found') % app_name)
return None
apphook_pool = ApphookPool()

295
cms/appresolver.py Normal file
View file

@ -0,0 +1,295 @@
from collections import OrderedDict
from importlib import import_module
from django.contrib.sites.models import Site
from django.core.exceptions import ImproperlyConfigured
from django.db import OperationalError, ProgrammingError
from django.urls import NoReverseMatch, Resolver404, URLResolver, reverse
from django.urls.resolvers import RegexPattern, URLPattern
from django.utils.translation import get_language, override
from cms.apphook_pool import apphook_pool
from cms.models.pagemodel import Page
from cms.utils import get_current_site
from cms.utils.i18n import get_language_list
APP_RESOLVERS = []
def clear_app_resolvers():
global APP_RESOLVERS
APP_RESOLVERS = []
def applications_page_check(request):
"""Tries to find if given path was resolved over application.
Applications have higher priority than other cms pages.
"""
# We should get in this branch only if an apphook is active on /
# This removes the non-CMS part of the URL.
try:
path = request.path_info.replace(reverse('pages-root'), '', 1)
except NoReverseMatch:
path = request.path_info
# check if application resolver can resolve this
site = get_current_site(request)
for lang in get_language_list(site_id=site.pk):
if path.startswith(lang + "/"):
path = path[len(lang + "/"):]
for resolver in APP_RESOLVERS:
try:
page_id = resolver.resolve_page_id(path)
return Page.objects.get(id=page_id)
except Resolver404:
# Raised if the page is not managed by an apphook
pass
except Page.DoesNotExist:
pass
return None
class AppRegexURLResolver(URLResolver):
def __init__(self, *args, **kwargs):
self.page_id = None
self.url_patterns_dict = {}
super().__init__(*args, **kwargs)
@property
def urlconf_module(self):
# It is valid for urlconf_module to be a list of patterns. So we just
# return the list here.
#
# See https://github.com/django/django/blob/2.2.4/django/urls/resolvers.py#L578
#
return self.url_patterns_dict.get(get_language(), [])
# On URLResolver the url_patterns property is cached and thus calls made after
# language changes (different return values for get_language()) would not return the
# right value. Overriding here prevents caching.
@property
def url_patterns(self):
return self.urlconf_module
def resolve_page_id(self, path):
"""Resolves requested path similar way how resolve does, but instead
of return callback,.. returns page_id to which was application
assigned.
"""
tried = []
pattern = getattr(self, 'pattern', self)
match = pattern.regex.search(path)
if match:
new_path = path[match.end():]
for pattern in self.url_patterns:
if isinstance(pattern, AppRegexURLResolver):
try:
return pattern.resolve_page_id(new_path)
except Resolver404:
pass
else:
try:
sub_match = pattern.resolve(new_path)
except Resolver404 as e:
tried_match = e.args[0].get('tried')
if tried_match is not None:
tried.extend([[pattern] + t for t in tried_match])
else:
tried.extend([pattern])
else:
if sub_match:
return getattr(pattern, 'page_id', None)
pattern = getattr(pattern, 'pattern', pattern)
tried.append(pattern.regex.pattern)
raise Resolver404({'tried': tried, 'path': new_path})
def recurse_patterns(path, pattern_list, page_id, default_args=None,
nested=False):
"""
Recurse over a list of to-be-hooked patterns for a given path prefix
"""
newpatterns = []
for pattern in pattern_list:
app_pat = getattr(pattern, 'pattern', pattern).regex.pattern
# make sure we don't get patterns that start with more than one '^'!
app_pat = app_pat.lstrip('^')
path = path.lstrip('^')
regex = rf'^{path}{app_pat}' if not nested else r'^%s' % (app_pat)
if isinstance(pattern, URLResolver):
# include default_args
args = pattern.default_kwargs
if default_args:
args.update(default_args)
# see lines 243 and 236 of urlresolvers.py to understand the next line
urlconf_module = recurse_patterns(regex, pattern.url_patterns, page_id, args, nested=True)
# this is an 'include', recurse!
regex_pattern = regex
regex_pattern = RegexPattern(regex)
resolver = URLResolver(regex_pattern, urlconf_module,
pattern.default_kwargs, pattern.app_name,
pattern.namespace)
else:
# Re-do the URLPattern with the new regular expression
args = pattern.default_args
if default_args:
args.update(default_args)
regex_pattern = RegexPattern(regex, name=pattern.name, is_endpoint=True)
resolver = URLPattern(regex_pattern, pattern.callback, args,
pattern.name)
resolver.page_id = page_id
newpatterns.append(resolver)
return newpatterns
def _set_permissions(patterns, exclude_permissions):
for pattern in patterns:
if isinstance(pattern, URLResolver):
if pattern.namespace in exclude_permissions:
continue
_set_permissions(pattern.url_patterns, exclude_permissions)
else:
from cms.utils.decorators import cms_perms
pattern.callback = cms_perms(pattern.callback)
def _set_site_filter(patterns, site_id):
for pattern in patterns:
if isinstance(pattern, URLResolver):
_set_site_filter(pattern.url_patterns, site_id)
else:
from cms.utils.decorators import cms_site_filter
pattern.callback = cms_site_filter(site_id)(pattern.callback)
def get_app_urls(urls):
for urlconf in urls:
if isinstance(urlconf, str):
mod = import_module(urlconf)
if not hasattr(mod, 'urlpatterns'):
raise ImproperlyConfigured(
"URLConf `%s` has no urlpatterns attribute" % urlconf)
yield mod.urlpatterns
elif isinstance(urlconf, (list, tuple)):
yield urlconf
else:
yield [urlconf]
def get_patterns_for_page_url(page_url):
"""
Resolve the urlconf module for a path+title combination
Returns a list of url objects.
"""
path = page_url.path
if path and not path.endswith('/'):
path += '/'
app = apphook_pool.get_apphook(page_url.page.application_urls)
url_patterns = []
for pattern_list in get_app_urls(app.get_urls(page_url.page, page_url.language)):
url_patterns += recurse_patterns(path, pattern_list, page_url.page_id)
return url_patterns
def get_app_patterns():
try:
try:
site = Site.objects.get_current()
return _get_app_patterns(site)
except ImproperlyConfigured:
# Site framework not configured with SITE_ID, fallback to
# request-based site resolution (not possible here), so just
# add the apphook patterns with a site filter.
return _get_app_patterns(None) # site-independent mode
except (OperationalError, ProgrammingError,):
# ignore if DB is not ready
# Starting with Django 1.9 this code gets called even when creating
# or running migrations. So in many cases the DB will not be ready yet.
return []
def _get_app_patterns(site: Site | None) -> list[URLPattern]:
"""
Get a list of patterns for all hooked apps.
How this works:
By looking through all titles with an app hook (application_urls) we find
all urlconf modules we have to hook into titles.
If we use the ML URL Middleware, we namespace those patterns with the title
language.
All 'normal' patterns from the urlconf get re-written by prefixing them with
the title path and then included into the cms url patterns.
If the app is still configured, but is no longer installed/available, then
this method returns a degenerate patterns object: patterns('')
"""
from cms.models.pagemodel import PageUrl
included = []
hooked_applications = OrderedDict()
# we don't have a request here so get_page_queryset() can't be used,
# so use public() queryset.
# This can be done because url patterns are used just in frontend
page_urls = PageUrl.objects.get_for_site(site) if site else PageUrl.objects.all()
# Loop over all titles with an application hooked to them
page_urls = (
page_urls
.exclude(path__isnull=True)
.exclude(page__application_urls=None)
.exclude(page__application_urls='')
# Page types are blueprints for new pages, not content. Their apphook
# would otherwise be dispatched before the catch-all ``cms.views.details``
# route, serving the page type on the public site.
.exclude(page__is_page_type=True)
.order_by('-page__path')
.select_related('page')
)
for page_url in page_urls:
mix_id = "{}:{}:{}".format(
page_url.path + "/",
page_url.page.application_urls,
page_url.language,
)
if mix_id in included:
# don't add the same thing twice
continue
app = apphook_pool.get_apphook(page_url.page.application_urls)
if not app:
continue
if page_url.page not in hooked_applications:
hooked_applications[page_url.page] = {}
app_ns = app.app_name, page_url.page.application_namespace
with override(page_url.language):
hooked_applications[page_url.page][page_url.language] = (
app_ns, get_patterns_for_page_url(page_url), app)
included.append(mix_id)
# Build the app patterns to be included in the cms urlconfs
app_patterns = []
for page, languages in hooked_applications.items():
resolver = None
for lang, ((app_ns, inst_ns), current_patterns, app) in languages.items():
if not resolver:
regex_pattern = RegexPattern(r'')
resolver = AppRegexURLResolver(
regex_pattern, 'app_resolver', app_name=app_ns, namespace=inst_ns)
resolver.page_id = page.pk
if app.permissions:
_set_permissions(current_patterns, app.exclude_permissions)
if site is None:
_set_site_filter(current_patterns, page.site_id)
resolver.url_patterns_dict[lang] = current_patterns
app_patterns.append(resolver)
APP_RESOLVERS.append(resolver)
return app_patterns

14
cms/apps.py Normal file
View file

@ -0,0 +1,14 @@
from django.apps import AppConfig
from django.utils.translation import gettext_lazy as _
class CMSConfig(AppConfig):
name = 'cms'
verbose_name = _("django CMS")
default_auto_field = 'django.db.models.AutoField'
def ready(self):
from cms.utils.setup import setup, setup_cms_apps
setup()
setup_cms_apps()

87
cms/cache/__init__.py vendored Normal file
View file

@ -0,0 +1,87 @@
import re
from cms.utils.conf import get_cms_setting
CMS_PAGE_CACHE_VERSION_KEY = get_cms_setting("CACHE_PREFIX") + '_PAGE_CACHE_VERSION'
def _get_cache_version():
"""
Returns the current page cache version, explicitly setting one if not
defined.
"""
from django.core.cache import cache
version = cache.get(CMS_PAGE_CACHE_VERSION_KEY)
if version:
return version
else:
_set_cache_version(1)
return 1
def _set_cache_version(version):
"""
Set the cache version to the specified value.
"""
from django.core.cache import cache
cache.set(
CMS_PAGE_CACHE_VERSION_KEY,
version,
get_cms_setting('CACHE_DURATIONS')['content']
)
def invalidate_cms_page_cache():
"""
Invalidates the CMS PAGE CACHE.
"""
#
# NOTE: We're using a cache versioning strategy for invalidating the page
# cache when necessary. Instead of wiping all the old entries, we simply
# increment the version number rendering all previous entries
# inaccessible and left to expire naturally.
#
# ALSO NOTE: According to the Django documentation, a timeout value of
# `None' (in version 1.6+) is supposed to mean "cache forever", however,
# this is actually only implemented as only slightly less than 30 days in
# some backends (memcached, in particular). In older Djangos, `None' means
# "use default value". To avoid issues arising from different Django
# versions and cache backend implementations, we will explicitly set the
# lifespan of the CMS_PAGE_CACHE_VERSION entry to whatever is set in
# settings.CACHE_DURATIONS['content']. This allows users to adjust as
# necessary for their backend.
#
# To prevent writing cache entries that will live longer than our version
# key, we will always re-write the current version number into the cache
# just after we write any new cache entries, thus ensuring that the
# version number will always outlive any entries written against that
# version. This is a cheap operation.
#
# If there are no new cache writes before the version key expires, its
# perfectly OK, since any previous entries cached against that version
# will have also expired, so, it'd be pointless to try to access them
# anyway.
#
version = _get_cache_version()
_set_cache_version(version + 1)
CLEAN_KEY_PATTERN = re.compile(r'[^a-zA-Z0-9_-]')
def _clean_key(key):
return CLEAN_KEY_PATTERN.sub('-', key)
def _get_cache_key(name, page_lookup, lang, site_id):
from cms.models import Page
if isinstance(page_lookup, Page):
page_key = str(page_lookup.pk)
else:
page_key = str(page_lookup)
page_key = _clean_key(page_key)
return f'{get_cms_setting("CACHE_PREFIX")}{name}__page_lookup:{page_key}_site:{str(site_id)}_lang:{str(lang)}'

30
cms/cache/choices.py vendored Normal file
View file

@ -0,0 +1,30 @@
from django.conf import settings
from cms.utils.conf import get_cms_setting
def _site_cache_key(lang):
return "{}-{}".format(get_cms_setting('SITE_CHOICES_CACHE_KEY'), lang)
def _page_cache_key(lang):
return "{}-{}".format(get_cms_setting('PAGE_CHOICES_CACHE_KEY'), lang)
def _clean_many(prefix):
from django.core.cache import cache
keys = []
if settings.USE_I18N:
for lang in [language[0] for language in settings.LANGUAGES]:
keys.append(f"{prefix}-{lang}")
else:
keys = [f"{prefix}-{settings.LANGUAGE_CODE}"]
cache.delete_many(keys)
def clean_site_choices_cache(sender, **kwargs):
_clean_many(get_cms_setting('SITE_CHOICES_CACHE_KEY'))
def clean_page_choices_cache(sender, **kwargs):
_clean_many(get_cms_setting('PAGE_CHOICES_CACHE_KEY'))

272
cms/cache/page.py vendored Normal file
View file

@ -0,0 +1,272 @@
from __future__ import annotations
import datetime
import hashlib
from collections.abc import Mapping
from datetime import timedelta
from typing import TYPE_CHECKING, Any
from django.conf import settings
from django.utils.cache import (
add_never_cache_headers,
patch_response_headers,
patch_vary_headers,
)
from django.utils.encoding import iri_to_uri
from django.utils.timezone import now
from cms.cache import _get_cache_key, _get_cache_version, _set_cache_version
from cms.constants import EXPIRE_NOW, MAX_EXPIRATION_TTL
from cms.toolbar.utils import get_toolbar_from_request
from cms.utils import get_current_site
from cms.utils.compat.response import get_response_headers
from cms.utils.conf import get_cms_setting
from cms.utils.helpers import get_timezone_name
from cms.utils.i18n import get_default_language_for_site
if TYPE_CHECKING:
from collections.abc import Iterable
from django.http import HttpRequest, HttpResponse
from cms.models import Page
def _page_cache_key(request: HttpRequest, vary_on: Iterable[str] | None = None) -> str:
"""
Generate a cache key based on the request path and language.
The language is determined following django-cms's language resolution order.
``vary_on`` is an optional iterable of header names declared by plugins via
``get_vary_cache_on()``. The request's values for those headers are always
folded into the key so that responses varying on those headers are cached
separately per value (instead of the first variant being served to
everyone). A missing (``None``) or empty ``vary_on`` hashes to a constant.
"""
site = get_current_site(request)
if hasattr(request, "LANGUAGE_CODE"):
language = request.LANGUAGE_CODE
else:
language = get_default_language_for_site(site.pk)
cache_key = "%s:%d:%s:%s" % (
get_cms_setting("CACHE_PREFIX"),
site.pk,
language,
hashlib.sha1(iri_to_uri(request.get_full_path()).encode("utf-8")).hexdigest(),
)
if settings.USE_TZ:
cache_key += ".%s" % get_timezone_name()
cache_key += ".%s" % _vary_on_hash(request, vary_on or [])
return cache_key
def _vary_on_hash(request: HttpRequest, vary_on: Iterable[str]) -> str:
"""Hash of the request's values for the given (plugin-declared) headers."""
ctx = hashlib.sha1()
for header in sorted(header.lower() for header in vary_on):
# Mirror Django's translation of header names to ``request.META`` keys.
meta_key = "HTTP_" + header.upper().replace("-", "_")
value = request.META.get(meta_key, "")
ctx.update(("%s=%s&" % (header, iri_to_uri(value))).encode("utf-8"))
return ctx.hexdigest()
def _page_vary_headers_cache_key(request: HttpRequest) -> str:
"""Key under which the list of plugin-declared vary headers is stored.
The list cannot be known on a cache read until the page is rendered, so it
is persisted on write and looked up first on read (mirroring Django's
``learn_cache_key`` / ``get_cache_key`` two-step approach).
"""
return _page_cache_key(request) + ".vary-on"
def set_page_cache(response: HttpResponse) -> HttpResponse:
"""Store a rendered page response in the CMS page cache.
The response is only cached for anonymous requests when the page cache is
enabled and the toolbar has not disabled caching. The cache timeout is the
smallest of the configured ``content`` duration and every cache-able
placeholder's TTL (as returned by ``get_cache_expiration()``); if any
placeholder requests ``EXPIRE_NOW`` the response is not cached at all.
When the response is cacheable, expiration and ``Vary`` headers are patched
onto it and two entries are written: the list of plugin-declared vary
headers (see :func:`_page_vary_headers_cache_key`) and the response payload
(content, headers and absolute expiry timestamp) under the header-aware
content key. Otherwise ``never cache`` headers are added.
Returns the (possibly header-patched) ``response``.
"""
from django.core.cache import cache
request = response._request
toolbar = get_toolbar_from_request(request)
is_authenticated = request.user.is_authenticated
if is_authenticated or toolbar._cache_disabled or not get_cms_setting("PAGE_CACHE"):
add_never_cache_headers(response)
return response
# This *must* be TZ-aware
timestamp = now()
placeholders = toolbar.content_renderer.get_rendered_placeholders()
# Checks if there's a plugin using the legacy "cache = False"
placeholder_ttl_list = []
vary_cache_on_set = set()
for ph in placeholders:
# get_cache_expiration() always returns:
# EXPIRE_NOW <= int <= MAX_EXPIRATION_IN_SECONDS
ttl = ph.get_cache_expiration(request, timestamp)
vary_cache_on = ph.get_vary_cache_on(request)
placeholder_ttl_list.append(ttl)
if ttl and vary_cache_on:
# We're only interested in vary headers if they come from
# a cache-able placeholder.
vary_cache_on_set |= set(vary_cache_on)
if EXPIRE_NOW not in placeholder_ttl_list:
if placeholder_ttl_list:
min_placeholder_ttl = min(x for x in placeholder_ttl_list)
else:
# Should only happen when there are no placeholders at all
min_placeholder_ttl = MAX_EXPIRATION_TTL
ttl = min(get_cms_setting("CACHE_DURATIONS")["content"], min_placeholder_ttl)
if ttl > 0:
# Adds expiration, etc. to headers
vary_on = sorted(vary_cache_on_set)
patch_response_headers(response, cache_timeout=ttl)
patch_vary_headers(response, vary_on)
version = _get_cache_version()
# We also store the absolute expiration timestamp to avoid
# recomputing it on cache-reads.
expires_datetime = timestamp + timedelta(seconds=ttl)
response_headers = get_response_headers(response)
# Persist whether this page opted out of Django's clickjacking
# middleware. Only "Allow" pages (no X-Frame-Options header) set
# xframe_options_exempt; "Inherit" pages leave it unset so the
# middleware adds the site default. The read path must replay this
# decision -- unconditionally exempting a cached response would drop
# X-Frame-Options from every inherit-default page (clickjacking).
xframe_options_exempt = getattr(response, "xframe_options_exempt", False)
# Persist the list of plugin-declared vary headers so the read path
# can rebuild the same (header-value aware) content key.
cache.set(
_page_vary_headers_cache_key(request),
vary_on,
ttl,
version=version,
)
cache.set(
_page_cache_key(request, vary_on),
(
response.content,
response_headers,
expires_datetime,
xframe_options_exempt,
),
ttl,
version=version,
)
# See note in invalidate_cms_page_cache()
_set_cache_version(version)
return response
def get_page_cache(request: HttpRequest) -> tuple[bytes, Mapping[str, str], datetime] | None:
"""Return the cached page response for ``request`` or ``None``.
Mirrors Django's two-step ``get_cache_key`` lookup: first the list of
plugin-declared vary headers is read, then the request's values for those
headers are folded into the content key so the matching variant is
returned. The cached value is the ``(content, headers, expires_datetime)``
tuple stored by :func:`set_page_cache`, or ``None`` on a cache miss.
"""
from django.core.cache import cache
version = _get_cache_version()
# First resolve which headers (if any) the cached page varies on, then
# build the content key from this request's values for those headers.
vary_on = cache.get(_page_vary_headers_cache_key(request), version=version)
return cache.get(_page_cache_key(request, vary_on), version=version)
def get_xframe_cache(page: Page) -> int | None:
"""Return the cached ``X-Frame-Options`` value for ``page`` or ``None``."""
from django.core.cache import cache
return cache.get("cms:xframe_options:%s" % page.pk)
def set_xframe_cache(page: Page, xframe_options: int) -> None:
"""Cache the resolved ``X-Frame-Options`` value for ``page``.
The cache version is re-written afterwards so the version key always
outlives the entries written against it (see
:func:`cms.cache.invalidate_cms_page_cache`).
"""
from django.core.cache import cache
cache.set("cms:xframe_options:%s" % page.pk, xframe_options, version=_get_cache_version())
_set_cache_version(_get_cache_version())
def _page_url_key(
page_lookup: Any,
lang: str,
site_id: int,
extra_key: str | None,
) -> str:
"""Build the cache key for a page's absolute URL.
``page_lookup`` is the untyped page reference accepted by the ``page_url``
template tag (a :class:`~cms.models.Page`, pk, reverse id, ...). When
``extra_key`` is a string it is folded into the key so callers can cache
distinct URLs (e.g. per query string) for the same page.
"""
return "".join([
_get_cache_key("page_url", page_lookup, lang, site_id),
f"_key:{extra_key}" if isinstance(extra_key, str) else "",
"_type:absolute_url",
])
def set_page_url_cache(
page_lookup: Any,
lang: str,
site_id: int,
url: str,
extra_key: str | None = None,
) -> None:
"""Cache the absolute ``url`` of a page for the ``content`` duration.
The cache version is re-written afterwards so the version key always
outlives the entries written against it (see
:func:`cms.cache.invalidate_cms_page_cache`).
"""
from django.core.cache import cache
cache.set(
_page_url_key(page_lookup, lang, site_id, extra_key),
url,
get_cms_setting("CACHE_DURATIONS")["content"],
version=_get_cache_version(),
)
_set_cache_version(_get_cache_version())
def get_page_url_cache(
page_lookup: Any,
lang: str,
site_id: int,
extra_key: str | None = None,
) -> str | None:
"""Return the cached absolute URL for a page lookup, or ``None`` on a miss."""
from django.core.cache import cache
return cache.get(_page_url_key(page_lookup, lang, site_id, extra_key), version=_get_cache_version())

76
cms/cache/permissions.py vendored Normal file
View file

@ -0,0 +1,76 @@
from cms.utils.conf import get_cms_setting
PERMISSION_KEYS = [
'add_page', 'change_page', 'change_page_advanced_settings',
'change_page_permissions', 'delete_page', 'move_page',
'publish_page', 'view_page',
]
def get_cache_key(user, site, key):
"""Cache key for ``user``'s page permissions of ``key`` on ``site``.
The page permissions behind a key are computed per site, so the site has to
be part of the key: without it the value warmed for one site is served for
every other one, granting access to sites the user has no rights on.
"""
return "%s:permission:%d:%d:%s" % (
get_cms_setting('CACHE_PREFIX'), user.pk or 0, site.pk, key)
def get_cache_permission_version_key():
return "{}:permission:version".format(get_cms_setting('CACHE_PREFIX'))
def get_cache_permission_version():
from django.core.cache import cache
try:
version = int(cache.get(get_cache_permission_version_key()))
except Exception:
version = 1
return int(version)
def get_permission_cache(user, site, key):
"""
Helper for reading values from cache
"""
from django.core.cache import cache
return cache.get(get_cache_key(user, site, key), version=get_cache_permission_version())
def set_permission_cache(user, site, key, value):
"""
Helper method for storing values in cache. Stores used keys so
all of them can be cleaned when clean_permission_cache gets called.
"""
from django.core.cache import cache
# store this key, so we can clean it when required
cache_key = get_cache_key(user, site, key)
cache.set(cache_key, value,
get_cms_setting('CACHE_DURATIONS')['permissions'],
version=get_cache_permission_version())
def clear_user_permission_cache(user):
"""
Cleans permission cache for given user, on every site.
"""
from django.contrib.sites.models import Site
from django.core.cache import cache
cache.delete_many(
[get_cache_key(user, site, key) for site in Site.objects.all() for key in PERMISSION_KEYS],
version=get_cache_permission_version(),
)
def clear_permission_cache():
from django.core.cache import cache
version = get_cache_permission_version()
if version > 1:
cache.incr(get_cache_permission_version_key())
else:
cache.set(get_cache_permission_version_key(), 2,
get_cms_setting('CACHE_DURATIONS')['permissions'])

171
cms/cache/placeholder.py vendored Normal file
View file

@ -0,0 +1,171 @@
"""
This module manages placeholder caching. We use a cache-versioning strategy
in which each (placeholder x lang x site_id) manages its own version. The
actual cache includes additional keys appropriate for the placeholders
get_vary_cache_on().
Invalidation of a placeholder's cache simply increments the version number for
the (placeholder x lang) pair, which renders any cache entries for that
placeholder under that version inaccessible. Those cache entries will simply
expire and will be purged according to the policy of the cache backend in-use.
The cache entries themselves may include additional sub-keys, according to the
list of VARY header-names as returned by placeholder.get_vary_cache_on() and
the current HTTPRequest object.
The vary-on header-names are also stored with the version. This enables us to
check for cache hits without re-computing placeholder.get_vary_cache_on().
"""
import hashlib
import time
from django.utils.timezone import now
from cms.utils.conf import get_cms_setting
from cms.utils.helpers import get_header_name, get_timezone_name
def _get_placeholder_cache_version_key(placeholder, lang, site_id):
"""
Returns the version key for the given «placeholder», «lang» and «site_id».
Invalidating this (via clear_placeholder_cache by replacing the stored
value with a new value) will effectively make all "sub-caches" relating to
this (placeholder x lang) inaccessible. Sub-caches include caches per TZ
and per VARY header.
"""
prefix = get_cms_setting("CACHE_PREFIX")
key = f"{prefix}|placeholder_cache_version|id:{placeholder.pk}|lang:{str(lang)}|site:{site_id}"
# django itself adds "version" add the end of cache-keys, e.g. "<key>:1".
# -> If `cache.set()` is for example called with `version=""`, it still adds
# `:` at the end. So if we check the length for `> 250`, a length of 249
# or even 250 ends up in an InvalidCacheKey-exception.
# In order to avoid these errors, we hash the keys at a lower length to also
# have a little buffer.
if len(key) > 200:
key = "{prefix}|{hash}".format(
prefix=prefix,
hash=hashlib.sha1(key.encode("utf-8")).hexdigest(),
)
return key
def _get_placeholder_cache_version(placeholder, lang, site_id):
"""
Gets the (placeholder x lang)'s current version and vary-on header-names
list, if present, otherwise resets to («timestamp», []).
"""
from django.core.cache import cache
key = _get_placeholder_cache_version_key(placeholder, lang, site_id)
cached = cache.get(key)
if cached:
version, vary_on_list = cached
else:
version = int(time.time() * 1000000)
vary_on_list = []
_set_placeholder_cache_version(placeholder, lang, site_id, version, vary_on_list)
return version, vary_on_list
def _set_placeholder_cache_version(placeholder, lang, site_id, version, vary_on_list=None, duration=None):
"""
Sets the (placeholder x lang)'s version and vary-on header-names list.
"""
from django.core.cache import cache
key = _get_placeholder_cache_version_key(placeholder, lang, site_id)
if not version or version < 1:
version = int(time.time() * 1000000)
if vary_on_list is None:
vary_on_list = []
cache.set(key, (version, vary_on_list), duration)
def _get_placeholder_cache_key(placeholder, lang, site_id, request, soft=False):
"""
Returns the fully-addressed cache key for the given placeholder and
the request.
The kwarg «soft» should be set to True if getting the cache key to then
read from the cache. If instead the key retrieval is to support a cache
write, let «soft» be False.
"""
prefix = get_cms_setting("CACHE_PREFIX")
version, vary_on_list = _get_placeholder_cache_version(placeholder, lang, site_id)
tz = get_timezone_name()
main_key = f"{prefix}|render_placeholder|id:{placeholder.pk}|lang:{lang}|site:{site_id}|tz:{tz}|v:{version}"
if not soft:
# We are about to write to the cache, so we want to get the latest
# vary_cache_on headers and the correct cache expiration, ignoring any
# we already have. If the placeholder has already been rendered, this
# will be very efficient (zero-additional queries) due to the caching
# of all its plugins during the rendering process anyway.
vary_on_list = placeholder.get_vary_cache_on(request)
duration = placeholder.get_cache_expiration(request, now())
# Update the main placeholder cache version
_set_placeholder_cache_version(placeholder, lang, site_id, version, vary_on_list, duration)
sub_key_list = []
for key in vary_on_list:
value = request.META.get(get_header_name(key)) or "_"
sub_key_list.append(key + ":" + value)
cache_key = main_key
if sub_key_list:
cache_key += "|" + "|".join(sub_key_list)
# django itself adds "version" add the end of cache-keys, e.g. "<key>:1".
# -> If `cache.set()` is for example called with `version=""`, it still adds
# `:` at the end. So if we check the length for `> 250`, a length of 249
# or even 250 ends up in an InvalidCacheKey-exception.
# In order to avoid these errors, we hash the keys at a lower length to also
# have a little buffer.
if len(cache_key) > 200:
cache_key = "{prefix}|{hash}".format(
prefix=prefix,
hash=hashlib.sha1(cache_key.encode("utf-8")).hexdigest(),
)
return cache_key
def set_placeholder_cache(placeholder, lang, site_id, content, request):
"""
Sets the (correct) placeholder cache with the rendered placeholder.
"""
from django.core.cache import cache
key = _get_placeholder_cache_key(placeholder, lang, site_id, request)
duration = min(get_cms_setting("CACHE_DURATIONS")["content"], placeholder.get_cache_expiration(request, now()))
cache.set(key, content, duration)
# "touch" the cache-version, so that it stays as fresh as this content.
version, vary_on_list = _get_placeholder_cache_version(placeholder, lang, site_id)
_set_placeholder_cache_version(placeholder, lang, site_id, version, vary_on_list, duration=duration)
def get_placeholder_cache(placeholder, lang, site_id, request):
"""
Returns the placeholder from cache respecting the placeholder's
VARY headers.
"""
from django.core.cache import cache
key = _get_placeholder_cache_key(placeholder, lang, site_id, request, soft=True)
content = cache.get(key)
return content
def clear_placeholder_cache(placeholder, lang, site_id):
"""
Invalidates all existing cache entries for (placeholder x lang x site_id).
We don't need to re-store the vary_on_list, because the cache is now
effectively empty.
"""
version = int(time.time() * 1000000)
_set_placeholder_cache_version(placeholder, lang, site_id, version, [])

87
cms/cms_config.py Normal file
View file

@ -0,0 +1,87 @@
from collections.abc import Iterable
from functools import cached_property
from logging import getLogger
from django.core.exceptions import ImproperlyConfigured
from cms.app_base import CMSAppConfig, CMSAppExtension
from cms.cms_wizards import cms_page_wizard, cms_subpage_wizard
from cms.models import PageContent
from cms.page_rendering import render_pagecontent
from cms.wizards.wizard_base import Wizard
logger = getLogger(__name__)
class CMSCoreConfig(CMSAppConfig):
cms_enabled = True
cms_wizards = [cms_page_wizard, cms_subpage_wizard]
cms_toolbar_enabled_models = [(PageContent, render_pagecontent, "page")]
class CMSCoreExtensions(CMSAppExtension):
def __init__(self):
self.lazy_wizards = []
self.toolbar_enabled_models = {}
self.model_groupers = {}
self.toolbar_mixins = []
def configure_wizards(self, cms_config):
"""
Adds all registered wizards from apps that define them to the
wizards dictionary on this class
"""
if not isinstance(cms_config.cms_wizards, Iterable):
raise ImproperlyConfigured("cms_wizards must be iterable")
self.lazy_wizards.append(cms_config.cms_wizards)
@cached_property
def wizards(self) -> dict[str, Wizard]:
"""
Returns a dictionary of wizard instances keyed by their unique IDs.
Iterates over all iterables in `self.lazy_wizards`, filters out objects that are instances
of the `Wizard` class, and constructs a dictionary where each key is the wizard's `id`
and the value is the corresponding `Wizard` instance.
Returns:
dict: A dictionary mapping wizard IDs to `Wizard` instances.
"""
wizards = {}
for iterable in self.lazy_wizards:
new_wizards = {wizard.id: wizard for wizard in iterable}
if wizard := next((wizard for wizard in new_wizards.values() if not isinstance(wizard, Wizard)), None):
# If any wizard in the iterable is not an instance of Wizard, raise an exception
raise ImproperlyConfigured(
f"cms_wizards must be iterable of Wizard instances, got {type(wizard)}"
)
wizards |= new_wizards
return wizards
def configure_toolbar_enabled_models(self, cms_config):
if not isinstance(cms_config.cms_toolbar_enabled_models, Iterable):
raise ImproperlyConfigured("cms_toolbar_enabled_models must be iterable")
for model, render_func, *grouper in cms_config.cms_toolbar_enabled_models:
if model in self.toolbar_enabled_models:
logger.warning(
f"Model {model} already registered for frontend rendering",
)
else:
self.toolbar_enabled_models[model] = render_func
if grouper:
self.model_groupers[model] = grouper[0]
def configure_toolbar_mixin(self, cms_config):
if not issubclass(cms_config.cms_toolbar_mixin, object):
raise ImproperlyConfigured("cms_toolbar_mixin must be class")
self.toolbar_mixins.append(cms_config.cms_toolbar_mixin)
def configure_app(self, cms_config):
# The cms_wizards settings is optional. If it's not here
# just move on.
if hasattr(cms_config, 'cms_wizards'):
self.configure_wizards(cms_config)
if hasattr(cms_config, 'cms_toolbar_enabled_models'):
self.configure_toolbar_enabled_models(cms_config)
if hasattr(cms_config, 'cms_toolbar_mixin'):
self.configure_toolbar_mixin(cms_config)

526
cms/cms_menus.py Normal file
View file

@ -0,0 +1,526 @@
import re
from collections import defaultdict
from collections.abc import Generator, Iterable
from django.utils.functional import SimpleLazyObject
from cms import constants
from cms.apphook_pool import apphook_pool
from cms.models import Page, PageContent, PagePermission, PageUrl
from cms.toolbar.utils import get_object_edit_url, get_object_preview_url, get_toolbar_from_request
from cms.utils.conf import get_cms_setting
from cms.utils.i18n import (
get_fallback_languages,
get_public_languages,
hide_untranslated,
is_valid_site_language,
)
from cms.utils.page_permissions import user_can_view_all_pages
from menus.base import Menu, Modifier, NavigationNode
from menus.menu_pool import menu_pool
# Shortcut for visibility markers
VISIBLE_FOR_AUTHENTICATED = constants.VISIBILITY_ALL, constants.VISIBILITY_USERS
VISIBLE_FOR_ANONYMOUS = constants.VISIBILITY_ALL, constants.VISIBILITY_ANONYMOUS
def get_visible_page_contents(request, page_contents: Iterable[PageContent], site) -> Iterable[PageContent]:
"""
This code is a many-pages-at-once version of cms.utils.page_permissions.user_can_view_page.
`pages` contains all published pages.
"""
public_for = get_cms_setting("PUBLIC_FOR")
can_see_unrestricted = public_for == "all" or (public_for == "staff" and request.user.is_staff)
if not request.user.is_authenticated and not can_see_unrestricted:
# User is not authenticated and can't see unrestricted pages,
# no need to check for page restrictions because if there's some,
# user is anon and if there is not any, user can't see unrestricted.
return []
if user_can_view_all_pages(request.user, site):
return page_contents
if not get_cms_setting("PERMISSION"):
# If there's no restrictions, let the user see all pages
# only if he can see unrestricted, otherwise return no pages.
return page_contents if can_see_unrestricted else []
restrictions = PagePermission.objects.filter(
page_id__in={page_content.page.pk for page_content in page_contents},
can_view=True,
)
restriction_map = {perm.page_id: perm for perm in restrictions}
user_id = request.user.pk
user_groups = SimpleLazyObject(lambda: frozenset(request.user.groups.values_list("pk", flat=True)))
is_auth_user = request.user.is_authenticated
def user_can_see_page(page: Page) -> bool:
if page.pk in restriction_map:
# set internal fk cache to our page with loaded ancestors and descendants
PagePermission.page.field.set_cached_value(restriction_map[page.pk], page)
restricted = False
for perm in restrictions:
if perm.get_page_permission_tuple().contains(page.path):
if not is_auth_user:
return False
if perm.user_id == user_id or perm.group_id in user_groups:
return True
restricted = True
# Page has no view restrictions, fallback to the project's
# CMS_PUBLIC_FOR setting.
return can_see_unrestricted and not restricted
return list(page_content for page_content in page_contents if user_can_see_page(page_content.page))
class CMSNavigationNode(NavigationNode):
"""
Represents a CMS Navigation Node for a Page object in the page tree.
Attributes:
path: The path of the node.
language: The language used for the node (optional).
"""
def __init__(self, *args, language: str | None = None, **kwargs):
"""
Initializes a CMSNavigationNode instance.
:param *args: Positional arguments.
:param language: The language used for the node. Optional.
:param **kwargs: Keyword arguments.
"""
# language is only used when we're dealing with a fallback
self.language = language
super().__init__(*args, **kwargs)
def is_selected(self, request) -> bool:
try:
return request.current_page.pk == self.id
except AttributeError:
return False
class CMSMenu(Menu):
"""Subclass of :class:`menus.base.Menu`. Its :meth:`~menus.base.Menu.get_nodes()` creates a list of NavigationNodes
based on a site's :class:`cms.models.pagemodel.Page` objects.
"""
def __init__(self, renderer):
"""
Initializes a CMSMenu instance.
:param renderer: The renderer object.
"""
super().__init__(renderer)
lang = renderer.request_language
site_pk = renderer.site.pk
if is_valid_site_language(lang, site_id=site_pk):
_valid_language = True
_hide_untranslated = hide_untranslated(lang, site_pk)
else:
_valid_language = False
_hide_untranslated = False
if _valid_language:
# The request language has been explicitly configured
# for the current site.
if _hide_untranslated:
fallbacks = []
else:
fallbacks = get_fallback_languages(lang, site_id=site_pk)
self.languages = [lang] + [_lang for _lang in fallbacks if _lang != lang]
else:
# The request language is not configured for the current site.
# Fallback to all configured public languages for the current site.
self.languages = get_public_languages(site_pk)
def select_lang(self, page_contents: Iterable[PageContent]) -> Generator[PageContent, None, None]:
"""Generator that returns only those page content objects passed that contain the first language
present in the languages list."""
lang_index = len(self.languages)
translation = None
page = None
for page_content in page_contents:
if translation and page != page_content.page:
yield translation
lang_index = self.languages.index(page_content.language)
translation = page_content
page = page_content.page
elif self.languages.index(page_content.language) < lang_index:
lang_index = self.languages.index(page_content.language)
translation = page_content
page = page_content.page
if translation:
yield translation
def get_menu_node_for_page_content(
self,
page_content: PageContent,
view_url: str | None = None,
cut: bool = False,
) -> CMSNavigationNode:
"""
Transform a CMS page content object into a navigation node.
:param page: The page to transform.
:param languages: The list of the current language plus fallbacks used to render the menu.
:param view_url: If given, serves as a "pattern" for a view url with the assumption that "/0/" is replaced
by the actual page content pk. Default is None.
:param cut: If True the parent_id is set to None. Default is False.
:returns: A CMSNavigationNode instance.
"""
page = page_content.page
# These are simple to port over, since they are not calculated.
# Other attributes will be added conditionally later.
visibility = page_content.limit_visibility_in_menu
attr = {
"is_page": True,
"soft_root": page_content.soft_root,
"redirect_url": page_content.redirect,
"auth_required": page.login_required,
"reverse_id": page.reverse_id,
"is_home": page.is_home,
"visible_for_authenticated": visibility in VISIBLE_FOR_AUTHENTICATED,
"visible_for_anonymous": visibility in VISIBLE_FOR_ANONYMOUS,
}
extenders = []
if page.navigation_extenders:
if page.navigation_extenders in self.renderer.menus:
extenders.append(page.navigation_extenders)
elif f"{page.navigation_extenders}:{page.pk}" in self.renderer.menus:
extenders.append(f"{page.navigation_extenders}:{page.pk}")
# Is this page an apphook? If so, we need to handle the apphooks's nodes
# Only run this if we have a translation in the requested language for this
# object. The page content cache should have been prepopulated in CMSMenu.get_nodes
# but otherwise, just request the title normally
if page.application_urls and page_content.language == self.languages[0]:
# it means it is an apphook
app = apphook_pool.get_apphook(page.application_urls)
if app:
extenders.extend(app.get_menus(page, self.languages[0]))
# CMSAattachMenus are treated a bit differently to allow them to be
# able to be attached to multiple points in the navigation.
attr["navigation_extenders"] = [
f"{ext.__name__}:{page.pk}" if hasattr(ext, "get_instances") else getattr(ext, "__name__", ext)
for ext in extenders
]
# Now finally, build the NavigationNode object and return it.
# The parent_id is manually set by the menu get_nodes method.
if view_url:
# Build view url by replacing "/0/" in the url template by the actual pk of the page content object
# Hacky, but faster than calling `admin_reverse` for each page content object
url = re.sub("(/0/)", f"/{page_content.pk}/", view_url)
else:
url = page.get_absolute_url(language=page_content.language)
return CMSNavigationNode(
title=page_content.menu_title or page_content.title,
url=url,
id=page.pk,
parent_id=None if cut else page_content.page.parent_id,
attr=attr,
visible=page_content.in_navigation,
language=(page_content.language if page_content.language != self.languages[0] else None),
)
def get_nodes(self, request) -> list[NavigationNode]:
"""
Returns a list of NavigationNode objects representing the navigation nodes to be displayed in the menu.
This method is performance-critical since the number of page content objects can be
large.
:param self: The instance of the class.
:param request: The HTTP request object.
:return: A list of NavigationNode objects representing the navigation nodes.
:rtype: list[NavigationNode]
.. note::
* The method retrieves the necessary data from the database to build the navigation nodes for the menu.
* The behavior of the method depends on whether the edit mode or preview mode is active in the toolbar.
* If either edit mode or preview mode is active, the method retrieves all current page content objects
visible in the admin for the current page.
* If neither edit mode nor preview mode is active, the method retrieves only public page content objects.
* The retrieved page contents are filtered based on the specified languages, sorted by page path,
and filtered by site.
* Only specific fields of the page content objects are selected to optimize performance.
* If either edit mode or preview mode is active, a preview URL is constructed for a "virtual" non-existing
page content with id=0 to avoid too many calls to ``revert`` the admin URL.
* The method includes a nested function for prefetching URLs and filling the URL cache.
* The visibility of the page contents is further filtered based on authentication and permissions.
* The homepage is determined based on the page contents and marked for cutting if necessary.
* The menu node for each page content is created using the get_menu_node_for_page_content method of the
instance.
* The prefetch_urls function is called for each page content to fill the URL cache and provide necessary
data for creating the menu node.
* The select_lang method is used to filter the page contents based on the specified language preferences.
"""
site = self.renderer.site
toolbar = get_toolbar_from_request(request)
if toolbar.edit_mode_active or toolbar.preview_mode_active:
# Get all translations visible in the admin for the current page
translations_qs = PageContent.admin_manager.current_content()
else:
# Only get public translations
translations_qs = PageContent.objects
page_contents = (
translations_qs.filter(language__in=self.languages)
.order_by("page__path")
.filter(page__site=site)
.select_related("page")
.only(
"page_id",
"language",
"menu_title",
"title",
"limit_visibility_in_menu",
"soft_root",
"in_navigation",
"redirect",
"page__site_id",
"page__parent_id",
"page__path", # needed by the view-restriction check in get_visible_page_contents
"page__is_home",
"page__login_required",
"page__reverse_id",
"page__navigation_extenders",
"page__application_urls",
)
)
if toolbar.edit_mode_active or toolbar.preview_mode_active:
# Edit URL for a "virtual" non-existing page content with id=0. This is used to quickly build many
# edit urls by replacing "/0/" by the page content pk in the edit url
view_url = get_object_edit_url(PageContent(id=0)) if toolbar.edit_mode_active else get_object_preview_url(PageContent(id=0))
def prefetch_urls(page_content: PageContent) -> PageContent:
return page_content
else:
view_url = None # No short-cut here
prefetched_urls = PageUrl.objects.filter(
language__in=(page_content.language for page_content in page_contents),
page_id__in=(page_content.page.pk for page_content in page_contents),
) # Fetch the PageUrl objects
# Prepare for filling urls_cache
filtered_urls = defaultdict(dict)
for page_url in prefetched_urls:
filtered_urls[page_url.page_id][page_url.language] = page_url
def prefetch_urls(page_content: PageContent) -> PageContent:
# Fill url cache for existing urls and page contents into the cache
# Access the cache directly to not lead to a db hit when accessing
# non-existing languages
page_content.page.urls_cache = filtered_urls.get(page_content.page_id)
return page_content
page_contents = get_visible_page_contents(request, page_contents, site)
home = next((page_content for page_content in page_contents if page_content.page.is_home), None)
# Find homepage
cut_homepage = home and not home.in_navigation
homepage_pk = home.page.pk if home else None
return [
self.get_menu_node_for_page_content(
prefetch_urls(page_content),
view_url=view_url,
cut=page_content.page.parent_id == homepage_pk and cut_homepage,
)
for page_content in self.select_lang(page_contents)
]
menu_pool.register_menu(CMSMenu)
class NavExtender(Modifier):
def modify(self, request, nodes, namespace, root_id, post_cut, breadcrumb):
if post_cut:
return nodes
# rearrange the parent relations
# Find home
home = next((n for n in nodes if n.attr.get("is_home", False)), None)
# Find nodes with NavExtenders
exts = []
for node in nodes:
extenders = node.attr.get("navigation_extenders", None)
if extenders:
for ext in extenders:
if ext not in exts:
exts.append(ext)
# Link the nodes
for extnode in nodes:
if extnode.namespace == ext and not extnode.parent_id:
# if home has nav extenders but home is not visible
if node == home and not node.visible:
# extnode.parent_id = None
extnode.parent_namespace = None
extnode.parent = None
else:
extnode.parent_id = node.id
extnode.parent_namespace = node.namespace
extnode.parent = node
node.children.append(extnode)
removed = []
# find all not assigned nodes
for menu in self.renderer.menus.items():
if hasattr(menu[1], "cms_enabled") and menu[1].cms_enabled and menu[0] not in exts:
for node in nodes:
if node.namespace == menu[0]:
removed.append(node)
if breadcrumb:
# if breadcrumb and home not in navigation add node
if breadcrumb and home and not home.visible:
home.visible = True
if request.path_info == home.get_absolute_url():
home.selected = True
else:
home.selected = False
# remove all nodes that are nav_extenders and not assigned
for node in removed:
nodes.remove(node)
return nodes
menu_pool.register_modifier(NavExtender)
class SoftRootCutter(Modifier):
"""
A soft root is a page that acts as the root for a menu navigation tree.
Typically, this will be a page that is the root of a significant new
section on your site.
When the soft root feature is enabled, the navigation menu for any page
will start at the nearest soft root, rather than at the real root of
the site’s page hierarchy.
This feature is useful when your site has deep page hierarchies (and
therefore multiple levels in its navigation trees). In such a case, you
usually don’t want to present site visitors with deep menus of nested
items.
For example, you’re on the page -Introduction to Bleeding-?, so the menu
might look like this:
* School of Medicine
* Medical Education
* Departments
* Department of Lorem Ipsum
* Department of Donec Imperdiet
* Department of Cras Eros
* Department of Mediaeval Surgery
* Theory
* Cures
* Bleeding
* Introduction to Bleeding <this is the current page>
* Bleeding - the scientific evidence
* Cleaning up the mess
* Cupping
* Leaches
* Maggots
* Techniques
* Instruments
* Department of Curabitur a Purus
* Department of Sed Accumsan
* Department of Etiam
* Research
* Administration
* Contact us
* Impressum
which is frankly overwhelming.
By making "Department of Mediaeval Surgery" a soft root, the menu
becomes much more manageable:
* Department of Mediaeval Surgery
* Theory
* Cures
* Bleeding
* Introduction to Bleeding <current page>
* Bleeding - the scientific evidence
* Cleaning up the mess
* Cupping
* Leaches
* Maggots
* Techniques
* Instruments
"""
def modify(self, request, nodes, namespace, root_id, post_cut, breadcrumb):
# only apply this modifier if we're pre-cut (since what we do is cut)
# or if no id argument is provided, indicating {% show_menu_below_id %}
if post_cut or root_id:
return nodes
selected = None
root_nodes = []
# find the selected node as well as all the root nodes
for node in nodes:
if node.selected:
selected = node
if not node.parent:
root_nodes.append(node)
# if we found a selected ...
if selected:
# and the selected is a softroot
if selected.attr.get("soft_root", False):
# get it's descendants
nodes = selected.get_descendants()
# remove the link to parent
selected.parent = None
# make the selected page the root in the menu
nodes = [selected] + nodes
else:
# if it's not a soft root, walk ancestors (upwards!)
nodes = self.find_ancestors_and_remove_children(selected, nodes)
return nodes
def find_and_remove_children(self, node, nodes):
for child in node.children:
if child.attr.get("soft_root", False):
self.remove_children(child, nodes)
return nodes
def remove_children(self, node, nodes):
for child in node.children:
nodes.remove(child)
self.remove_children(child, nodes)
node.children = []
def find_ancestors_and_remove_children(self, node, nodes):
"""
Check ancestors of node for soft roots
"""
if node.parent:
if node.parent.attr.get("soft_root", False):
nodes = node.parent.get_descendants()
node.parent.parent = None
nodes = [node.parent] + nodes
else:
nodes = self.find_ancestors_and_remove_children(node.parent, nodes)
else:
for newnode in nodes:
if newnode != node and not newnode.parent:
self.find_and_remove_children(newnode, nodes)
for child in node.children:
if child != node:
self.find_and_remove_children(child, nodes)
return nodes
menu_pool.register_modifier(SoftRootCutter)

19
cms/cms_plugins.py Normal file
View file

@ -0,0 +1,19 @@
from django.utils.translation import gettext_lazy as _
from cms.models.placeholderpluginmodel import PlaceholderReference
from cms.plugin_base import CMSPluginBase
from cms.plugin_pool import plugin_pool
class PlaceholderPlugin(CMSPluginBase):
name = _("Placeholder")
parent_classes = ['0'] # so you will not be able to add it something
# require_parent = True
render_plugin = False
admin_preview = False
system = True
model = PlaceholderReference
plugin_pool.register_plugin(PlaceholderPlugin)

730
cms/cms_toolbars.py Normal file
View file

@ -0,0 +1,730 @@
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
)

56
cms/cms_wizards.py Executable file
View file

@ -0,0 +1,56 @@
from django.utils.translation import gettext_lazy as _
from cms.models import Page
from cms.utils.page_permissions import user_can_add_page, user_can_add_subpage
from .forms.wizards import CreateCMSPageForm, CreateCMSSubPageForm
from .wizards.wizard_base import Wizard
class CMSPageWizard(Wizard):
def user_has_add_permission(self, user, page=None, **kwargs):
site = kwargs.get("site")
parent_page = page.parent if page else None
if page and parent_page:
# User is adding a page which will be a right
# sibling to the current page.
return user_can_add_subpage(user, target=parent_page, site=site or parent_page.site)
elif page:
return user_can_add_page(user, site=site or page.site)
return user_can_add_page(user, site=site)
def get_success_url(self, obj, **kwargs):
page_content = obj.pagecontent_set(manager="admin_manager").first()
return super().get_success_url(page_content, **kwargs)
class CMSSubPageWizard(Wizard):
def user_has_add_permission(self, user, page=None, **kwargs):
if not page or page.application_urls:
# We can't really add a sub-page to a non-existent page. Or to an
# app-hooked page.
return False
return user_can_add_subpage(user, target=page)
def get_success_url(self, obj, **kwargs):
page_content = obj.pagecontent_set(manager="admin_manager").first()
return super().get_success_url(page_content, **kwargs)
cms_page_wizard = CMSPageWizard(
title=_("New page"),
weight=100,
form=CreateCMSPageForm,
model=Page,
description=_("Create a new page next to the current page.")
)
cms_subpage_wizard = CMSSubPageWizard(
title=_("New sub page"),
weight=110,
form=CreateCMSSubPageForm,
model=Page,
description=_("Create a page below the current page.")
)

90
cms/constants.py Normal file
View file

@ -0,0 +1,90 @@
"""
Collection of important constants used throughout django CMS.
"""
#: The token used to identify when a user selects "inherit" as template for a page.
TEMPLATE_INHERITANCE_MAGIC = 'INHERIT'
REFRESH_PAGE = 'REFRESH_PAGE'
"""
Supplied to ``on_close`` arguments to refresh the current page when the frame is closed, for
example:
.. code-block:: python
from cms.constants import REFRESH_PAGE
self.toolbar.add_modal_item(
'Modal item',
url=modal_url,
on_close=REFRESH_PAGE
)
"""
FOLLOW_REDIRECT = 'FOLLOW_REDIRECT'
URL_CHANGE = 'URL_CHANGE'
#: Used as a position indicator in the toolbar: On the right side.
RIGHT = object() # this is a trick so "foo is RIGHT" will only ever work for this, same goes for LEFT.
#: Used as a position indicator in the toolbar: On the left side.
LEFT = object()
PAGE_TYPES_ID = "page_types"
PAGE_TREE_POSITIONS = ('last-child', 'first-child', 'left', 'right')
#: Used for the ``limit_visibility_in_menu`` keyword argument to
#: :func: `create_page`.Does not limit menu visibility.
VISIBILITY_ALL = None
#: Used for the ``limit_visibility_in_menu`` keyword argument to :func: `create_page`.
#: Limits menu visibility to authenticated users.
VISIBILITY_USERS = 1
#: Used for the ``limit_visibility_in_menu`` keyword argument to :func: `create_page`.
#: Limits menu visibility to anonymous(not authenticated) users.
VISIBILITY_ANONYMOUS = 2
X_FRAME_OPTIONS_INHERIT = 0
X_FRAME_OPTIONS_DENY = 1
X_FRAME_OPTIONS_SAMEORIGIN = 2
X_FRAME_OPTIONS_ALLOW = 3
PAGE_USERNAME_MAX_LENGTH = 255
SLUG_REGEXP = '[0-9A-Za-z-_.//]+'
#: Used for cache control headers: 0 seconds, i.e. now.
EXPIRE_NOW = 0
#: Used for cache control headers: 365 * 24 * 3600 seconds, i.e. one year. HTTP specification says
#: max caching should only be up to one year.
MAX_EXPIRATION_TTL = 365 * 24 * 3600
def _get_toolbar_js(plugin_type: str) -> str:
return f'<script data-cms-{plugin_type} id="cms-{plugin_type}-%(pk)s" type="application/json">%(config)s</script>'
PLUGIN_TOOLBAR_JS = _get_toolbar_js("plugin")
PLACEHOLDER_TOOLBAR_JS = _get_toolbar_js("placeholder")
# In the permissions system we use user levels to determine
# the depth in which the user has permissions.
# This constant represents a user that can see pages at all depths.
ROOT_USER_LEVEL = -1
GRANT_ALL_PERMISSIONS = 'All'
PUBLISH_COMMENT = "Publish"
SCRIPT_USERNAME = 'script'
CMS_CONFIG_NAME = 'cms_config'
MODAL_HTML_REDIRECT = '<body><a class="cms-view-new-object" target="_top" href="{url}">Redirecting...</a></body>'
#: Default chunk_size for QuerySet.iterator() calls on plugin querysets.
#: Required since Django 5.0 when prefetch_related is applied.
PLUGIN_ITERATOR_CHUNK_SIZE = 2000

31
cms/context_processors.py Normal file
View file

@ -0,0 +1,31 @@
from functools import cache
from django.utils.functional import lazy
from cms.utils.conf import get_cms_setting
from cms.utils.page import get_page_template_from_request
def cms_settings(request):
"""
Adds cms-related variables to the context.
"""
from menus.menu_pool import MenuRenderer
@cache
def _get_menu_renderer():
# We use lru_cache to avoid getting the manager
# every time this function is called.
from menus.menu_pool import menu_pool
return menu_pool.get_renderer(request)
# Now use lazy() to avoid getting the menu renderer
# up until the point is needed.
# lazy() does not memoize results, is why lru_cache is needed.
_get_menu_renderer = lazy(_get_menu_renderer, MenuRenderer)
return {
'cms_menu_renderer': _get_menu_renderer(),
'CMS_MEDIA_URL': get_cms_setting('MEDIA_URL'),
'CMS_TEMPLATE': lambda: get_page_template_from_request(request),
}

85
cms/exceptions.py Normal file
View file

@ -0,0 +1,85 @@
class PluginAlreadyRegistered(Exception):
pass
class PluginNotRegistered(Exception):
pass
class PluginLimitReached(Exception):
"""Gets triggered when a placeholder has reached its plugin limit."""
pass
class AppAlreadyRegistered(Exception):
pass
class ToolbarAlreadyRegistered(Exception):
pass
class ToolbarNotRegistered(Exception):
pass
class NotImplemented(Exception):
pass
class SubClassNeededError(Exception):
pass
class MissingFormError(Exception):
pass
class NoHomeFound(Exception):
pass
class PermissionsException(Exception):
"""Base permission exception"""
class NoPermissionsException(PermissionsException):
"""Can be fired when some violate action is performed on permission system."""
class PublicIsUnmodifiable(Exception):
"""A method was invoked on the public copy, but is only valid for the
draft version"""
pass
class PublicVersionNeeded(Exception):
"""A Public version of this page is needed"""
pass
class Deprecated(Exception):
pass
class DuplicatePlaceholderWarning(Warning):
pass
class DontUsePageAttributeWarning(Warning):
pass
class LanguageError(Exception):
pass
class PluginConsistencyError(Exception):
pass
class PlaceholderNotFound(Exception):
pass

View file

@ -0,0 +1,19 @@
from .admin import PageContentExtensionAdmin, PageExtensionAdmin
from .extension_pool import extension_pool
from .models import PageContentExtension, PageExtension
# Legacy django-cms compatibility
TitleExtension = PageContentExtension
TitleExtensionAdmin = PageContentExtensionAdmin
__all__ = [
'extension_pool',
'PageContentExtension',
'PageContentExtensionAdmin',
'PageExtension',
'PageExtensionAdmin',
'TitleExtension',
'TitleExtensionAdmin',
]

109
cms/extensions/admin.py Normal file
View file

@ -0,0 +1,109 @@
from django.contrib import admin
from django.contrib.admin.options import csrf_protect_m
from django.core.exceptions import PermissionDenied
from django.http import HttpResponseRedirect
from django.urls import reverse
from cms.models import Page, PageContent
from cms.utils.page_permissions import user_can_change_page
from cms.utils.urlutils import static_with_version
class ExtensionAdmin(admin.ModelAdmin):
change_form_template = "admin/cms/extensions/change_form.html"
add_form_template = "admin/cms/extensions/change_form.html"
class Media:
css = {"all": (static_with_version("cms/css/cms.admin.css"),)}
class PageExtensionAdmin(ExtensionAdmin):
def save_model(self, request, obj, form, change):
if not change and 'extended_object' in request.GET:
obj.extended_object = Page.objects.get(pk=request.GET['extended_object'])
page = Page.objects.get(pk=request.GET['extended_object'])
else:
page = obj.extended_object
if not user_can_change_page(request.user, page):
raise PermissionDenied()
super().save_model(request, obj, form, change)
def delete_model(self, request, obj):
if not obj.extended_object.has_change_permission(request.user):
raise PermissionDenied()
obj.delete()
def get_model_perms(self, request):
"""
Return empty perms dict thus hiding the model from admin index.
"""
return {}
@csrf_protect_m
def add_view(self, request, form_url='', extra_context=None):
"""
Check if the page already has an extension object. If so, redirect to edit view instead.
"""
extended_object_id = request.GET.get('extended_object', False)
if extended_object_id:
try:
page = Page.objects.get(pk=extended_object_id)
extension = self.model.objects.get(extended_object=page)
opts = self.model._meta
change_url = reverse(
f'admin:{opts.app_label}_{opts.model_name}_change',
args=(extension.pk,),
current_app=self.admin_site.name
)
return HttpResponseRedirect(change_url)
except self.model.DoesNotExist:
pass
return super().add_view(request, form_url, extra_context)
class PageContentExtensionAdmin(ExtensionAdmin):
def save_model(self, request, obj, form, change):
if not change and 'extended_object' in request.GET:
obj.extended_object = PageContent.admin_manager.get(pk=request.GET['extended_object'])
content = PageContent.admin_manager.get(pk=request.GET['extended_object'])
else:
content = obj.extended_object
if not user_can_change_page(request.user, page=content.page):
raise PermissionDenied()
super().save_model(request, obj, form, change)
def delete_model(self, request, obj):
page = obj.extended_object.page
if not user_can_change_page(request.user, page):
raise PermissionDenied()
obj.delete()
def get_model_perms(self, request):
"""
Return empty perms dict thus hiding the model from admin index.
"""
return {}
@csrf_protect_m
def add_view(self, request, form_url='', extra_context=None):
"""
Check if the page already has an extension object. If so, redirect to edit view instead.
"""
extended_object_id = request.GET.get('extended_object', False)
if extended_object_id:
try:
content = PageContent.admin_manager.get(pk=extended_object_id)
extension = self.model.objects.get(extended_object=content)
opts = self.model._meta
change_url = reverse(
f'admin:{opts.app_label}_{opts.model_name}_change',
args=(extension.pk,),
current_app=self.admin_site.name
)
return HttpResponseRedirect(change_url)
except self.model.DoesNotExist:
pass
return super().add_view(request, form_url, extra_context)

View file

@ -0,0 +1,94 @@
from cms.exceptions import SubClassNeededError
from .models import PageContentExtension, PageExtension
class ExtensionPool:
def __init__(self):
self.page_extensions = set()
self.page_content_extensions = set()
def register(self, extension):
"""
Registers the given extension.
Example::
class MyExtension(PageExtension):
pass
extension_pool.register(MyExtension)
or as decorator::
@extension_pool.register
class MyExtension(PageExtension):
pass
"""
if issubclass(extension, PageExtension):
self.page_extensions.add(extension)
elif issubclass(extension, PageContentExtension):
self.page_content_extensions.add(extension)
else:
raise SubClassNeededError(
f'Extension has to subclass either {PageExtension} or {PageContentExtension}. {extension} does not!'
)
return extension
def unregister(self, extension):
"""
Unregisters the given extension. No error is thrown if given extension isn't an extension or wasn't
registered yet.
"""
try:
if issubclass(extension, PageExtension):
self.page_extensions.remove(extension)
elif issubclass(extension, PageContentExtension):
self.page_content_extensions.remove(extension)
except KeyError:
pass
def _copy_page_extensions(self, source_page, target_page, language):
for extension in self.page_extensions:
for instance in extension.objects.filter(extended_object=source_page):
instance.copy(target_page, language)
def _copy_content_extensions(self, source_page, target_page, language):
source_content = source_page.pagecontent_set(manager="admin_manager").current_content(language=language).get()
target_content = target_page.pagecontent_set(manager="admin_manager").current_content(language=language).get()
for extension in self.page_content_extensions:
for instance in extension.objects.filter(extended_object=source_content):
instance.copy(target_content, language)
def copy_extensions(self, source_page, target_page, languages=None):
if not languages:
languages = target_page.get_languages()
if self.page_extensions:
self._copy_page_extensions(source_page, target_page, None)
for language in languages:
if self.page_content_extensions:
self._copy_content_extensions(source_page, target_page, language)
def get_page_extensions(self, page=None):
extensions = []
for extension in self.page_extensions:
if page:
extensions.extend(list(extension.objects.filter(extended_object=page)))
else:
extensions.extend(list(extension.objects.all()))
return extensions
def get_page_content_extensions(self, page_content=None):
extensions = []
for extension in self.page_content_extensions:
if page_content:
extensions.extend(list(extension.objects.filter(extended_object=page_content)))
else:
extensions.extend(list(extension.objects.all()))
return extensions
extension_pool = ExtensionPool()

69
cms/extensions/models.py Normal file
View file

@ -0,0 +1,69 @@
from django.db import models
from django.db.models import ManyToManyField
from cms.models import Page, PageContent
class BaseExtension(models.Model):
extended_object = None
class Meta:
abstract = True
def get_page(self): # pragma: no cover
raise NotImplementedError('Function must be overwritten in subclasses and return the extended page object.')
def copy_relations(self, oldinstance, language):
"""
Copy relations like many to many or foreign key relations to the public version.
Similar to the same named cms plugin function.
:param oldinstance: the draft version of the extension
"""
pass
@classmethod
def _get_related_objects(cls):
fields = cls._meta._get_fields(
forward=False, reverse=True,
include_parents=True,
include_hidden=False,
)
return list(obj for obj in fields if not isinstance(obj.field, ManyToManyField))
def copy(self, target, language):
"""
This method copies this extension to an unrelated-target.
"""
clone = self.__class__.objects.get(pk=self.pk) # get a copy of this instance
clone.pk = None
clone.extended_object = target # set the new public object
# Nullify all concrete parent primary keys. See issue #5494
for parent, field in clone._meta.parents.items():
if field:
setattr(clone, parent._meta.pk.attname, None)
clone.save()
clone.copy_relations(self, language)
return clone
class PageExtension(BaseExtension):
extended_object = models.OneToOneField(Page, on_delete=models.CASCADE, editable=False)
class Meta:
abstract = True
def get_page(self):
return self.extended_object
class PageContentExtension(BaseExtension):
extended_object = models.OneToOneField(PageContent, on_delete=models.CASCADE, editable=False)
class Meta:
abstract = True
def get_page(self):
return self.extended_object.page

101
cms/extensions/toolbar.py Normal file
View file

@ -0,0 +1,101 @@
from django.urls import NoReverseMatch
from cms.models import PageContent
from cms.toolbar_base import CMSToolbar
from cms.utils.page_permissions import user_can_change_page
from cms.utils.urlutils import admin_reverse
class ExtensionToolbar(CMSToolbar):
"""Offers simplified API for providing the user access to the admin of page extensions and
page content extensions through the toolbar."""
model = None
page = None
page_content = None
def _setup_extension_toolbar(self):
"""
Does all the sanity check for the current environment:
* that a page exists
* permissions check on the current page
It returns the page menu or None if the above conditions are not met
"""
page = self._get_page()
if page and user_can_change_page(self.request.user, page=page):
return self.toolbar.get_or_create_menu("page")
return
def _get_page(self):
if not self.page:
obj = self.toolbar.get_object() # Try getting the PageContent object from the toolbar
if isinstance(obj, PageContent):
self.page = obj.page
self.page_content = obj
else:
self.page = self.request.current_page # Otherwise get Page object from the request
if self.page:
self.page_content = self.page.get_content_obj(self.current_lang)
return self.page
def get_page_extension_admin(self):
"""
Get the admin url for the page extension menu item, depending on whether a PageExtension instance exists
for the current page or not.
Return a tuple of the current extension and the url; the extension is None if no instance exists,
the url is None is no admin is registered for the extension.
"""
page = self._get_page()
# Page extension
try:
page_extension = self.model.objects.get(extended_object_id=page.pk)
except self.model.DoesNotExist:
page_extension = None
try:
app_label, model_name = self.model._meta.app_label, self.model.__name__.lower()
if page_extension:
admin_url = admin_reverse(f"{app_label}_{model_name}_change", args=(page_extension.pk,))
else:
admin_url = "{}?extended_object={}".format(
admin_reverse(f"{app_label}_{model_name}_add"), self.page.pk
)
except NoReverseMatch: # pragma: no cover
admin_url = None
return page_extension, admin_url
def get_page_content_extension_admin(self, page_content_obj=None):
"""
Get the admin url for the page content extensions menu item, depending on whether a
:class:`~cms.extensions.models.PageContentExtension` instance exists for the
:class:`~cms.models.contentmodels.PageContent` displayed.
Return a tuple of the page content extension and the url; the extension is None
if no instance exists, the url is None is no admin is registered for the extension.
"""
self._get_page()
page_content = page_content_obj or self.page_content
try:
pagecontent_extension = self.model.objects.get(extended_object_id=page_content.pk)
except self.model.DoesNotExist:
pagecontent_extension = None
try:
app_label, model_name = self.model._meta.app_label, self.model.__name__.lower()
if pagecontent_extension:
admin_url = admin_reverse(f"{app_label}_{model_name}_change", args=(pagecontent_extension.pk,))
else:
admin_url = "{}?extended_object={}".format(
admin_reverse(f"{app_label}_{model_name}_add"), page_content.pk
)
except NoReverseMatch: # pragma: no cover
admin_url = None
return pagecontent_extension, admin_url
def _get_sub_menu(self, current_menu, key, label, position=None):
"""
Utility function to get a submenu of the current menu
"""
extension_menu = current_menu.get_or_create_menu(key, label, position=position)
return extension_menu

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

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,22 @@
# SOME DESCRIPTIVE TITLE.
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
# This file is distributed under the same license as the PACKAGE package.
#
# Translators:
# Translators:
msgid ""
msgstr ""
"Project-Id-Version: django CMS\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2023-12-11 19:18+0000\n"
"PO-Revision-Date: 2010-11-30 06:49+0000\n"
"Last-Translator: yakky <i.spalletti@nephila.it>\n"
"Language-Team: Afrikaans (http://app.transifex.com/divio/django-cms/language/af/)\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Language: af\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
msgid "Are you sure you want to change tabs without saving the page first?"
msgstr ""

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,27 @@
# SOME DESCRIPTIVE TITLE.
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
# This file is distributed under the same license as the PACKAGE package.
#
# Translators:
# Translators:
# Abdelkader Maarouf <dep2g2@gmail.com>, 2015
# Ahmed H <info@draco-003.com>, 2013
# Bashar Al-Abdulhadi, 2017
# Bashar Al-Abdulhadi, 2017
# Bashar Ghadanfar <10.tens@gmail.com>, 2017
msgid ""
msgstr ""
"Project-Id-Version: django CMS\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2023-12-11 19:18+0000\n"
"PO-Revision-Date: 2010-11-30 06:49+0000\n"
"Last-Translator: Bashar Ghadanfar <10.tens@gmail.com>, 2017\n"
"Language-Team: Arabic (http://app.transifex.com/divio/django-cms/language/ar/)\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Language: ar\n"
"Plural-Forms: nplurals=6; plural=n==0 ? 0 : n==1 ? 1 : n==2 ? 2 : n%100>=3 && n%100<=10 ? 3 : n%100>=11 && n%100<=99 ? 4 : 5;\n"
msgid "Are you sure you want to change tabs without saving the page first?"
msgstr "هل أنت متأكد من الإنتقال الى تبويب أخر دون حفظ الصفحة أولاً؟"

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,22 @@
# SOME DESCRIPTIVE TITLE.
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
# This file is distributed under the same license as the PACKAGE package.
#
# Translators:
# Translators:
msgid ""
msgstr ""
"Project-Id-Version: django CMS\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2023-12-11 19:18+0000\n"
"PO-Revision-Date: 2010-11-30 06:49+0000\n"
"Last-Translator: yakky <i.spalletti@nephila.it>\n"
"Language-Team: Arabic (Saudi Arabia) (http://app.transifex.com/divio/django-cms/language/ar_SA/)\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Language: ar_SA\n"
"Plural-Forms: nplurals=6; plural=(n==0 ? 0 : n==1 ? 1 : n==2 ? 2 : n%100>=3 && n%100<=10 ? 3 : n%100>=11 && n%100<=99 ? 4 : 5);\n"
msgid "Are you sure you want to change tabs without saving the page first?"
msgstr ""

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,22 @@
# SOME DESCRIPTIVE TITLE.
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
# This file is distributed under the same license as the PACKAGE package.
#
# Translators:
# Translators:
msgid ""
msgstr ""
"Project-Id-Version: django CMS\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2023-12-11 19:18+0000\n"
"PO-Revision-Date: 2010-11-30 06:49+0000\n"
"Last-Translator: yakky <i.spalletti@nephila.it>\n"
"Language-Team: Belarusian (http://app.transifex.com/divio/django-cms/language/be/)\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Language: be\n"
"Plural-Forms: nplurals=4; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<12 || n%100>14) ? 1 : n%10==0 || (n%10>=5 && n%10<=9) || (n%100>=11 && n%100<=14)? 2 : 3);\n"
msgid "Are you sure you want to change tabs without saving the page first?"
msgstr ""

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,27 @@
# SOME DESCRIPTIVE TITLE.
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
# This file is distributed under the same license as the PACKAGE package.
#
# Translators:
# Translators:
# Boris Chervenkov <office@sentido.bg>, 2012
# Boyko Amarov <boyko.amarov@gmail.com>, 2013
# Ivaylo Nakov, 2025
# Ivaylo Nakov, 2025
# suleyman <posta@suleymans.com>, 2011
msgid ""
msgstr ""
"Project-Id-Version: django CMS\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2023-12-11 19:18+0000\n"
"PO-Revision-Date: 2010-11-30 06:49+0000\n"
"Last-Translator: suleyman <posta@suleymans.com>, 2011\n"
"Language-Team: Bulgarian (http://app.transifex.com/divio/django-cms/language/bg/)\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Language: bg\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
msgid "Are you sure you want to change tabs without saving the page first?"
msgstr "Наистина ли искате да промените раздели без първо да запазите страницата?"

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,23 @@
# SOME DESCRIPTIVE TITLE.
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
# This file is distributed under the same license as the PACKAGE package.
#
# Translators:
# Translators:
# Aniruddha Adhikary <aniruddha@adhikary.net>, 2013
msgid ""
msgstr ""
"Project-Id-Version: django CMS\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2023-12-11 19:18+0000\n"
"PO-Revision-Date: 2010-11-30 06:49+0000\n"
"Last-Translator: Aniruddha Adhikary <aniruddha@adhikary.net>, 2013\n"
"Language-Team: Bengali (http://app.transifex.com/divio/django-cms/language/bn/)\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Language: bn\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
msgid "Are you sure you want to change tabs without saving the page first?"
msgstr "আপনি কি নিশ্চিত যে, আপনি পৃষ্ঠাটি সংরক্ষণ না করেই ট্যাব পরিবর্তন করতে চান?"

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,23 @@
# SOME DESCRIPTIVE TITLE.
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
# This file is distributed under the same license as the PACKAGE package.
#
# Translators:
# Translators:
# Roger Pons <rogerpons@gmail.com>, 2012,2015
msgid ""
msgstr ""
"Project-Id-Version: django CMS\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2023-12-11 19:18+0000\n"
"PO-Revision-Date: 2010-11-30 06:49+0000\n"
"Last-Translator: Roger Pons <rogerpons@gmail.com>, 2012,2015\n"
"Language-Team: Catalan (http://app.transifex.com/divio/django-cms/language/ca/)\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Language: ca\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
msgid "Are you sure you want to change tabs without saving the page first?"
msgstr "Esteu segur que voleu canviar de secció sense guardar la primera pàgina?"

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,22 @@
# SOME DESCRIPTIVE TITLE.
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
# This file is distributed under the same license as the PACKAGE package.
#
# Translators:
# Translators:
msgid ""
msgstr ""
"Project-Id-Version: django CMS\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2020-11-05 12:20+0000\n"
"PO-Revision-Date: 2018-01-11 16:38+0000\n"
"Last-Translator: Paulo Alvarado <paulo.alvarado@divio.ch>\n"
"Language-Team: Chinese (Mandarin) (http://www.transifex.com/divio/django-cms/language/cmn/)\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Language: cmn\n"
"Plural-Forms: nplurals=1; plural=0;\n"
msgid "Are you sure you want to change tabs without saving the page first?"
msgstr ""

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,26 @@
# SOME DESCRIPTIVE TITLE.
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
# This file is distributed under the same license as the PACKAGE package.
#
# Translators:
# Translators:
# Jakub Dorňák <jakub.dornak@misli.cz>, 2013
# Jakub Dorňák <jakub.dornak@misli.cz>, 2013
# xlu <xlu@seznam.cz>, 2013
# xlu <xlu@seznam.cz>, 2013
msgid ""
msgstr ""
"Project-Id-Version: django CMS\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2023-12-11 19:18+0000\n"
"PO-Revision-Date: 2010-11-30 06:49+0000\n"
"Last-Translator: xlu <xlu@seznam.cz>, 2013\n"
"Language-Team: Czech (http://app.transifex.com/divio/django-cms/language/cs/)\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Language: cs\n"
"Plural-Forms: nplurals=4; plural=(n == 1 && n % 1 == 0) ? 0 : (n >= 2 && n <= 4 && n % 1 == 0) ? 1: (n % 1 != 0 ) ? 2 : 3;\n"
msgid "Are you sure you want to change tabs without saving the page first?"
msgstr "Určitě chcete změnit záložku bez uložení změn?"

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,22 @@
# SOME DESCRIPTIVE TITLE.
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
# This file is distributed under the same license as the PACKAGE package.
#
# Translators:
# Translators:
msgid ""
msgstr ""
"Project-Id-Version: django CMS\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2023-12-11 19:18+0000\n"
"PO-Revision-Date: 2010-11-30 06:49+0000\n"
"Last-Translator: yakky <i.spalletti@nephila.it>\n"
"Language-Team: Czech (Czech Republic) (http://app.transifex.com/divio/django-cms/language/cs_CZ/)\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Language: cs_CZ\n"
"Plural-Forms: nplurals=4; plural=(n == 1 && n % 1 == 0) ? 0 : (n >= 2 && n <= 4 && n % 1 == 0) ? 1: (n % 1 != 0 ) ? 2 : 3;\n"
msgid "Are you sure you want to change tabs without saving the page first?"
msgstr ""

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,22 @@
# SOME DESCRIPTIVE TITLE.
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
# This file is distributed under the same license as the PACKAGE package.
#
# Translators:
# Translators:
msgid ""
msgstr ""
"Project-Id-Version: django CMS\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2023-12-11 19:18+0000\n"
"PO-Revision-Date: 2010-11-30 06:49+0000\n"
"Last-Translator: yakky <i.spalletti@nephila.it>\n"
"Language-Team: Welsh (http://app.transifex.com/divio/django-cms/language/cy/)\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Language: cy\n"
"Plural-Forms: nplurals=4; plural=(n==1) ? 0 : (n==2) ? 1 : (n != 8 && n != 11) ? 2 : 3;\n"
msgid "Are you sure you want to change tabs without saving the page first?"
msgstr ""

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,25 @@
# SOME DESCRIPTIVE TITLE.
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
# This file is distributed under the same license as the PACKAGE package.
#
# Translators:
# Translators:
# Kristian Øllegaard <kristian@oellegaard.com>, 2011
# Kristian Øllegaard <kristian@oellegaard.com>, 2011
# Kristian Øllegaard <kristian@oellegaard.com>, 2011
msgid ""
msgstr ""
"Project-Id-Version: django CMS\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2023-12-11 19:18+0000\n"
"PO-Revision-Date: 2010-11-30 06:49+0000\n"
"Last-Translator: Kristian Øllegaard <kristian@oellegaard.com>, 2011\n"
"Language-Team: Danish (http://app.transifex.com/divio/django-cms/language/da/)\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Language: da\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
msgid "Are you sure you want to change tabs without saving the page first?"
msgstr "Er du sikker på du vil ændre tabs uden at gemme siden først?"

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,26 @@
# SOME DESCRIPTIVE TITLE.
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
# This file is distributed under the same license as the PACKAGE package.
#
# Translators:
# Translators:
# Jonas Obrist <me@ojii.ch>, 2011
# Michael P. Jung <michael.jung@terreon.de>, 2013
# Peter Wischer <peter.wischer@lwl.org>, 2022
# Stefan T. Oertel <me@schneck.cc>, 2015
msgid ""
msgstr ""
"Project-Id-Version: django CMS\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2023-12-11 19:18+0000\n"
"PO-Revision-Date: 2010-11-30 06:49+0000\n"
"Last-Translator: Stefan T. Oertel <me@schneck.cc>, 2015\n"
"Language-Team: German (http://app.transifex.com/divio/django-cms/language/de/)\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Language: de\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
msgid "Are you sure you want to change tabs without saving the page first?"
msgstr "Sind Sie sicher Sie wollen den Reiter wechseln ohne vorher zu speichern?"

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,23 @@
# SOME DESCRIPTIVE TITLE.
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
# This file is distributed under the same license as the PACKAGE package.
#
# Translators:
# Translators:
# George Petsagourakis <petsagouris@gmail.com>, 2013
msgid ""
msgstr ""
"Project-Id-Version: django CMS\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2023-12-11 19:18+0000\n"
"PO-Revision-Date: 2010-11-30 06:49+0000\n"
"Last-Translator: George Petsagourakis <petsagouris@gmail.com>, 2013\n"
"Language-Team: Greek (http://app.transifex.com/divio/django-cms/language/el/)\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Language: el\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
msgid "Are you sure you want to change tabs without saving the page first?"
msgstr "Είσαι σίγουρος πως θες να αλλάξεις καρτέλες χωρίς να έχεις αποθηκεύσει πρώτα τη σελίδα?"

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,23 @@
# SOME DESCRIPTIVE TITLE.
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
# This file is distributed under the same license as the PACKAGE package.
#
# Translators:
# Jonas Obrist <ojiidotch@gmail.com>, 2011
msgid ""
msgstr ""
"Project-Id-Version: django-cms\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2023-12-11 19:18+0000\n"
"PO-Revision-Date: 2015-10-31 12:28+0000\n"
"Last-Translator: yakky <i.spalletti@nephila.it>\n"
"Language-Team: English (http://www.transifex.com/divio/django-cms/language/"
"en/)\n"
"Language: en\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
msgid "Are you sure you want to change tabs without saving the page first?"
msgstr "Are you sure you want to change tabs without saving the page first?"

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,22 @@
# SOME DESCRIPTIVE TITLE.
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
# This file is distributed under the same license as the PACKAGE package.
#
# Translators:
# Translators:
msgid ""
msgstr ""
"Project-Id-Version: django CMS\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2023-12-11 19:18+0000\n"
"PO-Revision-Date: 2010-11-30 06:49+0000\n"
"Last-Translator: yakky <i.spalletti@nephila.it>\n"
"Language-Team: English (United Kingdom) (http://app.transifex.com/divio/django-cms/language/en_GB/)\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Language: en_GB\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
msgid "Are you sure you want to change tabs without saving the page first?"
msgstr ""

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,22 @@
# SOME DESCRIPTIVE TITLE.
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
# This file is distributed under the same license as the PACKAGE package.
#
# Translators:
# Translators:
msgid ""
msgstr ""
"Project-Id-Version: django CMS\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2023-12-11 19:18+0000\n"
"PO-Revision-Date: 2010-11-30 06:49+0000\n"
"Last-Translator: yakky <i.spalletti@nephila.it>\n"
"Language-Team: English (Hong Kong) (http://app.transifex.com/divio/django-cms/language/en_HK/)\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Language: en_HK\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
msgid "Are you sure you want to change tabs without saving the page first?"
msgstr ""

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,22 @@
# SOME DESCRIPTIVE TITLE.
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
# This file is distributed under the same license as the PACKAGE package.
#
# Translators:
# Translators:
msgid ""
msgstr ""
"Project-Id-Version: django CMS\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2023-12-11 19:18+0000\n"
"PO-Revision-Date: 2010-11-30 06:49+0000\n"
"Last-Translator: yakky <i.spalletti@nephila.it>\n"
"Language-Team: English (United States) (http://app.transifex.com/divio/django-cms/language/en_US/)\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Language: en_US\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
msgid "Are you sure you want to change tabs without saving the page first?"
msgstr ""

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,22 @@
# SOME DESCRIPTIVE TITLE.
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
# This file is distributed under the same license as the PACKAGE package.
#
# Translators:
# Translators:
msgid ""
msgstr ""
"Project-Id-Version: django CMS\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2023-12-11 19:18+0000\n"
"PO-Revision-Date: 2010-11-30 06:49+0000\n"
"Last-Translator: yakky <i.spalletti@nephila.it>\n"
"Language-Team: Esperanto (http://app.transifex.com/divio/django-cms/language/eo/)\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Language: eo\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
msgid "Are you sure you want to change tabs without saving the page first?"
msgstr ""

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,25 @@
# SOME DESCRIPTIVE TITLE.
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
# This file is distributed under the same license as the PACKAGE package.
#
# Translators:
# Translators:
# e4db27214f7e7544f2022c647b585925_bb0e321, 2015
# Edwin Caldon <edycop@gmail.com>, 2011
# e4db27214f7e7544f2022c647b585925_bb0e321, 2015
msgid ""
msgstr ""
"Project-Id-Version: django CMS\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2023-12-11 19:18+0000\n"
"PO-Revision-Date: 2010-11-30 06:49+0000\n"
"Last-Translator: e4db27214f7e7544f2022c647b585925_bb0e321, 2015\n"
"Language-Team: Spanish (http://app.transifex.com/divio/django-cms/language/es/)\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Language: es\n"
"Plural-Forms: nplurals=3; plural=n == 1 ? 0 : n != 0 && n % 1000000 == 0 ? 1 : 2;\n"
msgid "Are you sure you want to change tabs without saving the page first?"
msgstr "¿Estás seguro de que desea cambiar de pestañas sin guardar antes la página?"

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,23 @@
# SOME DESCRIPTIVE TITLE.
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
# This file is distributed under the same license as the PACKAGE package.
#
# Translators:
# Translators:
# Edwin Caldon <edycop@gmail.com>, 2011
msgid ""
msgstr ""
"Project-Id-Version: django CMS\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2023-12-11 19:18+0000\n"
"PO-Revision-Date: 2010-11-30 06:49+0000\n"
"Last-Translator: Edwin Caldon <edycop@gmail.com>, 2011\n"
"Language-Team: Spanish (Argentina) (http://app.transifex.com/divio/django-cms/language/es_AR/)\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Language: es_AR\n"
"Plural-Forms: nplurals=3; plural=n == 1 ? 0 : n != 0 && n % 1000000 == 0 ? 1 : 2;\n"
msgid "Are you sure you want to change tabs without saving the page first?"
msgstr "¿Estás seguro de que desea cambiar de pestañas sin guardar antes la página?"

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,25 @@
# SOME DESCRIPTIVE TITLE.
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
# This file is distributed under the same license as the PACKAGE package.
#
# Translators:
# Translators:
# Edwin Caldon <edycop@gmail.com>, 2011
# mariocesar <mariocesar.c50@gmail.com>, 2011
# mariocesar <mariocesar.c50@gmail.com>, 2011
msgid ""
msgstr ""
"Project-Id-Version: django CMS\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2023-12-11 19:18+0000\n"
"PO-Revision-Date: 2010-11-30 06:49+0000\n"
"Last-Translator: mariocesar <mariocesar.c50@gmail.com>, 2011\n"
"Language-Team: Spanish (Bolivia) (http://app.transifex.com/divio/django-cms/language/es_BO/)\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Language: es_BO\n"
"Plural-Forms: nplurals=3; plural=n == 1 ? 0 : n != 0 && n % 1000000 == 0 ? 1 : 2;\n"
msgid "Are you sure you want to change tabs without saving the page first?"
msgstr "¿Estás seguro de que desea cambiar de pestañas sin guardar antes la página?"

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,22 @@
# SOME DESCRIPTIVE TITLE.
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
# This file is distributed under the same license as the PACKAGE package.
#
# Translators:
# Translators:
msgid ""
msgstr ""
"Project-Id-Version: django CMS\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2023-12-11 19:18+0000\n"
"PO-Revision-Date: 2010-11-30 06:49+0000\n"
"Last-Translator: yakky <i.spalletti@nephila.it>\n"
"Language-Team: Spanish (Dominican Republic) (http://app.transifex.com/divio/django-cms/language/es_DO/)\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Language: es_DO\n"
"Plural-Forms: nplurals=3; plural=n == 1 ? 0 : n != 0 && n % 1000000 == 0 ? 1 : 2;\n"
msgid "Are you sure you want to change tabs without saving the page first?"
msgstr ""

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,24 @@
# SOME DESCRIPTIVE TITLE.
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
# This file is distributed under the same license as the PACKAGE package.
#
# Translators:
# Translators:
# Martin <martinpajuste@gmail.com>, 2012
# Martin <martinpajuste@gmail.com>, 2016
msgid ""
msgstr ""
"Project-Id-Version: django CMS\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2023-12-11 19:18+0000\n"
"PO-Revision-Date: 2010-11-30 06:49+0000\n"
"Last-Translator: Martin <martinpajuste@gmail.com>, 2016\n"
"Language-Team: Estonian (http://app.transifex.com/divio/django-cms/language/et/)\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Language: et\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
msgid "Are you sure you want to change tabs without saving the page first?"
msgstr "Kas olete kindel, et soovite sakki vahetada ilma lehte salvestamata?"

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,23 @@
# SOME DESCRIPTIVE TITLE.
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
# This file is distributed under the same license as the PACKAGE package.
#
# Translators:
# Translators:
# Ales Zabala Alava <shagi@gisa-elkartea.org>, 2012
msgid ""
msgstr ""
"Project-Id-Version: django CMS\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2023-12-11 19:18+0000\n"
"PO-Revision-Date: 2010-11-30 06:49+0000\n"
"Last-Translator: Ales Zabala Alava <shagi@gisa-elkartea.org>, 2012\n"
"Language-Team: Basque (http://app.transifex.com/divio/django-cms/language/eu/)\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Language: eu\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
msgid "Are you sure you want to change tabs without saving the page first?"
msgstr "Ziur orria gorde gabe fitxaz aldatu nahi duzula?"

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,25 @@
# SOME DESCRIPTIVE TITLE.
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
# This file is distributed under the same license as the PACKAGE package.
#
# Translators:
# Translators:
# cafe-click <cafeclick@rocketmail.com>, 2013
# Pouya Abbassi, 2016
# Pouya Abbassi, 2016
msgid ""
msgstr ""
"Project-Id-Version: django CMS\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2023-12-11 19:18+0000\n"
"PO-Revision-Date: 2010-11-30 06:49+0000\n"
"Last-Translator: Pouya Abbassi, 2016\n"
"Language-Team: Persian (http://app.transifex.com/divio/django-cms/language/fa/)\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Language: fa\n"
"Plural-Forms: nplurals=2; plural=(n > 1);\n"
msgid "Are you sure you want to change tabs without saving the page first?"
msgstr "آیا مطمئن هستید میخواهید تب‌ها را تغییر دهید بدون اینکه صفحه را از قبل ذخیره کرده باشید؟"

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,23 @@
# SOME DESCRIPTIVE TITLE.
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
# This file is distributed under the same license as the PACKAGE package.
#
# Translators:
# Translators:
# Javad Miraftabzadeh <javad.miraftabzadeh@gmail.com>, 2016
msgid ""
msgstr ""
"Project-Id-Version: django CMS\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2023-12-11 19:18+0000\n"
"PO-Revision-Date: 2010-11-30 06:49+0000\n"
"Last-Translator: Javad Miraftabzadeh <javad.miraftabzadeh@gmail.com>, 2016\n"
"Language-Team: Persian (Iran) (http://app.transifex.com/divio/django-cms/language/fa_IR/)\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Language: fa_IR\n"
"Plural-Forms: nplurals=2; plural=(n > 1);\n"
msgid "Are you sure you want to change tabs without saving the page first?"
msgstr "آیا مطمئنید که می‌خواهید زبانه‌ها را پیش از ذخیره صفحه تغییر دهید؟"

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,23 @@
# SOME DESCRIPTIVE TITLE.
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
# This file is distributed under the same license as the PACKAGE package.
#
# Translators:
# Translators:
# Niklas Jerva <niklas.jerva@gmail.com>, 2017
msgid ""
msgstr ""
"Project-Id-Version: django CMS\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2023-12-11 19:18+0000\n"
"PO-Revision-Date: 2010-11-30 06:49+0000\n"
"Last-Translator: Niklas Jerva <niklas.jerva@gmail.com>, 2017\n"
"Language-Team: Finnish (http://app.transifex.com/divio/django-cms/language/fi/)\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Language: fi\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
msgid "Are you sure you want to change tabs without saving the page first?"
msgstr "Haluatko varmasti muuttaa välilehtiä tallentamatta sivua ensin?"

Some files were not shown because too many files have changed in this diff Show more