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.
- django_select_multiple_field-1.0.0.dist-info/METADATA +357 -0
- django_select_multiple_field-1.0.0.dist-info/RECORD +11 -0
- django_select_multiple_field-1.0.0.dist-info/WHEEL +4 -0
- django_select_multiple_field-1.0.0.dist-info/licenses/LICENSE +57 -0
- select_multiple_field/__about__.py +1 -0
- select_multiple_field/__init__.py +3 -0
- select_multiple_field/codecs.py +29 -0
- select_multiple_field/forms.py +140 -0
- select_multiple_field/models.py +392 -0
- select_multiple_field/validators.py +48 -0
- select_multiple_field/widgets.py +52 -0
|
@@ -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
|
+

|
|
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,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,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)
|