Skip to content

HTTPS clone URL

Subversion checkout URL

You can clone with
or
.
Download ZIP

Loading…

Fixed #2445 -- ``limit_choices_to`` attribute can now be a callable. #1600

Closed
wants to merge 1 commit into from

3 participants

@adamsc64
  • ForeignKey or ManyToManyField attribute limit_choices_to can now be a callable that returns either a Q object or a dict.
  • The callable will be invoked at ModelForm initialization time.
  • Admin form behavior modified to handle new functionality.
  • Admin widget behavior modified to handle new functionality.
  • Updated Django documentation field reference section.
  • Added unit tests for limit_choices_to on ModelForms.
  • Tweaked unit tests for Admin to use some callables for limit_choices_to.
@adamsc64 adamsc64 Fixed #2445 -- ``limit_choices_to`` attribute can now be a callable.
- ForeignKey or ManyToManyField attribute ``limit_choices_to`` can now
  be a callable that returns either a ``Q`` object or a dict.
- The callable should be invoked at ModelForm initialization time.
- Admin form behavior modified to handle new functionality.
- Admin widget behavior modified to handle new functionality.
- Updated Django documentation field reference section.
- Added unit tests for ``limit_choices_to`` on ModelForms.
- Tweaked unit tests for Admin to use some callables for
  ``limit_choices_to``.
0f9d45b
@freakboy3742 freakboy3742 commented on the diff
tests/admin_views/models.py
@@ -127,7 +128,7 @@ class Meta:
@python_2_unicode_compatible
class Thing(models.Model):
title = models.CharField(max_length=20)
- color = models.ForeignKey(Color, limit_choices_to={'warm': True})
+ color = models.ForeignKey(Color, limit_choices_to=lambda: {'warm': True})
@freakboy3742 Owner

Why does this change the existing test? Generally, changing old tests indicates that you're introducing a potential regression, because old behavior isn't going to be tested any more.

@adamsc64
adamsc64 added a note

Russell,

Good point, apologies for that. This was just an extra attempt at mixing the new functionality in with the old, but in retrospect it is a bad idea. I will re-submit the pull request without modifying the old test.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
@freakboy3742 freakboy3742 commented on the diff
django/contrib/admin/widgets.py
@@ -171,7 +171,10 @@ def render(self, name, value, attrs=None):
return mark_safe(''.join(output))
def base_url_parameters(self):
- return url_params_from_lookup_dict(self.rel.limit_choices_to)
+ limit_choices_to = self.rel.limit_choices_to
+ if callable(limit_choices_to):
+ limit_choices_to = limit_choices_to()
+ return url_params_from_lookup_dict(limit_choices_to)
@freakboy3742 Owner

Isn't this inconsistent with allowing limit_choices_to() to return a Q object?

@adamsc64
adamsc64 added a note

Russell,

You are absolutely correct -- Django does not allow Q objects for the widgets ManyToManyRawIdWidget and ForeignKeyRawIdWidget. I don't exactly know why this limitation is there, however it was recently documented by @evildmp [Daniele Procida] at 79cc666. As you can see, his changes to the Django documentation for limit_choices_to specify this limitation.

So this is a limitation that exists regardless of whether limit_choices_to can be a callable or not. In other words, I don't think it's a new limitation I am introducing in this pull request.

Background: The methods base_url_parameters() as well as url_params_from_lookup_dict() are only invoked when these widget types are used. Use of these widgets does not support using Q objects for limit_choices_to, I believe perhaps because they involve raw ids.

I don't know what the solution to this problem is, as it's probably hard to translate a Q object into a set of key-value parameters (see url_params_from_lookup_dict() function for example). But I believe solving this problem is beyond the scope of this ticket. What do you think?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
@adamsc64 adamsc64 closed this
@adamsc64 adamsc64 deleted the adamsc64:ticket_2445 branch
@adamsc64

