Skip to content

Commit 9c5241d

Browse files
authored
Merge pull request #508 from MongoEngine/boolean_field
New model form generator: Support of BooleanField
2 parents 21b59b5 + d8abc8d commit 9c5241d

File tree

10 files changed

+255
-24
lines changed

10 files changed

+255
-24
lines changed

.sourcery.yaml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@ ignore: []
22

33
refactor:
44
include: []
5-
skip: []
5+
skip:
6+
- use-contextlib-suppress
67
rule_types:
78
- refactoring
89
- suggestion

docs/forms.md

Lines changed: 115 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,119 @@ Not yet documented. Please help us with new pull request.
8585

8686
### BooleanField
8787

88-
Not yet documented. Please help us with new pull request.
88+
- API: {class}`.db_fields.BooleanField`
89+
- Default form field class: {class}`~.MongoBooleanField`
90+
91+
#### Form generation behaviour
92+
93+
BooleanField is very complicated in terms of Mongo database support. In
94+
Flask-Mongoengine before version **2.0.0+** database BooleanField used
95+
{class}`wtforms.fields.BooleanField` as form representation, this raised several not
96+
clear problems, that was related to how {class}`wtforms.fields.BooleanField` parse
97+
and work with form values. Known problems in version, before **2.0.0+**:
98+
99+
- Default value of field, specified in database definition was ignored, if default
100+
is `None` and nulls allowed, i.e. {attr}`null=True` (Value was always `False`).
101+
- Field was always created in database document, even if not checked, as there is
102+
impossible to split `None` and `False` values, when only checkbox available.
103+
104+
To fix all these issues, and do not create database field by default, Flask-Mongoengine
105+
**2.0.0+** uses dropdown field by default.
106+
107+
By default, database BooleanField not allowing `None` value, meaning that field can
108+
be `True`, `False` or not created in database at all. If database field configuration
109+
allowing `None` values, i.e. {attr}`null=True`, then, when nothing selected in
110+
dropdown, the field will be created with `None` value.
111+
112+
```{important}
113+
It is responsobility of developer, to correctly setup database field definition and
114+
make proper tests before own application release. BooleanField can create unexpected
115+
application behavior in if checks. Developer, should recheck all if checks like:
116+
117+
- `if filed_value:` this will match `True` database value
118+
- `if not filed_value:` this will match `False` or `None` database value or not existing
119+
document key
120+
- `if field_value is None:` this will match `None` database value or not existing
121+
document key
122+
- `if field_value is True:` this will match `True` database value
123+
- `if field_value is False:` this will match `False` database value
124+
- `if field_value is not None:` this will match `True`, `False` database value
125+
- `if field_value is not True:` this will match `False`, `None` database value or not
126+
existing document key
127+
- `if filed_value is not False:` this will match `True`, `None` database value or not
128+
existing document key
129+
```
130+
131+
#### Examples
132+
133+
##### BooleanField with default dropdown
134+
135+
Such definition will not create any field in document, if dropdown not selected.
136+
137+
```python
138+
"""boolean_demo.py"""
139+
from example_app.models import db
140+
141+
142+
class BooleanDemoModel(db.Document):
143+
"""Documentation example model."""
144+
145+
boolean_field = db.BooleanField()
146+
```
147+
148+
##### BooleanField with allowed `None` value
149+
150+
Such definition will create document field, even if nothing selected. The value will
151+
be `None`. If, during edit, `yes` or `no` dropdown values replaced to `---`, then
152+
saved value in document will be aslo changed to `None`.
153+
154+
By default, `None` value represented as `---` text in dropdown.
155+
156+
```python
157+
"""boolean_demo.py"""
158+
from example_app.models import db
159+
160+
161+
class BooleanDemoModel(db.Document):
162+
"""Documentation example model."""
163+
164+
boolean_field_with_null = db.BooleanField(null=True)
165+
```
166+
167+
##### BooleanField with replaced dropdown text
168+
169+
Dropdown text can be easily replaced, there is only one requirement: New choices,
170+
should be correctly coerced by {func}`~.coerce_boolean`, or function should be
171+
replaced too.
172+
173+
```python
174+
"""boolean_demo.py"""
175+
from example_app.models import db
176+
177+
178+
class BooleanDemoModel(db.Document):
179+
"""Documentation example model."""
180+
181+
boolean_field_with_as_choices_replace = db.BooleanField(
182+
wtf_options={
183+
"choices": [("", "Not selected"), ("yes", "Positive"), ("no", "Negative")]
184+
}
185+
)
186+
187+
```
188+
189+
##### BooleanField with default `True` value, but with allowed nulls
190+
191+
```python
192+
"""boolean_demo.py"""
193+
from example_app.models import db
194+
195+
196+
class BooleanDemoModel(db.Document):
197+
"""Documentation example model."""
198+
199+
true_boolean_field_with_allowed_null = db.BooleanField(default=True, null=True)
200+
```
89201

