|
| 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