diff --git a/example_app/app.py b/example_app/app.py index 2b424f2f..6f51b903 100644 --- a/example_app/app.py +++ b/example_app/app.py @@ -3,6 +3,7 @@ from pymongo import monitoring from example_app import views +from example_app.binary_demo import binary_demo_view from example_app.boolean_demo import boolean_demo_view from example_app.dates_demo import dates_demo_view from example_app.dict_demo import dict_demo_view @@ -55,6 +56,8 @@ app.add_url_rule("/bool//", view_func=boolean_demo_view, methods=["GET", "POST"]) app.add_url_rule("/dict", view_func=dict_demo_view, methods=["GET", "POST"]) app.add_url_rule("/dict//", view_func=dict_demo_view, methods=["GET", "POST"]) +app.add_url_rule("/binary", view_func=binary_demo_view, methods=["GET", "POST"]) +app.add_url_rule("/binary//", view_func=binary_demo_view, methods=["GET", "POST"]) if __name__ == "__main__": app.run(host="0.0.0.0", port=8000) diff --git a/example_app/binary_demo.py b/example_app/binary_demo.py new file mode 100644 index 00000000..d61857bb --- /dev/null +++ b/example_app/binary_demo.py @@ -0,0 +1,20 @@ +"""Strings and strings related fields demo model.""" + +from example_app.models import db + + +class BinaryDemoModel(db.Document): + """Documentation example model.""" + + string_field = db.StringField() + binary_field = db.BinaryField() + binary_field_with_default = db.BinaryField(default=lambda: "foobar".encode("utf-8")) + file_field = db.FileField() + image_field = db.ImageField() + + +def binary_demo_view(pk=None): + """Return all fields demonstration.""" + from example_app.views import demo_view + + return demo_view(model=BinaryDemoModel, view_name=binary_demo_view.__name__, pk=pk) diff --git a/example_app/boolean_demo.py b/example_app/boolean_demo.py index 2fbe082b..4a4368f1 100644 --- a/example_app/boolean_demo.py +++ b/example_app/boolean_demo.py @@ -17,9 +17,6 @@ class BooleanDemoModel(db.Document): ) -BooleanDemoForm = BooleanDemoModel.to_wtf_form() - - def boolean_demo_view(pk=None): """Return all fields demonstration.""" from example_app.views import demo_view diff --git a/example_app/dates_demo.py b/example_app/dates_demo.py index af0f1c5c..066b1ab3 100644 --- a/example_app/dates_demo.py +++ b/example_app/dates_demo.py @@ -22,9 +22,6 @@ class DateTimeModel(db.Document): ) -DateTimeDemoForm = DateTimeModel.to_wtf_form() - - def dates_demo_view(pk=None): """Return all fields demonstration.""" from example_app.views import demo_view diff --git a/example_app/dict_demo.py b/example_app/dict_demo.py index a7621f1e..0dfb96d9 100644 --- a/example_app/dict_demo.py +++ b/example_app/dict_demo.py @@ -24,9 +24,6 @@ class DictDemoModel(db.Document): ) -DictDemoForm = DictDemoModel.to_wtf_form() - - def dict_demo_view(pk=None): """Return all fields demonstration.""" from example_app.views import demo_view diff --git a/example_app/numbers_demo.py b/example_app/numbers_demo.py index e363166f..9bbf6403 100644 --- a/example_app/numbers_demo.py +++ b/example_app/numbers_demo.py @@ -19,9 +19,6 @@ class NumbersDemoModel(db.Document): integer_field_limited = db.IntField(min_value=1, max_value=200) -NumbersDemoForm = NumbersDemoModel.to_wtf_form() - - def numbers_demo_view(pk=None): """Return all fields demonstration.""" from example_app.views import demo_view diff --git a/example_app/strings_demo.py b/example_app/strings_demo.py index 0738a6c8..7138f324 100644 --- a/example_app/strings_demo.py +++ b/example_app/strings_demo.py @@ -25,9 +25,6 @@ class StringsDemoModel(db.Document): url_field = db.URLField() -StringsDemoForm = StringsDemoModel.to_wtf_form() - - def strings_demo_view(pk=None): """Return all fields demonstration.""" from example_app.views import demo_view diff --git a/example_app/templates/form_demo.html b/example_app/templates/form_demo.html index 44f2621a..8d13c6ed 100644 --- a/example_app/templates/form_demo.html +++ b/example_app/templates/form_demo.html @@ -30,7 +30,7 @@ {{ render_navigation(page, view) }}
-
+ {% for field in form %} {{ render_field(field, style='font-weight: bold') }} {% endfor %} diff --git a/example_app/templates/layout.html b/example_app/templates/layout.html index 758ecf90..44533edc 100644 --- a/example_app/templates/layout.html +++ b/example_app/templates/layout.html @@ -23,6 +23,7 @@
  • DateTime demo
  • Booleans demo
  • Dict/Json demo
  • +
  • Binary/Files/Images demo
  • diff --git a/example_app/views.py b/example_app/views.py index a4c871e8..6e273cd8 100644 --- a/example_app/views.py +++ b/example_app/views.py @@ -4,11 +4,6 @@ from mongoengine.context_managers import switch_db from example_app import models -from example_app.boolean_demo import BooleanDemoModel -from example_app.dates_demo import DateTimeModel -from example_app.dict_demo import DictDemoModel -from example_app.numbers_demo import NumbersDemoModel -from example_app.strings_demo import StringsDemoModel def generate_data(): @@ -50,15 +45,10 @@ def generate_data(): def delete_data(): """Clear database.""" - with switch_db(models.Todo, "default"): - models.Todo.objects().delete() - BooleanDemoModel.objects().delete() - DateTimeModel.objects().delete() - DictDemoModel.objects().delete() - StringsDemoModel.objects().delete() - NumbersDemoModel.objects().delete() - with switch_db(models.Todo, "secondary"): - models.Todo.objects().delete() + from example_app.app import db + + db.connection["default"].drop_database("example_app") + db.connection["secondary"].drop_database("example_app_2") def index(): diff --git a/flask_mongoengine/db_fields.py b/flask_mongoengine/db_fields.py index fa7f9fb6..05504bd5 100644 --- a/flask_mongoengine/db_fields.py +++ b/flask_mongoengine/db_fields.py @@ -305,20 +305,7 @@ class BinaryField(WtfFieldMixin, fields.BinaryField): All arguments should be passed as keyword arguments, to exclude unexpected behaviour. """ - DEFAULT_WTF_FIELD = custom_fields.BinaryField if custom_fields else None - - def to_wtf_field( - self, - *, - model: Optional[Type] = None, - field_kwargs: Optional[dict] = None, - ): - """ - Protection from execution of :func:`to_wtf_field` in form generation. - - :raises NotImplementedError: Field converter to WTForm Field not implemented. - """ - raise NotImplementedError("Field converter to WTForm Field not implemented.") + DEFAULT_WTF_FIELD = custom_fields.MongoBinaryField if custom_fields else None class BooleanField(WtfFieldMixin, fields.BooleanField): @@ -590,20 +577,7 @@ class FileField(WtfFieldMixin, fields.FileField): All arguments should be passed as keyword arguments, to exclude unexpected behaviour. """ - DEFAULT_WTF_FIELD = wtf_fields.FileField if wtf_fields else None - - def to_wtf_field( - self, - *, - model: Optional[Type] = None, - field_kwargs: Optional[dict] = None, - ): - """ - Protection from execution of :func:`to_wtf_field` in form generation. - - :raises NotImplementedError: Field converter to WTForm Field not implemented. - """ - raise NotImplementedError("Field converter to WTForm Field not implemented.") + DEFAULT_WTF_FIELD = custom_fields.MongoFileField if custom_fields else None class FloatField(WtfFieldMixin, fields.FloatField): @@ -751,18 +725,15 @@ class ImageField(WtfFieldMixin, fields.ImageField): All arguments should be passed as keyword arguments, to exclude unexpected behaviour. """ - def to_wtf_field( - self, - *, - model: Optional[Type] = None, - field_kwargs: Optional[dict] = None, - ): - """ - Protection from execution of :func:`to_wtf_field` in form generation. + DEFAULT_WTF_FIELD = custom_fields.MongoImageField if custom_fields else None - :raises NotImplementedError: Field converter to WTForm Field not implemented. - """ - raise NotImplementedError("Field converter to WTForm Field not implemented.") + @property + @wtf_required + def wtf_generated_options(self) -> dict: + """Inserts accepted type in widget rendering (does not do validation).""" + options = super().wtf_generated_options + options["render_kw"] = {"accept": "image/*"} + return options class IntField(WtfFieldMixin, fields.IntField): diff --git a/flask_mongoengine/wtf/fields.py b/flask_mongoengine/wtf/fields.py index 09b7be58..7226e8c9 100644 --- a/flask_mongoengine/wtf/fields.py +++ b/flask_mongoengine/wtf/fields.py @@ -9,9 +9,13 @@ from flask import json from mongoengine.queryset import DoesNotExist +from werkzeug.datastructures import FileStorage from wtforms import fields as wtf_fields from wtforms import validators as wtf_validators from wtforms import widgets as wtf_widgets +from wtforms.utils import unset_value + +from flask_mongoengine.wtf import widgets as mongo_widgets def coerce_boolean(value: Optional[str]) -> Optional[bool]: @@ -31,6 +35,14 @@ def coerce_boolean(value: Optional[str]) -> Optional[bool]: raise ValueError("Unexpected string value.") +def _is_empty_file(file_object): + """Detects empty files and file streams.""" + file_object.seek(0) + first_char = file_object.read(1) + file_object.seek(0) + return not bool(first_char) + + # noinspection PyAttributeOutsideInit,PyAbstractClass class QuerySetSelectField(wtf_fields.SelectFieldBase): """ @@ -309,6 +321,26 @@ def process_formdata(self, valuelist): super().process_formdata(valuelist) +# noinspection PyAttributeOutsideInit +class MongoBinaryField(wtf_fields.TextAreaField): + """ + Special WTForm :class:`~.wtforms.fields.TextAreaField` that convert input to binary. + """ + + def process_formdata(self, valuelist): + """Converts string form value to binary type and ignoring empty form fields.""" + if not valuelist or valuelist[0] == "": + self.data = None + else: + self.data = valuelist[0].encode("utf-8") + + def _value(self): + """ + Ensures that encoded string data will not be encoded once more on form edit. + """ + return self.data.decode("utf-8") if self.data is not None else "" + + class MongoBooleanField(wtf_fields.SelectField): """Mongo SelectField field for BooleanFields, that correctly coerce values.""" @@ -325,8 +357,6 @@ def __init__( Replaces defaults of :class:`wtforms.fields.SelectField` with for Boolean values. Fully compatible with :class:`wtforms.fields.SelectField` and have same parameters. - - """ if coerce is None: coerce = coerce_boolean @@ -351,6 +381,53 @@ class MongoEmailField(EmptyStringIsNoneMixin, wtf_fields.EmailField): pass +class MongoFileField(wtf_fields.FileField): + """GridFS file field.""" + + widget = mongo_widgets.MongoFileInput() + + def __init__(self, **kwargs): + """Extends base field arguments with file delete marker.""" + super().__init__(**kwargs) + + self._should_delete = False + self._marker = f"_{self.name}_delete" + + def process(self, formdata, data=unset_value, extra_filters=None): + """Extracts 'delete' marker option, if exists in request.""" + if formdata and self._marker in formdata: + self._should_delete = True + return super().process(formdata, data=data, extra_filters=extra_filters) + + def populate_obj(self, obj, name): + """Upload, replace or delete file from database, according form action.""" + field = getattr(obj, name, None) + + if field is None: + return None + + if self._should_delete: + field.delete() + return None + + if isinstance(self.data, FileStorage) and not _is_empty_file(self.data.stream): + action = field.replace if field.grid_id else field.put + action( + self.data.stream, + filename=self.data.filename, + content_type=self.data.content_type, + ) + + +class MongoFloatField(wtf_fields.FloatField): + """ + Regular :class:`wtforms.fields.FloatField`, with widget replaced to + :class:`wtforms.widgets.NumberInput`. + """ + + widget = wtf_widgets.NumberInput(step="any") + + class MongoHiddenField(EmptyStringIsNoneMixin, wtf_fields.HiddenField): """ Regular :class:`wtforms.fields.HiddenField`, that transform empty string to `None`. @@ -359,6 +436,12 @@ class MongoHiddenField(EmptyStringIsNoneMixin, wtf_fields.HiddenField): pass +class MongoImageField(MongoFileField): + """GridFS image field.""" + + widget = mongo_widgets.MongoImageInput() + + class MongoPasswordField(EmptyStringIsNoneMixin, wtf_fields.PasswordField): """ Regular :class:`wtforms.fields.PasswordField`, that transform empty string to `None`. @@ -407,15 +490,6 @@ 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") - - class MongoDictField(MongoTextAreaField): """Form field to handle JSON in :class:`~flask_mongoengine.db_fields.DictField`.""" diff --git a/flask_mongoengine/wtf/widgets.py b/flask_mongoengine/wtf/widgets.py new file mode 100644 index 00000000..a41ce7ac --- /dev/null +++ b/flask_mongoengine/wtf/widgets.py @@ -0,0 +1,40 @@ +"""Custom widgets for Mongo fields.""" +from markupsafe import Markup, escape +from mongoengine.fields import GridFSProxy, ImageGridFsProxy +from wtforms.widgets.core import FileInput + + +class MongoFileInput(FileInput): + """Renders a file input field with delete option.""" + + template = """ +
    + %(name)s %(size)dk (%(content_type)s) + Delete +
    + """ + + def _is_supported_file(self, field) -> bool: + """Checks type of file input.""" + return field.data and isinstance(field.data, GridFSProxy) + + def __call__(self, field, **kwargs) -> Markup: + placeholder = "" + + if self._is_supported_file(field): + placeholder = self.template % { + "name": escape(field.data.name), + "content_type": escape(field.data.content_type), + "size": field.data.length // 1024, + "marker": f"_{field.name}_delete", + } + + return Markup(placeholder) + super().__call__(field, **kwargs) + + +class MongoImageInput(MongoFileInput): + """Renders an image input field with delete option.""" + + def _is_supported_file(self, field) -> bool: + """Checks type of file input.""" + return field.data and isinstance(field.data, ImageGridFsProxy) diff --git a/tests/test_db_fields.py b/tests/test_db_fields.py index 52dac9c7..0cb8ded5 100644 --- a/tests/test_db_fields.py +++ b/tests/test_db_fields.py @@ -148,19 +148,16 @@ def test__ensure_callable_or_list__raise_error_if_argument_not_callable_and_not_ @pytest.mark.parametrize( "FieldClass", [ - db_fields.BinaryField, db_fields.CachedReferenceField, db_fields.DynamicField, db_fields.EmbeddedDocumentField, db_fields.EmbeddedDocumentListField, db_fields.EnumField, - db_fields.FileField, db_fields.GenericEmbeddedDocumentField, db_fields.GenericLazyReferenceField, db_fields.GenericReferenceField, db_fields.GeoJsonBaseField, db_fields.GeoPointField, - db_fields.ImageField, db_fields.LazyReferenceField, db_fields.LineStringField, db_fields.ListField,