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__`` 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__`` 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