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
21 changes: 8 additions & 13 deletions src/django_smartbase_admin/actions/admin_action_list.py
Original file line number Diff line number Diff line change
Expand Up @@ -149,24 +149,19 @@ def init_column_fields(self) -> None:
def get_filters(self):
return [field for field in self.column_fields if field.filter_widget]

def get_tabulator_columns_add_id_column_if_missing(self, add_id_column=True):
def get_tabulator_columns(self):
"""Serialized Tabulator columns + the pk field name (the frontend's
``tableIdColumnName``)."""
columns_serialized = []
id_column_name = None
for field in self.column_fields:
if getattr(field.model_field, "primary_key", False):
id_column_name = field.field
columns_serialized.append(field.serialize_tabulator())
# Add ID column in case there is none
if add_id_column and not id_column_name:
model_id_field = self.get_pk_field()
id_column_name = model_id_field.name
id_field = self.view.auto_create_field_from_model_field(model_id_field)
id_field.title = "ID"
id_field.list_visible = False
id_field.init_field_static(
self.view, self.threadsafe_request.request_data.configuration
)
columns_serialized = [id_field.serialize_tabulator()] + columns_serialized
if id_column_name is None:
# No column is the pk (e.g. an annotation addresses it instead);
# the pk value is still in every row, so name it directly.
id_column_name = self.get_pk_field().name
return columns_serialized, id_column_name

def get_excel_columns(self):
Expand Down Expand Up @@ -208,7 +203,7 @@ def get_template_data(self):
"AJAX_NOTIFICATIONS_KEY": SB_ADMIN_AJAX_NOTIFICATIONS_KEY,
}

columns, id_column_name = self.get_tabulator_columns_add_id_column_if_missing()
columns, id_column_name = self.get_tabulator_columns()
row_actions = self.view.get_sbadmin_row_actions_processed(
self.threadsafe_request
)
Expand Down
8 changes: 5 additions & 3 deletions src/django_smartbase_admin/audit/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -142,15 +142,17 @@ def _extract_fk_affected(model, changes: dict) -> list[dict]:
old_repr = None
new_repr = None

# Add old value if present
# Add old value if present. ``old_val`` / ``new_val`` arrive already
# JSON-safe from serialization (int PKs stay int, UUID/char PKs are
# str) — use them as-is. Casting to int() crashed on non-int PKs.
if old_val:
obj_repr = old_repr or _get_object_repr(related_model, old_val)
affected.append({"ct": ct_label, "id": int(old_val), "repr": obj_repr})
affected.append({"ct": ct_label, "id": old_val, "repr": obj_repr})

# Add new value if present and different
if new_val and new_val != old_val:
obj_repr = new_repr or _get_object_repr(related_model, new_val)
affected.append({"ct": ct_label, "id": int(new_val), "repr": obj_repr})
affected.append({"ct": ct_label, "id": new_val, "repr": obj_repr})

return affected

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ def get_pk_field(self):
def get_filters(self):
return []

def get_tabulator_columns_add_id_column_if_missing(self, add_id_column=True):
def get_tabulator_columns(self):
return [], "id"


Expand Down
44 changes: 44 additions & 0 deletions src/django_smartbase_admin/audit/tests/test_fk_affected.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
"""``_extract_fk_affected`` must keep FK pks as they arrive (already
JSON-safe from serialization): int pks stay int, and non-int pks (UUID /
char) are kept as their string form instead of crashing on ``int(...)`` —
the regression that logged a noisy "Failed to create audit log" whenever a
FK pointed at a UUID-keyed model.
"""

from __future__ import annotations

from django.contrib.auth.models import Permission
from django.test import SimpleTestCase

from django_smartbase_admin.audit.manager import _extract_fk_affected

# ``Permission.content_type`` is a ForeignKey; the related model's own pk
# type is irrelevant to the bug — only the value handed in matters.


