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}

+
+
{draft_label}
{draft}
+
{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 %} +
+ {% for sub_widget in sub_widgets %} + {% render_widget sub_widget request %} + {% endfor %} +
+{% 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}
' + '
{count}
', + 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 %} +
+
+ {% include "sb_admin/components/filters.html" with filters=settings all_filters_visible=True default_button=True view_id=widget_id %} + {% include "sb_admin/components/filters.html" with filters=filters all_filters_visible=True default_button=True view_id=widget_id %} +
+
+ {% for sub_widget in sub_widgets %} + {% render_widget sub_widget request %} + {% endfor %} +
+
+{% 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 %} +
+
+ {{ html|safe }} +
+
+ {% 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(