diff --git a/CHANGES b/CHANGES index f319300..b64c658 100644 --- a/CHANGES +++ b/CHANGES @@ -1 +1,11 @@ -Added PageTitlesFunctionMixin. +Next +- Improved slug functions, slug fields (DowngradingSlugField). +- Move location of SLUG_HELP, DEFAULT_SLUG and AutoSlugField, now in shared.utils.models.slugs. +- Added SlugTreeMixin and helper signal functions. +- __str__ function for PageTitlesMixin + +0.2.4 +- Added RuntimeMixin. + +0.2.3 +- Added PageTitlesFunctionMixin. diff --git a/shared/utils/__init__.py b/shared/utils/__init__.py index f3d0159..d760fd3 100644 --- a/shared/utils/__init__.py +++ b/shared/utils/__init__.py @@ -10,11 +10,3 @@ except ImportError: VERSION = __version__.split('+') VERSION = tuple(list(map(int, VERSION[0].split('.'))) + VERSION[1:]) - - -try: - from django.utils.translation import ugettext_lazy as _ - - SLUG_HELP = _("Kurzfassung des Namens für die Adresszeile im Browser. Vorzugsweise englisch, keine Umlaute, nur Bindestrich als Sonderzeichen.") -except ImportError: - pass diff --git a/shared/utils/fields.py b/shared/utils/fields.py index 386cc25..888b014 100644 --- a/shared/utils/fields.py +++ b/shared/utils/fields.py @@ -1,83 +1,55 @@ # -*- coding: utf-8 -*- from __future__ import unicode_literals -# Erik Stein , 2008-2015 import re -from django.db.models import fields -from django.utils import six -from django.utils.translation import ugettext_lazy as _ -if six.PY3: - from functools import reduce -from .text import slugify_long as slugify -from . import SLUG_HELP +from .text import slugify -DEFAULT_SLUG = _("item") +# TODO Remove deprecated location +from .models.slugs import AutoSlugField -def unique_slug(instance, slug_field, slug_value, max_length=50, queryset=None): - """ - TODO Doesn't work with model inheritance, where the slug field is part of the parent class. +def uniquify_field_value(instance, field_name, value, max_length=None, queryset=None): """ - if not slug_value: - raise ValueError("Cannot uniquify empty slug") - orig_slug = slug = slugify(slug_value) - index = 0 - if not queryset: - queryset = instance.__class__._default_manager.get_queryset() + Makes a char field value unique by appending an index, taking care of the + field's max length. - def get_similar_slugs(slug): + FIXME Doesn't work with model inheritance, where the field is part of the parent class. + """ + def get_similar_values(value): return queryset.exclude(pk=instance.pk) \ - .filter(**{"%s__istartswith" % slug_field: slug}).values_list(slug_field, flat=True) + .filter(**{"%s__istartswith" % field_name: value}).values_list(field_name, flat=True) - similar_slugs = list(get_similar_slugs(slug)) - while slug in similar_slugs or len(slug) > max_length: + if not value: + raise ValueError("Cannot uniquify empty value") + # TODO Instead get value from instance.field, or use a default value? + if not max_length: + max_length = instance._meta.get_field(field_name).max_length + if not queryset: + queryset = instance._meta.default_manager.get_queryset() + + # Find already existing counter + m = re.match(r'(.+)(-\d+)$', value) + if m: + base_value, counter = m.groups() + index = int(counter.strip("-")) + 1 + else: + base_value = value + index = 2 # Begin appending "-2" + + similar_values = get_similar_values(value) + while value in similar_values or len(value) > max_length: + value = "%s-%i" % (base_value, index) + if len(value) > max_length: + base_value = base_value[:-(len(value) - max_length)] + value = "%s-%i" % (base_value, index) + similar_values = get_similar_values(base_value) index += 1 - slug = "%s-%i" % (orig_slug, index) - if len(slug) > max_length: - orig_slug = orig_slug[:-(len(slug) - max_length)] - slug = "%s-%i" % (orig_slug, index) - similar_slugs = get_similar_slugs(orig_slug) - return slug - + return value -def unique_slug2(instance, slug_source, slug_field): - slug = slugify(slug_source) - all_slugs = [sl.values()[0] for sl in instance.__class__._default_manager.values(slug_field)] - if slug in all_slugs: - counter_finder = re.compile(r'-\d+$') - counter = 2 - slug = "%s-%i" % (slug, counter) - while slug in all_slugs: - slug = re.sub(counter_finder, "-%i" % counter, slug) - counter += 1 - return slug - - -class AutoSlugField(fields.SlugField): - # AutoSlugField based on http://www.djangosnippets.org/snippets/728/ - - 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) - super(AutoSlugField, self).__init__(*args, **kwargs) - - def pre_save(self, model_instance, add): - value = getattr(model_instance, self.attname) - if not value: - if hasattr(self, 'populate_from'): - # 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() - if not value: - value = DEFAULT_SLUG - if self.unique_slug: - return unique_slug(model_instance, self.name, value, max_length=self.max_length) - else: - return slugify(value) +# TODO Remove alias +def unique_slug(instance, slug_field, slug_value, max_length=50, queryset=None): + slug_value = slugify(slug_value) + return uniquify_field_value(instance, slug_field, slug_value, max_length=50, queryset=None) diff --git a/shared/utils/models/pages.py b/shared/utils/models/pages.py index 93926df..ecae3ac 100644 --- a/shared/utils/models/pages.py +++ b/shared/utils/models/pages.py @@ -88,6 +88,9 @@ class PageTitlesMixin(models.Model, PageTitlesFunctionMixin): class Meta: abstract = True + def __str__(self): + return self.short_title + class PageTitleAdminMixin(object): search_fields = ['short_title', 'title', 'window_title'] diff --git a/shared/utils/models/slugs.py b/shared/utils/models/slugs.py new file mode 100644 index 0000000..6e35243 --- /dev/null +++ b/shared/utils/models/slugs.py @@ -0,0 +1,153 @@ +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 import six +from django.utils.translation import ugettext_lazy as _ + +from dirtyfields import DirtyFieldsMixin + +from ..text import slugify, downgrading_slugify, django_slugify + +if six.PY3: + from functools import reduce + + +DEFAULT_SLUG = getattr(settings, 'DEFAULT_SLUG', "item") + +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) + 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'): + # 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() + if not value: + value = DEFAULT_SLUG + value = self.slugify(value) + 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 `has_url` field. + """ + slug_path = models.CharField(_("URL path"), max_length=2000, editable=False) + has_url = models.BooleanField(_("has webaddress"), default=True) + + FIELDS_TO_CHECK = ['slug'] + + 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 + # not allowed 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() diff --git a/shared/utils/text.py b/shared/utils/text.py index 3c6314c..5d185ad 100644 --- a/shared/utils/text.py +++ b/shared/utils/text.py @@ -1,43 +1,47 @@ # -*- coding: utf-8 -*- from __future__ import unicode_literals -# Erik Stein , 2015-2017 - -from django.utils import six -if six.PY3: - import html +import codecs +import translitcodec # provides 'translit/long', used by codecs.encode() import re +from django.conf import settings from django.utils.encoding import force_text, smart_text -from django.utils.functional import keep_lazy, keep_lazy_text -from django.utils.safestring import SafeText +from django.utils.functional import keep_lazy_text from django.utils.html import mark_safe -from django.utils.text import slugify +from django.utils import six +from django.utils.text import slugify as django_slugify from django.utils.translation import ugettext_lazy -# from bs4 import BeautifulStoneSoup -import translitcodec # provides 'translit/long', used by codecs.encode() -import codecs - +@keep_lazy_text def downgrade(value): """ Downgrade unicode to ascii, transliterating accented characters. """ value = force_text(value) return codecs.encode(value, 'translit/long') -# downgrade = allow_lazy(downgrade, six.text_type, SafeText) +@keep_lazy_text def slugify_long(value): - return slugify(downgrade(value)) -# slugify_long = allow_lazy(slugify_long, six.text_type, SafeText) + return django_slugify(downgrade(value)) # Spreading umlauts is included in the translit/long codec. slugify_german = slugify_long +@keep_lazy_text +def downgrading_slugify(value): + # Slugfiy only allowing hyphens, numbers and ASCII characters + return re.sub("[ _]+", "-", django_slugify(downgrade(value))) + + +SLUGIFY_FUNCTION = getattr(settings, 'SLUGIFY_FUNCTION', downgrading_slugify) +slugify = SLUGIFY_FUNCTION + + if six.PY2: import bs4 @@ -46,18 +50,23 @@ if six.PY2: return smart_text(bs4.BeautifulSoup(html), 'lxml') else: + import html + # Works only with Python >= 3.4 def html_entities_to_unicode(html_str): return html.unescape(html_str) # html_entities_to_unicode = allow_lazy(html_entities_to_unicode, six.text_type, SafeText) -# Translators: This string is used as a separator between list elements +# Translators: Separator between list elements DEFAULT_SEPARATOR = ugettext_lazy(", ") +# Translators: Last separator of list elements +LAST_WORD_SEPARATOR = ugettext_lazy(" and ") + @keep_lazy_text -def get_text_joined(list_, separator=DEFAULT_SEPARATOR, last_word=ugettext_lazy(' and ')): +def get_text_joined(list_, separator=DEFAULT_SEPARATOR, last_word=LAST_WORD_SEPARATOR): list_ = list(list_) if len(list_) == 0: return '' @@ -68,9 +77,10 @@ def get_text_joined(list_, separator=DEFAULT_SEPARATOR, last_word=ugettext_lazy( force_text(last_word), force_text(list_[-1])) +@keep_lazy_text def slimdown(text): """ - Converts simplified markdown (**, *, _) to , und tags. + Converts simplified markdown (`**`, `*`, `__`) to , und tags. """ b_pattern = re.compile(r"(\*\*)(.*?)\1") i_pattern = re.compile(r"(\*)(.*?)\1") @@ -80,4 +90,3 @@ def slimdown(text): text, n = re.subn(i_pattern, "\\2", text) text, n = re.subn(u_pattern, "\\2", text) return mark_safe(text) -