Skip to content
Closed
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
24 changes: 24 additions & 0 deletions doc/changelog.rst
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,33 @@ Changelog

[unreleased]
------------------
Breaking changes:
[unreleased]
------------------
Breaking changes:

* XEditableWidget has been replaced by HTMXEditableWidget, which removes the dependency on the unmaintained
x-editable library. XEditableWidget remains as a deprecated alias, but HTMXEditableWidget should be used for any new
development and existing code should be migrated accordingly.
Subsequently, the ``get_kwargs`` method on ``XEditableWidget`` no
longer has any effect. It existed solely to produce x-editable data
attributes which are not used in the new implementation. To customise
inline edit rendering, override ``HTMXEditableWidget.__call__`` instead::

from flask_admin.model.widgets import HTMXEditableWidget

class CustomWidget(HTMXEditableWidget):
def __call__(self, field, **kwargs):
# custom rendering logic
return super().__call__(field, **kwargs)

* ``ajax/update/`` response format: this endpoint now returns an HTML fragment instead
of a plain text string. Any custom JavaScript calling this endpoint
directly will need to be updated to handle the new response format.

Bugfixes:
* Fix encoding for editing file in FileAdmin. Now it uses UTF-8 and accepts non-ASCII characters.
* SQLAlchemy backend: ``conv_ARRAY`` now infers the array element's ``python_type`` and passes it through as the ``Select2TagsField`` ``coerce`` callable. Saving a Postgres ``ARRAY(Integer)`` / ``ARRAY(Float)`` column no longer fails with ``column "x" is of type integer[] but expression is of type text[]`` (closes #1724).

Type hints:
* Type hints added to all functions and methods (some using `typing.Any` where full typing not yet available)
Expand Down
1 change: 1 addition & 0 deletions examples/sqla_column_editable/.python-version
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
3.10
33 changes: 33 additions & 0 deletions examples/sqla_column_editable/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# SQLAlchemy column_editable_list Example

This example demonstrates inline editing in Flask-Admin list views using `column_editable_list`. It covers all supported field types:

- **StringField** - text input
- **TextAreaField** - text area
- **IntegerField** - number input
- **FloatField** - number input (decimal)
- **BooleanField** - Yes/No select
- **DateField** - date picker
- **TimeField** - time picker
- **DateTimeField** - datetime picker
- **Enum (SelectField)** - dropdown
- **ForeignKey (QuerySelectField)** - relation dropdown

## How to run this example

Clone the repository and navigate to this example:

```shell
git clone https://github.com/pallets-eco/flask-admin.git
cd flask-admin/examples/sqla_column_editable
```

> This example uses [`uv`](https://docs.astral.sh/uv/) to manage its dependencies and developer environment.

Run the example using `uv`, which will manage the environment and dependencies automatically:

```shell
uv run main.py
```

Then visit http://127.0.0.1:5000/admin/dish/ and click any value in the editable columns to edit inline.
Empty file.
166 changes: 166 additions & 0 deletions examples/sqla_column_editable/main.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
import enum
from datetime import date
from datetime import datetime
from datetime import time

from flask import Flask
from flask_admin import Admin
from flask_admin.contrib.sqla import ModelView
from flask_admin.theme import Bootstrap4Theme
from flask_sqlalchemy import SQLAlchemy

db = SQLAlchemy()


class Cuisine(db.Model):
__tablename__ = "cuisines"
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(50), nullable=False, unique=True)

def __str__(self) -> str:
return self.name


class SpiceLevel(enum.Enum):
mild = "Mild"
medium = "Medium"
hot = "Hot"
extra_hot = "Extra Hot"


class Dish(db.Model):
__tablename__ = "dishes"
id = db.Column(db.Integer, primary_key=True)
# StringField
name = db.Column(db.String(100), nullable=False)
# TextAreaField
description = db.Column(db.Text, nullable=True)
# IntegerField
calories = db.Column(db.Integer, nullable=True)
# FloatField
price = db.Column(db.Float, nullable=False)
# BooleanField
vegetarian = db.Column(db.Boolean, default=False)
available = db.Column(db.Boolean, default=True)
# DateField
added_on = db.Column(db.Date, nullable=True)
# TimeField
available_from = db.Column(db.Time, nullable=True)
# DateTimeField
last_ordered = db.Column(db.DateTime, nullable=True)
# Enum → SelectField
spice_level = db.Column(db.Enum(SpiceLevel), nullable=True)
# ForeignKey → QuerySelectField
cuisine_id = db.Column(db.Integer, db.ForeignKey("cuisines.id"), nullable=True)
cuisine = db.relationship("Cuisine", backref="dishes")

