Skip to content

Commit 3f051b7

Browse files
authored
Tabulator Nested data (#130)
* docs * nested tabulator data * nested tabulator data --------- Co-authored-by: oko-vac <oko-vac@users.noreply.github.com>
1 parent eb71a6c commit 3f051b7

11 files changed

Lines changed: 1620 additions & 19 deletions

File tree

AGENTS.md

Lines changed: 604 additions & 0 deletions
Large diffs are not rendered by default.

src/django_smartbase_admin/actions/admin_action_list.py

Lines changed: 70 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -370,7 +370,9 @@ def search_in_queryset(self, base_qs):
370370
)
371371
return base_qs
372372

373-
def build_final_data_count_queryset(self, additional_filter=None):
373+
def build_final_data_count_queryset(
374+
self, additional_filter=None, apply_plugins=True
375+
):
374376
additional_filter = additional_filter or Q()
375377
filter_fields = self.get_filter_fields_from_request()
376378
base_qs = (
@@ -379,20 +381,59 @@ def build_final_data_count_queryset(self, additional_filter=None):
379381
.filter(additional_filter)
380382
)
381383
base_qs = self.search_in_queryset(base_qs)
384+
if apply_plugins:
385+
request = self.threadsafe_request
386+
plugins = list(request.request_data.configuration.plugins)
387+
for plugin in plugins:
388+
base_qs = plugin.modify_count_queryset(
389+
self,
390+
request=request,
391+
qs=base_qs,
392+
)
382393
return base_qs
383394

384-
def build_final_data_queryset(self, page_num, page_size, additional_filter=None):
395+
def build_final_data_queryset(
396+
self, page_num, page_size, additional_filter=None, apply_plugins=True
397+
):
398+
"""Return the sliced data qs for the current page.
399+
400+
``apply_plugins=False`` is the escape hatch plugins use to
401+
re-enter this method and grab the raw filtered+ordered qs
402+
without recursing back into their own hooks.
403+
"""
385404
additional_filter = additional_filter or Q()
386405
from_item = (page_num - 1) * page_size
387406
to_item = page_num * page_size
388-
data_queryset = self.get_data_queryset()
389-
base_qs = (
390-
data_queryset.values(*self.get_data_queryset_values())
391-
.filter(self.get_filter_from_request())
392-
.filter(additional_filter)
407+
values = list(self.get_data_queryset_values())
408+
base_qs = self.get_data_queryset().values(*values)
409+
410+
request = self.threadsafe_request
411+
plugins = (
412+
list(request.request_data.configuration.plugins) if apply_plugins else []
413+
)
414+
for plugin in plugins:
415+
base_qs = plugin.modify_base_queryset(
416+
self,
417+
request=request,
418+
qs=base_qs,
419+
values=values,
420+
)
421+
422+
base_qs = base_qs.filter(self.get_filter_from_request()).filter(
423+
additional_filter
393424
)
394425
base_qs = self.search_in_queryset(base_qs)
395-
return base_qs.order_by(*self.get_order_by_from_request())[from_item:to_item]
426+
base_qs = base_qs.order_by(*self.get_order_by_from_request())
427+
if apply_plugins:
428+
for plugin in plugins:
429+
base_qs = plugin.modify_data_queryset(
430+
self,
431+
request=request,
432+
qs=base_qs,
433+
page_num=page_num,
434+
page_size=page_size,
435+
)
436+
return base_qs[from_item:to_item]
396437

397438
def get_data(self, page_num=None, page_size=None, additional_filter=None):
398439
additional_filter = additional_filter or Q()
@@ -401,14 +442,23 @@ def get_data(self, page_num=None, page_size=None, additional_filter=None):
401442
page_size = page_size or self.page_size
402443

403444
total_count = self.build_final_data_count_queryset(additional_filter).count()
404-
final_data = list(
405-
self.build_final_data_queryset(page_num, page_size, additional_filter)
406-
)
407445

408-
self.process_final_data(final_data)
446+
data_qs = self.build_final_data_queryset(page_num, page_size, additional_filter)
447+
data = list(data_qs)
448+
449+
self.process_final_data(data)
450+
request = self.threadsafe_request
451+
plugins = list(request.request_data.configuration.plugins)
452+
for plugin in plugins:
453+
data = plugin.modify_final_data(
454+
self,
455+
request=request,
456+
data=data,
457+
)
458+
409459
return {
410460
"last_page": math.ceil(total_count / page_size),
411-
"data": final_data,
461+
"data": data,
412462
"last_row": total_count,
413463
}
414464

@@ -488,6 +538,13 @@ def get_xlsx_data(self, request):
488538
additional_filter=additional_filter,
489539
)["data"]
490540
)
541+
plugins = list(request.request_data.configuration.plugins)
542+
for plugin in plugins:
543+
data_list = plugin.modify_xlsx_data(
544+
self,
545+
request=request,
546+
data=data_list,
547+
)
491548
options = (
492549
self.view.get_sbadmin_xlsx_options(request).to_json()
493550
if self.view.get_sbadmin_xlsx_options(request)

src/django_smartbase_admin/admin/admin_base.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -890,7 +890,9 @@ def get_previous_next_context(self, request, object_id) -> dict | dict[str, Any]
890890
request, object_id
891891
)
892892
all_ids = list(
893-
list_action.build_final_data_count_queryset(additional_filter)
893+
list_action.build_final_data_count_queryset(
894+
additional_filter, apply_plugins=False
895+
)
894896
.order_by(*list_action.get_order_by_from_request())
895897
.values_list("id", flat=True)
896898
)

