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: 21 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -878,6 +878,26 @@ Styles live in `static/sb_admin/src/css/_tabulator.css`: the icon is hidden on s

> **Recommendation:** Prefer `MultipleChoiceFilterWidget` over `ChoiceFilterWidget` for choice-based filters. It provides a better UX and gives users more flexibility to select multiple values at once.

### Grouped Choices

Both `ChoiceFilterWidget` and `MultipleChoiceFilterWidget` accept Django-style grouped choices in addition to the flat form. Grouped input renders a header (`<optgroup>` in select templates, a styled header `<li>` in the checkbox-dropdown templates); flat input renders identically to before.

```python
# Flat (unchanged)
MultipleChoiceFilterWidget(choices=[
("draft", "Draft"),
("published", "Published"),
])

# Grouped — shipper is the group header
MultipleChoiceFilterWidget(choices=[
("GLS", [("1", "Insurance"), ("2", "Signature required")]),
("SPS", [("5", "Special handling"), ("6", "Overweight")]),
])
```

Detection follows the same rule Django's `ChoiceWidget.optgroups` uses: the top-level structure is grouped if the second element of the first item is a list/tuple. Mixing flat and grouped choices in the same call is not supported.

### Custom Filter Widget Example

```python
Expand Down Expand Up @@ -3312,6 +3332,7 @@ Quick reference for all `sbadmin_` prefixed class attributes available in `SBAdm
| `sbadmin_list_reorder_field` | str | Field name for drag-and-drop row reordering |
| `sbadmin_xlsx_options` | dict | Excel export configuration options |
| `sbadmin_table_history_enabled` | bool | Enable/disable table state history (default: `True`) |
| `sbadmin_list_sticky_header_and_footer` | bool \| None | Enable sticky Tabulator column header together with sticky pagination footer and synced horizontal scrollbar. `None` falls back to `SBAdminRoleConfiguration.default_list_sticky_header_and_footer`; explicit `True`/`False` overrides the global setting. |

### Detail/Change View Attributes (SBAdmin)

Expand Down
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,15 @@ MIDDLEWARE = [
]
```

Enable Django i18n URLs so `{% url 'set_language' %}` is available for the navigation language picker:
```python
from django.urls import include, path

