Add option-groups capability to Choices.

This commit is contained in:
Carl Meyer 2013-08-29 22:00:53 -06:00
parent 32f839b577
commit 2e44f1c2c0
4 changed files with 135 additions and 29 deletions

View file

@ -4,6 +4,8 @@ CHANGES
master (unreleased)
-------------------
* `Choices` now accepts option-groupings. Fixes GH-14.
* `Choices` can now be added to other `Choices` or to any iterable, and can be
compared for equality with itself. Thanks Tony Aldridge. (Merge of GH-76.)

View file

@ -15,7 +15,6 @@ Choices
class Article(models.Model):
STATUS = Choices('draft', 'published')
# ...
status = models.CharField(choices=STATUS, default=STATUS.draft, max_length=20)
A ``Choices`` object is initialized with any number of choices. In the
@ -34,7 +33,6 @@ representation. In this case you can provide choices as two-tuples:
class Article(models.Model):
STATUS = Choices(('draft', _('draft')), ('published', _('published')))
# ...
status = models.CharField(choices=STATUS, default=STATUS.draft, max_length=20)
But what if your database representation of choices is constrained in
@ -52,9 +50,20 @@ the third is the human-readable version:
class Article(models.Model):
STATUS = Choices((0, 'draft', _('draft')), (1, 'published', _('published')))
# ...
status = models.IntegerField(choices=STATUS, default=STATUS.draft)
Option groups can also be used with ``Choices``; in that case each
argument is a tuple consisting of the option group name and a list of
options, where each option in the list is either a string, a two-tuple,
or a triple as outlined above. For example::
.. code-block:: python
from model_utils import Choices
class Article(models.Model):
STATUS = Choices(('Visible', ['new', 'archived']), ('Invisible', ['draft', 'deleted']))
Choices can be concatenated with the ``+`` operator, both to other Choices
instances and other iterable objects that could be converted into Choices:
@ -66,7 +75,6 @@ instances and other iterable objects that could be converted into Choices:
class Article(models.Model):
STATUS = GENERIC_CHOICES + [(2, 'featured', _('featured'))]
# ...
status = models.IntegerField(choices=STATUS, default=STATUS.draft)

View file

