Skip to content
Open
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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -35,3 +35,5 @@ doc/_build
# Testing only
flask_admin/tests/fileadmin/files/*
f.html
/instance/file:mem
/instance/file:test_different_bind_joins[with_session_deprecated-SQLALiteProvider]
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,8 @@ application.

Out-of-the-box, Flask-Admin plays nicely with various ORM\'s, including

- [SQLAlchemy](https://www.sqlalchemy.org/)
- [SQLAlchemy](https://www.sqlalchemy.org/) (via either [Flask-SQLAlchemy](https://flask-sqlalchemy.palletsprojects.com/) or)
[Flask-SQLAlchemy-Lite](https://flask-sqlalchemy-lite.readthedocs.io/))
- [pymongo](https://pymongo.readthedocs.io/)
- [MongoEngine](https://mongoengine.org/)
- and [Peewee](https://github.com/coleifer/peewee).
Expand Down
16 changes: 11 additions & 5 deletions examples/datetime_timezone/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,21 +8,26 @@
from flask_admin import Admin
from flask_admin.contrib.sqla import ModelView
from flask_admin.model import typefmt
from flask_sqlalchemy import SQLAlchemy
from flask_sqlalchemy_lite import SQLAlchemy
from markupsafe import Markup
from sqlalchemy import DateTime
from sqlalchemy import String
from sqlalchemy.orm import DeclarativeBase
from sqlalchemy.orm import Mapped
from sqlalchemy.orm import mapped_column

app = Flask(__name__)
app.config["SECRET_KEY"] = "secret"
app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///db.sqlite"
app.config["SQLALCHEMY_ENGINES"] = {"default": "sqlite:///:memory:"}
db = SQLAlchemy()
db.init_app(app)
admin = Admin(app, name="Example: Datetime and Timezone")


class Base(DeclarativeBase):
pass


@app.route("/")
def index():
return '<a href="/admin/timezone_aware_article">Click me to get to Admin!</a>'
Expand All @@ -42,7 +47,8 @@ def set_timezone():
return jsonify({"error": "Invalid timezone"}), 400


class Article(db.Model):
class Article(Base):
__tablename__ = "article"
id: Mapped[int] = mapped_column(primary_key=True)
text: Mapped[str] = mapped_column(String(30))
last_edit: Mapped[datetime] = mapped_column(DateTime(timezone=True))
Expand Down Expand Up @@ -107,8 +113,8 @@ class BlogModelView(ModelView):

if __name__ == "__main__":
with app.app_context():
db.drop_all()
db.create_all()
Base.metadata.drop_all(db.engine)
Base.metadata.create_all(db.engine)
db.session.add(
Article(text="Written at 9:00 UTC", last_edit=datetime(2024, 8, 8, 9, 0, 0))
)
Expand Down
3 changes: 2 additions & 1 deletion examples/datetime_timezone/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ description = "Datetime Timezone Example."
readme = "README.md"
requires-python = ">=3.10"
dependencies = [
"flask-admin[sqlalchemy-with-utils]",
"flask-admin[sqlalchemy-lite]",
"sqlalchemy>=2.0",
]

[tool.uv.sources]
Expand Down
475 changes: 219 additions & 256 deletions examples/datetime_timezone/uv.lock

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion flask_admin/_compat.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@

import typing as t
from types import MappingProxyType
from flask_admin._types import T_TRANSLATABLE, T_ITER_CHOICES, T_ORM_MODEL
from flask_admin._types import T_TRANSLATABLE, T_ITER_CHOICES

text_type = str
string_types = (str,)
Expand Down
46 changes: 34 additions & 12 deletions flask_admin/_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,17 +52,25 @@
from arrow import Arrow as T_ARROW # noqa
from flask_babel import LazyString as T_LAZY_STRING # noqa

from flask_sqlalchemy import Model as T_SQLALCHEMY_MODEL
from flask_sqlalchemy import Model as T_SQLALCHEMY_LEGACY_MODEL
from sqlalchemy.orm import DeclarativeBase as T_DECLARATIVE_BASE

T_SQLALCHEMY_MODEL: t.TypeAlias = t.Union[
T_SQLALCHEMY_LEGACY_MODEL, T_DECLARATIVE_BASE
]

from mongoengine import Document as T_MONGO_ENGINE_DOCUMENT
from peewee import Field as T_PEEWEE_FIELD
from peewee import Model as T_PEEWEE_MODEL
from peewee import Field as T_PEEWEE_FIELD # noqa
from pymongo import MongoClient
from mongoengine import Document as T_MONGO_ENGINE_CLIENT
from sqlalchemy import Column
from sqlalchemy import Table as T_TABLE # noqa
from sqlalchemy import FromClause
from sqlalchemy import Table
from sqlalchemy.orm import InstrumentedAttribute
from sqlalchemy_utils import Choice as T_CHOICE # noqa
from sqlalchemy_utils import ChoiceType as T_CHOICE_TYPE # noqa

# Handle SQLAlchemy generic type changes
try:
T_INSTRUMENTED_ATTRIBUTE = InstrumentedAttribute[t.Any]
except TypeError: # Fall back to non-generic types for older SQLAlchemy
Expand All @@ -72,6 +80,7 @@
T_SQLALCHEMY_COLUMN = Column[t.Any]
except TypeError: # Fall back to non-generic types for older SQLAlchemy
T_SQLALCHEMY_COLUMN = Column # type: ignore[misc]

T_MONGO_CLIENT = MongoClient[t.Any]
from redis import Redis as T_REDIS # noqa
from flask_admin.contrib.peewee.ajax import (
Expand All @@ -81,6 +90,16 @@
QueryAjaxModelLoader as T_SQLA_QUERY_AJAX_MODEL_LOADER,
) # noqa
from PIL.Image import Image as T_PIL_IMAGE # noqa

T_ORM_MODEL: t.TypeAlias = t.Union[
T_SQLALCHEMY_LEGACY_MODEL,
T_DECLARATIVE_BASE,
T_PEEWEE_MODEL,
T_MONGO_CLIENT,
T_MONGO_ENGINE_DOCUMENT,
]

T_SQLALCHEMY_TABLE: t.TypeAlias = t.Union[Table, FromClause]
else:
T_VIEW = "flask_admin.base.BaseView"
T_INPUT_REQUIRED = "InputRequired"
Expand All @@ -107,12 +126,12 @@
T_ARROW = "arrow.Arrow"
T_LAZY_STRING = "flask_babel.LazyString"
T_SQLALCHEMY_COLUMN = "sqlalchemy.Column[t.Any]"
T_SQLALCHEMY_MODEL = t.TypeVar("T_SQLALCHEMY_MODEL", bound=t.Any)
T_SQLALCHEMY_LEGACY_MODEL = "flask_sqlalchemy.Model"
T_SQLALCHEMY_MODEL = t.Any
T_PEEWEE_FIELD = "peewee.Field"
T_PEEWEE_MODEL = t.TypeVar("T_PEEWEE_MODEL", bound=t.Any)
T_PEEWEE_MODEL = t.Any
T_MONGO_CLIENT = "pymongo.MongoClient[t.Any]"
T_MONGO_ENGINE_CLIENT = "mongoengine.Document"
T_TABLE = "sqlalchemy.Table"
T_MONGO_ENGINE_DOCUMENT = "mongoengine.Document"
T_CHOICE_TYPE = "sqlalchemy_utils.ChoiceType"
T_CHOICE = "sqlalchemy_utils.Choice"

Expand All @@ -125,6 +144,10 @@
"flask_admin.contrib.sqla.ajax.QueryAjaxModelLoader"
)
T_PIL_IMAGE = "PIL.Image.Image"
T_ORM_MODEL = t.Any
T_SQLALCHEMY_TABLE = (
"sqlalchemy.sql.schema.Table | sqlalchemy.sql.selectable.FromClause"
)

T_COLUMN = t.Union[str, T_SQLALCHEMY_COLUMN, T_INSTRUMENTED_ATTRIBUTE]
T_FILTER = tuple[int, T_COLUMN, str]
Expand All @@ -144,20 +167,19 @@
T_OPTION = tuple[str, T_TRANSLATABLE]
T_OPTION_LIST = t.Sequence[T_OPTION]
T_OPTIONS = t.Union[None, T_OPTION_LIST, t.Callable[[], T_OPTION_LIST]]
T_ORM_MODEL = t.Union[
T_SQLALCHEMY_MODEL, T_PEEWEE_MODEL, T_MONGO_CLIENT, T_MONGO_ENGINE_CLIENT
]
T_QUERY_AJAX_MODEL_LOADER = t.Union[
T_PEEWEE_QUERY_AJAX_MODEL_LOADER, T_SQLA_QUERY_AJAX_MODEL_LOADER
]
T_RESPONSE = t.Union[Response, Wkzg_Response]

T_SQLALCHEMY_INLINE_MODELS = t.Sequence[
t.Union[
T_INLINE_FORM_ADMIN,
type[T_SQLALCHEMY_MODEL],
tuple[type[T_SQLALCHEMY_MODEL], dict[str, t.Any]],
tuple[type[T_SQLALCHEMY_MODEL]] | dict[str, t.Any],
]
]

T_RULES_SEQUENCE = t.Sequence[
t.Union[str, T_FIELD_SET, T_BASE_RULE, T_HEADER, T_FLASK_ADMIN_FIELD, T_MACRO]
]
Expand Down
15 changes: 8 additions & 7 deletions flask_admin/contrib/mongoengine/view.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
from flask_admin.model import BaseModelView
from flask_admin.model.form import create_editable_list_form

from ..._types import T_MONGO_ENGINE_DOCUMENT
from .ajax import create_ajax_loader
from .ajax import process_ajax_references
from .filters import BaseMongoEngineFilter
Expand Down Expand Up @@ -245,7 +246,7 @@ class MyAdmin(ModelView):

def __init__(
self,
model,
model: type[T_MONGO_ENGINE_DOCUMENT],
name=None,
category=None,
endpoint=None,
Expand Down Expand Up @@ -282,7 +283,7 @@ def __init__(
:param menu_icon_value:
Icon glyph name or URL, depending on `menu_icon_type` setting
"""
self._search_fields = []
self._search_fields: list[t.Any] = []

super().__init__(
model,
Expand All @@ -295,7 +296,7 @@ def __init__(
menu_icon_type=menu_icon_type,
menu_icon_value=menu_icon_value,
)

self.model: type[T_MONGO_ENGINE_DOCUMENT]
self._primary_key = self.scaffold_pk()

def _refresh_cache(self):
Expand Down Expand Up @@ -391,7 +392,7 @@ def init_search(self):
if self.column_searchable_list:
for p in self.column_searchable_list:
if isinstance(p, string_types):
p = self.model._fields.get(p) # type: ignore[union-attr]
p = self.model._fields.get(p)

if p is None:
raise Exception("Invalid search field")
Expand All @@ -417,7 +418,7 @@ def scaffold_filters(self, name):
Either field name or field instance
"""
if isinstance(name, string_types):
attr = self.model._fields.get(name) # type: ignore[union-attr]
attr = self.model._fields.get(name)
else:
attr = name

Expand Down Expand Up @@ -494,7 +495,7 @@ def get_query(self):
Returns the QuerySet for this view. By default, it returns all the
objects for the current model.
"""
return self.model.objects # type: ignore[union-attr]
return self.model.objects

def _search(self, query, search_term):
# TODO: Unfortunately, MongoEngine contains bug which
Expand Down Expand Up @@ -620,7 +621,7 @@ def create_model(self, form):
model = self.model()
form.populate_obj(model)
self._on_model_change(form, model, True)
model.save() # type: ignore[operator]
model.save()
except Exception as ex:
if not self.handle_view_exception(ex):
flash(
Expand Down
30 changes: 15 additions & 15 deletions flask_admin/contrib/peewee/view.py
Original file line number Diff line number Diff line change
Expand Up @@ -216,25 +216,25 @@ def __init__(
menu_icon_type=menu_icon_type,
menu_icon_value=menu_icon_value,
)
self.model: type[T_PEEWEE_MODEL]
self._primary_key = self.scaffold_pk()

def _get_model_fields(
self, model: type[T_PEEWEE_MODEL] | None = None
) -> t.Generator[tuple[str, Field], t.Any, None]:
if model is None:
model = self.model # type: ignore[assignment]
model = t.cast(type[T_PEEWEE_MODEL], model)
model = self.model
return ((field.name, field) for field in get_meta_fields(model))

def scaffold_pk(self) -> str:
return get_primary_key(self.model) # type: ignore[arg-type]
return get_primary_key(self.model)

def get_pk_value(self, model: type[T_PEEWEE_MODEL]) -> t.Any: # type: ignore[override]
if self.model._meta.composite_key: # type: ignore[union-attr]
if self.model._meta.composite_key: # type: ignore[attr-defined]
return tuple(
[
getattr(model, field_name)
for field_name in self.model._meta.primary_key.field_names # type: ignore[union-attr]
for field_name in self.model._meta.primary_key.field_names # type: ignore[attr-defined]
]
)
return getattr(model, self._primary_key)
Expand Down Expand Up @@ -378,7 +378,7 @@ def scaffold_inline_form_models(self, form_class: type[Form]) -> type[Form]:
def _create_ajax_loader(
self, name: str, options: dict[str, t.Any]
) -> QueryAjaxModelLoader:
return create_ajax_loader(self.model, name, name, options) # type: ignore[arg-type]
return create_ajax_loader(self.model, name, name, options)

def _handle_join(
self, query: ModelSelect, field: t.Any, joins: set[str]
Expand Down Expand Up @@ -426,7 +426,7 @@ def _sort_clause(
return query, joins, clause

def get_query(self) -> ModelSelect:
return self.model.select() # type: ignore[union-attr]
return self.model.select()

def get_list( # type: ignore[override]
self,
Expand Down Expand Up @@ -524,18 +524,18 @@ def get_list( # type: ignore[override]
return count, query

def get_one(self, id: t.Any) -> t.Any:
if self.model._meta.composite_key: # type: ignore[union-attr]
return self.model.get( # type: ignore[union-attr]
**dict(zip(self.model._meta.primary_key.field_names, id, strict=False)) # type: ignore[union-attr]
if self.model._meta.composite_key: # type: ignore[attr-defined]
return self.model.get(
**dict(zip(self.model._meta.primary_key.field_names, id, strict=False)) # type: ignore[attr-defined]
)
return self.model.get(**{self._primary_key: id}) # type: ignore[union-attr]
return self.model.get(**{self._primary_key: id})

def create_model(self, form: Form) -> t.Union[bool, T_PEEWEE_MODEL]:
try:
model = self.model()
form.populate_obj(model)
self._on_model_change(form, model, True)
model.save(force_insert=True) # type: ignore[operator]
model.save(force_insert=True)

# For peewee have to save inline forms after model was saved
save_inline(form, model)
Expand All @@ -551,7 +551,7 @@ def create_model(self, form: Form) -> t.Union[bool, T_PEEWEE_MODEL]:
else:
self.after_model_change(form, model, True)

return model # type: ignore[return-value]
return model

def update_model(self, form: Form, model: T_PEEWEE_MODEL) -> bool | None: # type: ignore[override]
try:
Expand Down Expand Up @@ -611,11 +611,11 @@ def action_delete(self, ids: t.Any) -> None:
model_pk = getattr(self.model, self._primary_key)

if self.fast_mass_delete:
count = self.model.delete().where(model_pk << ids).execute() # type: ignore[union-attr]
count = self.model.delete().where(model_pk << ids).execute()
else:
count = 0

query = self.model.select().filter(model_pk << ids) # type: ignore[union-attr]
query = self.model.select().filter(model_pk << ids)

for m in query:
self.on_model_delete(m)
Expand Down
Loading