90202
### ComplexDateTimeField
91203

@@ -174,6 +286,8 @@ done, during field generation. Field is fully controllable by [global transforms
174286
dates_demo.py in example app contain basic non-requirement example. You can adjust
175287
it to any provided example for test purposes.
176288

289+
##### Not limited DateField
290+
177291
```python
178292
"""dates_demo.py"""
179293
from example_app.models import db
@@ -185,12 +299,6 @@ class DateTimeModel(db.Document):
185299
date = db.DateField()
186300
```
187301

188-
##### Not limited DateField
189-
190-
```python
191-
pass
192-
```
193-
194302
### DateTimeField
195303

196304
- API: {class}`.db_fields.DateTimeField`

example_app/app.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from pymongo import monitoring
44

55
from example_app import views
6+
from example_app.boolean_demo import boolean_demo_view
67
from example_app.dates_demo import dates_demo_view
78
from example_app.models import db
89
from example_app.numbers_demo import numbers_demo_view
@@ -49,6 +50,8 @@
4950
app.add_url_rule("/numbers/<pk>/", view_func=numbers_demo_view, methods=["GET", "POST"])
5051
app.add_url_rule("/dates", view_func=dates_demo_view, methods=["GET", "POST"])
5152
app.add_url_rule("/dates/<pk>/", view_func=dates_demo_view, methods=["GET", "POST"])
53+
app.add_url_rule("/bool", view_func=boolean_demo_view, methods=["GET", "POST"])
54+
app.add_url_rule("/bool/<pk>/", view_func=boolean_demo_view, methods=["GET", "POST"])
5255

5356
if __name__ == "__main__":
5457
app.run(host="0.0.0.0", port=8000)

example_app/boolean_demo.py

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
"""Booleans fields demo model."""
2+
3+
from flask import render_template, request
4+
5+
from example_app.models import db
6+
7+
8+
class BooleanDemoModel(db.Document):
9+
"""Documentation example model."""
10+
11+
simple_sting_name = db.StringField()
12+
boolean_field = db.BooleanField()
13+
boolean_field_with_null = db.BooleanField(null=True)
14+
true_boolean_field_with_allowed_null = db.BooleanField(null=True, default=True)
15+
boolean_field_with_as_choices_replace = db.BooleanField(
16+
wtf_options={
17+
"choices": [("", "Not selected"), ("yes", "Positive"), ("no", "Negative")]
18+
}
19+
)
20+
21+
22+
BooleanDemoForm = BooleanDemoModel.to_wtf_form()
23+
24+
25+
def boolean_demo_view(pk=None):
26+
"""Return all fields demonstration."""
27+
form = BooleanDemoForm()
28+
obj = None
29+
if pk:
30+
obj = BooleanDemoModel.objects.get(pk=pk)
31+
form = BooleanDemoForm(obj=obj)
32+
33+
if request.method == "POST" and form.validate_on_submit():
34+
if pk:
35+
form.populate_obj(obj)
36+
obj.save()
37+
else:
38+
form.save()
39+
page_num = int(request.args.get("page") or 1)
40+
page = BooleanDemoModel.objects.paginate(page=page_num, per_page=100)
41+
42+
return render_template(
43+
"form_demo.html",
44+
view=boolean_demo_view.__name__,
45+
page=page,
46+
form=form,
47+
model=BooleanDemoModel,
48+
)

example_app/templates/layout.html

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
<li><a href="{{ url_for("strings_demo_view") }}">Strings demo</a></li>
2222
<li><a href="{{ url_for("numbers_demo_view") }}">Numbers demo</a></li>
2323
<li><a href="{{ url_for("dates_demo_view") }}">DateTime demo</a></li>
24+
<li><a href="{{ url_for("boolean_demo_view") }}">Booleans demo</a></li>
2425
</ul>
2526
</nav>
2627
<div>

example_app/views.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from mongoengine.context_managers import switch_db
55

66
from example_app import models
7+
from example_app.boolean_demo import BooleanDemoModel
78
from example_app.dates_demo import DateTimeModel
89
from example_app.numbers_demo import NumbersDemoModel
910
from example_app.strings_demo import StringsDemoModel
@@ -50,6 +51,7 @@ def delete_data():
5051
"""Clear database."""
5152
with switch_db(models.Todo, "default"):
5253
models.Todo.objects().delete()
54+
BooleanDemoModel.objects().delete()
5355
DateTimeModel.objects().delete()
5456
StringsDemoModel.objects().delete()
5557
NumbersDemoModel.objects().delete()

flask_mongoengine/db_fields.py

Lines changed: 1 addition & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -329,21 +329,7 @@ class BooleanField(WtfFieldMixin, fields.BooleanField):
329329
All arguments should be passed as keyword arguments, to exclude unexpected behaviour.
330330
"""
331331

332-
DEFAULT_WTF_FIELD = wtf_fields.BooleanField if wtf_fields else None
333-
DEFAULT_WTF_CHOICES_COERCE = bool
334-
335-
def to_wtf_field(
336-
self,
337-
*,
338-
model: Optional[Type] = None,
339-
field_kwargs: Optional[dict] = None,
340-
):
341-
"""
342-
Protection from execution of :func:`to_wtf_field` in form generation.
343-
344-
:raises NotImplementedError: Field converter to WTForm Field not implemented.
345-
"""
346-
raise NotImplementedError("Field converter to WTForm Field not implemented.")
332+
DEFAULT_WTF_FIELD = custom_fields.MongoBooleanField if custom_fields else None
347333

348334

349335
class CachedReferenceField(WtfFieldMixin, fields.CachedReferenceField):

flask_mongoengine/wtf/fields.py

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
"QuerySetSelectField",
77
]
88
from gettext import gettext as _
9+
from typing import Optional
910

1011
from flask import json
1112
from mongoengine.queryset import DoesNotExist
@@ -14,6 +15,23 @@
1415
from wtforms import widgets as wtf_widgets
1516

1617

18+
def coerce_boolean(value: Optional[str]) -> Optional[bool]:
19+
"""Transform SelectField boolean value from string and in reverse direction."""
20+
try:
21+
value = value.lower()
22+
except AttributeError:
23+
pass
24+
25+
if value is None or value in {"", "none", "null"}:
26+
return None
27+
elif value is False or value in {"no", "n", "false"}:
28+
return False
29+
elif value is True or value in {"yes", "y", "true"}:
30+
return True
31+
else:
32+
raise ValueError("Unexpected string value.")
33+
34+
1735
# noinspection PyAttributeOutsideInit,PyAbstractClass
1836
class QuerySetSelectField(wtf_fields.SelectFieldBase):
1937
"""
@@ -291,6 +309,40 @@ def process_formdata(self, valuelist):
291309
super().process_formdata(valuelist)
292310

293311

312+
class MongoBooleanField(wtf_fields.SelectField):
313+
"""Mongo SelectField field for BooleanFields, that correctly coerce values."""
314+
315+
def __init__(
316+
self,
317+
label=None,
318+
validators=None,
319+
coerce=None,
320+
choices=None,
321+
validate_choice=True,
322+
**kwargs,
323+
):
324+
"""
325+
Replaces defaults of :class:`wtforms.fields.SelectField` with for Boolean values.
326+
327+
Fully compatible with :class:`wtforms.fields.SelectField` and have same parameters.
328+
329+
330+
"""
331+
if coerce is None:
332+
coerce = coerce_boolean
333+
if choices is None:
334+
choices = [("", "---"), ("yes", "yes"), ("no", "no")]
335+
336+
super().__init__(
337+
label=label,
338+
validators=validators,
339+
coerce=coerce,
340+
choices=choices,
341+
validate_choice=validate_choice,
342+
**kwargs,
343+
)
344+
345+
294346
class MongoEmailField(EmptyStringIsNoneMixin, wtf_fields.EmailField):
295347
"""
296348
Regular :class:`wtforms.fields.EmailField`, that transform empty string to `None`.

tests/test_db_fields.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -149,7 +149,6 @@ def test__ensure_callable_or_list__raise_error_if_argument_not_callable_and_not_
149149
"FieldClass",
150150
[
151151
db_fields.BinaryField,
152-
db_fields.BooleanField,
153152
db_fields.CachedReferenceField,
154153
db_fields.DictField,
155154
db_fields.DynamicField,

tests/test_forms_v2.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,37 @@ def local_app(app):
1818
yield app
1919

2020

21+
@pytest.mark.parametrize(
22+
["value", "expected_value"],
23+
[
24+
("", None),
25+
("none", None),
26+
("nOne", None),
27+
("None", None),
28+
("null", None),
29+
(None, None),
30+
("no", False),
31+
("N", False),
32+
("n", False),
33+
("false", False),
34+
("False", False),
35+
(False, False),
36+
("yes", True),
37+
("y", True),
38+
("true", True),
39+
(True, True),
40+
],
41+
)
42+
def test_coerce_boolean__return_correct_value(value, expected_value):
43+
assert mongo_fields.coerce_boolean(value) == expected_value
44+
45+
46+
def test_coerce_boolean__raise_on_unexpected_value():
47+
with pytest.raises(ValueError) as error:
48+
mongo_fields.coerce_boolean("some")
49+
assert str(error.value) == "Unexpected string value."
50+
51+
2152
def test__full_document_form__does_not_create_any_unexpected_data_in_database(db):
2253
"""
2354
Test to ensure that we are following own promise in documentation, read:

0 commit comments

Comments
 (0)