@ -34,66 +34,111 @@ class Choices(object):
identifier, the database representation itself is available as an
attribute on the ``Choices`` object, returning itself.)
Option groups can also be used with ``Choices``; in that case each
argument is a tuple consisting of the option group name and a list
of options, where each option in the list is either a string, a
two-tuple, or a triple as outlined above.
"""
def __init__(self, *choices):
self._full = []
self._choices = []
self._choice_dict = {}
for choice in self.equalize(choices):
self._full.append(choice)
self._choices.append((choice[0], choice[2]))
self._choice_dict[choice[1]] = choice[0]
# list of choices expanded to triples - can include optgroups
self._triples = []
# list of choices as (db, human-readable) - can include optgroups
self._doubles = []
# dictionary mapping Python identifier to db representation
self._mapping = {}
# set of db representations
self._db_values = set()
self._process(choices)
def _store(self, triple, triple_collector, double_collector):
self._mapping[triple[1]] = triple[0]
self._db_values.add(triple[0])
triple_collector.append(triple)
double_collector.append((triple[0], triple[2]))
def _process(self, choices, triple_collector=None, double_collector=None):
if triple_collector is None:
triple_collector = self._triples
if double_collector is None:
double_collector = self._doubles
store = lambda c: self._store(c, triple_collector, double_collector)
def equalize(self, choices):
for choice in choices:
if isinstance(choice, (list, tuple)):
if len(choice) == 3:
yield choice
store(choice)
elif len(choice) == 2:
yield (choice[0], choice[0], choice[1])
if isinstance(choice[1], (list, tuple)):
# option group
group_name = choice[0]
subchoices = choice[1]
tc = []
triple_collector.append((group_name, tc))
dc = []
double_collector.append((group_name, dc))
self._process(subchoices, tc, dc)
else:
store((choice[0], choice[0], choice[1]))
else:
raise ValueError("Choices can't handle a list/tuple of length %s, only 2 or 3"
% len(choice))
raise ValueError(
"Choices can't take a list of length %s, only 2 or 3"
% len(choice)
)
else:
yield (choice, choice, choice)
store((choice, choice, choice))
def __len__(self):
return len(self._choices)
return len(self._doubles)
def __iter__(self):
return iter(self._choices)
return iter(self._doubles)
def __getattr__(self, attname):
try:
return self._choice_dict[attname]
return self._mapping[attname]
except KeyError:
raise AttributeError(attname)
def __getitem__(self, index):
return self._choices[index]
return self._doubles[index]
def __add__(self, other):
if isinstance(other, self.__class__):
other = other._full
other = other._triples
else:
other = list(other)
return Choices(*(self._full + other))
return Choices(*(self._triples + other))
def __radd__(self, other):
# radd is never called for matching types, so we don't check here
other = list(other)
return Choices(*(other + self._full))
return Choices(*(other + self._triples))
def __eq__(self, other):
if isinstance(other, self.__class__):
return self._full == other._full
return self._triples == other._triples
return False
def __repr__(self):
return '%s(%s)' % (self.__class__.__name__,
', '.join(("%s" % repr(i) for i in self._full)))
return '%s(%s)' % (
self.__class__.__name__,
', '.join(("%s" % repr(i) for i in self._triples))
)
def __contains__(self, item):
if item in self._choice_dict.values():
return True
return item in self._db_values

View file

@ -222,10 +222,12 @@ class ChoicesTests(TestCase):
with self.assertRaises(ValueError):
Choices(('a',))
def test_contains_value(self):
self.assertTrue('PUBLISHED' in self.STATUS)
self.assertTrue('DRAFT' in self.STATUS)
def test_doesnt_contain_value(self):
self.assertFalse('UNPUBLISHED' in self.STATUS)
@ -245,6 +247,17 @@ class ChoicesTests(TestCase):
self.assertEqual(('DRAFT',) + Choices('PUBLISHED'), self.STATUS)
def test_option_groups(self):
c = Choices(('group a', ['one', 'two']), ['group b', ('three',)])
self.assertEqual(
list(c),
[
('group a', [('one', 'one'), ('two', 'two')]),
('group b', [('three', 'three')]),
],
)
class LabelChoicesTests(ChoicesTests):
def setUp(self):
self.STATUS = Choices(
@ -302,6 +315,7 @@ class LabelChoicesTests(ChoicesTests):
('DELETED', 'DELETED', 'DELETED'),
)))
def test_contains_value(self):
self.assertTrue('PUBLISHED' in self.STATUS)
self.assertTrue('DRAFT' in self.STATUS)
@ -309,9 +323,11 @@ class LabelChoicesTests(ChoicesTests):
# and the internal representation are both DELETED.
self.assertTrue('DELETED' in self.STATUS)
def test_doesnt_contain_value(self):
self.assertFalse('UNPUBLISHED' in self.STATUS)
def test_doesnt_contain_display_value(self):
self.assertFalse('is draft' in self.STATUS)
@ -333,6 +349,21 @@ class LabelChoicesTests(ChoicesTests):
)
def test_option_groups(self):
c = Choices(
('group a', [(1, 'one'), (2, 'two')]),
['group b', ((3, 'three'),)]
)
self.assertEqual(
list(c),
[
('group a', [(1, 'one'), (2, 'two')]),
('group b', [(3, 'three')]),
],
)
class IdentifierChoicesTests(ChoicesTests):
def setUp(self):
self.STATUS = Choices(
@ -367,20 +398,25 @@ class IdentifierChoicesTests(ChoicesTests):
(2, 'DELETED', 'is deleted'),
)))
def test_contains_value(self):
self.assertTrue(0 in self.STATUS)
self.assertTrue(1 in self.STATUS)
self.assertTrue(2 in self.STATUS)
def test_doesnt_contain_value(self):
self.assertFalse(3 in self.STATUS)
def test_doesnt_contain_display_value(self):
self.assertFalse('is draft' in self.STATUS)
def test_doesnt_contain_python_attr(self):
self.assertFalse('PUBLISHED' in self.STATUS)
def test_equality(self):
self.assertEqual(self.STATUS, Choices(
(0, 'DRAFT', 'is draft'),
@ -388,6 +424,7 @@ class IdentifierChoicesTests(ChoicesTests):
(2, 'DELETED', 'is deleted')
))
def test_inequality(self):
self.assertNotEqual(self.STATUS, [
(0, 'DRAFT', 'is draft'),
@ -429,6 +466,20 @@ class IdentifierChoicesTests(ChoicesTests):
)
def test_option_groups(self):
c = Choices(
('group a', [(1, 'ONE', 'one'), (2, 'TWO', 'two')]),
['group b', ((3, 'THREE', 'three'),)]
)
self.assertEqual(
list(c),
[
('group a', [(1, 'one'), (2, 'two')]),
('group b', [(3, 'three')]),
],
)
class InheritanceManagerTests(TestCase):
def setUp(self):
self.child1 = InheritanceManagerTestChild1.objects.create()