Skip to content

Commit 0792b2b

Browse files
authored
Fix 10 issues found in high-effort review of main (#141)
* Fix 10 issues found in high-effort review of main Backend: - SQLAlchemy: coerce relationship (m2m) filter values to int for integer pks (500 on PostgreSQL/asyncpg) and reject unsupported relation lookups with 422 instead of silently collapsing them to pk equality - PonyORM: only rewrite <relation>_<pk> filter keys when the base attribute is a real relation, so plain columns ending in _id filter correctly - PonyORM: run list search as a single OR'd WHERE clause instead of materializing every matching row once per search field - Flask: pass through HTTPExceptions with an attached response, preserve error headers on JSON error responses, log unhandled errors with traceback - Filtering: text columns support IS NULL again via __exact=null while substring lookups keep the literal string Frontend: - only coerce "true"/"false" strings to booleans for boolean widgets, so text content that spells a boolean survives the change form - treat fields without a change widget as known non-date (null) instead of falling back to shape-based date detection - transformDataFromServer takes the model configuration directly, removing the duplicated getChangeWidgetTypes incantation at every call site * Add coverage for relation-filter rejection and Flask error passthrough Covers the three new defensive branches: the service-layer 422 for substring lookups on m2m filter fields, the SQLAlchemy adapter's loud failure for unsupported relation conditions, and the Flask handler's passthrough of HTTPExceptions with an attached response (plus header preservation on JSON error responses).
1 parent ecb3ea6 commit 0792b2b

15 files changed

Lines changed: 242 additions & 71 deletions

File tree

CHANGELOG.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,31 @@
22

33
All notable changes to FastAdmin are documented in this file.
44

5+
## Unreleased
6+
7+
Bug-fix follow-up to the 0.8.1 audit. No public API changes.
8+
9+
- **SQLAlchemy**: relationship (m2m) filter values are coerced to `int` for
10+
integer primary keys (previously raised HTTP 500 on strictly typed drivers
11+
such as PostgreSQL/asyncpg), and unsupported lookups on relation fields
12+
(e.g. `icontains`) are rejected with HTTP 422 instead of silently matching
13+
by primary-key equality.
14+
- **Pony ORM**: the foreign-key filter rewrite no longer corrupts filters on
15+
plain columns whose name merely ends with `_id`, and list-view search now
16+
runs as a single OR'd WHERE clause instead of materializing every matching
17+
row once per search field.
18+
- **Flask**: `HTTPException`s with an attached response (`abort(Response(...))`)
19+
are passed through unchanged, error headers (`Allow`, `WWW-Authenticate`,
20+
`Retry-After`, ...) are preserved on JSON error responses, and unhandled
21+
errors are logged with their traceback.
22+
- **Filtering**: `IS NULL` is expressible again on text columns via
23+
`field__exact=null` (substring lookups keep the literal string `"null"`).
24+
- **Frontend**: stop corrupting text fields whose content is the literal word
25+
`"true"`/`"false"` on the change form (booleans are now only coerced for
26+
boolean widgets), stop shape-based date detection for fields known to have
27+
no date widget, and pass the model configuration directly to
28+
`transformDataFromServer` so every call site gets the safe behavior.
29+
530
## 0.8.1
631

732
Bug-fix release from a full-source audit of `main`. No public API changes.

fastadmin/api/frameworks/flask/app.py

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -33,11 +33,19 @@ def default(self, o):
3333
@app.errorhandler(Exception)
3434
def exception_handler(exc):
3535
if isinstance(exc, HTTPException):
36+
if exc.response is not None:
37+
# abort(Response(...)) attached a fully-formed response; pass it
38+
# through untouched instead of replacing it with a generic error.
39+
return exc
3640
# Return API errors as JSON {"detail": ...} with the proper HTTP status
3741
# so the shared React frontend can read the message (matching the Django
3842
# and FastAPI integrations), instead of werkzeug's default HTML page.
39-
return {"detail": exc.description}, exc.code or 500
40-
# Unhandled server error: log it server-side but never leak internals to the
41-
# client, and return a real HTTP 500 (a bare dict would be sent as HTTP 200).
42-
logger.error("Unhandled admin error: %s", exc)
43+
# Keep the exception's headers (Allow, WWW-Authenticate, Retry-After, ...)
44+
# apart from its text/html Content-Type, which the JSON body replaces.
45+
headers = [(k, v) for k, v in exc.get_headers() if k.lower() != "content-type"]
46+
return {"detail": exc.description}, exc.code or 500, headers
47+
# Unhandled server error: log it (with traceback — this is the only place it
48+
# is captured) but never leak internals to the client, and return a real
49+
# HTTP 500 (a bare dict would be sent as HTTP 200).
50+
logger.error("Unhandled admin error: %s", exc, exc_info=exc)
4351
return {"detail": "Internal server error."}, 500

fastadmin/api/helpers.py

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,17 +24,26 @@
2424
def sanitize_filter_value(
2525
value: str | list,
2626
field: ModelFieldWidgetSchema | None = None,
27+
condition: str = "exact",
2728
) -> bool | None | str | list:
2829
"""Sanitize value (string or list for __in filters).
2930
3031
:params value: a value (str or list of str for __in).
3132
:params field: the field being filtered, used to decide whether the
3233
"true"/"false"/"null" literals should be coerced (skipped for text fields).
34+
:params condition: the filter lookup (exact/in/icontains/...), used to keep
35+
IS NULL expressible on text fields.
3336
:return: A sanitized value.
3437
"""
3538
if isinstance(value, list):
36-
return [sanitize_filter_value(v, field) for v in value]
39+
return [sanitize_filter_value(v, field, condition) for v in value]
3740
if field is not None and field.filter_widget_type in TEXT_FILTER_WIDGET_TYPES:
41+
# Text content may legitimately be the words "true"/"false"/"null", so
42+
# they are kept literal — except "null" on an exact lookup, which stays
43+
# the only way to express IS NULL on a text column (match the literal
44+
# word via contains/icontains instead).
45+
if value == "null" and condition == "exact":
46+
return None
3847
return value
3948
match value:
4049
case "false":
@@ -109,7 +118,8 @@ def build_query_filters(
109118
continue
110119
field_name = key.partition("__")[0]
111120
field = next((f for f in fields if f.name == field_name), None)
112-
result[sanitize_filter_key(key, fields)] = sanitize_filter_value(value, field)
121+
sanitized_key = sanitize_filter_key(key, fields)
122+
result[sanitized_key] = sanitize_filter_value(value, field, sanitized_key[1])
113123
return result
114124

115125

fastadmin/api/service.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,10 +103,13 @@ def _validate_filters(admin_model: Any, filters: dict, exclude_filter_fields: tu
103103
104104
The filterable field set is ``list_filter`` when the admin defines it,
105105
otherwise the serialized field set; the lookup suffix must be one of
106-
ALLOWED_FILTER_CONDITIONS.
106+
ALLOWED_FILTER_CONDITIONS. Relation (m2m) fields only support pk
107+
equality/membership, so other lookups are rejected here instead of
108+
failing (or silently degrading) inside the ORM adapters.
107109
"""
108110
list_filter = getattr(admin_model, "list_filter", ())
109111
allowlist = set(list_filter) if list_filter else fields
112+
m2m_fields = {f.name for f in admin_model.get_model_fields_with_widget_types() if f.is_m2m}
110113
for k in filters:
111114
if k in exclude_filter_fields:
112115
continue
@@ -115,6 +118,8 @@ def _validate_filters(admin_model: Any, filters: dict, exclude_filter_fields: tu
115118
raise AdminApiException(422, detail=f"Filter by {k} is not allowed")
116119
if field not in allowlist:
117120
raise AdminApiException(422, detail=f"Filter by {k} is not allowed")
121+
if field in m2m_fields and condition and condition not in ("exact", "in"):
122+
raise AdminApiException(422, detail=f"Filter by {k} is not allowed")
118123

119124
@staticmethod
120125
def _bind_admin_context(

fastadmin/models/orms/ponyorm.py

Lines changed: 18 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -259,8 +259,16 @@ def orm_get_list(
259259
field = field_with_condition[0]
260260
condition = field_with_condition[1]
261261
model_pk_name = self.get_model_pk_name(self.model_cls)
262-
if field.endswith(f"_{model_pk_name}"):
263-
field = field.replace(f"_{model_pk_name}", f".{model_pk_name}")
262+
# A "<relation>_<pk>" key (added by sanitize_filter_key for fk
263+
# fields) is rewritten to the "<relation>.<pk>" path — but only
264+
# when the base attribute really is a relation, so a plain
265+
# column that merely ends with "_id" is left untouched.
266+
pk_suffix = f"_{model_pk_name}"
267+
if field.endswith(pk_suffix):
268+
base_name = field[: -len(pk_suffix)]
269+
base_attr = getattr(self.model_cls, base_name, None) if base_name else None
270+
if base_attr is not None and getattr(base_attr, "is_relation", False):
271+
field = f"{base_name}.{model_pk_name}"
264272
# Bind the user-supplied value as a local (`fval`) so Pony
265273
# resolves it as a query parameter. Only the field path — built
266274
# from the admin's validated/allowlisted field names — is
@@ -272,21 +280,16 @@ def orm_get_list(
272280

273281
search_fields = list(self.search_fields)
274282
if search and search_fields:
275-
model_pk_name = self.get_model_pk_name(self.model_cls)
276-
ids = []
277283
# Bind the user-supplied search term as a local so Pony resolves it
278-
# as a parameter. Only the field path (from the admin's trusted
279-
# search_fields config) is interpolated — never the search value.
284+
# as a parameter. Only the field paths (from the admin's trusted
285+
# search_fields config) are interpolated — never the search value.
286+
# All fields are OR-ed into a single filter so the search runs as
287+
# one WHERE clause instead of materializing matching rows per field.
280288
search_term = search.lower() # noqa: F841 (referenced by Pony filter string)
281-
for search_field in search_fields:
282-
pony_search_field = search_field.replace("__", ".")
283-
qs_ids = qs.filter(f"search_term in m.{pony_search_field}.lower()")
284-
objs = list(qs_ids)
285-
ids += [getattr(o, model_pk_name) for o in objs]
286-
# Bind the collected pks as a local so Pony resolves it as a
287-
# parameter; only the trusted pk field path is interpolated.
288-
pk_ids = set(ids) # noqa: F841 (referenced by Pony filter string)
289-
qs = qs.filter(f"m.{model_pk_name} in pk_ids")
289+
search_expr = " or ".join(
290+
f"search_term in m.{search_field.replace('__', '.')}.lower()" for search_field in search_fields
291+
)
292+
qs = qs.filter(search_expr)
290293

291294
ordering = [sort_by] if sort_by else self.ordering
292295
if ordering:

fastadmin/models/orms/sqlalchemy.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -340,9 +340,17 @@ def order_column(ordering_field: str):
340340
if related_mapper is not None:
341341
# Relationship field (e.g. m2m): SQLAlchemy rejects a
342342
# scalar comparison against a collection, so match on the
343-
# related row's pk via any()/has() instead.
343+
# related row's pk via any()/has() instead. Only pk
344+
# equality/membership is meaningful here — anything else
345+
# must fail loudly rather than degrade to an equality
346+
# match (the service layer rejects these with a 422).
347+
if condition not in ("exact", "in"):
348+
raise ValueError(f"Filter condition {condition!r} is not supported for relation {field!r}")
344349
related_cls = related_mapper.class_
345350
related_pk = getattr(related_cls, self.get_model_pk_name(related_cls))
351+
if isinstance(related_pk.expression.type, BIGINT | Integer):
352+
with contextlib.suppress(ValueError, TypeError):
353+
value = [int(x) for x in value] if condition == "in" else int(value)
346354
match_expr = related_pk.in_(value) if condition == "in" else related_pk == value
347355
if getattr(rel_property, "uselist", True):
348356
q.append(model_field.any(match_expr))

frontend/src/components/async-select/index.tsx

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -28,10 +28,7 @@ import { getFetcher, patchFetcher, postFetcher } from "@/fetchers/fetchers";
2828
import { getConfigurationModel } from "@/helpers/configuration";
2929
import { handleError } from "@/helpers/forms";
3030
import { getTitleFromModel } from "@/helpers/title";
31-
import {
32-
getChangeWidgetTypes,
33-
transformDataFromServer,
34-
} from "@/helpers/transform";
31+
import { transformDataFromServer } from "@/helpers/transform";
3532
import { EModelPermission } from "@/interfaces/configuration";
3633
import { ConfigurationContext } from "@/providers/ConfigurationProvider";
3734

@@ -90,10 +87,7 @@ export const AsyncSelect: React.FC<IAsyncSelect> = ({
9087
const asyncSelectChangeInitialValues = useMemo(
9188
() =>
9289
initialChangeValues != null
93-
? transformDataFromServer(
94-
initialChangeValues,
95-
getChangeWidgetTypes(modelConfiguration),
96-
)
90+
? transformDataFromServer(initialChangeValues, modelConfiguration)
9791
: undefined,
9892
[initialChangeValues, modelConfiguration],
9993
);

frontend/src/components/inline-widget/index.tsx

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,6 @@ import {
3030
import { handleError } from "@/helpers/forms";
3131
import { getTitleFromModel } from "@/helpers/title";
3232
import {
33-
getChangeWidgetTypes,
3433
transformDataFromServer,
3534
transformDataToServer,
3635
transformFiltersToServer,
@@ -152,10 +151,7 @@ export const InlineWidget: React.FC<IInlineWidget> = ({
152151
const inlineChangeInitialValues = useMemo(
153152
() =>
154153
initialChangeValues != null
155-
? transformDataFromServer(
156-
initialChangeValues,
157-
getChangeWidgetTypes(modelConfiguration),
158-
)
154+
? transformDataFromServer(initialChangeValues, modelConfiguration)
159155
: undefined,
160156
[initialChangeValues, modelConfiguration],
161157
);

frontend/src/containers/change/index.tsx

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,6 @@ import { getConfigurationModel } from "@/helpers/configuration";
2828
import { handleError } from "@/helpers/forms";
2929
import { getTitleFromModel } from "@/helpers/title";
3030
import {
31-
getChangeWidgetTypes,
3231
transformDataFromServer,
3332
transformDataToServer,
3433
} from "@/helpers/transform";
@@ -62,10 +61,7 @@ export const Change: React.FC = () => {
6261
const initialValues = useMemo(
6362
() =>
6463
initialChangeValues != null
65-
? transformDataFromServer(
66-
initialChangeValues,
67-
getChangeWidgetTypes(modelConfiguration),
68-
)
64+
? transformDataFromServer(initialChangeValues, modelConfiguration)
6965
: undefined,
7066
[initialChangeValues, modelConfiguration],
7167
);

frontend/src/helpers/transform.test.tsx

Lines changed: 53 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -232,21 +232,71 @@ describe("transform", () => {
232232
"12:00:00",
233233
);
234234
});
235+
it("keeps a boolean-looking string as-is for a non-boolean widget", () => {
236+
expect(transformValueFromServer("false", EFieldWidgetType.Input)).toBe(
237+
"false",
238+
);
239+
expect(transformValueFromServer("True", EFieldWidgetType.TextArea)).toBe(
240+
"True",
241+
);
242+
});
243+
it("coerces boolean strings for boolean widgets", () => {
244+
expect(transformValueFromServer("false", EFieldWidgetType.Switch)).toBe(
245+
false,
246+
);
247+
expect(transformValueFromServer("true", EFieldWidgetType.Checkbox)).toBe(
248+
true,
249+
);
250+
});
251+
it("keeps real booleans regardless of widget type", () => {
252+
expect(transformValueFromServer(true, EFieldWidgetType.Input)).toBe(true);
253+
expect(transformValueFromServer(false, null)).toBe(false);
254+
});
255+
it("skips shape detection when the widget is known to be absent", () => {
256+
expect(transformValueFromServer("2024-01-15", null)).toBe("2024-01-15");
257+
expect(transformValueFromServer("false", null)).toBe("false");
258+
});
235259
});
236260

237261
describe("transformDataFromServer", () => {
238262
it("transforms all values", () => {
239263
const r = transformDataFromServer({ d: "2024-01-15" });
240264
expect(r).toHaveProperty("d");
241265
});
242-
it("respects per-field widget types", () => {
266+
it("respects per-field widget types from the model configuration", () => {
243267
const r = transformDataFromServer(
244268
{ at: "2024-01-15", code: "2024-01-15" },
245-
{ at: EFieldWidgetType.DatePicker, code: EFieldWidgetType.Input },
269+
{
270+
fields: [
271+
{
272+
name: "at",
273+
change_configuration: {
274+
form_widget_type: EFieldWidgetType.DatePicker,
275+
},
276+
},
277+
{
278+
name: "code",
279+
change_configuration: {
280+
form_widget_type: EFieldWidgetType.Input,
281+
},
282+
},
283+
],
284+
} as any,
246285
);
247286
expect(dayjs.isDayjs(r.at)).toBe(true);
248287
expect(r.code).toBe("2024-01-15");
249288
});
289+
it("does not shape-detect fields without a change widget when a configuration is given", () => {
290+
const r = transformDataFromServer(
291+
{ code: "2024-01-15", flag: "false", unknown: "2024-01-15" },
292+
{
293+
fields: [{ name: "code", change_configuration: {} }],
294+
} as any,
295+
);
296+
expect(r.code).toBe("2024-01-15");
297+
expect(r.flag).toBe("false");
298+
expect(r.unknown).toBe("2024-01-15");
299+
});
250300
});
251301

252302
describe("getChangeWidgetTypes", () => {
@@ -262,7 +312,7 @@ describe("transform", () => {
262312
{ name: "code", change_configuration: {} },
263313
],
264314
} as any);
265-
expect(map).toEqual({ at: EFieldWidgetType.DatePicker, code: undefined });
315+
expect(map).toEqual({ at: EFieldWidgetType.DatePicker, code: null });
266316
});
267317
it("returns an empty map when configuration is missing", () => {
268318
expect(getChangeWidgetTypes()).toEqual({});

0 commit comments

Comments
 (0)