Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

New model form generator: Support of BinaryField, FileField, ImageField #511

Open
wants to merge 11 commits into
base: master
Choose a base branch
from
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