From 1ed9c3089292b0f8ef432c3ea0336446f0f9b4b8 Mon Sep 17 00:00:00 2001 From: Andrey Shpak Date: Fri, 12 Aug 2022 12:39:18 +0300 Subject: [PATCH 1/5] Add numbers field to example app demo base --- example_app/app.py | 5 +++ example_app/numbers_demo.py | 40 ++++++++++++++++++++++++ example_app/templates/layout.html | 1 + example_app/templates/numbers_demo.html | 41 +++++++++++++++++++++++++ example_app/views.py | 2 ++ 5 files changed, 89 insertions(+) create mode 100644 example_app/numbers_demo.py create mode 100644 example_app/templates/numbers_demo.html diff --git a/example_app/app.py b/example_app/app.py index f8cb4b55..8f1f721c 100644 --- a/example_app/app.py +++ b/example_app/app.py @@ -4,6 +4,7 @@ from example_app import views from example_app.models import db +from example_app.numbers_demo import numbers_demo_view from example_app.strings_demo import strings_demo_view from flask_mongoengine.panels import mongo_command_logger @@ -45,6 +46,10 @@ app.add_url_rule( "/strings_demo//", view_func=strings_demo_view, methods=["GET", "POST"] ) +app.add_url_rule("/numbers_demo", view_func=numbers_demo_view, methods=["GET", "POST"]) +app.add_url_rule( + "/numbers_demo//", view_func=numbers_demo_view, methods=["GET", "POST"] +) if __name__ == "__main__": app.run(host="0.0.0.0", port=8000) diff --git a/example_app/numbers_demo.py b/example_app/numbers_demo.py new file mode 100644 index 00000000..7591da57 --- /dev/null +++ b/example_app/numbers_demo.py @@ -0,0 +1,40 @@ +"""Numbers and related fields demo model.""" + +from flask import render_template, request + +from example_app.models import db + + +class NumbersDemoModel(db.Document): + """Documentation example model.""" + + simple_sting_name = db.StringField() + decimal_field_unlimited = db.DecimalField() + integer_field_unlimited = db.IntField() + decimal_field_limited = db.DecimalField() + integer_field_limited = db.IntField() + + +NumbersDemoForm = NumbersDemoModel.to_wtf_form() + + +def numbers_demo_view(pk=None): + """Return all fields demonstration.""" + form = NumbersDemoForm() + obj = None + if pk: + obj = NumbersDemoModel.objects.get(pk=pk) + form = NumbersDemoForm(obj=obj) + + if request.method == "POST" and form.validate_on_submit(): + if pk: + form.populate_obj(obj) + obj.save() + else: + form.save() + page_num = int(request.args.get("page") or 1) + page = NumbersDemoModel.objects.paginate(page=page_num, per_page=100) + + return render_template( + "numbers_demo.html", page=page, form=form, model=NumbersDemoModel + ) diff --git a/example_app/templates/layout.html b/example_app/templates/layout.html index b49ea6ae..a188680d 100644 --- a/example_app/templates/layout.html +++ b/example_app/templates/layout.html @@ -19,6 +19,7 @@
  • Home
  • Pagination
  • Strings demo
  • +
  • Numbers demo
  • diff --git a/example_app/templates/numbers_demo.html b/example_app/templates/numbers_demo.html new file mode 100644 index 00000000..d18bb39d --- /dev/null +++ b/example_app/templates/numbers_demo.html @@ -0,0 +1,41 @@ +{% extends "layout.html" %} +{% from "_formhelpers.html" import render_field %} +{% from "_formhelpers.html" import render_navigation %} + +{% block body %} + +
    + + + + {% for field in model._fields_ordered %} + + {% endfor %} + + + + + {% for page_object in page.items %} + + {% for field in page_object._fields_ordered %} + + {% endfor %} + + + {% endfor %} + +
    {{ model[field].name }}Edit
    {{ page_object[field] }}edit
    +
    +
    + {{ render_navigation(page, "numbers_demo_view") }} +
    +
    +
    + {% for field in form %} + {{ render_field(field, style='font-weight: bold') }} + {% endfor %} + +
    +
    + +{% endblock %} diff --git a/example_app/views.py b/example_app/views.py index 9fd1213b..01a1f049 100644 --- a/example_app/views.py +++ b/example_app/views.py @@ -4,6 +4,7 @@ from mongoengine.context_managers import switch_db from example_app import models +from example_app.numbers_demo import NumbersDemoModel from example_app.strings_demo import StringsDemoModel @@ -49,6 +50,7 @@ def delete_data(): with switch_db(models.Todo, "default"): models.Todo.objects().delete() StringsDemoModel.objects().delete() + NumbersDemoModel.objects().delete() with switch_db(models.Todo, "secondary"): models.Todo.objects().delete() From 14f81b6eeb719e11da06ae19a1b643f2d10dd7fa Mon Sep 17 00:00:00 2001 From: Andrey Shpak Date: Fri, 12 Aug 2022 13:55:46 +0300 Subject: [PATCH 2/5] Set number limits in example app --- example_app/numbers_demo.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/example_app/numbers_demo.py b/example_app/numbers_demo.py index 7591da57..885bb07f 100644 --- a/example_app/numbers_demo.py +++ b/example_app/numbers_demo.py @@ -1,5 +1,7 @@ """Numbers and related fields demo model.""" +from decimal import Decimal + from flask import render_template, request from example_app.models import db @@ -9,10 +11,14 @@ class NumbersDemoModel(db.Document): """Documentation example model.""" simple_sting_name = db.StringField() + float_field_unlimited = db.FloatField() decimal_field_unlimited = db.DecimalField() integer_field_unlimited = db.IntField() - decimal_field_limited = db.DecimalField() - integer_field_limited = db.IntField() + float_field_limited = db.FloatField(min_value=float(1), max_value=200.455) + decimal_field_limited = db.DecimalField( + min_value=Decimal("1"), max_value=Decimal("200.455") + ) + integer_field_limited = db.IntField(min_value=1, max_value=200) NumbersDemoForm = NumbersDemoModel.to_wtf_form() From adfed53bbbbdef81e01e087b6ef0d4d85a64cc97 Mon Sep 17 00:00:00 2001 From: Andrey Shpak Date: Fri, 12 Aug 2022 13:56:50 +0300 Subject: [PATCH 3/5] Add DecimalField, FloatField and IntField wtf forms support --- flask_mongoengine/db_fields.py | 80 ++++++++++++++++++++------------- flask_mongoengine/wtf/fields.py | 9 ++++ tests/test_db_fields.py | 3 -- 3 files changed, 58 insertions(+), 34 deletions(-) diff --git a/flask_mongoengine/db_fields.py b/flask_mongoengine/db_fields.py index eb864a9c..43c11315 100644 --- a/flask_mongoengine/db_fields.py +++ b/flask_mongoengine/db_fields.py @@ -85,6 +85,30 @@ def _setup_strings_common_validators(options: dict, obj: fields.StringField) -> return options +@wtf_required +def _setup_numbers_common_validators( + options: dict, obj: Union[fields.IntField, fields.DecimalField, fields.FloatField] +) -> dict: + """ + Extend :attr:`base_options` with common validators for number types. + + :param options: dict, usually from :class:`WtfFieldMixin.wtf_generated_options` + :param obj: Any :class:`mongoengine.fields.IntField` or + :class:`mongoengine.fields.DecimalField` or + :class:`mongoengine.fields.FloatField` subclasses instance. + """ + assert isinstance( + obj, (fields.IntField, fields.DecimalField, fields.FloatField) + ), "Improperly configured" + + if obj.min_value or obj.max_value: + options["validators"].insert( + 0, wtf_validators_.NumberRange(min=obj.min_value, max=obj.max_value) + ) + + return options + + class WtfFieldMixin: """ Extension wrapper class for mongoengine BaseField. @@ -425,18 +449,16 @@ class DecimalField(WtfFieldMixin, fields.DecimalField): DEFAULT_WTF_FIELD = wtf_fields.DecimalField if wtf_fields else None DEFAULT_WTF_CHOICES_COERCE = decimal.Decimal - def to_wtf_field( - self, - *, - model: Optional[Type] = None, - field_kwargs: Optional[dict] = None, - ): + @property + @wtf_required + def wtf_generated_options(self) -> dict: """ - Protection from execution of :func:`to_wtf_field` in form generation. - - :raises NotImplementedError: Field converter to WTForm Field not implemented. + Extend form validators with :class:`wtforms.validators.NumberRange`. """ - raise NotImplementedError("Field converter to WTForm Field not implemented.") + options = super().wtf_generated_options + options = _setup_numbers_common_validators(options, self) + + return options class DictField(WtfFieldMixin, fields.DictField): @@ -610,21 +632,19 @@ class FloatField(WtfFieldMixin, fields.FloatField): All arguments should be passed as keyword arguments, to exclude unexpected behaviour. """ - DEFAULT_WTF_FIELD = wtf_fields.FloatField if wtf_fields else None + DEFAULT_WTF_FIELD = custom_fields.MongoFloatField if wtf_fields else None DEFAULT_WTF_CHOICES_COERCE = float - def to_wtf_field( - self, - *, - model: Optional[Type] = None, - field_kwargs: Optional[dict] = None, - ): + @property + @wtf_required + def wtf_generated_options(self) -> dict: """ - Protection from execution of :func:`to_wtf_field` in form generation. - - :raises NotImplementedError: Field converter to WTForm Field not implemented. + Extend form validators with :class:`wtforms.validators.NumberRange`. """ - raise NotImplementedError("Field converter to WTForm Field not implemented.") + options = super().wtf_generated_options + options = _setup_numbers_common_validators(options, self) + + return options class GenericEmbeddedDocumentField(WtfFieldMixin, fields.GenericEmbeddedDocumentField): @@ -770,18 +790,16 @@ class IntField(WtfFieldMixin, fields.IntField): DEFAULT_WTF_FIELD = wtf_fields.IntegerField if wtf_fields else None DEFAULT_WTF_CHOICES_COERCE = int - def to_wtf_field( - self, - *, - model: Optional[Type] = None, - field_kwargs: Optional[dict] = None, - ): + @property + @wtf_required + def wtf_generated_options(self) -> dict: """ - Protection from execution of :func:`to_wtf_field` in form generation. - - :raises NotImplementedError: Field converter to WTForm Field not implemented. + Extend form validators with :class:`wtforms.validators.NumberRange`. """ - raise NotImplementedError("Field converter to WTForm Field not implemented.") + options = super().wtf_generated_options + options = _setup_numbers_common_validators(options, self) + + return options class LazyReferenceField(WtfFieldMixin, fields.LazyReferenceField): diff --git a/flask_mongoengine/wtf/fields.py b/flask_mongoengine/wtf/fields.py index 1454e6bf..6971d36e 100644 --- a/flask_mongoengine/wtf/fields.py +++ b/flask_mongoengine/wtf/fields.py @@ -353,3 +353,12 @@ class MongoURLField(EmptyStringIsNoneMixin, wtf_fields.URLField): """ pass + + +class MongoFloatField(wtf_fields.FloatField): + """ + Regular :class:`wtforms.fields.FloatField`, with widget replaced to + :class:`wtforms.widgets.NumberInput`. + """ + + widget = wtf_widgets.NumberInput(step="any") diff --git a/tests/test_db_fields.py b/tests/test_db_fields.py index fd7f7640..c97c74c1 100644 --- a/tests/test_db_fields.py +++ b/tests/test_db_fields.py @@ -154,21 +154,18 @@ def test__ensure_callable_or_list__raise_error_if_argument_not_callable_and_not_ db_fields.ComplexDateTimeField, db_fields.DateField, db_fields.DateTimeField, - db_fields.DecimalField, db_fields.DictField, db_fields.DynamicField, db_fields.EmbeddedDocumentField, db_fields.EmbeddedDocumentListField, db_fields.EnumField, db_fields.FileField, - db_fields.FloatField, db_fields.GenericEmbeddedDocumentField, db_fields.GenericLazyReferenceField, db_fields.GenericReferenceField, db_fields.GeoJsonBaseField, db_fields.GeoPointField, db_fields.ImageField, - db_fields.IntField, db_fields.LazyReferenceField, db_fields.LineStringField, db_fields.ListField, From 08b81eaeb2467c22a8eb6560bf5feac05f1ed027 Mon Sep 17 00:00:00 2001 From: Andrey Shpak Date: Fri, 12 Aug 2022 14:27:22 +0300 Subject: [PATCH 4/5] Add docs --- docs/forms.md | 140 ++++++++++++++++++++++++++++++++- flask_mongoengine/db_fields.py | 4 + 2 files changed, 141 insertions(+), 3 deletions(-) diff --git a/docs/forms.md b/docs/forms.md index f734add2..28c180c3 100644 --- a/docs/forms.md +++ b/docs/forms.md @@ -97,7 +97,51 @@ Not yet documented. Please help us with new pull request. ### DecimalField -Not yet documented. Please help us with new pull request. +- API: {class}`.db_fields.DecimalField` +- Default form field class: {class}`wtforms.fields.DecimalField` + +#### Form generation behaviour + +From form generation side this field is pretty standard and do not use any form +generation adjustments. + +If database field definition has any of {attr}`min_value` or {attr}`max_value`, then +{class}`~wtforms.validators.NumberRange` validator will be added to form field. + +#### Examples + +numbers_demo.py in example app contain basic non-requirement example. You can adjust +it to any provided example for test purposes. + +##### Not limited DecimalField + +```python +"""numbers_demo.py""" +from example_app.models import db + + +class NumbersDemoModel(db.Document): + """Documentation example model.""" + + decimal_field_unlimited = db.DecimalField() +``` + +##### Limited DecimalField + +```python +"""numbers_demo.py""" +from decimal import Decimal + +from example_app.models import db + + +class NumbersDemoModel(db.Document): + """Documentation example model.""" + + decimal_field_limited = db.DecimalField( + min_value=Decimal("1"), max_value=Decimal("200.455") + ) +``` ### DictField @@ -163,11 +207,101 @@ Not yet documented. Please help us with new pull request. ### FloatField -Not yet documented. Please help us with new pull request. +```{versionchanged} 2.0.0 +Default form field class changed from: {class}`wtforms.fields.FloatField` to +{class}`~.fields.MongoFloatField`. +``` + +- API: {class}`.db_fields.FloatField` +- Default form field class: {class}`~.fields.MongoFloatField` + +#### Form generation behaviour + +For Mongo database {class}`~.db_fields.FloatField` special WTForm field was created. +This field's behaviour is the same, as for {class}`wtforms.fields.FloatField`, +but the widget is replaced to {class}`~wtforms.widgets.NumberInput`, this should make a +look of generated form better. It is possible, that in some cases usage of base, +{class}`wtforms.fields.FloatField` can be required by form design. Both fields are +completely compatible, and replace can be done with {attr}`wtf_field_class` db form +parameter. + +If database field definition has any of {attr}`min_value` or {attr}`max_value`, then +{class}`~wtforms.validators.NumberRange` validator will be added to form field. + +#### Examples + +numbers_demo.py in example app contain basic non-requirement example. You can adjust +it to any provided example for test purposes. + +##### Not limited FloatField + +```python +"""numbers_demo.py""" +from example_app.models import db + + +class NumbersDemoModel(db.Document): + """Documentation example model.""" + + float_field_unlimited = db.FloatField() +``` + +##### Limited FloatField + +```python +"""numbers_demo.py""" +from example_app.models import db + + +class NumbersDemoModel(db.Document): + """Documentation example model.""" + + float_field_limited = db.FloatField(min_value=float(1), max_value=200.455) +``` ### IntField -Not yet documented. Please help us with new pull request. +- API: {class}`.db_fields.IntField` +- Default form field class: {class}`wtforms.fields.IntegerField` + +#### Form generation behaviour + +From form generation side this field is pretty standard and do not use any form +generation adjustments. + +If database field definition has any of {attr}`min_value` or {attr}`max_value`, then +{class}`~wtforms.validators.NumberRange` validator will be added to form field. + +#### Examples + +numbers_demo.py in example app contain basic non-requirement example. You can adjust +it to any provided example for test purposes. + +##### Not limited IntField + +```python +"""numbers_demo.py""" +from example_app.models import db + + +class NumbersDemoModel(db.Document): + """Documentation example model.""" + + integer_field_unlimited = db.IntField() +``` + +##### Limited IntField + +```python +"""numbers_demo.py""" +from example_app.models import db + + +class NumbersDemoModel(db.Document): + """Documentation example model.""" + + integer_field_limited = db.IntField(min_value=1, max_value=200) +``` ### ListField diff --git a/flask_mongoengine/db_fields.py b/flask_mongoengine/db_fields.py index 43c11315..665e4447 100644 --- a/flask_mongoengine/db_fields.py +++ b/flask_mongoengine/db_fields.py @@ -630,6 +630,10 @@ class FloatField(WtfFieldMixin, fields.FloatField): For full list of arguments and keyword arguments, look parent field docs. All arguments should be passed as keyword arguments, to exclude unexpected behaviour. + + .. versionchanged:: 2.0.0 + Default form field output changed from :class:`wtforms.fields.FloatField` to + :class:`flask_mongoengine.wtf.fields.MongoFloatField` with 'numbers' input type. """ DEFAULT_WTF_FIELD = custom_fields.MongoFloatField if wtf_fields else None From a27e008440eebb640083aaff98c2f3875b064f3c Mon Sep 17 00:00:00 2001 From: Andrey Shpak Date: Fri, 12 Aug 2022 16:10:25 +0300 Subject: [PATCH 5/5] Some number fields tests --- tests/test_db_fields.py | 35 +++++++++++++++++++++++++++++++++++ tests/test_forms_v2.py | 7 +++++++ 2 files changed, 42 insertions(+) diff --git a/tests/test_db_fields.py b/tests/test_db_fields.py index c97c74c1..7c8047ae 100644 --- a/tests/test_db_fields.py +++ b/tests/test_db_fields.py @@ -1049,3 +1049,38 @@ def test__parent__init__method_included_in_init_chain(self, db, mocker): base_init_spy.assert_called_once() field_init_spy.assert_called_once() mixin_init_spy.assert_called_once() + + +@pytest.mark.skipif(condition=wtforms_not_installed, reason="No WTF CI/CD chain") +@pytest.mark.parametrize( + "NumberClass", + [ + db_fields.FloatField, + db_fields.IntField, + db_fields.DecimalField, + ], +) +class TestNumberFieldCommons: + @pytest.mark.parametrize( + ["min_", "max_", "validator_min", "validator_max"], + [ + [None, 3, None, 3], + [None, -3, None, -3], + [3, None, 3, None], + [-3, None, -3, None], + [-1, -3, -1, -3], + [3, 5, 3, 5], + ], + ) + def test__init__method__set_number_range_validator__if_range_given( + self, NumberClass, min_, max_, validator_min, validator_max + ): + field = NumberClass(min_value=min_, max_value=max_) + validator = [ + val + for val in field.wtf_field_options["validators"] + if val.__class__ is wtf_validators_.NumberRange + ][0] + assert validator is not None + assert validator.min == validator_min + assert validator.max == validator_max diff --git a/tests/test_forms_v2.py b/tests/test_forms_v2.py index 6d1dc3f1..0bde94f6 100644 --- a/tests/test_forms_v2.py +++ b/tests/test_forms_v2.py @@ -6,6 +6,7 @@ wtforms = pytest.importorskip("wtforms") from wtforms import fields as wtf_fields # noqa +from wtforms import widgets as wtf_widgets # noqa from flask_mongoengine.wtf import fields as mongo_fields # noqa @@ -284,3 +285,9 @@ def test_url_field_mro_not_changed(self): wtf_fields.URLField, wtf_fields.StringField, ] + + +class TestMongoFloatField: + def test_ensure_widget_not_accidentally_replaced(self): + field = mongo_fields.MongoFloatField + assert isinstance(field.widget, wtf_widgets.NumberInput)