1import json 2 3import django 4 5from django.apps import apps 6from django.conf import settings 7try: 8 from django.core.urlresolvers import reverse 9except ImportError: 10 # TODO: swap this over when Django 2+ becomes more prevalent 11 from django.urls import reverse 12from django.forms.widgets import Select, SelectMultiple, Media 13from django.utils.safestring import mark_safe 14from django.utils.encoding import force_text 15from django.utils.html import escape 16 17from smart_selects.utils import unicode_sorter, sort_results 18 19get_model = apps.get_model 20 21USE_DJANGO_JQUERY = getattr(settings, 'USE_DJANGO_JQUERY', False) 22JQUERY_URL = getattr(settings, 'JQUERY_URL', 'https://ajax.googleapis.com/ajax/libs/jquery/2.2.0/jquery.min.js') 23 24URL_PREFIX = getattr(settings, "SMART_SELECTS_URL_PREFIX", "") 25 26 27class JqueryMediaMixin(object): 28 @property 29 def media(self): 30 """Media defined as a dynamic property instead of an inner class.""" 31 media = super(JqueryMediaMixin, self).media 32 33 js = [] 34 35 if JQUERY_URL: 36 js.append(JQUERY_URL) 37 elif JQUERY_URL is not False: 38 vendor = '' if django.VERSION < (1, 9, 0) else 'vendor/jquery/' 39 extra = '' if settings.DEBUG else '.min' 40 41 jquery_paths = [ 42 '{}jquery{}.js'.format(vendor, extra), 43 'jquery.init.js', 44 ] 45 46 if USE_DJANGO_JQUERY: 47 jquery_paths = ['admin/js/{}'.format(path) for path in jquery_paths] 48 49 js.extend(jquery_paths) 50 51 media += Media(js=js) 52 return media 53 54 55class ChainedSelect(JqueryMediaMixin, Select): 56 def __init__(self, to_app_name, to_model_name, chained_field, chained_model_field, 57 foreign_key_app_name, foreign_key_model_name, foreign_key_field_name, 58 show_all, auto_choose, sort=True, manager=None, view_name=None, *args, **kwargs): 59 self.to_app_name = to_app_name 60 self.to_model_name = to_model_name 61 self.chained_field = chained_field 62 self.chained_model_field = chained_model_field 63 self.show_all = show_all 64 self.auto_choose = auto_choose 65 self.sort = sort 66 self.manager = manager 67 self.view_name = view_name 68 self.foreign_key_app_name = foreign_key_app_name 69 self.foreign_key_model_name = foreign_key_model_name 70 self.foreign_key_field_name = foreign_key_field_name 71 super(Select, self).__init__(*args, **kwargs) 72 73 @property 74 def media(self): 75 """Media defined as a dynamic property instead of an inner class.""" 76 media = super(ChainedSelect, self).media 77 js = ['smart-selects/admin/js/chainedfk.js', 78 'smart-selects/admin/js/bindfields.js'] 79 media += Media(js=js) 80 return media 81 82 # TODO: Simplify this and remove the noqa tag 83 def render(self, name, value, attrs=None, choices=(), renderer=None): # noqa: C901 84 if len(name.split('-')) > 1: # formset 85 chained_field = '-'.join(name.split('-')[:-1] + [self.chained_field]) 86 else: 87 chained_field = self.chained_field 88 89 if not self.view_name: 90 if self.show_all: 91 view_name = "chained_filter_all" 92 else: 93 view_name = "chained_filter" 94 else: 95 view_name = self.view_name 96 kwargs = { 97 'app': self.to_app_name, 98 'model': self.to_model_name, 99 'field': self.chained_model_field, 100 'foreign_key_app_name': self.foreign_key_app_name, 101 'foreign_key_model_name': self.foreign_key_model_name, 102 'foreign_key_field_name': self.foreign_key_field_name, 103 'value': '1' 104 } 105 if self.manager is not None: 106 kwargs.update({'manager': self.manager}) 107 url = URL_PREFIX + ("/".join(reverse(view_name, kwargs=kwargs).split("/")[:-2])) 108 if self.auto_choose: 109 auto_choose = 'true' 110 else: 111 auto_choose = 'false' 112 if choices: 113 iterator = iter(self.choices) 114 if hasattr(iterator, '__next__'): 115 empty_label = iterator.__next__()[1] 116 else: 117 # Hacky way to getting the correct empty_label from the field instead of a hardcoded '--------' 118 empty_label = iterator.next()[1] 119 else: 120 empty_label = "--------" 121 122 final_choices = [] 123 124 if value: 125 available_choices = self._get_available_choices(self.queryset, value) 126 for choice in available_choices: 127 final_choices.append((choice.pk, force_text(choice))) 128 if len(final_choices) > 1: 129 final_choices = [("", (empty_label))] + final_choices 130 if self.show_all: 131 final_choices.append(("", (empty_label))) 132 self.choices = list(self.choices) 133 if self.sort: 134 self.choices.sort(key=lambda x: unicode_sorter(x[1])) 135 for ch in self.choices: 136 if ch not in final_choices: 137 final_choices.append(ch) 138 self.choices = final_choices 139 140 attrs.update(self.attrs) 141 attrs["data-chainfield"] = chained_field 142 attrs["data-url"] = url 143 attrs["data-value"] = "null" if value is None or value == "" else value 144 attrs["data-auto_choose"] = auto_choose 145 attrs["data-empty_label"] = escape(empty_label) 146 attrs["name"] = name 147 final_attrs = self.build_attrs(attrs) 148 if 'class' in final_attrs: 149 final_attrs['class'] += ' chained-fk' 150 else: 151 final_attrs['class'] = 'chained-fk' 152 153 if renderer: 154 output = super(ChainedSelect, self).render(name, value, final_attrs, renderer) 155 else: 156 output = super(ChainedSelect, self).render(name, value, final_attrs) 157 158 return mark_safe(output) 159 160 def _get_available_choices(self, queryset, value): 161 """ 162 get possible choices for selection 163 """ 164 item = queryset.filter(pk=value).first() 165 if item: 166 try: 167 pk = getattr(item, self.chained_model_field + "_id") 168 filter = {self.chained_model_field: pk} 169 except AttributeError: 170 try: # maybe m2m? 171 pks = getattr(item, self.chained_model_field).all().values_list('pk', flat=True) 172 filter = {self.chained_model_field + "__in": pks} 173 except AttributeError: 174 try: # maybe a set? 175 pks = getattr(item, self.chained_model_field + "_set").all().values_list('pk', flat=True) 176 filter = {self.chained_model_field + "__in": pks} 177 except AttributeError: # give up 178 filter = {} 179 filtered = list(get_model(self.to_app_name, self.to_model_name).objects.filter(**filter).distinct()) 180 if self.sort: 181 sort_results(filtered) 182 else: 183 # invalid value for queryset 184 filtered = [] 185 186 return filtered 187 188 189class ChainedSelectMultiple(JqueryMediaMixin, SelectMultiple): 190 def __init__(self, to_app_name, to_model_name, chain_field, chained_model_field, 191 foreign_key_app_name, foreign_key_model_name, foreign_key_field_name, 192 auto_choose, horizontal, verbose_name='', manager=None, *args, **kwargs): 193 self.to_app_name = to_app_name 194 self.to_model_name = to_model_name 195 self.chain_field = chain_field 196 self.chained_model_field = chained_model_field 197 self.auto_choose = auto_choose 198 self.horizontal = horizontal 199 self.verbose_name = verbose_name 200 self.manager = manager 201 self.foreign_key_app_name = foreign_key_app_name 202 self.foreign_key_model_name = foreign_key_model_name 203 self.foreign_key_field_name = foreign_key_field_name 204 super(SelectMultiple, self).__init__(*args, **kwargs) 205 206 @property 207 def media(self): 208 """Media defined as a dynamic property instead of an inner class.""" 209 media = super(ChainedSelectMultiple, self).media 210 js = [] 211 if self.horizontal: 212 # For horizontal mode add django filter horizontal javascript code 213 js.extend(["admin/js/core.js", 214 "admin/js/SelectBox.js", 215 "admin/js/SelectFilter2.js"]) 216 js.extend([ 217 'smart-selects/admin/js/chainedm2m.js', 218 'smart-selects/admin/js/bindfields.js' 219 ]) 220 media += Media(js=js) 221 return media 222 223 def render(self, name, value, attrs=None, choices=(), renderer=None): 224 if len(name.split('-')) > 1: # formset 225 chain_field = '-'.join(name.split('-')[:-1] + [self.chain_field]) 226 else: 227 chain_field = self.chain_field 228 229 view_name = 'chained_filter' 230 231 kwargs = { 232 'app': self.to_app_name, 233 'model': self.to_model_name, 234 'field': self.chained_model_field, 235 'foreign_key_app_name': self.foreign_key_app_name, 236 'foreign_key_model_name': self.foreign_key_model_name, 237 'foreign_key_field_name': self.foreign_key_field_name, 238 'value': '1' 239 } 240 if self.manager is not None: 241 kwargs.update({'manager': self.manager}) 242 url = URL_PREFIX + ("/".join(reverse(view_name, kwargs=kwargs).split("/")[:-2])) 243 if self.auto_choose: 244 auto_choose = 'true' 245 else: 246 auto_choose = 'false' 247 248 # since we cannot deduce the value of the chained_field 249 # so we just render empty choices here and let the js 250 # fetch related choices later 251 final_choices = [] 252 self.choices = final_choices 253 254 attrs["data-chainfield"] = chain_field 255 attrs["data-url"] = url 256 attrs["data-value"] = "null" if value is None else json.dumps(value) 257 attrs["data-auto_choose"] = auto_choose 258 attrs["name"] = name 259 final_attrs = self.build_attrs(attrs) 260 if 'class' in final_attrs: 261 final_attrs['class'] += ' chained' 262 else: 263 final_attrs['class'] = 'chained' 264 if self.horizontal: 265 # For hozontal mode add django filter horizontal javascript selector class 266 final_attrs['class'] += ' selectfilter' 267 final_attrs['data-field-name'] = self.verbose_name 268 if renderer: 269 output = super(ChainedSelectMultiple, self).render(name, value, final_attrs, renderer) 270 else: 271 output = super(ChainedSelectMultiple, self).render(name, value, final_attrs) 272 273 return mark_safe(output) 274