Skip to content
Closed
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
8 changes: 4 additions & 4 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
# See https://pre-commit.com/hooks.html for more hooks
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v5.0.0
rev: v6.0.0
hooks:
- id: check-added-large-files
args:
Expand All @@ -14,18 +14,18 @@ repos:
- id: end-of-file-fixer
- id: trailing-whitespace
- repo: https://github.com/asottile/pyupgrade
rev: v3.19.1
rev: v3.21.2
hooks:
- id: pyupgrade
args:
- --py38-plus
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.11.9
rev: v0.15.6
hooks:
- id: ruff
args:
- --fix
- repo: https://github.com/psf/black
rev: 25.1.0
rev: 26.5.1
hooks:
- id: black
2 changes: 1 addition & 1 deletion examples/mongoengine/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
)
],
on_startup=[lambda: connect("example")],
on_shutdown=[lambda: disconnect()],
on_shutdown=[disconnect],
)

# Create admin
Expand Down
8 changes: 4 additions & 4 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -47,9 +47,9 @@ i18n = [
test = [
"pytest >=8.3.0, <8.4.0",
"pytest-asyncio >=0.24.0, <0.25.0",
"mypy ==1.19.0",
"ruff ==0.14.7",
"black ==25.11.0",
"mypy ==2.1.0",
"ruff ==0.15.6",
"black ==26.5.1",
"httpx >=0.23.3, <0.29.0",
"SQLAlchemy-Utils >=0.40.0, <0.42.0",
"sqlmodel >=0.0.11, <0.1.0",
Expand Down Expand Up @@ -109,7 +109,7 @@ features = [
[tool.hatch.envs.test.scripts]
lint = [
"mypy starlette_admin",
"ruff check starlette_admin tests",
"ruff check starlette_admin tests examples",
"black . --check"
]
all = "coverage run -m pytest tests"
Expand Down
2 changes: 1 addition & 1 deletion starlette_admin/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
__version__ = "0.16.0"
__version__ = "0.16.1"

from ._types import ExportType as ExportType
from ._types import RequestAction as RequestAction
Expand Down
22 changes: 16 additions & 6 deletions starlette_admin/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -235,7 +235,7 @@ def _setup_templates(self) -> None:
templates.env.globals["logo_url"] = self.logo_url
templates.env.globals["login_logo_url"] = self.login_logo_url
templates.env.globals["favicon_url"] = self.favicon_url
templates.env.globals["custom_render_js"] = lambda r: self.custom_render_js(r)
templates.env.globals["custom_render_js"] = self.custom_render_js
templates.env.globals["get_locale"] = get_locale
templates.env.globals["get_locale_display_name"] = get_locale_display_name
templates.env.globals["i18n_config"] = self.i18n_config or I18nConfig()
Expand All @@ -255,13 +255,11 @@ def _setup_templates(self) -> None:
)
templates.env.filters["tojson"] = lambda data: json.dumps(data, default=str)
templates.env.filters["file_icon"] = get_file_icon
templates.env.filters["to_model"] = (
lambda identity: self._find_model_from_identity(identity)
)
templates.env.filters["to_model"] = self._find_model_from_identity
templates.env.filters["is_iter"] = lambda v: isinstance(v, (list, tuple))
templates.env.filters["is_str"] = lambda v: isinstance(v, str)
templates.env.filters["is_dict"] = lambda v: isinstance(v, dict)
templates.env.filters["ra"] = lambda a: RequestAction(a)
templates.env.filters["ra"] = RequestAction
# install i18n
templates.env.install_gettext_callables(gettext, ngettext, True) # type: ignore
self.templates = templates
Expand Down Expand Up @@ -304,7 +302,7 @@ async def wrapper(request: Request) -> Response:

return wrapper

async def _render_api(self, request: Request) -> Response:
async def _render_api(self, request: Request) -> Response: # noqa: C901
identity = request.path_params.get("identity")
model = self._find_model_from_identity(identity)
if not model.is_accessible(request):
Expand All @@ -325,6 +323,18 @@ async def _render_api(self, request: Request) -> Response:
where = json.loads(where)
except JSONDecodeError:
where = str(where)
if order_by:
error = model._validate_order_by(request, order_by)
if error:
return JSONResponse(
{"detail": error}, status_code=HTTP_422_UNPROCESSABLE_ENTITY
)
if isinstance(where, dict):
error = model._validate_where(request, where)
if error:
return JSONResponse(
{"detail": error}, status_code=HTTP_422_UNPROCESSABLE_ENTITY
)
items = await model.find_all(
request=request,
skip=skip,
Expand Down
22 changes: 11 additions & 11 deletions starlette_admin/contrib/beanie/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,14 +32,14 @@ def query(self) -> Dict[str, Any]:


OPERATORS: Dict[str, Callable[[str, Any], BaseFindOperator]] = {
"eq": lambda f, v: Eq(f, v),
"neq": lambda f, v: NE(f, v),
"lt": lambda f, v: LT(f, v),
"gt": lambda f, v: GT(f, v),
"le": lambda f, v: LTE(f, v),
"ge": lambda f, v: GTE(f, v),
"in": lambda f, v: In(f, v),
"not_in": lambda f, v: NotIn(f, v),
"eq": Eq,
"neq": NE,
"lt": LT,
"gt": GT,
"le": LTE,
"ge": GTE,
"in": In,
"not_in": NotIn,
"startswith": lambda f, v: RegEx(f, f"^{v}", "i"),
"not_startswith": lambda f, v: Not(RegEx(f, f"^{v}", "i")),
"endswith": lambda f, v: RegEx(f, f"{v}$", "i"),
Expand Down Expand Up @@ -156,16 +156,16 @@ def resolve_deep_query(
_arr = [(resolve_deep_query(q, document, latest_field)) for q in where[key]]
if len(_arr) > 0:
funcs = {
"or": lambda q1, q2: Or(q1, q2),
"and": lambda q1, q2: And(q1, q2),
"or": Or,
"and": And,
}
_all_queries.append(functools.reduce(funcs[key], _arr))
elif key in OPERATORS:
_all_queries.append(OPERATORS[key](latest_field, where[key])) # type: ignore
elif isvalid_field(document, key):
_all_queries.append(resolve_deep_query(where[key], document, key))
if _all_queries:
return functools.reduce(lambda q1, q2: And(q1, q2), _all_queries)
return functools.reduce(And, _all_queries)
return BeanieLogicalOperator()


Expand Down
2 changes: 1 addition & 1 deletion starlette_admin/contrib/beanie/view.py
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,7 @@ async def build_full_text_search_query(
queries.append(RegEx(field.name, term, options="i"))
if queries:
return (
functools.reduce(lambda q1, q2: Or(q1, q2), queries),
functools.reduce(Or, queries),
False,
)
return BeanieLogicalOperator(), False
Expand Down
2 changes: 1 addition & 1 deletion starlette_admin/contrib/mongoengine/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ def empty(cls) -> BaseQ:


OPERATORS: Dict[str, Callable[[str, Any], Q]] = {
"eq": lambda f, v: Q(f, v),
"eq": Q,
"neq": lambda f, v: Q(f, v, "ne"),
"lt": lambda f, v: Q(f, v, "lt"),
"gt": lambda f, v: Q(f, v, "gt"),
Expand Down
2 changes: 1 addition & 1 deletion starlette_admin/contrib/mongoengine/view.py
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,7 @@ async def _populate_obj( # noqa: C901
if fields is None:
fields = self.get_fields_list(request, request.state.action)
for field in fields:
name, value = field.name, data.get(field.name, None)
name, value = field.name, data.get(field.name)
me_field = getattr(document, name)
if isinstance(field, (FileField, ImageField)):
proxy: GridFSProxy = getattr(obj, name)
Expand Down
2 changes: 1 addition & 1 deletion starlette_admin/contrib/odmantic/view.py
Original file line number Diff line number Diff line change
Expand Up @@ -191,7 +191,7 @@ async def _arrange_data(
if fields is None:
fields = self.get_fields_list(request, request.state.action)
for field in fields:
name, value = field.name, data.get(field.name, None)
name, value = field.name, data.get(field.name)
if isinstance(field, CollectionField) and value is not None:
arranged_data[name] = await self._arrange_data(
request,
Expand Down
2 changes: 1 addition & 1 deletion starlette_admin/contrib/sqla/view.py
Original file line number Diff line number Diff line change
Expand Up @@ -555,7 +555,7 @@ async def _populate_obj(
is_edit: bool = False,
) -> Any:
for field in self.get_fields_list(request, request.state.action):
name, value = field.name, data.get(field.name, None)
name, value = field.name, data.get(field.name)
if isinstance(field, FileField):
value, should_be_deleted = not_none(value)
if should_be_deleted:
Expand Down
2 changes: 1 addition & 1 deletion starlette_admin/converters.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ def converts(
Callable[..., BaseField],
]:
def wrap(func: Callable[..., BaseField]) -> Callable[..., BaseField]:
func._converter_for = frozenset(args) # type:ignore [attr-defined]
func._converter_for = frozenset(args) # type: ignore [attr-defined]
return func

return wrap
Expand Down
77 changes: 67 additions & 10 deletions starlette_admin/views.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import inspect
from abc import abstractmethod
from collections import OrderedDict
from collections import OrderedDict, deque
from typing import (
Any,
Awaitable,
Expand All @@ -10,6 +10,7 @@
List,
Optional,
Sequence,
Set,
Tuple,
Type,
Union,
Expand All @@ -27,6 +28,7 @@
CollectionField,
FileField,
HasOne,
ListField,
RelationField,
)
from starlette_admin.helpers import extract_fields, not_none
Expand Down Expand Up @@ -266,16 +268,18 @@ class BaseModelView(BaseView):
_find_foreign_model: Callable[[str], "BaseModelView"]

def __init__(self) -> None: # noqa: C901
fringe = list(self.fields)
all_field_names = []
while len(fringe) > 0:
field = fringe.pop(0)
queue = deque(self.fields)
all_fields: list[BaseField] = []
while len(queue) > 0:
field = queue.popleft()
if not hasattr(field, "_name"):
field._name = field.name # type: ignore
if isinstance(field, CollectionField):
for f in field.fields:
f._name = f"{field._name}.{f.name}" # type: ignore
fringe.extend(field.fields)
queue.extend(field.fields)
if isinstance(field, ListField):
queue.append(field.field)
name = field._name # type: ignore
if name == self.pk_attr and not self.form_include_pk:
field.exclude_from_create = True
Expand All @@ -289,22 +293,25 @@ def __init__(self) -> None: # noqa: C901
if name in self.exclude_fields_from_edit:
field.exclude_from_edit = True
if not isinstance(field, CollectionField):
all_field_names.append(name)
all_fields.append(field)
field.searchable = (self.searchable_fields is None) or (
name in self.searchable_fields
)
field.orderable = (self.sortable_fields is None) or (
name in self.sortable_fields
)
all_fields_names: List[str] = [f._name for f in all_fields] # type: ignore[attr-defined]
if self.searchable_fields is None:
self.searchable_fields = all_field_names[:]
self.searchable_fields = all_fields_names[:]
if self.sortable_fields is None:
self.sortable_fields = all_field_names[:]
self.sortable_fields = all_fields_names[:]
if self.export_fields is None:
self.export_fields = all_field_names[:]
self.export_fields = all_fields_names[:]
if self.fields_default_sort is None:
self.fields_default_sort = [self.pk_attr] # type: ignore[list-item]

self._all_fields: list[BaseField] = all_fields

# Actions
self._actions: Dict[str, Dict[str, str]] = OrderedDict()
self._row_actions: Dict[str, Dict[str, str]] = OrderedDict()
Expand Down Expand Up @@ -928,6 +935,56 @@ def get_fields_list(
"""
return extract_fields(self.fields, action)

@staticmethod
def _extract_fields_from_where(where: Dict[str, Any]) -> Set[str]:
"""Recursively collect field names from a structured where dict."""
fields: Set[str] = set()
for key, value in where.items():
if key in ("and", "or") and isinstance(value, list):
for sub in value:
if isinstance(sub, dict):
fields.update(BaseModelView._extract_fields_from_where(sub))
elif key == "not" and isinstance(value, dict):
fields.update(BaseModelView._extract_fields_from_where(value))
else:
fields.add(key)
return fields

def _validate_order_by(
self, request: Request, order_by: List[str]
) -> Optional[str]:
"""Validate order_by clauses against list fields and sortable_fields.

Returns an error message string if invalid, otherwise None.
"""
list_field_names = {f.name for f in self._all_fields if not f.exclude_from_list}
sortable: Set[str] = set(self.sortable_fields or [])
for clause in order_by:
parts = clause.split(maxsplit=1)
if len(parts) < 2:
return f"Invalid order_by clause: '{clause}'"
field_name = parts[0]
if field_name not in list_field_names or field_name not in sortable:
return f"Unknown field or field is not sortable in order_by: '{field_name}'"
return None

def _validate_where(self, request: Request, where: Dict[str, Any]) -> Optional[str]:
"""Validate that all field names in a where dict are visible list fields
and are declared as searchable.

Returns an error message string if invalid, otherwise None.
"""
list_field_names = {f.name for f in self._all_fields if not f.exclude_from_list}
searchable = set(self.searchable_fields or [])
for field_name in self._extract_fields_from_where(where):
if field_name not in list_field_names or (
searchable and field_name not in searchable
):
return (
f"Unknown field or field is not searchable in where: '{field_name}'"
)
return None

def _additional_css_links(
self, request: Request, action: RequestAction
) -> Sequence[str]:
Expand Down
2 changes: 2 additions & 0 deletions tests/sqla/test_sync_engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ class User(Base):
class ProductView(ModelView):
sortable_fields = ["id", "title", "price", "user"]
sortable_field_mapping = {"user": User.name}
searchable_fields = ["id", "title", "price", "description", "user", "in_stock"]

async def before_create(
self, request: Request, data: Dict[str, Any], obj: Any
Expand Down Expand Up @@ -100,6 +101,7 @@ async def after_delete(self, request: Request, obj: Any) -> None:

class UserView(ModelView):
form_include_pk = True
searchable_fields = ["name", "products"]


@pytest.fixture
Expand Down
Loading
Loading