fields.py 12.6 KB
# 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)