from collections import OrderedDict
from django.core.checks import Warning
from django.core.validators import MinValueValidator, RegexValidator
from django.db import models
from django.db.models import Max, Q
from django.urls import NoReverseMatch, get_script_prefix, reverse
from django.utils.encoding import iri_to_uri
from django.utils.translation import gettext_lazy as _
from tree_queries.models import TreeNode, TreeQuerySet
from feincms3.utils import validation_error
[docs]
def path_with_script_prefix(path):
"""
Return ``path`` prefixed with the current script prefix
"""
# See django/contrib/flatpages/models.py
return iri_to_uri(get_script_prefix().rstrip("/") + path)
[docs]
class AbstractPageQuerySet(TreeQuerySet):
"""
Defines a single method, ``active``, which only returns pages with
``is_active=True``.
"""
[docs]
def active(self):
"""
Return only active pages
This function is used in :func:`~feincms3.applications.apps_urlconf`
and is the recommended way to fetch active pages in your code as well.
"""
return self.filter(is_active=True)
[docs]
def applications(self):
"""
Helper for transforming a queryset into the apps format
:func:`~feincms3.applications.apps_urlconf` expects. The queryset isn't
filtered (on purpose) so you have to apply the ``.active()`` filtering
yourself.
"""
fields = ("path", "page_type", "app_namespace", "language_code")
return list(
self.without_tree_fields()
.exclude(app_namespace="")
.values_list(*fields)
.order_by(*fields)
)
[docs]
class AbstractPage(TreeNode):
"""
Short version: If you want to build a CMS with a hierarchical page
structure, use this base class.
It comes with the following fields:
- ``parent``: (a nullable tree foreign key) and a ``position`` field for
relatively ordering pages. While it is technically possible for
``position`` to be 0, e.g., data bulk imported from another CMS, it is not
recommended, as the ``save()`` method will override values of 0 if you
manipulate pages using the ORM.
- ``is_active``: Boolean field. The ``save()`` method ensures that inactive
pages never have any active descendants.
- ``title`` and ``slug``
- ``path``: The complete path to the page, starting and ending with a
slash. The maximum length of path (1000) should be enough for everyone
(tm, famous last words). This field also has a unique index, which means
that MySQL with its low limit on unique indexes will not work with this
base class. Sorry.
- ``static_path``: A boolean which, when ``True``, allows you to fill in
the ``path`` field all by yourself. By default, ``save()`` ensures that
the ``path`` fields are always composed of a concatenation of the
parent's ``path`` with the page's own ``slug`` (with slashes of course).
This is especially useful for root pages (set ``path`` to ``/``) or,
when building a multilingual site, for language root pages (i.e.
``/en/``, ``/de/``, ``/pt-br/`` etc.)
"""
is_active = models.BooleanField(_("is active"), default=True)
title = models.CharField(_("title"), max_length=200)
slug = models.SlugField(_("slug"))
position = models.PositiveIntegerField(
db_index=True,
editable=False,
validators=[
MinValueValidator(
limit_value=1,
message=_("Position is expected to be greater than zero."),
)
],
)
# Who even cares about MySQL
path = models.CharField(
_("path"),
max_length=1000,
blank=True,
unique=True,
help_text=_(
"Automatically generated by concatenating the parent's path and"
" the slug unless the path is defined manually."
),
validators=[
RegexValidator(
regex=r"^/(|.+/)$",
message=_("Path must start and end with a slash (/)."),
)
],
)
static_path = models.BooleanField(_("manually define the path"), default=False)
objects = AbstractPageQuerySet.as_manager(with_tree_fields=True)
class Meta:
abstract = True
ordering = ("position",)
verbose_name = _("page")
verbose_name_plural = _("pages")
def __str__(self):
return self.title
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Go through self.__dict__ to avoid triggering deferred field loading
self._save_descendants_cache = (
self.__dict__.get("is_active"),
self.__dict__.get("path"),
)
def _branch_for_update(self):
nodes = OrderedDict({self.pk: self})
for node in self.descendants():
# Assign already-updated instance:
node.parent = nodes[node.parent_id]
if not node.static_path:
node.path = f"{node.parent.path}{node.slug}/"
# Descendants of inactive nodes cannot be active themselves:
if not node.parent.is_active:
node.is_active = False
nodes[node.id] = node
return nodes
def _clash_candidates(self):
# Hook used in feincms3-sites and feincms3-language-sites
return self.__class__._default_manager
def _path_clash_candidates(self):
return self._clash_candidates().exclude(
Q(pk__in=self.descendants(), static_path=False) | Q(pk=self.pk)
)
[docs]
def clean_fields(self, exclude=None):
"""
Check for path uniqueness problems.
"""
super().clean_fields(exclude)
if self.static_path:
if not self.path:
raise validation_error(
_("Static paths cannot be empty. Did you mean '/'?"),
field="path",
exclude=exclude,
)
else:
self.path = "{}{}/".format(
self.parent.path if self.parent else "/", self.slug
)
super().clean()
# Skip if we don't exist yet.
if not self.pk:
return
clash_candidates = dict(self._path_clash_candidates().values_list("path", "id"))
for node in self._branch_for_update().values():
if (
node.path in clash_candidates
and not clash_candidates[node.path] == node.pk
):
raise validation_error(
_("The page %(page)s's new path %(path)s would not be unique.")
% {"page": node, "path": node.path},
field="path",
exclude=exclude,
)
[docs]
def save(self, *args, **kwargs):
"""save(self, ..., save_descendants=None)
Saves the page instance, and traverses all descendants to update their
``path`` fields and ensure that inactive pages (``is_active=False``)
never have any descendants with ``is_active=True``.
By default, descendants are only updated when any of ``is_active`` and
``path`` change. This can be overridden by either forcing updates using
``save_descendants=True`` or skipping them using
``save_descendants=False``.
"""
save_descendants = kwargs.pop("save_descendants", None)
if not self.static_path:
self.path = "{}{}/".format(
self.parent.path if self.parent else "/", self.slug
)
if not self.position:
self.position = 10 + (
self.__class__._default_manager.filter(parent_id=self.parent_id)
.order_by()
.aggregate(p=Max("position"))["p"]
or 0
)
super().save(*args, **kwargs)
if save_descendants is True or (
save_descendants is None
and (self.is_active, self.path) != self._save_descendants_cache
):
for pk, node in self._branch_for_update().items():
if pk == self.pk:
continue
node.save(save_descendants=False)
save.alters_data = True
[docs]
def get_absolute_url(self):
"""
Return the page's absolute URL
If path is ``/``, reverses ``pages:root`` without any arguments,
alternatively reverses ``pages:page`` with an argument of ``path``.
Note that this ``path`` is not the same as ``self.path`` -- slashes
are stripped from the beginning and the end of the string to make
building an URLconf more straightforward.
If any ``reverse()`` call fails it falls back to returning
``self.path`` prefixed with the script prefix which is ``/`` in the
standard case.
"""
try:
if self.path == "/":
return reverse("pages:root")
return reverse("pages:page", kwargs={"path": self.path.strip("/")})
except NoReverseMatch:
return path_with_script_prefix(self.path)
@classmethod
def check(cls, **kwargs):
errors = super().check(**kwargs)
errors.extend(cls._check_feincms3_pages_default_ordering(**kwargs))
return errors
@classmethod
def _check_feincms3_pages_default_ordering(cls, **kwargs):
if tuple(cls._meta.ordering) != ("position",):
return [
Warning(
"The page subclass isn't ordered by `position`.",
hint=(
'Define `ordering = ("position",)` when defining your own'
" `class Meta` on subclassed pages."
),
obj=cls,
id="feincms3.W001",
),
]
return []