def __str__(self) -> str:
return self.name


class DishView(ModelView):
column_list = [
"id",
"name",
"description",
"price",
"calories",
"vegetarian",
"available",
"spice_level",
"cuisine",
"added_on",
"available_from",
"last_ordered",
]
column_editable_list = [
"name", # StringField
"description", # TextAreaField
"price", # FloatField
"calories", # IntegerField
"vegetarian", # BooleanField
"available", # BooleanField
"spice_level", # SelectField (enum)
"cuisine", # QuerySelectField (relation)
"added_on", # DateField
"available_from", # TimeField
"last_ordered", # DateTimeField
]
column_labels = {
"added_on": "Added On",
"available_from": "Available From",
"last_ordered": "Last Ordered",
"spice_level": "Spice Level",
}


class CuisineView(ModelView):
column_editable_list = ["name"]


app = Flask(__name__)
app.config["SECRET_KEY"] = "dev-secret-key"
app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///db.sqlite"

db.init_app(app)

admin = Admin(
app, name="Click any editable column value to edit inline", theme=Bootstrap4Theme()
)
admin.add_view(DishView(Dish, db.session, name="Dishes"))
admin.add_view(CuisineView(Cuisine, db.session, name="Cuisines"))


@app.route("/")
def index():
return '<a href="/admin/">Click me to get to Admin!</a>'


with app.app_context():
db.create_all()
if not db.session.query(Dish).first():
cuisines = [
Cuisine(name="Italian"),
Cuisine(name="Japanese"),
Cuisine(name="Mexican"),
Cuisine(name="Indian"),
Cuisine(name="Thai"),
]
db.session.add_all(cuisines)
db.session.flush()

dishes = [
Dish(
name="Margherita Pizza",
description="Classic pizza with tomato, mozzarella, and basil",
price=12.99,
calories=850,
vegetarian=True,
available=True,
spice_level=SpiceLevel.mild,
cuisine=cuisines[0],
added_on=date(2025, 1, 15),
available_from=time(11, 0),
last_ordered=datetime(2026, 3, 28, 19, 30),
),
Dish(
name="Tonkotsu Ramen",
description="Rich pork bone broth with chashu and soft-boiled egg",
price=15.50,
calories=720,
vegetarian=False,
available=True,
spice_level=SpiceLevel.medium,
cuisine=cuisines[1],
added_on=date(2025, 3, 20),
available_from=time(11, 30),
last_ordered=datetime(2026, 3, 29, 12, 15),
),
]
db.session.add_all(dishes)
db.session.commit()
print("Seeded 5 cuisines and 6 dishes.")


if __name__ == "__main__":
app.run(debug=True)
12 changes: 12 additions & 0 deletions examples/sqla_column_editable/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
[project]
name = "example-sqla-column-editable"
version = "0.1.0"
description = "SQLAlchemy column_editable_list example for Flask-Admin."
readme = "README.md"
requires-python = ">=3.10"
dependencies = [
"flask-admin[sqlalchemy-with-utils]",
]

[tool.uv.sources]
flask-admin = { path = "../../", editable = true }
12 changes: 9 additions & 3 deletions flask_admin/_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,9 @@
InlineFieldListWidget as T_INLINE_FIELD_LIST_WIDGET,
)
from flask_admin.model.widgets import InlineFormWidget as T_INLINE_FORM_WIDGET
from flask_admin.model.widgets import XEditableWidget as T_INLINE_X_EDITABLE_WIDGET
from flask_admin.model.widgets import (
HTMXEditableWidget as T_INLINE_HTMX_EDITABLE_WIDGET,
)

