Skip to content
Merged
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
13 changes: 8 additions & 5 deletions fastadmin/api/frameworks/flask/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,11 @@ def default(self, o):
@app.errorhandler(Exception)
def exception_handler(exc):
if isinstance(exc, HTTPException):
return exc
return {
"status_code": 500,
"content": {"exception": str(exc)},
}
# Return API errors as JSON {"detail": ...} with the proper HTTP status
# so the shared React frontend can read the message (matching the Django
# and FastAPI integrations), instead of werkzeug's default HTML page.
return {"detail": exc.description}, exc.code or 500
# Unhandled server error: log it server-side but never leak internals to the
# client, and return a real HTTP 500 (a bare dict would be sent as HTTP 200).
logger.error("Unhandled admin error: %s", exc)
return {"detail": "Internal server error."}, 500
59 changes: 54 additions & 5 deletions fastadmin/api/helpers.py
Original file line number Diff line number Diff line change
@@ -1,17 +1,41 @@
from pathlib import Path
from uuid import UUID

from fastadmin.models.schemas import ModelFieldWidgetSchema


def sanitize_filter_value(value: str | list) -> bool | None | str | list:
from fastadmin.models.schemas import ModelFieldWidgetSchema, WidgetType

# Text-like filter widgets whose values are free-form strings. For these the
# literals "true"/"false"/"null" are legitimate content and must NOT be coerced
# to bool/None (otherwise a Char column can never be filtered for those words).
TEXT_FILTER_WIDGET_TYPES = frozenset(
{
WidgetType.Input,
WidgetType.TextArea,
WidgetType.RichTextArea,
WidgetType.JsonTextArea,
WidgetType.SlugInput,
WidgetType.EmailInput,
WidgetType.PhoneInput,
WidgetType.UrlInput,
WidgetType.PasswordInput,
}
)


def sanitize_filter_value(
value: str | list,
field: ModelFieldWidgetSchema | None = None,
) -> bool | None | str | list:
"""Sanitize value (string or list for __in filters).

:params value: a value (str or list of str for __in).
:params field: the field being filtered, used to decide whether the
"true"/"false"/"null" literals should be coerced (skipped for text fields).
:return: A sanitized value.
"""
if isinstance(value, list):
return [sanitize_filter_value(v) for v in value]
return [sanitize_filter_value(v, field) for v in value]
if field is not None and field.filter_widget_type in TEXT_FILTER_WIDGET_TYPES:
return value
match value:
case "false":
return False
Expand Down Expand Up @@ -64,6 +88,31 @@ def sanitize_filter_key(key: str, fields: list[ModelFieldWidgetSchema]) -> tuple
return field_name, condition


def build_query_filters(
filters: dict,
fields: list[ModelFieldWidgetSchema],
exclude: tuple[str, ...],
) -> dict[tuple[str, str], bool | None | str | list]:
"""Build the sanitized ``{(field_name, condition): value}`` filter dict.

Resolves each key's field so value coercion (true/false/null) can be applied
in a type-aware way (see :func:`sanitize_filter_value`).

:param filters: raw filters mapping ``key -> value``.
:param fields: model fields with widget types.
:param exclude: keys to skip (search, sort_by, offset, limit).
:return: sanitized filters dict.
"""
result: dict[tuple[str, str], bool | None | str | list] = {}
for key, value in filters.items():
if key in exclude:
continue
field_name = key.partition("__")[0]
field = next((f for f in fields if f.name == field_name), None)
result[sanitize_filter_key(key, fields)] = sanitize_filter_value(value, field)
return result


