Skip to content

HTTPS clone URL

Subversion checkout URL

You can clone with
or
.
Download ZIP
Browse files

Fixed #2445 -- Allowed limit_choices_to attribute to be a callable.

ForeignKey or ManyToManyField attribute ``limit_choices_to`` can now
be a callable that returns either a ``Q`` object or a dict.

Thanks michael at actrix.gen.nz for the original suggestion.
  • Loading branch information...
commit eefc88feefec0c3685bfb102714530b751b4ae90 1 parent a718fcf
@adamsc64 adamsc64 authored timgraham committed
View
2  AUTHORS
@@ -57,7 +57,7 @@ answer newbie questions, and generally made Django that much better:
Gisle Aas <gisle@aas.no>
Chris Adams
- Christopher Adams <christopher.r.adams@gmail.com>
+ Christopher Adams <http://christopheradams.info>
Mathieu Agopian <mathieu.agopian@gmail.com>
Roberto Aguilar <roberto@baremetal.io>
ajs <adi@sieker.info>
View
5 django/contrib/admin/options.py
@@ -240,7 +240,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):
@@ -383,6 +383,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
12 django/contrib/admin/utils.py
@@ -459,17 +459,17 @@ 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
@@ -180,7 +180,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)
def url_parameters(self):
from django.contrib.admin.views.main import TO_FIELD_VAR
View
4 django/db/models/fields/__init__.py
@@ -742,11 +742,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
38 django/db/models/fields/related.py
@@ -309,6 +309,35 @@ 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):
+ """Passes ``limit_choices_to`` to field being constructed.
+
+ Only passes it if there is a type that supports related fields.
+ This is a similar strategy used to pass the ``queryset`` to the field
+ being constructed.
+ """
+ defaults = {}
+ if hasattr(self.rel, 'get_related_field'):
+ # If this is a callable, do not invoke it here. Just pass
+ # it in the defaults for when the form class will later be
+ # instantiated.
+ limit_choices_to = self.rel.limit_choices_to
+ defaults.update({
+ '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
@@ -1525,6 +1554,9 @@ 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(), self.related_accessor_class(related))
+ # While 'limit_choices_to' might be a callable, simply 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)
@@ -1633,7 +1665,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'],
@@ -1691,7 +1723,7 @@ def formfield(self, **kwargs):
(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)
@@ -2127,7 +2159,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
11 django/forms/fields.py
@@ -170,6 +170,17 @@ def widget_attrs(self, widget):
"""
return {}
+ 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 _has_changed(self, initial, data):
"""
Return True if data differs from initial.
View
13 django/forms/models.py
@@ -324,6 +324,15 @@ def __init__(self, data=None, files=None, auto_id='id_%s', prefix=None,
self._validate_unique = False
super(BaseModelForm, self).__init__(data, files, auto_id, prefix, object_data,
error_class, label_suffix, empty_permitted)
+ # Apply ``limit_choices_to`` to each field.
+ 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 _get_validation_exclusions(self):
"""
@@ -1082,7 +1091,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:
@@ -1094,6 +1104,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 # limit the queryset later.
self.choice_cache = None
self.to_field_name = to_field_name
View
46 docs/ref/models/fields.txt
@@ -1078,21 +1078,45 @@ 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 :class:`ModelForm <django.forms.ModelForm>`
- choices for this object. 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
+ in the queryset 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.
+
+ For example::
staff_member = models.ForeignKey(User, limit_choices_to={'is_staff': True})
causes the corresponding field on the ``ModelForm`` to list only ``Users``
- that have ``is_staff=True``.
-
- Instead of a dictionary this can also be a :class:`Q object
- <django.db.models.Q>` for more :ref:`complex queries
- <complex-lookups-with-q>`. However, if ``limit_choices_to`` is a :class:`Q
- object <django.db.models.Q>` then it will only have an effect on the
- choices available in the admin when the field is not listed in
- ``raw_id_fields`` in the ``ModelAdmin`` for the model.
+ that have ``is_staff=True``. This may be helpful in the Django admin.
+
+ The callable form can be helpful, for instance, when used in conjunction
+ with the Python ``datetime`` module to limit selections by date range. For
+ example::
+
+ limit_choices_to = lambda: {'pub_date__lte': datetime.date.utcnow()}
+
+ If ``limit_choices_to`` is or returns a :class:`Q object
+ <django.db.models.Q>`, which is useful for :ref:`complex queries
+ <complex-lookups-with-q>`, then it will only have an effect on the choices
+ available in the admin when the field is not listed in
+ :attr:`~django.contrib.admin.ModelAdmin.raw_id_fields` in the
+ ``ModelAdmin`` for the model.
+
+ .. versionchanged:: 1.7
+
+ Previous versions of Django do not allow passing a callable as a value
+ for ``limit_choices_to``.
+
+ .. 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, for example by management commands or 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 several times.
.. attribute:: ForeignKey.related_name
View
4 docs/releases/1.7.txt
@@ -608,6 +608,10 @@ Models
* It is now possible to use ``None`` as a query value for the :lookup:`iexact`
lookup.
+* It is now possible to pass a callable as value for the attribute
+ :attr:`ForeignKey.limit_choices_to` when defining a ``ForeignKey`` or
+ ``ManyToManyField``.
+
Signals
^^^^^^^
View
3  tests/admin_views/admin.py
@@ -34,7 +34,7 @@
UnchangeableObject, UserMessenger, Simple, Choice, ShortMessage, Telegram,
FilteredManager, EmptyModelHidden, EmptyModelVisible, EmptyModelMixin,
State, City, Restaurant, Worker, ParentWithDependentChildren,
- DependentChild)
+ DependentChild, StumpJoke)
def callable_year(dt_value):
@@ -884,6 +884,7 @@ class RestaurantAdmin(admin.ModelAdmin):
site.register(EmptyModelHidden, EmptyModelHiddenAdmin)
site.register(EmptyModelVisible, EmptyModelVisibleAdmin)
site.register(EmptyModelMixin, EmptyModelMixinAdmin)
+site.register(StumpJoke)
# Register core models we need in our tests
from django.contrib.auth.models import User, Group
View
27 tests/admin_views/models.py
@@ -173,6 +173,33 @@ def __str__(self):
return self.title
+def today_callable_dict():
+ return {"last_action__gte": datetime.datetime.today()}
+
+
+def today_callable_q():
+ return models.Q(last_action__gte=datetime.datetime.today())
+
+
+@python_2_unicode_compatible
+class Character(models.Model):
+ username = models.CharField(max_length=100)
+ last_action = models.DateTimeField()
+
+ def __str__(self):
+ return self.username
+
+
+@python_2_unicode_compatible
+class StumpJoke(models.Model):
+ variation = models.CharField(max_length=100)
+ most_recently_fooled = models.ForeignKey(Character, limit_choices_to=today_callable_dict, related_name="+")
+ has_fooled_today = models.ManyToManyField(Character, limit_choices_to=today_callable_q, related_name="+")
+
+ def __str__(self):
+ return self.variation
+
+
class Fabric(models.Model):
NG_CHOICES = (
('Textured', (
View
29 tests/admin_views/tests.py
@@ -52,7 +52,7 @@
Report, MainPrepopulated, RelatedPrepopulated, UnorderedObject,
Simple, UndeletableObject, UnchangeableObject, Choice, ShortMessage,
Telegram, Pizza, Topping, FilteredManager, City, Restaurant, Worker,
- ParentWithDependentChildren)
+ ParentWithDependentChildren, Character)
from .admin import site, site2, CityAdmin
@@ -3662,6 +3662,33 @@ def test_readonly_backwards_ref(self):
@override_settings(PASSWORD_HASHERS=('django.contrib.auth.hashers.SHA1PasswordHasher',))
+class LimitChoicesToInAdminTest(TestCase):
+ urls = "admin_views.urls"
+ fixtures = ['admin-views-users.xml']
+
+ def setUp(self):
+ self.client.login(username='super', password='secret')
+
+ def tearDown(self):
+ self.client.logout()
+
+ def test_limit_choices_to_as_callable(self):
+ """Test for ticket 2445 changes to admin."""
+ threepwood = Character.objects.create(
+ username='threepwood',
+ last_action=datetime.datetime.today() + datetime.timedelta(days=1),
+ )
+ marley = Character.objects.create(
+ username='marley',
+ last_action=datetime.datetime.today() - datetime.timedelta(days=1),
+ )
+ response = self.client.get('/test_admin/admin/admin_views/stumpjoke/add/')
+ # The allowed option should appear twice; the limited option should not appear.
+ self.assertContains(response, threepwood.username, count=2)
+ self.assertNotContains(response, marley.username)
+
+
+@override_settings(PASSWORD_HASHERS=('django.contrib.auth.hashers.SHA1PasswordHasher',))
class RawIdFieldsTest(TestCase):
urls = "admin_views.urls"
fixtures = ['admin-views-users.xml']
View
20 tests/model_forms/models.py
@@ -8,6 +8,7 @@
"""
from __future__ import unicode_literals
+import datetime
import os
import tempfile
@@ -71,7 +72,6 @@ class Article(models.Model):
status = models.PositiveIntegerField(choices=ARTICLE_STATUS, blank=True, null=True)
def save(self):
- import datetime
if not self.id:
self.created = datetime.date.today()
return super(Article, self).save()
@@ -329,3 +329,21 @@ class CustomErrorMessage(models.Model):
def clean(self):
if self.name1 == 'FORBIDDEN_VALUE':
raise ValidationError({'name1': [ValidationError('Model.clean() error messages.')]})
+
+
+def today_callable_dict():
+ return {"last_action__gte": datetime.datetime.today()}
+
+
+def today_callable_q():
+ return models.Q(last_action__gte=datetime.datetime.today())
+
+
+class Character(models.Model):
+ username = models.CharField(max_length=100)
+ last_action = models.DateTimeField()
+
+
+class StumpJoke(models.Model):
+ most_recently_fooled = models.ForeignKey(Character, limit_choices_to=today_callable_dict, related_name="+")
+ has_fooled_today = models.ManyToManyField(Character, limit_choices_to=today_callable_q, related_name="+")
View
40 tests/model_forms/tests.py
@@ -22,7 +22,8 @@
DerivedPost, ExplicitPK, FlexibleDatePost, ImprovedArticle,
ImprovedArticleWithParentLink, Inventory, Post, Price,
Product, TextFile, Writer, WriterProfile, Colour, ColourfulItem,
- ArticleStatusNote, DateTimePost, CustomErrorMessage, test_images)
+ ArticleStatusNote, DateTimePost, CustomErrorMessage, test_images,
+ StumpJoke, Character)
if test_images:
from .models import ImageFile, OptionalImageFile
@@ -521,6 +522,12 @@ class Meta:
}
+class StumpJokeForm(forms.ModelForm):
+ class Meta:
+ model = StumpJoke
+ fields = '__all__'
+
+
class TestFieldOverridesTroughFormMeta(TestCase):
def test_widget_overrides(self):
form = FieldOverridesTroughFormMetaForm()
@@ -1921,3 +1928,34 @@ class Form2(forms.Form):
self.assertEqual(list(type(str('NewForm'), (ModelForm, Mixin, Form), {})().fields.keys()), ['name'])
self.assertEqual(list(type(str('NewForm'), (ModelForm, Form, Mixin), {})().fields.keys()), ['name', 'age'])
self.assertEqual(list(type(str('NewForm'), (ModelForm, Form), {'age': None})().fields.keys()), ['name'])
+
+
+class LimitChoicesToTest(TestCase):
+ """
+ Tests the functionality of ``limit_choices_to``.
+ """
+ def setUp(self):
+ self.threepwood = Character.objects.create(
+ username='threepwood',
+ last_action=datetime.datetime.today() + datetime.timedelta(days=1),
+ )
+ self.marley = Character.objects.create(
+ username='marley',
+ last_action=datetime.datetime.today() - datetime.timedelta(days=1),
+ )
+
+ def test_limit_choices_to_callable_for_fk_rel(self):
+ """
+ A ForeignKey relation can use ``limit_choices_to`` as a callable, re #2554.
+ """
+ stumpjokeform = StumpJokeForm()
+ self.assertIn(self.threepwood, stumpjokeform.fields['most_recently_fooled'].queryset)
+ self.assertNotIn(self.marley, stumpjokeform.fields['most_recently_fooled'].queryset)
+
+ def test_limit_choices_to_callable_for_m2m_rel(self):
+ """
+ A ManyToMany relation can use ``limit_choices_to`` as a callable, re #2554.
+ """
+ stumpjokeform = StumpJokeForm()
+ self.assertIn(self.threepwood, stumpjokeform.fields['has_fooled_today'].queryset)
+ self.assertNotIn(self.marley, stumpjokeform.fields['has_fooled_today'].queryset)
Please sign in to comment.
Something went wrong with that request. Please try again.