From 349798dd903d31183c03c2541989b195eec67be4 Mon Sep 17 00:00:00 2001 From: Maxim Dementyev Date: Tue, 7 Apr 2026 21:00:20 +0300 Subject: [PATCH 1/4] Test implementation multiple admin panels --- sqladmin/_menu.py | 6 +- sqladmin/application.py | 19 ++-- sqladmin/authentication.py | 6 +- sqladmin/helpers.py | 29 +++++ sqladmin/models.py | 23 ++-- sqladmin/templates/sqladmin/base.html | 26 ++--- sqladmin/templates/sqladmin/create.html | 4 +- sqladmin/templates/sqladmin/details.html | 8 +- sqladmin/templates/sqladmin/edit.html | 4 +- sqladmin/templates/sqladmin/layout.html | 4 +- sqladmin/templates/sqladmin/list.html | 16 +-- sqladmin/templates/sqladmin/login.html | 2 +- sqladmin/templating.py | 4 +- tests/test_ajax.py | 8 +- tests/test_application.py | 10 +- tests/test_authentication.py | 3 +- tests/test_forms/test_forms.py | 2 +- tests/test_menu.py | 10 +- tests/test_models.py | 128 ++++++++++++++--------- tests/test_models_action.py | 14 +-- tests/test_pretty_export.py | 39 +++---- 21 files changed, 214 insertions(+), 151 deletions(-) diff --git a/sqladmin/_menu.py b/sqladmin/_menu.py index 057528b5..a4d8cab6 100644 --- a/sqladmin/_menu.py +++ b/sqladmin/_menu.py @@ -8,6 +8,8 @@ if TYPE_CHECKING: from sqladmin.application import BaseView, ModelView +from sqladmin.helpers import local_url_for + class ItemMenu: def __init__(self, name: str, icon: str | None = None) -> None: @@ -73,8 +75,8 @@ def is_active(self, request: Request) -> bool: 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}") + return local_url_for(request, "list", identity=self.view.identity) + return local_url_for(request, self.view.identity) @property def display_name(self) -> str: diff --git a/sqladmin/application.py b/sqladmin/application.py index ac0af92d..deb307cf 100644 --- a/sqladmin/application.py +++ b/sqladmin/application.py @@ -41,6 +41,7 @@ from sqladmin.helpers import ( get_object_identifier, is_async_session_maker, + local_url_for, slugify_action_name, ) from sqladmin.models import BaseView, ModelView @@ -236,13 +237,12 @@ class UserAdmin(ModelView, model=User): view._admin_ref = self # Set database engine from Admin instance - view.session_maker = self.session_maker view.is_async = self.is_async view.ajax_lookup_url = urljoin( self.base_url + "/", f"{view.identity}/ajax/lookup" ) view.templates = self.templates - view_instance = view() + view_instance = view(self.session_maker) self._find_decorated_funcs( view, view_instance, self._handle_action_decorated_func @@ -367,6 +367,7 @@ def __init__( # type: ignore[no-any-unimported] debug: bool = False, templates_dir: str = "templates", authentication_backend: AuthenticationBackend | None = None, + mount_name: str = "admin", ) -> None: """ Args: @@ -446,7 +447,7 @@ async def http_exception( self.admin.router.routes = routes self.admin.exception_handlers = {HTTPException: http_exception} self.admin.debug = debug - self.app.mount(base_url, app=self.admin, name="admin") + self.app.mount(base_url, app=self.admin, name=mount_name) @login_required async def index(self, request: Request) -> Response: @@ -519,7 +520,7 @@ async def delete(self, request: Request) -> Response: referer_url = URL(request.headers.get("referer", "")) referer_params = MultiDict(parse_qsl(referer_url.query)) - url = URL(str(request.url_for("admin:list", identity=identity))) + url = URL(str(local_url_for(request, "list", identity=identity))) url = url.include_query_params(**referer_params) return PlainTextResponse(content=str(url)) @@ -658,7 +659,7 @@ async def login(self, request: Request) -> Response: request, "sqladmin/login.html", context, status_code=400 ) - return RedirectResponse(request.url_for("admin:index"), status_code=302) + return RedirectResponse(local_url_for(request, "index"), status_code=302) async def logout(self, request: Request) -> Response: if self.authentication_backend is None: @@ -672,7 +673,7 @@ async def logout(self, request: Request) -> Response: if isinstance(response, Response): return response - return RedirectResponse(request.url_for("admin:index"), status_code=302) + return RedirectResponse(local_url_for(request, "index"), status_code=302) async def ajax_lookup(self, request: Request) -> Response: """Ajax lookup route.""" @@ -706,14 +707,14 @@ def get_save_redirect_url( identifier = get_object_identifier(obj) if form.get("save") == "Save": - return request.url_for("admin:list", identity=identity) + return local_url_for(request, "list", identity=identity) if form.get("save") == "Save and continue editing" or ( form.get("save") == "Save as new" and model_view.save_as_continue ): - return request.url_for("admin:edit", identity=identity, pk=identifier) + return local_url_for(request, "edit", identity=identity, pk=identifier) - return request.url_for("admin:create", identity=identity) + return local_url_for(request, "create", identity=identity) async def _handle_form_data(self, request: Request, obj: Any = None) -> FormData: """ diff --git a/sqladmin/authentication.py b/sqladmin/authentication.py index 3238402f..e4a8746b 100644 --- a/sqladmin/authentication.py +++ b/sqladmin/authentication.py @@ -8,6 +8,8 @@ from starlette.requests import Request from starlette.responses import RedirectResponse, Response +from sqladmin.helpers import local_url_for + class AuthenticationBackend: """Base class for implementing the Authentication into SQLAdmin. @@ -66,7 +68,9 @@ async def wrapper_decorator(*args: Any, **kwargs: Any) -> Any: if isinstance(response, Response): return response if not bool(response): - return RedirectResponse(request.url_for("admin:login"), status_code=302) + return RedirectResponse( + local_url_for(request, "login"), status_code=302 + ) if inspect.iscoroutinefunction(func): return await func(*args, **kwargs) diff --git a/sqladmin/helpers.py b/sqladmin/helpers.py index a7b9b320..ecc398dc 100644 --- a/sqladmin/helpers.py +++ b/sqladmin/helpers.py @@ -18,6 +18,9 @@ from sqlalchemy import Column, inspect from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import RelationshipProperty, sessionmaker +from starlette.datastructures import URL +from starlette.requests import Request +from starlette.routing import Mount, Router from sqladmin._types import MODEL_PROPERTY @@ -322,3 +325,29 @@ def choice_coerce(value: Any) -> Any: def is_async_session_maker(session_maker: sessionmaker) -> bool: return AsyncSession in session_maker.class_.__mro__ + + +def local_url_for(request: Request, name: str, **kwargs: Any) -> URL: + target_router = request.app.router + start_router = request.scope["router"] + if start_router == target_router: + return request.url_for(name, **kwargs) + router_name = get_current_router_name(start_router, target_router) + return request.url_for( + f"{f'{router_name}:' if router_name else ''}{name}", **kwargs + ) + + +def get_current_router_name(start_router: Any, target_router: Router) -> str | None: + for router in getattr(start_router, "routes", []): + if router == target_router: + return router.name + if not isinstance(router, Mount): + continue + app_router = getattr(router.app, "router", None) + if app_router == target_router: + return router.name + sub_name = get_current_router_name(router, target_router) + if sub_name is not None: + return f"{router.name}:{sub_name}" + return None diff --git a/sqladmin/models.py b/sqladmin/models.py index c1fdec49..e7c819c6 100644 --- a/sqladmin/models.py +++ b/sqladmin/models.py @@ -51,6 +51,7 @@ Writer, get_object_identifier, get_primary_keys, + local_url_for, object_identifier_values, prettify_class_name, secure_filename, @@ -210,12 +211,6 @@ class UserAdmin(ModelView, model=User): # Internals pk_columns: ClassVar[Tuple[Column]] - session_maker: ClassVar[ # type: ignore[no-any-unimported] - Union[ - sessionmaker, - "async_sessionmaker", - ] - ] is_async: ClassVar[bool] = False is_model: ClassVar[bool] = True ajax_lookup_url: ClassVar[str] = "" @@ -703,7 +698,10 @@ class UserAdmin(ModelView, model=User): ``` """ - def __init__(self) -> None: + def __init__( + self, session_maker: Union[sessionmaker, "async_sessionmaker"] + ) -> None: + self.session_maker = session_maker self._mapper = inspect(self.model) self._prop_names = [attr.key for attr in self._mapper.attrs] self._relations = [ @@ -795,8 +793,8 @@ async def _run_query(self, stmt: ClauseElement) -> Any: def _url_for_delete(self, request: Request, obj: Any) -> str: pk = get_object_identifier(obj) query_params = urlencode({"pks": pk}) - url = request.url_for( - "admin:delete", identity=slugify_class_name(obj.__class__.__name__) + url = local_url_for( + request, "delete", identity=slugify_class_name(obj.__class__.__name__) ) return str(url) + "?" + query_params @@ -804,13 +802,14 @@ def _url_for_details_with_prop(self, request: Request, obj: Any, prop: str) -> U target = getattr(obj, prop, None) if target is None: return URL() - return self._build_url_for("admin:details", request, target) + return self._build_url_for("details", request, target) def _url_for_action(self, request: Request, action_name: str) -> str: - return str(request.url_for(f"admin:action-{self.identity}-{action_name}")) + return str(local_url_for(request, f"action-{self.identity}-{action_name}")) def _build_url_for(self, name: str, request: Request, obj: Any) -> URL: - return request.url_for( + return local_url_for( + request, name, identity=slugify_class_name(obj.__class__.__name__), pk=get_object_identifier(obj), diff --git a/sqladmin/templates/sqladmin/base.html b/sqladmin/templates/sqladmin/base.html index 2524648b..7b9ba0a5 100644 --- a/sqladmin/templates/sqladmin/base.html +++ b/sqladmin/templates/sqladmin/base.html @@ -4,12 +4,12 @@ - - - - - - + + + + + + {% if admin.favicon_url %} {% endif %} @@ -25,13 +25,13 @@ {% endblock %} {% endblock %} - - - - - - - + + + + + + + {% block tail %} {% endblock %} diff --git a/sqladmin/templates/sqladmin/create.html b/sqladmin/templates/sqladmin/create.html index e5557979..9234fd21 100644 --- a/sqladmin/templates/sqladmin/create.html +++ b/sqladmin/templates/sqladmin/create.html @@ -7,7 +7,7 @@

New {{ model_view.name }}

-
{% if error %} @@ -19,7 +19,7 @@

New {{ model_view.name }}

diff --git a/sqladmin/templates/sqladmin/details.html b/sqladmin/templates/sqladmin/details.html index 8e1958a2..f1cb9e73 100644 --- a/sqladmin/templates/sqladmin/details.html +++ b/sqladmin/templates/sqladmin/details.html @@ -29,9 +29,9 @@

{% for elem, formatted_elem in zip(value, formatted_value) %} {% if model_view.show_compact_lists %} - ({{ formatted_elem }}) + ({{ formatted_elem }}) {% else %} - {{ formatted_elem }}
+ {{ formatted_elem }}
{% endif %} {% endfor %} @@ -50,7 +50,7 @@