├── .coveragerc ├── .gitignore ├── .travis.yml ├── CHANGELOG ├── LICENSE ├── MANIFEST.in ├── README.rst ├── dynamic_choices ├── __init__.py ├── admin.py ├── db │ ├── __init__.py │ ├── models.py │ └── query.py ├── fixtures │ ├── dynamic_choices_admin_test_data.json │ └── dynamic_choices_test_data.json ├── forms │ ├── __init__.py │ └── fields.py ├── models.py ├── static │ └── js │ │ ├── dynamic-choices-admin.js │ │ └── dynamic-choices.js ├── templates │ └── admin │ │ └── dynamic_choices │ │ └── change_form.html └── utils.py ├── setup.cfg ├── setup.py ├── tests ├── __init__.py ├── admin.py ├── forms.py ├── models.py ├── settings.py ├── templates │ └── dynamic_choices_tests │ │ ├── do_not_extends_change_form.html │ │ ├── extends_change_form.html │ │ └── extends_change_form_twice.html ├── test_admin.py ├── test_models.py └── urls.py └── tox.ini /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | source = dynamic_choices 3 | branch = 1 -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.db 2 | *.pyc 3 | dist/* 4 | .coverage 5 | .tox 6 | django_dynamic_choices.egg-info 7 | build/* 8 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | 3 | python: 4 | - 2.7 5 | - 3.2 6 | - 3.3 7 | - 3.4 8 | 9 | env: 10 | - DJANGO=1.6 11 | - DJANGO=1.7 12 | - DJANGO=1.8 13 | - DJANGO=master 14 | 15 | matrix: 16 | include: 17 | - python: 2.6 18 | env: DJANGO=1.6 19 | exclude: 20 | - python: 3.2 21 | env: DJANGO=master 22 | - python: 3.4 23 | env: DJANGO=1.6 24 | allow_failures: 25 | - env: DJANGO=master 26 | 27 | install: 28 | pip install tox coveralls 29 | 30 | script: 31 | tox -e `python -c 'import sys,os;print("py%d%d-"%sys.version_info[0:2]+os.environ["DJANGO"])'` 32 | 33 | after_success: coveralls 34 | -------------------------------------------------------------------------------- /CHANGELOG: -------------------------------------------------------------------------------- 1 | ============================ 2 | django-dynamic-choices changelog 3 | ============================ 4 | 5 | Version 1.0.0, 22 february 2015 6 | -------------------------------- 7 | * Added support for Django 1.7 8 | 9 | Version 0.3.0, 6 august 2012 10 | -------------------------------- 11 | * Simplified grouped queryset validation 12 | * Added tested support for 1.4.X 13 | 14 | Version 0.2.0, 31 March 2012: 15 | -------------------------------- 16 | * dropped django 1.2.X support 17 | * added django 1.4.X support 18 | 19 | Version 0.1.8, 17 August 2011: 20 | -------------------------------- 21 | * added DynamicChoicesOneToOneField. 22 | 23 | Version 0.1.7, 20 June 2011: 24 | -------------------------------- 25 | * fixed the FilteredSelectMultiple widget handler to works when the the widget 26 | is not initialized yet such as when the widget is used in an inline. 27 | * simplified choices callback validation logic by using the class_prepared signal. 28 | * added minimal testing for validation logic. 29 | * fixed an issue with lazy reference FK and M2M. 30 | 31 | Version 0.1.6, 7 May 2011: 32 | -------------------------------- 33 | * raise exception when a custom admin add|change_form_template is specified 34 | on a DynamicAdmin if it doesn't extends the dynamic_choices one. 35 | * make sure admin select widgets trigger 'change' events when a new option is added to them 36 | without relying on DOMEvents. 37 | * make sure DynamicModelChoiceField's queryset returns distinct objects to avoid raising 38 | MultipleObjectsReturned when the dynamically generated queryset spans over multiple tables 39 | and returns the same object twice. 40 | 41 | Version 0.1.5, 5 April 2011: 42 | -------------------------------- 43 | 44 | * fixed some issue with the add link binders in the admin 45 | * make DynamicChoicesQueryset cloneable 46 | 47 | Version 0.1.4, 8 March 2011: 48 | -------------------------------- 49 | 50 | * make sure Promise objects such as translation objects specified in the choices callback 51 | are encoded correcly. 52 | * make sure admin select widgets trigger 'change' events when a new option is added to them 53 | * added js field onchange bindings while making sure to avoid circular references 54 | 55 | Version 0.1.3, 18 February 2011: 56 | -------------------------------- 57 | 58 | * added js api to bind FK/M2M admin widget add links to specific fields 59 | * added support for multiple jQuery versions 60 | * fixed an issue with admin dynamic-choices js files not considering settings.(STATIC|MEDIA)_URL. Thanks bmeyer71@github.com 61 | * embed admin choices binder directly in the page 62 | (make sure to extend "admin/dynamic_choices_change_form.html" if you're overriding DynamicAdmin.change|add_form_template) 63 | 64 | Version 0.1.2, 5 February 2011: 65 | -------------------------------- 66 | 67 | * fixed an issue with choices callback with no lookups 68 | * fixed an issue with user defined forms on DynamicAdmin and inlines 69 | * handle 3+ depth descriptors (field__field__...) 70 | 71 | Version 0.1.1, 29 December 2010: 72 | -------------------------------- 73 | 74 | * fixed an issue with formset empty form 75 | * added support for south 76 | * added support for reversion 77 | 78 | I'm planing to refactor the code for version 0.2.0 in order to add support for dynamic managers or "model instance managers". 79 | Dynamic choices will be bound to such managers. Data provided will also be wrapped in some class and tuple choices in some kind of QuerysetCollection. 80 | Stay tuned! 81 | 82 | Version 0.1.0, 27 December 2010: 83 | -------------------------------- 84 | 85 | * initial release 86 | 87 | At the moment this project is more of a "proof of concept" or exploration in order to find a clever way of adding dynamic model field choices to django. 88 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | copyright (c) 2010 SIMON CHARETTE 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.rst 2 | include CHANGELOG 3 | include LICENSE 4 | recursive-include dynamic_choices/static * 5 | recursive-include dynamic_choices/templates * -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | django-dynamic-choices 2 | ====================== 3 | 4 | .. image:: https://travis-ci.org/charettes/django-dynamic-choices.svg?branch=master 5 | :target: https://travis-ci.org/charettes/django-dynamic-choices 6 | :alt: Build Status 7 | 8 | .. image:: https://coveralls.io/repos/charettes/django-dynamic-choices/badge.svg?branch=master 9 | :target: https://coveralls.io/r/charettes/django-dynamic-choices?branch=master 10 | :alt: Coverage Status 11 | 12 | Django appilcation that provides fk and m2m dynamic choices and react to django.contrib.admin 13 | edit/add view interaction to update those choices. 14 | 15 | Installation 16 | ------------ 17 | 18 | .. code:: sh 19 | 20 | pip install django-dynamic-choices 21 | -------------------------------------------------------------------------------- /dynamic_choices/__init__.py: -------------------------------------------------------------------------------- 1 | VERSION = (1, 2, 0, 'a', 0) 2 | -------------------------------------------------------------------------------- /dynamic_choices/admin.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | import json 4 | from functools import update_wrapper 5 | 6 | import django 7 | from django.conf.urls import url 8 | from django.contrib import admin 9 | from django.core.exceptions import ImproperlyConfigured, ValidationError 10 | from django.db import models 11 | from django.db.models.constants import LOOKUP_SEP 12 | from django.forms.models import ModelForm, _get_foreign_key, model_to_dict 13 | from django.forms.widgets import Select, SelectMultiple 14 | from django.http import Http404, HttpResponse, HttpResponseBadRequest 15 | from django.template.defaultfilters import escape 16 | from django.utils.encoding import force_text 17 | from django.utils.functional import Promise 18 | from django.utils.safestring import SafeText 19 | from django.utils.six import with_metaclass 20 | from django.utils.six.moves import range 21 | 22 | from .forms import DynamicModelForm, dynamic_model_form_factory 23 | from .forms.fields import DynamicModelChoiceField 24 | from .utils import template_extends 25 | 26 | 27 | class LazyEncoder(json.JSONEncoder): 28 | def default(self, obj): 29 | if isinstance(obj, Promise): 30 | return force_text(obj) 31 | return super(LazyEncoder, self).default(obj) 32 | 33 | lazy_encoder = LazyEncoder() 34 | 35 | 36 | def get_dynamic_choices_from_form(form): 37 | fields = {} 38 | if form.prefix: 39 | prefix = "%s-%s" % (form.prefix, '%s') 40 | else: 41 | prefix = '%s' 42 | for name, field in form.fields.items(): 43 | if isinstance(field, DynamicModelChoiceField): 44 | widget_cls = field.widget.widget.__class__ 45 | if widget_cls in (Select, SelectMultiple): 46 | widget = 'default' 47 | else: 48 | widget = "%s.%s" % (widget_cls.__module__, 49 | widget_cls.__name__) 50 | fields[prefix % name] = { 51 | 'widget': widget, 52 | 'value': list(field.widget.choices) 53 | } 54 | return fields 55 | 56 | 57 | def dynamic_formset_factory(fieldset_cls, initial): 58 | class cls(fieldset_cls): 59 | def __init__(self, *args, **kwargs): 60 | super(cls, self).__init__(*args, **kwargs) 61 | store = getattr(self, 'initial', None) 62 | if store is None: 63 | store = [] 64 | setattr(self, 'initial', store) 65 | for i in range(self.total_form_count()): 66 | try: 67 | actual = store[i] 68 | actual.update(initial) 69 | except (ValueError, IndexError): 70 | store.insert(i, initial) 71 | 72 | @property 73 | def empty_form(self): 74 | form = self.form( 75 | auto_id=self.auto_id, 76 | prefix=self.add_prefix('__prefix__'), 77 | empty_permitted=True, 78 | initial=initial, 79 | ) 80 | self.add_fields(form, None) 81 | return form 82 | 83 | cls.__name__ = str("Dynamic%s" % fieldset_cls.__name__) 84 | return cls 85 | 86 | 87 | def dynamic_inline_factory(inline_cls): 88 | "Make sure the inline has a dynamic form" 89 | form_cls = getattr(inline_cls, 'form', None) 90 | if form_cls is ModelForm: 91 | form_cls = DynamicModelForm 92 | elif issubclass(form_cls, DynamicModelForm): 93 | return inline_cls 94 | else: 95 | form_cls = dynamic_model_form_factory(form_cls) 96 | 97 | class cls(inline_cls): 98 | form = form_cls 99 | 100 | def get_formset(self, request, obj=None, **kwargs): 101 | formset = super(cls, self).get_formset(request, obj=None, **kwargs) 102 | if not isinstance(formset.form(), DynamicModelForm): 103 | raise Exception('DynamicAdmin inlines\'s formset\'s form must be an instance of DynamicModelForm') 104 | return formset 105 | 106 | cls.__name__ = str("Dynamic%s" % inline_cls.__name__) 107 | return cls 108 | 109 | 110 | def dynamic_admin_factory(admin_cls): 111 | 112 | change_form_template = 'admin/dynamic_choices/change_form.html' 113 | 114 | class meta_cls(type(admin_cls)): 115 | 116 | "Metaclass that ensure form and inlines are dynamic" 117 | def __new__(cls, name, bases, attrs): 118 | # If there's already a form defined we make sure to subclass it 119 | if 'form' in attrs: 120 | attrs['form'] = dynamic_model_form_factory(attrs['form']) 121 | else: 122 | attrs['form'] = DynamicModelForm 123 | 124 | # Make sure the specified add|change_form_template 125 | # extends "admin/dynamic_choices/change_form.html" 126 | for t, default in [('add_form_template', None), 127 | ('change_form_template', change_form_template)]: 128 | if t in attrs: 129 | if not template_extends(attrs[t], change_form_template): 130 | raise ImproperlyConfigured( 131 | "Make sure %s.%s template extends '%s' in order to enable DynamicAdmin" % ( 132 | name, t, change_form_template 133 | ) 134 | ) 135 | else: 136 | attrs[t] = default 137 | 138 | # If there's some inlines defined we make sure that their form is dynamic 139 | # see dynamic_inline_factory 140 | if 'inlines' in attrs: 141 | attrs['inlines'] = [dynamic_inline_factory(inline_cls) for inline_cls in attrs['inlines']] 142 | 143 | return super(meta_cls, cls).__new__(cls, name, bases, attrs) 144 | 145 | class cls(with_metaclass(meta_cls, admin_cls)): 146 | def _media(self): 147 | media = super(cls, self).media 148 | media.add_js(('js/dynamic-choices.js', 149 | 'js/dynamic-choices-admin.js')) 150 | return media 151 | media = property(_media) 152 | 153 | def get_urls(self): 154 | def wrap(view): 155 | def wrapper(*args, **kwargs): 156 | return self.admin_site.admin_view(view)(*args, **kwargs) 157 | return update_wrapper(wrapper, view) 158 | 159 | info = self.model._meta.app_label, self.model._meta.model_name 160 | 161 | urlpatterns = [ 162 | url(r'(?:add|(?P\w+))/choices/$', 163 | wrap(self.dynamic_choices), 164 | name="%s_%s_dynamic_admin" % info), 165 | ] + super(cls, self).get_urls() 166 | 167 | return urlpatterns 168 | 169 | def get_dynamic_choices_binder(self, request): 170 | 171 | def id(field): 172 | return "[name='%s']" % field 173 | 174 | def inline_field_selector(fieldset, field): 175 | return "[name^='%s-'][name$='-%s']" % (fieldset, field) 176 | 177 | fields = {} 178 | 179 | def add_fields(to_fields, to_field, bind_fields): 180 | if not (to_field in to_fields): 181 | to_fields[to_field] = set() 182 | to_fields[to_field].update(bind_fields) 183 | 184 | model_name = self.model._meta.model_name 185 | 186 | # Use get_form in order to allow formfield override 187 | # We should create a fake request from referer but all this 188 | # hack will be fixed when the code is embed directly in the page 189 | form = self.get_form(request)() 190 | rels = form.get_dynamic_relationships() 191 | for rel in rels: 192 | field_name = rel.split(LOOKUP_SEP)[0] 193 | if rel in form.fields: 194 | add_fields(fields, id(field_name), [id(field) for field in rels[rel] if field in form.fields]) 195 | 196 | inlines = {} 197 | for formset, _inline in self.get_formsets_with_inlines(request): 198 | inline = {} 199 | formset_form = formset.form() 200 | inline_rels = formset_form.get_dynamic_relationships() 201 | prefix = formset.get_default_prefix() 202 | for rel in inline_rels: 203 | if LOOKUP_SEP in rel: 204 | base, field = rel.split(LOOKUP_SEP)[0:2] 205 | if base == model_name and field in form.fields: 206 | bound_fields = [ 207 | inline_field_selector(prefix, f) 208 | for f in inline_rels[rel] if f in formset_form.fields 209 | ] 210 | add_fields(fields, id(field), bound_fields) 211 | elif base in formset_form.fields: 212 | add_fields(inline, base, inline_rels[rel]) 213 | elif rel in formset_form.fields: 214 | add_fields(inline, rel, inline_rels[rel]) 215 | if len(inline): 216 | inlines[prefix] = inline 217 | 218 | # Replace sets in order to allow JSON serialization 219 | for field, bound_fields in fields.items(): 220 | fields[field] = list(bound_fields) 221 | 222 | for fieldset, inline_fields in inlines.items(): 223 | for field, bound_fields in inline_fields.items(): 224 | inlines[fieldset][field] = list(bound_fields) 225 | 226 | return SafeText("django.dynamicAdmin(%s, %s);" % (json.dumps(fields), json.dumps(inlines))) 227 | 228 | def dynamic_choices(self, request, object_id=None): 229 | 230 | opts = self.model._meta 231 | obj = self.get_object(request, object_id) 232 | # Make sure the specified object exists 233 | if object_id is not None and obj is None: 234 | raise Http404('%(name)s object with primary key %(key)r does not exist.' % { 235 | 'name': force_text(opts.verbose_name), 'key': escape(object_id)}) 236 | 237 | form = self.get_form(request)(request.GET, instance=obj) 238 | data = get_dynamic_choices_from_form(form) 239 | 240 | for formset, _inline in self.get_formsets_with_inlines(request, obj): 241 | prefix = formset.get_default_prefix() 242 | try: 243 | fs = formset(request.GET, instance=obj) 244 | forms = fs.forms + [fs.empty_form] 245 | except ValidationError: 246 | return HttpResponseBadRequest("Missing %s ManagementForm data" % prefix) 247 | for form in forms: 248 | data.update(get_dynamic_choices_from_form(form)) 249 | 250 | if 'DYNAMIC_CHOICES_FIELDS' in request.GET: 251 | fields = request.GET.get('DYNAMIC_CHOICES_FIELDS').split(',') 252 | for field in list(data): 253 | if field not in fields: 254 | del data[field] 255 | 256 | return HttpResponse(lazy_encoder.encode(data), content_type='application/json') 257 | 258 | if django.VERSION >= (1, 7): 259 | _get_formsets_with_inlines = admin_cls.get_formsets_with_inlines 260 | else: 261 | def _get_formsets_with_inlines(self, request, obj=None): 262 | formsets = super(cls, self).get_formsets(request, obj) 263 | inlines = self.get_inline_instances(request, obj) 264 | for formset, inline in zip(formsets, inlines): 265 | yield formset, inline 266 | 267 | def get_formsets(self, request, obj=None): 268 | for formset, _inline in self.get_formsets_with_inlines(request, obj): 269 | yield formset 270 | 271 | def get_formsets_with_inlines(self, request, obj=None): 272 | # Make sure to pass request data to fieldsets 273 | # so they can use it to define choices 274 | initial = {} 275 | model = self.model 276 | opts = model._meta 277 | data = getattr(request, request.method).items() 278 | # If an object is provided we collect data 279 | if obj is not None: 280 | initial.update(model_to_dict(obj)) 281 | # Make sure to collect parent model data 282 | # and provide it to fieldsets in the form of 283 | # parent__field from request if its provided. 284 | # This data should be more "up-to-date". 285 | for k, v in data: 286 | if v: 287 | try: 288 | f = opts.get_field(k) 289 | except models.FieldDoesNotExist: 290 | continue 291 | if isinstance(f, models.ManyToManyField): 292 | initial[k] = v.split(",") 293 | else: 294 | initial[k] = v 295 | 296 | for formset, inline in self._get_formsets_with_inlines(request, obj): 297 | fk = _get_foreign_key(self.model, inline.model, fk_name=inline.fk_name).name 298 | fk_initial = dict(('%s__%s' % (fk, k), v) for k, v in initial.items()) 299 | # If we must provide additional data 300 | # we must wrap the formset in a subclass 301 | # because passing 'initial' key argument is intercepted 302 | # and not provided to subclasses by BaseInlineFormSet.__init__ 303 | if len(initial): 304 | formset = dynamic_formset_factory(formset, fk_initial) 305 | yield formset, inline 306 | 307 | def add_view(self, request, form_url='', extra_context=None): 308 | context = {'dynamic_choices_binder': self.get_dynamic_choices_binder(request)} 309 | context.update(extra_context or {}) 310 | return super(cls, self).add_view(request, form_url='', extra_context=context) 311 | 312 | def change_view(self, request, object_id, extra_context=None): 313 | context = {'dynamic_choices_binder': self.get_dynamic_choices_binder(request)} 314 | context.update(extra_context or {}) 315 | return super(cls, self).change_view(request, object_id, extra_context=context) 316 | 317 | return cls 318 | 319 | DynamicAdmin = dynamic_admin_factory(admin.ModelAdmin) 320 | 321 | try: 322 | from reversion.admin import VersionAdmin 323 | DynamicVersionAdmin = dynamic_admin_factory(VersionAdmin) 324 | except ImportError: 325 | pass 326 | -------------------------------------------------------------------------------- /dynamic_choices/db/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/charettes/django-dynamic-choices/08abc345e2763c58a6efeb578ede6a0eae1755aa/dynamic_choices/db/__init__.py -------------------------------------------------------------------------------- /dynamic_choices/db/models.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | import inspect 4 | 5 | from django.core import exceptions 6 | from django.core.exceptions import FieldError 7 | from django.db.models import ForeignKey, ManyToManyField, OneToOneField 8 | from django.db.models.base import Model 9 | from django.db.models.constants import LOOKUP_SEP 10 | from django.db.models.fields import Field, FieldDoesNotExist 11 | from django.db.models.fields.related import add_lazy_relation 12 | from django.db.models.query import QuerySet 13 | from django.db.models.signals import class_prepared 14 | from django.forms.models import model_to_dict 15 | from django.utils import six 16 | 17 | from ..forms.fields import ( 18 | DynamicModelChoiceField, DynamicModelMultipleChoiceField, 19 | ) 20 | from .query import CompositeQuerySet, dynamic_queryset_factory 21 | 22 | 23 | class DynamicChoicesField(object): 24 | 25 | def __init__(self, *args, **kwargs): 26 | super(DynamicChoicesField, self).__init__(*args, **kwargs) 27 | # Hack to bypass non iterable choices validation 28 | if isinstance(self._choices, six.string_types) or callable(self._choices): 29 | self._choices_callback = self._choices 30 | self._choices = [] 31 | else: 32 | self._choices_callback = None 33 | self._choices_relationships = None 34 | 35 | def contribute_to_class(self, cls, name): 36 | super(DynamicChoicesField, self).contribute_to_class(cls, name) 37 | 38 | if self._choices_callback is not None: 39 | class_prepared.connect(self.__validate_definition, sender=cls) 40 | 41 | def __validate_definition(self, *args, **kwargs): 42 | def error(message): 43 | raise FieldError("%s: %s: %s" % (self.model._meta, self.name, message)) 44 | 45 | original_choices_callback = self._choices_callback 46 | 47 | # The choices we're defined by a string 48 | # therefore it should be a cls method 49 | if isinstance(self._choices_callback, six.string_types): 50 | callback = getattr(self.model, self._choices_callback, None) 51 | if not callable(callback): 52 | error('Cannot find method specified by choices.') 53 | args_length = 2 # Since the callback is a method we must emulate the 'self' 54 | self._choices_callback = callback 55 | self._choices_callback_requires_instance = True 56 | else: 57 | args_length = 1 # It's a callable, it needs no reference to model instance 58 | self._choices_callback_requires_instance = False 59 | 60 | spec = inspect.getargspec(self._choices_callback) 61 | 62 | # Make sure the callback has the correct number or arg 63 | if spec.defaults is not None: 64 | spec_defaults_len = len(spec.defaults) 65 | args_length += spec_defaults_len 66 | self._choices_relationships = spec.args[-spec_defaults_len:] 67 | else: 68 | self._choices_relationships = [] 69 | 70 | if len(spec.args) != args_length: 71 | error('Specified choices callback must accept only a single arg') 72 | 73 | self._choices_callback_field_descriptors = {} 74 | 75 | # We make sure field descriptors are valid 76 | for descriptor in self._choices_relationships: 77 | lookups = descriptor.split(LOOKUP_SEP) 78 | meta = self.model._meta 79 | depth = len(lookups) 80 | step = 1 81 | fields = [] 82 | for lookup in lookups: 83 | try: 84 | field = meta.get_field(lookup) 85 | # The field is a foreign key to another model 86 | if isinstance(field, ForeignKey): 87 | try: 88 | meta = field.rel.to._meta 89 | except AttributeError: 90 | # The model hasn't been loaded yet 91 | # so we must stop here and start over 92 | # when it is loaded. 93 | if isinstance(field.rel.to, six.string_types): 94 | self._choices_callback = original_choices_callback 95 | return add_lazy_relation(field.model, field, 96 | field.rel.to, 97 | self.__validate_definition) 98 | else: 99 | raise 100 | step += 1 101 | # We cannot go deeper if it's not a model 102 | elif step != depth: 103 | error('Invalid descriptor "%s", "%s" is not a ForeignKey to a model' % ( 104 | LOOKUP_SEP.join(lookups), LOOKUP_SEP.join(lookups[:step]))) 105 | fields.append(field) 106 | except FieldDoesNotExist: 107 | # Lookup failed, suggest alternatives 108 | depth_descriptor = LOOKUP_SEP.join(descriptor[:step - 1]) 109 | if depth_descriptor: 110 | depth_descriptor += LOOKUP_SEP 111 | choice_descriptors = [(depth_descriptor + name) for name in meta.get_all_field_names()] 112 | error('Invalid descriptor "%s", choices are %s' % ( 113 | LOOKUP_SEP.join(descriptor), ', '.join(choice_descriptors))) 114 | 115 | self._choices_callback_field_descriptors[descriptor] = fields 116 | 117 | @property 118 | def has_choices_callback(self): 119 | return callable(self._choices_callback) 120 | 121 | @property 122 | def choices_relationships(self): 123 | return self._choices_relationships 124 | 125 | def _invoke_choices_callback(self, model_instance, qs, data): 126 | args = [qs] 127 | # Make sure we pass the instance if the callback is a class method 128 | if self._choices_callback_requires_instance: 129 | args.insert(0, model_instance) 130 | 131 | values = {} 132 | for descriptor, fields in self._choices_callback_field_descriptors.items(): 133 | depth = len(fields) 134 | step = 1 135 | lookup_data = data 136 | value = None 137 | 138 | # Direct lookup 139 | # foo__bar in data 140 | if descriptor in data: 141 | value = data[descriptor] 142 | step = depth 143 | field = fields[-1] 144 | else: 145 | # We're going to try to lookup every step of the descriptor. 146 | # We first try field1, then field1__field2, etc.. 147 | # When there's a match we start over with fieldmatch and set the lookup data 148 | # to the matched value. 149 | field_name = "%s" 150 | for field in fields: 151 | field_name = field_name % field.name 152 | if field_name in lookup_data: 153 | value = lookup_data[field_name] 154 | if step != depth: 155 | if isinstance(field, ManyToManyField): 156 | # We cannot lookup in m2m, it must be the final step 157 | break 158 | elif isinstance(value, list): 159 | value = value[0] # Make sure we've got a scalar 160 | if isinstance(field, ForeignKey): 161 | if value is None: 162 | break 163 | elif not isinstance(value, Model): 164 | try: 165 | value = field.rel.to.objects.get(pk=value) 166 | except Exception: 167 | break 168 | lookup_data = model_to_dict(value) 169 | field_name = "%s" 170 | step += 1 171 | elif step != depth: 172 | field_name = "%s%s%s" % (field_name, LOOKUP_SEP, '%s') 173 | 174 | # We reached descriptors depth 175 | if step == depth: 176 | if isinstance(value, list) and \ 177 | not isinstance(field, ManyToManyField): 178 | value = value[0] # Make sure we've got a scalar if its not a m2m 179 | # Attempt to cast value, if failed we don't assign since it's invalid 180 | try: 181 | values[descriptor] = field.to_python(value) 182 | except Exception: 183 | pass 184 | 185 | return self._choices_callback(*args, **values) 186 | 187 | def __super(self): 188 | # Dirty hack to allow both DynamicChoicesForeignKey and DynamicChoicesManyToManyField 189 | # to inherit this behavior with multiple inheritance 190 | for base in self.__class__.__bases__: 191 | if issubclass(base, Field): 192 | self.__super = lambda: base # cache 193 | return base 194 | raise Exception('Subclasses must inherit from atleast one subclass of django.db.fields.Field') 195 | 196 | def formfield(self, **kwargs): 197 | if self.has_choices_callback: 198 | db = kwargs.pop('using', None) 199 | qs = self.rel.to._default_manager.using(db).complex_filter(self.rel.limit_choices_to) 200 | defaults = { 201 | 'using': db, 202 | 'form_class': self.form_class, 203 | 'queryset': dynamic_queryset_factory(qs, self) 204 | } 205 | defaults.update(kwargs) 206 | else: 207 | defaults = kwargs 208 | 209 | return self.__super().formfield(self, **defaults) 210 | 211 | 212 | class DynamicChoicesForeignKeyMixin(DynamicChoicesField): 213 | 214 | def validate(self, value, model_instance): 215 | if self.has_choices_callback: 216 | if value is None: 217 | return 218 | 219 | data = model_to_dict(model_instance) 220 | for field in model_instance._meta.fields: 221 | try: 222 | data[field.name] = getattr(model_instance, field.name) 223 | except field.rel.to.DoesNotExist: 224 | pass 225 | if model_instance.pk: 226 | for m2m in model_instance._meta.many_to_many: 227 | data[m2m.name] = getattr(model_instance, m2m.name).all() 228 | 229 | queryset = self.rel.to._default_manager.filter(**{self.rel.field_name: value}) 230 | queryset = queryset.complex_filter(self.rel.limit_choices_to) 231 | 232 | queryset = self._invoke_choices_callback(model_instance, queryset, data) 233 | 234 | # If the choices are not a queryset we assume it's an iterable of couple 235 | # of label and querysets. 236 | if not isinstance(queryset, QuerySet): 237 | queryset = CompositeQuerySet(qs[1] for qs in queryset) 238 | 239 | if not queryset.exists(): 240 | raise exceptions.ValidationError(self.error_messages['invalid'], code='invalid', params={ 241 | 'model': self.rel.to._meta.verbose_name, 242 | 'field': self.rel.field_name, 243 | 'value': value, 244 | 'pk': value, # included for backwards compatibility 245 | }) 246 | else: 247 | super(DynamicChoicesForeignKeyMixin, self).validate(value, model_instance) 248 | 249 | 250 | class DynamicChoicesForeignKey(DynamicChoicesForeignKeyMixin, ForeignKey): 251 | 252 | form_class = DynamicModelChoiceField 253 | 254 | 255 | class DynamicChoicesOneToOneField(DynamicChoicesForeignKeyMixin, OneToOneField): 256 | 257 | form_class = DynamicModelChoiceField 258 | 259 | 260 | class DynamicChoicesManyToManyField(DynamicChoicesField, ManyToManyField): 261 | 262 | form_class = DynamicModelMultipleChoiceField 263 | 264 | try: 265 | from south.modelsinspector import add_introspection_rules 266 | add_introspection_rules([], ['^dynamic_choices\.db\.models']) 267 | except ImportError: 268 | pass 269 | -------------------------------------------------------------------------------- /dynamic_choices/db/query.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | from itertools import chain 4 | 5 | import django 6 | from django.db.models.query import EmptyQuerySet, QuerySet 7 | 8 | 9 | class CompositeQuerySet(object): 10 | 11 | """ 12 | A queryset like object composed of multiple querysets 13 | """ 14 | 15 | def __init__(self, querysets): 16 | self._querysets = tuple(querysets) 17 | self.model = self.querysets[0].model 18 | assert all(qs.model == self.model for qs in self.querysets[1:]), \ 19 | 'All querysets must be of the same model' 20 | 21 | @property 22 | def querysets(self): 23 | return self._querysets 24 | 25 | def __iter__(self): 26 | return chain(*self.querysets) 27 | 28 | def get(self, *args, **kwargs): 29 | for qs in self.querysets: 30 | try: 31 | obj = qs.get(*args, **kwargs) 32 | except self.model.DoesNotExist: 33 | pass 34 | else: 35 | return obj 36 | raise self.model.DoesNotExist 37 | 38 | def _compose(self, method, *args, **kwargs): 39 | return self.__class__(getattr(qs, method)(*args, **kwargs) 40 | for qs in self.querysets) 41 | 42 | def filter(self, *args, **kwargs): 43 | return self._compose('filter', *args, **kwargs) 44 | 45 | def distinct(self): 46 | return self._compose('distinct') 47 | 48 | def exists(self): 49 | return any(qs.exists() for qs in self.querysets) 50 | 51 | 52 | class EmptyDynamicChoicesQuerySet(EmptyQuerySet): 53 | 54 | def filter_for_instance(self): 55 | return self 56 | 57 | 58 | class DynamicChoicesQuerySet(QuerySet): 59 | 60 | def _clone(self, *args, **kwargs): 61 | clone = super(DynamicChoicesQuerySet, self)._clone(*args, **kwargs) 62 | clone._field = self._field 63 | return clone 64 | 65 | if django.VERSION >= (1, 6): 66 | def filter_for_instance(self, instance, data): 67 | if self.query.is_empty(): 68 | return self 69 | return self._field._invoke_choices_callback(instance, self, data) 70 | else: 71 | def filter_for_instance(self, instance, data): 72 | return self._field._invoke_choices_callback(instance, self, data) 73 | 74 | def none(self): 75 | return self._clone(klass=EmptyDynamicChoicesQuerySet) 76 | 77 | 78 | def dynamic_queryset_factory(queryset, field): 79 | 80 | clone = queryset._clone(DynamicChoicesQuerySet) 81 | clone._field = field 82 | 83 | return clone 84 | -------------------------------------------------------------------------------- /dynamic_choices/fixtures/dynamic_choices_admin_test_data.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "pk": 1, 4 | "model": "auth.user", 5 | "fields": { 6 | "username": "superuser", 7 | "is_active": true, 8 | "is_superuser": true, 9 | "is_staff": true, 10 | "password": "sha1$8c78a$1e7c5a48d821bcb237a6d11c8487fff977e921ee", 11 | "email": "su@do" 12 | } 13 | } 14 | ] 15 | -------------------------------------------------------------------------------- /dynamic_choices/fixtures/dynamic_choices_test_data.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "pk": 1, 4 | "model": "dynamic_choices.master", 5 | "fields": { 6 | "alignment": 1 7 | } 8 | }, 9 | { 10 | "pk": 2, 11 | "model": "dynamic_choices.master", 12 | "fields": { 13 | "alignment": 0 14 | } 15 | }, 16 | { 17 | "pk": 1, 18 | "model": "dynamic_choices.puppet", 19 | "fields": { 20 | "friends": [], 21 | "master": 1, 22 | "alignment": 1 23 | } 24 | }, 25 | { 26 | "pk": 2, 27 | "model": "dynamic_choices.puppet", 28 | "fields": { 29 | "friends": [], 30 | "master": 2, 31 | "alignment": 0 32 | } 33 | }, 34 | { 35 | "pk": 1, 36 | "model": "dynamic_choices.enemy", 37 | "fields": { 38 | "enemy": 2, 39 | "since": "2010-12-12", 40 | "puppet": 1, 41 | "because_of": 2 42 | } 43 | } 44 | ] -------------------------------------------------------------------------------- /dynamic_choices/forms/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | from django.forms.models import ModelForm 4 | 5 | from .fields import DynamicModelChoiceField, DynamicModelMultipleChoiceField # NOQA 6 | 7 | 8 | __all__ = ('DynamicModelForm', 'dynamic_model_form_factory') 9 | 10 | 11 | def original_dynamic_model_form_factory(model_form_cls): 12 | class cls(model_form_cls): 13 | 14 | def __init__(self, *args, **kwargs): 15 | super(cls, self).__init__(*args, **kwargs) 16 | 17 | # Fetch initial data for initial 18 | data = self.initial.copy() 19 | 20 | # Update data if it's available 21 | for field in self.fields: 22 | raw_value = self._raw_value(field) 23 | if raw_value is not None: 24 | if raw_value: 25 | data[field] = raw_value 26 | elif field in data: 27 | del data[field] 28 | 29 | # Bind instances to dynamic fields 30 | for field in self.fields.values(): 31 | if isinstance(field, DynamicModelChoiceField): 32 | field.set_choice_data(self.instance, data) 33 | 34 | def get_dynamic_relationships(self): 35 | rels = {} 36 | opts = self.instance._meta 37 | for name, field in self.fields.items(): 38 | # TODO: check for excludes? 39 | if isinstance(field, DynamicModelChoiceField): 40 | for choice in opts.get_field(name).choices_relationships: 41 | if not (choice in rels): 42 | rels[choice] = set() 43 | rels[choice].add(name) 44 | return rels 45 | 46 | cls.__name__ = str("Dynamic%s" % model_form_cls.__name__) 47 | return cls 48 | 49 | DynamicModelForm = original_dynamic_model_form_factory(ModelForm) 50 | 51 | 52 | def dynamic_model_form_factory(model_form_cls): 53 | cls = original_dynamic_model_form_factory(model_form_cls) 54 | cls.__bases__ += (DynamicModelForm,) 55 | return cls 56 | -------------------------------------------------------------------------------- /dynamic_choices/forms/fields.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | from django.db.models.query import QuerySet 4 | from django.forms.fields import ChoiceField 5 | from django.forms.models import ( 6 | ModelChoiceField, ModelChoiceIterator, ModelMultipleChoiceField, 7 | ) 8 | 9 | from ..db.query import CompositeQuerySet, DynamicChoicesQuerySet 10 | 11 | 12 | class GroupedModelChoiceIterator(ModelChoiceIterator): 13 | 14 | def __init__(self, field): 15 | super(GroupedModelChoiceIterator, self).__init__(field) 16 | self.groups = field._groups 17 | 18 | def __iter__(self): 19 | if self.field.empty_label is not None: 20 | yield ("", self.field.empty_label) 21 | 22 | for label, queryset in self.groups: 23 | yield (label, [self.choice(obj) for obj in queryset]) 24 | 25 | def __len__(self): 26 | return sum(len(group[1]) for group in self.groups) 27 | 28 | 29 | class DynamicModelChoiceField(ModelChoiceField): 30 | 31 | def __init__(self, *args, **kwargs): 32 | self._instance = None 33 | self._data = {} 34 | self._groups = None 35 | super(DynamicModelChoiceField, self).__init__(*args, **kwargs) 36 | 37 | def _get_queryset(self): 38 | return self._queryset.distinct() 39 | 40 | def _set_queryset(self, queryset): 41 | self._original_queryset = queryset 42 | self._groups = None 43 | if self._instance and isinstance(queryset, DynamicChoicesQuerySet): 44 | queryset = queryset.filter_for_instance(self._instance, self._data) 45 | if not isinstance(queryset, QuerySet): 46 | self._groups = queryset 47 | queryset = CompositeQuerySet(q[1] for q in queryset) 48 | self._queryset = queryset 49 | self.widget.choices = self.choices 50 | 51 | queryset = property(_get_queryset, _set_queryset) 52 | 53 | def set_choice_data(self, instance, data): 54 | self._instance = instance 55 | self._data = data 56 | self.queryset = self._original_queryset 57 | 58 | def _get_choices(self): 59 | if self._groups is None: 60 | return super(DynamicModelChoiceField, self)._get_choices() 61 | return GroupedModelChoiceIterator(self) 62 | 63 | choices = property(_get_choices, ChoiceField._set_choices) 64 | 65 | 66 | class DynamicModelMultipleChoiceField(DynamicModelChoiceField, ModelMultipleChoiceField): 67 | pass 68 | -------------------------------------------------------------------------------- /dynamic_choices/models.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | # Empty module to make sure this app can be found by django 4 | -------------------------------------------------------------------------------- /dynamic_choices/static/js/dynamic-choices-admin.js: -------------------------------------------------------------------------------- 1 | ($ || jQuery || django.jQuery)(function($) { 2 | var assignOptions = $.fn.updateFields.widgetHandlers['default']; 3 | 4 | var filteredSelectMultiple = 'django.contrib.admin.widgets.FilteredSelectMultiple'; 5 | $.fn.updateFields.widgetHandlers[filteredSelectMultiple] = function(field, values) { 6 | var chosenField = $(field), 7 | alreadyChosens = $(field.options).map(function(index, element) { 8 | return element.value; 9 | }), 10 | availableField = $('#id_' + field.name + '_from'), 11 | fromCache = [], 12 | toCache = [], 13 | availables = [], 14 | chosens = []; 15 | 16 | //The widget isn't initialized yet (it might a be an inline empty template) 17 | if (availableField.length == 0) { 18 | return assignOptions(chosenField, values); 19 | } 20 | 21 | SelectBox.cache['id_' + field.name + '_from'] = []; 22 | SelectBox.cache['id_' + field.name + '_to'] = []; 23 | 24 | availableField.empty(); 25 | chosenField.empty(); 26 | 27 | $(values).each(function(index, value) { 28 | //We cast the value to string since the "type" is lost when retreiving 29 | //from option.value by SelectBox 30 | var chosen = $.inArray(String(value[0]), alreadyChosens) != -1; 31 | (chosen ? chosens : availables).push(value); 32 | (chosen ? toCache : fromCache).push({ 33 | value: value[0], 34 | text: value[1], 35 | displayed: 1 36 | }); 37 | }); 38 | 39 | SelectBox.cache['id_' + field.name + '_from'] = fromCache; 40 | SelectBox.cache['id_' + field.name + '_to'] = toCache; 41 | 42 | assignOptions(availableField, availables); 43 | assignOptions(chosenField, chosens); 44 | }; 45 | 46 | django.dynamicAdmin = function(fields, inlines) { 47 | var url = document.location.pathname + 'choices/'; 48 | for (f in fields) { 49 | $(f).bindFields(url, fields[f].join(', ')); 50 | } 51 | for (f in inlines) { 52 | $('#' + f + '-group').bindFieldset(url, f, inlines[f]); 53 | } 54 | }; 55 | 56 | var DATA_ORIGINAL_HREF = 'data-original-href'; 57 | 58 | function getAddLink(element) { 59 | var addLink = $('#add_' + element.id); 60 | if (!addLink.length) throw new Error('Cannot find add link of field ' + element.id); 61 | return addLink; 62 | }; 63 | 64 | function prepareAddLink(element) { 65 | var addLink = getAddLink(element); 66 | addLink.attr(DATA_ORIGINAL_HREF, addLink.attr('href')); 67 | }; 68 | 69 | function updateAddLink(element, fields, fieldsetFields, parametersCallback) { 70 | var addLink = getAddLink(element), 71 | parameters = {}, 72 | encodedParameters = []; 73 | 74 | $(fields).each(function(index, field) { 75 | var name = $(field).attr('name'), 76 | value = $(field).val(); 77 | if (value) parameters[name] = value; 78 | }); 79 | $(fieldsetFields).each(function(index, field) { 80 | var name = inlineField(field).name, 81 | value = $(field).val(); 82 | if (value) parameters[name] = value; 83 | }); 84 | 85 | if ($.isFunction(parametersCallback)) parameters = parametersCallback(parameters); 86 | 87 | for (var name in parameters) { 88 | encodedParameters.push([encodeURI(name), encodeURI(parameters[name])].join('=')); 89 | } 90 | 91 | $(addLink).attr('href', addLink.attr(DATA_ORIGINAL_HREF) + '?' + encodedParameters.join('&')); 92 | }; 93 | 94 | django.dynamicAdmin.bindFieldsAddLink = function(field, fields, parametersCallback) { 95 | $(field).each(function(index, element) { 96 | try { 97 | prepareAddLink(element); 98 | } catch (e) { 99 | return 100 | } 101 | $(fields).change(function() { 102 | updateAddLink(element, fields, null, parametersCallback); 103 | }); 104 | updateAddLink(element, fields, null, parametersCallback); 105 | }); 106 | }; 107 | 108 | function inlineField(field) { 109 | var field = $(field).attr('name').split('-'); 110 | return { 111 | fieldset: field[0], 112 | index: field[1], 113 | name: field[2] 114 | } 115 | }; 116 | 117 | function buildInlineFieldSelector(fieldName) { 118 | return '[name$="' + fieldName + '"]'; 119 | }; 120 | 121 | function buildInlineFieldId(formsetName, fieldName, fieldIndex) { 122 | return '#id_' + formsetName + '-' + fieldIndex + '-' + fieldName; 123 | }; 124 | 125 | function buildFormsetFieldsSelector(formsetName, fields, fieldIndex) { 126 | return $(fields).map(function(index, field) { 127 | return buildInlineFieldId(formsetName, field, fieldIndex); 128 | }).toArray().join(', '); 129 | }; 130 | 131 | django.dynamicAdmin.bindFormsetFieldsAddLink = function(formset, field, boundFormsetFields, boundFormFieldsSelector, parametersCallback) { 132 | boundFormsetFields = boundFormsetFields || []; 133 | boundFormFieldsSelector = boundFormFieldsSelector || ''; 134 | var fieldSelector = buildInlineFieldSelector(field); 135 | $(formset).each(function(index, formset) { 136 | var formsetName = formset.id.match(/^(\w+)-group$/)[1] 137 | boundFieldsetFieldsSelector = $(boundFormsetFields) 138 | .map(function(i, e) { 139 | return buildInlineFieldSelector(e) 140 | }) 141 | .toArray().join(', '); 142 | var fields = $(formset).find(fieldSelector).map(function(index, element) { 143 | try { 144 | prepareAddLink(element) 145 | } catch (e) { 146 | return false 147 | }; 148 | var index = inlineField(element).index; 149 | updateAddLink(element, 150 | boundFormFieldsSelector, 151 | buildFormsetFieldsSelector(formsetName, boundFormsetFields, index), 152 | parametersCallback); 153 | return true; 154 | }).toArray(); 155 | if ($.inArray(true, fields) == -1) return; 156 | $(formset).delegate(boundFieldsetFieldsSelector, 'change', function(event) { 157 | var index = inlineField(event.target).index; 158 | updateAddLink($(buildInlineFieldId(formsetName, field, index))[0], 159 | boundFormFieldsSelector, 160 | buildFormsetFieldsSelector(formsetName, boundFormsetFields, index), 161 | parametersCallback); 162 | }); 163 | $(boundFormFieldsSelector).change(function(event) { 164 | $(formset).find(fieldSelector).each(function(index, element) { 165 | var index = inlineField(element).index; 166 | updateAddLink(element, 167 | boundFormFieldsSelector, 168 | buildFormsetFieldsSelector(formsetName, boundFormsetFields, index), 169 | parametersCallback); 170 | }); 171 | }); 172 | }); 173 | }; 174 | 175 | // Creating new instance within the admin doesn't trigger the change event on select fields. 176 | // Attempt to trigger it correcly. 177 | var originalDismissAddAnotherPopup = dismissAddAnotherPopup; 178 | dismissAddAnotherPopup = function(win, newId, newRepr) { 179 | originalDismissAddAnotherPopup(win, newId, newRepr); 180 | newId = html_unescape(newId); 181 | newRepr = html_unescape(newRepr); 182 | var id = windowname_to_id(win.name); 183 | $('#' + id).trigger('change'); 184 | }; 185 | dismissAddAnotherPopup.original = originalDismissAddAnotherPopup; 186 | 187 | }); -------------------------------------------------------------------------------- /dynamic_choices/static/js/dynamic-choices.js: -------------------------------------------------------------------------------- 1 | ($ || jQuery || django.jQuery)(function($) { 2 | var DATA_BOUND_FIELDS = 'data-dynamic-choices-bound-fields', 3 | DATA_FORMSET = 'data-dynamic-choices-formset'; 4 | 5 | var error = (function() { 6 | if ('console' in window && $.isFunction(console.error)) 7 | return function(e) { 8 | console.error(e); 9 | }; 10 | else return function(e) { 11 | throw new Error(e); 12 | }; 13 | })(); 14 | 15 | function getFieldNames(fields) { 16 | return fields.map(function(index, field) { 17 | return field.name; 18 | }).toArray(); 19 | } 20 | 21 | $.fn.updateFields = function(url, form) { 22 | var handlers = $.fn.updateFields.widgetHandlers; 23 | if (this.length) { 24 | form = $(form ? form : this[0].form); 25 | var fields = $(this).addClass('loading'), 26 | data = $(form).serializeArray(), 27 | boundFields = []; 28 | // Make sure fields bound to these ones are updated right after 29 | fields.each(function(i, field) { 30 | var selector = $(field).attr(DATA_BOUND_FIELDS); 31 | if (selector) boundFields.push(selector); 32 | var formset = $(field).closest('[' + DATA_FORMSET + ']').attr(DATA_FORMSET); 33 | if (formset) boundFields.push($.fn.bindFieldset.formsetsBindings[formset](field)); 34 | }); 35 | fields.addClass('loading'); 36 | data.push({ 37 | name: 'DYNAMIC_CHOICES_FIELDS', 38 | value: getFieldNames(fields).join(',') 39 | }); 40 | $.getJSON(url, $.param(data), function(json) { 41 | fields.each(function(index, field) { 42 | if (field.name in json) { 43 | var data = json[field.name]; 44 | if (data.widget in handlers) { 45 | handlers[data.widget](field, data.value); 46 | $(field).trigger('change', { 47 | 'triggeredByDynamicChoices': true 48 | }); 49 | } else error('Missing handler for "' + data.widget + '" widget.'); 50 | } 51 | $(field).removeClass('loading'); 52 | }); 53 | $(boundFields.join(', ')).updateFields(url, form); 54 | }); 55 | } 56 | return this; 57 | }; 58 | 59 | function assignOptions(element, options) { 60 | $(options).each(function(index, option) { 61 | if ($.isArray(option[1])) { 62 | var optGroup = $('').attr({ 63 | label: option[0] 64 | }); 65 | assignOptions(optGroup, option[1]); 66 | element.append(optGroup); 67 | } else { 68 | element.append($('').attr({ 69 | value: option[0] 70 | }).html(option[1])); 71 | } 72 | }); 73 | } 74 | 75 | function selectWidgetHandler(select, options) { 76 | select = $(select); 77 | var value = select.val(); 78 | select.empty(); 79 | assignOptions(select, options); 80 | select.val(value); 81 | } 82 | 83 | $.fn.updateFields.widgetHandlers = { 84 | 'default': selectWidgetHandler 85 | }; 86 | 87 | $.fn.bindFields = function(url, fields) { 88 | var handlers = $.fn.bindFields.widgetHandlers; 89 | return this.each(function(index, field) { 90 | $(field).change(function(event, data) { 91 | if (data && 'triggeredByDynamicChoices' in data) return; 92 | $(fields).updateFields(url, field.form); 93 | }).attr(DATA_BOUND_FIELDS, fields); 94 | }); 95 | }; 96 | 97 | function defaultFieldNameExtractor(fieldset, field) { 98 | var match = field.match(/^([\w_]+)-(\w+)-([\w_]+)$/); 99 | if (match && match[1] == fieldset) { 100 | return { 101 | index: match[2], 102 | name: match[3] 103 | }; 104 | } else error('Can\'t resolve field "' + field + '" of specified fieldset "' + fieldset + '".'); 105 | } 106 | 107 | function defaultFieldSelectorBuilder(fieldset, field, index) { 108 | return '#id_' + fieldset + '-' + index + '-' + field; 109 | } 110 | 111 | function curryBuilder(fieldset, index, builder) { 112 | return function(i, field) { 113 | return builder(fieldset, field, index); 114 | }; 115 | } 116 | 117 | function formsetFieldBoundFields(fieldset, field, fields, extractor, builder) { 118 | field = extractor(fieldset, field.name); 119 | if (field.name in fields) { 120 | var selectors = $(fields[field.name]).map(curryBuilder(fieldset, field.index, builder)); 121 | return selectors.toArray().join(', '); 122 | } else return ''; 123 | } 124 | 125 | $.fn.bindFieldset = function(url, fieldset, fields, extractor, builder) { 126 | extractor = $.isFunction(extractor) ? extractor : defaultFieldNameExtractor; 127 | builder = $.isFunction(builder) ? builder : defaultFieldSelectorBuilder; 128 | $.fn.bindFieldset.formsetsBindings[fieldset] = function(field) { 129 | return formsetFieldBoundFields(fieldset, field, fields, extractor, builder); 130 | }; 131 | return this.each(function(index, container) { 132 | $(container).change(function(event, data) { 133 | if (data && 'triggeredByDynamicChoices' in data) return; 134 | var target = event.target, 135 | selectors = formsetFieldBoundFields(fieldset, target, fields, extractor, builder); 136 | $(selectors).updateFields(url, target.form); 137 | }).attr(DATA_FORMSET, fieldset); 138 | }); 139 | }; 140 | $.fn.bindFieldset.formsetsBindings = {}; 141 | }); -------------------------------------------------------------------------------- /dynamic_choices/templates/admin/dynamic_choices/change_form.html: -------------------------------------------------------------------------------- 1 | {% extends "admin/change_form.html" %} 2 | {% block extrahead %}{{ block.super }} 3 | 4 | {% endblock %} 5 | -------------------------------------------------------------------------------- /dynamic_choices/utils.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | from django.template.loader import get_template 4 | from django.template.loader_tags import ExtendsNode 5 | 6 | 7 | def template_extends(template_name, expected_parent_name): 8 | """Returns whether or not a template extends the specified parent""" 9 | template = get_template(template_name) 10 | template = getattr(template, 'template', template) 11 | if template.nodelist and isinstance(template.nodelist[0], ExtendsNode): 12 | node = template.nodelist[0] 13 | parent_name = node.parent_name.resolve({}) 14 | if parent_name == expected_parent_name: 15 | return True 16 | return template_extends(parent_name, expected_parent_name) 17 | return False 18 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length = 119 3 | 4 | [isort] 5 | combine_as_imports=true 6 | include_trailing_comma=true 7 | multi_line_output=5 8 | 9 | [metadata] 10 | license-file = LICENSE 11 | 12 | [wheel] 13 | universal = 1 14 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | from setuptools import setup, find_packages 3 | 4 | from dynamic_choices import VERSION 5 | 6 | 7 | github_url = 'https://github.com/charettes/django-dynamic-choices' 8 | long_desc = ''' 9 | %s 10 | 11 | %s 12 | ''' % (open('README.rst').read(), open('CHANGELOG').read()) 13 | 14 | setup( 15 | name='django-dynamic-choices', 16 | version='.'.join(str(v) for v in VERSION), 17 | description='Django admin fk and m2m dynamic choices by providing callback support', 18 | long_description=long_desc, 19 | url=github_url, 20 | author='Simon Charette', 21 | author_email='charette.s+django-dynamic-choices@gmail.com', 22 | license='MIT', 23 | classifiers=[ 24 | 'Development Status :: 5 - Production/Stable', 25 | 'Environment :: Web Environment', 26 | 'Framework :: Django', 27 | 'Intended Audience :: Developers', 28 | 'License :: OSI Approved :: MIT License', 29 | 'Operating System :: OS Independent', 30 | 'Programming Language :: Python', 31 | 'Programming Language :: Python :: 2', 32 | 'Programming Language :: Python :: 2.7', 33 | 'Programming Language :: Python :: 3', 34 | 'Programming Language :: Python :: 3.2', 35 | 'Programming Language :: Python :: 3.3', 36 | 'Programming Language :: Python :: 3.4', 37 | 'Topic :: Internet :: WWW/HTTP', 38 | 'Topic :: Internet :: WWW/HTTP :: Dynamic Content', 39 | 'Topic :: Internet :: WWW/HTTP :: WSGI', 40 | 'Topic :: Software Development :: Libraries :: Application Frameworks', 41 | 'Topic :: Software Development :: Libraries :: Python Modules', 42 | ], 43 | keywords=['django admin choices dynamic'], 44 | packages=find_packages(exclude=['tests', 'tests.*']), 45 | install_requires=['Django>=1.6,<1.9'], 46 | include_package_data=True, 47 | ) 48 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/charettes/django-dynamic-choices/08abc345e2763c58a6efeb578ede6a0eae1755aa/tests/__init__.py -------------------------------------------------------------------------------- /tests/admin.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | from django.contrib import admin 4 | 5 | from dynamic_choices.admin import DynamicAdmin 6 | 7 | from .forms import UserDefinedForm 8 | from .models import Master, Puppet 9 | 10 | 11 | class EnemyInline(admin.TabularInline): 12 | model = Puppet.enemies.through 13 | fk_name = 'puppet' 14 | form = UserDefinedForm 15 | 16 | 17 | class PuppetAdmin(DynamicAdmin): 18 | inlines = (EnemyInline,) 19 | form = UserDefinedForm 20 | 21 | 22 | class MasterAdmin(DynamicAdmin): 23 | pass 24 | 25 | 26 | site = admin.AdminSite('admin') 27 | site.register(Puppet, PuppetAdmin) 28 | site.register(Master, MasterAdmin) 29 | -------------------------------------------------------------------------------- /tests/forms.py: -------------------------------------------------------------------------------- 1 | 2 | from __future__ import unicode_literals 3 | 4 | from django import forms 5 | 6 | 7 | class UserDefinedForm(forms.ModelForm): 8 | pass 9 | -------------------------------------------------------------------------------- /tests/models.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | from django.db import models 4 | from django.utils.encoding import force_text 5 | from django.utils.six import python_2_unicode_compatible 6 | from django.utils.translation import ugettext_lazy as _ 7 | 8 | from dynamic_choices.db.models import ( 9 | DynamicChoicesForeignKey, DynamicChoicesManyToManyField, 10 | DynamicChoicesOneToOneField, 11 | ) 12 | 13 | ALIGNMENT_EVIL = 0 14 | ALIGNMENT_GOOD = 1 15 | ALIGNMENT_NEUTRAL = 2 16 | 17 | ALIGNMENT_CHOICES = [ 18 | (ALIGNMENT_EVIL, _('Evil')), 19 | (ALIGNMENT_GOOD, _('Good')), 20 | (ALIGNMENT_NEUTRAL, _('Neutral')), 21 | ] 22 | 23 | 24 | def same_alignment(queryset, alignment=None): 25 | return queryset.filter(alignment=alignment) 26 | 27 | 28 | def alignment_display(alignment): 29 | for align, label in ALIGNMENT_CHOICES: 30 | if alignment == align: 31 | return force_text(label) 32 | 33 | 34 | @python_2_unicode_compatible 35 | class Master(models.Model): 36 | alignment = models.SmallIntegerField(choices=ALIGNMENT_CHOICES) 37 | 38 | class Meta: 39 | app_label = 'dynamic_choices' 40 | 41 | def __str__(self): 42 | return "%s master (%s)" % (self.get_alignment_display(), self.pk) 43 | 44 | 45 | @python_2_unicode_compatible 46 | class Puppet(models.Model): 47 | alignment = models.SmallIntegerField(choices=ALIGNMENT_CHOICES) 48 | master = DynamicChoicesForeignKey(Master, choices=same_alignment) 49 | secret_lover = DynamicChoicesOneToOneField( 50 | 'self', choices='choices_for_secret_lover', related_name='secretly_loves_me', blank=True, null=True 51 | ) 52 | friends = DynamicChoicesManyToManyField('self', choices='choices_for_friends', blank=True, null=True) 53 | enemies = DynamicChoicesManyToManyField('self', through='Enemy', symmetrical=False, blank=True, null=True) 54 | 55 | class Meta: 56 | app_label = 'dynamic_choices' 57 | 58 | def __str__(self): 59 | return "%s puppet (%s)" % (self.get_alignment_display(), self.pk) 60 | 61 | def choices_for_friends(self, queryset, id=None, alignment=None): 62 | """Make sure our friends share our alignment or are neutral""" 63 | same_alignment = queryset.filter(alignment=alignment).exclude(id=id) 64 | if alignment in (None, ALIGNMENT_NEUTRAL): 65 | return same_alignment 66 | return ( 67 | (alignment_display(alignment), same_alignment), 68 | ('Neutral', queryset.filter(alignment=ALIGNMENT_NEUTRAL)) 69 | ) 70 | 71 | def choices_for_secret_lover(self, queryset): 72 | if self.pk: 73 | try: 74 | secretly_loves_me_qs = queryset.filter(secret_lover=self.pk) 75 | secretly_loves_me_qs.get() 76 | except (Puppet.DoesNotExist, Puppet.MultipleObjectsReturned): 77 | pass 78 | else: 79 | return secretly_loves_me_qs 80 | return queryset 81 | 82 | 83 | class Enemy(models.Model): 84 | puppet = DynamicChoicesForeignKey(Puppet) 85 | enemy = DynamicChoicesForeignKey(Puppet, choices='choices_for_enemy', related_name='+') 86 | because_of = DynamicChoicesForeignKey(Master, choices='choices_for_because_of', related_name='becauses_of') 87 | since = models.DateField() 88 | 89 | class Meta: 90 | app_label = 'dynamic_choices' 91 | 92 | def choices_for_because_of(self, queryset, enemy__alignment=None): 93 | return queryset.filter(alignment=enemy__alignment) 94 | 95 | def choices_for_enemy(self, queryset, puppet__alignment=None): 96 | if puppet__alignment is None: 97 | return queryset.none() 98 | return [ 99 | (label, queryset.filter(alignment=alignment)) 100 | for alignment, label in ALIGNMENT_CHOICES if alignment != puppet__alignment 101 | ] 102 | -------------------------------------------------------------------------------- /tests/settings.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | import os 4 | 5 | import django 6 | 7 | MODULE_PATH = os.path.abspath(os.path.dirname(__file__)) 8 | 9 | DEBUG = True 10 | 11 | SECRET_KEY = 'not-anymore' 12 | 13 | TIME_ZONE = 'America/Chicago' 14 | 15 | DATABASES = { 16 | 'default': { 17 | 'ENGINE': 'django.db.backends.sqlite3', 18 | } 19 | } 20 | 21 | SITE_ID = 1 22 | 23 | TEMPLATES = [ 24 | { 25 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 26 | 'DIRS': [os.path.join(MODULE_PATH, 'templates')], 27 | 'APP_DIRS': True, 28 | 'OPTIONS': { 29 | 'context_processors': [ 30 | 'django.template.context_processors.debug', 31 | 'django.template.context_processors.request', 32 | 'django.contrib.auth.context_processors.auth', 33 | 'django.contrib.messages.context_processors.messages', 34 | ], 35 | }, 36 | }, 37 | ] 38 | 39 | INSTALLED_APPS = [ 40 | 'django.contrib.auth', 41 | 'django.contrib.contenttypes', 42 | 'django.contrib.sessions', 43 | 'django.contrib.sites', 44 | 'django.contrib.admin', 45 | 'dynamic_choices', 46 | ] 47 | 48 | MIDDLEWARE_CLASSES = [ 49 | 'django.middleware.common.CommonMiddleware', 50 | 'django.contrib.sessions.middleware.SessionMiddleware', 51 | 'django.middleware.csrf.CsrfViewMiddleware', 52 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 53 | 'django.contrib.messages.middleware.MessageMiddleware', 54 | ] 55 | 56 | if django.VERSION >= (1, 7): 57 | MIDDLEWARE_CLASSES.append('django.contrib.auth.middleware.SessionAuthenticationMiddleware') 58 | 59 | ROOT_URLCONF = 'tests.urls' 60 | -------------------------------------------------------------------------------- /tests/templates/dynamic_choices_tests/do_not_extends_change_form.html: -------------------------------------------------------------------------------- 1 | {% extends "admin/change_form.html" %} 2 | {# This template do not extends "admin/dynamic_choices/change_form.html" #} 3 | -------------------------------------------------------------------------------- /tests/templates/dynamic_choices_tests/extends_change_form.html: -------------------------------------------------------------------------------- 1 | {% extends "admin/dynamic_choices/change_form.html" %} 2 | -------------------------------------------------------------------------------- /tests/templates/dynamic_choices_tests/extends_change_form_twice.html: -------------------------------------------------------------------------------- 1 | {% extends "dynamic_choices_tests/extends_change_form.html" %} 2 | -------------------------------------------------------------------------------- /tests/test_admin.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | import json 4 | import os 5 | 6 | from django.core.exceptions import ImproperlyConfigured 7 | from django.test import SimpleTestCase, TestCase 8 | from django.test.client import Client 9 | from django.test.utils import override_settings 10 | from django.utils.encoding import force_text 11 | 12 | from dynamic_choices.admin import DynamicAdmin 13 | from dynamic_choices.forms import DynamicModelForm 14 | from dynamic_choices.forms.fields import ( 15 | DynamicModelChoiceField, DynamicModelMultipleChoiceField, 16 | ) 17 | 18 | from .admin import PuppetAdmin 19 | from .models import ALIGNMENT_GOOD, Master, Puppet 20 | 21 | MODULE_PATH = os.path.abspath(os.path.dirname(__file__)) 22 | 23 | 24 | @override_settings( 25 | TEMPLATE_LOADERS=[ 26 | 'django.template.loaders.filesystem.Loader', 27 | 'django.template.loaders.app_directories.Loader', 28 | ], 29 | TEMPLATE_DIRS=[os.path.join(MODULE_PATH, 'templates')], 30 | ) 31 | class ChangeFormTemplateTests(SimpleTestCase): 32 | template_attr = 'change_form_template' 33 | 34 | def test_doesnt_extend_change_form(self): 35 | expected_message = ( 36 | "Make sure DoesntExtend.%s template extends " 37 | "'admin/dynamic_choices/change_form.html' in order to enable DynamicAdmin" 38 | ) % self.template_attr 39 | with self.assertRaisesMessage(ImproperlyConfigured, expected_message): 40 | type(str('DoesntExtend'), (DynamicAdmin,), { 41 | self.template_attr: 'dynamic_choices_tests/do_not_extends_change_form.html' 42 | }) 43 | 44 | def test_extends_directly(self): 45 | type(str('ExtendsDirectly'), (DynamicAdmin,), { 46 | self.template_attr: 'dynamic_choices_tests/extends_change_form.html' 47 | }) 48 | 49 | def test_extends_change_from_through_child(self): 50 | type(str('ExtendsThroughChild'), (DynamicAdmin,), { 51 | self.template_attr: 'dynamic_choices_tests/extends_change_form_twice.html' 52 | }) 53 | 54 | 55 | class AddFormTemplateTests(ChangeFormTemplateTests): 56 | template_attr = 'add_form_template' 57 | 58 | 59 | class AdminTestBase(TestCase): 60 | fixtures = ['dynamic_choices_test_data', 'dynamic_choices_admin_test_data'] 61 | 62 | def setUp(self): 63 | self.client = Client() 64 | self.client.login(username='superuser', password='sudo') 65 | 66 | 67 | class DynamicAdminFormTests(AdminTestBase): 68 | 69 | def assertChoices(self, queryset, field, msg=None): 70 | self.assertEqual(list(queryset), list(field.widget.choices.queryset), msg) 71 | 72 | def assertEmptyChoices(self, field, msg=None): 73 | return self.assertChoices((), field, msg=msg) 74 | 75 | def test_GET_add(self): 76 | response = self.client.get('/admin/dynamic_choices/puppet/add/', follow=True) 77 | adminform = response.context['adminform'] 78 | form = adminform.form 79 | enemies_inline = response.context['inline_admin_formsets'][0] 80 | fields = form.fields 81 | self.assertEqual(200, response.status_code, 'Cannot display add page') 82 | self.assertIsInstance(form, DynamicModelForm, 'Form is not an instance of DynamicModelForm') 83 | self.assertIsInstance(fields['master'], DynamicModelChoiceField, 84 | 'Field master is not an instance of DynamicChoicesField') 85 | self.assertIsInstance(fields['friends'], DynamicModelMultipleChoiceField, 86 | 'Field friends is not an instance of DynamicModelMultipleChoiceField') 87 | self.assertEmptyChoices(fields['master'], 'Since no alignment is defined master choices should be empty') 88 | self.assertEmptyChoices(fields['friends'], 'Since no alignment is defined friends choices should be empty') 89 | enemies_inline_form = enemies_inline.opts.form 90 | self.assertTrue(issubclass(enemies_inline_form, DynamicModelForm) or 91 | enemies_inline_form.__name__ == "Dynamic%s" % enemies_inline_form.__base__.__name__, 92 | 'Inline form is not a subclass of DynamicModelForm') 93 | for form in enemies_inline.formset.forms: 94 | fields = form.fields 95 | self.assertEmptyChoices(fields['enemy'], 'Since no alignment is defined enemy choices should be empty') 96 | self.assertEmptyChoices( 97 | fields['because_of'], 'Since no enemy is defined because_of choices should be empty') 98 | 99 | def test_GET_add_with_defined_alignment(self): 100 | alignment = ALIGNMENT_GOOD 101 | response = self.client.get('/admin/dynamic_choices/puppet/add/', {'alignment': alignment}, follow=True) 102 | self.assertEqual(200, response.status_code, 'Cannot display add page') 103 | adminform = response.context['adminform'] 104 | form = adminform.form 105 | enemies_inline = response.context['inline_admin_formsets'][0] 106 | fields = form.fields 107 | self.assertIsInstance(form, DynamicModelForm, 'Form is not an instance of DynamicModelForm') 108 | self.assertIsInstance(fields['master'], DynamicModelChoiceField, 109 | 'Field master is not an instance of DynamicChoicesField') 110 | self.assertIsInstance(fields['friends'], DynamicModelMultipleChoiceField, 111 | 'Field friends is not an instance of DynamicModelMultipleChoiceField') 112 | self.assertChoices(Master.objects.filter(alignment=alignment), fields['master'], 113 | "Since puppet alignment is 'Good' only 'Good' master are valid choices for master field") 114 | self.assertChoices(Puppet.objects.filter(alignment=alignment), fields['friends'], 115 | "Since puppet alignment is 'Good' only 'Good' puppets are valid choices for friends field") 116 | enemies_inline_form = enemies_inline.opts.form 117 | self.assertTrue(issubclass(enemies_inline_form, DynamicModelForm) or 118 | enemies_inline_form.__name__ == "Dynamic%s" % enemies_inline_form.__base__.__name__, 119 | 'Inline form is not a subclass of DynamicModelForm') 120 | for form in enemies_inline.formset.forms: 121 | fields = form.fields 122 | self.assertChoices( 123 | Puppet.objects.exclude(alignment=alignment), fields['enemy'], 124 | "Since puppet alignment is 'Good' only not 'Good' puppets are valid choices for enemy field" 125 | ) 126 | self.assertEmptyChoices( 127 | fields['because_of'], 'Since no enemy is defined because_of choices should be empty') 128 | 129 | def test_POST_add(self): 130 | alignment = ALIGNMENT_GOOD 131 | data = { 132 | 'alignment': alignment, 133 | 'master': 1, 134 | 'friends': [1], 135 | 'enemy_set-TOTAL_FORMS': 3, 136 | 'enemy_set-INITIAL_FORMS': 0, 137 | } 138 | response = self.client.post('/admin/dynamic_choices/puppet/add/', data) 139 | self.assertEqual(302, response.status_code, 'Failed to validate') 140 | 141 | # Attempt to save an empty enemy inline 142 | # and make sure because_of has correct choices 143 | def test_POST_add_because_of(self): 144 | alignment = ALIGNMENT_GOOD 145 | data = { 146 | 'alignment': alignment, 147 | 'master': 1, 148 | 'friends': [1], 149 | 'enemy_set-TOTAL_FORMS': 2, 150 | 'enemy_set-INITIAL_FORMS': 0, 151 | 'enemy_set-0-enemy': 2, 152 | } 153 | response = self.client.post('/admin/dynamic_choices/puppet/add/', data) 154 | self.assertNotEqual(302, response.status_code, 'Empty inline should not validate') 155 | self.assertChoices( 156 | Master.objects.filter(alignment=Puppet.objects.get(id=2).alignment), 157 | response.context['inline_admin_formsets'][0].formset.forms[0].fields['because_of'], 158 | 'Since enemy is specified because_of choices must have the same alignment' 159 | ) 160 | self.assertChoices( 161 | Puppet.objects.exclude(alignment=ALIGNMENT_GOOD), 162 | response.context['inline_admin_formsets'][0].formset.forms[0].fields['enemy'], 163 | 'Since puppet alignment is specified only non-good puppets should be allowed to be enemies' 164 | ) 165 | self.assertEmptyChoices( 166 | response.context['inline_admin_formsets'][0].formset.forms[1].fields['because_of'], 167 | 'Enemy is only specified for the first inline, second one because_of should be empty' 168 | ) 169 | self.assertChoices( 170 | Puppet.objects.exclude(alignment=ALIGNMENT_GOOD), 171 | response.context['inline_admin_formsets'][0].formset.forms[1].fields['enemy'], 172 | 'Since puppet alignment is specified only non-good puppets should be allowed to be enemies' 173 | ) 174 | 175 | # TODO: Add test_(GET & POST)_edit testcases 176 | 177 | def test_user_defined_forms(self): 178 | self.assertTrue(issubclass(PuppetAdmin.form, DynamicModelForm), 179 | 'User defined forms should be subclassed from DynamicModelForm by metaclass') 180 | self.assertTrue( 181 | issubclass(PuppetAdmin.inlines[0].form, DynamicModelForm), 182 | 'User defined inline forms should be subclassed from DynamicModelForm by dynamic_inline_factory' 183 | ) 184 | 185 | 186 | class AdminChoicesTests(AdminTestBase): 187 | 188 | def _get_choices(self, data=None): 189 | default = { 190 | 'enemy_set-TOTAL_FORMS': 0, 191 | 'enemy_set-INITIAL_FORMS': 0, 192 | } 193 | if data: 194 | default.update(data) 195 | return self.client.get('/admin/dynamic_choices/puppet/1/choices/', default) 196 | 197 | def test_medias_presence(self): 198 | """Make sure extra js files are present in the response""" 199 | response = self.client.get('/admin/dynamic_choices/puppet/1/') 200 | self.assertContains(response, 'js/dynamic-choices.js') 201 | self.assertContains(response, 'js/dynamic-choices-admin.js') 202 | 203 | def test_fk_as_empty_string(self): 204 | """Make sure fk specified as empty string are parsed correctly""" 205 | data = {'alignment': ''} 206 | response = self._get_choices(data) 207 | self.assertEqual(200, response.status_code, "Empty string fk shouldn't be cast as int") 208 | 209 | def test_empty_string_value_overrides_default(self): 210 | """Make sure specified empty string overrides instance field""" 211 | data = { 212 | 'DYNAMIC_CHOICES_FIELDS': 'enemy_set-0-because_of', 213 | 'enemy_set-0-id': 1, 214 | 'enemy_set-0-enemy': '', 215 | 'enemy_set-TOTAL_FORMS': 3, 216 | 'enemy_set-INITIAL_FORMS': 1, 217 | } 218 | response = self._get_choices(data) 219 | self.assertEqual(response.status_code, 200) 220 | data = json.loads(force_text(response.content)) 221 | self.assertEqual(data['enemy_set-0-because_of']['value'], [['', '---------']]) 222 | 223 | def test_empty_form(self): 224 | """Make sure data is provided for an empty form""" 225 | data = { 226 | 'DYNAMIC_CHOICES_FIELDS': 'enemy_set-__prefix__-enemy', 227 | 'alignment': 1, 228 | } 229 | response = self._get_choices(data) 230 | self.assertEqual(response.status_code, 200) 231 | data = json.loads(force_text(response.content)) 232 | self.assertEqual(data['enemy_set-__prefix__-enemy']['value'], [ 233 | ['', '---------'], 234 | ['Evil', [[2, 'Evil puppet (2)'], ]], 235 | ['Neutral', []], 236 | ]) 237 | -------------------------------------------------------------------------------- /tests/test_models.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | from django.core.exceptions import FieldError, ValidationError 4 | from django.db.models import Model 5 | from django.test import SimpleTestCase, TestCase 6 | 7 | from dynamic_choices.db.models import DynamicChoicesForeignKey 8 | 9 | from .models import ALIGNMENT_EVIL, ALIGNMENT_GOOD, Enemy, Master, Puppet 10 | 11 | 12 | class DefinitionValidationTest(SimpleTestCase): 13 | def test_missing_method(self): 14 | with self.assertRaises(FieldError): 15 | class MissingChoicesCallbackModel(Model): 16 | field = DynamicChoicesForeignKey('self', choices='missing_method') 17 | 18 | class Meta: 19 | app_label = 'dynamic_choices' 20 | 21 | def test_callable(self): 22 | class CallableChoicesCallbackModel(Model): 23 | field = DynamicChoicesForeignKey('self', choices=lambda qs: qs) 24 | 25 | class Meta: 26 | app_label = 'dynamic_choices' 27 | 28 | 29 | class DynamicForeignKeyTests(TestCase): 30 | def setUp(self): 31 | self.good_master = Master.objects.create(alignment=ALIGNMENT_GOOD) 32 | self.evil_master = Master.objects.create(alignment=ALIGNMENT_EVIL) 33 | 34 | def test_valid_value(self): 35 | good_puppet = Puppet(master=self.good_master, alignment=ALIGNMENT_GOOD) 36 | good_puppet.full_clean() 37 | good_puppet.save() 38 | evil_puppet = Puppet(master=self.evil_master, alignment=ALIGNMENT_EVIL) 39 | evil_puppet.full_clean() 40 | evil_puppet.save() 41 | enemy = Enemy(puppet=evil_puppet, enemy=good_puppet, because_of=self.good_master) 42 | enemy.full_clean(exclude=['since']) 43 | 44 | def test_invalid_value(self): 45 | puppet = Puppet(master=self.good_master, alignment=ALIGNMENT_EVIL) 46 | self.assertRaises(ValidationError, puppet.full_clean) 47 | 48 | 49 | class DynamicOneToOneFieldTests(TestCase): 50 | fixtures = ['dynamic_choices_test_data'] 51 | 52 | def setUp(self): 53 | self.good_puppet = Puppet.objects.get(alignment=ALIGNMENT_GOOD) 54 | self.evil_puppet = Puppet.objects.get(alignment=ALIGNMENT_EVIL) 55 | 56 | def test_valid_value(self): 57 | self.evil_puppet.secret_lover = self.good_puppet 58 | self.evil_puppet.full_clean() 59 | self.evil_puppet.save() 60 | self.assertEqual(self.good_puppet.secretly_loves_me, self.evil_puppet) 61 | self.good_puppet.secret_lover = self.evil_puppet 62 | self.good_puppet.full_clean() 63 | 64 | def test_invalid_value(self): 65 | self.evil_puppet.secret_lover = self.good_puppet 66 | self.evil_puppet.save() 67 | self.good_puppet.secret_lover = self.good_puppet 68 | self.assertRaises( 69 | ValidationError, self.good_puppet.full_clean, 70 | "Since the evil puppet secretly loves the good puppet the good puppet can only secretly love the bad one." 71 | ) 72 | -------------------------------------------------------------------------------- /tests/urls.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import, unicode_literals 2 | 3 | from django.conf.urls import include, url 4 | 5 | from . import admin 6 | 7 | urlpatterns = [ 8 | url(r'^admin/', include(admin.site.urls)), 9 | ] 10 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | args_are_paths = false 3 | envlist = 4 | py26-1.6, 5 | py27-{1.6,1.7,1.8,master}, 6 | py32-{1.6,1.7,1.8}, 7 | py33-{1.6,1.7,1.8,master}, 8 | py34-{1.7,1.8,master}, 9 | 10 | [testenv] 11 | basepython = 12 | py26: python2.6 13 | py27: python2.7 14 | py32: python3.2 15 | py33: python3.3 16 | py34: python3.4 17 | usedevelop = true 18 | commands = 19 | python -R -Wonce {envbindir}/coverage run {envbindir}/django-admin.py test -v2 --settings=tests.settings {posargs} 20 | coverage report 21 | deps = 22 | coverage 23 | py26: argparse 24 | 1.6: Django>=1.6,<1.7 25 | 1.7: Django>=1.7,<1.8 26 | 1.8: Django==1.8b1 27 | master: https://github.com/django/django/archive/master.zip 28 | --------------------------------------------------------------------------------