Skip to content
3 changes: 3 additions & 0 deletions example_app/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -55,6 +56,8 @@
app.add_url_rule("/bool/<pk>/", 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/<pk>/", 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/<pk>/", view_func=binary_demo_view, methods=["GET", "POST"])

if __name__ == "__main__":
app.run(host="0.0.0.0", port=8000)
20 changes: 20 additions & 0 deletions example_app/binary_demo.py
Original file line number Diff line number Diff line change
@@ -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)
3 changes: 0 additions & 3 deletions example_app/boolean_demo.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 0 additions & 3 deletions example_app/dates_demo.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 0 additions & 3 deletions example_app/dict_demo.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 0 additions & 3 deletions example_app/numbers_demo.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 0 additions & 3 deletions example_app/strings_demo.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion example_app/templates/form_demo.html
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@
{{ render_navigation(page, view) }}
</div>
<div>
<form method="POST">
<form method="POST" enctype="multipart/form-data">
{% for field in form %}
{{ render_field(field, style='font-weight: bold') }}
{% endfor %}
Expand Down
1 change: 1 addition & 0 deletions example_app/templates/layout.html
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
<li><a href="{{ url_for("dates_demo_view") }}">DateTime demo</a></li>
<li><a href="{{ url_for("boolean_demo_view") }}">Booleans demo</a></li>
<li><a href="{{ url_for("dict_demo_view") }}">Dict/Json demo</a></li>
<li><a href="{{ url_for("binary_demo_view") }}">Binary/Files/Images demo</a></li>
</ul>
</nav>
<div>
Expand Down
18 changes: 4 additions & 14 deletions example_app/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand Down Expand Up @@ -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():
Expand Down
49 changes: 10 additions & 39 deletions flask_mongoengine/db_fields.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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):
Expand Down
96 changes: 85 additions & 11 deletions flask_mongoengine/wtf/fields.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]:
Expand All @@ -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):
"""
Expand Down Expand Up @@ -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."""

Expand All @@ -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
Expand All @@ -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`.
Expand All @@ -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`.
Expand Down Expand Up @@ -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`."""

Expand Down
40 changes: 40 additions & 0 deletions flask_mongoengine/wtf/widgets.py
Original file line number Diff line number Diff line change
@@ -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 = """
<div>
<i class="icon-file"></i>%(name)s %(size)dk (%(content_type)s)
<input type="checkbox" name="%(marker)s">Delete</input>
</div>
"""

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