class ExtractFkAffectedTests(SimpleTestCase):
def test_uuid_string_pk_kept_not_cast_to_int(self):
uid = "aa57f521-1c2b-4f3a-9d4e-5f6a7b8c9d0e"
affected = _extract_fk_affected(
Permission,
{"content_type": {"old": None, "new": uid, "new_display": "Queue X"}},
)
self.assertEqual(
affected,
[{"ct": "contenttypes.contenttype", "id": uid, "repr": "Queue X"}],
)

def test_integer_pk_preserved_as_int(self):
affected = _extract_fk_affected(
Permission,
{
"content_type": {
"old": 7,
"new": 9,
"old_display": "A",
"new_display": "B",
}
},
)
self.assertEqual([a["id"] for a in affected], [7, 9])
self.assertTrue(all(isinstance(a["id"], int) for a in affected))
67 changes: 65 additions & 2 deletions src/django_smartbase_admin/engine/admin_base_view.py
Original file line number Diff line number Diff line change
Expand Up @@ -290,7 +290,7 @@ def init_view_dynamic(self, request, request_data=None, **kwargs):

def get_field_map(self, request) -> dict[str, "SBAdminField"]:
return self.init_fields_cache(
self.get_sbadmin_list_display(request),
self.get_effective_list_display(request),
request.request_data.configuration,
request=request,
)
Expand Down Expand Up @@ -826,6 +826,69 @@ def init_view_dynamic(self, request, request_data=None, **kwargs) -> None:
def get_sbadmin_list_display(self, request) -> list[str] | list:
return self.sbadmin_list_display or self.list_display or []

def get_effective_list_display(self, request) -> list:
"""``get_sbadmin_list_display`` augmented with a synthetic,
read-only primary-key column when the admin hasn't declared one.

The list pipeline already emits every row's pk under a normalized
``"id"`` key, but without a matching field nothing can *select*,
*sort*, or *filter* by it — the field map rejects the name (this is
what made MCP ``list_rows(fields=["id"])`` fail, and why the browser
only ever got a display-only id column). Promoting the pk to a real,
hidden column closes that round-trip through the normal column /
sort / filter machinery, for the UI and MCP alike. Hidden by default
(``list_visible=False``) so it doesn't clutter the grid. With the pk
now a real column, :meth:`get_tabulator_columns` reports its id-column
name directly instead of grafting on a display-only one.
"""
list_display = list(self.get_sbadmin_list_display(request) or [])
pk_field = self._build_synthetic_pk_field(list_display)
if pk_field is not None:
list_display.append(pk_field)
return list_display

def _build_synthetic_pk_field(self, list_display):
"""Synthetic read-only pk column for the list — the canonical ``id``
column the agent uses for select / sort / filter, and the frontend for
row identity.

Returns ``None`` when a declared column already addresses the pk — by
name, by an explicit ``filter_field`` pointing at it, or via a method
``@admin.display(ordering="id")`` — since the author has already
exposed it and a second column would be redundant; or when the pk
can't be resolved (e.g. a composite pk).
"""
from django_smartbase_admin.engine.filter_widgets import (
PrimaryKeyFilterWidget,
)

model = getattr(self, "model", None)
if model is None:
return None
pk_model_field = getattr(model._meta, "pk", None)
pk_name = getattr(pk_model_field, "name", None)
if not pk_name:
return None

for entry in list_display:
name = getattr(entry, "name", entry)
method = getattr(self, name, None) if isinstance(name, str) else None
if (
name == pk_name
or getattr(entry, "filter_field", None) == pk_name
Comment thread
oko-x marked this conversation as resolved.
or (
callable(method)
and getattr(method, "admin_order_field", None) == pk_name
)
):
return None

field = self.auto_create_field_from_model_field(pk_model_field)
Comment thread
oko-x marked this conversation as resolved.
field.title = "ID"
field.list_visible = False # off by default; still select/sort/filterable
field.filter_widget = PrimaryKeyFilterWidget()
return field

