dynamicweb/cms/models/pagemodel.py

1379 lines
53 KiB
Python

import warnings
from logging import getLogger
from os.path import join
from django.contrib.auth import get_user_model
from django.contrib.sites.models import Site
from django.db import IntegrityError, connection, models, router, transaction
from django.db.models import Prefetch
from django.db.models.base import ModelState
from django.db.models.constraints import UniqueConstraint
from django.db.models.functions import Concat
from django.forms import model_to_dict
from django.urls import NoReverseMatch, reverse
from django.utils.encoding import force_str
from django.utils.timezone import now
from django.utils.translation import get_language, gettext_lazy as _, override as force_language
from cms import constants
from cms.models.managers import PageManager, PageUrlManager
from cms.utils import i18n
from cms.utils.compat.warnings import RemovedInDjangoCMS60Warning
from cms.utils.conf import get_cms_setting
from cms.utils.i18n import get_current_language
from cms.utils.mptree import get_tree_base
from cms.utils.page import get_clean_username
from menus.menu_pool import menu_pool
logger = getLogger(__name__)
def _lock_tree_roots(*pages):
# Serializes concurrent URL path updates by taking a row-level lock on the
# tree root(s) of the given pages, which prevents deadlocks on cms_pageurl.
# Locks are acquired in pk order to avoid deadlocks between the locks
# themselves when two roots are involved.
assert connection.in_atomic_block, "_lock_tree_roots requires an active transaction"
pks = sorted({page.get_root().pk for page in pages})
list(Page.objects.filter(pk__in=pks).order_by("pk").select_for_update())
class AdminCacheDict(dict):
"""Dictionary that disallows setting individual items to prevent accidental cache corruption."""
def __setitem__(self, key, value):
raise ValueError("Do not set individual items in the admin cache dict. Use the clear_cache method instead.")
MP_Node = get_tree_base()
class Page(MP_Node):
"""
A ``Page`` is the basic unit of site structure in django CMS. The CMS uses a hierarchical page model: each page
stands in relation to other pages as parent, child or sibling. This hierarchy is managed by the `django-treebeard
<http://django-treebeard.readthedocs.io/en/latest/>`_ library.
A ``Page`` is an abstract entity. It does not have any content associated with it, nor does is provide any slugs to
build a URL. This is part of the model :class:`~cms.models.contentmodels.PageContent` and
:class:`~cms.models.pagemodel.PageUrl` respectively.
"""
parent = models.ForeignKey(
"self",
on_delete=models.CASCADE,
blank=True,
null=True,
related_name="children",
db_index=True,
)
site = models.ForeignKey(
Site,
on_delete=models.CASCADE,
verbose_name=_("site"),
related_name="djangocms_pages",
db_index=True,
)
created_by = models.CharField(
_("created by"),
max_length=constants.PAGE_USERNAME_MAX_LENGTH,
editable=False,
)
changed_by = models.CharField(
_("changed by"),
max_length=constants.PAGE_USERNAME_MAX_LENGTH,
editable=False,
)
creation_date = models.DateTimeField(auto_now_add=True)
changed_date = models.DateTimeField(auto_now=True)
#
# Please use toggle_in_navigation() instead of affecting this property
# directly so that the cms page cache can be invalidated as appropriate.
#
reverse_id = models.CharField(
_("id"),
max_length=40,
db_index=True,
blank=True,
null=True,
help_text=_("A unique identifier that is used with the page_url templatetag for linking to this page"),
)
navigation_extenders = models.CharField(
_("attached menu"),
max_length=80,
db_index=True,
blank=True,
null=True,
)
login_required = models.BooleanField(
_("login required"),
default=False,
)
is_home = models.BooleanField(
editable=False,
db_index=True,
default=False,
)
application_urls = models.CharField(
_("application"),
max_length=200,
blank=True,
null=True,
db_index=True,
)
application_namespace = models.CharField(
_("application instance name"),
max_length=200,
blank=True,
null=True,
)
is_page_type = models.BooleanField(
default=False,
help_text=_("Mark this page as a page type"),
)
# Managers
objects = PageManager()
class Meta:
default_permissions = ["add", "change", "delete"]
permissions = [
("view_page", "Can view page"),
("publish_page", "Can publish page"),
("edit_static_placeholder", "Can edit static placeholders"),
]
verbose_name = _("page")
verbose_name_plural = _("pages")
app_label = "cms"
ordering = ["path"]
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.urls_cache = {}
#: Internal cache for page urls
self.page_content_cache = {}
#: Internal cache for page content objects visible publicly
self.admin_content_cache = None
#: Internal cache for page content objects visible in the admin (i.e. to staff users.)
#: Might be larger than the page_content_cache
def __str__(self):
page_content = self.get_admin_content(get_language(), fallback=True)
if page_content:
title = page_content.menu_title or page_content.title
else:
title = _("No available title")
return force_str(title)
def __repr__(self):
display = f"<{self.__module__}.{self.__class__.__name__} id={self.pk} object at {hex(id(self))}>"
return display
@property
def node(self):
warnings.warn(
"The `node` has been removed from Page objects. Access the TreeNode attributes directly.",
RemovedInDjangoCMS60Warning,
stacklevel=2,
)
return self
@property
def is_branch(self):
return bool(self.numchild)
def get_ancestor_paths(self):
paths = frozenset(self.path[0:pos] for pos in range(0, len(self.path), self.steplen)[1:])
return paths
def add_child(self, **kwargs):
if len(kwargs) == 1 and "instance" in kwargs:
kwargs["instance"].parent = self
else:
kwargs["parent"] = self
return super().add_child(**kwargs)
def add_sibling(self, pos=None, *args, **kwargs):
if len(kwargs) == 1 and "instance" in kwargs:
kwargs["instance"].parent_id = self.parent_id
else:
kwargs["parent_id"] = self.parent_id
return super().add_sibling(*args, **kwargs)
def get_cached_ancestors(self):
if self._has_cached_hierarchy():
return self._ancestors
return []
def get_cached_descendants(self):
if self._has_cached_hierarchy():
return self._descendants
return []
def _clear_internal_cache(self):
self.urls_cache = {}
self.page_content_cache = {}
self.admin_content_cache = None
if hasattr(self, "_prefetched_objects_cache"):
del self._prefetched_objects_cache
@property
def parent_page(self):
warnings.warn(
"Attribute `parent_page` is deprecated. Instead use the attribute `parent`.",
RemovedInDjangoCMS60Warning,
stacklevel=2,
)
return self.parent
def has_valid_url(self, language):
return self.urls.filter(language=language, path__isnull=False).exists()
def set_as_homepage(self, user=None):
"""
Sets the given page as the homepage.
Updates the url paths for all affected pages.
Returns the old home page (if any).
"""
if user:
changed_by = get_clean_username(user)
else:
changed_by = constants.SCRIPT_USERNAME
changed_date = now()
try:
old_home = self.__class__.objects.get(
is_home=True,
site=self.site_id,
)
except self.__class__.DoesNotExist:
_lock_tree_roots(self)
old_home_tree = []
else:
_lock_tree_roots(self, old_home)
old_home.update(
is_home=False,
changed_by=changed_by,
changed_date=changed_date,
)
old_home_tree = old_home._set_title_root_path()
self.update(
is_home=True,
changed_by=changed_by,
changed_date=changed_date,
)
new_home_tree = self._remove_title_root_path()
return (new_home_tree, old_home_tree)
def _has_cached_hierarchy(self):
return hasattr(self, "_descendants") and hasattr(self, "_ancestors")
def _set_hierarchy(self, pages, ancestors=None):
if self.is_branch:
self._descendants = [page for page in pages if page.path.startswith(self.path) and page.depth > self.depth]
else:
self._descendants = []
if self.is_root():
self._ancestors = []
else:
self._ancestors = ancestors
for child in self._descendants:
if child.depth == self.depth + 1:
child._set_hierarchy(self._descendants, ancestors=([self] + self._ancestors))
def _get_path_sql_value(self, base_path=""):
if base_path:
new_path = Concat(
models.Value(base_path),
models.Value("/"),
models.F("slug"),
output_field=models.CharField(),
)
elif base_path is None:
new_path = None
else:
# the homepage
new_path = models.F("slug")
return new_path
def _update_url_path(self, language):
base_path = self.parent.get_path(language) if self.parent else ""
new_path = self._get_path_sql_value(base_path)
(
PageUrl.objects.filter(language=language, page=self).exclude(managed=False).update(path=new_path)
) # TODO: Update or create?
def _update_url_path_recursive(self, language):
"""Rebuild the managed URL paths of all descendant pages for
``language``, level by level: each page's path derives from its
parent's effective path. Unmanaged (overwritten) URLs keep their fixed
path but still serve as the base for their own descendants.
Costs one SELECT and at most one bulk UPDATE per tree level instead of
two queries per descendant page.
"""
if self.is_leaf() or language not in self.get_languages():
return
base_paths = {self.pk: self.get_path(language)}
while base_paths:
urls = PageUrl.objects.filter(page__parent_id__in=base_paths, language=language).values_list(
"pk", "page_id", "page__parent_id", "slug", "managed", "path"
)
changed = []
next_base_paths = {}
for pk, page_id, parent_id, slug, managed, path in urls:
if managed:
base = base_paths[parent_id]
if base is None:
# an unreachable parent makes the page unreachable too
new_path = None
else:
# an empty base means the parent is the home page
new_path = f"{base}/{slug}" if base else slug
if new_path != path:
changed.append(PageUrl(pk=pk, path=new_path))
next_base_paths[page_id] = new_path
else:
next_base_paths[page_id] = path
if changed:
PageUrl.objects.bulk_update(changed, ["path"])
base_paths = next_base_paths
def _set_title_root_path(self):
page_tree = self.__class__.get_tree(self)
page_urls = PageUrl.objects.filter(page__in=page_tree, managed=True, path__isnull=False)
for language, slug in self.urls.values_list("language", "slug"):
# Update the translations for all descendants of this page
# to include this page's slug as its path prefix
(page_urls.filter(language=language).update(path=Concat(models.Value(slug), models.Value("/"), "path")))
self.update_urls(language, path=slug)
return page_tree
def _remove_title_root_path(self):
page_tree = self.__class__.get_tree(self)
page_urls = PageUrl.objects.filter(page__in=page_tree, managed=True, path__isnull=False)
for language, slug in self.urls.values_list("language", "slug"):
# Use 2 because of 1 indexing plus the fact we need to trim
# the "/" character.
trim_count = len(slug) + 2
sql_func = models.Func(
models.F("path"),
models.Value(trim_count),
function="substr",
)
(page_urls.filter(language=language, path__startswith=slug).update(path=sql_func))
return page_tree
def is_potential_home(self):
"""
Encapsulates logic for determining if this page is eligible to be set
as `is_home`. This is a public method so that it can be accessed in the
admin for determining whether to enable the "Set as home" menu item.
:return: Boolean
"""
# Only root nodes are eligible for homepage
return not self.is_home and bool(self.is_root())
def get_absolute_url(self, language=None, fallback=True):
"""Return the absolute URL of the page for the given language, or
``None`` if the page has no reachable path (e.g. it or an ancestor
is unpublished)."""
if not language:
language = get_current_language()
with force_language(language):
try:
if self.is_home:
return reverse("pages-root")
path = self.get_path(language, fallback)
return reverse("pages-details-by-slug", kwargs={"slug": path}) if path else None
except NoReverseMatch:
return None
def set_tree_node(self, site, target=None, position="first-child"):
warnings.warn(
"Method `set_tree_node` is deprecated. Use `add_to_tree` instead.",
RemovedInDjangoCMS60Warning,
stacklevel=2,
)
self.site = site
self.parent = target
return self.add_to_tree(position=position)
def add_to_tree(self, position="first-child"):
assert position in ("last-child", "first-child", "left", "right")
if self.parent is None:
Page.add_root(instance=self)
elif position == "first-child" and self.parent.is_branch:
self.parent.get_first_child().add_sibling(pos="left", instance=self)
elif position in ("last-child", "first-child"):
self.parent.add_child(instance=self)
else:
self.parent.add_sibling(pos=position, instance=self)
def move_page(self, target_page, position="first-child", user=None):
"""
Called from admin interface when page is moved. Should be used on
all the places which are changing page position. Used like an interface
to django-treebeard.
"""
assert isinstance(target_page, Page), f"{target_page} is not an instance of Page."
inherited_template = self.template == constants.TEMPLATE_INHERITANCE_MAGIC
# Snapshot before the move: the ancestors protecting this subtree stop
# doing so as soon as its path changes.
inherited_restrictions = self.get_view_restrictions(inherited_only=True)
if inherited_template and target_page.is_root() and position in ("left", "right"):
# The page is being moved to a root position.
# Explicitly set the inherited template on the titles
# to keep all plugins / placeholders.
template = self.get_template()
self.update_translations(template=template)
# Don't use a cached node. Always get a fresh one.
self._clear_internal_cache()
# Runs the SQL updates on the treebeard fields
self.move(target_page, position)
if position in ("first-child", "last-child"):
self.parent = target_page
else:
# moving relative to sibling
# or to the root of the tree
self.parent = target_page.parent
# Runs the SQL updates on the parent field
self.update(parent=self.parent)
self.refresh_from_db(fields=("path", "depth"))
# The subtree left the ancestors it inherited its view restrictions
# from, so they have to be materialized on its new root.
self.apply_view_restrictions(inherited_restrictions, user=user)
# Update the urls for the page being moved and its descendants.
_lock_tree_roots(self)
languages = self.urls.values_list("language", flat=True)
for language in languages:
if not self.is_home:
self._update_url_path(language)
self._update_url_path_recursive(language)
self.clear_cache(menu=True)
return self
def _clear_placeholders(self, language):
from cms.models import CMSPlugin
placeholders = self.get_placeholders(language)
placeholder_ids = (placeholder.pk for placeholder in placeholders)
plugins = CMSPlugin.objects.filter(placeholder__in=placeholder_ids, language=language)
models.query.QuerySet.delete(plugins)
return placeholders
def get_view_restrictions(self, inherited_only=False):
"""Snapshot the ``can_view`` grants that currently protect this page.
Each entry is a ``(user_id, group_id, grant_on)`` tuple whose scope is
expressed relative to *this* page: a grant inherited from an ancestor
becomes ``ACCESS_PAGE_AND_DESCENDANTS`` when it also covers what is
below this page, and ``ACCESS_PAGE`` when it stops here. The page's own
rows keep their own scope.
"""
from cms.models import ACCESS_PAGE, ACCESS_PAGE_AND_DESCENDANTS, PagePermission
from cms.models.permissionmodels import MASK_DESCENDANTS
if not get_cms_setting("PERMISSION"):
return []
restrictions = [
(
permission.user_id,
permission.group_id,
ACCESS_PAGE_AND_DESCENDANTS if permission.grant_on & MASK_DESCENDANTS else ACCESS_PAGE,
)
for permission in PagePermission.objects.for_page(self).filter(can_view=True)
if permission.page_id != self.pk
]
if not inherited_only:
restrictions += [
(permission.user_id, permission.group_id, permission.grant_on)
for permission in self.pagepermission_set.filter(can_view=True)
]
return restrictions
def apply_view_restrictions(self, restrictions, user=None):
"""Recreate ``restrictions`` (from ``get_view_restrictions``) on this page.
Used when a page leaves the subtree its view restrictions came from: the
ancestors that protected it no longer do, so their grants have to be
materialized here. Only ``can_view`` is carried over -- the editing
privileges attached to the original rows are not privileges of the new
location. Grants the page already inherits at its new position with at
least the same scope are skipped, so repeated moves do not pile up
redundant rows.
"""
from cms.cache.permissions import clear_permission_cache
from cms.models import PagePermission
from cms.models.permissionmodels import MASK_DESCENDANTS
from cms.utils.permissions import clear_permission_lru_caches
if not restrictions or not get_cms_setting("PERMISSION"):
return []
def scope(grant_on):
return 2 if grant_on & MASK_DESCENDANTS else 1
covered = {}
for permission in PagePermission.objects.for_page(self).filter(can_view=True):
audience = (permission.user_id, permission.group_id)
covered[audience] = max(covered.get(audience, 0), scope(permission.grant_on))
new_permissions = [
PagePermission(
page=self,
user_id=user_id,
group_id=group_id,
grant_on=grant_on,
**{flag: flag == "can_view" for flag in PagePermission.get_all_permissions()},
)
for user_id, group_id, grant_on in restrictions
if scope(grant_on) > covered.get((user_id, group_id), 0)
]
if new_permissions:
PagePermission.objects.bulk_create(new_permissions)
# bulk_create does not emit the permission signals.
clear_permission_cache()
if user is not None:
clear_permission_lru_caches(user)
transaction.on_commit(clear_permission_cache)
return new_permissions
def copy(
self,
site,
parent_page=None,
parent_node=None,
language=None,
translations=True,
permissions=False,
extensions=True,
user=None,
):
from cms.models import PageContent
from cms.utils.page import get_available_slug
assert parent_page is None or isinstance(parent_page, Page), f"{parent_page} is not an instance of Page."
assert isinstance(user, get_user_model()), f"{user} is not an instance of User."
if parent_node is not None:
warnings.warn(
"Argument `parent_node` is deprecated. Use `parent_page` instead.",
RemovedInDjangoCMS60Warning,
stacklevel=2,
)
parent_page = parent_page or parent_node
if parent_page:
new_page = parent_page.add_child(site=site, login_required=self.login_required)
else:
new_page = Page.add_root(site=site, login_required=self.login_required)
new_page._state = ModelState()
new_page._clear_internal_cache()
if language and translations:
translations = self.pagecontent_set(manager="admin_manager").filter(language=language)
elif translations:
translations = self.pagecontent_set(manager="admin_manager")
else:
translations = self.pagecontent_set(manager="admin_manager").none()
translations = translations.prefetch_related("placeholders")
# copy titles of this page
for title in translations.current_content():
new_title = model_to_dict(title)
new_title.pop("id", None) # No PK
new_title["page"] = new_page
if parent_page:
base = parent_page.get_path(title.language)
path = f"{base}/{title.slug}" if base else title.slug
else:
base = ""
path = title.slug
new_title["slug"] = get_available_slug(site, path, title.language)
if title.overwrite_url:
new_title["overwrite_url"] = "{}/{}".format(base, new_title["slug"]) if base else new_title["slug"]
new_title = PageContent.objects.with_user(user).create(**new_title)
if new_title.is_public():
# The page URL is derived from public content only. With a
# versioning package installed the copy is created as a draft
# and gets its URL on first publish.
new_page.update_urls_from_content(new_title.language)
for placeholder in title.placeholders.all():
# copy the placeholders (and plugins on those placeholders!)
new_placeholder = new_title.placeholders.create(
slot=placeholder.slot,
default_width=placeholder.default_width,
)
placeholder.copy_plugins(new_placeholder, language=new_title.language)
new_page.page_content_cache[new_title.language] = new_title
if extensions:
from cms.extensions import extension_pool
extension_pool.copy_extensions(self, new_page)
# copy permissions if requested
if permissions and get_cms_setting("PERMISSION"):
permissions = self.pagepermission_set.iterator()
permissions_new = []
for permission in permissions:
permission.pk = None
permission.page = new_page
permissions_new.append(permission)
if permissions_new:
from cms.cache.permissions import clear_permission_cache
from cms.utils.permissions import clear_permission_lru_caches
new_page.pagepermission_set.bulk_create(permissions_new)
# bulk_create does not emit the permission signals.
clear_permission_cache()
clear_permission_lru_caches(user)
# A concurrent request can rebuild the cache before these rows commit.
transaction.on_commit(clear_permission_cache)
return new_page
def copy_with_descendants(
self, target_page=None, target_node=None, position=None, copy_permissions=True, target_site=None, user=None
):
"""
Copy a page [ and all its descendants to a new location ]
"""
from cms.models import PageContent
if target_node is not None:
warnings.warn(
"Argument `parent_node` is deprecated. Use `parent_page` instead.",
RemovedInDjangoCMS60Warning,
stacklevel=2,
)
target_page = target_page or target_node
if position in ("first-child", "last-child"):
parent_page = target_page
elif target_page:
parent_page = target_page.parent
else:
parent_page = None
if target_site is None:
target_site = parent_page.site if parent_page else self.site
# Evaluate the descendants queryset BEFORE copying the page.
# Otherwise, if the page is copied and pasted on itself, it will duplicate.
descendants = list(
self.get_descendant_pages().prefetch_related(
"urls",
Prefetch("pagecontent_set", queryset=PageContent.admin_manager.all()),
)
)
if copy_permissions:
# Snapshot paths before inserting the copy can move source nodes.
inherited_restrictions = self.get_view_restrictions(inherited_only=True)
new_root_page = self.copy(target_site, parent_page=parent_page, permissions=copy_permissions, user=user)
if target_page and position in ("first-child"):
# target page is a parent and user has requested to
# insert the new page as its first child
new_root_page.move(target_page, position)
new_root_page.refresh_from_db(fields=("path", "depth"))
if target_page and position in ("left", "last-child"):
# target page is a sibling
new_root_page.move(target_page, position)
new_root_page.refresh_from_db(fields=("path", "depth"))
pages_by_id = {self.id: new_root_page}
for page in descendants:
parent = pages_by_id[page.parent_id]
pages_by_id[page.id] = page.copy(
target_site, parent_page=parent, translations=True, permissions=copy_permissions, user=user
)
if copy_permissions:
# Ancestors outside the copied subtree will no longer protect it.
new_root_page.apply_view_restrictions(inherited_restrictions, user=user)
return new_root_page
def delete(self, *args, **kwargs):
using = self._state.db or router.db_for_write(Page, instance=self)
Page.get_tree(self).using(using).delete_fast()
if self.parent_id:
Page.objects.using(using).filter(id=self.parent_id).update(
numchild=models.F("numchild") - 1
)
self.clear_cache(menu=True)
def delete_translations(self, language=None):
if language is None:
languages = self.get_languages()
else:
languages = [language]
self.pagecontent_set(manager="admin_manager").filter(language__in=languages).delete()
def save(self, **kwargs):
if self.reverse_id == "":
self.reverse_id = None
if self.application_namespace == "":
self.application_namespace = None
created = not bool(self.pk)
from cms.utils.permissions import get_current_user_name
self.changed_by = get_current_user_name()
if created:
self.created_by = self.changed_by
super().save(**kwargs)
def update(self, refresh=False, **data):
cls = self.__class__
cls.objects.filter(pk=self.pk).update(**data)
if refresh:
return self.reload()
else:
for field, value in data.items():
setattr(self, field, value)
return
def update_translations(self, language=None, **data):
if language:
translations = self.pagecontent_set.filter(language=language)
else:
translations = self.pagecontent_set.all()
return translations.update(**data)
def has_translation(self, language):
return self.pagecontent_set.filter(language=language).exists()
def clear_cache(self, language=None, menu=False, placeholder=False):
from cms.cache import invalidate_cms_page_cache
if get_cms_setting("PAGE_CACHE"):
# Clears all the page caches
invalidate_cms_page_cache()
if placeholder and get_cms_setting("PLACEHOLDER_CACHE"):
assert language, "language is required when clearing placeholder cache"
placeholders = self.get_placeholders(language)
for placeholder_instance in placeholders:
placeholder_instance.clear_cache(language, site_id=self.site_id)
if menu:
# Clears all menu caches for this page's site
menu_pool.clear(site_id=self.site_id)
def get_child_pages(self):
return self.get_children().order_by("path")
def get_ancestor_pages(self):
return self.get_ancestors().order_by("path")
def get_descendant_pages(self):
return self.get_descendants().order_by("path")
def get_root(self):
return self.__class__.objects.using(self._state.db).get(
path=self.path[0 : self.steplen]
)
def get_parent_page(self):
warnings.warn(
"Method `get_parent_page()` is deprecated. Instead use the `parent` attribute.",
RemovedInDjangoCMS60Warning,
stacklevel=2,
)
return self.parent
@property
def languages(self):
warnings.warn(
"Attribute `languages` is deprecated. Use `get_languages` instead.",
RemovedInDjangoCMS60Warning,
stacklevel=2,
)
return ",".join(self.get_languages())
def get_languages(self, admin_manager=True):
"""Returns available languages for the page. This is potentially costly."""
if admin_manager:
if self.admin_content_cache is None:
self.set_admin_content_cache()
return list(self.admin_content_cache.keys())
if not self.page_content_cache:
self._get_page_content_cache(get_language(), fallback=False, force_reload=False)
return list(self.page_content_cache.keys())
def remove_language(self, language):
warnings.warn(
"Method `remove_language` is deprecated and has no effect any more.",
RemovedInDjangoCMS60Warning,
stacklevel=2,
)
def update_languages(self, languages):
warnings.warn(
"Method `update_languages` is deprecated and has no effect any more.",
RemovedInDjangoCMS60Warning,
stacklevel=2,
)
def get_published_languages(self):
warnings.warn(
"Method `get_published_languages` is deprecated. Use `get_languages(admin_manager=False)` instead.",
RemovedInDjangoCMS60Warning,
stacklevel=2,
)
return self.get_languages(admin_manager=False)
def set_translations_cache(self):
warnings.warn(
"Method `set_translations_cache` is deprecated. Use `get_content_obj` instead. "
"For admin views use `set_admin_content_cache` instead.",
RemovedInDjangoCMS60Warning,
stacklevel=2,
)
for translation in self.pagecontent_set.all():
self.page_content_cache.setdefault(translation.language, translation)
def set_admin_content_cache(self):
self.admin_content_cache = AdminCacheDict()
for translation in self.pagecontent_set(manager="admin_manager").latest_content().all():
self.admin_content_cache.setdefault(translation.language, translation)
def get_admin_content(self, language, fallback=False):
from cms.models.contentmodels import EmptyPageContent
if self.admin_content_cache is None:
self.set_admin_content_cache()
page_content = self.admin_content_cache.get(language, EmptyPageContent(language=language, page=self))
if not page_content and fallback:
for lang in i18n.get_fallback_languages(language, site_id=self.site_id):
page_content = self.admin_content_cache.get(lang)
if page_content:
return page_content
page_content = EmptyPageContent(language=language, page=self)
if fallback == "force":
# Try any page content object
for item in self.admin_content_cache.values():
if item:
return item
return page_content
def get_path_for_slug(self, slug, language):
if self.is_home:
return ""
if self.parent:
if self.parent.is_home:
# children of the home page live directly at the root
path = slug
else:
base = self.parent.get_path(language, fallback=True)
# a non-home parent without a path (e.g. unpublished) means
# this page has no reachable path either
path = f"{base}/{slug}" if base else None
else:
path = slug
return path
def get_url_data(self, slug, overwrite_url, language):
"""Derive the :class:`~cms.models.pagemodel.PageUrl` fields (``slug``,
``path``, ``managed``) from an authored slug and overwrite URL."""
if overwrite_url and not self.is_home:
path = overwrite_url.strip("/")
else:
# the home page always lives at the root path
path = self.get_path_for_slug(slug, language)
return {
"slug": slug,
"path": path,
"managed": not bool(overwrite_url),
}
def get_url(self, language):
return self.get_urls().get(language=language)
def get_urls(self):
return self.urls.all()
def update_urls(self, language=None, **data):
if language:
page_urls_qs = self.get_urls().filter(language=language)
else:
page_urls_qs = self.get_urls().all()
if "path" in data and data["path"] is not None:
new_path = data["path"]
languages_to_check = (
[language] if language else list(page_urls_qs.values_list("language", flat=True).distinct())
)
for lang in languages_to_check:
# Check if the new path exists for this language on another page on the same site
existing_url_collision = (
PageUrl.objects.filter(
language=lang,
path=new_path,
site=self.site_id,
)
.exclude(pk__in=page_urls_qs.filter(language=lang).values("pk"))
.exists()
)
if existing_url_collision:
page_pks_being_updated = list(
page_urls_qs.filter(language=lang).values_list("page__pk", flat=True)
)
raise IntegrityError(
f"Cannot update URL path. A page URL with path='{new_path}' "
f"and language='{lang}' already exists for another page "
f"on the same site (not page(s): {page_pks_being_updated})."
)
# If no collision detected, proceed with the update
return page_urls_qs.update(**data)
def update_urls_from_content(self, language):
"""Derive this page's :class:`~cms.models.pagemodel.PageUrl` for ``language``
from the publicly visible :class:`~cms.models.contentmodels.PageContent`
object, then update the paths of all descendant pages.
The slug and overwrite URL are authored on the page content object.
``PageUrl`` is a routing table that must only ever reflect published
content: without a versioning package every saved content is public and
this runs on every save; with a versioning package installed it must be
called when a version is published or unpublished.
When no public content exists, the URL's path is set to ``None`` so the
page stops resolving while the slug stays reserved for the page.
Raises ``IntegrityError`` if the derived path collides with the URL of
another page on the same site.
"""
from cms.models import PageContent
content = PageContent.objects.filter(page=self, language=language).first()
url = self.urls.filter(language=language).first()
if content is None:
if url is None or url.path is None:
return
self.urls.filter(language=language).update(path=None)
else:
data = self.get_url_data(content.slug, content.overwrite_url, language)
path = data["path"]
if url is not None:
if (url.slug, url.path, url.managed) == (data["slug"], data["path"], data["managed"]):
# the URL already reflects the content; descendant paths derive
# from this page's path, so they are up to date as well
return
self.update_urls(language, **data) # includes the path collision check
else:
collision = (
path is not None
and PageUrl.objects.filter(language=language, path=path, site=self.site_id).exists()
)
if collision:
raise IntegrityError(
f"Cannot create URL. A page URL with path='{path}' and "
f"language='{language}' already exists for another page on the same site."
)
self.urls.create(page=self, site_id=self.site_id, language=language, **data)
self.urls_cache = {}
self._update_url_path_recursive(language)
def get_fallbacks(self, language):
return i18n.get_fallback_languages(language, site_id=self.site_id)
# ## PageContent object access
def get_content_obj(self, language=None, fallback=True, force_reload=False):
"""Helper function for accessing wanted / current title.
If wanted title doesn't exist, EmptyPageContent instance will be returned.
"""
language = self._get_page_content_cache(language, fallback, force_reload)
if language in self.page_content_cache:
return self.page_content_cache[language]
from cms.models import EmptyPageContent
return EmptyPageContent(language=language, page=self)
def get_page_content_obj_attribute(self, attrname, language=None, fallback=True, force_reload=False):
"""Helper function for getting attribute or None from wanted/current page content."""
try:
attribute = getattr(self.get_content_obj(language, fallback, force_reload), attrname)
return attribute
except AttributeError:
return None
def get_url_obj(self, language, fallback=True):
"""Get the path of the page depending on the given language"""
languages = [language]
if fallback:
languages.extend(self.get_fallbacks(language))
if self.urls_cache is None or language not in self.urls_cache:
# `get_page_from_request` will fill the cache only for the current language
# Here, we fully fill it and try again
self.urls_cache = {url.language: url for url in self.urls.all() if url.language in languages}
return next((self.urls_cache[lang] for lang in languages if lang in self.urls_cache), None)
def get_path(self, language, fallback=True):
url = self.get_url_obj(language, fallback)
if url:
return url.path
return None
def get_slug(self, language, fallback=True):
url = self.get_url_obj(language, fallback)
if url:
return url.slug
return None
def get_title(self, language=None, fallback=True, force_reload=False):
"""
get the title of the page depending on the given language
"""
return self.get_page_content_obj_attribute("title", language, fallback, force_reload)
def get_menu_title(self, language=None, fallback=True, force_reload=False):
"""
get the menu title of the page depending on the given language
"""
menu_title = self.get_page_content_obj_attribute("menu_title", language, fallback, force_reload)
if not menu_title:
return self.get_title(language, True, force_reload)
return menu_title
def get_placeholders(self, language: str, admin_manager: bool = False) -> models.QuerySet:
from cms.models import PageContent, Placeholder
if admin_manager:
# Use the admin manager to get the current content (i.e. potentially not yet published content)
page_content = PageContent.admin_manager.current_content().get(language=language, page=self)
else:
page_content = PageContent.objects.get(language=language, page=self)
return Placeholder.objects.get_for_obj(page_content)
def get_changed_date(self, language=None, fallback=True, force_reload=False):
"""
get when this page was last updated
"""
return self.get_page_content_obj_attribute("changed_date", language, fallback, force_reload)
def get_changed_by(self, language=None, fallback=True, force_reload=False):
"""
get user who last changed this page
"""
return self.get_page_content_obj_attribute("changed_by", language, fallback, force_reload)
def get_page_title(self, language=None, fallback=True, force_reload=False):
"""
get the page title of the page depending on the given language
"""
page_title = self.get_page_content_obj_attribute("page_title", language, fallback, force_reload)
if not page_title:
return self.get_title(language, True, force_reload)
return page_title
def get_meta_description(self, language=None, fallback=True, force_reload=False):
"""
get content for the description meta tag for the page depending on the given language
"""
return self.get_page_content_obj_attribute("meta_description", language, fallback, force_reload)
def get_application_urls(self, language=None, fallback=True, force_reload=False):
"""
get application urls conf for application hook
"""
return self.application_urls
def get_redirect(self, language=None, fallback=True, force_reload=False):
"""
get redirect
"""
return self.get_page_content_obj_attribute("redirect", language, fallback, force_reload)
def _get_page_content_cache(self, language, fallback, force_reload):
"""
Sets the internal page object cache for page content objects available to the general user.
It optionally respe
"""
def get_fallback_language(page, language):
fallback_langs = i18n.get_fallback_languages(language, site_id=page.site_id)
for lang in fallback_langs:
if page.page_content_cache.get(lang):
return lang
if not language:
language = get_language()
# Update page_content_cache from _prefetched_objects_cache if available
prefetch_cache = getattr(self, "_prefetched_objects_cache", {})
cached_page_content = prefetch_cache.get("pagecontent_set", [])
for page_content in cached_page_content:
self.page_content_cache[page_content.language] = page_content
# Reload if explicitly needed or language not in content cache
if force_reload or language not in self.page_content_cache:
for page_content in self.pagecontent_set.all():
self.page_content_cache[page_content.language] = page_content
if self.page_content_cache.get(language):
return language
use_fallback = all(
[fallback, not self.page_content_cache.get(language), get_fallback_language(self, language)]
)
if use_fallback:
# language can be in the cache but might be an EmptyPageContent instance
return get_fallback_language(self, language)
return language
@property
def template(self):
return self.get_page_content_obj_attribute("template")
@property
def soft_root(self):
return self.get_page_content_obj_attribute("soft_root")
def get_template(self, language=None, fallback=True, force_reload=False):
content = self.get_content_obj(language, fallback, force_reload)
if content:
return content.get_template(app_hooks=False)
return get_cms_setting("TEMPLATES")[0][0] if get_cms_setting("TEMPLATES") else ""
def get_template_name(self):
"""
get the textual name (2nd parameter in get_cms_setting('TEMPLATES'))
of the template of this page or of the nearest
ancestor. failing to find that, return the name of the default template.
"""
template = self.get_template()
for t in get_cms_setting("TEMPLATES"):
if t[0] == template:
return t[1]
return _("default")
def has_view_permission(self, user):
from cms.utils.page_permissions import user_can_view_page
return user_can_view_page(user, page=self)
def has_view_restrictions(self, site):
from cms.models import PagePermission
if get_cms_setting("PERMISSION"):
restrictions = PagePermission.objects.for_page(self).filter(can_view=True)
return restrictions.exists()
return False
def has_add_permission(self, user):
"""
Has user ability to add page under current page?
"""
from cms.utils.page_permissions import user_can_add_subpage
return user_can_add_subpage(user, self)
def has_change_permission(self, user):
from cms.utils.page_permissions import user_can_change_page
return user_can_change_page(user, page=self)
def has_delete_permission(self, user):
from cms.utils.page_permissions import user_can_delete_page
return user_can_delete_page(user, page=self)
def has_delete_translation_permission(self, user, language):
from cms.utils.page_permissions import user_can_delete_page_translation
return user_can_delete_page_translation(user, page=self, language=language)
def has_publish_permission(self, user):
from cms.utils.page_permissions import user_can_publish_page
return user_can_publish_page(user, page=self)
def has_advanced_settings_permission(self, user):
from cms.utils.page_permissions import user_can_change_page_advanced_settings
return user_can_change_page_advanced_settings(user, page=self)
def has_change_permissions_permission(self, user):
"""
Has user ability to change permissions for current page?
"""
from cms.utils.page_permissions import user_can_change_page_permissions
return user_can_change_page_permissions(user, page=self)
def has_move_page_permission(self, user):
"""Has user ability to move current page?"""
from cms.utils.page_permissions import user_can_move_page
return user_can_move_page(user, page=self)
def get_media_path(self, filename):
"""
Returns path (relative to MEDIA_ROOT/MEDIA_URL) to directory for storing
page-scope files. This allows multiple pages to contain files with
identical names without namespace issues. Plugins such as Picture can
use this method to initialise the 'upload_to' parameter for File-based
fields. For example:
image = models.ImageField(
_("image"), upload_to=CMSPlugin.get_media_path)
where CMSPlugin.get_media_path calls self.page.get_media_path
This location can be customised using the CMS_PAGE_MEDIA_PATH setting
"""
return join(get_cms_setting("PAGE_MEDIA_PATH"), "%d" % self.pk, filename)
def reload(self):
"""
Reload a page from the database
"""
return self.__class__.objects.get(pk=self.pk)
def rescan_placeholders(self, language):
return self.get_content_obj(language=language).rescan_placeholders()
def get_declared_placeholders(self, language=None, fallback=True, force_reload=False):
from cms.utils.placeholder import get_declared_placeholders_for_obj
content = self.get_content_obj(language, fallback, force_reload)
if content:
return get_declared_placeholders_for_obj(content)
return []
def get_xframe_options(self, language=None, fallback=True, force_reload=False):
content = self.get_content_obj(language, fallback, force_reload)
if content:
return content.get_xframe_options()
def get_soft_root(self, language=None, fallback=True, force_reload=False):
return self.get_page_content_obj_attribute("soft_root", language, fallback, force_reload)
def get_in_navigation(self, language=None, fallback=True, force_reload=False):
return self.get_page_content_obj_attribute("in_navigation", language, fallback, force_reload)
def get_limit_visibility_in_menu(self, language=None, fallback=True, force_reload=False):
return self.get_page_content_obj_attribute("limit_visibility_in_menu", language, fallback, force_reload)
class PageUrl(models.Model):
slug = models.SlugField(_("slug"), max_length=255, db_index=True)
path = models.CharField(_("Path"), max_length=255, db_index=True, null=True)
language = models.CharField(_("language"), max_length=15, db_index=True)
page = models.ForeignKey(
Page,
on_delete=models.CASCADE,
verbose_name=_("page"),
related_name="urls",
)
#: Denormalized from ``page.site`` so path uniqueness per site can be
#: enforced by the database.
site = models.ForeignKey(
Site,
on_delete=models.CASCADE,
verbose_name=_("site"),
related_name="djangocms_urls",
editable=False,
)
managed = models.BooleanField(default=False)
objects = PageUrlManager()
class Meta:
app_label = "cms"
default_permissions = []
constraints = [
UniqueConstraint(fields=["page", "language"], name="unique_together_page_language"),
# NULL paths (unreachable pages) are distinct on all supported
# backends, so any number of them may coexist.
UniqueConstraint(fields=["site", "language", "path"], name="unique_site_language_path"),
]
def __str__(self):
return f"{self.path or self.slug} ({self.language})"
def save(self, *args, **kwargs):
if self.site_id is None:
self.site_id = self.page.site_id
super().save(*args, **kwargs)
def get_absolute_url(self, language=None, fallback=True):
if not language:
language = get_current_language()
with force_language(language):
try:
if self.path == "":
return reverse("pages-root")
return reverse("pages-details-by-slug", kwargs={"slug": self.path})
except NoReverseMatch:
return None
def get_path_for_base(self, base_path=""):
old_base, sep, slug = self.path.rpartition("/")
return f"{base_path}/{slug}" if base_path else slug
class PageType(Page):
class Meta:
proxy = True
default_permissions = []
@classmethod
def get_root_page(cls, site):
pages = Page.objects.on_site(site).filter(
depth=1,
is_page_type=True,
)
return pages.first()
def is_potential_home(self):
return False