src/django_smartbase_admin/engine/admin_base_view.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -318,11 +318,21 @@ class SBAdminBaseListView(SBAdminBaseView):
318318
sbadmin_table_history_enabled = True
319319
sbadmin_list_history_enabled = True
320320
sbadmin_list_reorder_field = None
321+
sbadmin_nested: dict | None = None
321322
search_field_placeholder = _("Search...")
322323
filters_version = None
323324
sbadmin_actions_initialized = False
324325
sbadmin_list_action_class = SBAdminListAction
325326

327+
def get_sbadmin_nested(self, request) -> dict | None:
328+
"""Return the nested config dict for this view, or ``None`` for a flat list.
329+
330+
Override for per-request logic. The returned dict must contain a
331+
``parent_field`` key pointing at a self-referential ForeignKey.
332+
See ``plugins/nested.py`` for the full schema.
333+
"""
334+
return self.sbadmin_nested
335+
326336
def activate_reorder(self, request) -> None:
327337
request.reorder_active = True
328338

@@ -538,6 +548,12 @@ def get_tabulator_definition(self, request) -> dict[str, Any]:
538548
"headerTabsModule",
539549
]
540550
)
551+
for plugin in request.request_data.configuration.plugins:
552+
tabulator_definition = plugin.modify_tabulator_definition(
553+
self,
554+
request=request,
555+
definition=tabulator_definition,
556+
)
541557
return tabulator_definition
542558

543559
def _get_sbadmin_list_actions(self, request) -> list[SBAdminCustomAction] | list:

src/django_smartbase_admin/engine/configuration.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,11 @@ class SBAdminRoleConfiguration(metaclass=Singleton):
187187
default_color_scheme = ColorScheme.AUTO
188188
login_view_class = LoginView
189189
admin_title = "SBAdmin"
190+
# List of SBAdminPlugin subclasses that participate in every list
191+
# view's data pipeline and Tabulator definition. See
192+
# ``plugins/base.py`` for the protocol. Each plugin hook is expected
193+
# to self-guard based on admin config (e.g. ``sbadmin_nested``).
194+
plugins: list = []
190195

