I've got this model in Django:


class MyModel(models.Model):
   completed_date = models.DateTimeField(null=True)

My dislike for booleans and that impact on the Django Admin By using a DateTimeField instead of a BooleanField I'm able to record if an instance is completed or not and when it was completed. A very common pattern in relational applications. Booleans are brief but often insufficient. (Check out Ned Batchelder's Booleans suck)

To make it a bit more convenient (and readable) to work with I added this method:


class MyModel(models.Model):
   completed_date = models.DateTimeField(null=True)

   @property
   def completed(self):
       return self.completed_date is not None

That's great! Now I can do this (use your imagination now):


>>> from myapp.models import MyModel
>>> instance = MyModel.objects.all()[0]
>>> instance.completed
False
>>> instance.completed_date = datetime.datetime.now()
>>> instance.save()
>>> instance.completed
True

I guess I could add a setter too.

But Django's QuerySet machinery doesn't really tie in with the ORM Python classes until the last step so you can't use these property methods in your filtering/excluding. What I want to do is to be able to do this:


>>> from myapp.models import MyModel
>>> completed_instances = MyModel.objects.filter(completed=True)
>>> incomplete_instances = MyModel.objects.filter(completed=False)

To be able to do that I had to add special manager which is sensitive to the parameters it gets and changes them on the fly. So, the manager plus model now looks like this:


class SpecialManager(models.Manager):
   """turn certain booleanesque parameters into date parameters"""

   def filter(self, *args, **kwargs):
       self.__transform_kwargs(kwargs)
       return super(SpecialManager, self).filter(*args, **kwargs)

   def exclude(self, *args, **kwargs):
       self.__transform_kwargs(kwargs)
       return super(SpecialManager, self).exclude(*args, **kwargs)

   def __transform_kwargs(self, kwargs):
       bool_name, date_name = 'completed', 'completed_date'
       for key, value in kwargs.items():
           if bool_name == key or key.startswith('%s__' % bool_name):
               if kwargs.pop(key):
                   kwargs['%s__lte' % date_name] = datetime.now()
               else:
                   kwargs[date_name] = None

class MyModel(models.Model):
   completed_date = models.DateTimeField(null=True)

   @property
   def completed(self):
       return self.completed_date is not None

Now, that's fine but there's one problem. For the application in hand, we're relying on the admin interface a lot. Because of the handy @property decorator I set on the method completed() I now can't include completed into the admin's list_display so I have to do this special trick:


class MyModelAdmin(admin.ModelAdmin):
   list_display = ('is_completed',)

   def is_completed(self, object_):
       return object_.completed
   is_completed.short_description = u'Completed?'
   is_completed.boolean = True

Now, I get the same nice effect in the admin view where this appears as a boolean. The information is still there about when it was completed if I need to extract that for other bits and pieces such as an advanced view or auditing. Pleased!

Now one last challenge with the Django admin interface was how to filter on these non-database-fields? It's been deliberately done so that you can't filter on methods but it's slowly changing and with some hope it'll be in Django 1.2. But I'm not interested in making my application depend on a patch to django.contrib but I really want to filter in the admin. We've already added some custom links and widgets to the admin interface.

After a lot of poking around and hacking together with my colleague Bruno Renié we came up with the following solution:


class MyModelAdmin(admin.ModelAdmin):
   list_display = ('is_completed',)

   def is_completed(self, object_):
       return object_.completed
   is_arrived.short_description = u'Completed?'
   is_arrived.boolean = True

   def changelist_view(self, request, extra_context=None, **kwargs):
       from django.contrib.admin.views.main import ChangeList
       cl = ChangeList(request, self.model, list(self.list_display),
                       self.list_display_links, self.list_filter,
                       self.date_hierarchy, self.search_fields, 
                       self.list_select_related,
                       self.list_per_page,
                       self.list_editable, self)
       cl.formset = None

       if extra_context is None:
           extra_context = {}

       if kwargs.get('only_completed'):
           cl.result_list = cl.result_list.exclude(completed_date=None)
           extra_context['extra_filter'] = "Only completed ones"

       extra_context['cl'] = cl
       return super(SendinRequestAdmin, self).\
         changelist_view(request, extra_context=extra_context)

   def get_urls(self):
       from django.conf.urls.defaults import patterns, url
       urls = super(SendinRequestAdmin, self).get_urls()
       my_urls = patterns('',
               url(r'^only-completed/$', 
                   self.admin_site.admin_view(self.changelist_view),
                    {'only_completed':True}, name="changelist_view"),
       )
       return my_urls + urls

Granted, we're not getting the nice filter widget on the right hand side in the admin interface this time but it's good enough for me to be able to make a special link to /admin/myapp/mymodel/only-completed/ and it works just like a normal filter.

Ticket 5833 is quite busy and has been going on for a while. It feels a daunting task to dig in and contribute when so many people are already ahead of me. By writing this blog entry hopefully it will help other people who're hacking on their Django admin interfaces who, like me, hate booleans.

Comments

Post your own comment
Andreas Pelme

Why don't you just use MyModel.objects.filter(completed_date__isnull=True) instead?

It might be slightly less convenient to write when making queries, but you wont need to create a custom manager. Putting completed=True/False in a Q-object will not work either.

Peter Bengtsson

I didn't know about the __isnull trick. Thanks for that.

No, putting completed=True/False in a Q-object does work as long as it's using my manager (MyModel.objects). That was the whole point of the blog :)

Andreas Pelme

I think that you are missing my point with Q-objects. Your manager only rewrites kwargs passed to filter and exclude, it will not rewrite Q objects that are passed in directly. With Q objects i mean django.db.models.Q: http://docs.djangoproject.com/en/dev/topics/db/queries/#complex-lookups-with-q-objects

If you had another field - foo - on MyModel and wanted all objects that are completed or where foo='bar', you would issue a query that looks something like this:

MyModel.objects.filter(Q(completed=True) | Q(foo='bar'))

You cannot use your rewrite when chaining filters(), since your filter()/exclude() overrides only works on the manager, and not the queryset. This means that MyModel.objects.all().filter(completed=True) will not work either.

I would just use __isnull instead, it does exactly what you want, without going through the hassle of overriding managers, querys and Q objects - and works everywhere.

However, I think your post is interesting and shows some of the potential with being able to subclass and override managers. It is definately a violation of DRY and ugly to have different fields.

Peter Bengtsson

I seeeeeee! When you wrote Q-object I first thought you meant Queryset, not the search thingy.

And now I see what you mean about MyModel.objects.all().filter(completed=True).

I guess I've taken on a principle too strongly that doesn't quite work the Django way.

Christian

why don't you create a DateField "date_completed" AND a BoolanField "completed" and update the boolean automaticly depending on the DateFields value? filtering on a method is possible, but requires iterating over all results. that can be quite a performance hit.

Peter Bengtsson

That's exactly what I didn't want. Why add extra database fields when you don't have to?

No, filtering on a method IS possible but that's why I had to write the special manager.

baken

you feed really sucks. Did you see how it messed up the django community page?

Peter Bengtsson

But that's just because the django community aggregator doesn't display the HTML within. Netvibes does.
And the feed is valid

http://beta.feedvalidator.org/check.cgi?url=http%3A%2F%2Fwww.peterbe.com%2Frss.xml

Your email will never ever be published.

Related posts