Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions flask_admin/_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,10 @@ class T_FIELD_ARGS_VALIDATORS_ALLOW_BLANK(T_FIELD_ARGS_VALIDATORS):
allow_blank: NotRequired[bool]


class T_FIELD_ARGS_VALIDATORS_SELECTABLE(T_FIELD_ARGS_VALIDATORS_ALLOW_BLANK):
coerce: NotRequired[t.Callable[[t.Any], t.Any]]
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we can simply add coerce to T_FIELD_ARGS_VALIDATORS and remove T_FIELD_ARGS_VALIDATORS_SELECTABLE

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we can simply add coerce to T_FIELD_ARGS_VALIDATORS and remove T_FIELD_ARGS_VALIDATORS_SELECTABLE

I've tried your suggestion but unfortunately it all fields that don't include coerce attr. will be affected and raise typing issues. I believe T_FIELD_ARGS_VALIDATORS_SELECTABLE is more clear and dedicated to SELECT fields



class T_FIELD_ARGS_VALIDATORS_FILES(T_FIELD_ARGS_VALIDATORS):
base_path: NotRequired[str]
allow_overwrite: NotRequired[bool]
Expand Down
6 changes: 5 additions & 1 deletion flask_admin/contrib/peewee/view.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
from flask_admin.model.form import InlineFormAdmin

