Mimsy Were the Borogoves

Hacks: Articles about programming in Python, Perl, PHP, and whatever else I happen to feel like hacking at.

Custom managers for Django ForeignKeys

Jerry Stratton, January 27, 2011

I have a Django model for keywords that I use extensively as a ForeignKey or as a ManyToManyField throughout the rest of my models. I have a Boolean on the KeyWord model for “categorization only”. If that’s checked, this keyword is only available for categorization of pages and of URLs attached to a page. That is, it’s only available for two out of the many models that use it.

Making it disappear for all models by default is easy.

[toggle code]

  • #manager for keywords
  • class KeyManager(models.Manager):
    • #most uses of keywords do not get to use "categorizationOnly" keywords
    • def get_query_set(self):
      • return super(KeyManager, self).get_query_set().filter(categorizationOnly=False)
    • def categoryKeys(self):
      • return super(KeyManager, self).get_query_set()

The default menu selection for KeyWord objects will now only display choices for which categorizationOnly is not true.

At first, I thought setting the queryset ought to work:

ModelChoiceField.queryset

A QuerySet of model objects from which the choices for the field will be derived, and which will be used to validate the user's selection.

It’s easy enough to do:

[toggle code]

  • class PageURLForm(forms.ModelForm):
    • class Meta:
      • model = PageURL
    • def __init__(self, *args, **kwargs):
      • super(PageURLForm, self).__init__(*args, **kwargs)
      • self.fields['category'].queryset = KeyWord.objects.categoryKeys()
  • class PageURLAdmin(admin.ModelAdmin):
    • form = PageURLForm

But the .queryset description is slightly misleading. The form will validate using the custom queryset, but the model itself will continue to validate using the default manager method. The result is that the restricted values will appear in the pulldown menu, but on trying to save one of them, I end up seeing:

Model KeyWord with pk 638 does not exist.

A grep for the text “with pk” showed that the offender is the validate method in the ForeignKey class in django/db/models/fields/related.py.

[toggle code]

  • def validate(self, value, model_instance):
    • if self.rel.parent_link:
      • return
    • super(ForeignKey, self).validate(value, model_instance)
    • if value is None:
      • return
    • using = router.db_for_read(model_instance.__class__, instance=model_instance)
    • qs = self.rel.to._default_manager.using(using).filter(
      • **{self.rel.field_name: value}
    • )
    • qs = qs.complex_filter(self.rel.limit_choices_to)
    • if not qs.exists():
      • raise exceptions.ValidationError(self.error_messages['invalid'] % {
        • 'model': self.rel.to._meta.verbose_name, 'pk': value})

There’s probably a better way to do this (and if you know it, an example in the comments will be appreciated) but I ended up subclassing ForeignKey and rewriting it to accept a custom manager method:

[toggle code]

  • from django.db import router, models
  • class CustomManagerForeignKey(models.ForeignKey):
    • def __init__(self, *args, **kwargs):
      • if 'manager' in kwargs:
        • self.customManager = kwargs['manager']()
        • del kwargs['manager']
      • else:
        • self.customManager = None
      • super(CustomManagerForeignKey, self).__init__(*args, **kwargs)
    • def formfield(self, **kwargs):
      • field = super(CustomManagerForeignKey, self).formfield(**kwargs)
      • if self.customManager:
        • field.queryset = self.customManager
      • return field
    • def validate(self, value, model_instance):
      • if self.rel.parent_link:
        • return
      • super(models.ForeignKey, self).validate(value, model_instance)
      • if value is None:
        • return
      • if self.customManager:
        • manager = self.customManager
      • else:
        • using = router.db_for_read(model_instance.__class__, instance=model_instance)
        • manager = self.rel.to._default_manager.using(using)
      • qs = manager.filter(**{self.rel.field_name: value})
      • qs = qs.complex_filter(self.rel.limit_choices_to)
      • if not qs.exists():
        • raise exceptions.ValidationError(self.error_messages['invalid'] % {'model': self.rel.to._meta.verbose_name, 'pk': value})

The __init__ method looks for a “manager” option, and saves it if it’s there. Then, the validate method uses that in preference to the hard-coded default manager method. I can call it using something like this:

[toggle code]

  • class PageURL(models.Model):
    • category = CustomManagerForeignKey(KeyWord, blank=True, null=True, manager=KeyWord.objects.categoryKeys)

The main thing I don’t like about this is that it duplicates existing code from the validate method, code that will probably change in upcoming versions of Django. However, anything else seemed to require editing the source code directly, and that will definitely fail on an upgrade.

  1. <- Apple software can’t connect
  2. readfile and Host ->