Monday, April 4, 2011

Django howto: Non-conflicting slugs

A while ago I was testing a Django project at work. In the project we had a Django app called Groups. To create a group, you should point the browser to www.domain.tld/group/create/ and to view a group, you had to point your browser to www.domain.tld/group/<group_slug>/. Of course a group slug is unique, so we should never have any conflicts. That is, until I decided to create a group with the slug 'create'.

As expected, I was faced by the 'Create a new group'-page when I tried to view my pretty new group. When I swapped some URLs in de Groups app, everybody who tried to create a new group, was served the page of my beautiful group. Everything worked as expected, but still it was considered a bug. This was not the kind of functionality the customer was looking for. For the time being we just let it be (what are the chances the customer would create a group with the slug 'create'?), but the case kept whining in the back of my head.
Until today. I decided to tackle the problem. And of course, it was easier than I thought.

After entering some smart queries, Google told me that there is something called the Django test client. Django says: 'The test client is a Python class that acts as a dummy Web browser, allowing you to test your views and interact with your Django-powered application programmatically.' (source) In other words: The test client can be used to send a request to and get a response from your server. This could come in handy!

Long story short: I decided to create a SaveSlugField that uses the Django test client to check if a certain slug (or URL) already exists. If the slug already exists, it throws a standard validation error. Here's the code you've been craving for:

from django import forms
from django.test.client import Client


class SaveSlugField(forms.SlugField):

    def slug_url_exists(self, slug):
        """
        This function uses the Django test Client to determine whether or
        not a given URL (or slug) is already being used in this website.
        """

        c = Client()
        if not slug[0] == '/':
            slug = '/' + slug # Add a slash to create a valid URL

        response = c.get(slug)
        if response.status_code == 404:
            return False

        return True

    def clean(self, *args, **kwargs):
        data = super(SaveSlugField, self).clean(*args, **kwargs)

        if self.slug_url_exists(data):
            raise forms.ValidationError('This slug is not available')

        return data

I think the code is pretty self explaining. This SaveSlugField checks if the URL www.domain.tld/<slug>/ is still available. Of course you can use subclassing (like I subclassed SlugField) to check for your custom slugs/URLs like www.domain.tld/group/<group_slug>/.

If there are any better, prettier, easier or faster way to solve this problem, do not hesitate to let me know in the comments!

EDIT:
Thanks @ polichism. I was afraid I might be kind of confusing ;-)
For the sake of clarity: The URL config in the Group app probably looked a little like this:
url(r'^group/create/$', 'create', name = 'groups.create'),
url(r'^group/(?P<slug>[-\w]+)/$', 'view', name = 'groups.view'),

14 comments:

  1. Just asking:
    Was:
    slug = models.SlugField(unique=True)
    Not enough?

    ReplyDelete
  2. No.

    Unique makes sure you don't enter a slug like 'hello-world' twice, but if you already defined (in the URL config) a page like '/group/create/' it does not stop you from creating a group with the slug 'create'. Even if a group with that slug will conflict (through the URL config) with the Create Group page.

    ReplyDelete
  3. Fair point then :) nice piece of work, and thanks of sharing knowledge

    ReplyDelete
  4. Made it a little clearer, hopefully ;-)
    Thx for the feedback and compliments!

    ReplyDelete
  5. Read the article once more and notice what the real problem is...

    ReplyDelete
  6. Nice Post!

    Regarding the URL to resolve it might be better to use reverse() (See: http://docs.djangoproject.com/en/dev/topics/http/urls/#topics-http-reversing-url-namespaces) instead of '/'+slug ? The SaveSlugField should get the name of the view upon instantiating..

    ReplyDelete
  7. And maybe it's useful to use namespaces:

    root url:
    (r'^group/', include('group.urls', namespace="group"))

    group url:
    url(R'^create/$', 'group.views.create', name="create")

    So you can use reverse in your views and your templates.

    template:
    {% load url %} {% url "group:create" %}

    ReplyDelete
  8. It would be better to use the standard resolve function (django.core.urlresolvers.resolve) instead of the test client I think. The test client is intended for testing.

    See http://docs.djangoproject.com/en/dev/topics/http/urls/#django.core.urlresolvers.resolve

    Also, using the url dispatcher directly is faster than the exception throwing test client.

    ReplyDelete
  9. The real problem seems to be that you're using the same URL pattern for two different purposes. It may not be the same URL pattern in your URLConf, but if there's even the possibility of a conflict, there is something wrong.

    I think a much better solution would be to modify your URLs. There are two glaring options.

    /groups/create/
    /groups/view/<slug>/

    or

    /groups/create/
    /groups/<group_id>/<optional_slug>

    ReplyDelete
  10. @ionelmc: Thx, I'll dive into it and try to fiddle it out when I got the time. :-)

    @Josh.Smeaton: True, but what if, for example, you've got a CMS? It's not really pretty to let users create their own URLS (users want that sometimes), but force them to use '/view_pages/'. Besides, it seems that search engines don't like that either ;-) To fix that problem, I was looking for a pretty solution and this was what I came up with.

    ReplyDelete
  11. I would follow the implementation done by http://www.elfsternberg.com/2009/06/26/dynamic-names-as-first-level-url-path-objects-in-django/

    ReplyDelete
  12. You could also try my project django-aislug, https://github.com/aino/django-aislug. It will automatically compute a unique slug for a model. You can provide invalid slugs, either from a list or a callback function. Another thing to consider is that updating slugs are mostly not such a good idea, django-aislug provides a keyword argument 'update' to control this behaviour too.

    ReplyDelete
  13. I don't think that this solution solves completely the problem. Imagine that, after using this implementation, someone creates a group named "coolest" (just an example), and months later you want to add a new function to your application to show the coolest groups. I think that you could not use a url like '/groups/coolest/', because you would have the same conflict, but in a reverse direction.

    ReplyDelete
    Replies
    1. Good point strider. I guess the most fool-proof way to prevent URL collision is to make a seperate URL for displaying objects with a slug. I'll give it some more thoughts though.

      Delete