I re-opened another pull request [#2233] with:
1. Rebase from django/django:master (there were a few changes that needed to be made to my branch).
2. Removed the offending unit test change in tests/admin_views/models.py, called out by @freakboy3742 in the above comment.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Commits on Sep 7, 2013
  1. @adamsc64

    Fixed #2445 -- ``limit_choices_to`` attribute can now be a callable.

    adamsc64 authored
    - ForeignKey or ManyToManyField attribute ``limit_choices_to`` can now
      be a callable that returns either a ``Q`` object or a dict.
    - The callable should be invoked at ModelForm initialization time.
    - Admin form behavior modified to handle new functionality.
    - Admin widget behavior modified to handle new functionality.
    - Updated Django documentation field reference section.
    - Added unit tests for ``limit_choices_to`` on ModelForms.
    - Tweaked unit tests for Admin to use some callables for
      ``limit_choices_to``.
This page is out of date. Refresh to see the latest.
View
5 django/contrib/admin/options.py
@@ -198,7 +198,7 @@ def get_field_queryset(self, db, db_field, request):
if related_admin is not None:
ordering = related_admin.get_ordering(request)
if ordering is not None and ordering != ():
- return db_field.rel.to._default_manager.using(db).order_by(*ordering).complex_filter(db_field.rel.limit_choices_to)
+ return db_field.rel.to._default_manager.using(db).order_by(*ordering)
return None
def formfield_for_foreignkey(self, db_field, request=None, **kwargs):
@@ -324,6 +324,9 @@ def lookup_allowed(self, lookup, value):
# ForeignKeyRawIdWidget, on the basis of ForeignKey.limit_choices_to,
# are allowed to work.
for l in model._meta.related_fkey_lookups:
+ # As ``limit_choices_to`` can be a callable, invoke it here.
+ if callable(l):
+ l = l()
for k, v in widgets.url_params_from_lookup_dict(l).items():
if k == lookup and v == value:
return True
View
13 django/contrib/admin/util.py
@@ -457,17 +457,20 @@ def get_limit_choices_to_from_path(model, path):
""" Return Q object for limiting choices if applicable.
If final model in path is linked via a ForeignKey or ManyToManyField which
- has a `limit_choices_to` attribute, return it as a Q object.
+ has a ``limit_choices_to`` attribute, return it as a Q object.
"""
fields = get_fields_from_path(model, path)
fields = remove_trailing_data_field(fields)
- limit_choices_to = (
+ get_limit_choices_to = (
fields and hasattr(fields[-1], 'rel') and
- getattr(fields[-1].rel, 'limit_choices_to', None))
- if not limit_choices_to:
+ getattr(fields[-1].rel, 'get_limit_choices_to', None))
+
+ if not get_limit_choices_to:
return models.Q() # empty Q
- elif isinstance(limit_choices_to, models.Q):
+
+ limit_choices_to = get_limit_choices_to()
+ if isinstance(limit_choices_to, models.Q):
return limit_choices_to # already a Q
else:
return models.Q(**limit_choices_to) # convert dict to Q
View
5 django/contrib/admin/widgets.py
@@ -171,7 +171,10 @@ def render(self, name, value, attrs=None):
return mark_safe(''.join(output))
def base_url_parameters(self):
- return url_params_from_lookup_dict(self.rel.limit_choices_to)
+ limit_choices_to = self.rel.limit_choices_to
+ if callable(limit_choices_to):
+ limit_choices_to = limit_choices_to()
+ return url_params_from_lookup_dict(limit_choices_to)
@freakboy3742 Owner

Isn't this inconsistent with allowing limit_choices_to() to return a Q object?

@adamsc64
adamsc64 added a note

Russell,

You are absolutely correct -- Django does not allow Q objects for the widgets ManyToManyRawIdWidget and ForeignKeyRawIdWidget. I don't exactly know why this limitation is there, however it was recently documented by @evildmp [Daniele Procida] at 79cc666. As you can see, his changes to the Django documentation for limit_choices_to specify this limitation.

So this is a limitation that exists regardless of whether limit_choices_to can be a callable or not. In other words, I don't think it's a new limitation I am introducing in this pull request.

Background: The methods base_url_parameters() as well as url_params_from_lookup_dict() are only invoked when these widget types are used. Use of these widgets does not support using Q objects for limit_choices_to, I believe perhaps because they involve raw ids.

I don't know what the solution to this problem is, as it's probably hard to translate a Q object into a set of key-value parameters (see url_params_from_lookup_dict() function for example). But I believe solving this problem is beyond the scope of this ticket. What do you think?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
def url_parameters(self):
from django.contrib.admin.views.main import TO_FIELD_VAR
View
4 django/db/models/fields/__init__.py
@@ -583,11 +583,11 @@ def get_choices(self, include_blank=True, blank_choice=BLANK_CHOICE_DASH):
lst = [(getattr(x, self.rel.get_related_field().attname),
smart_text(x))
for x in rel_model._default_manager.complex_filter(
- self.rel.limit_choices_to)]
+ self.get_limit_choices_to())]
else:
lst = [(x._get_pk_val(), smart_text(x))
for x in rel_model._default_manager.complex_filter(
- self.rel.limit_choices_to)]
+ self.get_limit_choices_to())]
return first_choice + lst
def get_choices_default(self):
View
37 django/db/models/fields/related.py
@@ -131,6 +131,32 @@ def do_related_class(self, other, cls):
if not cls._meta.abstract:
self.contribute_to_related_class(other, self.related)
+ def get_limit_choices_to(self):
+ """
+ Returns 'limit_choices_to' for this model field. If it is a callable,
+ it will be invoked and the result will be returned.
+ """
+ if callable(self.rel.limit_choices_to):
+ return self.rel.limit_choices_to()
+ return self.rel.limit_choices_to
+
+ def formfield(self, **kwargs):
+ """
+ Makes sure that ``limit_choices_to`` is passed to the field being
+ constructed. This is the same strategy used to pass the
+ ``queryset`` to the field being constructed.
+ """
+ # If this is a callable, do not invoke it here. Just pass it in
+ # the defaults to be executed when the form class will later
+ # instantiate itself.
+ limit_choices_to = self.rel.limit_choices_to
+
+ defaults = {
+ 'limit_choices_to': limit_choices_to,
+ }
+ defaults.update(kwargs)
+ return super(RelatedField, self).formfield(**defaults)
+
def related_query_name(self):
# This method defines the name that can be used to identify this
# related object in a table-spanning query. It uses the lower-cased
@@ -1119,6 +1145,10 @@ def contribute_to_related_class(self, cls, related):
# and swapped models don't get a related descriptor.
if not self.rel.is_hidden() and not related.model._meta.swapped:
setattr(cls, related.get_accessor_name(), ForeignRelatedObjectsDescriptor(related))
+
+ # While 'limit_choices_to' might be a callable, pass it
+ # along for later - this is too early because it's still
+ # model load time.
if self.rel.limit_choices_to:
cls._meta.related_fkey_lookups.append(self.rel.limit_choices_to)
@@ -1202,7 +1232,7 @@ def validate(self, value, 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)
+ qs = qs.complex_filter(self.get_limit_choices_to())
if not qs.exists():
raise exceptions.ValidationError(
self.error_messages['invalid'],
@@ -1258,9 +1288,10 @@ def formfield(self, **kwargs):
raise ValueError("Cannot create form field for %r yet, because "
"its related model %r has not been loaded yet" %
(self.name, self.rel.to))
+
defaults = {
'form_class': forms.ModelChoiceField,
- 'queryset': self.rel.to._default_manager.using(db).complex_filter(self.rel.limit_choices_to),
+ 'queryset': self.rel.to._default_manager.using(db),
'to_field_name': self.rel.field_name,
}
defaults.update(kwargs)
@@ -1554,7 +1585,7 @@ def formfield(self, **kwargs):
db = kwargs.pop('using', None)
defaults = {
'form_class': forms.ModelMultipleChoiceField,
- 'queryset': self.rel.to._default_manager.using(db).complex_filter(self.rel.limit_choices_to)
+ 'queryset': self.rel.to._default_manager.using(db),
}
defaults.update(kwargs)
# If initial is passed in, it's a list of related objects, but the
View
9 django/forms/fields.py
@@ -769,6 +769,15 @@ def __init__(self, choices=(), required=True, widget=None, label=None,
initial=initial, help_text=help_text, *args, **kwargs)
self.choices = choices
+ def get_limit_choices_to(self):
+ """
+ Returns 'limit_choices_to' for this form field. If it is a callable,
+ it will be invoked and the result will be returned.
+ """
+ if callable(self.limit_choices_to):
+ return self.limit_choices_to()
+ return self.limit_choices_to
+
def __deepcopy__(self, memo):
result = super(ChoiceField, self).__deepcopy__(memo)
result._choices = copy.deepcopy(self._choices, memo)
View
18 django/forms/models.py
@@ -295,6 +295,7 @@ def __init__(self, data=None, files=None, auto_id='id_%s', prefix=None,
initial=None, error_class=ErrorList, label_suffix=None,
empty_permitted=False, instance=None):
opts = self._meta
+
if opts.model is None:
raise ValueError('ModelForm has no model class specified.')
if instance is None:
@@ -307,13 +308,26 @@ def __init__(self, data=None, files=None, auto_id='id_%s', prefix=None,
# if initial was provided, it should override the values from instance
if initial is not None:
object_data.update(initial)
+
# self._validate_unique will be set to True by BaseModelForm.clean().
# It is False by default so overriding self.clean() and failing to call
# super will stop validate_unique from being called.
self._validate_unique = False
+
super(BaseModelForm, self).__init__(data, files, auto_id, prefix, object_data,
error_class, label_suffix, empty_permitted)
+ # This form instance should now have its own set of fields in
+ # ``self.fields``. Apply the attribute 'limit_choices_to', if applicable.
+ for field_name in self.fields:
+ formfield = self.fields[field_name]
+ if hasattr(formfield, 'queryset'):
+ limit_choices_to = formfield.limit_choices_to
+ if limit_choices_to is not None:
+ if callable(limit_choices_to):
+ limit_choices_to = limit_choices_to()
+ formfield.queryset = formfield.queryset.complex_filter(limit_choices_to)
+
def _update_errors(self, errors):
for field, messages in errors.error_dict.items():
if field not in self.fields:
@@ -1058,7 +1072,8 @@ class ModelChoiceField(ChoiceField):
def __init__(self, queryset, empty_label="---------", cache_choices=False,
required=True, widget=None, label=None, initial=None,
- help_text='', to_field_name=None, *args, **kwargs):
+ help_text='', to_field_name=None, limit_choices_to=None,
+ *args, **kwargs):
if required and (initial is not None):
self.empty_label = None
else:
@@ -1070,6 +1085,7 @@ def __init__(self, queryset, empty_label="---------", cache_choices=False,
Field.__init__(self, required, widget, label, initial, help_text,
*args, **kwargs)
self.queryset = queryset
+ self.limit_choices_to = limit_choices_to # added to limit queryset later.
self.choice_cache = None
self.to_field_name = to_field_name
View
27 docs/ref/models/fields.txt
@@ -1054,15 +1054,23 @@ define the details of how the relation works.
.. attribute:: ForeignKey.limit_choices_to
- A dictionary of lookup arguments and values (see :doc:`/topics/db/queries`)
- that limit the available admin or ModelForm choices for this object. Use
- this with functions from the Python ``datetime`` module to limit choices of
- objects by date. For example::
+ Sets a limit to the available choices for this field when this field is
+ rendered using a ``ModelForm`` or the admin (by default, all objects
+ are available to choose). Either a dictionary, a :class:`~django.db.models.Q`
+ object, or a callable returning a dictionary or :class:`~django.db.models.Q`
+ object can be used.
+
+ The dictionary, if used, should be a dictionary of lookup keyword arguments
+ (see :doc:`/topics/db/queries`) that limit the queryset for the generated
+ ``ModelChoiceField``. Use this with functions from the Python ``datetime``
+ module to limit choices of objects by date. For example::
limit_choices_to = {'pub_date__lte': datetime.date.today}
only allows the choice of related objects with a ``pub_date`` before the
- current date to be chosen.
+ current date to be chosen. As a callable::
+
+ limit_choices_to = lambda: {'pub_date__lte': datetime.date.today}
Instead of a dictionary this can also be a :class:`Q object
<django.db.models.Q>` for more :ref:`complex queries
@@ -1071,6 +1079,15 @@ define the details of how the relation works.
choices available in the admin when the field is not listed in
``raw_id_fields`` in the ``ModelAdmin`` for the model.
+.. note::
+
+ If a callable is used for ``limit_choices_to``, it will be invoked every time a
+ new form is instantiated. It may also be invoked when a model is validated using
+ various management commands, as well as in the admin. The admin constructs
+ querysets to validate its form inputs in various edge cases multiple times,
+ so there is a possibility your callable may be invoked that many times.
+ Make sure there are no undesired side effects to this behavior in your case.
+
.. attribute:: ForeignKey.related_name
The name to use for the relation from the related object back to this one.
View
9 tests/admin_views/forms.py
@@ -1,6 +1,8 @@
from django import forms
from django.contrib.admin.forms import AdminAuthenticationForm
+from .models import StumpJoke
+
class CustomAdminAuthenticationForm(AdminAuthenticationForm):
@@ -9,3 +11,10 @@ def clean_username(self):
if username == 'customform':
raise forms.ValidationError('custom form error')
return username
+
+
+class StumpJokeForm(forms.ModelForm):
+
+ class Meta:
+ model = StumpJoke
+ exclude = []
View
18 tests/admin_views/models.py
@@ -10,6 +10,7 @@
from django.contrib.contenttypes.models import ContentType
from django.core.files.storage import FileSystemStorage
from django.db import models
+from django.db.models import Q
from django.utils.encoding import python_2_unicode_compatible
@@ -127,7 +128,7 @@ class Meta:
@python_2_unicode_compatible
class Thing(models.Model):
title = models.CharField(max_length=20)
- color = models.ForeignKey(Color, limit_choices_to={'warm': True})
+ color = models.ForeignKey(Color, limit_choices_to=lambda: {'warm': True})
@freakboy3742 Owner

Why does this change the existing test? Generally, changing old tests indicates that you're introducing a potential regression, because old behavior isn't going to be tested any more.

@adamsc64
adamsc64 added a note

Russell,

Good point, apologies for that. This was just an extra attempt at mixing the new functionality in with the old, but in retrospect it is a bad idea. I will re-submit the pull request without modifying the old test.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
pub_date = models.DateField(blank=True, null=True)
def __str__(self):
return self.title
@@ -160,7 +161,7 @@ class Sketch(models.Model):
'expected': False,
})
defendant0 = models.ForeignKey(Actor, limit_choices_to={'title__isnull': False}, related_name='as_defendant0')
- defendant1 = models.ForeignKey(Actor, limit_choices_to={'title__isnull': True}, related_name='as_defendant1')
+ defendant1 = models.ForeignKey(Actor, limit_choices_to=lambda: {'title__isnull': True}, related_name='as_defendant1')
def __str__(self):
return self.title
@@ -525,6 +526,19 @@ def __str__(self):
return self.answer
+def today_callable_dict():
+ return {"date_joined__gte": datetime.datetime.today}
+
+def today_callable_q():
+ return Q(date_joined__gte=datetime.datetime.today)
+
+
+@python_2_unicode_compatible
+class StumpJoke(models.Model):
+ most_recently_fooled = models.ForeignKey(User, limit_choices_to=today_callable_dict, related_name="+")
+ has_fooled_today = models.ManyToManyField(User, limit_choices_to=today_callable_q, related_name="+")
+
+
class Reservation(models.Model):
start_date = models.DateTimeField()
price = models.IntegerField()
View
25 tests/admin_views/tests.py
@@ -48,8 +48,9 @@
AdminOrderedModelMethod, AdminOrderedAdminMethod, AdminOrderedCallable,
Report, MainPrepopulated, RelatedPrepopulated, UnorderedObject,
Simple, UndeletableObject, UnchangeableObject, Choice, ShortMessage,
- Telegram, Pizza, Topping, FilteredManager)
+ Telegram, Pizza, Topping, FilteredManager, StumpJoke)
from .admin import site, site2
+from .forms import StumpJokeForm
ERROR_MESSAGE = "Please enter the correct username and password \
@@ -3746,6 +3747,28 @@ def test_limit_choices_to_isnull_true(self):
self.assertContains(response2, "Palin")
+class LimitChoicesToTest(TestCase):
+ def setUp(self):
+ self.user1 = User.objects.create_user('threepwood')
+ self.user2 = User.objects.create_user('marley')
+
+ self.user1.date_joined = datetime.datetime.today() + datetime.timedelta(days=1)
+ self.user1.save()
+
+ self.user2.date_joined = datetime.datetime.today() - datetime.timedelta(days=1)
+ self.user2.save()
+
+ def test_limit_choices_to_callable_for_fk_rel(self):
+ stumpjokeform = StumpJokeForm()
+ self.assertIn(self.user1, stumpjokeform.fields['most_recently_fooled'].queryset)
+ self.assertNotIn(self.user2, stumpjokeform.fields['most_recently_fooled'].queryset)
+
+ def test_limit_choices_to_callable_for_m2m_rel(self):
+ stumpjokeform = StumpJokeForm()
+ self.assertIn(self.user1, stumpjokeform.fields['has_fooled_today'].queryset)
+ self.assertNotIn(self.user2, stumpjokeform.fields['has_fooled_today'].queryset)
+
+
@override_settings(PASSWORD_HASHERS=('django.contrib.auth.hashers.SHA1PasswordHasher',))
class UserAdminTest(TestCase):
"""
Something went wrong with that request. Please try again.