191196
def __init__(
192197
self,
@@ -198,6 +203,7 @@ def __init__(
198203
default_color_scheme=None,
199204
login_view_class=None,
200205
admin_title=None,
206+
plugins=None,
201207
) -> None:
202208
super().__init__()
203209
self.default_view = default_view or self.default_view or []
@@ -210,6 +216,9 @@ def __init__(
210216
self.default_color_scheme = default_color_scheme or self.default_color_scheme
211217
self.login_view_class = login_view_class or self.login_view_class
212218
self.admin_title = admin_title or self.admin_title
219+
# Copy the class-level list to avoid accidental cross-instance
220+
# mutation when subclasses assign ``plugins = [...]``.
221+
self.plugins = list(plugins if plugins is not None else self.plugins)
213222

214223
def init_registered_views(self):
215224
registered_views = []

src/django_smartbase_admin/plugins/__init__.py

Whitespace-only changes.
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
"""Plugin protocol for SBAdmin list views.
2+
3+
Plugins are registered on :class:`SBAdminRoleConfiguration` via the
4+
``plugins=[...]`` constructor argument. Subclasses override only the
5+
hooks they care about and self-guard by inspecting admin config
6+
(e.g. ``view.sbadmin_nested``) — returning the input unchanged when
7+
the plugin doesn't apply.
8+
9+
Hook pipeline (in call order):
10+
11+
1. ``modify_tabulator_definition`` — Tabulator JSON sent to the client.
12+
2. ``modify_count_queryset`` — the qs ``.count()`` runs on (e.g. group
13+
by parent id so pagination counts parent groups, not rows).
14+
3. ``modify_base_queryset`` — unfiltered ``.values()``-applied qs;
15+
**store-only**. Reshaping here leaks into the visible page.
16+
4. ``modify_data_queryset`` — unsliced, filtered, ordered qs; returned
17+
qs is sliced ``[from:to]`` by the caller.
18+
5. ``modify_final_data`` — reshape the already-formatted row dicts
19+
(e.g. assemble ``_children`` trees from group metadata).
20+
6. ``modify_xlsx_data`` — final pass before XLSX serialization, after
21+
all paged ``get_data`` chunks are concatenated (e.g. flatten a
22+
``_children`` tree back into sibling rows the spreadsheet can render).
23+
24+
Hook contract:
25+
26+
* Hooks are ``classmethod`` — plugins are stateless.
27+
* Every hook takes ``request`` + ``**kwargs`` so call sites stay
28+
backwards compatible.
29+
* Plugins that need to re-enter ``build_final_data_{count_,}queryset``
30+
pass ``apply_plugins=False`` to avoid recursion.
31+
* Cross-hook state goes through
32+
:meth:`SBAdminPlugin.get_request_data_plugin_store`; the action
33+
never writes into a plugin's slot.
34+
"""
35+
36+
from typing import TYPE_CHECKING, Any
37+
38+
if TYPE_CHECKING:
39+
from django.db.models import QuerySet
40+
from django.http import HttpRequest
41+
42+
from django_smartbase_admin.actions.admin_action_list import SBAdminListAction
43+
from django_smartbase_admin.engine.admin_base_view import SBAdminBaseListView
44+
45+
46+
#: Slot on ``request.request_data.additional_data``; each plugin
47+
#: gets its own sub-dict keyed by ``cls.__name__`` so plugins don't
48+
#: stomp on each other.
49+
PLUGIN_DATA_KEY = "sbadmin_plugin_data"
50+
51+
52+
class SBAdminPlugin:
53+
"""Classmethod-only plugin base for list views.
54+
55+
Plugins are stateless by contract — within a single request they
56+
share state across hooks via
57+
:meth:`get_request_data_plugin_store`. The action never writes
58+
into a plugin's slot; all queryset building and stashing is the
59+
plugin's own responsibility.
60+
"""
61+
62+
@classmethod
63+
def get_request_data_plugin_store(cls, request: "HttpRequest") -> dict[str, Any]:
64+
"""Per-request, per-plugin-class scratch dict, keyed by
65+
``cls.__name__`` so sibling plugins don't collide."""
66+
additional = request.request_data.additional_data
67+
store: dict[str, dict[str, Any]] = additional.setdefault(PLUGIN_DATA_KEY, {})
68+
return store.setdefault(cls.__name__, {})
69+
70+
@classmethod
71+
def modify_base_queryset(
72+
cls,
73+
action: "SBAdminListAction",
74+
request: "HttpRequest",
75+
qs: "QuerySet",
76+
values: list[str],
77+
**kwargs: Any,
78+
) -> "QuerySet":
79+
"""Observation hook — **store-only**. Any reshape here
80+
propagates into filter / search / order / slice and silently
81+
changes the visible page; return ``qs`` unchanged."""
82+
return qs
83+
84+
@classmethod
85+
def modify_tabulator_definition(
86+
cls,
87+
view: "SBAdminBaseListView",
88+
request: "HttpRequest",
89+
definition: dict[str, Any],
90+
**kwargs: Any,
91+
) -> dict[str, Any]:
92+
return definition
93+
94+
@classmethod
95+
def modify_count_queryset(
96+
cls,
97+
action: "SBAdminListAction",
98+
request: "HttpRequest",
99+
qs: "QuerySet",
100+
**kwargs: Any,
101+
) -> "QuerySet":
102+
"""Reshape the qs ``.count()`` runs on (e.g. group by parent
103+
id so pagination counts parent groups, not rows)."""
104+
return qs
105+
106+
@classmethod
107+
def modify_data_queryset(
108+
cls,
109+
action: "SBAdminListAction",
110+
request: "HttpRequest",
111+
qs: "QuerySet",
112+
page_num: int,
113+
page_size: int,
114+
**kwargs: Any,
115+
) -> "QuerySet":
116+
"""Reshape the unsliced, filtered, ordered data qs; caller
117+
slices ``[from:to]`` on the return value."""
118+
return qs
119+
120+
@classmethod
121+
def modify_final_data(
122+
cls,
123+
action: "SBAdminListAction",
124+
request: "HttpRequest",
125+
data: list[dict[str, Any]],
126+
**kwargs: Any,
127+
) -> list[dict[str, Any]]:
128+
"""Reshape rows **after** column formatters have run (e.g.
129+
assemble a ``_children`` tree from group metadata)."""
130+
return data
131+
132+
@classmethod
133+
def modify_xlsx_data(
134+
cls,
135+
action: "SBAdminListAction",
136+
request: "HttpRequest",
137+
data: list[dict[str, Any]],
138+
**kwargs: Any,
139+
) -> list[dict[str, Any]]:
140+
"""Final pass before XLSX serialization. Runs once on the
141+
concatenated rows from all paged ``get_data`` chunks — the
142+
right place to unbundle tree rows (``_children``) that the
143+
spreadsheet can't render nested."""
144+
return data

0 commit comments

Comments
 (0)