From 43bbb84b57d21557f00c45e490abc77262717767 Mon Sep 17 00:00:00 2001 From: Maxim Zemskov Date: Thu, 11 Apr 2024 10:32:14 +0400 Subject: [PATCH 01/17] Hide save and add another button from edit.html if can_create is False (#742) --- sqladmin/templates/edit.html | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/sqladmin/templates/edit.html b/sqladmin/templates/edit.html index ae51125c..5d4e9c16 100644 --- a/sqladmin/templates/edit.html +++ b/sqladmin/templates/edit.html @@ -43,10 +43,12 @@

Edit {{ model_view.name }}

- {% if model_view.save_as %} - - {% else %} - + {% if model_view.can_create %} + {% if model_view.save_as %} + + {% else %} + + {% endif %} {% endif %}
@@ -55,4 +57,4 @@

Edit {{ model_view.name }}

-{% endblock %} \ No newline at end of file +{% endblock %} From feeb3fe0077426667139d824a64e9ba114430b08 Mon Sep 17 00:00:00 2001 From: Amin Alaee Date: Fri, 12 Apr 2024 12:34:07 +0200 Subject: [PATCH 02/17] Fix list page sort symbol (#744) --- sqladmin/templates/list.html | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/sqladmin/templates/list.html b/sqladmin/templates/list.html index e574026b..fda36b2c 100644 --- a/sqladmin/templates/list.html +++ b/sqladmin/templates/list.html @@ -95,10 +95,10 @@

{{ model_view.name_plural }}

{% if name in model_view._sort_fields %} {% if request.query_params.get("sortBy") == name and request.query_params.get("sort") == "asc" %} - {{ + {{ label }} {% elif request.query_params.get("sortBy") == name and request.query_params.get("sort") == "desc" %} - {{ label + {{ label }} {% else %} {{ label }} @@ -219,4 +219,4 @@

{{ model_view.name_plural }}

{% endif %} {% endfor %} -{% endblock %} \ No newline at end of file +{% endblock %} From 9ed5414492670dac573024efe80f935952e4f336 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hasan=20Sezer=20Ta=C5=9Fan?= <13135006+hasansezertasan@users.noreply.github.com> Date: Mon, 15 Apr 2024 09:52:49 +0300 Subject: [PATCH 03/17] Move template files from `templates` to `templates/sqladmin` (#748) --- docs/configurations.md | 8 ++++---- docs/cookbook/using_wysiwyg.md | 2 +- docs/working_with_templates.md | 6 +++--- docs/writing_custom_views.md | 2 +- sqladmin/application.py | 8 ++++---- sqladmin/models.py | 16 ++++++++-------- sqladmin/templates/index.html | 3 --- sqladmin/templates/{ => sqladmin}/_macros.html | 0 sqladmin/templates/{ => sqladmin}/base.html | 0 sqladmin/templates/{ => sqladmin}/create.html | 2 +- sqladmin/templates/{ => sqladmin}/details.html | 6 +++--- sqladmin/templates/{ => sqladmin}/edit.html | 2 +- sqladmin/templates/{ => sqladmin}/error.html | 2 +- sqladmin/templates/sqladmin/index.html | 3 +++ sqladmin/templates/{ => sqladmin}/layout.html | 4 ++-- sqladmin/templates/{ => sqladmin}/list.html | 6 +++--- sqladmin/templates/{ => sqladmin}/login.html | 2 +- .../templates/{ => sqladmin}/modals/delete.html | 0 .../modals/details_action_confirmation.html | 0 .../modals/list_action_confirmation.html | 0 tests/templates/custom.html | 2 +- 21 files changed, 37 insertions(+), 37 deletions(-) delete mode 100644 sqladmin/templates/index.html rename sqladmin/templates/{ => sqladmin}/_macros.html (100%) rename sqladmin/templates/{ => sqladmin}/base.html (100%) rename sqladmin/templates/{ => sqladmin}/create.html (98%) rename sqladmin/templates/{ => sqladmin}/details.html (96%) rename sqladmin/templates/{ => sqladmin}/edit.html (98%) rename sqladmin/templates/{ => sqladmin}/error.html (87%) create mode 100644 sqladmin/templates/sqladmin/index.html rename sqladmin/templates/{ => sqladmin}/layout.html (95%) rename sqladmin/templates/{ => sqladmin}/list.html (98%) rename sqladmin/templates/{ => sqladmin}/login.html (97%) rename sqladmin/templates/{ => sqladmin}/modals/delete.html (100%) rename sqladmin/templates/{ => sqladmin}/modals/details_action_confirmation.html (100%) rename sqladmin/templates/{ => sqladmin}/modals/list_action_confirmation.html (100%) diff --git a/docs/configurations.md b/docs/configurations.md index 5c8c8020..cfceffaf 100644 --- a/docs/configurations.md +++ b/docs/configurations.md @@ -234,10 +234,10 @@ The export options can be set per model and includes the following options: The template files are built using Jinja2 and can be completely overridden in the configurations. The pages available are: -* `list_template`: Template to use for models list page. Default is `list.html`. -* `create_template`: Template to use for model creation page. Default is `create.html`. -* `details_template`: Template to use for model details page. Default is `details.html`. -* `edit_template`: Template to use for model edit page. Default is `edit.html`. +* `list_template`: Template to use for models list page. Default is `sqladmin/list.html`. +* `create_template`: Template to use for model creation page. Default is `sqladmin/create.html`. +* `details_template`: Template to use for model details page. Default is `sqladmin/details.html`. +* `edit_template`: Template to use for model edit page. Default is `sqladmin/edit.html`. !!! example diff --git a/docs/cookbook/using_wysiwyg.md b/docs/cookbook/using_wysiwyg.md index dab4ae7c..4f24a5a2 100644 --- a/docs/cookbook/using_wysiwyg.md +++ b/docs/cookbook/using_wysiwyg.md @@ -12,7 +12,7 @@ class Post(Base): - First create a `templates` directory in your project. - Then add a file `custom_edit.html` there with the following content: ```html title="custom_edit.html" -{% extends "edit.html" %} +{% extends "sqladmin/edit.html" %} {% block tail %} + {% endblock %} + + ``` + ## Customizing Jinja2 environment You can add custom environment options to use it on your custom templates. First set up a project: @@ -90,7 +105,7 @@ Usage in templates: ```python def value_is_filepath(value: Any) -> bool: return isinstance(value, str) and os.path.isfile(value) - + admin.templates.env.globals["value_is_filepath"] = value_is_filepath ``` From b51c95295e9ce772a118029824b94d997b80f945 Mon Sep 17 00:00:00 2001 From: Luke Climenhage <56767690+lukeclimen@users.noreply.github.com> Date: Tue, 11 Jun 2024 10:10:46 -0400 Subject: [PATCH 13/17] Fix `edit_form_query` documentation example (#777) --- docs/cookbook/optimize_relationship_loading.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/docs/cookbook/optimize_relationship_loading.md b/docs/cookbook/optimize_relationship_loading.md index f7eb8fbb..68b54c2c 100644 --- a/docs/cookbook/optimize_relationship_loading.md +++ b/docs/cookbook/optimize_relationship_loading.md @@ -72,8 +72,7 @@ class ParentAdmin(ModelView, model=Parent): def edit_form_query(self, request: Request) -> Select: parent_id = request.path_params["pk"] return ( - super() - .edit_form_query(request) + self._stmt_by_identifier(parent_id) .join(Child) .options(contains_eager(Parent.children)) .filter(Child.parent_id == parent_id) From c2dd278df2345c4d18e4783dc7a6e0f096f6df35 Mon Sep 17 00:00:00 2001 From: Amin Alaee Date: Wed, 12 Jun 2024 14:42:47 +0200 Subject: [PATCH 14/17] Add `form_rules`, `form_create_rules`, `form_edit_rules` (#779) --- docs/api_reference/model_view.md | 5 +- docs/configurations.md | 7 ++- sqladmin/application.py | 2 + sqladmin/models.py | 58 ++++++++++++++++++++++++ sqladmin/templates/sqladmin/_macros.html | 33 ++++++++++++++ sqladmin/templates/sqladmin/create.html | 20 +------- sqladmin/templates/sqladmin/edit.html | 32 ++++--------- tests/test_views/test_view_sync.py | 6 +++ 8 files changed, 119 insertions(+), 44 deletions(-) diff --git a/docs/api_reference/model_view.md b/docs/api_reference/model_view.md index 87cd0b4e..a266566a 100644 --- a/docs/api_reference/model_view.md +++ b/docs/api_reference/model_view.md @@ -44,12 +44,15 @@ - form_include_pk - form_ajax_refs - form_converter + - form_edit_query + - form_rules + - form_create_rules + - form_edit_rules - column_type_formatters - list_query - count_query - search_query - sort_query - - edit_form_query - on_model_change - after_model_change - on_model_delete diff --git a/docs/configurations.md b/docs/configurations.md index 10394ff0..96dacea5 100644 --- a/docs/configurations.md +++ b/docs/configurations.md @@ -200,7 +200,10 @@ The forms are based on `WTForms` package and include the following options: * `form_include_pk`: Control if primary key column should be included in create/edit forms. Default is `False`. * `form_ajax_refs`: Use Ajax with Select2 for loading relationship models async. This is use ful when the related model has a lot of records. * `form_converter`: Allow adding custom converters to support additional column types. -* `edit_form_query`: A method with the signature of `(request) -> stmt` which can customize the edit form data. +* `form_edit_query`: A method with the signature of `(request) -> stmt` which can customize the edit form data. +* `form_rules`: List of form rules to manage rendering and behaviour of form. +* `form_create_rules`: List of form rules to manage rendering and behaviour of form in create page. +* `form_edit_rules`: List of form rules to manage rendering and behaviour of form in edit page. !!! example @@ -217,6 +220,8 @@ The forms are based on `WTForms` package and include the following options: "order_by": ("id",), } } + form_create_rules = ["name", "password"] + form_edit_rules = ["name"] ``` ## Export options diff --git a/sqladmin/application.py b/sqladmin/application.py index 93a8e996..34caaf2f 100644 --- a/sqladmin/application.py +++ b/sqladmin/application.py @@ -509,6 +509,7 @@ async def create(self, request: Request) -> Response: model_view = self._find_model_view(identity) Form = await model_view.scaffold_form() + model_view._validate_form_class(model_view._form_create_rules, Form) form_data = await self._handle_form_data(request) form = Form(form_data) @@ -559,6 +560,7 @@ async def edit(self, request: Request) -> Response: raise HTTPException(status_code=404) Form = await model_view.scaffold_form() + model_view._validate_form_class(model_view._form_edit_rules, Form) context = { "obj": model, "model_view": model_view, diff --git a/sqladmin/models.py b/sqladmin/models.py index b31163ba..ac50ce45 100644 --- a/sqladmin/models.py +++ b/sqladmin/models.py @@ -1,4 +1,7 @@ +from __future__ import annotations + import time +import warnings from enum import Enum from typing import ( TYPE_CHECKING, @@ -29,6 +32,7 @@ from starlette.requests import Request from starlette.responses import StreamingResponse from wtforms import Field, Form +from wtforms.fields.core import UnboundField from sqladmin._queries import Query from sqladmin._types import MODEL_ATTR @@ -598,6 +602,28 @@ class UserAdmin(ModelAdmin, model=User): ``` """ + form_rules: ClassVar[list[str]] = [] + """List of rendering rules for model creation and edit form. + This property changes default form rendering behavior and to rearrange + order of rendered fields, add some text between fields, group them, etc. + If not set, will use default Flask-Admin form rendering logic. + + ???+ example + ```python + class UserAdmin(ModelAdmin, model=User): + form_rules = [ + "first_name", + "last_name", + ] + ``` + """ + + form_create_rules: ClassVar[list[str]] = [] + """Customized rules for the create form. Cannot be specified with `form_rules`.""" + + form_edit_rules: ClassVar[list[str]] = [] + """Customized rules for the edit form. Cannot be specified with `form_rules`.""" + # General options column_labels: ClassVar[Dict[MODEL_ATTR, str]] = {} """A mapping of column labels, used to map column names to new names. @@ -685,6 +711,8 @@ def __init__(self) -> None: model_admin=self, name=name, options=options ) + self._refresh_form_rules_cache() + self._custom_actions_in_list: Dict[str, str] = {} self._custom_actions_in_detail: Dict[str, str] = {} self._custom_actions_confirmation: Dict[str, str] = {} @@ -1054,6 +1082,13 @@ def list_query(self, request: Request) -> Select: return select(self.model) def edit_form_query(self, request: Request) -> Select: + msg = ( + "Overriding 'edit_form_query' is deprecated. Use 'form_edit_query' instead." + ) + warnings.warn(msg, DeprecationWarning, stacklevel=2) + return self.form_edit_query(request) + + def form_edit_query(self, request: Request) -> Select: """ The SQLAlchemy select expression used for the edit form page which can be customized. By default it will select the object by primary key(s) without any @@ -1143,3 +1178,26 @@ async def generate(writer: Writer) -> AsyncGenerator[Any, None]: media_type="text/csv", headers={"Content-Disposition": f"attachment;filename={filename}"}, ) + + def _refresh_form_rules_cache(self) -> None: + if self.form_rules: + self._form_create_rules = self.form_rules + self._form_edit_rules = self.form_rules + else: + self._form_create_rules = self.form_create_rules + self._form_edit_rules = self.form_edit_rules + + def _validate_form_class(self, ruleset: List[Any], form_class: Type[Form]) -> None: + form_fields = [] + for name, obj in form_class.__dict__.items(): + if isinstance(obj, UnboundField): + form_fields.append(name) + + missing_fields = [] + if ruleset: + for field_name in form_fields: + if field_name not in ruleset: + missing_fields.append(field_name) + + for field_name in missing_fields: + delattr(form_class, field_name) diff --git a/sqladmin/templates/sqladmin/_macros.html b/sqladmin/templates/sqladmin/_macros.html index acd29481..51e09b03 100644 --- a/sqladmin/templates/sqladmin/_macros.html +++ b/sqladmin/templates/sqladmin/_macros.html @@ -53,3 +53,36 @@ {% endfor %} {% endmacro %} + +{% macro render_field(field, kwargs={}) %} +
+ {{ field.label(class_="form-label col-sm-2 col-form-label") }} +
+ {% if field.errors %} + {{ field(class_="form-control is-invalid") }} + {% else %} + {{ field() }} + {% endif %} + {% for error in field.errors %} +
{{ error }}
+ {% endfor %} + {% if field.description %} + {{ field.description }} + {% endif %} +
+
+{% endmacro %} + +{% macro render_form_fields(form, form_opts=None) %} +{% if form.hidden_tag is defined %} +{{ form.hidden_tag() }} +{% else %} +{% for f in form if f.widget.input_type == 'hidden' %} +{{ f }} +{% endfor %} +{% endif %} + +{% for f in form if f.widget.input_type != 'hidden' %} +{{ render_field(f, kwargs) }} +{% endfor %} +{% endmacro %} \ No newline at end of file diff --git a/sqladmin/templates/sqladmin/create.html b/sqladmin/templates/sqladmin/create.html index d62d6c36..e5557979 100644 --- a/sqladmin/templates/sqladmin/create.html +++ b/sqladmin/templates/sqladmin/create.html @@ -1,4 +1,5 @@ {% extends "sqladmin/layout.html" %} +{% from 'sqladmin/_macros.html' import render_form_fields %} {% block content %}
@@ -14,24 +15,7 @@

New {{ model_view.name }}

{% endif %}
- {% for field in form %} -
- {{ field.label(class_="form-label col-sm-2 col-form-label") }} -
- {% if field.errors %} - {{ field(class_="form-control is-invalid") }} - {% else %} - {{ field() }} - {% endif %} - {% for error in field.errors %} -
{{ error }}
- {% endfor %} - {% if field.description %} - {{ field.description }} - {% endif %} -
-
- {% endfor %} + {{ render_form_fields(form, form_opts=form_opts) }}
diff --git a/sqladmin/templates/sqladmin/edit.html b/sqladmin/templates/sqladmin/edit.html index e6650ce9..c84507d5 100644 --- a/sqladmin/templates/sqladmin/edit.html +++ b/sqladmin/templates/sqladmin/edit.html @@ -1,4 +1,5 @@ {% extends "sqladmin/layout.html" %} +{% from 'sqladmin/_macros.html' import render_form_fields %} {% block content %}
@@ -14,24 +15,7 @@

Edit {{ model_view.name }}

{% endif %}
- {% for field in form %} -
- {{ field.label(class_="form-label col-sm-2 col-form-label") }} -
- {% if field.errors %} - {{ field(class_="form-control is-invalid") }} - {% else %} - {{ field() }} - {% endif %} - {% for error in field.errors %} -
{{ error }}
- {% endfor %} - {% if field.description %} - {{ field.description }} - {% endif %} -
-
- {% endfor %} + {{ render_form_fields(form, form_opts=form_opts) }}
@@ -44,11 +28,11 @@

Edit {{ model_view.name }}

{% if model_view.can_create %} - {% if model_view.save_as %} - - {% else %} - - {% endif %} + {% if model_view.save_as %} + + {% else %} + + {% endif %} {% endif %}
@@ -57,4 +41,4 @@

Edit {{ model_view.name }}

-{% endblock %} +{% endblock %} \ No newline at end of file diff --git a/tests/test_views/test_view_sync.py b/tests/test_views/test_view_sync.py index 46274507..b1dc7d69 100644 --- a/tests/test_views/test_view_sync.py +++ b/tests/test_views/test_view_sync.py @@ -150,6 +150,8 @@ class UserAdmin(ModelView, model=User): User.profile_formattable: lambda m, a: f"Formatted {m.profile_formattable}", } save_as = True + form_create_rules = ["name", "email", "addresses", "profile", "birthdate", "status"] + form_edit_rules = ["name", "email", "addresses", "profile", "birthdate"] class AddressAdmin(ModelView, model=Address): @@ -442,6 +444,7 @@ def test_create_endpoint_get_form(client: TestClient) -> None: '' in response.text ) + assert '' not in response.text + ) response = client.get("/admin/address/edit/1") From 870f6285b2f8a5733246ed9c98fa51c36ae7dcce Mon Sep 17 00:00:00 2001 From: Amin Alaee Date: Thu, 13 Jun 2024 17:23:30 +0200 Subject: [PATCH 15/17] Uplift type hints (#780) --- .../cookbook/optimize_relationship_loading.md | 6 +- sqladmin/_menu.py | 20 ++-- sqladmin/_queries.py | 14 ++- sqladmin/ajax.py | 8 +- sqladmin/application.py | 67 +++++------ sqladmin/authentication.py | 6 +- sqladmin/fields.py | 68 ++++++----- sqladmin/forms.py | 113 +++++++++--------- sqladmin/helpers.py | 18 ++- sqladmin/models.py | 2 +- sqladmin/pagination.py | 8 +- sqladmin/templating.py | 16 +-- tests/test_models.py | 4 +- 13 files changed, 178 insertions(+), 172 deletions(-) diff --git a/docs/cookbook/optimize_relationship_loading.md b/docs/cookbook/optimize_relationship_loading.md index 68b54c2c..6a718a35 100644 --- a/docs/cookbook/optimize_relationship_loading.md +++ b/docs/cookbook/optimize_relationship_loading.md @@ -61,15 +61,15 @@ class ParentAdmin(ModelView, model=Parent): form_excluded_columns = [Parent.children] ``` -### Using `edit_form_query` to customize the edit form data +### Using `form_edit_query` to customize the edit form data If you would like to fully customize the query to populate the edit object form, you may override -the `edit_form_query` function with your own SQLAlchemy query. In the following example, overriding +the `form_edit_query` function with your own SQLAlchemy query. In the following example, overriding the default query will allow you to filter relationships to show only related children of the parent. ```py class ParentAdmin(ModelView, model=Parent): - def edit_form_query(self, request: Request) -> Select: + def form_edit_query(self, request: Request) -> Select: parent_id = request.path_params["pk"] return ( self._stmt_by_identifier(parent_id) diff --git a/sqladmin/_menu.py b/sqladmin/_menu.py index 579c76a5..7389de66 100644 --- a/sqladmin/_menu.py +++ b/sqladmin/_menu.py @@ -1,4 +1,6 @@ -from typing import TYPE_CHECKING, List, Optional, Union +from __future__ import annotations + +from typing import TYPE_CHECKING from starlette.datastructures import URL from starlette.requests import Request @@ -8,11 +10,11 @@ class ItemMenu: - def __init__(self, name: str, icon: Optional[str] = None) -> None: + def __init__(self, name: str, icon: str | None = None) -> None: self.name = name self.icon = icon - self.parent: Optional["ItemMenu"] = None - self.children: List["ItemMenu"] = [] + self.parent: "ItemMenu" | None = None + self.children: list["ItemMenu"] = [] def add_child(self, item: "ItemMenu") -> None: item.parent = self @@ -27,7 +29,7 @@ def is_accessible(self, request: Request) -> bool: def is_active(self, request: Request) -> bool: return False - def url(self, request: Request) -> Union[str, URL]: + def url(self, request: Request) -> str | URL: return "#" @property @@ -53,9 +55,9 @@ def type_(self) -> str: class ViewMenu(ItemMenu): def __init__( self, - view: Union["BaseView", "ModelView"], + view: "BaseView" | "ModelView", name: str, - icon: Optional[str] = None, + icon: str | None = None, ) -> None: super().__init__(name=name, icon=icon) self.view = view @@ -69,7 +71,7 @@ def is_accessible(self, request: Request) -> bool: def is_active(self, request: Request) -> bool: return self.view.identity == request.path_params.get("identity") - def url(self, request: Request) -> Union[str, URL]: + def url(self, request: Request) -> str | URL: if self.view.is_model: return request.url_for("admin:list", identity=self.view.identity) return request.url_for(f"admin:{self.view.identity}") @@ -85,7 +87,7 @@ def type_(self) -> str: class Menu: def __init__(self) -> None: - self.items: List[ItemMenu] = [] + self.items: list[ItemMenu] = [] def add(self, item: ItemMenu) -> None: # Only works for one-level menu diff --git a/sqladmin/_queries.py b/sqladmin/_queries.py index 54e88acc..44ae2b9e 100644 --- a/sqladmin/_queries.py +++ b/sqladmin/_queries.py @@ -1,4 +1,6 @@ -from typing import TYPE_CHECKING, Any, Dict, List +from __future__ import annotations + +from typing import TYPE_CHECKING, Any import anyio from sqlalchemy import select @@ -24,7 +26,7 @@ class Query: def __init__(self, model_view: "ModelView") -> None: self.model_view = model_view - def _get_to_many_stmt(self, relation: MODEL_PROPERTY, values: List[Any]) -> Select: + def _get_to_many_stmt(self, relation: MODEL_PROPERTY, values: list[Any]) -> Select: target = relation.mapper.class_ target_pks = get_primary_keys(target) @@ -131,7 +133,7 @@ async def _set_attributes_async( setattr(obj, key, value) return obj - def _update_sync(self, pk: Any, data: Dict[str, Any], request: Request) -> Any: + def _update_sync(self, pk: Any, data: dict[str, Any], request: Request) -> Any: stmt = self.model_view._stmt_by_identifier(pk) with self.model_view.session_maker(expire_on_commit=False) as session: @@ -147,7 +149,7 @@ def _update_sync(self, pk: Any, data: Dict[str, Any], request: Request) -> Any: return obj async def _update_async( - self, pk: Any, data: Dict[str, Any], request: Request + self, pk: Any, data: dict[str, Any], request: Request ) -> Any: stmt = self.model_view._stmt_by_identifier(pk) @@ -187,7 +189,7 @@ async def _delete_async(self, pk: str, request: Request) -> None: await session.commit() await self.model_view.after_model_delete(obj, request) - def _insert_sync(self, data: Dict[str, Any], request: Request) -> Any: + def _insert_sync(self, data: dict[str, Any], request: Request) -> Any: obj = self.model_view.model() with self.model_view.session_maker(expire_on_commit=False) as session: @@ -202,7 +204,7 @@ def _insert_sync(self, data: Dict[str, Any], request: Request) -> Any: ) return obj - async def _insert_async(self, data: Dict[str, Any], request: Request) -> Any: + async def _insert_async(self, data: dict[str, Any], request: Request) -> Any: obj = self.model_view.model() async with self.model_view.session_maker(expire_on_commit=False) as session: diff --git a/sqladmin/ajax.py b/sqladmin/ajax.py index 63f51175..28457810 100644 --- a/sqladmin/ajax.py +++ b/sqladmin/ajax.py @@ -1,4 +1,6 @@ -from typing import TYPE_CHECKING, Any, Dict, List +from __future__ import annotations + +from typing import TYPE_CHECKING, Any from sqlalchemy import String, cast, inspect, or_, select @@ -52,13 +54,13 @@ def _process_fields(self) -> list: return remote_fields - def format(self, model: type) -> Dict[str, Any]: + def format(self, model: type) -> dict[str, Any]: if not model: return {} return {"id": str(get_object_identifier(model)), "text": str(model)} - async def get_list(self, term: str, limit: int = DEFAULT_PAGE_SIZE) -> List[Any]: + async def get_list(self, term: str, limit: int = DEFAULT_PAGE_SIZE) -> list[Any]: stmt = select(self.model) # no type casting to string if a ColumnAssociationProxyInstance is given diff --git a/sqladmin/application.py b/sqladmin/application.py index 34caaf2f..c0660e61 100644 --- a/sqladmin/application.py +++ b/sqladmin/application.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import inspect import io import logging @@ -7,12 +9,7 @@ Any, Awaitable, Callable, - List, - Optional, Sequence, - Tuple, - Type, - Union, cast, no_type_check, ) @@ -66,14 +63,14 @@ class BaseAdmin: def __init__( self, app: Starlette, - engine: Optional[ENGINE_TYPE] = None, - session_maker: Optional[sessionmaker] = None, + engine: ENGINE_TYPE | None = None, + session_maker: sessionmaker | None = None, base_url: str = "/admin", title: str = "Admin", - logo_url: Optional[str] = None, + logo_url: str | None = None, templates_dir: str = "templates", - middlewares: Optional[Sequence[Middleware]] = None, - authentication_backend: Optional[AuthenticationBackend] = None, + middlewares: Sequence[Middleware] | None = None, + authentication_backend: AuthenticationBackend | None = None, ) -> None: self.app = app self.engine = engine @@ -100,7 +97,7 @@ def __init__( self.admin = Starlette(middleware=middlewares) self.templates = self.init_templating_engine() - self._views: List[Union[BaseView, ModelView]] = [] + self._views: list[BaseView | ModelView] = [] self._menu = Menu() def init_templating_engine(self) -> Jinja2Templates: @@ -120,7 +117,7 @@ def init_templating_engine(self) -> Jinja2Templates: return templates @property - def views(self) -> List[Union[BaseView, ModelView]]: + def views(self) -> list[BaseView | ModelView]: """Get list of ModelView and BaseView instances lazily. Returns: @@ -136,7 +133,7 @@ def _find_model_view(self, identity: str) -> ModelView: raise HTTPException(status_code=404) - def add_view(self, view: Union[Type[ModelView], Type[BaseView]]) -> None: + def add_view(self, view: type[ModelView] | type[BaseView]) -> None: """Add ModelView or BaseView classes to Admin. This is a shortcut that will handle both `add_model_view` and `add_base_view`. """ @@ -149,10 +146,10 @@ def add_view(self, view: Union[Type[ModelView], Type[BaseView]]) -> None: def _find_decorated_funcs( self, - view: Type[Union[BaseView, ModelView]], - view_instance: Union[BaseView, ModelView], + view: type[BaseView | ModelView], + view_instance: BaseView | ModelView, handle_fn: Callable[ - [MethodType, Type[Union[BaseView, ModelView]], Union[BaseView, ModelView]], + [MethodType, type[BaseView | ModelView], BaseView | ModelView], None, ], ) -> None: @@ -164,8 +161,8 @@ def _find_decorated_funcs( def _handle_action_decorated_func( self, func: MethodType, - view: Type[Union[BaseView, ModelView]], - view_instance: Union[BaseView, ModelView], + view: type[BaseView | ModelView], + view_instance: BaseView | ModelView, ) -> None: if hasattr(func, "_action"): view_instance = cast(ModelView, view_instance) @@ -194,8 +191,8 @@ def _handle_action_decorated_func( def _handle_expose_decorated_func( self, func: MethodType, - view: Type[Union[BaseView, ModelView]], - view_instance: Union[BaseView, ModelView], + view: type[BaseView | ModelView], + view_instance: BaseView | ModelView, ) -> None: if hasattr(func, "_exposed"): self.admin.add_route( @@ -208,7 +205,7 @@ def _handle_expose_decorated_func( view.identity = getattr(func, "_identity") - def add_model_view(self, view: Type[ModelView]) -> None: + def add_model_view(self, view: type[ModelView]) -> None: """Add ModelView to the Admin. ???+ usage @@ -237,7 +234,7 @@ class UserAdmin(ModelView, model=User): self._views.append(view_instance) self._build_menu(view_instance) - def add_base_view(self, view: Type[BaseView]) -> None: + def add_base_view(self, view: type[BaseView]) -> None: """Add BaseView to the Admin. ???+ usage @@ -265,7 +262,7 @@ async def test_page(self, request: Request): self._views.append(view_instance) self._build_menu(view_instance) - def _build_menu(self, view: Union[ModelView, BaseView]) -> None: + def _build_menu(self, view: ModelView | BaseView) -> None: if view.category: menu = CategoryMenu(name=view.category) menu.add_child(ViewMenu(view=view, name=view.name, icon=view.icon)) @@ -338,15 +335,15 @@ class UserAdmin(ModelView, model=User): def __init__( self, app: Starlette, - engine: Optional[ENGINE_TYPE] = None, - session_maker: Optional[Union[sessionmaker, "async_sessionmaker"]] = None, + engine: ENGINE_TYPE | None = None, + session_maker: sessionmaker | "async_sessionmaker" | None = None, base_url: str = "/admin", title: str = "Admin", - logo_url: Optional[str] = None, - middlewares: Optional[Sequence[Middleware]] = None, + logo_url: str | None = None, + middlewares: Sequence[Middleware] | None = None, debug: bool = False, templates_dir: str = "templates", - authentication_backend: Optional[AuthenticationBackend] = None, + authentication_backend: AuthenticationBackend | None = None, ) -> None: """ Args: @@ -374,7 +371,7 @@ def __init__( async def http_exception( request: Request, exc: Exception - ) -> Union[Response, Awaitable[Response]]: + ) -> Response | Awaitable[Response]: assert isinstance(exc, HTTPException) context = { "status_code": exc.status_code, @@ -662,7 +659,7 @@ async def ajax_lookup(self, request: Request) -> Response: def get_save_redirect_url( self, request: Request, form: FormData, model_view: ModelView, obj: Any - ) -> Union[str, URL]: + ) -> str | URL: """ Get the redirect URL after a save action which is triggered from create/edit page. @@ -687,7 +684,7 @@ async def _handle_form_data(self, request: Request, obj: Any = None) -> FormData """ form = await request.form() - form_data: List[Tuple[str, Union[str, UploadFile]]] = [] + form_data: list[tuple[str, str | UploadFile]] = [] for key, value in form.multi_items(): if not isinstance(value, UploadFile): form_data.append((key, value)) @@ -728,8 +725,8 @@ def _denormalize_wtform_data(self, form_data: dict, obj: Any) -> dict: def expose( path: str, *, - methods: List[str] = ["GET"], - identity: Optional[str] = None, + methods: list[str] = ["GET"], + identity: str | None = None, include_in_schema: bool = True, ) -> Callable[..., Any]: """Expose View with information.""" @@ -748,8 +745,8 @@ def wrap(func): def action( name: str, - label: Optional[str] = None, - confirmation_message: Optional[str] = None, + label: str | None = None, + confirmation_message: str | None = None, *, include_in_schema: bool = True, add_in_detail: bool = True, diff --git a/sqladmin/authentication.py b/sqladmin/authentication.py index 50443bec..14723cb1 100644 --- a/sqladmin/authentication.py +++ b/sqladmin/authentication.py @@ -1,6 +1,8 @@ +from __future__ import annotations + import functools import inspect -from typing import Any, Callable, Union +from typing import Any, Callable from starlette.middleware import Middleware from starlette.requests import Request @@ -33,7 +35,7 @@ async def logout(self, request: Request) -> bool: """ raise NotImplementedError() - async def authenticate(self, request: Request) -> Union[Response, bool]: + async def authenticate(self, request: Request) -> Response | bool: """Implement authenticate logic here. This method will be called for each incoming request to validate the authentication. diff --git a/sqladmin/fields.py b/sqladmin/fields.py index 508e6877..821eda5f 100644 --- a/sqladmin/fields.py +++ b/sqladmin/fields.py @@ -1,6 +1,8 @@ +from __future__ import annotations + import json import operator -from typing import Any, Callable, Dict, Generator, List, Optional, Set, Tuple, Union +from typing import Any, Callable, Generator from wtforms import Form, ValidationError, fields, widgets @@ -43,7 +45,7 @@ class IntervalField(fields.StringField): A text field which stores a `datetime.timedelta` object. """ - def process_formdata(self, valuelist: List[str]) -> None: + def process_formdata(self, valuelist: list[str]) -> None: if not valuelist: return @@ -57,19 +59,19 @@ def process_formdata(self, valuelist: List[str]) -> None: class SelectField(fields.SelectField): def __init__( self, - label: Optional[str] = None, - validators: Optional[list] = None, + label: str | None = None, + validators: list | None = None, coerce: type = str, - choices: Optional[Union[list, Callable]] = None, + choices: list | Callable | None = None, allow_blank: bool = False, - blank_text: Optional[str] = None, + blank_text: str | None = None, **kwargs: Any, ) -> None: super().__init__(label, validators, coerce, choices, **kwargs) self.allow_blank = allow_blank self.blank_text = blank_text or " " - def iter_choices(self) -> Generator[Tuple[str, str, bool, Dict], None, None]: + def iter_choices(self) -> Generator[tuple[str, str, bool, dict], None, None]: choices = self.choices or [] if self.allow_blank: @@ -86,7 +88,7 @@ def iter_choices(self) -> Generator[Tuple[str, str, bool, Dict], None, None]: {}, ) - def process_formdata(self, valuelist: List[str]) -> None: + def process_formdata(self, valuelist: list[str]) -> None: if valuelist: if valuelist[0] == "__None": self.data = None @@ -112,7 +114,7 @@ def _value(self) -> str: else: return "{}" - def process_formdata(self, valuelist: List[str]) -> None: + def process_formdata(self, valuelist: list[str]) -> None: if valuelist: value = valuelist[0] @@ -132,10 +134,10 @@ class QuerySelectField(fields.SelectFieldBase): def __init__( self, - data: Optional[list] = None, - label: Optional[str] = None, - validators: Optional[list] = None, - get_label: Optional[Union[Callable, str]] = None, + data: list | None = None, + label: str | None = None, + validators: list | None = None, + get_label: Callable | str | None = None, allow_blank: bool = False, blank_text: str = "", **kwargs: Any, @@ -153,11 +155,11 @@ def __init__( self.allow_blank = allow_blank self.blank_text = blank_text - self._data: Optional[tuple] - self._formdata: Optional[Union[str, List[str]]] + self._data: tuple | None + self._formdata: str | list[str] | None @property - def data(self) -> Optional[tuple]: + def data(self) -> tuple | None: if self._formdata is not None: for pk, _ in self._select_data: if pk == self._formdata: @@ -170,7 +172,7 @@ def data(self, data: tuple) -> None: self._data = data self._formdata = None - def iter_choices(self) -> Generator[Tuple[str, str, bool, Dict], None, None]: + def iter_choices(self) -> Generator[tuple[str, str, bool, dict], None, None]: if self.allow_blank: yield ("__None", self.blank_text, self.data is None, {}) @@ -186,7 +188,7 @@ def iter_choices(self) -> Generator[Tuple[str, str, bool, Dict], None, None]: for pk, label in self._select_data: yield (pk, self.get_label(label), str(pk) == primary_key, {}) - def process_formdata(self, valuelist: List[str]) -> None: + def process_formdata(self, valuelist: list[str]) -> None: if valuelist: if self.allow_blank and valuelist[0] == "__None": self.data = None @@ -220,9 +222,9 @@ class QuerySelectMultipleField(QuerySelectField): def __init__( self, - data: Optional[list] = None, - label: Optional[str] = None, - validators: Optional[list] = None, + data: list | None = None, + label: str | None = None, + validators: list | None = None, default: Any = None, **kwargs: Any, ) -> None: @@ -238,11 +240,11 @@ def __init__( "allow_blank=True does not do anything for QuerySelectMultipleField." ) self._invalid_formdata = False - self._formdata: Optional[List[str]] = None - self._data: Optional[tuple] = None + self._formdata: list[str] | None = None + self._data: tuple | None = None @property - def data(self) -> Optional[tuple]: + def data(self) -> tuple | None: formdata = self._formdata if formdata is not None: data = [] @@ -262,7 +264,7 @@ def data(self, data: tuple) -> None: self._data = data self._formdata = None - def iter_choices(self) -> Generator[Tuple[str, Any, bool, Dict], None, None]: + def iter_choices(self) -> Generator[tuple[str, Any, bool, dict], None, None]: if self.data is not None: primary_keys = ( self.data @@ -272,7 +274,7 @@ def iter_choices(self) -> Generator[Tuple[str, Any, bool, Dict], None, None]: for pk, label in self._select_data: yield (pk, self.get_label(label), pk in primary_keys, {}) - def process_formdata(self, valuelist: List[str]) -> None: + def process_formdata(self, valuelist: list[str]) -> None: self._formdata = list(set(valuelist)) def pre_validate(self, form: Form) -> None: @@ -292,8 +294,8 @@ class AjaxSelectField(fields.SelectFieldBase): def __init__( self, loader: QueryAjaxModelLoader, - label: Optional[str] = None, - validators: Optional[list] = None, + label: str | None = None, + validators: list | None = None, allow_blank: bool = False, **kwargs: Any, ) -> None: @@ -334,9 +336,9 @@ class AjaxSelectMultipleField(fields.SelectFieldBase): def __init__( self, loader: QueryAjaxModelLoader, - label: Optional[str] = None, - validators: Optional[list] = None, - default: Optional[list] = None, + label: str | None = None, + validators: list | None = None, + default: list | None = None, allow_blank: bool = False, **kwargs: Any, ) -> None: @@ -344,7 +346,7 @@ def __init__( self.loader = loader self.allow_blank = allow_blank default = default or [] - self._formdata: Set[Any] = set() + self._formdata: set[Any] = set() super().__init__(label, validators, default=default, **kwargs) @@ -377,7 +379,7 @@ def pre_validate(self, form: Form) -> None: def process_formdata(self, valuelist: list) -> None: self.data = valuelist - def process_data(self, value: Optional[list]) -> None: + def process_data(self, value: list | None) -> None: self.data = value or [] diff --git a/sqladmin/forms.py b/sqladmin/forms.py index c6c62f0a..1328e516 100644 --- a/sqladmin/forms.py +++ b/sqladmin/forms.py @@ -1,18 +1,15 @@ """ The converters are from Flask-Admin project. """ +from __future__ import annotations + import enum import inspect import sys from typing import ( Any, Callable, - Dict, - List, - Optional, Sequence, - Tuple, - Type, TypeVar, Union, no_type_check, @@ -85,7 +82,7 @@ def __call__( self, model: type, prop: MODEL_PROPERTY, - kwargs: Dict[str, Any], + kwargs: dict[str, Any], ) -> UnboundField: ... # pragma: no cover @@ -107,7 +104,7 @@ def _inner(func: T_CC) -> T_CC: class ModelConverterBase: - _converters: Dict[str, ConverterCallable] = {} + _converters: dict[str, ConverterCallable] = {} def __init__(self) -> None: super().__init__() @@ -128,12 +125,12 @@ async def _prepare_kwargs( self, prop: MODEL_PROPERTY, session_maker: sessionmaker, - field_args: Dict[str, Any], - field_widget_args: Dict[str, Any], + field_args: dict[str, Any], + field_widget_args: dict[str, Any], form_include_pk: bool, - label: Optional[str] = None, - loader: Optional[QueryAjaxModelLoader] = None, - ) -> Optional[Dict[str, Any]]: + label: str | None = None, + loader: QueryAjaxModelLoader | None = None, + ) -> dict[str, Any] | None: if not isinstance(prop, (RelationshipProperty, ColumnProperty)): return None @@ -205,7 +202,7 @@ async def _prepare_relationship( prop: RelationshipProperty, kwargs: dict, session_maker: sessionmaker, - loader: Optional[QueryAjaxModelLoader] = None, + loader: QueryAjaxModelLoader | None = None, ) -> dict: nullable = True for pair in prop.local_remote_pairs: @@ -225,7 +222,7 @@ async def _prepare_select_options( self, prop: RelationshipProperty, session_maker: sessionmaker, - ) -> List[Tuple[str, Any]]: + ) -> list[tuple[str, Any]]: target_model = prop.mapper.class_ stmt = select(target_model) @@ -283,13 +280,13 @@ async def convert( model: type, prop: MODEL_PROPERTY, session_maker: sessionmaker, - field_args: Dict[str, Any], - field_widget_args: Dict[str, Any], + field_args: dict[str, Any], + field_widget_args: dict[str, Any], form_include_pk: bool, - label: Optional[str] = None, - override: Optional[Type[Field]] = None, - form_ajax_refs: Dict[str, QueryAjaxModelLoader] = {}, - ) -> Optional[UnboundField]: + label: str | None = None, + override: type[Field] | None = None, + form_ajax_refs: dict[str, QueryAjaxModelLoader] = {}, + ) -> UnboundField: loader = form_ajax_refs.get(prop.key) kwargs = await self._prepare_kwargs( prop=prop, @@ -329,7 +326,7 @@ def _get_identifier_value(self, o: Any) -> str: class ModelConverter(ModelConverterBase): @staticmethod - def _string_common(prop: ColumnProperty) -> List[Validator]: + def _string_common(prop: ColumnProperty) -> list[Validator]: li = [] column: Column = prop.columns[0] if isinstance(column.type.length, int) and column.type.length: @@ -338,7 +335,7 @@ def _string_common(prop: ColumnProperty) -> List[Validator]: @converts("String", "CHAR") # includes Unicode def conv_string( - self, model: type, prop: ColumnProperty, kwargs: Dict[str, Any] + self, model: type, prop: ColumnProperty, kwargs: dict[str, Any] ) -> UnboundField: extra_validators = self._string_common(prop) kwargs.setdefault("validators", []) @@ -347,7 +344,7 @@ def conv_string( @converts("Text", "LargeBinary", "Binary") # includes UnicodeText def conv_text( - self, model: type, prop: ColumnProperty, kwargs: Dict[str, Any] + self, model: type, prop: ColumnProperty, kwargs: dict[str, Any] ) -> UnboundField: kwargs.setdefault("validators", []) extra_validators = self._string_common(prop) @@ -356,7 +353,7 @@ def conv_text( @converts("Boolean", "dialects.mssql.base.BIT") def conv_boolean( - self, model: type, prop: ColumnProperty, kwargs: Dict[str, Any] + self, model: type, prop: ColumnProperty, kwargs: dict[str, Any] ) -> UnboundField: if not prop.columns[0].nullable: kwargs.setdefault("render_kw", {}) @@ -370,25 +367,25 @@ def conv_boolean( @converts("Date") def conv_date( - self, model: type, prop: ColumnProperty, kwargs: Dict[str, Any] + self, model: type, prop: ColumnProperty, kwargs: dict[str, Any] ) -> UnboundField: return DateField(**kwargs) @converts("Time") def conv_time( - self, model: type, prop: ColumnProperty, kwargs: Dict[str, Any] + self, model: type, prop: ColumnProperty, kwargs: dict[str, Any] ) -> UnboundField: return TimeField(**kwargs) @converts("DateTime") def conv_datetime( - self, model: type, prop: ColumnProperty, kwargs: Dict[str, Any] + self, model: type, prop: ColumnProperty, kwargs: dict[str, Any] ) -> UnboundField: return DateTimeField(**kwargs) @converts("Enum") def conv_enum( - self, model: type, prop: ColumnProperty, kwargs: Dict[str, Any] + self, model: type, prop: ColumnProperty, kwargs: dict[str, Any] ) -> UnboundField: available_choices = [(e, e) for e in prop.columns[0].type.enums] accepted_values = [choice[0] for choice in available_choices] @@ -408,13 +405,13 @@ def conv_enum( @converts("Integer") # includes BigInteger and SmallInteger def conv_integer( - self, model: type, prop: ColumnProperty, kwargs: Dict[str, Any] + self, model: type, prop: ColumnProperty, kwargs: dict[str, Any] ) -> UnboundField: return IntegerField(**kwargs) @converts("Numeric") # includes DECIMAL, Float/FLOAT, REAL, and DOUBLE def conv_decimal( - self, model: type, prop: ColumnProperty, kwargs: Dict[str, Any] + self, model: type, prop: ColumnProperty, kwargs: dict[str, Any] ) -> UnboundField: # override default decimal places limit, use database defaults instead kwargs.setdefault("places", None) @@ -422,13 +419,13 @@ def conv_decimal( @converts("JSON", "JSONB") def conv_json( - self, model: type, prop: ColumnProperty, kwargs: Dict[str, Any] + self, model: type, prop: ColumnProperty, kwargs: dict[str, Any] ) -> UnboundField: return JSONField(**kwargs) @converts("Interval") def conv_interval( - self, model: type, prop: ColumnProperty, kwargs: Dict[str, Any] + self, model: type, prop: ColumnProperty, kwargs: dict[str, Any] ) -> UnboundField: kwargs["render_kw"]["placeholder"] = "Like: 1 day 1:25:33.652" return IntervalField(**kwargs) @@ -439,7 +436,7 @@ def conv_interval( "sqlalchemy_utils.types.ip_address.IPAddressType", ) def conv_ip_address( - self, model: type, prop: ColumnProperty, kwargs: Dict[str, Any] + self, model: type, prop: ColumnProperty, kwargs: dict[str, Any] ) -> UnboundField: kwargs.setdefault("validators", []) kwargs["validators"].append(validators.IPAddress(ipv4=True, ipv6=True)) @@ -450,7 +447,7 @@ def conv_ip_address( "sqlalchemy.dialects.postgresql.types.MACADDR", ) def conv_mac_address( - self, model: type, prop: ColumnProperty, kwargs: Dict[str, Any] + self, model: type, prop: ColumnProperty, kwargs: dict[str, Any] ) -> UnboundField: kwargs.setdefault("validators", []) kwargs["validators"].append(validators.MacAddress()) @@ -463,7 +460,7 @@ def conv_mac_address( "sqlalchemy_utils.types.uuid.UUIDType", ) def conv_uuid( - self, model: type, prop: ColumnProperty, kwargs: Dict[str, Any] + self, model: type, prop: ColumnProperty, kwargs: dict[str, Any] ) -> UnboundField: kwargs.setdefault("validators", []) kwargs["validators"].append(validators.UUID()) @@ -473,13 +470,13 @@ def conv_uuid( "sqlalchemy.dialects.postgresql.base.ARRAY", "sqlalchemy.sql.sqltypes.ARRAY" ) def conv_ARRAY( - self, model: type, prop: ColumnProperty, kwargs: Dict[str, Any] + self, model: type, prop: ColumnProperty, kwargs: dict[str, Any] ) -> UnboundField: return Select2TagsField(**kwargs) @converts("sqlalchemy_utils.types.email.EmailType") def conv_email( - self, model: type, prop: ColumnProperty, kwargs: Dict[str, Any] + self, model: type, prop: ColumnProperty, kwargs: dict[str, Any] ) -> UnboundField: kwargs.setdefault("validators", []) kwargs["validators"].append(validators.Email()) @@ -487,7 +484,7 @@ def conv_email( @converts("sqlalchemy_utils.types.url.URLType") def conv_url( - self, model: type, prop: ColumnProperty, kwargs: Dict[str, Any] + self, model: type, prop: ColumnProperty, kwargs: dict[str, Any] ) -> UnboundField: kwargs.setdefault("validators", []) kwargs["validators"].append(validators.URL()) @@ -495,7 +492,7 @@ def conv_url( @converts("sqlalchemy_utils.types.currency.CurrencyType") def conv_currency( - self, model: type, prop: ColumnProperty, kwargs: Dict[str, Any] + self, model: type, prop: ColumnProperty, kwargs: dict[str, Any] ) -> UnboundField: kwargs.setdefault("validators", []) kwargs["validators"].append(CurrencyValidator()) @@ -503,7 +500,7 @@ def conv_currency( @converts("sqlalchemy_utils.types.timezone.TimezoneType") def conv_timezone( - self, model: type, prop: ColumnProperty, kwargs: Dict[str, Any] + self, model: type, prop: ColumnProperty, kwargs: dict[str, Any] ) -> UnboundField: kwargs.setdefault("validators", []) kwargs["validators"].append( @@ -513,7 +510,7 @@ def conv_timezone( @converts("sqlalchemy_utils.types.phone_number.PhoneNumberType") def conv_phone_number( - self, model: type, prop: ColumnProperty, kwargs: Dict[str, Any] + self, model: type, prop: ColumnProperty, kwargs: dict[str, Any] ) -> UnboundField: kwargs.setdefault("validators", []) kwargs["validators"].append(PhoneNumberValidator()) @@ -521,7 +518,7 @@ def conv_phone_number( @converts("sqlalchemy_utils.types.color.ColorType") def conv_color( - self, model: type, prop: ColumnProperty, kwargs: Dict[str, Any] + self, model: type, prop: ColumnProperty, kwargs: dict[str, Any] ) -> UnboundField: kwargs.setdefault("validators", []) kwargs["validators"].append(ColorValidator()) @@ -530,7 +527,7 @@ def conv_color( @converts("sqlalchemy_utils.types.choice.ChoiceType") @no_type_check def convert_choice_type( - self, model: type, prop: ColumnProperty, kwargs: Dict[str, Any] + self, model: type, prop: ColumnProperty, kwargs: dict[str, Any] ) -> UnboundField: available_choices = [] column = prop.columns[0] @@ -559,32 +556,32 @@ def convert_choice_type( @converts("fastapi_storages.integrations.sqlalchemy.FileType") def conv_file( - self, model: type, prop: ColumnProperty, kwargs: Dict[str, Any] + self, model: type, prop: ColumnProperty, kwargs: dict[str, Any] ) -> UnboundField: return FileField(**kwargs) @converts("fastapi_storages.integrations.sqlalchemy.ImageType") def conv_image( - self, model: type, prop: ColumnProperty, kwargs: Dict[str, Any] + self, model: type, prop: ColumnProperty, kwargs: dict[str, Any] ) -> UnboundField: return FileField(**kwargs) @converts("ONETOONE") def conv_one_to_one( - self, model: type, prop: RelationshipProperty, kwargs: Dict[str, Any] + self, model: type, prop: RelationshipProperty, kwargs: dict[str, Any] ) -> UnboundField: kwargs["allow_blank"] = True return QuerySelectField(**kwargs) @converts("MANYTOONE") def conv_many_to_one( - self, model: type, prop: RelationshipProperty, kwargs: Dict[str, Any] + self, model: type, prop: RelationshipProperty, kwargs: dict[str, Any] ) -> UnboundField: return QuerySelectField(**kwargs) @converts("MANYTOMANY", "ONETOMANY") def conv_many_to_many( - self, model: type, prop: RelationshipProperty, kwargs: Dict[str, Any] + self, model: type, prop: RelationshipProperty, kwargs: dict[str, Any] ) -> UnboundField: return QuerySelectMultipleField(**kwargs) @@ -592,17 +589,17 @@ def conv_many_to_many( async def get_model_form( model: type, session_maker: sessionmaker, - only: Optional[Sequence[str]] = None, - exclude: Optional[Sequence[str]] = None, - column_labels: Optional[Dict[str, str]] = None, - form_args: Optional[Dict[str, Dict[str, Any]]] = None, - form_widget_args: Optional[Dict[str, Dict[str, Any]]] = None, - form_class: Type[Form] = Form, - form_overrides: Optional[Dict[str, Type[Field]]] = None, - form_ajax_refs: Optional[Dict[str, QueryAjaxModelLoader]] = None, + only: Sequence[str] | None = None, + exclude: Sequence[str] | None = None, + column_labels: dict[str, str] | None = None, + form_args: dict[str, dict[str, Any]] | None = None, + form_widget_args: dict[str, dict[str, Any]] | None = None, + form_class: type[Form] = Form, + form_overrides: dict[str, type[Field]] | None = None, + form_ajax_refs: dict[str, QueryAjaxModelLoader] | None = None, form_include_pk: bool = False, - form_converter: Type[ModelConverterBase] = ModelConverter, -) -> Type[Form]: + form_converter: type[ModelConverterBase] = ModelConverter, +) -> type[Form]: type_name = model.__name__ + "Form" converter = form_converter() mapper = sqlalchemy_inspect(model) diff --git a/sqladmin/helpers.py b/sqladmin/helpers.py index 3e2459ef..99cc08c5 100644 --- a/sqladmin/helpers.py +++ b/sqladmin/helpers.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import csv import enum import os @@ -9,11 +11,7 @@ Any, AsyncGenerator, Callable, - Dict, Generator, - List, - Optional, - Tuple, TypeVar, ) @@ -136,11 +134,11 @@ class Writer(ABC): """https://docs.python.org/3/library/csv.html#writer-objects""" @abstractmethod - def writerow(self, row: List[str]) -> None: + def writerow(self, row: list[str]) -> None: pass # pragma: no cover @abstractmethod - def writerows(self, rows: List[List[str]]) -> None: + def writerows(self, rows: list[list[str]]) -> None: pass # pragma: no cover @property @@ -174,7 +172,7 @@ def stream_to_csv( return callback(writer) # type: ignore -def get_primary_keys(model: Any) -> Tuple[Column, ...]: +def get_primary_keys(model: Any) -> tuple[Column, ...]: return tuple(inspect(model).mapper.primary_key) @@ -191,7 +189,7 @@ def get_object_identifier(obj: Any) -> Any: return ";".join(str(v).replace("\\", "\\\\").replace(";", r"\;") for v in values) -def _object_identifier_parts(id_string: str, model: type) -> Tuple[str, ...]: +def _object_identifier_parts(id_string: str, model: type) -> tuple[str, ...]: pks = get_primary_keys(model) if len(pks) == 1: # Only one primary key so no special processing @@ -255,7 +253,7 @@ def is_relationship(prop: MODEL_PROPERTY) -> bool: return isinstance(prop, RelationshipProperty) -def parse_interval(value: str) -> Optional[timedelta]: +def parse_interval(value: str) -> timedelta | None: match = ( standard_duration_re.match(value) or iso8601_duration_re.match(value) @@ -265,7 +263,7 @@ def parse_interval(value: str) -> Optional[timedelta]: if not match: return None - kw: Dict[str, Any] = match.groupdict() + kw: dict[str, Any] = match.groupdict() sign = -1 if kw.pop("sign", "+") == "-" else 1 if kw.get("microseconds"): kw["microseconds"] = kw["microseconds"].ljust(6, "0") diff --git a/sqladmin/models.py b/sqladmin/models.py index ac50ce45..42ab23a3 100644 --- a/sqladmin/models.py +++ b/sqladmin/models.py @@ -848,7 +848,7 @@ async def get_object_for_details(self, value: Any) -> Any: return await self._get_object_by_pk(stmt) async def get_object_for_edit(self, request: Request) -> Any: - stmt = self.edit_form_query(request) + stmt = self.form_edit_query(request) return await self._get_object_by_pk(stmt) async def get_object_for_delete(self, value: Any) -> Any: diff --git a/sqladmin/pagination.py b/sqladmin/pagination.py index 36754ef8..cb6ba562 100644 --- a/sqladmin/pagination.py +++ b/sqladmin/pagination.py @@ -1,5 +1,7 @@ +from __future__ import annotations + from dataclasses import dataclass, field -from typing import Any, List +from typing import Any from starlette.datastructures import URL @@ -12,11 +14,11 @@ class PageControl: @dataclass class Pagination: - rows: List[Any] + rows: list[Any] page: int page_size: int count: int - page_controls: List[PageControl] = field(default_factory=list) + page_controls: list[PageControl] = field(default_factory=list) max_page_controls: int = 7 @property diff --git a/sqladmin/templating.py b/sqladmin/templating.py index d48d546d..3e175350 100644 --- a/sqladmin/templating.py +++ b/sqladmin/templating.py @@ -1,4 +1,6 @@ -from typing import Any, Dict, Mapping, Optional +from __future__ import annotations + +from typing import Any, Mapping import jinja2 from starlette.background import BackgroundTask @@ -13,11 +15,11 @@ def __init__( self, template: jinja2.Template, content: str, - context: Dict, + context: dict, status_code: int = 200, - headers: Optional[Mapping[str, str]] = None, - media_type: Optional[str] = None, - background: Optional[BackgroundTask] = None, + headers: Mapping[str, str] | None = None, + media_type: str | None = None, + background: BackgroundTask | None = None, ): self.template = template self.context = context @@ -42,7 +44,7 @@ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: class Jinja2Templates: def __init__(self, directory: str) -> None: @jinja2.pass_context - def url_for(context: Dict, __name: str, **path_params: Any) -> URL: + def url_for(context: dict, __name: str, **path_params: Any) -> URL: request = context["request"] return request.url_for(__name, **path_params) @@ -54,7 +56,7 @@ async def TemplateResponse( self, request: Request, name: str, - context: Optional[Dict] = None, + context: dict | None = None, status_code: int = 200, ) -> _TemplateResponse: context = context or {} diff --git a/tests/test_models.py b/tests/test_models.py index c9e64318..71a9f98f 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -382,7 +382,7 @@ def list_query(self, request: Request) -> Select: assert len(await view.get_model_objects(request)) == 1 -async def test_edit_form_query() -> None: +async def test_form_edit_query() -> None: session = session_maker() batman = User(id=123, name="batman") batcave = Address(user=batman, name="bat cave") @@ -396,7 +396,7 @@ class UserAdmin(ModelView, model=User): async_engine = False session_maker = session_maker - def edit_form_query(self, request: Request) -> Select: + def form_edit_query(self, request: Request) -> Select: return ( select(self.model) .join(Address) From 7a8bbb79d5a8c236a95c30c363408709653e2230 Mon Sep 17 00:00:00 2001 From: Amin Alaee Date: Thu, 20 Jun 2024 14:29:42 +0200 Subject: [PATCH 16/17] Add cookbook: Working with passwords (#783) --- docs/cookbook/working_with_passwords.md | 48 +++++++++++++++++++++++++ mkdocs.yml | 1 + 2 files changed, 49 insertions(+) create mode 100644 docs/cookbook/working_with_passwords.md diff --git a/docs/cookbook/working_with_passwords.md b/docs/cookbook/working_with_passwords.md new file mode 100644 index 00000000..37ed1200 --- /dev/null +++ b/docs/cookbook/working_with_passwords.md @@ -0,0 +1,48 @@ +It's a comment use-case that you have a model +with a `Password` field which needs a custom behaviour. + +Let's say you have the following `User` model: + +```py +class User(Base): + __tablename__ = "user" + + id: Mapped[int] = mapped_column(primary_key=True) + name: Mapped[str] = mapped_column(String(50)) + hashed_password: Mapped[str] = mapped_column(String) +``` + +In this specific case we want the following features to be available in the Admin console: + +- We only want `hashed_password` when creating a new `User`, and we want to hide it when editing a `User`. +- When a User is created, the password should be hashed and stored in the `hashed_password` column. + +So we define the following `UserAdmin` class for it: + +```py +class UserAdmin(ModelView, model=User): + column_labels = {"hashed_password": "password"} + form_create_rules = ["name", "hashed_password"] + form_edit_rules = ["name"] + + async def on_model_change(self, data, model, is_created, request) -> None: + if is_created: + # Hash the password before saving into DB ! + data["hashed_password"] = data["hashed_password"] + "_hashed" + +``` + +So let's see what is happening. + +The `column_labels` is just saying to rename `hashed_password` to `password` when displaying or creating a form for the `User`. + +Next we have defined two extra attributes called `form_create_rules` and `form_edit_rules` which +controls how the create and edit forms are created. + +In the `form_create_rules` declaration we specify we want `name` and `hashed_password` when creating a `User`. + +But in `form_edit_rules` we specifically excluded `hashed_password` so we only want to edit `name` of the `User`. + +And finally the last step is to hash the password before saving into the database. +There could be a few options to do this, but in this case we are overriding `on_model_change` and only hashing the password +when we are creating a `User`. diff --git a/mkdocs.yml b/mkdocs.yml index cc071cd0..427ea422 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -27,6 +27,7 @@ nav: - Using a request object: "cookbook/using_request_object.md" - Multiple databases: "cookbook/multiple_databases.md" - Using rich text editor: "cookbook/using_wysiwyg.md" + - Working with Passwords: "cookbook/working_with_passwords.md" - API Reference: - Application: "api_reference/application.md" - ModelView: "api_reference/model_view.md" From 32637d15960ba22065774390acac9f6da0be86be Mon Sep 17 00:00:00 2001 From: Amin Alaee Date: Mon, 1 Jul 2024 13:00:01 +0200 Subject: [PATCH 17/17] Version 0.18.0 (#786) --- CHANGELOG.md | 13 +++++++++++++ sqladmin/__init__.py | 2 +- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f8644087..a0bf535f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,19 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## Version 0.18.0 - 2024-07-01 + +### Added + +* Add `form_rules`, `form_create_rules`, `form_edit_rules` by @aminalaee in https://github.com/aminalaee/sqladmin/pull/779 +* Add more docs for overriding default tempates by @jonocodes in https://github.com/aminalaee/sqladmin/pull/769 + +### Fixed +* Fix edit_form_query documentation example by @lukeclimen in https://github.com/aminalaee/sqladmin/pull/777 + +**Full Changelog**: https://github.com/aminalaee/sqladmin/compare/0.17.0...0.18.0 + ## Version 0.17.0 - 2024-05-13 ### Added diff --git a/sqladmin/__init__.py b/sqladmin/__init__.py index e7208617..de850b87 100644 --- a/sqladmin/__init__.py +++ b/sqladmin/__init__.py @@ -1,7 +1,7 @@ from sqladmin.application import Admin, action, expose from sqladmin.models import BaseView, ModelView -__version__ = "0.17.0" +__version__ = "0.18.0" __all__ = [ "Admin",