dynamicweb/cms/admin/forms.py

1714 lines
63 KiB
Python
Raw Normal View History

from django import forms
from django.apps import apps
from django.contrib.auth import get_permission_codename, get_user_model
from django.contrib.auth.models import Permission
from django.contrib.contenttypes.models import ContentType
from django.contrib.sites.models import Site
from django.core.exceptions import ObjectDoesNotExist, ValidationError
from django.forms.utils import ErrorList
from django.forms.widgets import HiddenInput
from django.utils import timezone
from django.utils.encoding import force_str
from django.utils.functional import cached_property
from django.utils.translation import get_language, gettext, gettext_lazy as _
from cms import api
from cms.apphook_pool import apphook_pool
from cms.cache.permissions import clear_permission_cache
from cms.constants import PAGE_TYPES_ID, ROOT_USER_LEVEL
from cms.exceptions import PluginLimitReached
from cms.extensions import extension_pool
from cms.forms.fields import PageSmartLinkField
from cms.forms.validators import validate_path, validate_relative_url, validate_url_uniqueness
from cms.forms.widgets import (
AppHookSelect,
ApplicationConfigSelect,
UserSelectAdminWidget,
)
from cms.models import (
CMSPlugin,
GlobalPagePermission,
Page,
PageContent,
PagePermission,
PageType,
PageUser,
PageUserGroup,
Placeholder,
)
from cms.models.permissionmodels import (
EDIT_PERMISSION_REQUIRED_MESSAGES,
User,
)
from cms.operations import ADD_PAGE_TRANSLATION, CHANGE_PAGE_TRANSLATION
from cms.operations.helpers import (
send_post_page_operation,
send_pre_page_operation,
)
from cms.plugin_pool import plugin_pool
from cms.signals.apphook import set_restart_trigger
from cms.utils import get_current_site
from cms.utils.compat.forms import UserChangeForm
from cms.utils.conf import get_cms_setting
from cms.utils.i18n import get_language_list, get_site_language_from_request
from cms.utils.page import get_clean_username
from cms.utils.page_permissions import (
user_can_add_page,
user_can_add_subpage,
user_can_change_page,
user_can_view_page,
)
from cms.utils.permissions import (
get_current_user,
get_grantable_global_permissions,
get_model_permission_codename,
get_subordinate_groups,
get_subordinate_users,
get_user_permission_level,
)
from cms.utils.urlutils import static_with_version
from menus.menu_pool import menu_pool
def get_permission_accessor(obj):
User = get_user_model()
if isinstance(
obj,
(
PageUser,
User,
),
):
rel_name = "user_permissions"
else:
rel_name = "permissions"
return getattr(obj, rel_name)
def get_page_changed_by_filter_choices():
# This is not site-aware
# Been like this forever
# Would be nice for it to filter out by site
values = Page.objects.distinct().order_by("changed_by").values_list("changed_by", flat=True)
yield ("", _("All"))
for value in values:
yield (value, value)
def get_page_template_filter_choices():
yield ("", _("All"))
yield from get_cms_setting("TEMPLATES")
def get_main_language_page_content_template(new_page):
# If you have djangocms-versioning installed, this will get the latest version of the page content, and ignore drafts.
# Get the main language page content template if available
if new_page:
try:
main_language = new_page.get_languages()[0]
main_language_page_content = new_page.get_admin_content(language=main_language)
return main_language_page_content.template if main_language_page_content else None
except (IndexError, AttributeError):
# Handle cases where get_languages() returns empty list
# or if there's any attribute access error
pass
return None
# Permission actions managed by the CMS permission forms (Django's defaults).
CMS_PERMISSION_ACTIONS = ("add", "change", "delete")
# Maps each ``can_<action>_<name>`` permission group to the model whose
# permission governs it. Used to render, read and authorize the checkboxes.
CMS_PERMISSION_MODELS = (
(Page, "page"),
(PageUser, "pageuser"),
(PagePermission, "pagepermission"),
)
def save_permissions(data, obj):
# ``PageUserGroup`` shares the ``pageuser`` field with ``PageUser`` but has
# its own content type, so the permission must be (un)assigned on both.
models = (*CMS_PERMISSION_MODELS, (PageUserGroup, "pageuser"))
if not obj.pk:
# save obj, otherwise we can't assign permissions to him
obj.save()
permission_accessor = get_permission_accessor(obj)
for model, name in models:
content_type = ContentType.objects.get_for_model(model)
for key in CMS_PERMISSION_ACTIONS:
# add permission `key` for model `model`
codename = get_permission_codename(key, model._meta)
permission = Permission.objects.get(content_type=content_type, codename=codename)
field = f"can_{key}_{name}"
if data.get(field):
permission_accessor.add(permission)
elif field in data:
permission_accessor.remove(permission)
class CopyPermissionForm(forms.Form):
"""
Holds the specific field for permissions
"""
copy_permissions = forms.BooleanField(
label=_("Copy permissions"),
required=False,
initial=True,
)
class SlugWidget(forms.widgets.TextInput):
"""
Special widget for the slug field that requires Title field to be there.
Adds the js for the slugifying.
"""
def __init__(self, attrs=None):
if attrs is None:
attrs = {}
else:
attrs = attrs.copy()
language = attrs.get("language", get_language())
self.uhd_lang, self.uhd_urls = self.get_unihandecode_settings(language)
attrs["data-decoder"] = self.uhd_lang
super().__init__(attrs)
def get_unihandecode_settings(self, language):
if language[:2] in get_cms_setting("UNIHANDECODE_DECODERS"):
uhd_lang = language[:2]
else:
uhd_lang = get_cms_setting("UNIHANDECODE_DEFAULT_DECODER")
uhd_host = get_cms_setting("UNIHANDECODE_HOST")
uhd_version = get_cms_setting("UNIHANDECODE_VERSION")
if uhd_lang and uhd_host and uhd_version:
uhd_urls = [
f"{uhd_host}unihandecode-{uhd_version}.core.min.js",
f"{uhd_host}unihandecode-{uhd_version}.{uhd_lang}.min.js",
]
else:
uhd_urls = []
return uhd_lang, uhd_urls
@property
def media(self):
js_media = [
"admin/js/urlify.js",
static_with_version("cms/js/dist/bundle.forms.slugwidget.min.js"),
] + self.uhd_urls
return forms.Media(js=js_media)
class BasePageContentForm(forms.ModelForm):
_site = None
_request = None
title = forms.CharField(
label=PageContent._meta.get_field("title").verbose_name.capitalize(),
max_length=255,
widget=forms.TextInput(),
help_text=PageContent._meta.get_field("title").help_text,
)
slug = forms.SlugField(
label=_("Slug"),
max_length=255,
help_text=_("The part of the title that is used in the URL"),
)
menu_title = forms.CharField(
label=PageContent._meta.get_field("menu_title").verbose_name.capitalize(),
widget=forms.TextInput(),
help_text=PageContent._meta.get_field("menu_title").help_text,
required=False,
)
page_title = forms.CharField(
label=PageContent._meta.get_field("page_title").verbose_name.capitalize(),
widget=forms.TextInput(),
required=False,
help_text=PageContent._meta.get_field("page_title").help_text,
)
meta_description = forms.CharField(
label=PageContent._meta.get_field("meta_description").verbose_name.capitalize(),
max_length=320,
required=False,
widget=forms.Textarea(attrs={"maxlength": "320", "rows": "4"}),
help_text=PageContent._meta.get_field("meta_description").help_text,
)
class Meta:
model = PageContent
fields = []
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
attrs = dict(self.fields["slug"].widget.attrs or {}, language=self._language)
self.fields["slug"].widget = SlugWidget(attrs=attrs)
@cached_property
def _language(self):
return get_site_language_from_request(self._request, site_id=self._site.pk)
@property
def _user(self):
return self._request.user
class AddPageForm(BasePageContentForm):
source = forms.ModelChoiceField(
label=_("Page type"),
queryset=Page.objects.filter(is_page_type=True),
required=False,
)
cms_page = forms.ModelChoiceField(
queryset=Page.objects.all(),
required=False,
widget=forms.HiddenInput(),
)
parent_page = forms.ModelChoiceField(
queryset=Page.objects.all(),
required=False,
widget=forms.HiddenInput(),
)
edit = forms.IntegerField(
# Got to edit/preview mode after adding
required=False,
widget=forms.HiddenInput(),
)
content_defaults = {
"in_navigation": get_cms_setting("DEFAULT_IN_NAVIGATION"),
}
source_permission_denied = _("You do not have permission to use this page type.")
class Meta:
model = PageContent
fields = ["source"]
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
page_field = self.fields.get("cms_page")
if page_field:
page_field.queryset = page_field.queryset.filter(site=self._site)
source_field = self.fields.get("source")
if not source_field or source_field.widget.is_hidden:
return
root_page = PageType.get_root_page(site=self._site)
if root_page:
# Set the choicefield's choices to the various page_types
descendants = root_page.get_descendant_pages().filter(is_page_type=True)
titles = PageContent.objects.filter(page__in=descendants, language=self._language)
choices = [("", "---------")]
choices.extend((title.page_id, title.title) for title in titles)
# Narrow the queryset and not just the choices: ``source`` is validated
# against the queryset, which otherwise spans the page types of every
# site, while the choices are merely what the widget renders.
source_field.queryset = descendants
source_field.choices = choices
else:
source_field.queryset = source_field.queryset.none()
choices = []
if len(choices) < 2:
source_field.widget = forms.HiddenInput()
def clean(self):
data = self.cleaned_data
if self._errors:
# Form already has errors, best to let those be
# addressed first.
return data
if data.get("cms_page") and data.get("source"):
# Translations reuse an existing page and skip from_source(), where
# the source's view restrictions are preserved before copying content.
self.add_error("source", _("A source page cannot be used when adding a translation."))
return data
parent_page = data.get("parent_page")
if parent_page:
slug = data["slug"]
parent_path = parent_page.get_path(self._language)
path = f"{parent_path}/{slug}" if parent_path else slug
else:
path = data["slug"]
try:
# Validate the url
validate_url_uniqueness(
self._site,
path=path,
language=self._language,
user_language=self._language,
)
except ValidationError as error:
self.add_error("slug", error)
else:
data["path"] = path
return data
def clean_source(self):
source = self.cleaned_data.get("source")
# ``source`` is a hidden field whose value is fully controlled by the
# client on POST and ``has_add_permission`` only checks that the user may
# create *a* page, not that they may read ``source`` -- whose placeholders,
# plugins and extensions ``save()`` copies into the new page.
if source and not user_can_view_page(self._user, source):
raise ValidationError(self.source_permission_denied)
return source
def clean_parent_page(self):
parent_page = self.cleaned_data.get("parent_page")
if parent_page and parent_page.site_id != self._site.pk:
raise ValidationError("Site doesn't match the parent's page site")
if self.cleaned_data.get("cms_page"):
# A translation is added to an existing page, the parent is not used.
# ``clean_cms_page`` checks the permission on that page instead.
return parent_page
# ``parent_page`` is a hidden field whose value is fully controlled by the
# client on POST, while ``has_add_permission`` only validated the parent
# taken from the query string. Re-check the permission against the parent
# the page is actually created under -- this is to prevent people from
# possible form-hacking.
if parent_page:
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:
raise ValidationError(_("You do not have permission to add a page here."))
return parent_page
def clean_cms_page(self):
page = self.cleaned_data.get("cms_page")
if page and not user_can_change_page(self._user, page, site=page.site):
raise ValidationError(_("You do not have permission to change this page."))
return page
def create_translation(self, page, main_language_page_content_template=None):
data = self.cleaned_data
title_kwargs = {
"page": page,
"language": self._language,
"slug": data["slug"],
"path": data["path"],
"title": data["title"],
"template": self.get_template(),
"created_by": self._user,
}
title_kwargs.update(self.content_defaults)
if "menu_title" in data:
title_kwargs["menu_title"] = data["menu_title"]
if "page_title" in data:
title_kwargs["page_title"] = data["page_title"]
if "meta_description" in data:
title_kwargs["meta_description"] = data["meta_description"]
if main_language_page_content_template:
title_kwargs["template"] = main_language_page_content_template
return api.create_page_content(**title_kwargs)
def from_source(self, source, parent=None):
new_page = source.copy(
site=self._site,
parent_page=parent,
language=self._language,
translations=False,
permissions=False,
extensions=False,
user=self._user,
)
new_page.update(is_page_type=False)
# ``Page.copy()`` is called with ``permissions=False``, so a copy of a
# view-restricted source would be world-readable. Carry the source's view
# restrictions -- its own and the ones it inherits -- over to the copy.
new_page.apply_view_restrictions(source.get_view_restrictions(), user=self._user)
return new_page
def get_template(self):
source = self.cleaned_data.get("source")
if source:
return source.get_template(self._language)
return PageContent.TEMPLATE_DEFAULT
def save(self, *args, **kwargs):
page = self.cleaned_data.get("cms_page")
source = self.cleaned_data.get("source")
parent = self.cleaned_data.get("parent_page")
operation_token = send_pre_page_operation(
request=self._request,
operation=ADD_PAGE_TRANSLATION,
language=self._language,
)
if page:
new_page = page
elif source:
new_page = self.from_source(source, parent=parent)
else:
new_page = Page(site=self._site, parent=parent)
new_page.add_to_tree(position="last-child")
new_page.save()
translation = self.create_translation(
new_page, main_language_page_content_template=get_main_language_page_content_template(new_page)
)
new_page.page_content_cache[translation.language] = translation
if source:
extension_pool.copy_extensions(
source_page=source,
target_page=new_page,
languages=[translation.language],
)
placeholders = source.get_placeholders(translation.language)
for source_placeholder in placeholders:
target_placeholder = translation.placeholders.create(
slot=source_placeholder.slot,
default_width=source_placeholder.default_width,
)
source_placeholder.copy_plugins(target_placeholder, language=translation.language)
is_first = not (Page.objects.on_site(self._site).exclude(pk=new_page.id).exists())
if is_first and not new_page.is_page_type:
# it's the first page. Make it the homepage
new_page.set_as_homepage(self._user)
send_post_page_operation(
request=self._request,
operation=ADD_PAGE_TRANSLATION,
token=operation_token,
obj=new_page,
language=self._language,
)
return translation
def save_m2m(self):
return
class AddPageTypeForm(AddPageForm):
menu_title = None
meta_description = None
page_title = None
source = forms.ModelChoiceField(
queryset=Page.objects.all(),
required=False,
widget=forms.HiddenInput(),
)
content_defaults = {
"in_navigation": False,
}
def get_or_create_root(self):
"""
Creates the root node used to store all page types
for the current site if it doesn't exist.
"""
root_page = PageType.get_root_page(site=self._site)
if not root_page:
root_page = Page(is_page_type=True)
root_page.set_tree_node(self._site)
root_page.save()
if not root_page.has_translation(self._language):
api.create_page_content(
language=self._language,
title=gettext("Page Types"),
page=root_page,
slug=PAGE_TYPES_ID,
path=PAGE_TYPES_ID,
in_navigation=False,
)
return root_page
def clean_parent_page(self):
parent_page = super().clean_parent_page()
if parent_page and not parent_page.item.is_page_type:
raise ValidationError("Parent has to be a page type.")
if not parent_page:
# parent was not explicitly selected.
# fallback to the page types root
parent_page = self.get_or_create_root()
return parent_page
def from_source(self, source, parent=None):
new_page = source.copy(
site=self._site,
parent_page=parent,
language=self._language,
translations=False,
permissions=False,
extensions=False,
)
new_page.update(is_page_type=True)
return new_page
def save(self, *args, **kwargs):
new_page = super().save(*args, **kwargs)
if not self.cleaned_data.get("source"):
# User has created a page-type via "Add page"
# instead of from another page.
new_page.update(is_page_type=True)
return new_page
class DuplicatePageForm(AddPageForm):
source = forms.ModelChoiceField(
queryset=Page.objects.all(),
required=True,
widget=forms.HiddenInput(),
)
source_permission_denied = _("You do not have permission to copy this page.")
class ChangePageForm(BasePageContentForm):
overwrite_url = forms.CharField(
label=_("Overwrite URL"),
max_length=255,
required=False,
help_text=_("Keep this field empty if standard path should be used."),
)
soft_root = forms.BooleanField(
label=PageContent._meta.get_field("soft_root").verbose_name.capitalize(),
required=False,
help_text=PageContent._meta.get_field("soft_root").help_text,
)
redirect = PageSmartLinkField(
label=PageContent._meta.get_field("redirect").verbose_name.capitalize(),
required=False,
help_text=PageContent._meta.get_field("redirect").help_text,
placeholder_text=_("Start typing..."),
ajax_view="admin:cms_page_get_list",
)
limit_visibility_in_menu = forms.TypedChoiceField(
choices=PageContent._meta.get_field("limit_visibility_in_menu").choices,
label=PageContent._meta.get_field("limit_visibility_in_menu").verbose_name.capitalize(),
help_text=_("limit when this page is visible in the menu"),
initial=PageContent._meta.get_field("limit_visibility_in_menu").default,
required=False,
coerce=int,
empty_value=None,
)
xframe_options = forms.ChoiceField(
choices=PageContent._meta.get_field("xframe_options").choices,
label=_("X Frame Options"),
help_text=_("Whether this page can be embedded in other pages or websites."),
initial=PageContent._meta.get_field("xframe_options").default,
required=False,
)
fieldsets = (
(
None,
{
"fields": (
"title",
"slug",
"template",
"page_title",
"meta_description",
),
},
),
(
_("URL options"),
{
"fields": ("overwrite_url", "redirect"),
"classes": ["collapse"],
},
),
(
_("Menu options"),
{
"fields": ("soft_root", "menu_title", "limit_visibility_in_menu"),
"classes": ["collapse"],
},
),
(
_("Headers"),
{
"fields": ("xframe_options",),
"classes": ["collapse"],
},
),
)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields["slug"].initial = self.instance.slug
self.fields["redirect"].widget.language = self._language
self.fields["redirect"].initial = self.instance.redirect
if self.instance.overwrite_url:
self.fields["overwrite_url"].initial = self.instance.overwrite_url
@cached_property
def _language(self):
return self.instance.language
def clean_overwrite_url(self):
path = (self.cleaned_data.get("overwrite_url") or "").strip("/")
if path:
# Validate the characters here rather than in ``clean()``: that method returns early
# for the home page and for pages without a reachable path, but the value is persisted
# either way and becomes the live url as soon as the page stops being home.
validate_path(path)
return self.cleaned_data["overwrite_url"]
def clean(self):
data = super().clean()
if self._errors:
# Form already has errors, best to let those be
# addressed first.
return data
page = self.instance.page
if page.is_home:
# the home page always lives at the root path
return data
slug = data["slug"]
path_override = self.cleaned_data.get("overwrite_url")
# the same derivation Page.update_urls_from_content applies on save
path = page.get_url_data(slug, path_override, self._language)["path"]
if path is None:
# the page has no reachable path (e.g. unpublished parent),
# so there is no URL to validate
return data
user_language = get_site_language_from_request(self._request, site_id=self._site.pk)
try:
# Validate the url
validate_url_uniqueness(
self._site,
path=path,
language=self._language,
user_language=user_language,
exclude_page=page,
)
except ValidationError as error:
field = "overwrite_url" if path_override else "slug"
self.add_error(field, error)
return data
def clean_xframe_options(self):
if "xframe_options" not in self.fields:
return # nothing to do, field isn't present
xframe_options = self.cleaned_data["xframe_options"]
if xframe_options == "":
return PageContent._meta.get_field("xframe_options").default
return xframe_options
def save(self, commit=True):
operation_token = send_pre_page_operation(
request=self._request,
operation=CHANGE_PAGE_TRANSLATION,
language=self._language,
)
data = self.cleaned_data.copy()
page = self.instance.page
data["overwrite_url"] = (data.get("overwrite_url") or "").strip("/") or None
page_content = super().save(commit=False)
page_content.update(
changed_by=get_clean_username(self._request.user),
changed_date=timezone.now(),
**data,
)
if page_content.is_public():
# This content is what visitors see (nothing like a versioning
# package hides it), so the page URL follows the change right away.
# Otherwise the URL is only updated once this content is published.
page.update_urls_from_content(self._language)
if page.application_urls and "slug" in self.changed_data:
# Connects the apphook restart handler to the request finished signal
set_restart_trigger()
page.clear_cache(menu=True)
send_post_page_operation(
request=self._request,
operation=CHANGE_PAGE_TRANSLATION,
token=operation_token,
obj=page,
language=self._language,
)
return page_content
class AdvancedSettingsForm(forms.ModelForm):
_site = None
_request = None
application_urls = forms.ChoiceField(
label=_("Application"),
choices=(),
required=False,
help_text=_("Hook application to this page."),
)
# This is really a 'fake' field which does not correspond to any Page attribute
# But creates a stub field to be populate by js
application_configs = forms.CharField(
label=_("Application configurations"),
required=False,
widget=ApplicationConfigSelect,
)
class Meta:
model = Page
fields = [
"login_required",
"reverse_id",
"navigation_extenders",
"application_urls",
"application_namespace",
"application_configs",
]
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if "navigation_extenders" in self.fields:
navigation_extenders = self.get_navigation_extenders()
self.fields["navigation_extenders"].widget = forms.Select({}, [("", "---------")] + navigation_extenders)
if "application_urls" in self.fields:
# Prepare a dict mapping the apps by class name ('PollApp') to
# their app_name attribute ('polls'), if any.
app_namespaces = {}
app_configs = {}
for hook in apphook_pool.get_apphooks():
app = apphook_pool.get_apphook(hook[0])
if app.app_name:
app_namespaces[hook[0]] = app.app_name
if app.app_config:
app_configs[hook[0]] = app
self.fields["application_urls"].widget = AppHookSelect(
attrs={"id": "application_urls"}, app_namespaces=app_namespaces
)
self.fields["application_urls"].choices = [("", "---------")] + apphook_pool.get_apphooks()
page_data = self.data if self.data else self.initial
if app_configs:
self.fields["application_configs"].widget = ApplicationConfigSelect(
attrs={"id": "application_configs"},
app_configs=app_configs,
)
if page_data.get("application_urls", False) and page_data["application_urls"] in app_configs:
configs = app_configs[page_data["application_urls"]].get_configs()
self.fields["application_configs"].widget.choices = [
(config.pk, force_str(config)) for config in configs
]
try:
config = configs.get(namespace=self.initial["application_namespace"])
self.fields["application_configs"].initial = config.pk
except ObjectDoesNotExist:
# Provided apphook configuration doesn't exist (anymore),
# just skip it
# The user will choose another value anyway
pass
@cached_property
def _language(self):
return get_site_language_from_request(self._request, site_id=self._site.pk)
def clean(self):
cleaned_data = super().clean()
if self._errors:
# Fail fast if there's errors in the form
return cleaned_data
if "reverse_id" in self.fields:
reverse_id = cleaned_data["reverse_id"]
if reverse_id:
lookup = Page.objects.on_site(self._site).filter(reverse_id=reverse_id)
if lookup.exclude(pk=self.instance.pk).exists():
self._errors["reverse_id"] = self.error_class(
[_("A page with this reverse URL id exists already.")]
)
apphook = cleaned_data.get("application_urls", None)
# The field 'application_namespace' is a misnomer. It should be
# 'instance_namespace'.
instance_namespace = cleaned_data.get("application_namespace", None)
application_config = cleaned_data.get("application_configs", None)
if apphook:
apphooks_with_config = self.get_apphooks_with_config()
# application_config wins over application_namespace
if apphook in apphooks_with_config and application_config:
# the value of the application config namespace is saved in
# the 'usual' namespace field to be backward compatible
# with existing apphooks
try:
appconfig_pk = forms.IntegerField(required=True).to_python(application_config)
except ValidationError:
self._errors["application_configs"] = ErrorList([_("Invalid application config value")])
return self.cleaned_data
try:
config = apphooks_with_config[apphook].get_configs().get(pk=appconfig_pk)
except ObjectDoesNotExist:
self._errors["application_configs"] = ErrorList([_("Invalid application config value")])
return self.cleaned_data
if self._check_unique_namespace_instance(config.namespace):
# Looks like there's already one with the default instance
# namespace defined.
self._errors["application_configs"] = ErrorList(
[_("An application instance using this configuration already exists.")]
)
else:
self.cleaned_data["application_namespace"] = config.namespace
else:
if instance_namespace:
if self._check_unique_namespace_instance(instance_namespace):
self._errors["application_namespace"] = ErrorList(
[_("An application instance with this name already exists.")]
)
else:
# The attribute on the apps 'app_name' is a misnomer, it should be
# 'application_namespace'.
application_namespace = apphook_pool.get_apphook(apphook).app_name
if application_namespace and not instance_namespace:
if self._check_unique_namespace_instance(application_namespace):
# Looks like there's already one with the default instance
# namespace defined.
self._errors["application_namespace"] = ErrorList(
[_("An application instance with this name already exists.")]
)
else:
# OK, there are zero instances of THIS app that use the
# default instance namespace, so, since the user didn't
# provide one, we'll use the default. NOTE: The following
# line is really setting the "instance namespace" of the
# new app to the app's "application namespace", which is
# the default instance namespace.
self.cleaned_data["application_namespace"] = application_namespace
if instance_namespace and not apphook:
self.cleaned_data["application_namespace"] = None
if application_config and not apphook:
self.cleaned_data["application_configs"] = None
return self.cleaned_data
def get_apphooks(self):
for hook in apphook_pool.get_apphooks():
yield (hook[0], apphook_pool.get_apphook(hook[0]))
def get_apphooks_with_config(self):
return {key: app for key, app in self.get_apphooks() if app.app_config}
def get_navigation_extenders(self):
return menu_pool.get_menus_by_attribute("cms_enabled", True)
def _check_unique_namespace_instance(self, namespace):
return (
Page.objects.on_site(self._site)
.filter(application_namespace=namespace)
.exclude(pk=self.instance.pk)
.exists()
)
def has_changed_apphooks(self):
changed_data = self.changed_data
if "application_urls" in changed_data:
return True
return "application_namespace" in changed_data
def save(self, *args, **kwargs):
page = super().save(*args, **kwargs)
page.clear_cache(menu=True)
clear_permission_cache()
if self.has_changed_apphooks():
set_restart_trigger()
return page
class PageTreeForm(forms.Form):
position = forms.IntegerField(initial=0, required=True)
target = forms.ModelChoiceField(queryset=Page.objects.none(), required=False)
def __init__(self, *args, **kwargs):
self.page = kwargs.pop("page")
self._site = kwargs.pop("site", self.page.site)
super().__init__(*args, **kwargs)
self.fields["target"].queryset = Page.objects.filter(
site=self._site,
is_page_type=self.page.is_page_type,
)
def get_root_pages(self):
pages = Page.get_root_nodes()
return pages.exclude(is_page_type=not self.page.is_page_type)
def get_tree_options(self):
position = self.cleaned_data["position"]
if target_page := self.cleaned_data.get("target"):
return self._get_tree_options_for_parent(target_page, position)
return self._get_tree_options_for_root(position)
def _get_tree_options_for_root(self, position):
siblings = Page.get_root_nodes().filter(site=self._site)
try:
return siblings[position], "left"
except IndexError:
# The position requested is not occupied.
# Add the node as the last root node,
# relative to the current site.
return siblings.reverse()[0], "right"
def _get_tree_options_for_parent(self, parent_page, position):
if position == 0:
return parent_page, "first-child"
siblings = parent_page.get_children().filter(site=self._site)
try:
return siblings[position], "left"
except IndexError:
# The position requested is not occupied.
# Add the node to be the parent's first child
return parent_page, "last-child"
class MovePageForm(PageTreeForm):
def clean(self):
cleaned_data = super().clean()
if self.page.is_home and cleaned_data.get("target"):
self.add_error(
"target",
force_str(_("You can't move the home page inside another page")),
)
return cleaned_data
if self.page.is_home:
# The home page can only be reordered among the root pages, which
# changes neither its URLs nor those of its descendants.
return cleaned_data
target_page, position = self.get_tree_options()
new_parent = self._determine_new_parent(target_page, position)
# Moving affects every URL, regardless of the active admin language or
# whether public content is available. Never-published pages may have
# no URLs yet; their URLs will be validated when they are published.
for url in self.page.urls.all():
if not url.managed:
# Overwritten URLs keep their fixed path when the page moves,
# so they cannot collide at the new position.
continue
language, slug = url.language, url.slug
if position not in ("first-child", "last-child"):
self._validate_slug_uniqueness(new_parent, language, slug)
if new_parent:
parent_path = new_parent.get_path(language)
if parent_path is None:
# Below an unreachable parent the page is unreachable, too.
# Its URL is validated once the parent gets a path.
continue
new_path = f"{parent_path}/{slug}" if parent_path else slug
else:
new_path = slug
self._validate_url_uniqueness(new_path, language)
return cleaned_data
def get_tree_options(self):
target_page, target_page_position = super().get_tree_options()
if target_page_position != "left":
return target_page, target_page_position
if self.page.path < target_page.path:
if self.page.is_sibling_of(target_page):
# The page being moved appears before the target page and is a sibling of the target node.
# The user is moving from left to right.
return target_page, "right"
# The node being moved appears before the target node but is not a sibling of the target node.
# The user is moving from right to left.
return target_page, "left"
# The node being moved appears after the target node.
# The user is moving from right to left.
return target_page, "left"
def move_page(self, user=None):
self.page.move_page(*self.get_tree_options(), user=user)
def _determine_new_parent(self, target_page, position):
if position in ("first-child", "last-child"):
return target_page
return target_page.parent
def _validate_slug_uniqueness(self, parent, language, slug):
target_siblings = Page.objects.filter(
parent=parent,
site=self._site,
is_page_type=self.page.is_page_type,
).exclude(pk=self.page.pk)
if target_siblings.filter(urls__slug=slug, urls__language=language).exists():
raise ValidationError(
_(
"You cannot have two pages with the same slug at the same page level. "
"Please alter the slug of one of the pages before trying to move again."
)
)
def _validate_url_uniqueness(self, path, language):
try:
validate_url_uniqueness(
self._site,
path=path,
language=language,
exclude_page=self.page,
)
except ValidationError:
raise ValidationError(
_(
"You cannot have two pages with the same slug at the same page level. "
"Please alter the slug of one of the pages before trying to move again."
)
)
class CopyPageForm(PageTreeForm):
copy_permissions = forms.BooleanField(initial=False, required=False)
def copy_page(self, user):
target_page, position = self.get_tree_options()
copy_permissions = self.cleaned_data.get("copy_permissions", False)
new_page = self.page.copy_with_descendants(
target_page=target_page,
position=position,
copy_permissions=copy_permissions,
target_site=self._site,
user=user,
)
return new_page
def _get_tree_options_for_root(self, position):
try:
return super()._get_tree_options_for_root(position)
except IndexError:
# The user is copying a page to a site with no pages
# Add the node as the last root node.
siblings = self.get_root_pages().reverse()
return (siblings[0], "right")
class ChangeListForm(forms.Form):
BOOLEAN_CHOICES = (
("", _("All")),
("1", _("Yes")),
("0", _("No")),
)
q = forms.CharField(required=False, widget=forms.HiddenInput())
in_navigation = forms.ChoiceField(required=False, choices=BOOLEAN_CHOICES)
template = forms.ChoiceField(required=False)
changed_by = forms.ChoiceField(required=False)
soft_root = forms.ChoiceField(required=False, choices=BOOLEAN_CHOICES)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields["changed_by"].choices = get_page_changed_by_filter_choices()
self.fields["template"].choices = get_page_template_filter_choices()
def is_filtered(self):
data = self.cleaned_data
if self.cleaned_data.get("q"):
return True
return any(bool(data.get(field.name)) for field in self.visible_fields())
def get_filter_items(self):
for field in self.visible_fields():
value = self.cleaned_data.get(field.name)
if value:
yield (field.name, value)
def run_filters(self, queryset):
for field, value in self.get_filter_items():
query = {f"{field}__exact": value}
queryset = queryset.filter(**query)
return queryset
class BasePermissionAdminForm(forms.ModelForm):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
permission_fields = self._meta.model.get_all_permissions()
for field in permission_fields:
if field not in self.base_fields:
setattr(self.instance, field, False)
class PagePermissionInlineAdminForm(BasePermissionAdminForm):
"""
Page permission inline admin form used in inline admin. Required, because
user and group queryset must be changed. User can see only users on the same
level or under him in chosen page tree, and users which were created by him,
but aren't assigned to higher page level than current user.
"""
_page = None
page = forms.ModelChoiceField(
queryset=Page.objects.all(),
label=_("user"),
widget=HiddenInput(),
required=True,
)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
user = get_current_user() # current user from threadlocals
site_id = getattr(self._page, "site_id", None)
site = Site.objects._get_site_by_id(site_id) if site_id else None
sub_users = get_subordinate_users(user, site)
limit_choices = True
use_raw_id = False
# Unfortunately, if there are > 500 users in the system, non-superusers
# won't see any benefit here because if we ask Django to put all the
# user PKs in limit_choices_to in the query string of the popup we're
# in danger of causing 414 errors so we fall back to the normal input
# widget.
if get_cms_setting("RAW_ID_USERS"):
if sub_users.count() < 500:
# If there aren't too many users, proceed as normal and use a
# raw id field with limit_choices_to
limit_choices = True
use_raw_id = True
elif get_user_permission_level(user, site) == ROOT_USER_LEVEL:
# If there are enough choices to possibly cause a 414 request
# URI too large error, we only proceed with the raw id field if
# the user is a superuser & thus can legitimately circumvent
# the limit_choices_to condition.
limit_choices = False
use_raw_id = True
# We don't use the fancy custom widget if the admin form wants to use a
# raw id field for the user
if use_raw_id:
from django.contrib.admin.widgets import ForeignKeyRawIdWidget
# This check will be False if the number of users in the system
# is less than the threshold set by the RAW_ID_USERS setting.
if isinstance(self.fields["user"].widget, ForeignKeyRawIdWidget):
# We can't set a queryset on a raw id lookup, but we can use
# the fact that it respects the limit_choices_to parameter.
if limit_choices:
self.fields["user"].widget.rel.limit_choices_to = dict(
id__in=list(sub_users.values_list("pk", flat=True))
)
else:
self.fields["user"].widget = UserSelectAdminWidget()
self.fields["user"].queryset = sub_users
self.fields["user"].widget.user = user # assign current user
self.fields["group"].queryset = get_subordinate_groups(user, site)
class Meta:
fields = [
"user",
"group",
"can_add",
"can_change",
"can_delete",
"can_publish",
"can_change_advanced_settings",
"can_change_permissions",
"can_move_page",
"grant_on",
]
model = PagePermission
class ViewRestrictionInlineAdminForm(BasePermissionAdminForm):
page = forms.ModelChoiceField(
queryset=Page.objects.all(),
label=_("user"),
widget=HiddenInput(),
required=True,
)
can_view = forms.BooleanField(
label=_("can_view"),
widget=HiddenInput(),
initial=True,
)
class Meta:
fields = [
"user",
"group",
"grant_on",
"can_view",
]
model = PagePermission
def clean_can_view(self):
return True
class GlobalPagePermissionAdminForm(BasePermissionAdminForm):
"""Admin form for site-wide page permissions.
Unlike :class:`PagePermissionInlineAdminForm`, whose admin restricts the
rendered ``can_*`` flags in ``PagePermissionInlineAdmin.get_formset``, this
form is the only place where the "a manager cannot hand out rights they do
not hold themselves" invariant (see ``docs/explanation/permissions.rst``)
can be enforced for :class:`~cms.models.GlobalPagePermission`.
Without it, a delegate whose sole elevated right is ``can_change_permissions``
could grant themselves ``can_publish``, ``can_delete``,
``can_change_advanced_settings`` and ``can_move_page`` site-wide (CWE-269).
"""
_current_user = None
class Meta:
fields = [
"user",
"group",
"can_add",
"can_change",
"can_delete",
"can_publish",
"can_change_advanced_settings",
"can_change_permissions",
"can_move_page",
"can_view",
"sites",
]
model = GlobalPagePermission
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if not self.instance.pk:
return
# ``GlobalPagePermissionAdmin.get_form`` excludes the flags the manager
# may not grant, and ``BasePermissionAdminForm`` then blanks every
# excluded flag on the instance. That is right for a new row -- several
# ``can_*`` flags default to ``True`` on the model -- but on an existing
# one it would silently *revoke* rights the manager is equally
# unauthorized to touch, so restore the stored values here.
stored = (
self._meta.model._base_manager.filter(pk=self.instance.pk)
.values(*self._meta.model.get_all_permissions())
.first()
)
for field, value in (stored or {}).items():
if field not in self.fields:
setattr(self.instance, field, value)
def clean(self):
cleaned_data = super().clean()
user = self._current_user or get_current_user()
if user is None:
# No acting user means the form is used outside an admin request --
# a script, data migration or test -- where there is no privilege
# boundary to enforce. ``GlobalPagePermissionAdmin.get_form`` always
# sets ``_current_user``, so the admin never takes this path.
return cleaned_data
# ``sites`` is excluded from ``cleaned_data`` when it failed validation;
# falling back to "all sites" then applies the strictest check.
site_ids = [site.pk for site in cleaned_data.get("sites") or []]
grantable = get_grantable_global_permissions(user, site_ids)
if "can_change_permissions" not in grantable:
# ``has_add_permission`` only checks the current site; a manager must
# be allowed to manage permissions on every site the grant covers.
self.add_error(
"sites" if "sites" in self.fields else None,
forms.ValidationError(
_("You cannot manage permissions on every selected site."),
code="sites_not_managed",
),
)
return cleaned_data
for field in self._meta.model.get_all_permissions():
# Fields absent from the form were already excluded as ungrantable
# on any site; this catches the narrower case of a flag the manager
# holds on one site being granted for another.
if field in self.fields and cleaned_data.get(field) and field not in grantable:
self.add_error(
field,
forms.ValidationError(
_(
'You cannot grant "%(permission)s" because you do not hold it '
"yourself on every selected site."
),
code="permission_not_held",
params={"permission": self.fields[field].label or field},
),
)
return cleaned_data
class GenericCmsPermissionForm(forms.ModelForm):
"""Generic form for User & Group permissions in cms"""
_current_user = None
can_add_page = forms.BooleanField(label=_("Add"), required=False, initial=True)
can_change_page = forms.BooleanField(label=_("Change"), required=False, initial=True)
can_delete_page = forms.BooleanField(label=_("Delete"), required=False)
# pageuser is for pageuser & group - they are combined together,
# and read out from PageUser model
can_add_pageuser = forms.BooleanField(label=_("Add"), required=False)
can_change_pageuser = forms.BooleanField(label=_("Change"), required=False)
can_delete_pageuser = forms.BooleanField(label=_("Delete"), required=False)
can_add_pagepermission = forms.BooleanField(label=_("Add"), required=False)
can_change_pagepermission = forms.BooleanField(label=_("Change"), required=False)
can_delete_pagepermission = forms.BooleanField(label=_("Delete"), required=False)
# Maps the ``can_<action>_<name>`` permission fields to the model whose
# permission they grant. Mirrors ``save_permissions`` and the fieldsets
# rendered by ``PageUserGroupAdmin``/``PageUserAdmin``.
def __init__(self, *args, **kwargs):
instance = kwargs.get("instance")
initial = kwargs.get("initial") or {}
if instance:
initial = initial or {}
initial.update(self.populate_initials(instance))
kwargs["initial"] = initial
super().__init__(*args, **kwargs)
def clean(self):
data = super().clean()
# Validate Page options
if not data.get("can_change_page"):
if data.get("can_add_page"):
raise ValidationError(EDIT_PERMISSION_REQUIRED_MESSAGES["can_add"])
if data.get("can_delete_page"):
raise ValidationError(EDIT_PERMISSION_REQUIRED_MESSAGES["can_delete"])
if data.get("can_add_pagepermission"):
message = _(
"Users can't set page permissions without also being able to "
"change the page. Please also enable 'Can edit'."
)
raise ValidationError(message)
if data.get("can_delete_pagepermission"):
message = _(
"Users can't delete page permissions without also being able "
"to change the page. Please also enable 'Can edit'."
)
raise ValidationError(message)
# Validate PagePermission options
if not data.get("can_change_pagepermission"):
if data.get("can_add_pagepermission"):
message = _(
"Users can't create page permissions without also being able "
"to change them. Please also enable 'Can edit'."
)
raise ValidationError(message)
if data.get("can_delete_pagepermission"):
message = _(
"Users can't delete page permissions without also being able "
"to change them. Please also enable 'Can edit'."
)
raise ValidationError(message)
# Enforce "nobody can grant more than they have" at the data layer.
self._drop_unauthorized_permissions(data)
return data
def _drop_unauthorized_permissions(self, data):
"""Remove ``can_*`` entries the current manager is not allowed to manage.
``PageUserGroupAdmin.get_fieldsets`` only *renders* the permission
checkboxes the manager actually holds, but the ``can_*`` fields are
declared on this form and therefore stay in ``base_fields`` regardless
of the admin's ``fields=`` restriction. A crafted POST can set the
hidden ones, and ``save_permissions`` would then grant them -- letting a
delegated manager escalate a group beyond their own rights (CWE-269).
Dropping the unauthorized entries here means they are neither granted
nor revoked, so any existing value the manager may not touch is left
untouched as well.
"""
user = self._current_user
for model, name in CMS_PERMISSION_MODELS:
for action in CMS_PERMISSION_ACTIONS:
field = f"can_{action}_{name}"
if field not in self.fields:
continue
if user is None or not user.has_perm(get_model_permission_codename(model, action)):
data.pop(field, None)
return data
def populate_initials(self, obj):
"""Read out permissions from permission system."""
initials = {}
permission_accessor = get_permission_accessor(obj)
for model, name in CMS_PERMISSION_MODELS:
content_type = ContentType.objects.get_for_model(model)
permissions = permission_accessor.filter(content_type=content_type).values_list("codename", flat=True)
for key in CMS_PERMISSION_ACTIONS:
codename = get_permission_codename(key, model._meta)
initials[f"can_{key}_{name}"] = codename in permissions
return initials
def save(self, commit=True):
instance = super().save(commit=False)
instance.save()
save_permissions(self.cleaned_data, instance)
return instance
class PageUserAddForm(forms.ModelForm):
_current_user = None
user = forms.ModelChoiceField(queryset=User.objects.none())
class Meta:
fields = ["user"]
model = PageUser
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields["user"].queryset = self.get_subordinates()
def get_subordinates(self):
subordinates = get_subordinate_users(self._current_user, self._current_site)
return subordinates.filter(pageuser__isnull=True)
def save(self, commit=True):
user = self.cleaned_data["user"]
instance = super().save(commit=False)
instance.created_by = self._current_user
for field in user._meta.fields:
# assign all the fields - we can do this, because object is
# subclassing User (one to one relation)
value = getattr(user, field.name)
setattr(instance, field.name, value)
if commit:
instance.save()
return instance
class PageUserChangeForm(UserChangeForm):
_current_user = None
class Meta:
fields = "__all__"
model = PageUser
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if not self._current_user.is_superuser:
# Limit permissions to include only
# the permissions available to the manager.
permissions = self.get_available_permissions()
self.fields["user_permissions"].queryset = permissions
# Limit groups to include only those where
# the manager is a member.
self.fields["groups"].queryset = self.get_available_groups()
def get_available_permissions(self):
permissions = self._current_user.get_all_permissions()
permission_codes = (perm.rpartition(".")[-1] for perm in permissions)
return Permission.objects.filter(codename__in=permission_codes)
def get_available_groups(self):
return self._current_user.groups.all()
class PageUserGroupForm(GenericCmsPermissionForm):
class Meta:
model = PageUserGroup
fields = ("name",)
def save(self, commit=True):
if not self.instance.pk:
self.instance.created_by = self._current_user
return super().save(commit=commit)
class PluginAddValidationForm(forms.Form):
placeholder_id = forms.ModelChoiceField(
queryset=Placeholder.objects.all(),
required=True,
)
plugin_position = forms.IntegerField(required=True)
plugin_language = forms.CharField(required=True)
plugin_parent = forms.ModelChoiceField(
CMSPlugin.objects.all(),
required=False,
)
plugin_type = forms.CharField(required=True)
def clean_plugin_type(self):
plugin_type = self.cleaned_data["plugin_type"]
try:
plugin_pool.get_plugin(plugin_type)
except KeyError:
message = gettext("Invalid plugin type '%s'") % plugin_type
raise ValidationError(message)
return plugin_type
def clean(self):
from cms.utils.plugins import has_reached_plugin_limit
data = self.cleaned_data
if self.errors:
return data
language = data["plugin_language"]
position = data["plugin_position"]
placeholder = data["placeholder_id"]
parent_plugin = data.get("plugin_parent")
site = get_current_site(getattr(self, "_request", None))
if language not in get_language_list(site_id=site.pk):
message = gettext("Language must be set to a supported language!")
self.add_error("plugin_language", message)
return self.cleaned_data
if parent_plugin:
if parent_plugin.language != language:
message = gettext("Parent plugin language must be same as language!")
self.add_error("plugin_language", message)
return self.cleaned_data
if parent_plugin.placeholder_id != placeholder.pk:
message = gettext("Parent plugin placeholder must be same as placeholder!")
self.add_error("placeholder_id", message)
return self.cleaned_data
if position <= parent_plugin.position:
message = gettext("Plugin position must be greater than %(position)d")
self.add_error("placeholder_id", message % {"position": parent_plugin.position})
return self.cleaned_data
page = placeholder.page
template = page.get_template() if page else None
plugin_class = plugin_pool.get_plugin(data["plugin_type"])
if not plugin_class.is_allowed_in_slot(placeholder.slot):
message = gettext("Plugin %(plugin)s is not allowed in placeholder '%(slot)s'.")
self.add_error(None, message % {"plugin": plugin_class.__name__, "slot": placeholder.slot})
return self.cleaned_data
try:
has_reached_plugin_limit(placeholder, data["plugin_type"], language, template=template)
except PluginLimitReached as error:
self.add_error(None, force_str(error))
return self.cleaned_data
class RequestToolbarForm(forms.Form):
obj_id = forms.CharField(required=False)
obj_type = forms.CharField(required=False)
cms_path = forms.CharField(required=False)
def clean(self):
data = self.cleaned_data
obj_id = data.get("obj_id")
obj_type = data.get("obj_type")
if not bool(obj_id or obj_type):
return data
if (obj_id and not obj_type) or (obj_type and not obj_id):
message = "Invalid object lookup. Both obj_id and obj_type are required"
raise forms.ValidationError(message)
app, sep, model = obj_type.rpartition(".")
try:
model_class = apps.get_model(app_label=app, model_name=model)
except LookupError:
message = "Invalid object lookup. Both obj_id and obj_type are required"
raise forms.ValidationError(message)
try:
# Use admin manager if available for the toolbar form
if issubclass(model_class, PageContent):
generic_obj = model_class.admin_manager.select_related("page").get(pk=obj_id)
elif hasattr(model_class, "admin_manager"):
generic_obj = model_class.admin_manager.get(pk=obj_id)
else:
generic_obj = model_class.objects.get(pk=obj_id)
except model_class.DoesNotExist:
message = "Invalid object lookup. Both obj_id and obj_type are required"
raise forms.ValidationError(message)
else:
data["attached_obj"] = generic_obj
return data
def clean_cms_path(self):
path = self.cleaned_data.get("cms_path")
if path:
validate_relative_url(path)
return path