From 2e44f1c2c0fcd07bd617d7c648640f7312b59b5d Mon Sep 17 00:00:00 2001 From: Carl Meyer Date: Thu, 29 Aug 2013 22:00:53 -0600 Subject: [PATCH] Add option-groups capability to Choices. --- CHANGES.rst | 2 + docs/utilities.rst | 16 +++++-- model_utils/choices.py | 95 ++++++++++++++++++++++++++++---------- model_utils/tests/tests.py | 51 ++++++++++++++++++++ 4 files changed, 135 insertions(+), 29 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index 50697fd..3c20df0 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -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.) diff --git a/docs/utilities.rst b/docs/utilities.rst index 1eab98b..838b87f 100644 --- a/docs/utilities.rst +++ b/docs/utilities.rst @@ -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) diff --git a/model_utils/choices.py b/model_utils/choices.py index b657e59..f69fc9b 100644 --- a/model_utils/choices.py +++ b/model_utils/choices.py @@ -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 diff --git a/model_utils/tests/tests.py b/model_utils/tests/tests.py index 4d99bb5..e873944 100644 --- a/model_utils/tests/tests.py +++ b/model_utils/tests/tests.py @@ -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()