diff --git a/fastadmin/api/frameworks/flask/app.py b/fastadmin/api/frameworks/flask/app.py index 3a4ddba..0a0aca3 100644 --- a/fastadmin/api/frameworks/flask/app.py +++ b/fastadmin/api/frameworks/flask/app.py @@ -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 diff --git a/fastadmin/api/helpers.py b/fastadmin/api/helpers.py index 842a134..b63ee29 100644 --- a/fastadmin/api/helpers.py +++ b/fastadmin/api/helpers.py @@ -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 @@ -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. diff --git a/fastadmin/api/service.py b/fastadmin/api/service.py index 0f155fe..f636e7d 100644 --- a/fastadmin/api/service.py +++ b/fastadmin/api/service.py @@ -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, @@ -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: @@ -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: diff --git a/fastadmin/models/base.py b/fastadmin/models/base.py index 2f97aa4..3e580db 100644 --- a/fastadmin/models/base.py +++ b/fastadmin/models/base.py @@ -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] = () diff --git a/fastadmin/models/orms/ponyorm.py b/fastadmin/models/orms/ponyorm.py index daaff5e..f035a66 100644 --- a/fastadmin/models/orms/ponyorm.py +++ b/fastadmin/models/orms/ponyorm.py @@ -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 @@ -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() diff --git a/fastadmin/models/orms/sqlalchemy.py b/fastadmin/models/orms/sqlalchemy.py index 3549c3e..2afc2e6 100644 --- a/fastadmin/models/orms/sqlalchemy.py +++ b/fastadmin/models/orms/sqlalchemy.py @@ -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) diff --git a/frontend/src/components/async-select/index.test.tsx b/frontend/src/components/async-select/index.test.tsx index dfc4ffd..d069d1d 100644 --- a/frontend/src/components/async-select/index.test.tsx +++ b/frontend/src/components/async-select/index.test.tsx @@ -163,6 +163,7 @@ vi.mock("@/helpers/forms", () => ({ })); vi.mock("@/helpers/transform", () => ({ + getChangeWidgetTypes: () => ({}), transformDataFromServer: (v: unknown) => v, })); diff --git a/frontend/src/components/async-select/index.tsx b/frontend/src/components/async-select/index.tsx index a4eecbb..bcf599d 100644 --- a/frontend/src/components/async-select/index.tsx +++ b/frontend/src/components/async-select/index.tsx @@ -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"; @@ -87,9 +90,12 @@ export const AsyncSelect: React.FC = ({ const asyncSelectChangeInitialValues = useMemo( () => initialChangeValues != null - ? transformDataFromServer(initialChangeValues) + ? transformDataFromServer( + initialChangeValues, + getChangeWidgetTypes(modelConfiguration), + ) : undefined, - [initialChangeValues], + [initialChangeValues, modelConfiguration], ); const { diff --git a/frontend/src/components/dashboard-action-widget/index.tsx b/frontend/src/components/dashboard-action-widget/index.tsx index 685f30d..12b6d12 100644 --- a/frontend/src/components/dashboard-action-widget/index.tsx +++ b/frontend/src/components/dashboard-action-widget/index.tsx @@ -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"; @@ -211,6 +212,8 @@ export const DashboardActionWidget: React.FC = ({ ); setActionResult(result as IWidgetActionResponse); setResultsView("json"); + } catch (error) { + handleError(error); } finally { setIsActionRunning(false); } @@ -229,6 +232,8 @@ export const DashboardActionWidget: React.FC = ({ }, ); setActionResult(result as IWidgetActionResponse); + } catch (error) { + handleError(error); } finally { setIsActionRefreshing(false); } diff --git a/frontend/src/components/form-container/index.tsx b/frontend/src/components/form-container/index.tsx index 08fbb61..64f2770 100644 --- a/frontend/src/components/form-container/index.tsx +++ b/frontend/src/components/form-container/index.tsx @@ -185,11 +185,12 @@ export const FormContainer: React.FC = ({ ] 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 } diff --git a/frontend/src/components/inline-widget/index.test.tsx b/frontend/src/components/inline-widget/index.test.tsx index 6a2488d..d8233b7 100644 --- a/frontend/src/components/inline-widget/index.test.tsx +++ b/frontend/src/components/inline-widget/index.test.tsx @@ -203,6 +203,7 @@ vi.mock("@/helpers/forms", () => ({ })); vi.mock("@/helpers/transform", () => ({ + getChangeWidgetTypes: () => ({}), transformDataFromServer: (v: unknown) => v, transformFiltersToServer: () => ({}), transformDataToServer: (v: unknown) => v, diff --git a/frontend/src/components/inline-widget/index.tsx b/frontend/src/components/inline-widget/index.tsx index ef06a22..55a1c7f 100644 --- a/frontend/src/components/inline-widget/index.tsx +++ b/frontend/src/components/inline-widget/index.tsx @@ -30,6 +30,7 @@ import { import { handleError } from "@/helpers/forms"; import { getTitleFromModel } from "@/helpers/title"; import { + getChangeWidgetTypes, transformDataFromServer, transformDataToServer, transformFiltersToServer, @@ -151,9 +152,12 @@ export const InlineWidget: React.FC = ({ const inlineChangeInitialValues = useMemo( () => initialChangeValues != null - ? transformDataFromServer(initialChangeValues) + ? transformDataFromServer( + initialChangeValues, + getChangeWidgetTypes(modelConfiguration), + ) : undefined, - [initialChangeValues], + [initialChangeValues, modelConfiguration], ); const { diff --git a/frontend/src/containers/add/index.test.tsx b/frontend/src/containers/add/index.test.tsx index 5e9c499..ce18b2a 100644 --- a/frontend/src/containers/add/index.test.tsx +++ b/frontend/src/containers/add/index.test.tsx @@ -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")); diff --git a/frontend/src/containers/add/index.tsx b/frontend/src/containers/add/index.tsx index 0d64cb3..3f4061e 100644 --- a/frontend/src/containers/add/index.tsx +++ b/frontend/src/containers/add/index.tsx @@ -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) => { diff --git a/frontend/src/containers/change/index.test.tsx b/frontend/src/containers/change/index.test.tsx index 4b1baa4..b4f1a11 100644 --- a/frontend/src/containers/change/index.test.tsx +++ b/frontend/src/containers/change/index.test.tsx @@ -160,6 +160,7 @@ vi.mock("@/helpers/title", () => ({ })); vi.mock("@/helpers/transform", () => ({ + getChangeWidgetTypes: () => ({}), transformDataFromServer: (data: unknown) => data, transformDataToServer: (...args: unknown[]) => mockTransformDataToServer(...args), @@ -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); diff --git a/frontend/src/containers/change/index.tsx b/frontend/src/containers/change/index.tsx index 365c94d..86c2c8c 100644 --- a/frontend/src/containers/change/index.tsx +++ b/frontend/src/containers/change/index.tsx @@ -28,6 +28,7 @@ import { getConfigurationModel } from "@/helpers/configuration"; import { handleError } from "@/helpers/forms"; import { getTitleFromModel } from "@/helpers/title"; import { + getChangeWidgetTypes, transformDataFromServer, transformDataToServer, } from "@/helpers/transform"; @@ -61,9 +62,12 @@ export const Change: React.FC = () => { const initialValues = useMemo( () => initialChangeValues != null - ? transformDataFromServer(initialChangeValues) + ? transformDataFromServer( + initialChangeValues, + getChangeWidgetTypes(modelConfiguration), + ) : undefined, - [initialChangeValues], + [initialChangeValues, modelConfiguration], ); const { @@ -75,7 +79,7 @@ export const Change: React.FC = () => { onSuccess: () => { message.success(_t("Succesfully added")); - queryClient.invalidateQueries([`/list/${model}`] as any); + queryClient.invalidateQueries({ queryKey: [`/list/${model}`] }); const next = form.getFieldValue("next"); /* v8 ignore next -- navigation branch is integration-covered */ if (next) { @@ -97,9 +101,9 @@ export const Change: React.FC = () => { onSuccess: () => { message.success(_t("Succesfully changed")); - queryClient.invalidateQueries([`/retrieve/${model}/${id}`] as any); + queryClient.invalidateQueries({ queryKey: [`/retrieve/${model}/${id}`] }); - queryClient.invalidateQueries([`/list/${model}`] as any); + queryClient.invalidateQueries({ queryKey: [`/list/${model}`] }); const next = form.getFieldValue("next"); /* v8 ignore next -- navigation branch is integration-covered */ if (next) { @@ -116,7 +120,7 @@ export const Change: React.FC = () => { onSuccess: () => { message.success(_t("Successfully deleted")); - queryClient.invalidateQueries([`/list/${model}`] as any); + queryClient.invalidateQueries({ queryKey: [`/list/${model}`] }); navigate(`/list/${model}`); }, onError: () => { diff --git a/frontend/src/helpers/transform.test.tsx b/frontend/src/helpers/transform.test.tsx index f282266..2b20fdb 100644 --- a/frontend/src/helpers/transform.test.tsx +++ b/frontend/src/helpers/transform.test.tsx @@ -1,5 +1,8 @@ +import dayjs from "dayjs"; import { describe, expect, it } from "vitest"; +import { EFieldWidgetType } from "@/interfaces/configuration"; import { + getChangeWidgetTypes, isArray, isBoolean, isDateTime, @@ -120,22 +123,26 @@ describe("transform", () => { it("maps over arrays", () => { expect(transformValueToServer([1, 2])).toEqual([1, 2]); }); - it("formats dayjs-like value", () => { - const d = { date: true, format: () => "2024-01-15" }; - expect(transformValueToServer(d)).toBe("2024-01-15"); + it("formats a real dayjs value", () => { + const d = dayjs("2024-01-15"); + expect(transformValueToServer(d)).toBe(d.format()); }); it("returns non-dayjs value as-is", () => { expect(transformValueToServer({ a: 1 })).toEqual({ a: 1 }); expect(transformValueToServer("hello")).toBe("hello"); }); + it("does not treat a plain object with a `date` key as dayjs", () => { + const value = { date: "2024-05-01", amount: 100 }; + expect(transformValueToServer(value)).toEqual(value); + }); }); describe("transformDataToServer", () => { it("transforms all values", () => { - const d = { - date: { date: true, format: () => "2024-01-15" }, - }; - expect(transformDataToServer(d)).toEqual({ date: "2024-01-15" }); + const d = dayjs("2024-01-15"); + expect(transformDataToServer({ created: d })).toEqual({ + created: d.format(), + }); }); }); @@ -210,6 +217,21 @@ describe("transform", () => { expect(r && typeof (r as any).isValid === "function").toBe(true); expect(r && (r as any).isValid()).toBe(true); }); + it("parses a date string into dayjs for a date widget", () => { + const r = transformValueFromServer( + "2024-01-15", + EFieldWidgetType.DatePicker, + ); + expect(dayjs.isDayjs(r)).toBe(true); + }); + it("keeps a date-looking string as-is for a non-date widget", () => { + expect( + transformValueFromServer("2024-01-15", EFieldWidgetType.Input), + ).toBe("2024-01-15"); + expect(transformValueFromServer("12:00:00", EFieldWidgetType.Input)).toBe( + "12:00:00", + ); + }); }); describe("transformDataFromServer", () => { @@ -217,6 +239,34 @@ describe("transform", () => { const r = transformDataFromServer({ d: "2024-01-15" }); expect(r).toHaveProperty("d"); }); + it("respects per-field widget types", () => { + const r = transformDataFromServer( + { at: "2024-01-15", code: "2024-01-15" }, + { at: EFieldWidgetType.DatePicker, code: EFieldWidgetType.Input }, + ); + expect(dayjs.isDayjs(r.at)).toBe(true); + expect(r.code).toBe("2024-01-15"); + }); + }); + + describe("getChangeWidgetTypes", () => { + it("maps field names to their change widget type", () => { + const map = getChangeWidgetTypes({ + fields: [ + { + name: "at", + change_configuration: { + form_widget_type: EFieldWidgetType.DatePicker, + }, + }, + { name: "code", change_configuration: {} }, + ], + } as any); + expect(map).toEqual({ at: EFieldWidgetType.DatePicker, code: undefined }); + }); + it("returns an empty map when configuration is missing", () => { + expect(getChangeWidgetTypes()).toEqual({}); + }); }); describe("transformColumnValueFromServer", () => { diff --git a/frontend/src/helpers/transform.tsx b/frontend/src/helpers/transform.tsx index 30bbed5..e43c05f 100644 --- a/frontend/src/helpers/transform.tsx +++ b/frontend/src/helpers/transform.tsx @@ -1,6 +1,20 @@ import { Checkbox, Tag } from "antd"; import dayjs from "dayjs"; import slugify from "slugify"; +import { + EFieldWidgetType, + type IModelField, +} from "@/interfaces/configuration"; + +// Widgets that expect a dayjs value. A server string is only parsed into a +// dayjs when its field uses one of these; otherwise a plain text field whose +// content merely looks like a date (e.g. "2024-01-01") would be corrupted. +const DATE_WIDGET_TYPES: EFieldWidgetType[] = [ + EFieldWidgetType.DatePicker, + EFieldWidgetType.DateTimePicker, + EFieldWidgetType.TimePicker, + EFieldWidgetType.RangePicker, +]; export const isTime = (v: string): boolean => { // Accept common backend time shapes, including: @@ -78,7 +92,10 @@ export const transformValueToServer = (value: any): any => { if (isArray(value)) { return value.map(transformValueToServer); } - if (value.date) { + // Only real dayjs values are serialized via format(). Checking `value.date` + // would misfire on any plain object that happens to have a `date` key and + // throw (`value.format is not a function`), aborting the save. + if (dayjs.isDayjs(value)) { return value.format(); } return value; @@ -119,31 +136,59 @@ export const transformFiltersToServer = (data: any) => { return filtersData; }; -export const transformValueFromServer = (value: any): any => { +export const transformValueFromServer = ( + value: any, + widgetType?: EFieldWidgetType, +): any => { if (value === null || value === undefined) { return value; } if (isArray(value)) { - return value.map(transformValueFromServer); + return value.map((v: any) => transformValueFromServer(v, widgetType)); } if (isBoolean(value)) { return value !== "false" && !!value; } - if (isDate(value)) { - return dayjs(value); - } - if (isTime(value)) { - return dayjs(`1970-01-01T${value}`); - } - if (isDateTime(value)) { - return dayjs(value); + // Parse date/time strings into dayjs only for date widgets. When the widget + // type is unknown, fall back to shape detection for backward compatibility. + const parseDates = + widgetType === undefined || DATE_WIDGET_TYPES.includes(widgetType); + if (parseDates) { + if (isDate(value)) { + return dayjs(value); + } + if (isTime(value)) { + return dayjs(`1970-01-01T${value}`); + } + if (isDateTime(value)) { + return dayjs(value); + } } return value; }; -export const transformDataFromServer = (data: Record) => { +// Map each field name to the widget type used on the change form, so +// transformDataFromServer can decide, per field, whether a date-looking string +// should become a dayjs (real date widget) or stay a string (e.g. a Char field). +export const getChangeWidgetTypes = ( + modelConfiguration?: { fields?: IModelField[] }, +): Record => { + const widgetTypes: Record = {}; + for (const field of modelConfiguration?.fields || []) { + widgetTypes[field.name] = field.change_configuration?.form_widget_type; + } + return widgetTypes; +}; + +export const transformDataFromServer = ( + data: Record, + widgetTypes?: Record, +) => { return Object.fromEntries( - Object.entries(data).map(([k, v]) => [k, transformValueFromServer(v)]), + Object.entries(data).map(([k, v]) => [ + k, + transformValueFromServer(v, widgetTypes?.[k]), + ]), ); }; diff --git a/tests/api/test_helpers.py b/tests/api/test_helpers.py index 2c7e8ad..e1bc096 100644 --- a/tests/api/test_helpers.py +++ b/tests/api/test_helpers.py @@ -4,6 +4,7 @@ import jwt from fastadmin.api.helpers import ( + build_query_filters, get_template, is_valid_id, is_valid_uuid, @@ -100,6 +101,45 @@ async def test_sanitize_filter_key(): assert cond == "icontains" +async def test_sanitize_filter_value_type_aware(): + # Text fields keep the literal "true"/"false"/"null" strings. + text_field = _make_field("title") # WidgetType.Input + assert sanitize_filter_value("null", text_field) == "null" + assert sanitize_filter_value("true", text_field) == "true" + assert sanitize_filter_value("false", text_field) == "false" + assert sanitize_filter_value(["null", "true"], text_field) == ["null", "true"] + + # Non-text fields still coerce. + bool_field = ModelFieldWidgetSchema( + name="active", + column_name="active", + is_m2m=False, + is_pk=False, + is_immutable=False, + form_widget_type=WidgetType.Checkbox, + form_widget_props={}, + filter_widget_type=WidgetType.Checkbox, + filter_widget_props={}, + ) + assert sanitize_filter_value("true", bool_field) is True + assert sanitize_filter_value("null", bool_field) is None + + +async def test_build_query_filters(): + fields = [ + _make_field("title"), + _make_field("tournament", filter_widget_props={"parentModel": "Tournament"}), + ] + filters = {"title__exact": "null", "tournament": "5", "search": "x"} + result = build_query_filters(filters, fields, exclude=("search",)) + # Text field: the literal "null" is preserved, not coerced to None. + assert result[("title", "exact")] == "null" + # parentModel field gets the _id suffix. + assert result[("tournament_id", "exact")] == "5" + # Excluded keys are dropped. + assert all(field_name != "search" for field_name, _ in result) + + async def test_is_valid_uuid(): assert is_valid_uuid(str(uuid.uuid1())) is True assert is_valid_uuid(str(uuid.uuid3(uuid.uuid4(), "test"))) is True diff --git a/tests/models/test_orm.py b/tests/models/test_orm.py index c2d27d4..5e97989 100644 --- a/tests/models/test_orm.py +++ b/tests/models/test_orm.py @@ -437,6 +437,31 @@ async def test_sqlalchemy_orm_get_list_filter_operators(event, session_with_type assert isinstance(objs, list) +async def test_sqlalchemy_orm_get_list_relation_filters(event, session_with_type): + _, session_type = session_with_type + if session_type != "sqlalchemy": + return + + admin_model = get_admin_model(event.__class__) + + # Many-to-many relationship (uselist) is matched via any() on the related + # pk, for both the "exact" and "in" conditions. + objs, total = await admin_model.orm_get_list( + filters={ + ("participants", "exact"): "1", + ("participants", "in"): ["1", "2"], + } + ) + assert isinstance(total, int) + assert isinstance(objs, list) + + # To-one relationship is matched via has() on the related pk. + objs, total = await admin_model.orm_get_list(filters={("tournament", "exact"): str(event.tournament_id)}) + assert isinstance(total, int) + assert isinstance(objs, list) + assert any(getattr(obj, "id", None) == event.id for obj in objs) + + async def test_sqlalchemy_orm_get_list_ordering_non_column_is_skipped(event, session_with_type): _, session_type = session_with_type if session_type != "sqlalchemy":