urlpatterns = [
path("i18n/", include("django.conf.urls.i18n")),
]
```

## 🔍 Audit Logging

Built-in optional audit app that automatically tracks all admin create, update, delete, and bulk operations with field-level diffs, snapshots, and request grouping. Just add `"django_smartbase_admin.audit"` to `INSTALLED_APPS` and run migrations.
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[tool.poetry]
name = "django-smartbase-admin"
version = "1.1.8"
version = "1.1.7b1"
description = ""
authors = ["SmartBase <info@smartbase.sk>"]
readme = "README.md"
Expand Down
47 changes: 44 additions & 3 deletions src/django_smartbase_admin/engine/admin_base_view.py
Original file line number Diff line number Diff line change
Expand Up @@ -151,9 +151,21 @@ def get_field_map(self, request) -> dict[str, "SBAdminField"]:
return self.field_cache

def init_fields_cache(self, fields_source, configuration, force=False):
if not force and self.field_cache:
return self.field_cache.values()
from django_smartbase_admin.engine.field import SBAdminField
from django_smartbase_admin.services.thread_local import (
SBAdminThreadLocalService,
)

try:
request = SBAdminThreadLocalService.get_request()
except LookupError:
request = None
cache_key = self.get_id()
if request is not None:
request_field_cache = getattr(request, "_sbadmin_field_cache", {})
if not force and cache_key in request_field_cache:
self.field_cache = request_field_cache[cache_key]
return self.field_cache.values()

fields = []
self.field_cache = {}
Expand All @@ -163,6 +175,10 @@ def init_fields_cache(self, fields_source, configuration, force=False):
field.init_field_static(self, configuration)
fields.append(field)
self.field_cache[field.name] = field
if request is not None:
request_field_cache = getattr(request, "_sbadmin_field_cache", {})
request_field_cache[cache_key] = self.field_cache
request._sbadmin_field_cache = request_field_cache
return fields

def get_action_url(self, action, modifier="template"):
Expand Down Expand Up @@ -217,6 +233,13 @@ def get_color_scheme_context(self, request):
"color_scheme_form": color_scheme_form,
}

def get_language_form_context(self, request):
if len(settings.LANGUAGES) <= 1:
return {"language_form": None}
from django_smartbase_admin.views.user_config_view import LanguageForm

return {"language_form": LanguageForm(request=request)}

def get_add_label(
self, request: HttpRequest, object_id: str | None = None
) -> str | None:
Expand Down Expand Up @@ -271,6 +294,7 @@ def get_global_context(
}
),
**self.get_color_scheme_context(request),
**self.get_language_form_context(request),
}

def get_model_path(self) -> str:
Expand Down Expand Up @@ -319,6 +343,7 @@ class SBAdminBaseListView(SBAdminBaseView):
sbadmin_list_history_enabled = True
sbadmin_list_reorder_field = None
sbadmin_nested: dict | None = None
sbadmin_list_sticky_header_and_footer = None
search_field_placeholder = _("Search...")
filters_version = None
sbadmin_actions_initialized = False
Expand Down Expand Up @@ -490,8 +515,16 @@ def has_add_permission(self, request, obj=None) -> bool:
return False
return super().has_add_permission(request)

def get_sbadmin_list_sticky_header_and_footer(self, request) -> bool:
if self.sbadmin_list_sticky_header_and_footer is not None:
return self.sbadmin_list_sticky_header_and_footer
return request.request_data.configuration.default_list_sticky_header_and_footer

def get_tabulator_definition(self, request) -> dict[str, Any]:
view_id = self.get_id()
sticky_header_and_footer = self.get_sbadmin_list_sticky_header_and_footer(
request
)
tabulator_definition = {
"viewId": view_id,
"advancedFilterId": f"{view_id}" + "-advanced-filter",
Expand All @@ -510,6 +543,7 @@ def get_tabulator_definition(self, request) -> dict[str, Any]:
"tableInitialSort": self.get_list_initial_order(request),
"tableInitialPageSize": self.get_list_per_page(request),
"tableHistoryEnabled": self.sbadmin_table_history_enabled,
"stickyHeaderAndFooter": sticky_header_and_footer,
# used to initialize all columns with these values
"defaultColumnData": {},
"locale": request.LANGUAGE_CODE,
Expand All @@ -520,6 +554,7 @@ def get_tabulator_definition(self, request) -> dict[str, Any]:
"filterModule",
"tableParamsModule",
"detailViewModule",
"dataTreeModule",
],
"tabulatorOptions": {
"renderVertical": "basic",
Expand Down Expand Up @@ -554,6 +589,8 @@ def get_tabulator_definition(self, request) -> dict[str, Any]:
request=request,
definition=tabulator_definition,
)
if sticky_header_and_footer:
tabulator_definition["modules"].append("stickyHeaderAndFooterModule")
return tabulator_definition

def _get_sbadmin_list_actions(self, request) -> list[SBAdminCustomAction] | list:
Expand Down Expand Up @@ -777,7 +814,11 @@ def get_all_config(self, request) -> dict[str, Any]:
if not list_filter:
return all_config
list_fields = self.get_sbadmin_list_display(request) or []
self.init_fields_cache(list_fields, request.request_data.configuration)
initialized_fields = self.init_fields_cache(
list_fields, request.request_data.configuration
)
if initialized_fields is not None:
list_fields = initialized_fields
base_filter = {
getattr(field, "filter_field", field): ""
for field in list_fields
Expand Down
6 changes: 6 additions & 0 deletions src/django_smartbase_admin/engine/configuration.py
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,7 @@ class SBAdminRoleConfiguration(metaclass=Singleton):
# ``plugins/base.py`` for the protocol. Each plugin hook is expected
# to self-guard based on admin config (e.g. ``sbadmin_nested``).
plugins: list = []
default_list_sticky_header_and_footer = True

def __init__(
self,
Expand All @@ -204,6 +205,7 @@ def __init__(
login_view_class=None,
admin_title=None,
plugins=None,
default_list_sticky_header_and_footer=None,
) -> None:
super().__init__()
self.default_view = default_view or self.default_view or []
Expand All @@ -219,6 +221,10 @@ def __init__(
# Copy the class-level list to avoid accidental cross-instance
# mutation when subclasses assign ``plugins = [...]``.
self.plugins = list(plugins if plugins is not None else self.plugins)
if default_list_sticky_header_and_footer is not None:
self.default_list_sticky_header_and_footer = (
default_list_sticky_header_and_footer
)

def init_registered_views(self):
registered_views = []
Expand Down
34 changes: 33 additions & 1 deletion src/django_smartbase_admin/engine/filter_widgets.py
Original file line number Diff line number Diff line change
Expand Up @@ -257,13 +257,45 @@ def __init__(
)
self.choices = self.choices or choices

@property
def grouped_choices(self):
"""Normalise ``choices`` into ``[(group_label_or_None, [(value, label), ...])]``.

Accepts flat ``[(value, label), ...]`` and Django-style grouped
``[(group_label, [(value, label), ...]), ...]``. Flat input becomes a
single ``None``-labelled group so templates iterate uniformly and skip
the header when ``group_label`` is falsy. Mirrors the detection
``ChoiceWidget.optgroups`` uses internally.
"""
if not self.choices:
return []
items = list(self.choices)
first = items[0]
is_grouped = (
isinstance(first, (list, tuple))
and len(first) == 2
and isinstance(first[1], (list, tuple))
)
if is_grouped:
return [(group_label, list(options)) for group_label, options in items]
return [(None, items)]

@property
def flat_choices(self):
"""Flat ``[(value, label), ...]`` view of ``choices`` — same list for
both flat and grouped input. Use this for label lookup."""
flat = []
for _, options in self.grouped_choices:
flat.extend(options)
return flat

def get_default_label(self):
if self.default_label:
return self.default_label
else:
default_value = self.get_default_value()
found_label = [
label for value, label in self.choices if value == default_value
label for value, label in self.flat_choices if value == default_value
]
return found_label[0] if found_label else default_value

Expand Down
12 changes: 11 additions & 1 deletion src/django_smartbase_admin/plugins/nested.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@
CHILDREN_FIELD = "_children"
PARENT_REAL_ID = "parent_real_id"
CHILDREN_IDS = "children_ids"
LAST_CHILD_FIELD = "_sbadmin_tree_last_child"

_KNOWN_KEYS = {
"parent_field",
Expand Down Expand Up @@ -126,6 +127,7 @@ def modify_tabulator_definition(
"dataTree": True,
"dataTreeChildField": CHILDREN_FIELD,
"dataTreeStartExpanded": nested.get("start_expanded", False),
"sbadminTreeLastChildField": LAST_CHILD_FIELD,
}
if element_column:
options["dataTreeElementColumn"] = element_column
Expand Down Expand Up @@ -260,7 +262,12 @@ def modify_final_data(
root_row = by_id.get(root_id)
if root_row is None:
continue
root_row[CHILDREN_FIELD] = children_by_parent.get(root_id, [])
children = children_by_parent.get(root_id)
if children:
children[-1][LAST_CHILD_FIELD] = True
root_row[CHILDREN_FIELD] = children
else:
root_row.pop(CHILDREN_FIELD, None)
result.append(root_row)
return result

Expand All @@ -285,6 +292,9 @@ def modify_xlsx_data(
flattened: list[dict[str, Any]] = []
for row in data:
children = row.pop(CHILDREN_FIELD, None) or []
row.pop(LAST_CHILD_FIELD, None)
if children:
children[-1].pop(LAST_CHILD_FIELD, None)
flattened.append(row)
flattened.extend(children)
return flattened
Expand Down
5 changes: 5 additions & 0 deletions src/django_smartbase_admin/plugins/tests/test_nested.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
from django_smartbase_admin.admin.site import sb_admin_site
from django_smartbase_admin.engine.request import SBAdminViewRequestData
from django_smartbase_admin.plugins.nested import (
LAST_CHILD_FIELD,
TabulatorNestedPlugin,
resolve_nested,
)
Expand Down Expand Up @@ -155,6 +156,7 @@ def test_tabulator_definition_enables_data_tree(self):
self.assertTrue(opts["dataTree"])
self.assertEqual(opts["dataTreeChildField"], "_children")
self.assertEqual(opts["dataTreeElementColumn"], "id")
self.assertEqual(opts["sbadminTreeLastChildField"], LAST_CHILD_FIELD)

view, request = self._make_view_and_request(sbadmin_nested=None)
opts = view.get_tabulator_definition(request)["tabulatorOptions"]
Expand Down Expand Up @@ -251,6 +253,9 @@ def test_action_list_json_preserves_child_order_within_group(self):
[child["name"] for child in payload["data"][0]["_children"]],
["a_child", "z_child"],
)
self.assertNotIn(LAST_CHILD_FIELD, payload["data"][0])
self.assertNotIn(LAST_CHILD_FIELD, payload["data"][0]["_children"][0])
self.assertTrue(payload["data"][0]["_children"][1][LAST_CHILD_FIELD])

@postgres_only
def test_only_show_filtered_children_false_shows_all_direct_children(self):
Expand Down
Loading
Loading