def _register_list_filter_autocomplete(self, request) -> None:
field_map = self.init_fields_cache(
self.get_sbadmin_list_display(request),
Expand All @@ -840,7 +903,7 @@ def _register_list_filter_autocomplete(self, request) -> None:
def get_list_display(self, request) -> list[str]:
return [
getattr(field, "name", field)
for field in self.get_sbadmin_list_display(request)
for field in self.get_effective_list_display(request)
]

def get_search_fields(self, request):
Expand Down
82 changes: 81 additions & 1 deletion src/django_smartbase_admin/engine/filter_widgets.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import json
import re
from datetime import datetime, timedelta

from django import forms
from django.core.exceptions import ImproperlyConfigured
from django.core.exceptions import ImproperlyConfigured, ValidationError
from django.contrib.postgres.fields import ArrayField
from django.db.models import Q, fields, FilteredRelation, Count
from django.http import JsonResponse
Expand Down Expand Up @@ -386,6 +387,85 @@ def get_advanced_filter_operators(self):
]


class PrimaryKeyFilterWidget(SBAdminFilterWidget):
"""Exact-match / ``IN`` filter on a primary key.

Accepts a single pk or a list of pks and filters
``<filter_field>__in=[...]``. Attached to the synthetic primary-key
column the list view exposes when the admin hasn't declared one, so a
row can be re-fetched by the ``id`` it already shows — in the browser
(a text box: one id, or several comma-separated) and over MCP (a pk or
a list of pks). Not auto-detected for any model field type; only used
when set explicitly.
"""

# A pk is typed, not picked — reuse the plain text-input template.
template_name = "sb_admin/filter_widgets/string_field.html"
close_dropdown_on_change = True

_SEPARATORS = re.compile(r"[\s,;]+")

def parse_value_from_input(self, request, filter_value):
"""Flat list of pks. Accepts a native scalar/list (MCP) or a string of
pks separated by commas, whitespace, or semicolons (the UI text box).
Values are coerced through the pk field; ones that can't be this pk
(e.g. ``"abc"`` for an int pk) are dropped, not handed to the ORM."""
value = filter_value
if isinstance(value, str):
value = [part for part in self._SEPARATORS.split(value.strip()) if part]
if not isinstance(value, list):
value = [value]
parsed = []
for item in value:
if item in forms.Field.empty_values:
continue
if self.model_field is not None:
try:
item = self.model_field.get_prep_value(item)
except (ValueError, TypeError, ValidationError):
continue
parsed.append(item)
return parsed

def get_base_filter_query_for_parsed_value(self, request, parsed_value):
if not parsed_value:
# Truly-empty values are dropped before the widget runs, so an
# empty list here is non-empty input that coerced to nothing —
# invalid. Fail closed so an active filter can't silently widen to
# the whole table (which would also widen bulk actions / exports).
return Q(pk__in=[])
return Q(**{f"{self.field.filter_field}__in": parsed_value})
Comment thread
oko-x marked this conversation as resolved.

def validate_value(self, value) -> None:
items = value if isinstance(value, list) else [value]
for item in items:
# ``bool`` is an ``int`` subclass but never a valid pk here.
if isinstance(item, bool) or not isinstance(item, (int, str)):
raise ValueError(
"PrimaryKeyFilterWidget expects a primary key or a list of "
"primary keys (int or str), got "
f"{type(item).__name__}: {item!r}"
)
# Reject up front so MCP gets a clear error, not an ORM failure
# mid-query.
if self.model_field is not None:
try:
self.model_field.get_prep_value(item)
except (ValueError, TypeError, ValidationError):
raise ValueError(
f"PrimaryKeyFilterWidget got {item!r}, not a valid "
f"{self.model_field.get_internal_type()} id"
)

def get_advanced_filter_operators(self):
return [
AllOperators.IN,
AllOperators.NOT_IN,
AllOperators.IS_NULL,
AllOperators.IS_NOT_NULL,
]


class NumberRangeFilterWidget(AutocompleteParseMixin, SBAdminFilterWidget):
template_name = "sb_admin/filter_widgets/number_range_field.html"

