6 changed files with 234 additions and 95 deletions
@ -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. |
||||
|
@ -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) |
||||
|
@ -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() |
Loading…
Reference in new issue