T_SQLALCHEMY_MODEL: t.TypeAlias = t.Union[
T_SQLALCHEMY_LEGACY_MODEL, T_DECLARATIVE_BASE
Expand Down Expand Up @@ -110,7 +112,7 @@
T_INLINE_FIELD_LIST_WIDGET = "flask_admin.model.widgets.InlineFieldListWidget"
T_INLINE_FORM_WIDGET = "flask_admin.model.widgets.InlineFormWidget"
T_INLINE_AJAX_SELECT2_WIDGET = "flask_admin.model.widgets.AjaxSelect2Widget"
T_INLINE_X_EDITABLE_WIDGET = "flask_admin.model.widgets.XEditableWidget"
T_INLINE_HTMX_EDITABLE_WIDGET = "flask_admin.model.widgets.HTMXEditableWidget"

T_FIELD_SET = "flask_admin.form.rules.FieldSet"
T_BASE_RULE = "flask_admin.form.rules.BaseRule"
Expand Down Expand Up @@ -204,7 +206,7 @@ def __call__(self, field: Field, **kwargs: t.Any) -> str | Markup: ...
T_INLINE_FIELD_LIST_WIDGET,
T_INLINE_FORM_WIDGET,
T_INLINE_AJAX_SELECT2_WIDGET,
T_INLINE_X_EDITABLE_WIDGET,
T_INLINE_HTMX_EDITABLE_WIDGET,
WidgetProtocol,
]

Expand Down Expand Up @@ -280,3 +282,7 @@ class _T_MONGOENGINE_FIELD_PROTOCOL(t.Protocol):
id: t.Any
data: t.Any
name: str


class T_FIELD_ARGS_VALIDATORS_COERCE(T_FIELD_ARGS_VALIDATORS, total=False):
coerce: t.Callable[[t.Any], t.Any]
2 changes: 1 addition & 1 deletion flask_admin/contrib/mongoengine/view.py
Original file line number Diff line number Diff line change
Expand Up @@ -488,7 +488,7 @@ def scaffold_list_form(
`self.column_editable_list`.

:param widget:
WTForms widget class. Defaults to `XEditableWidget`.
WTForms widget class. Defaults to `HTMXEditableWidget`.
:param validators:
`form_args` dict with only validators
{'name': {'validators': [required()]}}
Expand Down
2 changes: 1 addition & 1 deletion flask_admin/contrib/peewee/view.py
Original file line number Diff line number Diff line change
Expand Up @@ -346,7 +346,7 @@ def scaffold_list_form(
`self.column_editable_list`.

:param widget:
WTForms widget class. Defaults to `XEditableWidget`.
WTForms widget class. Defaults to `HTMXEditableWidget`.
:param validators:
`form_args` dict with only validators
{'name': {'validators': [required()]}}
Expand Down
14 changes: 13 additions & 1 deletion flask_admin/contrib/sqla/form.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
from ..._types import T_FIELD_ARGS_PLACES
from ..._types import T_FIELD_ARGS_VALIDATORS
from ..._types import T_FIELD_ARGS_VALIDATORS_ALLOW_BLANK
from ..._types import T_FIELD_ARGS_VALIDATORS_COERCE
from ..._types import T_FIELD_ARGS_VALIDATORS_FILES
from ..._types import T_INSTRUMENTED_ATTRIBUTE
from ..._types import T_MODEL_VIEW
Expand Down Expand Up @@ -621,8 +622,19 @@ def conv_PGUuid(
"sqlalchemy.dialects.postgresql.base.ARRAY", "sqlalchemy.sql.sqltypes.ARRAY"
)
def conv_ARRAY(
self, field_args: T_FIELD_ARGS_VALIDATORS, **extra: t.Any
self, field_args: T_FIELD_ARGS_VALIDATORS_COERCE, **extra: t.Any
) -> form.Select2TagsField:
# Ensure Select2TagsField uses the correct Python type for ARRAY element values.
# Otherwise, WTForms defaults to text_type, causing Postgres ARRAY type errors.
column = extra.get("column")
item_type = getattr(getattr(column, "type", None), "item_type", None)
if item_type is not None:
try:
python_type = item_type.python_type
except (AttributeError, NotImplementedError):
python_type = None
if python_type is not None and python_type is not str:
field_args.setdefault("coerce", python_type)
return form.Select2TagsField(save_as_list=True, **field_args)

@converts("HSTORE")
Expand Down
2 changes: 1 addition & 1 deletion flask_admin/contrib/sqla/view.py
Original file line number Diff line number Diff line change
Expand Up @@ -906,7 +906,7 @@ def scaffold_list_form(
`self.column_editable_list`.

:param widget:
WTForms widget class. Defaults to `XEditableWidget`.
WTForms widget class. Defaults to `HTMXEditableWidget`.
:param validators:
`form_args` dict with only validators
{'name': {'validators': [required()]}}
Expand Down
Loading