Browse Source

Improved slug functions, mixins; etc.

master
Erik Stein 7 years ago
parent
commit
62f4d0107c
  1. 12
      CHANGES
  2. 8
      shared/utils/__init__.py
  3. 106
      shared/utils/fields.py
  4. 3
      shared/utils/models/pages.py
  5. 153
      shared/utils/models/slugs.py
  6. 47
      shared/utils/text.py

12
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.

8
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

106
shared/utils/fields.py

@ -1,83 +1,55 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
# Erik Stein <code@classlibrary.net>, 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)

3
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']

153
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()

47
shared/utils/text.py

@ -1,43 +1,47 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
# Erik Stein <code@classlibrary.net>, 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 <b>, <i> und <u> tags.
Converts simplified markdown (`**`, `*`, `__`) to <b>, <i> und <u> 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, "<i>\\2</i>", text)
text, n = re.subn(u_pattern, "<u>\\2</u>", text)
return mark_safe(text)

Loading…
Cancel
Save