Skip to content

Commit 6b584a4

Browse files
committed
Add document.WtfFormMixin with wtf related functions
1 parent 75b1259 commit 6b584a4

File tree

2 files changed

+180
-6
lines changed

2 files changed

+180
-6
lines changed

flask_mongoengine/documents.py

Lines changed: 108 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,21 @@
11
"""Extended version of :mod:`mongoengine.document`."""
2+
import logging
3+
from typing import List, Optional, Type, Union
4+
25
import mongoengine
36
from flask import abort
47
from mongoengine.errors import DoesNotExist
58
from mongoengine.queryset import QuerySet
69

10+
from flask_mongoengine.decorators import wtf_required
711
from flask_mongoengine.pagination import ListFieldPagination, Pagination
812

13+
try:
14+
from flask_mongoengine.wtf.models import ModelForm
15+
except ImportError: # pragma: no cover
16+
ModelForm = None
17+
logger = logging.getLogger("flask_mongoengine")
18+
919

1020
class BaseQuerySet(QuerySet):
1121
"""Extends :class:`~mongoengine.queryset.QuerySet` class with handly methods."""
@@ -43,7 +53,7 @@ def first_or_404(self, _message_404=None):
4353
"""
4454
return self.first() or self._abort_404(_message_404)
4555

46-
def paginate(self, page, per_page, **kwargs):
56+
def paginate(self, page, per_page):
4757
"""
4858
Paginate the QuerySet with a certain number of docs per page
4959
and return docs for a given page.
@@ -64,8 +74,89 @@ def paginate_field(self, field_name, doc_id, page, per_page, total=None):
6474
)
6575

6676

67-
class Document(mongoengine.Document):
68-
"""Abstract document with extra helpers in the queryset class"""
77+
class WtfFormMixin:
78+
"""Special mixin, for form generation functions."""
79+
80+
@classmethod
81+
def _get_fields_names(
82+
cls: Union["WtfFormMixin", mongoengine.document.BaseDocument],
83+
only: Optional[List[str]],
84+
exclude: Optional[List[str]],
85+
):
86+
"""
87+
Filter fields names for further form generation.
88+
89+
:param only:
90+
An optional iterable with the property names that should be included in
91+
the form. Only these properties will have fields.
92+
Fields are always appear in provided order, this allows user to change form
93+
fields ordering, without changing database model.
94+
:param exclude:
95+
An optional iterable with the property names that should be excluded
96+
from the form. All other properties will have fields.
97+
Fields are appears in order, defined in model, excluding provided fields
98+
names. For adjusting fields ordering, use :attr:`only`.
99+
"""
100+
field_names = cls._fields_ordered
101+
102+
if only:
103+
field_names = [field for field in only if field in field_names]
104+
elif exclude:
105+
field_names = [field for field in field_names if field not in exclude]
106+
107+
return field_names
108+
109+
@classmethod
110+
@wtf_required
111+
def to_wtf_form(
112+
cls: Union["WtfFormMixin", mongoengine.document.BaseDocument],
113+
base_class: Type[ModelForm] = ModelForm,
114+
only: Optional[List[str]] = None,
115+
exclude: Optional[List[str]] = None,
116+
field_args=None,
117+
) -> Type[ModelForm]:
118+
"""
119+
Generate WTForm from Document model.
120+
121+
:param base_class:
122+
Base form class to extend from. Must be a :class:`.ModelForm` subclass.
123+
:param only:
124+
An optional iterable with the property names that should be included in
125+
the form. Only these properties will have fields.
126+
Fields are always appear in provided order, this allows user to change form
127+
fields ordering, without changing database model.
128+
:param exclude:
129+
An optional iterable with the property names that should be excluded
130+
from the form. All other properties will have fields.
131+
Fields are appears in order, defined in model, excluding provided fields
132+
names. For adjusting fields ordering, use :attr:`only`.
133+
:param field_args:
134+
An optional dictionary of field names mapping to keyword arguments used
135+
to construct each field object.
136+
"""
137+
form_fields_dict = {}
138+
fields_names = cls._get_fields_names(only, exclude)
139+
140+
for field_name in fields_names:
141+
# noinspection PyUnresolvedReferences
142+
field_class = cls._fields[field_name]
143+
try:
144+
form_fields_dict[field_name] = field_class.to_wtf_field(
145+
field_args.get(field_name)
146+
)
147+
except AttributeError:
148+
logger.warning(
149+
f"Field {field_name} ignored, field type does not have "
150+
f".to_wtf_field() method."
151+
)
152+
153+
form_fields_dict["model_class"] = cls
154+
# noinspection PyTypeChecker
155+
return type(f"{cls.__name__}Form", (base_class,), form_fields_dict)
156+
157+
158+
class Document(WtfFormMixin, mongoengine.Document):
159+
"""Abstract Document with QuerySet and WTForms extra helpers."""
69160

