diff --git a/autoslug/__init__.py b/autoslug/__init__.py new file mode 100644 index 0000000..71ee245 --- /dev/null +++ b/autoslug/__init__.py @@ -0,0 +1,15 @@ +# coding: utf-8 +# +# Copyright (c) 2008—2015 Andy Mikhailenko +# +# This file is part of django-autoslug. +# +# django-autoslug is free software under terms of the GNU Lesser +# General Public License version 3 (LGPLv3) as published by the Free +# Software Foundation. See the file README for copying conditions. +# +from autoslug.fields import AutoSlugField + + +__version__ = '1.9.3' +__all__ = ['AutoSlugField'] diff --git a/autoslug/fields.py b/autoslug/fields.py new file mode 100644 index 0000000..5c62ab2 --- /dev/null +++ b/autoslug/fields.py @@ -0,0 +1,343 @@ +# coding: utf-8 +# +# Copyright (c) 2008—2015 Andy Mikhailenko +# +# This file is part of django-autoslug. +# +# django-autoslug is free software under terms of the GNU Lesser +# General Public License version 3 (LGPLv3) as published by the Free +# Software Foundation. See the file README for copying conditions. +# + +# django +from django.conf import settings +from django.db.models.fields import SlugField +from django.db.models.signals import post_save + +# 3rd-party +try: + from south.modelsinspector import introspector +except ImportError: + introspector = lambda self: [], {} + +try: + from modeltranslation import utils as modeltranslation_utils +except ImportError: + modeltranslation_utils = None + +# this app +from autoslug.settings import slugify, autoslug_modeltranslation_enable +from autoslug import utils + +__all__ = ['AutoSlugField'] + +SLUG_INDEX_SEPARATOR = '-' # the "-" in "foo-2" + +try: # pragma: nocover + # Python 2.x + basestring +except NameError: # pragma: nocover + # Python 3.x + basestring = str + + +class AutoSlugField(SlugField): + """ + AutoSlugField is an extended SlugField able to automatically resolve name + clashes. + + AutoSlugField can also perform the following tasks on save: + + - populate itself from another field (using `populate_from`), + - use custom `slugify` function (using `slugify` or :doc:`settings`), and + - preserve uniqueness of the value (using `unique` or `unique_with`). + + None of the tasks is mandatory, i.e. you can have auto-populated non-unique + fields, manually entered unique ones (absolutely unique or within a given + date) or both. + + Uniqueness is preserved by checking if the slug is unique with given constraints + (`unique_with`) or globally (`unique`) and adding a number to the slug to make + it unique. + + :param always_update: boolean: if True, the slug is updated each time the + model instance is saved. Use with care because `cool URIs don't + change`_ (and the slug is usually a part of object's URI). Note that + even if the field is editable, any manual changes will be lost when + this option is activated. + :param populate_from: string or callable: if string is given, it is considered + as the name of attribute from which to fill the slug. If callable is given, + it should accept `instance` parameter and return a value to fill the slug + with. + :param sep: string: if defined, overrides default separator for automatically + incremented slug index (i.e. the "-" in "foo-2"). + :param slugify: callable: if defined, overrides `AUTOSLUG_SLUGIFY_FUNCTION` + defined in :doc:`settings`. + :param unique: boolean: ensure total slug uniqueness (unless more precise + `unique_with` is defined). + :param unique_with: string or tuple of strings: name or names of attributes + to check for "partial uniqueness", i.e. there will not be two objects + with identical slugs if these objects share the same values of given + attributes. For instance, ``unique_with='pub_date'`` tells AutoSlugField + to enforce slug uniqueness of all items published on given date. The + slug, however, may reappear on another date. If more than one field is + given, e.g. ``unique_with=('pub_date', 'author')``, then the same slug may + reappear within a day or within some author's articles but never within + a day for the same author. Foreign keys are also supported, i.e. not only + `unique_with='author'` will do, but also `unique_with='author__name'`. + + .. _cool URIs don't change: http://w3.org/Provider/Style/URI.html + + .. note:: always place any slug attribute *after* attributes referenced + by it (i.e. those from which you wish to `populate_from` or check + `unique_with`). The reasoning is that autosaved dates and other such + fields must be already processed before using them in the AutoSlugField. + + Example usage: + + .. code-block:: python + + from django.db import models + from autoslug import AutoSlugField + + class Article(models.Model): + '''An article with title, date and slug. The slug is not totally + unique but there will be no two articles with the same slug within + any month. + ''' + title = models.CharField(max_length=200) + pub_date = models.DateField(auto_now_add=True) + slug = AutoSlugField(populate_from='title', unique_with='pub_date__month') + + + More options: + + .. code-block:: python + + # slugify but allow non-unique slugs + slug = AutoSlugField() + + # globally unique, silently fix on conflict ("foo" --> "foo-1".."foo-n") + slug = AutoSlugField(unique=True) + + # autoslugify value from attribute named "title"; editable defaults to False + slug = AutoSlugField(populate_from='title') + + # same as above but force editable=True + slug = AutoSlugField(populate_from='title', editable=True) + + # ensure that slug is unique with given date (not globally) + slug = AutoSlugField(unique_with='pub_date') + + # ensure that slug is unique with given date AND category + slug = AutoSlugField(unique_with=('pub_date','category')) + + # ensure that slug in unique with an external object + # assuming that author=ForeignKey(Author) + slug = AutoSlugField(unique_with='author') + + # ensure that slug in unique with a subset of external objects (by lookups) + # assuming that author=ForeignKey(Author) + slug = AutoSlugField(unique_with='author__name') + + # mix above-mentioned behaviour bits + slug = AutoSlugField(populate_from='title', unique_with='pub_date') + + # minimum date granularity is shifted from day to month + slug = AutoSlugField(populate_from='title', unique_with='pub_date__month') + + # autoslugify value from a dynamic attribute (i.e. a method) + slug = AutoSlugField(populate_from='get_full_name') + + # autoslugify value from a custom callable + # (ex. usage: user profile models) + slug = AutoSlugField(populate_from=lambda instance: instance.user.get_full_name()) + + # specify model manager for looking up slugs shared by subclasses + + class Article(models.Model): + '''An article with title, date and slug. The slug is not totally + unique but there will be no two articles with the same slug within + any month. + ''' + objects = models.Manager() + title = models.CharField(max_length=200) + slug = AutoSlugField(populate_from='title', unique_with='pub_date__month', manager=objects) + + class NewsArticle(Article): + pass + + # autoslugify value using custom `slugify` function + from autoslug.settings import slugify as default_slugify + def custom_slugify(value): + return default_slugify(value).replace('-', '_') + slug = AutoSlugField(slugify=custom_slugify) + + """ + def __init__(self, *args, **kwargs): + kwargs['max_length'] = kwargs.get('max_length', 50) + + # autopopulated slug is not editable unless told so + self.populate_from = kwargs.pop('populate_from', None) + if self.populate_from: + kwargs.setdefault('editable', False) + + # unique_with value can be string or tuple + self.unique_with = kwargs.pop('unique_with', ()) + if isinstance(self.unique_with, basestring): + self.unique_with = (self.unique_with,) + + self.slugify = kwargs.pop('slugify', slugify) + assert hasattr(self.slugify, '__call__') + + self.index_sep = kwargs.pop('sep', SLUG_INDEX_SEPARATOR) + + if self.unique_with: + # we will do "manual" granular check below + kwargs['unique'] = False + + # Set db_index=True unless it's been set manually. + if 'db_index' not in kwargs: + kwargs['db_index'] = True + + # A boolean instructing the field to accept Unicode letters in + # addition to ASCII letters. Defaults to False. + self.allow_unicode = kwargs.pop('allow_unicode', False) + + # When using model inheritence, set manager to search for matching + # slug values + self.manager = kwargs.pop('manager', None) + + self.always_update = kwargs.pop('always_update', False) + super(SlugField, self).__init__(*args, **kwargs) + + def deconstruct(self): + name, path, args, kwargs = super(AutoSlugField, self).deconstruct() + + if self.max_length == 50: + kwargs.pop('max_length', None) + + if self.populate_from is not None: + kwargs['populate_from'] = self.populate_from + if self.editable is not False: + kwargs['editable'] = self.editable + + if self.unique_with != (): + kwargs['unique_with'] = self.unique_with + kwargs.pop('unique', None) + + if self.slugify != slugify: + kwargs['slugify'] = self.slugify + + if self.index_sep != SLUG_INDEX_SEPARATOR: + kwargs['sep'] = self.index_sep + + kwargs.pop('db_index', None) + + if self.manager is not None: + kwargs['manager'] = self.manager + + if self.always_update: + kwargs['always_update'] = self.always_update + + return name, path, args, kwargs + + def pre_save(self, instance, add): + + # get currently entered slug + value = self.value_from_object(instance) + + manager = self.manager + + # autopopulate + if self.always_update or (self.populate_from and not value): + value = utils.get_prepopulated_value(self, instance) + + # pragma: nocover + if __debug__ and not value and not self.blank: + print('Failed to populate slug %s.%s from %s' % \ + (instance._meta.object_name, self.name, self.populate_from)) + + if value: + slug = self.slugify(value) + else: + slug = None + + if not self.blank: + slug = instance._meta.model_name + elif not self.null: + slug = '' + + if not self.blank: + assert slug, 'slug is defined before trying to ensure uniqueness' + + if slug: + slug = utils.crop_slug(self, slug) + + # ensure the slug is unique (if required) + if self.unique or self.unique_with: + slug = utils.generate_unique_slug(self, instance, slug, manager) + + assert slug, 'value is filled before saving' + + # make the updated slug available as instance attribute + setattr(instance, self.name, slug) + + # modeltranslation support + if 'modeltranslation' in settings.INSTALLED_APPS \ + and not hasattr(self.populate_from, '__call__') \ + and autoslug_modeltranslation_enable: + post_save.connect(modeltranslation_update_slugs, sender=type(instance)) + + return slug + + + def south_field_triple(self): + "Returns a suitable description of this field for South." + args, kwargs = introspector(self) + kwargs.update({ + 'populate_from': 'None' if callable(self.populate_from) else repr(self.populate_from), + 'unique_with': repr(self.unique_with) + }) + return ('autoslug.fields.AutoSlugField', args, kwargs) + + +def modeltranslation_update_slugs(sender, **kwargs): + # https://bitbucket.org/neithere/django-autoslug/pull-request/11/modeltranslation-support-fix-issue-19/ + # http://django-modeltranslation.readthedocs.org + # + # TODO: tests + # + if not modeltranslation_utils: + return + + instance = kwargs['instance'] + slugs = {} + + for field in instance._meta.fields: + if type(field) != AutoSlugField: + continue + if not field.populate_from: + continue + for lang in settings.LANGUAGES: + lang_code, _ = lang + lang_code = lang_code.replace('-', '_') + + populate_from_localized = modeltranslation_utils.build_localized_fieldname(field.populate_from, lang_code) + field_name_localized = modeltranslation_utils.build_localized_fieldname(field.name, lang_code) + + # The source field or the slug field itself may not be registered + # with translator + if not hasattr(instance, populate_from_localized): + continue + if not hasattr(instance, field_name_localized): + continue + + populate_from_value = getattr(instance, populate_from_localized) + field_value = getattr(instance, field_name_localized) + + if not field_value or field.always_update: + slug = field.slugify(populate_from_value) + slugs[field_name_localized] = slug + + sender.objects.filter(pk=instance.pk).update(**slugs) diff --git a/autoslug/models.py b/autoslug/models.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/autoslug/models.py diff --git a/autoslug/settings.py b/autoslug/settings.py new file mode 100644 index 0000000..f3d9a7e --- /dev/null +++ b/autoslug/settings.py @@ -0,0 +1,68 @@ +# coding: utf-8 +# +# Copyright (c) 2008—2015 Andy Mikhailenko +# +# This file is part of django-autoslug. +# +# django-autoslug is free software under terms of the GNU Lesser +# General Public License version 3 (LGPLv3) as published by the Free +# Software Foundation. See the file README for copying conditions. +# +""" +Django settings that affect django-autoslug: + +`AUTOSLUG_SLUGIFY_FUNCTION` + Allows to define a custom slugifying function. + + The function can be repsesented as string or callable, e.g.:: + + # custom function, path as string: + AUTOSLUG_SLUGIFY_FUNCTION = 'some_app.slugify_func' + + # custom function, callable: + AUTOSLUG_SLUGIFY_FUNCTION = some_app.slugify_func + + # custom function, defined inline: + AUTOSLUG_SLUGIFY_FUNCTION = lambda slug: 'can i haz %s?' % slug + + If no value is given, default value is used. + + Default value is one of these depending on availability in given order: + + * `unidecode.unidecode()` if Unidecode_ is available; + * `pytils.translit.slugify()` if pytils_ is available; + * `django.template.defaultfilters.slugify()` bundled with Django. + + django-autoslug also ships a couple of slugify functions that use + the translitcodec_ Python library, e.g.:: + + # using as many characters as needed to make a natural replacement + AUTOSLUG_SLUGIFY_FUNCTION = 'autoslug.utils.translit_long' + + # using the minimum number of characters to make a replacement + AUTOSLUG_SLUGIFY_FUNCTION = 'autoslug.utils.translit_short' + + # only performing single character replacements + AUTOSLUG_SLUGIFY_FUNCTION = 'autoslug.utils.translit_one' + +.. _Unidecode: http://pypi.python.org/pypi/Unidecode +.. _pytils: http://pypi.python.org/pypi/pytils +.. _translitcodec: http://pypi.python.org/pypi/translitcodec + +`AUTOSLUG_MODELTRANSLATION_ENABLE` + Django-autoslug support of modeltranslation_ is still experimental. + If you wish to enable it, please set this option to `True` in your project + settings. Default is `False`. + +.. _modeltranslation: http://django-modeltranslation.readthedocs.org + +""" +from django.conf import settings +from django.core.urlresolvers import get_callable + +# use custom slugifying function if any +slugify_function_path = getattr(settings, 'AUTOSLUG_SLUGIFY_FUNCTION', 'autoslug.utils.slugify') +slugify = get_callable(slugify_function_path) + +# enable/disable modeltranslation support +autoslug_modeltranslation_enable = getattr(settings, 'AUTOSLUG_MODELTRANSLATION_ENABLE', False) diff --git a/autoslug/utils.py b/autoslug/utils.py new file mode 100644 index 0000000..52bf529 --- /dev/null +++ b/autoslug/utils.py @@ -0,0 +1,199 @@ +# coding: utf-8 +# +# Copyright (c) 2008—2015 Andy Mikhailenko +# +# This file is part of django-autoslug. +# +# django-autoslug is free software under terms of the GNU Lesser +# General Public License version 3 (LGPLv3) as published by the Free +# Software Foundation. See the file README for copying conditions. +# + +# django +from django.core.exceptions import ImproperlyConfigured +from django.db.models import ForeignKey +from django.db.models.fields import FieldDoesNotExist, DateField +from django.template.defaultfilters import slugify as django_slugify + +try: + # i18n-friendly approach + from unidecode import unidecode +except ImportError: + try: + # Cyrillic transliteration (primarily Russian) + from pytils.translit import slugify + except ImportError: + # fall back to Django's default method + slugify = django_slugify +else: + # Use Django's default method over decoded string + def slugify(value): + return django_slugify(unidecode(value)) + + +def get_prepopulated_value(field, instance): + """ + Returns preliminary value based on `populate_from`. + """ + if hasattr(field.populate_from, '__call__'): + # AutoSlugField(populate_from=lambda instance: ...) + return field.populate_from(instance) + else: + # AutoSlugField(populate_from='foo') + attr = getattr(instance, field.populate_from) + return callable(attr) and attr() or attr + + +def generate_unique_slug(field, instance, slug, manager): + """ + Generates unique slug by adding a number to given value until no model + instance can be found with such slug. If ``unique_with`` (a tuple of field + names) was specified for the field, all these fields are included together + in the query when looking for a "rival" model instance. + """ + + original_slug = slug = crop_slug(field, slug) + + default_lookups = tuple(get_uniqueness_lookups(field, instance, field.unique_with)) + + index = 1 + + if not manager: + manager = field.model._default_manager + + + # keep changing the slug until it is unique + while True: + # find instances with same slug + lookups = dict(default_lookups, **{field.name: slug}) + rivals = manager.filter(**lookups) + if instance.pk: + rivals = rivals.exclude(pk=instance.pk) + + if not rivals: + # the slug is unique, no model uses it + return slug + + # the slug is not unique; change once more + index += 1 + + # ensure the resulting string is not too long + tail_length = len(field.index_sep) + len(str(index)) + combined_length = len(original_slug) + tail_length + if field.max_length < combined_length: + original_slug = original_slug[:field.max_length - tail_length] + + # re-generate the slug + data = dict(slug=original_slug, sep=field.index_sep, index=index) + slug = '%(slug)s%(sep)s%(index)d' % data + + # ...next iteration... + + +def get_uniqueness_lookups(field, instance, unique_with): + """ + Returns a dict'able tuple of lookups to ensure uniqueness of a slug. + """ + for original_lookup_name in unique_with: + if '__' in original_lookup_name: + field_name, inner_lookup = original_lookup_name.split('__', 1) + else: + field_name, inner_lookup = original_lookup_name, None + + try: + other_field = instance._meta.get_field(field_name) + except FieldDoesNotExist: + raise ValueError('Could not find attribute %s.%s referenced' + ' by %s.%s (see constraint `unique_with`)' + % (instance._meta.object_name, field_name, + instance._meta.object_name, field.name)) + + if field == other_field: + raise ValueError('Attribute %s.%s references itself in `unique_with`.' + ' Please use "unique=True" for this case.' + % (instance._meta.object_name, field_name)) + + value = getattr(instance, field_name) + if not value: + if other_field.blank: + field_object, model, direct, m2m = instance._meta.get_field_by_name(field_name) + if isinstance(field_object, ForeignKey): + lookup = '%s__isnull' % field_name + yield lookup, True + break + raise ValueError('Could not check uniqueness of %s.%s with' + ' respect to %s.%s because the latter is empty.' + ' Please ensure that "%s" is declared *after*' + ' all fields listed in unique_with.' + % (instance._meta.object_name, field.name, + instance._meta.object_name, field_name, + field.name)) + if isinstance(other_field, DateField): # DateTimeField is a DateField subclass + inner_lookup = inner_lookup or 'day' + + if '__' in inner_lookup: + raise ValueError('The `unique_with` constraint in %s.%s' + ' is set to "%s", but AutoSlugField only' + ' accepts one level of nesting for dates' + ' (e.g. "date__month").' + % (instance._meta.object_name, field.name, + original_lookup_name)) + + parts = ['year', 'month', 'day'] + try: + granularity = parts.index(inner_lookup) + 1 + except ValueError: + raise ValueError('expected one of %s, got "%s" in "%s"' + % (parts, inner_lookup, original_lookup_name)) + else: + for part in parts[:granularity]: + lookup = '%s__%s' % (field_name, part) + yield lookup, getattr(value, part) + else: + # TODO: this part should be documented as it involves recursion + if inner_lookup: + if not hasattr(value, '_meta'): + raise ValueError('Could not resolve lookup "%s" in `unique_with` of %s.%s' + % (original_lookup_name, instance._meta.object_name, field.name)) + for inner_name, inner_value in get_uniqueness_lookups(field, value, [inner_lookup]): + yield original_lookup_name, inner_value + else: + yield field_name, value + + +def crop_slug(field, slug): + if field.max_length < len(slug): + return slug[:field.max_length] + return slug + + +try: + import translitcodec +except ImportError: + pass +else: + import re + PUNCT_RE = re.compile(r'[\t !"#$%&\'()*\-/<=>?@\[\\\]^_`{|},.]+') + + def translitcodec_slugify(codec): + def _slugify(value, delim='-', encoding=''): + """ + Generates an ASCII-only slug. + + Borrowed from http://flask.pocoo.org/snippets/5/ + """ + if encoding: + encoder = "%s/%s" % (codec, encoding) + else: + encoder = codec + result = [] + for word in PUNCT_RE.split(value.lower()): + word = word.encode(encoder) + if word: + result.append(word) + return unicode(delim.join(result)) + return _slugify + + translit_long = translitcodec_slugify("translit/long") + translit_short = translitcodec_slugify("translit/short") + translit_one = translitcodec_slugify("translit/one") -- libgit2 0.21.2