|
|
|
from functools import reduce
|
|
|
|
|
|
|
|
from django.conf import settings
|
|
|
|
from django.core import validators
|
|
|
|
from django.db import models
|
|
|
|
from django.db.models import fields as django_fields
|
|
|
|
from django.db.models.signals import pre_save, post_save
|
|
|
|
from django.dispatch import receiver
|
|
|
|
from django.utils.translation import gettext_lazy as _
|
|
|
|
|
|
|
|
from dirtyfields import DirtyFieldsMixin
|
|
|
|
|
|
|
|
from ..text import slugify, downgrading_slugify
|
|
|
|
|
|
|
|
|
|
|
|
# TODO: Use english
|
|
|
|
SLUG_HELP = _("Kurzfassung des Namens für die Adresszeile im Browser. Vorzugsweise englisch, keine Umlaute, nur Bindestrich als Sonderzeichen.")
|
|
|
|
|
|
|
|
|
|
|
|
slug_re = validators._lazy_re_compile(r'^[-a-z0-9]+\Z')
|
|
|
|
validate_downgraded_slug = validators.RegexValidator(
|
|
|
|
slug_re,
|
|
|
|
_("Enter a valid 'slug' consisting of lower-case letters, numbers or hyphens."),
|
|
|
|
'invalid'
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
class AutoSlugField(django_fields.SlugField):
|
|
|
|
"""
|
|
|
|
SlugField which optionally populates the value and/or makes sure that
|
|
|
|
the value is unique. By default as stricter slugify function is used.
|
|
|
|
|
|
|
|
populate_from: Field name
|
|
|
|
unique_slug: Boolean, automatically make the field value unique
|
|
|
|
"""
|
|
|
|
|
|
|
|
def __init__(self, *args, **kwargs):
|
|
|
|
kwargs.setdefault('max_length', 50)
|
|
|
|
kwargs.setdefault('help_text', SLUG_HELP)
|
|
|
|
if 'populate_from' in kwargs:
|
|
|
|
self.populate_from = kwargs.pop('populate_from')
|
|
|
|
self.unique_slug = kwargs.pop('unique_slug', False)
|
|
|
|
# FIXME Enforce unique=True
|
|
|
|
# if self.unique_slug:
|
|
|
|
# kwargs['unique'] = True
|
|
|
|
# TODO Refactor: We need a sibling FormField which also does our pre_save work and then validates correctly (called from Model.clean_fields)
|
|
|
|
super(AutoSlugField, self).__init__(*args, **kwargs)
|
|
|
|
|
|
|
|
def slugify(self, value):
|
|
|
|
return slugify(value)
|
|
|
|
|
|
|
|
def pre_save(self, model_instance, add):
|
|
|
|
value = getattr(model_instance, self.attname)
|
|
|
|
if not value:
|
|
|
|
if hasattr(self, 'populate_from'):
|
|
|
|
if callable(self.populate_from):
|
|
|
|
value = self.populate_from(model_instance, self)
|
|
|
|
else:
|
|
|
|
# Follow dotted path (e.g. "occupation.corporation.name")
|
|
|
|
value = reduce(lambda obj, attr: getattr(obj, attr),
|
|
|
|
self.populate_from.split("."), model_instance)
|
|
|
|
if callable(value):
|
|
|
|
value = value()
|
|
|
|
value = self.slugify(value)
|
|
|
|
if not value and not self.blank:
|
|
|
|
value = model_instance._meta.model_name
|
|
|
|
if self.unique_slug:
|
|
|
|
# TODO Move import to top of file once AutoSlugField is removed from shared.utils.fields and we no longer have a circular import
|
|
|
|
from ..fields import uniquify_field_value
|
|
|
|
return uniquify_field_value(
|
|
|
|
model_instance, self.name, value, max_length=self.max_length)
|
|
|
|
else:
|
|
|
|
return value
|
|
|
|
|
|
|
|
|
|
|
|
class DowngradingSlugField(AutoSlugField):
|
|
|
|
"""
|
|
|
|
SlugField which allows only lowercase ASCII characters and the dash,
|
|
|
|
automatically downgrading/replacing the entered content.
|
|
|
|
"""
|
|
|
|
|
|
|
|
default_validators = [validate_downgraded_slug]
|
|
|
|
|
|
|
|
def __init__(self, *args, **kwargs):
|
|
|
|
kwargs['allow_unicode'] = False
|
|
|
|
super(DowngradingSlugField, self).__init__(*args, **kwargs)
|
|
|
|
|
|
|
|
def slugify(self, value):
|
|
|
|
return downgrading_slugify(value)
|
|
|
|
|
|
|
|
def to_python(self, value):
|
|
|
|
# Downgrade immediately so that validators work
|
|
|
|
value = super().to_python(value)
|
|
|
|
return self.slugify(value)
|
|
|
|
|
|
|
|
def formfield(self, **kwargs):
|
|
|
|
# Remove the slug validator from the form field so that we can modify
|
|
|
|
# the field value in the model
|
|
|
|
field = super().formfield(**kwargs)
|
|
|
|
if field.default_validators:
|
|
|
|
try:
|
|
|
|
field.validators.remove(field.default_validators[0])
|
|
|
|
except ValueError:
|
|
|
|
pass
|
|
|
|
return field
|
|
|
|
|
|
|
|
|
|
|
|
class SlugTreeMixin(DirtyFieldsMixin, models.Model):
|
|
|
|
"""
|
|
|
|
Expects a `slug` and a `parent` field.
|
|
|
|
|
|
|
|
`has_url`: The node itself has an URL. It is possible that a node does
|
|
|
|
node have an URL, but its children have.
|
|
|
|
|
|
|
|
Maintains the `slug_path` field of the node and its children, watching if
|
|
|
|
either the `slug`, `parent` or `has_url` fields have changed.
|
|
|
|
|
|
|
|
"""
|
|
|
|
slug_path = models.CharField(_("URL path"),
|
|
|
|
unique=True, max_length=2000, editable=False)
|
|
|
|
# TODO Add validator to slug_path?
|
|
|
|
# validators=[
|
|
|
|
# RegexValidator(
|
|
|
|
# regex=r"^/(|.+/)$",
|
|
|
|
# message=_("Path must start and end with a slash (/)."),
|
|
|
|
# )
|
|
|
|
# ],
|
|
|
|
# TODO Make slug_path manually overridable (see feincms3 -> use_static_path)
|
|
|
|
has_url = models.BooleanField(_("has webaddress"), default=True)
|
|
|
|
|
|
|
|
FIELDS_TO_CHECK = ['slug', 'parent', 'has_url']
|
|
|
|
|
|
|
|
class Meta:
|
|
|
|
abstract = True
|
|
|
|
|
|
|
|
def _get_slug_path(self):
|
|
|
|
if self.pk:
|
|
|
|
ancestors = self.get_ancestors(include_self=False).filter(has_url=True).values_list('slug', flat=True)
|
|
|
|
parts = list(ancestors)
|
|
|
|
else:
|
|
|
|
parts = []
|
|
|
|
if self.slug:
|
|
|
|
parts += [self.slug]
|
|
|
|
return "/".join(parts)
|
|
|
|
|
|
|
|
def _rebuild_descendants_slug_path(self):
|
|
|
|
for p in self.get_descendants():
|
|
|
|
p.slug_path = p._get_slug_path()
|
|
|
|
p.save()
|
|
|
|
|
|
|
|
|
|
|
|
@receiver(pre_save)
|
|
|
|
def slug_tree_mixin_pre_save(sender, instance, **kwargs):
|
|
|
|
if isinstance(instance, SlugTreeMixin):
|
|
|
|
# FIXME: find a way to not always call this
|
|
|
|
instance.slug = instance._meta.get_field('slug').pre_save(instance, False)
|
|
|
|
instance.slug_path = instance._get_slug_path()
|
|
|
|
|
|
|
|
|
|
|
|
@receiver(post_save)
|
|
|
|
def slug_tree_mixin_post_save(sender, instance, **kwargs):
|
|
|
|
if isinstance(instance, SlugTreeMixin):
|
|
|
|
if kwargs.get('created'):
|
|
|
|
# Always get a new database instance before saving again
|
|
|
|
# or MPTTModel.save() will interpret the newly .save as
|
|
|
|
# a forbidden tree move action
|
|
|
|
# FIXME Not clear if this is a proper solution -> get rid of the slug_path stuff altogether
|
|
|
|
instance_copy = type(instance).objects.get(pk=instance.pk)
|
|
|
|
instance_copy.slug_path = instance_copy._get_slug_path()
|
|
|
|
if 'slug_path' in instance_copy.get_dirty_fields().keys():
|
|
|
|
instance_copy.save()
|
|
|
|
elif instance.get_dirty_fields().keys() & {'slug_path'}:
|
|
|
|
instance._rebuild_descendants_slug_path()
|