def is_valid_uuid(uuid_to_test: str) -> bool:
"""Check if uuid_to_test is a valid uuid.

Expand Down
22 changes: 11 additions & 11 deletions fastadmin/api/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
from asgiref.sync import sync_to_async

from fastadmin.api.exceptions import AdminApiException
from fastadmin.api.helpers import sanitize_filter_key, sanitize_filter_value
from fastadmin.api.helpers import build_query_filters
from fastadmin.api.schemas import (
ChangePasswordInputSchema,
ExportFormat,
Expand Down Expand Up @@ -233,11 +233,11 @@ async def list(
query_filters: dict[tuple[str, str], bool | str | None | list] | None = None
if query_params.filters:
self._validate_filters(admin_model, query_params.filters, exclude_filter_fields, fields)
query_filters = {
sanitize_filter_key(k, admin_model.get_model_fields_with_widget_types()): sanitize_filter_value(v)
for k, v in query_params.filters.items()
if k not in exclude_filter_fields
}
query_filters = build_query_filters(
query_params.filters,
admin_model.get_model_fields_with_widget_types(),
exclude_filter_fields,
)

if query_params.sort_by:
if query_params.sort_by.strip("-") not in fields:
Expand Down Expand Up @@ -456,11 +456,11 @@ async def export(
query_filters: dict[tuple[str, str], bool | str | None | list] | None = None
if query_params.filters:
self._validate_filters(admin_model, query_params.filters, exclude_filter_fields, fields)
query_filters = {
sanitize_filter_key(k, admin_model.get_model_fields_with_widget_types()): sanitize_filter_value(v)
for k, v in query_params.filters.items()
if k not in exclude_filter_fields
}
query_filters = build_query_filters(
query_params.filters,
admin_model.get_model_fields_with_widget_types(),
exclude_filter_fields,
)

if query_params.sort_by:
if query_params.sort_by.strip("-") not in fields:
Expand Down
5 changes: 2 additions & 3 deletions fastadmin/models/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -202,10 +202,9 @@ class BaseModelAdmin:
# Example of usage: show_full_result_count = True
show_full_result_count: bool = False

# By default, the list page allows sorting by all model fields
# By default (an empty collection), the list page allows sorting by all model fields.
# If you want to disable sorting for some columns, set sortable_by to a collection (e.g. list, tuple, or set)
# of the subset of list_display that you want to be sortable.
# An empty collection disables sorting for all columns.
# of the subset of list_display that you want to be sortable; columns not listed become non-sortable.
# Example of usage: sortable_by = ("mobile_number", "email")
sortable_by: Sequence[str] = ()

Expand Down
24 changes: 16 additions & 8 deletions fastadmin/models/orms/ponyorm.py
Original file line number Diff line number Diff line change
Expand Up @@ -272,6 +272,7 @@ def orm_get_list(

search_fields = list(self.search_fields)
if search and search_fields:
model_pk_name = self.get_model_pk_name(self.model_cls)
ids = []
# Bind the user-supplied search term as a local so Pony resolves it
# as a parameter. Only the field path (from the admin's trusted
Expand All @@ -281,17 +282,24 @@ def orm_get_list(
pony_search_field = search_field.replace("__", ".")
qs_ids = qs.filter(f"search_term in m.{pony_search_field}.lower()")
objs = list(qs_ids)
ids += [o.id for o in objs]
qs = qs.filter(lambda m: m.id in set(ids))
ids += [getattr(o, model_pk_name) for o in objs]
# Bind the collected pks as a local so Pony resolves it as a
# parameter; only the trusted pk field path is interpolated.
pk_ids = set(ids) # noqa: F841 (referenced by Pony filter string)
qs = qs.filter(f"m.{model_pk_name} in pk_ids")

ordering = [sort_by] if sort_by else self.ordering
if ordering:
desc_fields = [o[1:] for o in ordering if o.startswith("-")]
asc_fields = [o for o in ordering if not o.startswith("-")]
if asc_fields:
qs = qs.order_by(*(getattr(self.model_cls, o) for o in asc_fields))
if desc_fields:
qs = qs.order_by(*(desc(getattr(self.model_cls, o)) for o in desc_fields))
# Build a single order_by() call that preserves the declared field
# order. Pony prepends the fields of each separate order_by() call,
# so applying asc and desc fields in two calls would invert their
# relative priority (a later desc field wrongly outranks an earlier
# asc one).
order_exprs = [
desc(getattr(self.model_cls, o[1:])) if o.startswith("-") else getattr(self.model_cls, o)
for o in ordering
]
qs = qs.order_by(*order_exprs)

total = qs.count()

Expand Down
15 changes: 15 additions & 0 deletions fastadmin/models/orms/sqlalchemy.py
Original file line number Diff line number Diff line change
Expand Up @@ -335,6 +335,21 @@ def order_column(ordering_field: str):
condition = field_with_condition[1]
model_field = getattr(self.model_cls, field)

rel_property = getattr(model_field, "property", None)
related_mapper = getattr(rel_property, "mapper", None)
if related_mapper is not None:
# Relationship field (e.g. m2m): SQLAlchemy rejects a
# scalar comparison against a collection, so match on the
# related row's pk via any()/has() instead.
related_cls = related_mapper.class_
related_pk = getattr(related_cls, self.get_model_pk_name(related_cls))
match_expr = related_pk.in_(value) if condition == "in" else related_pk == value
if getattr(rel_property, "uselist", True):
q.append(model_field.any(match_expr))
else:
q.append(model_field.has(match_expr))
continue

if condition != "in" and isinstance(model_field.expression.type, BIGINT | Integer):
with contextlib.suppress(ValueError, TypeError):
value = int(value)
Expand Down
1 change: 1 addition & 0 deletions frontend/src/components/async-select/index.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,7 @@ vi.mock("@/helpers/forms", () => ({
}));

vi.mock("@/helpers/transform", () => ({
getChangeWidgetTypes: () => ({}),
transformDataFromServer: (v: unknown) => v,
}));

Expand Down
12 changes: 9 additions & 3 deletions frontend/src/components/async-select/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,10 @@ import { getFetcher, patchFetcher, postFetcher } from "@/fetchers/fetchers";
import { getConfigurationModel } from "@/helpers/configuration";
import { handleError } from "@/helpers/forms";
import { getTitleFromModel } from "@/helpers/title";
import { transformDataFromServer } from "@/helpers/transform";
import {
getChangeWidgetTypes,
transformDataFromServer,
} from "@/helpers/transform";
import { EModelPermission } from "@/interfaces/configuration";
import { ConfigurationContext } from "@/providers/ConfigurationProvider";

Expand Down Expand Up @@ -87,9 +90,12 @@ export const AsyncSelect: React.FC<IAsyncSelect> = ({
const asyncSelectChangeInitialValues = useMemo(
() =>
initialChangeValues != null
? transformDataFromServer(initialChangeValues)
? transformDataFromServer(
initialChangeValues,
getChangeWidgetTypes(modelConfiguration),
)
: undefined,
[initialChangeValues],
[initialChangeValues, modelConfiguration],
);

const {
Expand Down
5 changes: 5 additions & 0 deletions frontend/src/components/dashboard-action-widget/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { useCallback, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";

import { postFetcher } from "@/fetchers/fetchers";
import { handleError } from "@/helpers/forms";
import { getTitleFromFieldName } from "@/helpers/title";
import { transformValueToServer } from "@/helpers/transform";
import { getWidgetCls } from "@/helpers/widgets";
Expand Down Expand Up @@ -211,6 +212,8 @@ export const DashboardActionWidget: React.FC<DashboardActionWidgetProps> = ({
);
setActionResult(result as IWidgetActionResponse);
setResultsView("json");
} catch (error) {
handleError(error);
} finally {
setIsActionRunning(false);
}
Expand All @@ -229,6 +232,8 @@ export const DashboardActionWidget: React.FC<DashboardActionWidgetProps> = ({
},
);
setActionResult(result as IWidgetActionResponse);
} catch (error) {
handleError(error);
} finally {
setIsActionRefreshing(false);
}
Expand Down
11 changes: 6 additions & 5 deletions frontend/src/components/form-container/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -185,11 +185,12 @@ export const FormContainer: React.FC<IFormContainer> = ({
] as any
}
valuePropName={
[
EFieldWidgetType.Checkbox,
EFieldWidgetType.Switch,
EFieldWidgetType.CheckboxGroup,
].includes(getConf(field).form_widget_type as EFieldWidgetType)
// Only single boolean widgets use `checked`. Checkbox.Group is
// controlled via `value` (an array); binding it to `checked` would
// stop stored selections from rendering.
[EFieldWidgetType.Checkbox, EFieldWidgetType.Switch].includes(
getConf(field).form_widget_type as EFieldWidgetType,
)
? "checked"
: undefined
}
Expand Down
1 change: 1 addition & 0 deletions frontend/src/components/inline-widget/index.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,7 @@ vi.mock("@/helpers/forms", () => ({
}));

vi.mock("@/helpers/transform", () => ({
getChangeWidgetTypes: () => ({}),
transformDataFromServer: (v: unknown) => v,
transformFiltersToServer: () => ({}),
transformDataToServer: (v: unknown) => v,
Expand Down
8 changes: 6 additions & 2 deletions frontend/src/components/inline-widget/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import {
import { handleError } from "@/helpers/forms";
import { getTitleFromModel } from "@/helpers/title";
import {
getChangeWidgetTypes,
transformDataFromServer,
transformDataToServer,
transformFiltersToServer,
Expand Down Expand Up @@ -151,9 +152,12 @@ export const InlineWidget: React.FC<IInlineWidget> = ({
const inlineChangeInitialValues = useMemo(
() =>
initialChangeValues != null
? transformDataFromServer(initialChangeValues)
? transformDataFromServer(
initialChangeValues,
getChangeWidgetTypes(modelConfiguration),
)
: undefined,
[initialChangeValues],
[initialChangeValues, modelConfiguration],
);

const {
Expand Down
4 changes: 3 additions & 1 deletion frontend/src/containers/add/index.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -191,7 +191,9 @@ describe("Add container", () => {

mutationOptions.onSuccess();
expect(mockMessageSuccess).toHaveBeenCalledWith("Succesfully added");
expect(mockInvalidateQueries).toHaveBeenCalledWith(["/list/user"]);
expect(mockInvalidateQueries).toHaveBeenCalledWith({
queryKey: ["/list/user"],
});
expect(mockNavigate).toHaveBeenCalledWith("/list/user");

mutationOptions.onError(new Error("save failed"));
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/containers/add/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ export const Add: React.FC = () => {
onSuccess: () => {
message.success(_t("Succesfully added"));

queryClient.invalidateQueries([`/list/${model}`] as any);
queryClient.invalidateQueries({ queryKey: [`/list/${model}`] });
navigate(`/list/${model}`);
},
onError: (error: Error) => {
Expand Down
13 changes: 10 additions & 3 deletions frontend/src/containers/change/index.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,7 @@ vi.mock("@/helpers/title", () => ({
}));

vi.mock("@/helpers/transform", () => ({
getChangeWidgetTypes: () => ({}),
transformDataFromServer: (data: unknown) => data,
transformDataToServer: (...args: unknown[]) =>
mockTransformDataToServer(...args),
Expand Down Expand Up @@ -416,15 +417,21 @@ describe("Change container", () => {
expect(mockPostFetcher).toHaveBeenCalledWith("/add/user", { x: 1 });
addOptions.onSuccess();
expect(mockMessageSuccess).toHaveBeenCalledWith("Succesfully added");
expect(mockInvalidateQueries).toHaveBeenCalledWith(["/list/user"]);
expect(mockInvalidateQueries).toHaveBeenCalledWith({
queryKey: ["/list/user"],
});
addOptions.onError(new Error("add error"));

await changeOptions.mutationFn({ y: 1 });
expect(mockPatchFetcher).toHaveBeenCalledWith("/change/user/1", { y: 1 });
changeOptions.onSuccess();
expect(mockMessageSuccess).toHaveBeenCalledWith("Succesfully changed");
expect(mockInvalidateQueries).toHaveBeenCalledWith(["/retrieve/user/1"]);
expect(mockInvalidateQueries).toHaveBeenCalledWith(["/list/user"]);
expect(mockInvalidateQueries).toHaveBeenCalledWith({
queryKey: ["/retrieve/user/1"],
});
expect(mockInvalidateQueries).toHaveBeenCalledWith({
queryKey: ["/list/user"],
});
changeOptions.onError(new Error("change error"));
expect(mockHandleError).toHaveBeenCalledWith(expect.any(Error), mockForm);

Expand Down
Loading
Loading