Skip to content

Commit

Permalink
Allow using related model fields in list/details page (#653)
Browse files Browse the repository at this point in the history
  • Loading branch information
aminalaee authored Oct 23, 2023
1 parent 7f98575 commit 5d0a2ec
Show file tree
Hide file tree
Showing 8 changed files with 63 additions and 77 deletions.
10 changes: 8 additions & 2 deletions docs/configurations.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@ or list of the tuple for multiple columns.

```python
class UserAdmin(ModelView, model=User):
column_list = [User.id, User.name]
column_list = [User.id, User.name, "user.address.zip_code"]
column_searchable_list = [User.name]
column_sortable_list = [User.id]
column_formatters = {User.name: lambda m, a: m.name[:10]}
Expand Down Expand Up @@ -138,10 +138,16 @@ The options available are:

```python
class UserAdmin(ModelView, model=User):
column_details_list = [User.id, User.name]
column_details_list = [User.id, User.name, "user.address.zip_code"]
column_formatters_detail = {User.name: lambda m, a: m.name[:10]}
```

!!! tip

You can show related model's attributes by using a string value. For example
"user.address.zip_code" will load the relationship but it will trigger extra queries for each
relationship loading.

## Pagination options

The pagination options in the list page can be configured. The available options include:
Expand Down
80 changes: 37 additions & 43 deletions sqladmin/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
from sqlalchemy import Column, String, asc, cast, desc, func, inspect, or_
from sqlalchemy.exc import NoInspectionAvailable
from sqlalchemy.orm import joinedload, sessionmaker
from sqlalchemy.orm.exc import DetachedInstanceError
from sqlalchemy.sql.elements import ClauseElement
from sqlalchemy.sql.expression import Select, select
from starlette.datastructures import URL
Expand Down Expand Up @@ -702,22 +703,6 @@ async def _run_query(self, stmt: ClauseElement) -> Any:
else:
return await anyio.to_thread.run_sync(self._run_query_sync, stmt)

def _url_for_details(self, request: Request, obj: Any) -> Union[str, URL]:
pk = get_object_identifier(obj)
return request.url_for(
"admin:details",
identity=slugify_class_name(obj.__class__.__name__),
pk=pk,
)

def _url_for_edit(self, request: Request, obj: Any) -> Union[str, URL]:
pk = get_object_identifier(obj)
return request.url_for(
"admin:edit",
identity=slugify_class_name(obj.__class__.__name__),
pk=pk,
)

def _url_for_delete(self, request: Request, obj: Any) -> str:
pk = get_object_identifier(obj)
query_params = urlencode({"pks": pk})
Expand All @@ -726,24 +711,20 @@ def _url_for_delete(self, request: Request, obj: Any) -> str:
)
return str(url) + "?" + query_params

def _url_for_details_with_prop(
self, request: Request, obj: Any, prop: str
) -> Union[str, URL]:
target = getattr(obj, prop)
def _url_for_details_with_prop(self, request: Request, obj: Any, prop: str) -> URL:
target = getattr(obj, prop, None)
if target is None:
return ""

return request.url_for(
"admin:details",
identity=slugify_class_name(target.__class__.__name__),
pk=get_object_identifier(target),
)
return URL()
return self._build_url_for("admin: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(request.url_for(f"admin:action-{self.identity}-{action_name}"))

def _build_url_for(self, name: str, request: Request, obj: Any) -> URL:
return request.url_for(
name,
identity=slugify_class_name(obj.__class__.__name__),
pk=get_object_identifier(obj),
)

def _get_default_sort(self) -> List[Tuple[str, bool]]:
Expand Down Expand Up @@ -846,32 +827,45 @@ def _stmt_by_identifier(self, identifier: str) -> Select:
return stmt.where(*conditions)

async def get_prop_value(self, obj: Any, prop: str) -> Any:
result = getattr(obj, prop, None)
if result and isinstance(result, Enum):
result = result.name
for part in prop.split("."):
try:
obj = getattr(obj, part, None)
except DetachedInstanceError:
obj = await self._lazyload_prop(obj, part)

if obj and isinstance(obj, Enum):
obj = obj.name

return obj

return result
async def _lazyload_prop(self, obj: Any, prop: str) -> Any:
if self.is_async:
async with self.session_maker() as session:
session.add(obj)
return await session.run_sync(lambda sess: getattr(obj, prop))
else:
with self.session_maker() as session:
session.add(obj)
return await anyio.to_thread.run_sync(lambda: getattr(obj, prop))

async def get_list_value(self, obj: Any, prop: str) -> Tuple[Any, Any]:
"""Get tuple of (value, formatted_value) for the list view."""

value = await self.get_prop_value(obj, prop)
formatted_value = self._default_formatter(value)

formatter = self._list_formatters.get(prop)
if formatter:
formatted_value = formatter(obj, prop)
formatted_value = (
formatter(obj, prop) if formatter else self._default_formatter(value)
)
return value, formatted_value

async def get_detail_value(self, obj: Any, prop: str) -> Tuple[Any, Any]:
"""Get tuple of (value, formatted_value) for the detail view."""

value = await self.get_prop_value(obj, prop)
formatted_value = self._default_formatter(value)

formatter = self._detail_formatters.get(prop)
if formatter:
formatted_value = formatter(obj, prop)
formatted_value = (
formatter(obj, prop) if formatter else self._default_formatter(value)
)
return value, formatted_value

def _build_column_list(
Expand Down
4 changes: 2 additions & 2 deletions sqladmin/templates/details.html
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ <h3 class="card-title">
{% if is_list( value ) %}
<td>
{% for elem, formatted_elem in zip(value, formatted_value) %}
<a href="{{ model_view._url_for_details(request, elem) }}">({{ formatted_elem }})</a>
<a href="{{ model_view._build_url_for('admin:details', request, elem) }}">({{ formatted_elem }})</a>
{% endfor %}
</td>
{% else %}
Expand Down Expand Up @@ -58,7 +58,7 @@ <h3 class="card-title">
{% endif %}
{% if model_view.can_edit %}
<div class="col-md-1">
<a href="{{ model_view._url_for_edit(request, model) }}" class="btn btn-primary">
<a href="{{ model_view._build_url_for('admin:edit', request, model) }}" class="btn btn-primary">
Edit
</a>
</div>
Expand Down
2 changes: 1 addition & 1 deletion sqladmin/templates/edit.html
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
<h3 class="card-title">Edit {{ model_view.name }}</h3>
</div>
<div class="card-body border-bottom py-3">
<form action="{{ model_view._url_for_edit(request, obj) }}" method="POST" enctype="multipart/form-data">
<form action="{{ model_view._build_url_for('admin:edit', request, obj) }}" method="POST" enctype="multipart/form-data">
<fieldset class="form-fieldset">
{% for field in form %}
<div class="mb-3 form-group row">
Expand Down
6 changes: 3 additions & 3 deletions sqladmin/templates/list.html
Original file line number Diff line number Diff line change
Expand Up @@ -105,12 +105,12 @@ <h3 class="card-title">{{ model_view.name_plural }}</h3>
</td>
<td class="text-end">
{% if model_view.can_view_details %}
<a href="{{ model_view._url_for_details(request, row) }}" data-bs-toggle="tooltip" data-bs-placement="top" title="View">
<a href="{{ model_view._build_url_for('admin:details', request, row) }}" data-bs-toggle="tooltip" data-bs-placement="top" title="View">
<span class="me-1"><i class="fa-solid fa-eye"></i></span>
</a>
{% endif %}
{% if model_view.can_edit %}
<a href="{{ model_view._url_for_edit(request, row) }}" data-bs-toggle="tooltip" data-bs-placement="top" title="Edit">
<a href="{{ model_view._build_url_for('admin:edit', request, row) }}" data-bs-toggle="tooltip" data-bs-placement="top" title="Edit">
<span class="me-1"><i class="fa-solid fa-pen-to-square"></i></span>
</a>
{% endif %}
Expand All @@ -126,7 +126,7 @@ <h3 class="card-title">{{ model_view.name_plural }}</h3>
{% if is_list( value ) %}
<td>
{% for elem, formatted_elem in zip(value, formatted_value) %}
<a href="{{ model_view._url_for_details(request, elem) }}">({{ formatted_elem }})</a>
<a href="{{ model_view._build_url_for('admin:details', request, elem) }}">({{ formatted_elem }})</a>
{% endfor %}
</td>
{% else %}
Expand Down
34 changes: 10 additions & 24 deletions tests/test_models.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import enum
from typing import Generator
from unittest.mock import Mock, call, patch

import pytest
from markupsafe import Markup
Expand Down Expand Up @@ -382,27 +381,6 @@ def list_query(self, request: Request) -> Select:
assert len(await view.get_model_objects(request)) == 1


def test_url_for() -> None:
class UserAdmin(ModelView, model=User):
...

view = UserAdmin()
request = Request({"type": "http"})
user = User(id=1)
address = Address(pk=2, user=user)

with patch("starlette.requests.Request.url_for", Mock()) as mock:
view._url_for_details(request, user)
view._url_for_edit(request, address)
view._url_for_delete(request, address)

assert mock.call_args_list == [
call("admin:details", identity="user", pk=1),
call("admin:edit", identity="address", pk=2),
call("admin:delete", identity="address"),
]


def test_model_columns_all_keyword() -> None:
class AddressAdmin(ModelView, model=Address):
column_list = "__all__"
Expand All @@ -414,13 +392,21 @@ class AddressAdmin(ModelView, model=Address):

async def test_get_prop_value() -> None:
class ProfileAdmin(ModelView, model=Profile):
...
session_maker = session_maker

profile = Profile(is_active=True, role=Role.ADMIN, status=Status.ACTIVE)
with session_maker() as session:
user = User(name="admin")
address = Address(user=user)
profile = Profile(
is_active=True, role=Role.ADMIN, status=Status.ACTIVE, user=user
)
session.add_all([user, address, profile])
session.commit()

assert await ProfileAdmin().get_prop_value(profile, "is_active") is True
assert await ProfileAdmin().get_prop_value(profile, "role") == "ADMIN"
assert await ProfileAdmin().get_prop_value(profile, "status") == "ACTIVE"
assert await ProfileAdmin().get_prop_value(profile, "user.name") == "admin"


async def test_model_property_in_columns() -> None:
Expand Down
2 changes: 1 addition & 1 deletion tests/test_views/test_view_async.py
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,7 @@ class UserAdmin(ModelView, model=User):


class AddressAdmin(ModelView, model=Address):
column_list = ["id", "user_id", "user"]
column_list = ["id", "user_id", "user", "user.profile.id"]
name_plural = "Addresses"
export_max_rows = 3

Expand Down
2 changes: 1 addition & 1 deletion tests/test_views/test_view_sync.py
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,7 @@ class UserAdmin(ModelView, model=User):


class AddressAdmin(ModelView, model=Address):
column_list = ["id", "user_id", "user"]
column_list = ["id", "user_id", "user", "user.profile.id"]
name_plural = "Addresses"
export_max_rows = 3

Expand Down

0 comments on commit 5d0a2ec

Please sign in to comment.