Source code for feincms3.pages

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 []