django-select-multiple-field 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,357 @@
1
+ Metadata-Version: 2.4
2
+ Name: django-select-multiple-field
3
+ Version: 1.0.0
4
+ Summary: Select multiple choices in a single Django model field
5
+ Project-URL: Homepage, https://kelvinwong.ca/projects/django-select-multiple-field/
6
+ Project-URL: Repository, https://github.com/kelvinwong-ca/django-select-multiple-field
7
+ Author-email: Kelvin Wong <code@kelvinwong.ca>
8
+ License: BSD
9
+ License-File: LICENSE
10
+ Keywords: Django,Django-Select-Multiple-Field,model-field,select,select multiple
11
+ Classifier: Development Status :: 5 - Production/Stable
12
+ Classifier: Environment :: Web Environment
13
+ Classifier: Framework :: Django
14
+ Classifier: Framework :: Django :: 4.2
15
+ Classifier: Framework :: Django :: 5.2
16
+ Classifier: Framework :: Django :: 6
17
+ Classifier: Framework :: Django :: 6.0
18
+ Classifier: Framework :: Django :: 6.1
19
+ Classifier: Intended Audience :: Developers
20
+ Classifier: License :: OSI Approved :: BSD License
21
+ Classifier: Operating System :: OS Independent
22
+ Classifier: Programming Language :: Python
23
+ Classifier: Programming Language :: Python :: 3
24
+ Classifier: Programming Language :: Python :: 3 :: Only
25
+ Classifier: Programming Language :: Python :: 3.10
26
+ Classifier: Programming Language :: Python :: 3.11
27
+ Classifier: Programming Language :: Python :: 3.12
28
+ Classifier: Programming Language :: Python :: 3.13
29
+ Classifier: Programming Language :: Python :: 3.14
30
+ Classifier: Topic :: Internet :: WWW/HTTP
31
+ Requires-Python: >=3.10
32
+ Requires-Dist: django>=4.2
33
+ Description-Content-Type: text/markdown
34
+
35
+ # django-select-multiple-field
36
+
37
+ Store multiple choices in a single Django model field without using a many-to-many relationship.
38
+
39
+ ![Rendered using the multiselect.js plugin for jQuery](https://github.com/kelvinwong-ca/django-select-multiple-field/raw/master/docs/images/select_multiple_cropped.jpg)
40
+
41
+ Rendered using the multiselect.js plugin for jQuery. The plugin is available here: <https://github.com/lou/multi-select>
42
+
43
+
44
+ ## Denormalization Risk (Important)
45
+
46
+ This field intentionally denormalizes data by storing multiple selected choices as comma-separated text in a single column.
47
+
48
+ Use it when:
49
+
50
+ * you want a simple schema,
51
+ * you do not need relational queries (joins),
52
+ * and you mainly read/write the selected values as a list in application code.
53
+
54
+ If you need robust querying like “find all cookies with topping X,” a normalized many-to-many model is usually the better choice.
55
+
56
+ For example, if cookies can have toppings such as `chocolate`, `dark_chocolate`, and `white_chocolate`, a normalized design would usually use a `Topping` model and a `Cookie` model related by a `ManyToManyField`. That gives you joins, indexing, and exact filtering.
57
+
58
+ With this field, you filter against the stored text value instead.
59
+
60
+ ```python
61
+ # Caution: substring search can return false positives.
62
+ Cookie.objects.filter(toppings__icontains="chocolate")
63
+ ```
64
+
65
+ That kind of lookup can produce false positives because one encoded choice can appear inside another. For example, `chocolate` is a substring of `dark_chocolate`, so a substring search can match both.
66
+
67
+ If that risk is unacceptable for your application, use a many-to-many relation instead.
68
+
69
+
70
+ ## Installation
71
+
72
+ Install from PyPI:
73
+
74
+ ```bash
75
+ pip install django-select-multiple-field
76
+ ```
77
+
78
+
79
+ ## Quick Start
80
+
81
+ ### Model
82
+
83
+ > You must provide either `max_length` or both `choices` and `max_choices`
84
+
85
+ > Choices must be strings, not integers.
86
+
87
+ Add the select field choices normally in your model:
88
+
89
+ ```python
90
+ # models.py
91
+
92
+ from django.db import models
93
+
94
+ from select_multiple_field.models import SelectMultipleField
95
+
96
+ class Pizza(models.Model):
97
+ ANCHOVIES = 'a'
98
+ BLACK_OLIVES = 'b'
99
+ PEPPERONI = 'p'
100
+ MOZZARELLA = 'm'
101
+ TOPPING_CHOICES = (
102
+ (ANCHOVIES, 'Anchovies'),
103
+ (BLACK_OLIVES, 'Black olives'),
104
+ (PEPPERONI, 'Pepperoni'),
105
+ (MOZZARELLA, 'Mozzarella'),
106
+ )
107
+ # choices & max_choices are used to auto calc. max_length
108
+ toppings = SelectMultipleField(
109
+ max_choices=4,
110
+ choices=TOPPING_CHOICES,
111
+ )
112
+
113
+ PLAIN = 'n'
114
+ CHEDDAR = 'c'
115
+ CRUST_CHOICES = (
116
+ (PLAIN, 'Plain Crust'),
117
+ (MOZZARELLA, 'Mozzarella Stuffed Crust'),
118
+ (CHEDDAR, 'Cheddar Stuffed Crust'),
119
+ )
120
+ crust = SelectMultipleField(
121
+ max_length=6,
122
+ choices=CRUST_CHOICES,
123
+ )
124
+ ```
125
+
126
+ ### Encoded Length (Choose One)
127
+
128
+ Underneath this field is a CharField which takes a maximum length for the encoded string. You can choose to provide a `max_length` parameter or the field will calculate it from the `choices` and the `max_choices` parameters.
129
+
130
+ * `max_length` is the maximum length of the **encoded** string of choices. You need to add the maximum allowed choices and the delimiter character (usually a comma) that separates the choices when they are encoded.
131
+ * `max_choices` is the maximum choices that can be encoded per field.
132
+ * `choices` can be a mapping, iterable, or callable.
133
+
134
+ Example encoded-length calculation:
135
+
136
+ * Choice keys: `a`, `bb`, `ccc`, `dddd`
137
+ * `max_choices=2`
138
+ * Longest 2 keys are `dddd` and `ccc`, so encoded value is `dddd,ccc`
139
+ * Required `max_length` is `8`
140
+
141
+ ## Render the form
142
+
143
+ Use a generic view or a `ModelForm` as usual. In your template, use a regular form tag:
144
+
145
+ ```html
146
+ <!-- template_form.html -->
147
+ <form action="" method="post">
148
+ {% csrf_token %}
149
+ {{ form.as_p }}
150
+ <input type="submit" value="Submit">
151
+ </form>
152
+ ```
153
+
154
+ This renders the following HTML:
155
+
156
+ ```html
157
+ <!-- create.html -->
158
+ <form action="" method="post">
159
+ <p>
160
+ <label for="id_toppings">Toppings:</label>
161
+ <select multiple="multiple" id="id_toppings" name="toppings" class="select-multiple-field">
162
+ <option value="a">Anchovies</option>
163
+ <option value="b">Black olives</option>
164
+ <option value="p">Pepperoni</option>
165
+ <option value="m">Mozzarella</option>
166
+ </select>
167
+ </p>
168
+ <input type="submit" value="Submit">
169
+ </form>
170
+ ```
171
+
172
+ ## Null Handling
173
+
174
+ `SelectMultipleField` supports `null=True`, but it behaves differently from Django's standard `CharField`:
175
+
176
+ | `null` | Empty Selection in DB | `to_python(None)` | `from_db_value(None)` | `get_prep_value([])` |
177
+ |--------|----------------------|-------------------|----------------------|----------------------|
178
+ | `False` (default) | `""` (empty string) | `[]` | `[]` | `""` |
179
+ | `True` | `NULL` | `[]` | `[]` | `None` |
180
+
181
+ **Key semantics:**
182
+ - `null=True` changes **only database storage**. Python API always returns `[]`, never `None`
183
+ - `to_python(None)` always returns `[]` regardless of `null` setting
184
+ - `from_db_value(None, ...)` always returns `[]` regardless of `null` setting
185
+ - `get_prep_value(None)` returns `None` when `null=True`, else `""`
186
+ - `get_prep_value([])` returns `None` when `null=True`, else `""`
187
+
188
+ Use `null=True` only when your database conventions require `NULL` over empty string for empty selections. For form-level optional fields, use `blank=True` instead.
189
+
190
+ ## Validators
191
+
192
+ `SelectMultipleField` replaces Django's built-in `MaxLengthValidator` with two custom validators:
193
+
194
+ ### `MaxChoicesValidator`
195
+ - **Validates**: `len(value) ≤ max_choices`
196
+ - **Error code**: `max_choices`
197
+ - **Message**: `"Ensure this value has at most %(limit_value)d choice(s)..."`
198
+
199
+ ### `MaxLengthValidator`
200
+ - **Validates**: `len(encode_list_to_csv(value)) ≤ max_length`
201
+ - **Error code**: `max_length`
202
+ - **Message**: `"Ensure this value has at most %(limit_value)d character(s)..."`
203
+
204
+ Both validators run during model validation. If you provide `choices` and `max_choices` but omit `max_length`, the field auto-calculates `max_length` from the longest possible encoded CSV (longest `max_choices` keys joined by delimiters). If you explicitly set `max_length` smaller than this calculated maximum, a `RuntimeWarning` is emitted at field instantiation.
205
+
206
+ ## Validation Error Codes
207
+
208
+ The field can raise the following validation errors:
209
+
210
+ | Error Code | Message Template | Triggered When |
211
+ |------------|------------------|----------------|
212
+ | `invalid_type` | `"Types passed as value must be string, list, tuple or None, not '%(value)s'."` | `to_python` receives non-list/tuple/str/None |
213
+ | `invalid_choice` | `"Select a valid choice. %(value)s is not one of the available choices."` | Value not in `choices` |
214
+ | `blank` | `"This field cannot be blank."` | `blank=False` and empty value |
215
+ | `null` | `"This field cannot be null."` | `null=False` and `None` value |
216
+ | `max_choices` | `"Ensure this value has at most %(limit_value)d choice(s)..."` | `len(value) > max_choices` |
217
+ | `max_length` | `"Ensure this value has at most %(limit_value)d character(s)..."` | `len(encoded) > max_length` |
218
+
219
+ ## Displaying Stored Choices
220
+
221
+ To display your choices, decode the field contents. You can do this with a template tag:
222
+
223
+ ```python
224
+ # templatetags/pizza_tags.py
225
+
226
+ def decode_pie(ingredients):
227
+ """Decode pizza pie toppings."""
228
+ decoder = dict(Pizza.TOPPING_CHOICES)
229
+ decoded = [decoder[t] for t in ingredients]
230
+ decoded.sort()
231
+ return ', '.join(decoded)
232
+
233
+ register.filter('decode_pie', decode_pie)
234
+ ```
235
+
236
+ In your template, import the tag and use it:
237
+
238
+ ```django
239
+ {# details.html #}
240
+ {% load pizza_tags %}
241
+
242
+ {{ pizza.toppings|decode_pie }}
243
+ ```
244
+
245
+ ## Encoding the Choices
246
+
247
+ The selected choices are stored as comma-delimited text. For example, a pizza with the following toppings:
248
+
249
+ * Pepperoni
250
+ * Mozzarella
251
+
252
+ would be stored as:
253
+
254
+ ```text
255
+ p,m
256
+ ```
257
+
258
+ You can decode that string to a Python list using functions in the codecs module:
259
+
260
+ ```python
261
+ >>> from select_multiple_field.codecs import decode_csv_to_list
262
+ >>> encoded = 'a,b,c'
263
+ >>> decoded = decode_csv_to_list(encoded)
264
+ >>> print(decoded)
265
+ ['a', 'b', 'c']
266
+ >>> print(type(decoded))
267
+ <class 'list'>
268
+ ```
269
+
270
+ ## Custom Delimiters
271
+
272
+ The CSV delimiter is configurable via Django settings:
273
+
274
+ ```python
275
+ # settings.py
276
+ SELECTMULTIPLEFIELD_DELIMITER = "|" # Default: ","
277
+ ```
278
+
279
+ **Constraints:**
280
+ - Must be a **single character**
281
+ - Multi-character delimiters are not supported
282
+ - Choice values **cannot contain the delimiter** (e.g., if delimiter is `,`, a choice like `"a,b"` breaks encoding)
283
+
284
+ Both `encode_list_to_csv()` and `decode_csv_to_list()` in `select_multiple_field.codecs` respect this setting automatically. Changing the delimiter on an existing database requires a data migration to re-encode stored values.
285
+
286
+ The encoding method may limit your ability to search for choices.
287
+
288
+ ## Sample Application
289
+
290
+ This repository includes sample applications under `test_projects/`. You can run the Django 4.2 integration app like this:
291
+
292
+ ```bash
293
+ $ cd /path/to/django-select-multiple-field
294
+ $ cd test_projects/django42
295
+ $ python manage.py migrate
296
+ $ python manage.py runserver
297
+ Development server is running at http://127.0.0.1:8000/
298
+ Quit the server with CONTROL-C.
299
+ ```
300
+
301
+ ## Versions
302
+
303
+ This code was tested with the following versions of Django and Python:
304
+
305
+ * Django 4.2 LTS
306
+ * Python 3.10
307
+ * Python 3.11
308
+ * Python 3.12
309
+ * Python 3.13
310
+ * Django 5.2 LTS
311
+ * Python 3.10
312
+ * Python 3.11
313
+ * Python 3.12
314
+ * Python 3.13
315
+ * Python 3.14
316
+ * Django 6.0
317
+ * Python 3.12
318
+ * Python 3.13
319
+ * Python 3.14
320
+
321
+ ## Testing
322
+
323
+ Django-select-multiple-field contains two test suites: one for the field itself and one for the Django integration apps.
324
+
325
+ To run the unit tests use `hatch` interactively:
326
+
327
+ ```bash
328
+ hatch run tests # All tests {unit, integration}
329
+ hatch run i # Integration tests
330
+ hatch run t # Unit tests
331
+ ```
332
+
333
+ The dependencies are managed manually in your environment.
334
+
335
+ To run all the tests with differing Python versions and Django versions, use `tox`:
336
+
337
+ ```bash
338
+ tox # All tests for supported versions
339
+ tox -e py312-dj42 # All tests using Python 3.12 & Django 4.2 LTS
340
+ ```
341
+
342
+ ## Bugs! Help!!
343
+
344
+ If you find any bugs in this software, please report them via the GitHub issue tracker or send an email to code@kelvinwong.ca. Any serious security bugs should be reported via email only.
345
+
346
+ Issue tracker: <https://github.com/kelvinwong-ca/django-select-multiple-field/issues>
347
+
348
+ ## Links
349
+
350
+ * <https://pypi.org/project/django-select-multiple-field/>
351
+ * <https://github.com/kelvinwong-ca/django-select-multiple-field>
352
+
353
+ ## Thank You
354
+
355
+ Thank you for taking the time to evaluate this software. I appreciate receiving feedback on your experiences using it, and I welcome code contributions and development ideas.
356
+
357
+ <http://www.kelvinwong.ca/coders>
@@ -0,0 +1,11 @@
1
+ select_multiple_field/__about__.py,sha256=J-j-u0itpEFT6irdmWmixQqYMadNl1X91TxUmoiLHMI,22
2
+ select_multiple_field/__init__.py,sha256=x21WuUxILcXS4P-TdgpzfW_B3ztw6nN0iL0YiB4bANc,60
3
+ select_multiple_field/codecs.py,sha256=MQsyohxEaBA-_w35TQKIDBvcdH3SbYEHpzdbyW2knJ4,750
4
+ select_multiple_field/forms.py,sha256=dAcy-AquMAsDZDFFehQILFsenkzzO1_0uBsJ9LO26L0,4644
5
+ select_multiple_field/models.py,sha256=HhVUeE4Ba_yZOJ2rJxmGlTdrll1mJCzMwKI9Ft2MAR4,13795
6
+ select_multiple_field/validators.py,sha256=nP_p4H79jGkz40NacnNUZUTCB70yTVRGa5tZqEAlk6c,1523
7
+ select_multiple_field/widgets.py,sha256=MbTrH6nrh5F3Ad1RXd81vX_fxrSsypmY8sipFDj-lWk,1568
8
+ django_select_multiple_field-1.0.0.dist-info/METADATA,sha256=9LM_zB3JeJbxtUpFxoXFmzLTT-hkdhWcu5C_G7610n4,12157
9
+ django_select_multiple_field-1.0.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
10
+ django_select_multiple_field-1.0.0.dist-info/licenses/LICENSE,sha256=Ry1KabPjR5_tesZfixp4PFIvt6Byia_XRHv11vPCzlg,2973
11
+ django_select_multiple_field-1.0.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,57 @@
1
+ Copyright (c) Kelvin Wong <code@kelvinwong.ca> and contributors.
2
+ All rights reserved.
3
+
4
+ Redistribution and use in source and binary forms, with or without
5
+ modification, are permitted provided that the following conditions are met:
6
+
7
+ * Redistributions of source code must retain the above copyright
8
+ notice, this list of conditions and the following disclaimer.
9
+ * Redistributions in binary form must reproduce the above copyright
10
+ notice, this list of conditions and the following disclaimer in the
11
+ documentation and/or other materials provided with the distribution.
12
+
13
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
14
+ AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
15
+ IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
16
+ DISCLAIMED. IN NO EVENT SHALL "KELVIN WONG" BE LIABLE FOR ANY
17
+ DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
18
+ (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
19
+ LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
20
+ ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
21
+ (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
22
+ SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
23
+
24
+
25
+ ########
26
+
27
+
28
+ Some code was derived from Django which was licensed under the following terms
29
+
30
+
31
+ Copyright (c) Django Software Foundation and individual contributors.
32
+ All rights reserved.
33
+
34
+ Redistribution and use in source and binary forms, with or without
35
+ modification, are permitted provided that the following conditions are met:
36
+
37
+ 1. Redistributions of source code must retain the above copyright notice,
38
+ this list of conditions and the following disclaimer.
39
+
40
+ 2. Redistributions in binary form must reproduce the above copyright
41
+ notice, this list of conditions and the following disclaimer in the
42
+ documentation and/or other materials provided with the distribution.
43
+
44
+ 3. Neither the name of Django nor the names of its contributors may be
45
+ used to endorse or promote products derived from this software without
46
+ specific prior written permission.
47
+
48
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
49
+ AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
50
+ IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
51
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE
52
+ FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
53
+ DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
54
+ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
55
+ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
56
+ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
57
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
@@ -0,0 +1 @@
1
+ __version__ = "1.0.0"
@@ -0,0 +1,3 @@
1
+ from .__about__ import __version__
2
+
3
+ DEFAULT_DELIMITER = ","
@@ -0,0 +1,29 @@
1
+ from django.conf import settings
2
+
3
+ from . import DEFAULT_DELIMITER
4
+
5
+ delimiter = getattr(settings, "SELECTMULTIPLEFIELD_DELIMITER", DEFAULT_DELIMITER)
6
+ if len(delimiter) != 1:
7
+ raise ValueError("SELECTMULTIPLEFIELD_DELIMITER must be exactly one character")
8
+ _DELIMITER = delimiter
9
+
10
+
11
+ def decode_csv_to_list(encoded):
12
+ """
13
+ Decodes a delimiter separated string to a Python list.
14
+
15
+ Preserves order and duplicates (no sorting, no deduplication).
16
+ """
17
+ if encoded == "":
18
+ return []
19
+
20
+ return encoded.split(_DELIMITER)
21
+
22
+
23
+ def encode_list_to_csv(decoded):
24
+ """
25
+ Encodes a Python list to a delimiter separated string.
26
+
27
+ Preserves order and duplicates (no sorting, no deduplication).
28
+ """
29
+ return _DELIMITER.join(decoded)
@@ -0,0 +1,140 @@
1
+ import warnings
2
+
3
+ from django.core import validators
4
+ from django.forms import fields
5
+
6
+ from .codecs import decode_csv_to_list, encode_list_to_csv
7
+ from .widgets import SelectMultipleWidget
8
+
9
+ DEFAULT_MAX_CHOICES_ATTR = "data-max-choices"
10
+
11
+
12
+ class SelectMultipleFormField(fields.MultipleChoiceField):
13
+
14
+ widget = SelectMultipleWidget
15
+
16
+ def __init__(
17
+ self,
18
+ max_length=None,
19
+ size=4,
20
+ max_choices=None,
21
+ max_choices_attr=DEFAULT_MAX_CHOICES_ATTR,
22
+ *args,
23
+ **kwargs,
24
+ ):
25
+ """
26
+ SelectMultipleFormField rejects items with no answer by default
27
+
28
+ max_length refers to number of characters used to store the encoded
29
+ list of choices (est. 2n - 1)
30
+
31
+ size is the HTML element size attribute passed to the widget
32
+
33
+ max_choices is the maximum number of choices allowed by the field
34
+
35
+ max_choices_attr is a string used as an attribute name in the widget
36
+ representation of max_choices (currently a data attribute)
37
+
38
+ empty_value is the value used to represent an empty field
39
+
40
+ include_blank is deprecated and will be removed in a future release.
41
+ Use choices=[('', '---------'), ...] instead.
42
+ """
43
+ self.max_length, self.max_choices = max_length, max_choices
44
+ self.size, self.max_choices_attr = size, max_choices_attr
45
+ self.empty_value = kwargs.pop("empty_value", [])
46
+ if "include_blank" in kwargs:
47
+ warnings.warn(
48
+ "include_blank is deprecated; use choices=[('', '---------'), ...] instead",
49
+ DeprecationWarning,
50
+ stacklevel=2,
51
+ )
52
+ self.include_blank = kwargs.pop("include_blank")
53
+ if not hasattr(self, "empty_values"):
54
+ self.empty_values = list(validators.EMPTY_VALUES)
55
+ super(SelectMultipleFormField, self).__init__(*args, **kwargs)
56
+
57
+ def to_python(self, value):
58
+ """
59
+ Convert widget value to a Python list.
60
+
61
+ Handles lists, tuples, CSV strings, and empty values.
62
+
63
+ Returns list.
64
+ """
65
+ if isinstance(value, (list, tuple)):
66
+ if all(v in self.empty_values for v in value):
67
+ try:
68
+ # must be iterable - return a copy to avoid shared mutable state
69
+ iter(self.empty_value)
70
+ return list(self.empty_value)
71
+ except TypeError:
72
+ return []
73
+
74
+ elif (value == self.empty_value) or (value in self.empty_values):
75
+ try:
76
+ # must be iterable - return a copy to avoid shared mutable state
77
+ iter(self.empty_value)
78
+ return list(self.empty_value)
79
+ except TypeError:
80
+ return []
81
+
82
+ if isinstance(value, str):
83
+ if len(value) == 0:
84
+ return []
85
+
86
+ native = decode_csv_to_list(value)
87
+ return native
88
+
89
+ return list(value)
90
+
91
+ def get_prep_value(self, value):
92
+ """
93
+ Prepares a string for use in serializer
94
+ """
95
+ if isinstance(value, (list, tuple)):
96
+ if len(value) == 0:
97
+ return ""
98
+ else:
99
+ return encode_list_to_csv(value)
100
+
101
+ return ""
102
+
103
+ def get_choices(self, **kwargs):
104
+ """
105
+ Choices from model without initial blank choices
106
+
107
+ ie Stop widget from producing <option value="">---------</option>
108
+ """
109
+ if hasattr(self, "include_blank"):
110
+ #
111
+ # include_blank is deprecated and will be removed in a future release.
112
+ #
113
+ include_blank = self.include_blank
114
+ if "include_blank" in kwargs:
115
+ kwargs.pop("include_blank")
116
+ else:
117
+ include_blank = kwargs.pop("include_blank", False)
118
+
119
+ if hasattr(super(), "get_choices"):
120
+ field_options = {"include_blank": include_blank}
121
+ field_options.update(kwargs)
122
+ choices = super(SelectMultipleFormField, self).get_choices(**field_options)
123
+ return list(choices)
124
+ else:
125
+ return list(self.choices)
126
+
127
+ def widget_attrs(self, widget):
128
+ """
129
+ Given a Widget instance (*not* a Widget class), returns a dictionary of
130
+ any HTML attributes that should be added to the Widget, based on this
131
+ Field.
132
+ """
133
+ attrs = super(SelectMultipleFormField, self).widget_attrs(widget)
134
+ if self.size != 4:
135
+ attrs.update({"size": str(self.size)})
136
+
137
+ if self.max_choices:
138
+ attrs.update({self.max_choices_attr: str(self.max_choices)})
139
+
140
+ return attrs
@@ -0,0 +1,392 @@
1
+ import warnings
2
+
3
+ from django.core import exceptions, validators
4
+ from django.db import models
5
+ from django.utils.encoding import force_str
6
+ from django.utils.text import capfirst
7
+ from django.utils.translation import gettext_lazy as _
8
+
9
+ import select_multiple_field.forms as forms
10
+
11
+ from .codecs import decode_csv_to_list, encode_list_to_csv
12
+ from .validators import MaxChoicesValidator, MaxLengthValidator
13
+
14
+
15
+ class SelectMultipleField(models.CharField):
16
+ """Stores multiple selection choices as serialized list"""
17
+
18
+ default_error_messages = {
19
+ "blank": _("This field cannot be blank."),
20
+ "invalid_type": _(
21
+ "Types passed as value must be string, list, tuple or None, "
22
+ "not '%(value)s'."
23
+ ),
24
+ "invalid_choice": _(
25
+ "Select a valid choice. %(value)s is not one of the available choices."
26
+ ),
27
+ "null": _("This field cannot be null."),
28
+ }
29
+ description = _("Select multiple field")
30
+
31
+ def __init__(self, *args, **kwargs):
32
+ """
33
+ Stores selected choices as CSV in the database and as list-like values
34
+ in Python.
35
+
36
+ Behavior notes:
37
+
38
+ - `blank=False` means a value is required by field validation.
39
+ - `null=True` stores SQL NULL for empty values in the database.
40
+ - Python-side normalization converts `None` to an empty list in
41
+ `to_python()` / `from_db_value()` for a consistent in-memory API.
42
+ - If `choices` and `max_choices` are set and `max_length` is omitted,
43
+ `max_length` is computed from the longest possible encoded CSV value.
44
+ - If `max_length` is explicitly provided but is smaller than that
45
+ computed encoded length, a `RuntimeWarning` is emitted.
46
+
47
+ Extra kwargs:
48
+
49
+ - `max_choices`: optional positive integer that limits the number of
50
+ selected options.
51
+ """
52
+ self.max_choices = None
53
+ self.include_blank = False
54
+ self._include_blank_set = False
55
+
56
+ kwargs = kwargs.copy()
57
+
58
+ if "max_choices" in kwargs:
59
+ max_choices = kwargs.pop("max_choices")
60
+ if max_choices is not None:
61
+ if not isinstance(max_choices, int) or max_choices <= 0:
62
+ raise ValueError("max_choices must be a positive integer")
63
+ self.max_choices = max_choices
64
+
65
+ if "include_blank" in kwargs:
66
+ #
67
+ # include_blank is deprecated but retained for migration compatibility.
68
+ #
69
+ include_blank = kwargs.pop("include_blank")
70
+ if not isinstance(include_blank, bool):
71
+ raise TypeError("include_blank must be a boolean")
72
+ self.include_blank = include_blank
73
+ self._include_blank_set = True
74
+
75
+ explicit_max_length = "max_length" in kwargs
76
+
77
+ super(SelectMultipleField, self).__init__(*args, **kwargs)
78
+
79
+ if self.max_length is None and self.choices and self.max_choices is not None:
80
+ self.max_length = self._calculate_max_encoded_length()
81
+ elif explicit_max_length and self.choices and self.max_choices is not None:
82
+ calculated = self._calculate_max_encoded_length()
83
+ if self.max_length < calculated:
84
+ warnings.warn(
85
+ f"max_length={self.max_length} is too small for max_choices={self.max_choices} "
86
+ f"with choices={list(self.get_choices_keys())}. "
87
+ f"Encoded CSV will be up to {calculated} chars. "
88
+ f"Validation will fail for max selections.",
89
+ RuntimeWarning,
90
+ stacklevel=2,
91
+ )
92
+
93
+ self.validators = [
94
+ v
95
+ for v in self.validators
96
+ if not isinstance(v, validators.MaxLengthValidator)
97
+ ]
98
+
99
+ self.validators.append(MaxLengthValidator(self.max_length))
100
+ if self.max_choices is not None:
101
+ self.validators.append(MaxChoicesValidator(self.max_choices))
102
+
103
+ def _calculate_max_encoded_length(self):
104
+ """
105
+ Calculate max encoded CSV length from choices and max_choices.
106
+
107
+ Returns the max possible length of the CSV string when max_choices
108
+ are selected (choices joined by commas).
109
+ """
110
+ choice_keys = self.get_choices_keys()
111
+ if not choice_keys:
112
+ return 0
113
+
114
+ sorted_keys = sorted(choice_keys, key=len, reverse=True)
115
+ max_keys = sorted_keys[: self.max_choices]
116
+
117
+ return len(",".join(max_keys))
118
+
119
+ def get_internal_type(self):
120
+ return "CharField"
121
+
122
+ def to_python(self, value):
123
+ """
124
+ When SelectMultipleField is assigned a value, this method coerces
125
+ into a list usable by Python
126
+
127
+ value is Encoded strings from the database or Python native types in
128
+ need of validation
129
+
130
+ Raises ValidationError if value is not in choices or if invalid type
131
+
132
+ Returns list
133
+ """
134
+ if value is None:
135
+ return []
136
+
137
+ elif isinstance(value, (list, tuple)):
138
+ self.validate_options_list(value)
139
+ return value
140
+
141
+ elif isinstance(value, str):
142
+ #
143
+ # Strings are always encoded choices
144
+ #
145
+ native = decode_csv_to_list(value)
146
+ return native
147
+
148
+ msg = self.error_messages["invalid_type"] % {"value": type(value)}
149
+ raise exceptions.ValidationError(msg)
150
+
151
+ def from_db_value(self, value, expression, connection, context=None):
152
+ """
153
+ Converts a value as returned by the database to a Python object.
154
+ It is the reverse of get_prep_value().
155
+
156
+ This should always return a Python list.
157
+ """
158
+ if isinstance(value, str):
159
+ return decode_csv_to_list(value)
160
+ return []
161
+
162
+ def get_prep_value(self, value):
163
+ """
164
+ Perform preliminary non-db specific value checks and conversions.
165
+
166
+ This takes a Python list and encodes it into a string form representable
167
+ in the database.
168
+
169
+ If value is already a string (e.g. from a raw ORM lookup like
170
+ .filter(tags="django,api")), it is returned as-is to avoid
171
+ double-encoding.
172
+
173
+ Returns a string or None (if null=True and value is empty)
174
+ """
175
+ if value is None:
176
+ return None if self.null else ""
177
+
178
+ # Handle already-encoded CSV string (for query lookups)
179
+ if isinstance(value, str):
180
+ return value
181
+
182
+ if len(value) == 0:
183
+ if self.null:
184
+ return None
185
+ return ""
186
+
187
+ return encode_list_to_csv(value)
188
+
189
+ def get_choices(self, **kwargs):
190
+ """
191
+ Choices from model without initial blank choices
192
+
193
+ ie Stop widget from producing <option value="">---------</option>
194
+
195
+ If ModelField.include_blank is set then ignore any overrides sent via
196
+ kwargs
197
+ """
198
+ include_blank = False
199
+ if getattr(self, "_include_blank_set", False):
200
+ include_blank = self.include_blank
201
+ if "include_blank" in kwargs:
202
+ kwargs.pop("include_blank")
203
+ else:
204
+ include_blank = kwargs.pop("include_blank", False)
205
+
206
+ field_options = {"include_blank": include_blank}
207
+ field_options.update(kwargs)
208
+ choices = super(SelectMultipleField, self).get_choices(**field_options)
209
+ # Convert to list for Django < 5.0 compatibility (parent returns iterable in 5.0+)
210
+ return list(choices)
211
+
212
+ def has_choices(self):
213
+ """
214
+ Check if the field has choices values bound to it
215
+ """
216
+ choices = getattr(self, "choices", None)
217
+ if choices is None:
218
+ choices = getattr(self, "_choices", None)
219
+
220
+ return bool(choices)
221
+
222
+ def value_to_string(self, obj):
223
+ """
224
+ Used for serialization of the expected Python list
225
+ """
226
+ if hasattr(self, "value_from_object"):
227
+ native = self.value_from_object(obj)
228
+ else:
229
+ # Fallback for older Django versions
230
+ native = getattr(obj, self.attname)
231
+ return encode_list_to_csv(native)
232
+
233
+ def validate(self, value, model_instance):
234
+ """
235
+ Validates value and throws ValidationError. Subclasses should override
236
+ this to provide validation logic.
237
+ """
238
+ if not self.editable:
239
+ return
240
+
241
+ if isinstance(value, str):
242
+ value = decode_csv_to_list(value)
243
+
244
+ # Replicate parent Field.validate() blank/null/choice checks.
245
+ # We don't call super().validate() because with choices set
246
+ # and value as a list, the parent tries to match the list
247
+ # against choice keys, which always fails for multi-select values.
248
+ # Note: run_validators() is called by Field.clean() after validate(),
249
+ # so we don't call it here.
250
+ if not self.blank and value in validators.EMPTY_VALUES:
251
+ raise exceptions.ValidationError(self.error_messages["blank"], code="blank")
252
+ if value is None and not self.null:
253
+ raise exceptions.ValidationError(self.error_messages["null"], code="null")
254
+
255
+ if self.has_choices() and value:
256
+ if isinstance(value, (list, tuple)):
257
+ bad_values = self._find_invalid_choices(value)
258
+ if bad_values:
259
+ msg = self.error_messages["invalid_choice"] % {"value": bad_values}
260
+ raise exceptions.ValidationError(msg)
261
+ else:
262
+ msg = self.error_messages["invalid_choice"] % {"value": value}
263
+ raise exceptions.ValidationError(msg)
264
+
265
+ def _find_invalid_choices(self, value):
266
+ """
267
+ Returns a list of invalid choices in value that are not in the field's choices.
268
+ """
269
+ bad_values = []
270
+ for opt in value:
271
+ if self.blank and opt in validators.EMPTY_VALUES:
272
+ pass
273
+ elif opt not in self.get_choices_keys():
274
+ bad_values.append(opt)
275
+ return bad_values
276
+
277
+ def validate_options_list(self, value):
278
+ """
279
+ Checks that all options in value list are in choices
280
+
281
+ Raises ValidationError if an option in value list is not in choices
282
+
283
+ Returns None if all values are in choices
284
+ """
285
+ bad_values = self._find_invalid_choices(value)
286
+ if bad_values:
287
+ msg = self.error_messages["invalid_choice"] % {"value": bad_values}
288
+ raise exceptions.ValidationError(msg)
289
+
290
+ def get_choices_keys(self, **kwargs):
291
+ """
292
+ Flattens choices and optgroup choices into a plain list of keys
293
+
294
+ Returns choices keys as list
295
+ """
296
+ if not kwargs and hasattr(self, "_flat_choices_cache"):
297
+ return self._flat_choices_cache
298
+
299
+ choices = self.get_choices(**kwargs)
300
+ flat = []
301
+ for key, val in choices:
302
+ if isinstance(val, (list, tuple)):
303
+ for opt_key, opt_val in val:
304
+ flat.append(opt_key)
305
+ else:
306
+ flat.append(key)
307
+
308
+ if not kwargs:
309
+ self._flat_choices_cache = flat
310
+ return flat
311
+
312
+ def validate_option(self, value):
313
+ """
314
+ Legacy helper not used by the field's internal validation flow.
315
+
316
+ Checks that value is in choices.
317
+ """
318
+ if self.blank and value in validators.EMPTY_VALUES:
319
+ return True
320
+
321
+ flat_choices = self.get_choices_keys()
322
+ return value in flat_choices
323
+
324
+ def formfield(self, **kwargs):
325
+ """
326
+ This returns the correct formclass without calling super
327
+
328
+ Returns select_multiple_field.forms.SelectMultipleFormField
329
+ """
330
+ defaults = {
331
+ "required": not self.blank,
332
+ "label": capfirst(self.verbose_name),
333
+ "help_text": self.help_text,
334
+ }
335
+ if self.has_default():
336
+ if callable(self.default):
337
+ defaults["initial"] = self.default
338
+ defaults["show_hidden_initial"] = True
339
+ else:
340
+ defaults["initial"] = self.get_default()
341
+
342
+ if self.choices:
343
+ # Django normally includes an empty choice if blank, has_default
344
+ # and initial are all False, we are intentionally breaking this
345
+ # convention
346
+ include_blank = self.blank
347
+ defaults["choices"] = self.get_choices(include_blank=include_blank)
348
+ if self.null:
349
+ defaults["empty_value"] = None
350
+
351
+ allowed = {
352
+ "empty_value",
353
+ "choices",
354
+ "required",
355
+ "widget",
356
+ "label",
357
+ "initial",
358
+ "help_text",
359
+ "error_messages",
360
+ "show_hidden_initial",
361
+ }
362
+ kwargs = {k: v for k, v in kwargs.items() if k in allowed}
363
+
364
+ defaults.update(kwargs)
365
+ return forms.SelectMultipleFormField(**defaults)
366
+
367
+ def deconstruct(self):
368
+ """
369
+ How to reduce the field to a serializable form.
370
+
371
+ The arguments to pass to field constructor to reconstruct it.
372
+
373
+ Returns a tuple of four items:
374
+ the field's attribute name,
375
+ the full import path of the field class,
376
+ the positional arguments (an empty list in this case),
377
+ the keyword arguments (as a dict).
378
+ """
379
+ name, path, args, kwargs = super(SelectMultipleField, self).deconstruct()
380
+
381
+ if self.max_choices is not None:
382
+ kwargs["max_choices"] = self.max_choices
383
+
384
+ if getattr(self, "_include_blank_set", False):
385
+ kwargs["include_blank"] = self.include_blank
386
+
387
+ return (
388
+ force_str(name, strings_only=True),
389
+ path,
390
+ args,
391
+ kwargs,
392
+ )
@@ -0,0 +1,48 @@
1
+ from django.core import validators
2
+ from django.utils.deconstruct import deconstructible
3
+ from django.utils.encoding import force_str
4
+ from django.utils.translation import ngettext_lazy
5
+
6
+ from .codecs import encode_list_to_csv
7
+
8
+
9
+ @deconstructible
10
+ class MaxChoicesValidator(validators.BaseValidator):
11
+
12
+ message = ngettext_lazy(
13
+ "Ensure this value has at most %(limit_value)d choice (it has %(show_value)d).",
14
+ "Ensure this value has at most %(limit_value)d choices (it has %(show_value)d).",
15
+ "limit_value",
16
+ )
17
+ code = "max_choices"
18
+
19
+ def compare(self, a, b):
20
+ return a > b
21
+
22
+ def clean(self, x):
23
+ return len(x)
24
+
25
+
26
+ @deconstructible
27
+ class MaxLengthValidator(validators.BaseValidator):
28
+ """
29
+ Validates that the encoded CSV string length does not exceed max_length.
30
+
31
+ Since this field stores data as a CSV string in a CharField, max_length
32
+ refers to the database column width. The validator encodes the list to
33
+ CSV to measure the actual stored length. The double encoding (here +
34
+ get_prep_value) is standard — validators operate on Python values.
35
+ """
36
+
37
+ message = ngettext_lazy(
38
+ "Ensure this value has at most %(limit_value)d character (it has %(show_value)d).",
39
+ "Ensure this value has at most %(limit_value)d characters (it has %(show_value)d).",
40
+ "limit_value",
41
+ )
42
+ code = "max_length"
43
+
44
+ def compare(self, a, b):
45
+ return a > b
46
+
47
+ def clean(self, value):
48
+ return len(force_str(encode_list_to_csv(value)))
@@ -0,0 +1,52 @@
1
+ import warnings
2
+
3
+ from django.forms import widgets
4
+
5
+ HTML_ATTR_CLASS = "select-multiple-field"
6
+
7
+
8
+ class SelectMultipleWidget(widgets.SelectMultiple):
9
+ """Multiple select widget ready for jQuery multiselect.js"""
10
+
11
+ allow_multiple_selected = True
12
+
13
+ def render(self, name, value, attrs=None, choices=(), renderer=None):
14
+ rendered_attrs = {"class": HTML_ATTR_CLASS}
15
+ if attrs:
16
+ rendered_attrs.update(attrs)
17
+ if value is None:
18
+ value = []
19
+
20
+ original_choices = self.choices
21
+ try:
22
+ if choices:
23
+ self.choices = choices
24
+ if renderer is not None:
25
+ return super().render(
26
+ name, value, attrs=rendered_attrs, renderer=renderer
27
+ )
28
+ else:
29
+ return super().render(name, value, attrs=rendered_attrs)
30
+ finally:
31
+ self.choices = original_choices
32
+
33
+ def value_from_datadict(self, data, files, name):
34
+ """
35
+ SelectMultipleWidget delegates processing of raw user data to
36
+ Django's SelectMultiple widget
37
+
38
+ Returns list or None
39
+ """
40
+ return super().value_from_datadict(data, files, name)
41
+
42
+
43
+ class SelectMultipleField(SelectMultipleWidget):
44
+ """Deprecated — use SelectMultipleWidget instead."""
45
+
46
+ def __init__(self, *args, **kwargs):
47
+ warnings.warn(
48
+ "SelectMultipleField is deprecated; use SelectMultipleWidget instead.",
49
+ DeprecationWarning,
50
+ stacklevel=2,
51
+ )
52
+ super().__init__(*args, **kwargs)