70161
meta = {"abstract": True, "queryset_class": BaseQuerySet}
71162

@@ -79,7 +170,19 @@ def paginate_field(self, field_name, page, per_page, total=None):
79170
)
80171

81172

82-
class DynamicDocument(mongoengine.DynamicDocument):
83-
"""Abstract Dynamic document with extra helpers in the queryset class"""
173+
class DynamicDocument(WtfFormMixin, mongoengine.DynamicDocument):
174+
"""Abstract DynamicDocument with QuerySet and WTForms extra helpers."""
84175

85176
meta = {"abstract": True, "queryset_class": BaseQuerySet}
177+
178+
179+
class EmbeddedDocument(WtfFormMixin, mongoengine.EmbeddedDocument):
180+
"""Abstract EmbeddedDocument document with extra WTForms helpers."""
181+
182+
meta = {"abstract": True}
183+
184+
185+
class DynamicEmbeddedDocument(WtfFormMixin, mongoengine.DynamicEmbeddedDocument):
186+
"""Abstract DynamicEmbeddedDocument document with extra WTForms helpers."""
187+
188+
meta = {"abstract": True}

tests/test_db_fields.py

Lines changed: 72 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
from mongoengine import fields as base_fields
66
from pytest_mock import MockerFixture
77

8-
from flask_mongoengine import db_fields
8+
from flask_mongoengine import db_fields, documents
99

1010

1111
@pytest.fixture
@@ -15,6 +15,77 @@ def local_app(app):
1515
yield app
1616

1717

18+
class TestWtfFormMixin:
19+
@pytest.fixture(
20+
params=[
21+
documents.Document,
22+
documents.DynamicDocument,
23+
documents.DynamicEmbeddedDocument,
24+
documents.EmbeddedDocument,
25+
]
26+
)
27+
def TempDocument(self, request):
28+
"""Cool fixture"""
29+
30+
class Model(request.param):
31+
"""
32+
Temp document model for most of the tests.
33+
34+
field_one by design cannot be converted to FormField.
35+
"""
36+
37+
field_one = base_fields.StringField()
38+
field_two = db_fields.StringField()
39+
field_three = db_fields.StringField()
40+
field_four = db_fields.StringField()
41+
field_five = db_fields.StringField()
42+
43+
return Model
44+
45+
def test__get_fields_names__is_called_by_to_wtf_form_call(
46+
self, TempDocument, mocker: MockerFixture
47+
):
48+
get_fields_names_spy = mocker.patch.object(
49+
documents.WtfFormMixin, "_get_fields_names", autospec=True
50+
)
51+
TempDocument.to_wtf_form()
52+
get_fields_names_spy.assert_called_once()
53+
54+
def test__get_fields_names__hold_correct_fields_ordering_for_only(
55+
self, TempDocument
56+
):
57+
58+
field_names = TempDocument._get_fields_names(
59+
only=["field_five", "field_one"], exclude=None
60+
)
61+
assert field_names == ["field_five", "field_one"]
62+
63+
def test__get_fields_names__hold_correct_fields_ordering_for_exclude(
64+
self, TempDocument
65+
):
66+
field_names = TempDocument._get_fields_names(
67+
only=None, exclude=["id", "field_five", "field_one"]
68+
)
69+
assert field_names == ["field_two", "field_three", "field_four"]
70+
71+
def test__to_wtf_form__is_called_by_mixin_child_model(
72+
self, TempDocument, caplog, mocker: MockerFixture
73+
):
74+
to_wtf_spy = mocker.patch.object(
75+
documents.WtfFormMixin, "to_wtf_form", autospec=True
76+
)
77+
TempDocument.to_wtf_form()
78+
to_wtf_spy.assert_called_once()
79+
80+
def test__to_wtf_form__logs_error(self, caplog, TempDocument):
81+
TempDocument.to_wtf_form()
82+
83+
# Check error logging
84+
assert (
85+
"Field field_one ignored, field type does not have .to_wtf_field() method."
86+
) in caplog.messages
87+
88+
1889
class TestWtfFieldMixin:
1990
def test__init__set_additional_instance_arguments(self, db, mocker: MockerFixture):
2091
checker_spy = mocker.spy(db_fields.WtfFieldMixin, "_ensure_callable_or_list")

0 commit comments

Comments
 (0)