Skip to content

Commit 4f78e70

Browse files
support expose decorator in ModelView(#881)
Co-authored-by: jdraaijer <jelmerdraaijer@z-cert.nl>
1 parent 5709b9a commit 4f78e70

3 files changed

Lines changed: 59 additions & 5 deletions

File tree

docs/writing_custom_views.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,26 @@ admin = Admin(templates_dir="my_templates", ...)
3232

3333
Now visiting `/admin/report` you can render your `report.html` file.
3434

35+
It is also possible to use the expose decorator to add extra endpoints to a ModelView.
36+
The `path` is in this case prepended with the view's identity, in this case `/admin/user/profile/{pk}`.
37+
38+
!!! example
39+
40+
```python
41+
from sqladmin import ModelView, expose
42+
43+
class UserView(ModelView):
44+
45+
@expose("/profile/{pk}", methods=["GET"])
46+
async def profile(self, request):
47+
user: User = await self.get_object_for_edit(request)
48+
return await self.templates.TemplateResponse(
49+
request, "user.html", {"user": user}
50+
)
51+
52+
admin.add_view(UserView)
53+
```
54+
3555
### Database access
3656

3757
The example above was very basic and you probably want to access database and SQLAlchemy models in your custom view. You can use `sessionmaker` the same way SQLAdmin is using it to do so:

sqladmin/application.py

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -197,16 +197,22 @@ def _handle_expose_decorated_func(
197197
view_instance: BaseView | ModelView,
198198
) -> None:
199199
if hasattr(func, "_exposed"):
200+
if view.is_model:
201+
path = f"/{view_instance.identity}" + getattr(func, "_path")
202+
name = f"view-{view_instance.identity}-{func.__name__}"
203+
else:
204+
view.identity = getattr(func, "_identity")
205+
path = getattr(func, "_path")
206+
name = getattr(func, "_identity")
207+
200208
self.admin.add_route(
201209
route=func,
202-
path=getattr(func, "_path"),
210+
path=path,
203211
methods=getattr(func, "_methods"),
204-
name=getattr(func, "_identity"),
212+
name=name,
205213
include_in_schema=getattr(func, "_include_in_schema"),
206214
)
207215

208-
view.identity = getattr(func, "_identity")
209-
210216
def add_model_view(self, view: type[ModelView]) -> None:
211217
"""Add ModelView to the Admin.
212218
@@ -233,6 +239,11 @@ class UserAdmin(ModelView, model=User):
233239
self._find_decorated_funcs(
234240
view, view_instance, self._handle_action_decorated_func
235241
)
242+
243+
self._find_decorated_funcs(
244+
view, view_instance, self._handle_expose_decorated_func
245+
)
246+
236247
self._views.append(view_instance)
237248
self._build_menu(view_instance)
238249

tests/test_models.py

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,17 @@
22
from typing import Generator
33

44
import pytest
5+
from jinja2 import TemplateNotFound
56
from markupsafe import Markup
67
from sqlalchemy import Boolean, Column, Enum, ForeignKey, Integer, String, select
78
from sqlalchemy.dialects.postgresql import UUID
89
from sqlalchemy.orm import contains_eager, declarative_base, relationship, sessionmaker
910
from sqlalchemy.sql.expression import Select
1011
from starlette.applications import Starlette
1112
from starlette.requests import Request
13+
from starlette.testclient import TestClient
1214

13-
from sqladmin import Admin, ModelView
15+
from sqladmin import Admin, ModelView, expose
1416
from sqladmin.exceptions import InvalidModelError
1517
from sqladmin.helpers import get_column_python_type
1618
from tests.common import sync_engine as engine
@@ -77,6 +79,12 @@ def prepare_database() -> Generator[None, None, None]:
7779
Base.metadata.drop_all(engine)
7880

7981

82+
@pytest.fixture
83+
def client() -> Generator[TestClient, None, None]:
84+
with TestClient(app=app, base_url="http://testserver") as c:
85+
yield c
86+
87+
8088
def test_metadata_setup() -> None:
8189
class UserAdmin(ModelView, model=User):
8290
pass
@@ -478,3 +486,18 @@ class AddressAdmin(ModelView, model=Address):
478486
stmt = AddressAdmin().search_query(select(Address), "example")
479487
assert "lower(CAST(users.name AS VARCHAR))" in str(stmt)
480488
assert "lower(CAST(profiles.role AS VARCHAR))" in str(stmt)
489+
490+
491+
def test_expose_decorator(client: TestClient) -> None:
492+
class UserAdmin(ModelView, model=User):
493+
@expose("/profile/{pk}")
494+
async def profile(self, request: Request):
495+
user: User = await self.get_object_for_edit(request)
496+
return await self.templates.TemplateResponse(
497+
request, "user.html", {"user": user}
498+
)
499+
500+
admin.add_view(UserAdmin)
501+
502+
with pytest.raises(TemplateNotFound, match="user.html"):
503+
client.get("/admin/user/profile/1")

0 commit comments

Comments
 (0)