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