Expand Down
31 changes: 27 additions & 4 deletions src/django_smartbase_admin/mcp/mcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,9 @@ def _validate_sort(admin, request, sort, field_map=None) -> None:
field_map = admin.get_field_map(request)
field_map = field_map or {}
for entry in sort:
if not isinstance(entry, dict) or "field" not in entry:
# ``dir`` is required, not defaulted — the list pipeline reads
# ``sort['dir']`` directly, so a missing key must fail clearly here.
if not isinstance(entry, dict) or "field" not in entry or "dir" not in entry:
raise ValueError(
"Each sort entry must be {'field': <name>, 'dir': "
f"'asc'|'desc'}}, got {entry!r}"
Expand All @@ -172,9 +174,8 @@ def _validate_sort(admin, request, sort, field_map=None) -> None:
f"Admin {admin.get_id()!r} cannot sort by {name!r}; "
f"sortable fields: {sorted(field_map)}."
)
direction = entry.get("dir", "asc")
if direction not in ("asc", "desc"):
raise ValueError(f"sort dir must be 'asc' or 'desc', got {direction!r}")
if entry["dir"] not in ("asc", "desc"):
raise ValueError(f"sort dir must be 'asc' or 'desc', got {entry['dir']!r}")


def _decode_preset_url_params(url_params) -> dict:
Expand Down Expand Up @@ -517,6 +518,9 @@ def list_rows(
``list_admins["admin_views"][].fields[].name``. Every row also
carries a normalized ``"id"`` key for row identity, regardless
of the model's pk field name (so ``row["id"]`` is always safe).
``"id"`` is itself a selectable, sortable, and filterable column
— filter it with one id or a list of ids to re-fetch the exact
rows behind ids you already saw.
filter_data: ``{field: value}`` mapping. Each **key** is a column
``name`` from ``list_admins["admin_views"][].fields[].name`` —
the same identifier ``fields`` and ``sort`` use. (Keys returned
Expand Down Expand Up @@ -590,6 +594,25 @@ def list_rows(
# Built once and shared — ``get_field_map`` rebuilds + clones on
# every call.
field_map = admin.get_field_map(request)
# Accept ``"id"`` as an alias for a differently-named pk on input, so
# the documented refetch works (output always mirrors the pk to
# ``"id"``). No-op for the usual ``id``-pk model.
pk_name = admin.model._meta.pk.name
if pk_name != "id" and "id" not in field_map:
fields = [pk_name if f == "id" else f for f in fields]
if filter_data:
filter_data = {
(pk_name if k == "id" else k): v for k, v in filter_data.items()
}
if sort:
sort = [
(
{**s, "field": pk_name}
if isinstance(s, dict) and s.get("field") == "id"
else s
)
for s in sort
]
filter_data = _normalize_filter_keys(filter_data, field_map)
_validate_filter_data(admin, request, filter_data, field_map)
_validate_sort(admin, request, sort, field_map)
Expand Down
9 changes: 9 additions & 0 deletions src/django_smartbase_admin/mcp/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
DateFilterWidget,
MultipleChoiceFilterWidget,
NumberRangeFilterWidget,
PrimaryKeyFilterWidget,
)
from django_smartbase_admin.mcp.actions import collect_action_entries
from django_smartbase_admin.mcp.service import SBAdminMCPDetailService
Expand All @@ -46,6 +47,7 @@
# Order matters: ``MultipleChoiceFilterWidget`` subclasses
# ``ChoiceFilterWidget``, so it must be checked first.
_WIDGET_CATEGORIES: tuple[tuple[type, str], ...] = (
(PrimaryKeyFilterWidget, "PrimaryKeyFilterWidget"),
(DateFilterWidget, "DateFilterWidget"),
(NumberRangeFilterWidget, "NumberRangeFilterWidget"),
(BooleanFilterWidget, "BooleanFilterWidget"),
Expand All @@ -55,6 +57,13 @@
)

WIDGET_SHAPES: dict[str, dict] = {
"PrimaryKeyFilterWidget": {
"value_shape": (
"an id, or a list of ids, matched exactly (SQL IN); pass the "
"ids you already saw in the data to re-fetch those exact rows"
),
"example": [42, 7, 13],
},
"DateFilterWidget": {
"value_shape": (
"[start, end] — list of two ISO-8601 dates ('YYYY-MM-DD'); "
Expand Down
Loading
Loading