diff --git a/AGENTS.md b/AGENTS.md
index 1e16fb25..a472d00b 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -36,6 +36,7 @@ This document provides key patterns and gotchas for developers and AI assistants
| [Fieldset Options](#fieldset-options) | Supported keys for `fieldsets` / `sbadmin_fieldsets`, including descriptions, classes, dynamic regions, and collapse |
| [Detail View Layout (Sidebar)](#detail-view-layout-sidebar) | Placing fieldsets in the right sidebar using `DETAIL_STRUCTURE_RIGHT_CLASS` |
| [Detail View Tabs](#detail-view-tabs-sbadmin_tabs) | Organizing fieldsets and inlines into tabs with `sbadmin_tabs` |
+| [Dashboard Widgets](#dashboard-widgets) | Standalone dashboards: simple widgets, lightweight subwidgets, real subwidgets with separate or grouped AJAX |
| [Detail View Widgets](#detail-view-widgets) | Embedding dashboard-style list/chart widgets inside detail fieldsets |
| [Logo Customization](#logo-customization) | Override logo via static files |
| [URL-Callable Action Methods (`@sbadmin_action`)](#url-callable-action-methods-sbadmin_action) | `@sbadmin_action` decorator for URL-callable view methods |
@@ -84,6 +85,8 @@ This document provides key patterns and gotchas for developers and AI assistants
- **Collapse a form fieldset?** → [Fieldset Options](#fieldset-options)
- **Fields in sidebar?** → [Detail View Layout (Sidebar)](#detail-view-layout-sidebar)
- **Fieldsets/inlines in tabs?** → [Detail View Tabs](#detail-view-tabs-sbadmin_tabs)
+- **Building a dashboard page?** → [Dashboard Widgets](#dashboard-widgets)
+- **One AJAX call for several dashboard widgets?** → [Real Subwidgets With One AJAX](#4-real-subwidgets-with-one-ajax)
- **List or chart inside detail page?** → [Detail View Widgets](#detail-view-widgets)
- **Custom permission system (non-Django)?** → [Custom Permission System](#custom-permission-system-has_permission)
- **Audit trail / change history?** → [Audit Logging](#audit-logging)
@@ -4448,6 +4451,530 @@ In this example, the "Content" tab has a two-column layout (main fields on the l
---
+## Dashboard Widgets
+
+Use `SBAdminDashboardView` in `registered_views` to build standalone dashboard pages. Dashboard widgets are regular SBAdmin views: each widget has a stable `widget_id`, permission checks, optional settings/filters, a template, media, and an AJAX `action_get_data` endpoint.
+
+Dashboard widgets are usually built in one of four ways:
+
+| Pattern | Use when |
+|---------|----------|
+| Simple widget | The widget is independent and owns its own HTML/chart/list data. |
+| Lightweight chart subwidgets | One chart has small metric tabs/aggregates over the same queryset. |
+| Real subwidgets with separate AJAX | A parent owns common layout/settings, but each child should load independently. |
+| Real subwidgets with one AJAX | A parent/group owns common settings and one AJAX response refreshes all children together. |
+
+Register dashboards from the configuration:
+
+```python
+# blog/sbadmin_config.py
+from django.utils.translation import gettext_lazy as _
+
+from django_smartbase_admin.engine.configuration import SBAdminRoleConfiguration
+from django_smartbase_admin.engine.menu_item import SBAdminMenuItem
+from django_smartbase_admin.views.dashboard_view import SBAdminDashboardView
+
+from blog.dashboard_widgets import ArticleSummaryWidget
+
+
+class AdminConfiguration(SBAdminRoleConfiguration):
+ default_view = SBAdminMenuItem(view_id="dashboard")
+ menu_items = [
+ SBAdminMenuItem(label=_("Dashboard"), icon="All-application", view_id="dashboard"),
+ ]
+ registered_views = [
+ SBAdminDashboardView(
+ widgets=[
+ ArticleSummaryWidget(),
+ ],
+ title=_("Dashboard"),
+ ),
+ ]
+```
+
+All examples below assume the demo `Article` model from [Demo Schema Reference](#demo-schema-reference).
+
+### 1. Simple Widget
+
+Use a simple top-level widget when it does not need a parent. This is the default choice for independent counters, cards, charts, lists, and HTML blocks.
+
+```python
+# blog/dashboard_widgets.py
+from django.db.models import Count
+from django.utils.html import format_html
+from django.utils.translation import gettext_lazy as _
+
+from django_smartbase_admin.engine.dashboard import SBAdminDashboardHtmlWidget
+
+from blog.models import Article
+
+
+class ArticleSummaryWidget(SBAdminDashboardHtmlWidget):
+ widget_id = "article_summary"
+ name = _("Article summary")
+
+ def has_view_permission(self, request, obj=None) -> bool:
+ return True
+
+ def get_html(self, request):
+ counts = dict(
+ Article.objects.values_list("status").annotate(total=Count("id"))
+ )
+ return format_html(
+ """
+
+
{title}
+
+
+
{published_label}
{published}
+
{archived_label}
{archived}
+
+
+ """,
+ title=_("Article summary"),
+ draft_label=_("Draft"),
+ published_label=_("Published"),
+ archived_label=_("Archived"),
+ draft=counts.get("draft", 0),
+ published=counts.get("published", 0),
+ archived=counts.get("archived", 0),
+ )
+```
+
+Register it as a top-level widget:
+
+```python
+SBAdminDashboardView(
+ widgets=[ArticleSummaryWidget()],
+ title=_("Dashboard"),
+)
+```
+
+### 2. Lightweight Chart Subwidgets
+
+Use `SBAdminChartAggregateSubWidget` when the children are not real dashboard widgets. These are lightweight metric selectors rendered inside one chart widget. They do not get their own AJAX URLs; the parent chart owns the query and response.
+
+```python
+# blog/dashboard_widgets.py
+from django.db.models import Count, Q
+from django.db.models.functions import TruncMonth
+from django.utils.translation import gettext_lazy as _
+
+from django_smartbase_admin.engine.dashboard import (
+ SBAdminChartAggregateSubWidget,
+ SBAdminDashboardChartWidget,
+)
+
+from blog.models import Article
+
+
+class ArticleStatusChartWidget(SBAdminDashboardChartWidget):
+ widget_id = "article_status_chart"
+ name = _("Articles")
+ model = Article
+ chart_type = "line"
+ x_axis_annotate = TruncMonth("created_at")
+ y_axis_annotate = Count("id")
+ order_by = "x_axis"
+ sub_widgets = [
+ SBAdminChartAggregateSubWidget(
+ title=_("All"),
+ aggregate=Count("id"),
+ ),
+ SBAdminChartAggregateSubWidget(
+ title=_("Published"),
+ aggregate=Count("id", filter=Q(status="published")),
+ ),
+ SBAdminChartAggregateSubWidget(
+ title=_("Draft"),
+ aggregate=Count("id", filter=Q(status="draft")),
+ ),
+ ]
+
+ def has_view_permission(self, request, obj=None) -> bool:
+ return True
+
+ def process_label(self, request, label, data, labels, dataset_data):
+ return label.strftime("%Y-%m") if label else _("No date")
+```
+
+Register it as a top-level widget:
+
+```python
+SBAdminDashboardView(
+ widgets=[ArticleStatusChartWidget()],
+ title=_("Dashboard"),
+)
+```
+
+### 3. Real Subwidgets With Separate AJAX
+
+Use a normal `SBAdminDashboardWidget` parent with real dashboard widgets in `sub_widgets` when the parent owns common layout/settings, but every child should keep its own AJAX endpoint. This is useful when children may be slow or independent.
+
+The parent template renders each child. Chart children use the parent filter form id, so common parent settings are sent with every child AJAX request.
+
+```python
+# blog/dashboard_widgets.py
+from django.db.models import Count, F
+from django.db.models.functions import TruncMonth
+from django.utils.translation import gettext_lazy as _
+
+from django_smartbase_admin.engine.dashboard import (
+ SBAdminDashboardChartWidget,
+ SBAdminDashboardWidget,
+)
+from django_smartbase_admin.engine.field import SBAdminField
+from django_smartbase_admin.engine.filter_widgets import ChoiceFilterWidget
+
+from blog.models import Article
+
+
+class ArticleOverviewWidget(SBAdminDashboardWidget):
+ widget_id = "article_overview"
+ name = _("Article overview")
+ template_name = "dashboard/article_overview.html"
+ settings = [
+ SBAdminField(
+ title=_("Status"),
+ name="status",
+ filter_widget=ChoiceFilterWidget(
+ choices=[
+ ("", _("All")),
+ ("draft", _("Draft")),
+ ("published", _("Published")),
+ ("archived", _("Archived")),
+ ],
+ default_value="",
+ ),
+ )
+ ]
+
+ def has_view_permission(self, request, obj=None) -> bool:
+ return True
+
+ def get_data(self, request):
+ return {}
+
+
+class ArticleMonthlyChartWidget(SBAdminDashboardChartWidget):
+ name = _("Articles by month")
+ model = Article
+ chart_type = "bar"
+ x_axis_annotate = TruncMonth("created_at")
+ y_axis_annotate = Count("id")
+ order_by = "x_axis"
+
+ def has_view_permission(self, request, obj=None) -> bool:
+ return True
+
+ def get_queryset(self, request=None):
+ qs = super().get_queryset(request)
+ status = request.request_data.request_get.get("status")
+ if status:
+ qs = qs.filter(status=status)
+ return qs
+
+ def process_label(self, request, label, data, labels, dataset_data):
+ return label.strftime("%Y-%m") if label else _("No date")
+
+
+class ArticleAuthorChartWidget(SBAdminDashboardChartWidget):
+ name = _("Articles by author")
+ model = Article
+ chart_type = "bar"
+ x_axis_annotate = F("author__name")
+ y_axis_annotate = Count("id")
+ order_by = "x_axis"
+
+ def has_view_permission(self, request, obj=None) -> bool:
+ return True
+
+ def get_queryset(self, request=None):
+ qs = super().get_queryset(request)
+ status = request.request_data.request_get.get("status")
+ if status:
+ qs = qs.filter(status=status)
+ return qs
+```
+
+```django
+{# templates/dashboard/article_overview.html #}
+{% extends "sb_admin/dashboard/widget_base.html" %}
+{% load sb_admin_tags %}
+
+{% block content_inner %}
+
+{% endblock %}
+```
+
+```python
+SBAdminDashboardView(
+ widgets=[
+ ArticleOverviewWidget(
+ sub_widgets=[
+ ArticleMonthlyChartWidget(),
+ ArticleAuthorChartWidget(),
+ ],
+ ),
+ ],
+ title=_("Dashboard"),
+)
+```
+
+### 4. Real Subwidgets With One AJAX
+
+Use `SBAdminDashboardGroupWidget` when the parent is a group container and one parent AJAX response should refresh all children. The parent calls each child `get_data(request)` and returns:
+
+```python
+{
+ "sub_widget": {
+ "article_group_0": {...},
+ "article_group_1": {...},
+ }
+}
+```
+
+Charts and HTML widgets already know how to register with the group and update themselves from their slice of the response. List widgets can render inside a group, but their Tabulator data still uses the list widget's own table AJAX endpoint.
+
+```python
+# blog/dashboard_widgets.py
+from django.db.models import Count
+from django.db.models.functions import TruncMonth
+from django.utils.html import format_html
+from django.utils.translation import gettext_lazy as _
+
+from django_smartbase_admin.engine.dashboard import (
+ SBAdminDashboardChartWidget,
+ SBAdminDashboardGroupWidget,
+ SBAdminDashboardHtmlWidget,
+)
+from django_smartbase_admin.engine.field import SBAdminField
+from django_smartbase_admin.engine.filter_widgets import ChoiceFilterWidget
+
+from blog.models import Article
+
+
+class ArticleGroupWidget(SBAdminDashboardGroupWidget):
+ widget_id = "article_group"
+ name = _("Article dashboard")
+ settings = [
+ SBAdminField(
+ title=_("Status"),
+ name="status",
+ filter_widget=ChoiceFilterWidget(
+ choices=[
+ ("", _("All")),
+ ("draft", _("Draft")),
+ ("published", _("Published")),
+ ("archived", _("Archived")),
+ ],
+ default_value="",
+ ),
+ )
+ ]
+
+ def has_view_permission(self, request, obj=None) -> bool:
+ return True
+
+ def get_filtered_queryset(self, request):
+ qs = Article.objects.all()
+ status = request.request_data.request_get.get("status")
+ if status:
+ qs = qs.filter(status=status)
+ return qs
+
+ def get_data(self, request):
+ request.article_group_queryset = self.get_filtered_queryset(request)
+ return super().get_data(request)
+
+
+class GroupedArticleMonthlyChartWidget(SBAdminDashboardChartWidget):
+ name = _("Articles by month")
+ chart_type = "bar"
+
+ def has_view_permission(self, request, obj=None) -> bool:
+ return True
+
+ def get_data(self, request):
+ qs = getattr(request, "article_group_queryset", Article.objects.none())
+ rows = (
+ qs.annotate(month=TruncMonth("created_at"))
+ .values("month")
+ .annotate(total=Count("id"))
+ .order_by("month")
+ )
+ labels = [
+ row["month"].strftime("%Y-%m") if row["month"] else str(_("No date"))
+ for row in rows
+ ]
+ values = [row["total"] for row in rows]
+ return {
+ "main": {
+ "labels": labels,
+ "datasets": [
+ {
+ "label": str(_("Articles")),
+ "data": values,
+ "backgroundColor": "#2368A9",
+ }
+ ],
+ }
+ }
+
+
+class GroupedArticleSummaryWidget(SBAdminDashboardHtmlWidget):
+ name = _("Summary")
+
+ def has_view_permission(self, request, obj=None) -> bool:
+ return True
+
+ def get_html(self, request):
+ qs = getattr(request, "article_group_queryset", Article.objects.none())
+ return format_html(
+ '',
+ label=_("Articles"),
+ count=qs.count(),
+ )
+```
+
+```python
+SBAdminDashboardView(
+ widgets=[
+ ArticleGroupWidget(
+ sub_widgets=[
+ GroupedArticleMonthlyChartWidget(),
+ GroupedArticleSummaryWidget(),
+ ],
+ ),
+ ],
+ title=_("Dashboard"),
+)
+```
+
+**Grouped AJAX rules:**
+- Put common settings/filters on the parent group.
+- Child widgets should return their normal widget data shape. Chart widgets return `{"main": {"labels": ..., "datasets": ...}}`; HTML widgets return `{"html": "..."}`.
+- If the parent preloads shared data on `request`, use a project-specific attribute such as `request.article_group_queryset` and guard child initial render with `getattr(...)`.
+- Prefer this for chart/card/summary widgets. Use separate AJAX for expensive children or full list/table widgets.
+
+### Local Graph Settings
+
+Use `settings` for UI controls that affect how a dashboard widget displays data but are not ordinary queryset filters. Read them with `get_settings_from_request(request)`.
+
+```python
+# blog/dashboard_widgets.py
+from django.db.models import Count
+from django.db.models.functions import TruncDay, TruncMonth
+from django.utils import timezone
+from django.utils.translation import gettext_lazy as _
+
+from django_smartbase_admin.engine.dashboard import SBAdminDashboardChartWidget
+from django_smartbase_admin.engine.field import SBAdminField
+from django_smartbase_admin.engine.filter_widgets import ChoiceFilterWidget
+
+from blog.models import Article
+
+
+class ArticleResolutionChartWidget(SBAdminDashboardChartWidget):
+ widget_id = "article_resolution_chart"
+ name = _("Articles")
+ model = Article
+ chart_type = "line"
+ y_axis_annotate = Count("id")
+ order_by = "x_axis"
+ RESOLUTION_KEY = "article_resolution"
+ settings = [
+ SBAdminField(
+ title=_("Resolution"),
+ name=RESOLUTION_KEY,
+ filter_widget=ChoiceFilterWidget(
+ choices=[
+ ("day", _("Day")),
+ ("month", _("Month")),
+ ],
+ default_value="month",
+ allow_clear=False,
+ ),
+ )
+ ]
+
+ def has_view_permission(self, request, obj=None) -> bool:
+ return True
+
+ def get_resolution(self, request):
+ return self.get_settings_from_request(request).get(self.RESOLUTION_KEY) or "month"
+
+ def get_x_axis_annotate(self, request):
+ if self.get_resolution(request) == "day":
+ return TruncDay("created_at")
+ return TruncMonth("created_at")
+
+ def process_label(self, request, label, data, labels, dataset_data):
+ if not label:
+ return _("No date")
+ if self.get_resolution(request) == "day":
+ return label.strftime("%Y-%m-%d")
+ return label.strftime("%Y-%m")
+```
+
+For a child chart in the same module that has a local setting but should submit through the parent form together with common parent settings, retarget only that child's setting widgets after static initialization:
+
+```python
+class ParentFormSettingMixin:
+ def init_widget_static(self, configuration):
+ super().init_widget_static(configuration)
+ if not self.parent_view:
+ return
+ for setting in self.get_settings():
+ setting.filter_widget.view_id = self.parent_view.get_id()
+
+
+class ArticleCostChartWidget(ParentFormSettingMixin, SBAdminDashboardChartWidget):
+ name = _("Cost")
+ model = Article
+ chart_type = "bar"
+ y_axis_annotate = Count("id")
+ order_by = "x_axis"
+ TIME_FRAME_KEY = "article_cost_time_frame"
+ settings = [
+ SBAdminField(
+ title=_("Time frame"),
+ name=TIME_FRAME_KEY,
+ filter_widget=ChoiceFilterWidget(
+ choices=[
+ ("year", _("Year")),
+ ("all", _("All")),
+ ],
+ default_value="year",
+ allow_clear=False,
+ ),
+ )
+ ]
+
+ def has_view_permission(self, request, obj=None) -> bool:
+ return True
+
+ def get_x_axis_annotate(self, request):
+ return TruncMonth("created_at")
+
+ def get_queryset(self, request=None):
+ qs = super().get_queryset(request)
+ if request.request_data.request_get.get(self.TIME_FRAME_KEY, "year") == "year":
+ qs = qs.filter(created_at__year=timezone.now().year)
+ return qs
+```
+
+Use this child-local setting pattern only when the parent renders one shared filter form and one child needs an extra control. Keep setting names unique (`article_cost_time_frame`, not `time_frame`) to avoid collisions between sibling widgets.
+
+**Source:** `django_smartbase_admin/engine/dashboard.py`; `django_smartbase_admin/templates/sb_admin/dashboard/widget_base.html`; `django_smartbase_admin/templates/sb_admin/dashboard/chart_widget.html`; `django_smartbase_admin/templates/sb_admin/dashboard/group_widget.html`; `django_smartbase_admin/static/sb_admin/src/js/chart.js`; `django_smartbase_admin/static/sb_admin/src/js/dashboard_group.js`
+
+---
+
## Detail View Widgets
SBAdmin admins can register dashboard-style widgets and place them inside detail/change fieldsets. Use this when related data belongs on the object page but should stay read-only and self-contained, for example a compact related-row list or a small aggregate chart.
diff --git a/pyproject.toml b/pyproject.toml
index 02a626b8..9736db1c 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,6 +1,6 @@
[tool.poetry]
name = "django-smartbase-admin"
-version = "2.0.9"
+version = "2.0.10b2"
description = ""
authors = ["SmartBase "]
readme = "README.md"
diff --git a/src/django_smartbase_admin/actions/admin_action_list.py b/src/django_smartbase_admin/actions/admin_action_list.py
index ee2b9eaf..c33de4a9 100644
--- a/src/django_smartbase_admin/actions/admin_action_list.py
+++ b/src/django_smartbase_admin/actions/admin_action_list.py
@@ -37,6 +37,7 @@
TABLE_PARAMS_FULL_TEXT_SEARCH,
TABLE_PARAMS_SELECTED_FILTER_TYPE,
ADVANCED_FILTER_DATA_NAME,
+ PARENT_FILTER_DATA_NAME,
IGNORE_LIST_SELECTION,
MODIFIER_OBJECT_ID,
SB_ADMIN_AJAX_NOTIFICATIONS_KEY,
@@ -198,6 +199,7 @@ def get_template_data(self):
"TABLE_PARAMS_SELECTED_FILTER_TYPE": TABLE_PARAMS_SELECTED_FILTER_TYPE,
"FILTER_DATA_NAME": FILTER_DATA_NAME,
"ADVANCED_FILTER_DATA_NAME": ADVANCED_FILTER_DATA_NAME,
+ "PARENT_FILTER_DATA_NAME": PARENT_FILTER_DATA_NAME,
"BASE_PARAMS_NAME": BASE_PARAMS_NAME,
"TABLE_PARAMS_PAGE_NAME": TABLE_PARAMS_PAGE_NAME,
"TABLE_PARAMS_SORT_NAME": TABLE_PARAMS_SORT_NAME,
@@ -303,7 +305,10 @@ def get_filter_from_request(self):
self.threadsafe_request, self.column_fields, self.filter_data
)
advanced_filters = QueryBuilderService.get_filters_for_list_action(self)
- return base_filters & advanced_filters
+ extra_filters = self.view.get_extra_filter_from_request(
+ self.threadsafe_request, self
+ )
+ return base_filters & advanced_filters & extra_filters
def get_search_fields(self, request):
search_fields_definition = self.view.get_search_fields(request)
diff --git a/src/django_smartbase_admin/engine/admin_base_view.py b/src/django_smartbase_admin/engine/admin_base_view.py
index ee20a2fa..546c9d67 100644
--- a/src/django_smartbase_admin/engine/admin_base_view.py
+++ b/src/django_smartbase_admin/engine/admin_base_view.py
@@ -10,7 +10,7 @@
from django.contrib import messages
from django.contrib.admin.actions import delete_selected
from django.core.exceptions import ImproperlyConfigured, PermissionDenied
-from django.db.models import F
+from django.db.models import F, Q
from django.http import HttpResponse, JsonResponse, HttpRequest, Http404
from django.shortcuts import redirect
from django.template.response import TemplateResponse
@@ -673,6 +673,9 @@ class SBAdminBaseListView(SBAdminBaseView):
def get_list_view_media(self, request):
return forms.Media(js=("sb_admin/dist/table.js",))
+ def get_extra_filter_from_request(self, request, list_action):
+ return Q()
+
@classmethod
def _postgres_unaccent_extension_available(cls) -> bool:
from django.conf import settings
diff --git a/src/django_smartbase_admin/engine/const.py b/src/django_smartbase_admin/engine/const.py
index c7300a93..7a86b28d 100644
--- a/src/django_smartbase_admin/engine/const.py
+++ b/src/django_smartbase_admin/engine/const.py
@@ -59,6 +59,7 @@ class FilterVersions(Enum):
TABLE_UPDATE_ROW_DATA_EVENT_NAME = "SBAdminUpdateRowData"
FILTER_DATA_NAME = "filterData"
ADVANCED_FILTER_DATA_NAME = "advancedFilterData"
+PARENT_FILTER_DATA_NAME = "parentFilterData"
BASE_PARAMS_NAME = "params"
AUTOCOMPLETE_SEARCH_NAME = "__search_term__"
AUTOCOMPLETE_FORWARD_NAME = "__forward_data__"
diff --git a/src/django_smartbase_admin/engine/dashboard.py b/src/django_smartbase_admin/engine/dashboard.py
index cbe4720d..e84a3ed8 100644
--- a/src/django_smartbase_admin/engine/dashboard.py
+++ b/src/django_smartbase_admin/engine/dashboard.py
@@ -1,21 +1,24 @@
from copy import copy
from datetime import timedelta
+
from django import forms
from django.core.cache import cache
from django.core.exceptions import ImproperlyConfigured
from django.db import models
-from django.db.models import QuerySet
-from django.db.models.functions import TruncMonth, TruncDay, TruncWeek, TruncYear
+from django.db.models import Q, QuerySet
+from django.db.models.functions import TruncDay, TruncMonth, TruncWeek, TruncYear
from django.http import JsonResponse
from django.template.loader import render_to_string
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
-
from django_smartbase_admin.actions.admin_action_list import SBAdminListAction
from django_smartbase_admin.engine.actions import sbadmin_action
from django_smartbase_admin.engine.admin_base_view import SBAdminBaseListView
from django_smartbase_admin.engine.admin_view import SBAdminView
-from django_smartbase_admin.engine.const import OBJECT_ID_PLACEHOLDER
+from django_smartbase_admin.engine.const import (
+ OBJECT_ID_PLACEHOLDER,
+ PARENT_FILTER_DATA_NAME,
+)
from django_smartbase_admin.engine.field import SBAdminField
from django_smartbase_admin.engine.filter_widgets import (
DateFilterWidget,
@@ -75,6 +78,12 @@ def init_widget_static(self, configuration):
filter.init_field_static(self, configuration)
for setting in self.get_settings():
setting.init_field_static(self, configuration)
+ for index, sub_widget in enumerate(self.get_sub_widgets()):
+ sub_widget.init_sub_widget_dynamic(str(index), self)
+ sub_widget.init_widget_static(configuration)
+ sub_widget_id = sub_widget.get_id()
+ if sub_widget_id:
+ configuration.view_map[sub_widget_id] = sub_widget
def get_id(self):
return self.widget_id
@@ -90,6 +99,11 @@ def get_ajax_url(self, request=None):
"action_get_data", object_id=self.get_parent_instance_id(request)
)
+ def get_filter_form_id(self):
+ if self.parent_view is not None:
+ return f"{self.parent_view.get_id()}-filter-form"
+ return f"{self.get_id()}-filter-form"
+
def get_parent_instance_id(self, request):
request_data = getattr(request, "request_data", None)
return getattr(request_data, "object_id", None)
@@ -119,15 +133,21 @@ def action_get_data(self, request, modifier, object_id=None):
return JsonResponse(data={"data": self.get_cached_data(request)})
def get_widget_context_data(self, request):
- return {
+ parent_group_widget = self.get_parent_group_widget()
+ context = {
"widget_id": self.get_id(),
"widget_name": self.name,
- "ajax_url": self.get_ajax_url(request),
"filters": self.get_filters(),
"settings": self.get_settings(),
"sub_widgets": self.get_sub_widgets(),
"request": request,
+ "filter_form_id": self.get_filter_form_id(),
}
+ if parent_group_widget:
+ context["parent_widget_id"] = parent_group_widget.get_id()
+ else:
+ context["ajax_url"] = self.get_ajax_url(request)
+ return context
def get_sub_widgets(self):
return self.widget_views if self.widget_views is not None else self.sub_widgets
@@ -188,6 +208,38 @@ def get_active_sub_widget(self, request):
return sub_widget
return next(iter(sub_widgets), None)
+ def init_sub_widget_dynamic(self, sub_widget_id, parent_view):
+ self.widget_id = sub_widget_id
+ self.view_id = sub_widget_id
+ self.parent_view = parent_view
+
+ def get_parent_group_widget(self):
+ parent_view = self.parent_view
+ if parent_view is not None and isinstance(
+ parent_view, SBAdminDashboardGroupWidget
+ ):
+ return parent_view
+ return None
+
+ def init_view_dynamic(self, request, request_data=None, **kwargs):
+ result = super().init_view_dynamic(request, request_data, **kwargs)
+ for sub_widget in self.get_sub_widgets():
+ sub_widget.init_view_dynamic(request, request_data, **kwargs)
+ return result
+
+
+class SBAdminDashboardGroupWidget(SBAdminDashboardWidget):
+ template_name = "sb_admin/dashboard/group_widget.html"
+ media = forms.Media(js=("sb_admin/dist/dashboard_group.js",))
+
+ def get_data(self, request):
+ return {
+ "sub_widget": {
+ sub_widget.get_id(): sub_widget.get_data(request)
+ for sub_widget in self.get_sub_widgets()
+ }
+ }
+
class SBAdminChartAggregateSubWidget(object):
title = None
@@ -253,6 +305,33 @@ def init_view_dynamic(self, request, request_data=None, **kwargs):
pass
+class SBAdminDashboardHtmlWidget(SBAdminDashboardWidget):
+ template_name = "sb_admin/dashboard/html_widget.html"
+ content_template_name = None
+
+ def get_html_context_data(self, request):
+ return {}
+
+ def get_html(self, request):
+ if self.content_template_name is None:
+ raise ImproperlyConfigured(
+ f"{self.__class__.__name__} must define content_template_name."
+ )
+ return render_to_string(
+ self.content_template_name,
+ self.get_html_context_data(request),
+ request=request,
+ )
+
+ def get_data(self, request):
+ return {"html": self.get_html(request)}
+
+ def get_widget_context_data(self, request):
+ context = super().get_widget_context_data(request)
+ context["html"] = self.get_html(request)
+ return context
+
+
class SBAdminDashboardChartWidget(SBAdminDashboardWidget):
template_name = "sb_admin/dashboard/chart_widget.html"
media = forms.Media(js=("sb_admin/dist/chart.js",))
@@ -715,6 +794,17 @@ def get_queryset(self, request=None):
qs = super().get_queryset(request)
return self._filter_queryset_by_parent_request(request, qs)
+ def get_extra_filter_from_request(self, request, list_action):
+ return self.get_filter_from_dashboard_filter(
+ request, list_action.params.get(PARENT_FILTER_DATA_NAME, {})
+ )
+
+ def get_filter_from_dashboard_filter(self, request, dashboard_filter_data):
+ return Q()
+
+ def get_data(self, request):
+ return {}
+
def init_view_dynamic(self, request, request_data=None, **kwargs):
super().init_view_dynamic(request, request_data, **kwargs)
self.init_fields_cache(
@@ -740,6 +830,9 @@ def get_tabulator_header_template_name(self, request) -> str:
def get_tabulator_definition(self, request):
tabulator_definition = super().get_tabulator_definition(request)
+ parent_group_widget = self.get_parent_group_widget()
+ if parent_group_widget:
+ tabulator_definition["parentWidgetId"] = parent_group_widget.get_id()
tabulator_definition["stickyHeaderAndFooter"] = False
tabulator_definition["modules"] = [
"viewsModule",
@@ -748,6 +841,8 @@ def get_tabulator_definition(self, request):
"filterModule",
"columnDisplayModule",
]
+ if parent_group_widget:
+ tabulator_definition["modules"].append("dashboardParentFilterModule")
return tabulator_definition
diff --git a/src/django_smartbase_admin/static/sb_admin/build/webpack.common.js b/src/django_smartbase_admin/static/sb_admin/build/webpack.common.js
index 1ab8fda1..4702975e 100644
--- a/src/django_smartbase_admin/static/sb_admin/build/webpack.common.js
+++ b/src/django_smartbase_admin/static/sb_admin/build/webpack.common.js
@@ -7,6 +7,7 @@ const entries = {
main: './src/django_smartbase_admin/static/sb_admin/src/js/main.js',
table: './src/django_smartbase_admin/static/sb_admin/src/js/table.js',
chart: './src/django_smartbase_admin/static/sb_admin/src/js/chart.js',
+ dashboard_group: './src/django_smartbase_admin/static/sb_admin/src/js/dashboard_group.js',
calendar: './src/django_smartbase_admin/static/sb_admin/src/js/calendar.js',
main_style: './src/django_smartbase_admin/static/sb_admin/src/css/style.css',
translations: './src/django_smartbase_admin/static/sb_admin/src/js/translations.js',
diff --git a/src/django_smartbase_admin/static/sb_admin/src/js/chart.js b/src/django_smartbase_admin/static/sb_admin/src/js/chart.js
index 15129423..23c5d615 100644
--- a/src/django_smartbase_admin/static/sb_admin/src/js/chart.js
+++ b/src/django_smartbase_admin/static/sb_admin/src/js/chart.js
@@ -1,5 +1,5 @@
import Chart from "chart.js/auto"
-import {filterInputValueChangedUtil, filterInputValueChangeListener} from "./utils"
+import {ensureFilterForm, filterInputValueChangedUtil, filterInputValueChangeListener} from "./utils"
Chart.defaults.font.family = 'Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"'
@@ -8,7 +8,16 @@ class SBAdminChart {
constructor(options) {
this.options = options
this.initChart()
- this.initFilters()
+ this.initThemeRefresh()
+ if (this.options.parentWidgetId) {
+ this.registerParentGroup()
+ } else {
+ this.refreshData()
+ document.addEventListener(window.sb_admin_const.TABLE_RELOAD_DATA_EVENT_NAME, () => {
+ this.refreshData()
+ })
+ this.initFilters()
+ }
}
getGradient(gradientColorStart, gradientColorStop) {
@@ -32,10 +41,24 @@ class SBAdminChart {
options: this.options.chartOptions || {},
plugins: this.options.chartPlugins || []
})
- this.refreshData()
- document.addEventListener(window.sb_admin_const.TABLE_RELOAD_DATA_EVENT_NAME, () => {
- this.refreshData()
- })
+ }
+
+ initThemeRefresh() {
+ const refreshChartTheme = () => {
+ if (this.chart) {
+ this.chart.update('none')
+ }
+ }
+ document.body.addEventListener('color-scheme-change', refreshChartTheme)
+ if (!window.matchMedia) {
+ return
+ }
+ const media = window.matchMedia('(prefers-color-scheme: dark)')
+ if (media.addEventListener) {
+ media.addEventListener('change', refreshChartTheme)
+ } else if (media.addListener) {
+ media.addListener(refreshChartTheme)
+ }
}
processDatasets(datasets) {
@@ -48,8 +71,11 @@ class SBAdminChart {
}
refreshData() {
+ ensureFilterForm(this.options.formId)
const filterForm = document.getElementById(this.options.formId)
- const filterData = new FormData(filterForm).entries()
+ const filterData = (
+ filterForm instanceof HTMLFormElement ? new FormData(filterForm) : new FormData()
+ ).entries()
const filterDataNotEmpty = {}
for (const [key, value] of filterData) {
if (value) {
@@ -64,41 +90,58 @@ class SBAdminChart {
},
}).then(response => response.json())
.then(res => {
- this.chart.data.labels = res.data.main.labels
- this.chart.data.datasets = this.processDatasets(res.data.main.datasets)
- if (this.chart.data.labels.length >= 1) {
- this.chart.canvas.classList.remove('!hidden')
- } else {
- this.chart.canvas.classList.add('!hidden')
- }
- this.chart.update()
- const subWidgets = res.data.sub_widget
- if (subWidgets) {
- Object.keys(subWidgets).forEach((widgetId) => {
- const valueEl = document.getElementById(widgetId)
- if (valueEl) {
- valueEl.innerHTML = subWidgets[widgetId]['formatted_value'] || 0
- }
- })
+ this.updateData(res.data)
+ })
+ }
+
+ updateData(data) {
+ this.chart.data.labels = data.main.labels
+ this.chart.data.datasets = this.processDatasets(data.main.datasets)
+ if (this.chart.data.labels.length >= 1) {
+ this.chart.canvas.classList.remove('!hidden')
+ } else {
+ this.chart.canvas.classList.add('!hidden')
+ }
+ this.chart.update()
+ const subWidgets = data.sub_widget
+ if (subWidgets) {
+ Object.keys(subWidgets).forEach((widgetId) => {
+ const valueEl = document.getElementById(widgetId)
+ if (valueEl) {
+ valueEl.innerHTML = subWidgets[widgetId]['formatted_value'] || 0
}
- const subWidgetsCompare = res.data.sub_widget_compare
- if (subWidgetsCompare) {
- Object.keys(subWidgetsCompare).forEach((widgetId) => {
- const valueEl = document.getElementById(`${widgetId}_compare`)
- if (valueEl) {
- const subData = subWidgets[widgetId]['raw_value'] || 0
- const subDataCompare = subWidgetsCompare[widgetId]['raw_value'] || 0
- const percentage = (((subData / subDataCompare) - 1) * 100).toFixed(2)
- if (percentage !== 'NaN' && percentage !== 'Infinity') {
- valueEl.innerHTML = percentage + "%"
- } else {
- valueEl.innerHTML = ''
- }
- }
- })
+ })
+ }
+ const subWidgetsCompare = data.sub_widget_compare
+ if (subWidgetsCompare) {
+ Object.keys(subWidgetsCompare).forEach((widgetId) => {
+ const valueEl = document.getElementById(`${widgetId}_compare`)
+ if (valueEl) {
+ const subData = (subWidgets && subWidgets[widgetId] && subWidgets[widgetId]['raw_value']) || 0
+ const subDataCompare = subWidgetsCompare[widgetId]['raw_value'] || 0
+ const percentage = (((subData / subDataCompare) - 1) * 100).toFixed(2)
+ if (percentage !== 'NaN' && percentage !== 'Infinity') {
+ valueEl.innerHTML = percentage + "%"
+ } else {
+ valueEl.innerHTML = ''
+ }
}
- this.chart.canvas.dispatchEvent(new CustomEvent('chartDataLoaded'))
})
+ }
+ if (this.options.onData) {
+ this.options.onData(data)
+ }
+ this.chart.canvas.dispatchEvent(new CustomEvent('chartDataLoaded'))
+ }
+
+ registerParentGroup() {
+ window.SBAdminRegisterDashboardSubWidget(this.options.parentWidgetId, {
+ widgetId: this.options.widgetId,
+ formId: this.options.formId,
+ onData: (data) => {
+ this.updateData(data)
+ },
+ })
}
initFilters() {
diff --git a/src/django_smartbase_admin/static/sb_admin/src/js/dashboard_group.js b/src/django_smartbase_admin/static/sb_admin/src/js/dashboard_group.js
new file mode 100644
index 00000000..4c399516
--- /dev/null
+++ b/src/django_smartbase_admin/static/sb_admin/src/js/dashboard_group.js
@@ -0,0 +1,124 @@
+import {ensureFilterForm, filterInputValueChangeListener, filterInputValueChangedUtil} from "./utils"
+
+class SBAdminDashboardGroup {
+ constructor(element) {
+ this.element = element
+ this.groupId = element.dataset.dashboardGroupId
+ this.formId = element.dataset.filterFormId
+ this.ajaxUrl = element.dataset.ajaxUrl
+ this.subWidgets = new Map()
+ this.lastData = null
+ this.refreshCount = 0
+ this.initialized = false
+ }
+
+ filterFormIds() {
+ const formIds = new Set([this.formId])
+ this.subWidgets.forEach((definition) => {
+ if (definition.formId) {
+ formIds.add(definition.formId)
+ }
+ })
+ return formIds
+ }
+
+ formValues() {
+ const values = {}
+ this.filterFormIds().forEach((formId) => {
+ ensureFilterForm(formId)
+ const form = document.getElementById(formId)
+ const entries = form ? new FormData(form).entries() : new FormData().entries()
+ for (const [key, value] of entries) {
+ if (value) {
+ values[key] = value
+ }
+ }
+ })
+ return values
+ }
+
+ updateSubWidget(definition, responseData, isInitialData = false) {
+ if (isInitialData && definition.skipInitialData) {
+ return
+ }
+ const widgetData = responseData.sub_widget[definition.widgetId]
+ if (!widgetData || !definition.onData) {
+ return
+ }
+ definition.onData(widgetData)
+ }
+
+ refresh() {
+ const isInitialData = this.refreshCount === 0
+ fetch(`${this.ajaxUrl}?${new URLSearchParams(this.formValues())}`, {
+ method: 'GET',
+ headers: {"X-CSRFToken": window.csrf_token},
+ }).then(response => response.json()).then(response => {
+ this.lastData = response.data
+ this.refreshCount += 1
+ this.subWidgets.forEach((definition) => {
+ this.updateSubWidget(definition, this.lastData, isInitialData)
+ })
+ })
+ }
+
+ registerSubWidget(definition) {
+ this.subWidgets.set(definition.widgetId, definition)
+ if (this.lastData) {
+ this.updateSubWidget(definition, this.lastData, this.refreshCount === 1)
+ }
+ }
+
+ init() {
+ if (this.initialized) {
+ return
+ }
+ this.initialized = true
+ this.refresh()
+
+ document.addEventListener(window.sb_admin_const.TABLE_RELOAD_DATA_EVENT_NAME, () => {
+ this.refresh()
+ })
+ const filterInputSelector = [...this.filterFormIds()].map((formId) => `[form="${formId}"]`).join(',')
+ filterInputValueChangeListener(filterInputSelector, (event) => {
+ this.refresh()
+ filterInputValueChangedUtil(event.target)
+ })
+ }
+}
+
+const registeredSubWidgets = new Map()
+
+function getRegisteredSubWidgets(groupId) {
+ if (!registeredSubWidgets.has(groupId)) {
+ registeredSubWidgets.set(groupId, new Map())
+ }
+ return registeredSubWidgets.get(groupId)
+}
+
+function initDashboardGroups() {
+ window.SBAdminDashboardGroups = window.SBAdminDashboardGroups || {}
+ document.querySelectorAll('[data-dashboard-group-id]').forEach((element) => {
+ const groupId = element.dataset.dashboardGroupId
+ const group = new SBAdminDashboardGroup(element)
+ // Child widgets usually register while the group HTML is rendering.
+ // Init copies those callbacks into the live group before the first shared AJAX refresh.
+ getRegisteredSubWidgets(group.groupId).forEach((definition) => {
+ group.registerSubWidget(definition)
+ })
+ window.SBAdminDashboardGroups[groupId] = group
+ group.init()
+ })
+}
+
+window.SBAdminRegisterDashboardSubWidget = function(groupId, definition) {
+ // Keep every child callback in the registration map so init can wire it once the group exists.
+ getRegisteredSubWidgets(groupId).set(definition.widgetId, definition)
+ const group = window.SBAdminDashboardGroups && window.SBAdminDashboardGroups[groupId]
+ if (group) {
+ // Some widgets, especially tables, initialize after the group. Attach them immediately too.
+ // registerSubWidget de-duplicates by widgetId through the group's Map.
+ group.registerSubWidget(definition)
+ }
+}
+window.SBAdminInitDashboardGroups = initDashboardGroups
diff --git a/src/django_smartbase_admin/static/sb_admin/src/js/table.js b/src/django_smartbase_admin/static/sb_admin/src/js/table.js
index e30933aa..557a7ad6 100644
--- a/src/django_smartbase_admin/static/sb_admin/src/js/table.js
+++ b/src/django_smartbase_admin/static/sb_admin/src/js/table.js
@@ -12,6 +12,7 @@ import {FullTextSearchModule} from "./table_modules/full_text_search_module"
import { HeaderTabsModule } from "./table_modules/header_tabs_module"
import { DataTreeModule } from "./table_modules/data_tree_module"
import { StickyHeaderAndFooterModule } from "./table_modules/sticky_header_and_footer_module"
+import { DashboardParentFilterModule } from "./table_modules/dashboard_parent_filter_module"
import { SBAjaxParamsTabulatorModifier } from "./sb_ajax_params_tabulator_modifier"
import { createIcon } from "./utils"
import { registerFitDataFillAvailableSpaceLayout } from "./tabulator_layouts/fit_data_fill_available_space"
@@ -47,6 +48,7 @@ class SBAdminTable {
this.tableDataEditUrl = options.tableDataEditUrl
this.tableActionMoveUrl = options.tableActionMoveUrl
this.tableDetailUrl = options.tableDetailUrl
+ this.parentWidgetId = options.parentWidgetId
this.defaultColumnData = options.defaultColumnData
this.tableColumns = this.initDefaultColumns(options.tableColumns)
this.tableIdColumnName = options.tableIdColumnName
@@ -575,4 +577,5 @@ window.SBAdminTableModulesClass = {
'headerTabsModule': HeaderTabsModule,
'dataTreeModule': DataTreeModule,
'stickyHeaderAndFooterModule': StickyHeaderAndFooterModule,
+ 'dashboardParentFilterModule': DashboardParentFilterModule,
}
diff --git a/src/django_smartbase_admin/static/sb_admin/src/js/table_modules/dashboard_parent_filter_module.js b/src/django_smartbase_admin/static/sb_admin/src/js/table_modules/dashboard_parent_filter_module.js
new file mode 100644
index 00000000..43b31db6
--- /dev/null
+++ b/src/django_smartbase_admin/static/sb_admin/src/js/table_modules/dashboard_parent_filter_module.js
@@ -0,0 +1,38 @@
+import { SBAdminTableModule } from "./base_module"
+
+export class DashboardParentFilterModule extends SBAdminTableModule {
+ getParentGroup() {
+ return window.SBAdminDashboardGroups?.[this.table.parentWidgetId]
+ }
+
+ formValues() {
+ const group = this.getParentGroup()
+ return group ? group.formValues() : {}
+ }
+
+ getUrlParams() {
+ if (!this.table.parentWidgetId) {
+ return {}
+ }
+ const values = this.formValues()
+ if (Object.keys(values).length === 0) {
+ return {}
+ }
+ return {
+ [this.table.constants.PARENT_FILTER_DATA_NAME]: values,
+ }
+ }
+
+ afterInit() {
+ if (!this.table.parentWidgetId) {
+ return
+ }
+ window.SBAdminRegisterDashboardSubWidget(this.table.parentWidgetId, {
+ widgetId: this.table.viewId,
+ skipInitialData: true,
+ onData: () => {
+ this.table.tabulator.setData()
+ },
+ })
+ }
+}
diff --git a/src/django_smartbase_admin/templates/sb_admin/actions/dashboard.html b/src/django_smartbase_admin/templates/sb_admin/actions/dashboard.html
index 4e0ddccf..6e4fe14a 100644
--- a/src/django_smartbase_admin/templates/sb_admin/actions/dashboard.html
+++ b/src/django_smartbase_admin/templates/sb_admin/actions/dashboard.html
@@ -7,14 +7,17 @@
{% endblock %}
{% block content %}
+ {{ dashboard_media.js }}
{% for widget in direct_sub_views %}
{% render_widget widget request %}
{% endfor %}
+
{% endblock %}
{% block additional_js %}
{{ block.super }}
- {{ dashboard_media.js }}
{% endblock %}
diff --git a/src/django_smartbase_admin/templates/sb_admin/dashboard/chart_widget.html b/src/django_smartbase_admin/templates/sb_admin/dashboard/chart_widget.html
index 302b10c7..d5560e8d 100644
--- a/src/django_smartbase_admin/templates/sb_admin/dashboard/chart_widget.html
+++ b/src/django_smartbase_admin/templates/sb_admin/dashboard/chart_widget.html
@@ -15,38 +15,35 @@
{{ initial_data|get_json_script:'initial_data' }}
diff --git a/src/django_smartbase_admin/templates/sb_admin/dashboard/group_widget.html b/src/django_smartbase_admin/templates/sb_admin/dashboard/group_widget.html
new file mode 100644
index 00000000..e7e07159
--- /dev/null
+++ b/src/django_smartbase_admin/templates/sb_admin/dashboard/group_widget.html
@@ -0,0 +1,23 @@
+{% extends "sb_admin/dashboard/widget_base.html" %}
+{% load sb_admin_tags %}
+
+{% block filters %}
+{% endblock %}
+
+{% block content_inner %}
+
+{% endblock %}
diff --git a/src/django_smartbase_admin/templates/sb_admin/dashboard/html_widget.html b/src/django_smartbase_admin/templates/sb_admin/dashboard/html_widget.html
new file mode 100644
index 00000000..b962dde0
--- /dev/null
+++ b/src/django_smartbase_admin/templates/sb_admin/dashboard/html_widget.html
@@ -0,0 +1,32 @@
+{% extends "sb_admin/dashboard/widget_base.html" %}
+
+{% block filters %}
+{% endblock %}
+
+{% block content_inner %}
+
+ {% if parent_widget_id %}
+
+ {% endif %}
+{% endblock %}
diff --git a/src/django_smartbase_admin/tests/test_dashboard.py b/src/django_smartbase_admin/tests/test_dashboard.py
index 3f6a9377..74c0b269 100644
--- a/src/django_smartbase_admin/tests/test_dashboard.py
+++ b/src/django_smartbase_admin/tests/test_dashboard.py
@@ -7,23 +7,33 @@
from django.core.cache import cache
from django.core.exceptions import ImproperlyConfigured
from django.db import models
-from django.db.models import F
-from django.test import RequestFactory, SimpleTestCase
+from django.db.models import F, Q
from django.template.loader import render_to_string
+from django.test import RequestFactory, SimpleTestCase, override_settings
+from django.urls import path
from django_smartbase_admin.actions.admin_action_list import SBAdminListAction
from django_smartbase_admin.admin.admin_base import SBAdmin
+from django_smartbase_admin.admin.site import sb_admin_site
from django_smartbase_admin.engine.configuration import SBAdminRoleConfiguration
-from django_smartbase_admin.engine.const import FILTER_DATA_NAME, IGNORE_LIST_SELECTION
+from django_smartbase_admin.engine.const import (
+ FILTER_DATA_NAME,
+ IGNORE_LIST_SELECTION,
+ PARENT_FILTER_DATA_NAME,
+)
from django_smartbase_admin.engine.dashboard import (
SbAdminCalendarWidget,
SBAdminDashboardChartWidget,
- SBAdminDashboardWidget,
+ SBAdminDashboardGroupWidget,
+ SBAdminDashboardHtmlWidget,
SBAdminDashboardListWidget,
+ SBAdminDashboardWidget,
)
from django_smartbase_admin.engine.dynamic_forms import SBDynamicRegion
from django_smartbase_admin.engine.field import SBAdminField
from django_smartbase_admin.views.dashboard_view import SBAdminDashboardView
+urlpatterns = [path("", sb_admin_site.urls)]
+
class _DashboardWidget(SBAdminDashboardListWidget):
model = User
@@ -87,6 +97,42 @@ def get_data(self, request):
return {"object_id": request.request_data.object_id}
+class _DashboardGroupSubWidget(SBAdminDashboardWidget):
+ template_name = "sb_admin/blank_base.html"
+ name = "Sub widget"
+
+ def has_view_or_change_permission(self, request, obj=None):
+ return True
+
+ def get_data(self, request):
+ return {"value": 1}
+
+
+class _DashboardHtmlSubWidget(SBAdminDashboardHtmlWidget):
+ name = "HTML sub widget"
+
+ def has_view_or_change_permission(self, request, obj=None):
+ return True
+
+ def get_html(self, request):
+ return "Rendered HTML
"
+
+
+class _DashboardGroupWidget(SBAdminDashboardGroupWidget):
+ widget_id = "dashboard_group_widget"
+ name = "Group widget"
+ sub_widgets = [
+ _DashboardGroupSubWidget(),
+ _DashboardGroupSubWidget(),
+ ]
+
+ def get_action_url(self, action, modifier="template", object_id=None):
+ return f"/{self.get_id()}/{action}/{modifier}/"
+
+ def has_view_or_change_permission(self, request, obj=None):
+ return True
+
+
class _WidgetAdmin(SBAdmin):
widgets = [_RegisteredAdminWidget]
sbadmin_fieldsets = [(None, {"fields": []})]
@@ -114,6 +160,51 @@ class Meta:
managed = False
+class _DashboardGroupChartSubWidget(SBAdminDashboardChartWidget):
+ widget_id = "chart_sub_widget"
+ model = FieldsetWidgetTestModel
+ chart_type = "line"
+ x_axis_annotate = F("username")
+
+ def has_view_or_change_permission(self, request, obj=None):
+ return True
+
+
+class _DashboardChartGroupWidget(_DashboardGroupWidget):
+ sub_widgets = [_DashboardGroupChartSubWidget()]
+
+
+class _DashboardParentWidget(SBAdminDashboardWidget):
+ widget_id = "dashboard_parent_widget"
+ sub_widgets = [_DashboardGroupChartSubWidget()]
+
+ def get_action_url(self, action, modifier="template", object_id=None):
+ return f"/{self.get_id()}/{action}/{modifier}/"
+
+ def has_view_or_change_permission(self, request, obj=None):
+ return True
+
+
+class _DashboardListGroupWidget(_DashboardGroupWidget):
+ sub_widgets = [_StandaloneDashboardWidget()]
+
+
+class _DashboardParentFilterListWidget(_StandaloneDashboardWidget):
+ dashboard_filter_data = None
+
+ def get_filter_from_dashboard_filter(self, request, dashboard_filter_data):
+ self.dashboard_filter_data = dashboard_filter_data
+ return Q()
+
+
+class _DashboardParentFilterListGroupWidget(_DashboardGroupWidget):
+ sub_widgets = [_DashboardParentFilterListWidget()]
+
+
+class _DashboardHtmlGroupWidget(_DashboardGroupWidget):
+ sub_widgets = [_DashboardHtmlSubWidget()]
+
+
class _FieldsetWidgetAdmin(_WidgetAdmin):
sbadmin_fieldsets = [
(
@@ -181,10 +272,16 @@ def filter_queryset_by_parent_instance_ids(
return queryset.filter(id__in=parent_instance_ids)
+@override_settings(ROOT_URLCONF=__name__)
class TestSBAdminDashboardListWidget(SimpleTestCase):
def setUp(self):
self.factory = RequestFactory()
+ def init_dashboard_widget_static(self, widget):
+ configuration = SimpleNamespace(view_map={})
+ widget.init_widget_static(configuration)
+ return configuration
+
def test_init_view_dynamic_preserves_sbadmin_field_metadata(self):
widget = _DashboardWidget()
request = self.factory.get("/dashboard/")
@@ -579,6 +676,209 @@ def test_dashboard_widget_cache_key_includes_request_object_id(self):
widget.get_cached_data(second_request), {"object_id": "parent-2"}
)
+ def test_dashboard_group_widget_initializes_existing_sub_widgets(self):
+ widget = _DashboardGroupWidget()
+ self.init_dashboard_widget_static(widget)
+ request = self.factory.get("/dashboard/")
+ request.request_data = SimpleNamespace(
+ configuration=SBAdminRoleConfiguration(),
+ request_get={},
+ request_method="GET",
+ object_id=None,
+ )
+
+ widget.init_view_dynamic(request, request_data=request.request_data)
+ sub_widgets = widget.get_sub_widgets()
+
+ self.assertEqual(sub_widgets[0].get_id(), "dashboard_group_widget_0")
+ self.assertEqual(sub_widgets[1].get_id(), "dashboard_group_widget_1")
+ self.assertIs(sub_widgets[0].parent_view, widget)
+ self.assertEqual(
+ widget.get_data(request),
+ {
+ "sub_widget": {
+ "dashboard_group_widget_0": {"value": 1},
+ "dashboard_group_widget_1": {"value": 1},
+ }
+ },
+ )
+
+ def test_dashboard_group_widget_template_owns_single_parent_ajax_call(self):
+ widget = _DashboardGroupWidget()
+ self.init_dashboard_widget_static(widget)
+ request = self.factory.get("/dashboard/")
+ request.request_data = SimpleNamespace(
+ configuration=SBAdminRoleConfiguration(),
+ request_get={},
+ request_method="GET",
+ object_id=None,
+ )
+ widget.init_view_dynamic(request, request_data=request.request_data)
+
+ html = render_to_string(
+ widget.template_name,
+ widget.get_widget_context_data(request),
+ request=request,
+ )
+
+ self.assertIn('data-dashboard-group-id="dashboard_group_widget"', html)
+ self.assertIn('data-filter-form-id="dashboard_group_widget-filter-form"', html)
+ self.assertIn(
+ 'data-ajax-url="/dashboard_group_widget/action_get_data/template/"', html
+ )
+ self.assertNotIn("registerChart", html)
+ self.assertNotIn("setTimeout", html)
+
+ def test_dashboard_group_widget_renders_default_chart_subwidget_without_standalone_ajax(
+ self,
+ ):
+ widget = _DashboardChartGroupWidget()
+ self.init_dashboard_widget_static(widget)
+ request = self.factory.get("/dashboard/")
+ request.request_data = SimpleNamespace(
+ configuration=SBAdminRoleConfiguration(),
+ request_get={},
+ request_method="GET",
+ object_id=None,
+ )
+ widget.init_view_dynamic(request, request_data=request.request_data)
+
+ html = render_to_string(
+ widget.template_name,
+ widget.get_widget_context_data(request),
+ request=request,
+ )
+
+ self.assertIn('"parentWidgetId": "dashboard_group_widget"', html)
+ self.assertIn('"widgetId": "dashboard_group_widget_0"', html)
+ self.assertNotIn(
+ '"ajaxUrl": "/dashboard_group_widget_0/action_get_data/template/"', html
+ )
+ self.assertIn("SBAdminChartClassLoaded", html)
+
+ def test_dashboard_parent_widget_keeps_chart_subwidget_own_ajax(self):
+ widget = _DashboardParentWidget()
+ self.init_dashboard_widget_static(widget)
+ request = self.factory.get("/dashboard/")
+ request.request_data = SimpleNamespace(
+ configuration=SBAdminRoleConfiguration(),
+ request_get={},
+ request_method="GET",
+ object_id=None,
+ )
+ widget.init_view_dynamic(request, request_data=request.request_data)
+
+ sub_widget = widget.get_sub_widgets()[0]
+ html = render_to_string(
+ sub_widget.template_name,
+ sub_widget.get_widget_context_data(request),
+ request=request,
+ )
+
+ self.assertNotIn('"parentWidgetId": "dashboard_parent_widget"', html)
+ self.assertIn('"formId": "dashboard_parent_widget-filter-form"', html)
+ self.assertIn("dashboard_parent_widget_0/action_get_data/template/", html)
+
+ def test_dashboard_group_widget_keeps_list_subwidget_table_ajax(self):
+ widget = _DashboardListGroupWidget()
+ self.init_dashboard_widget_static(widget)
+ request = self.factory.get("/dashboard/")
+ request.LANGUAGE_CODE = "en"
+ request.request_data = SimpleNamespace(
+ configuration=SBAdminRoleConfiguration(),
+ request_get={},
+ request_method="GET",
+ object_id="parent-object",
+ user=SimpleNamespace(first_name="", last_name="", username="tester"),
+ )
+ request.user = SimpleNamespace(
+ is_anonymous=True, has_perm=lambda _permission: True
+ )
+ widget.init_view_dynamic(request, request_data=request.request_data)
+
+ html = render_to_string(
+ widget.template_name,
+ widget.get_widget_context_data(request),
+ request=request,
+ )
+
+ self.assertIn("dashboard_group_widget_0-table", html)
+ self.assertIn(
+ "/dashboard_group_widget_0/action_list_json/template/parent-object/",
+ html,
+ )
+ self.assertNotIn(
+ "/dashboard_group_widget/action_list_json/template/parent-object/",
+ html,
+ )
+ tabulator_definition = widget.get_sub_widgets()[0].get_tabulator_definition(
+ request
+ )
+ self.assertEqual(
+ tabulator_definition["parentWidgetId"], "dashboard_group_widget"
+ )
+ self.assertIn("dashboardParentFilterModule", tabulator_definition["modules"])
+ self.assertEqual(
+ widget.get_data(request),
+ {"sub_widget": {"dashboard_group_widget_0": {}}},
+ )
+
+ def test_dashboard_list_widget_passes_parent_filter_data_to_filter_hook(self):
+ widget = _DashboardParentFilterListGroupWidget()
+ self.init_dashboard_widget_static(widget)
+ request = self.factory.get("/dashboard/")
+ request.LANGUAGE_CODE = "en"
+ request.request_data = SimpleNamespace(
+ configuration=SBAdminRoleConfiguration(),
+ request_get={},
+ request_method="GET",
+ object_id="parent-object",
+ user=SimpleNamespace(first_name="", last_name="", username="tester"),
+ global_filter_instance=[],
+ )
+ request.user = SimpleNamespace(
+ is_anonymous=True, has_perm=lambda _permission: True
+ )
+ widget.init_view_dynamic(request, request_data=request.request_data)
+ list_widget = widget.get_sub_widgets()[0]
+
+ action = SBAdminListAction(
+ list_widget,
+ request,
+ all_params={
+ "dashboard_group_widget_0": {PARENT_FILTER_DATA_NAME: {"shipper": "42"}}
+ },
+ )
+ action.get_filter_from_request()
+
+ self.assertEqual(list_widget.dashboard_filter_data, {"shipper": "42"})
+
+ def test_dashboard_group_widget_updates_html_subwidget_from_parent_data(self):
+ widget = _DashboardHtmlGroupWidget()
+ self.init_dashboard_widget_static(widget)
+ request = self.factory.get("/dashboard/")
+ request.request_data = SimpleNamespace(
+ configuration=SBAdminRoleConfiguration(),
+ request_get={},
+ request_method="GET",
+ object_id=None,
+ )
+ widget.init_view_dynamic(request, request_data=request.request_data)
+
+ html = render_to_string(
+ widget.template_name,
+ widget.get_widget_context_data(request),
+ request=request,
+ )
+ data = widget.get_data(request)
+
+ self.assertIn("SBAdminRegisterDashboardSubWidget", html)
+ self.assertIn("SBAdminDashboardGroupLoaded", html)
+ self.assertIn('widgetId: "dashboard_group_widget_0"', html)
+ self.assertIn(
+ "Rendered HTML", data["sub_widget"]["dashboard_group_widget_0"]["html"]
+ )
+
def test_dashboard_list_widget_uses_batch_parent_filter_hook(self):
widget = _CustomParentScopedListWidget()
request = self.factory.get("/admin/auth/user/1/change/")
diff --git a/src/django_smartbase_admin/views/dashboard_view.py b/src/django_smartbase_admin/views/dashboard_view.py
index ea6059e3..e88c821d 100644
--- a/src/django_smartbase_admin/views/dashboard_view.py
+++ b/src/django_smartbase_admin/views/dashboard_view.py
@@ -35,7 +35,7 @@ def get_dashboard_media(self, request):
@sbadmin_action
def dashboard(self, request, modifier, object_id=None):
context = self.get_global_context(request)
- context["direct_sub_views"] = self.widget_views
+ context["direct_sub_views"] = self.get_widget_views(request, object_id)
context["dashboard_media"] = self.get_dashboard_media(request)
context["title"] = self.get_title()
return TemplateResponse(