from ..._types import T_FIELD_ARGS_VALIDATORS_FILES
from ..._types import T_FIELD_ARGS_VALIDATORS_SELECTABLE
from ..._types import T_FILTER
from ..._types import T_PEEWEE_MODEL
from ..._types import T_WIDGET
Expand Down Expand Up @@ -338,7 +339,10 @@ def scaffold_form(self) -> type[Form]:
def scaffold_list_form(
self,
widget: type[T_WIDGET] | None = None,
validators: dict[str, T_FIELD_ARGS_VALIDATORS_FILES] | None = None,
validators: dict[
str, T_FIELD_ARGS_VALIDATORS_FILES | T_FIELD_ARGS_VALIDATORS_SELECTABLE
]
| None = None,
) -> type[Form]:
"""
Create form for the `index_view` using only the columns from
Expand Down
26 changes: 24 additions & 2 deletions flask_admin/contrib/sqla/form.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
from sqlalchemy import Boolean
from sqlalchemy import Column
from sqlalchemy.orm import ColumnProperty
from sqlalchemy.sql.type_api import TypeEngine
from sqlalchemy_utils import ChoiceType
from wtforms import Field
from wtforms import fields
from wtforms import Form
Expand Down Expand Up @@ -36,6 +38,7 @@
from ..._types import T_FIELD_ARGS_VALIDATORS
from ..._types import T_FIELD_ARGS_VALIDATORS_ALLOW_BLANK
from ..._types import T_FIELD_ARGS_VALIDATORS_FILES
from ..._types import T_FIELD_ARGS_VALIDATORS_SELECTABLE
from ..._types import T_INSTRUMENTED_ATTRIBUTE
from ..._types import T_MODEL_VIEW
from ..._types import T_ORM_MODEL
Expand Down Expand Up @@ -228,7 +231,7 @@ def convert(
if isinstance(prop, FieldPlaceholder):
return form.recreate_field(prop.field)

kwargs: T_FIELD_ARGS_VALIDATORS_ALLOW_BLANK = {"validators": [], "filters": []}
kwargs: T_FIELD_ARGS_VALIDATORS_SELECTABLE = {"validators": [], "filters": []}

if field_args:
kwargs.update(field_args) # type: ignore[typeddict-item]
Expand Down Expand Up @@ -356,7 +359,11 @@ def convert(
form_choices = getattr(self.view, "form_choices", None)
if mapper.class_ == self.view.model and form_choices:
choices = form_choices.get(prop.key)

if choices:
if "coerce" not in kwargs:
kwargs["coerce"] = coerce_factory(column.type)

return form.Select2Field( # type: ignore[misc]
choices=choices,
allow_blank=column.nullable, # type: ignore[arg-type]
Expand Down Expand Up @@ -648,6 +655,18 @@ def avoid_empty_strings(value: t.Any) -> t.Any:
return value if value else None


def coerce_factory(type_: TypeEngine[t.Any]) -> t.Callable[[t.Any], t.Any]:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this PR only make it work for sqlalchemy? Or other libraries do not have this issue?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it is tested only in Sqlalchemy, however, i've tried to add a Peewee test but it seems like there is no way to use coerce function in Peewee or Pymongo

"""
Return a function to coerce a column, for use by Select2Field.
:param type_: Column type
"""

if isinstance(type_, ChoiceType):
return choice_type_coerce_factory(type_)
else:
return type_.python_type


def choice_type_coerce_factory(type_: T_CHOICE_TYPE) -> t.Callable[[t.Any], t.Any]:
"""
Return a function to coerce a ChoiceType column, for use by Select2Field.
Expand Down Expand Up @@ -692,7 +711,10 @@ def get_form(
base_class: type[form.BaseForm] = form.BaseForm,
only: t.Collection[str | T_INSTRUMENTED_ATTRIBUTE] | None = None,
exclude: t.Collection[str | T_INSTRUMENTED_ATTRIBUTE] | None = None,
field_args: dict[str, T_FIELD_ARGS_VALIDATORS_FILES] | None = None,
field_args: dict[
str, T_FIELD_ARGS_VALIDATORS_FILES | T_FIELD_ARGS_VALIDATORS_SELECTABLE
]
| None = None,
hidden_pk: bool = False,
ignore_hidden: bool = True,
extra_fields: dict[str | T_INSTRUMENTED_ATTRIBUTE, Field] | None = None,
Expand Down
6 changes: 5 additions & 1 deletion flask_admin/contrib/sqla/view.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
from ..._types import T_COLUMN
from ..._types import T_COLUMN_LIST
from ..._types import T_FIELD_ARGS_VALIDATORS_FILES
from ..._types import T_FIELD_ARGS_VALIDATORS_SELECTABLE
from ..._types import T_FILTER
from ..._types import T_INSTRUMENTED_ATTRIBUTE
from ..._types import T_SQLALCHEMY_COLUMN
Expand Down Expand Up @@ -892,7 +893,10 @@ def scaffold_form(self) -> type[Form]:
def scaffold_list_form(
self,
widget: type[T_WIDGET] | None = None,
validators: dict[str, T_FIELD_ARGS_VALIDATORS_FILES] | None = None,
validators: dict[
str, T_FIELD_ARGS_VALIDATORS_FILES | T_FIELD_ARGS_VALIDATORS_SELECTABLE
]
| None = None,
) -> type[Form]:
"""
Create form for the `index_view` using only the columns from
Expand Down
11 changes: 9 additions & 2 deletions flask_admin/model/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
from .._types import T_COLUMN_LIST
from .._types import T_COLUMN_TYPE_FORMATTERS
from .._types import T_FIELD_ARGS_VALIDATORS_FILES
from .._types import T_FIELD_ARGS_VALIDATORS_SELECTABLE
from .._types import T_FILTER
from .._types import T_INSTRUMENTED_ATTRIBUTE
from .._types import T_ORM_MODEL
Expand Down Expand Up @@ -656,7 +657,10 @@ class MyModelView(BaseModelView):

"""

form_args: dict[str, T_FIELD_ARGS_VALIDATORS_FILES] | None = None
form_args: (
dict[str, T_FIELD_ARGS_VALIDATORS_FILES | T_FIELD_ARGS_VALIDATORS_SELECTABLE]
| None
) = None
"""
Dictionary of form field arguments. Refer to WTForms documentation for
list of possible options.
Expand Down Expand Up @@ -1395,7 +1399,10 @@ def scaffold_form(self) -> type[Form]:
def scaffold_list_form(
self,
widget: type[T_WIDGET] | None = None,
validators: dict[str, T_FIELD_ARGS_VALIDATORS_FILES] | None = None,
validators: dict[
str, T_FIELD_ARGS_VALIDATORS_FILES | T_FIELD_ARGS_VALIDATORS_SELECTABLE
]
| None = None,
) -> type[Form]:
"""
Create form for the `index_view` using only the columns from
Expand Down
64 changes: 64 additions & 0 deletions flask_admin/tests/sqla/test_form.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,11 @@
import pytest
import wtforms
from wtforms.fields.simple import StringField
from wtforms.validators import NumberRange

from flask_admin.contrib.sqla import ModelView
from flask_admin.contrib.sqla.form import AdminModelConverter
from flask_admin.tests.sqla.test_basic import create_models

sqla_admin_model_converters = [
method_name
Expand Down Expand Up @@ -52,3 +55,64 @@ class TestForm(wtforms.Form):
pass

assert field() == "<p>widget overridden</p>"


@pytest.mark.parametrize(
"session_or_db, field_name",
[
pytest.param("session", "int_field", id="session"),
pytest.param("session", "float_field", id="session"),
pytest.param("db", "int_field", id="db"),
pytest.param("db", "float_field", id="db"),
],
)
def test_coerce(app, db, admin, session_or_db, field_name):
with app.app_context():
Model1, Model2 = create_models(db)
db.session.add_all(
[
Model2("1", int_field=1),
Model2("2", int_field=2),
]
)
db.session.commit()

class MyModelView(ModelView):
form_columns = ["int_field"]
form_choices = {"int_field": [("101", "101"), ("150", "150")]}
form_args = {
"int_field": {"validators": [NumberRange(min=100, max=199)]},
}

class MyModelView2(MyModelView):
form_args = {
"int_field": {
"validators": [NumberRange(min=100, max=199)],
"coerce": int,
},
}

param = db if session_or_db == "session" else db.session
# test column_list with a list of strings
view1 = MyModelView(Model2, param, name="My Model1")
view2 = MyModelView2(Model2, param, name="My Model1", endpoint="mymodelview2")
admin.add_view(view1)
admin.add_view(view2)

client = app.test_client()
rv = client.get("/admin/model2/new/")
data = rv.data.decode("utf-8")
assert 'value="101"' in data
assert ">101</option>" in data

rv = client.post(
"/admin/model2/new/", data={"int_field": "150"}, follow_redirects=True
)
data = rv.data.decode("utf-8")
assert "Record was successfully created" in data

rv = client.post(
"/admin/mymodelview2/new/", data={"int_field": "150"}, follow_redirects=True
)
data = rv.data.decode("utf-8")
assert "Record was successfully created" in data
Loading