diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 161a1f97..ee90ccfb 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -4,6 +4,7 @@ Changelog 1.4.0 (2024-08-02) ------------------ +* added DashboardStats.queryset_modifiers to allow to modify queryset before it is used in chart, e.g. to add prefetch_related, filters or execute annotation functions. Fixed criteria are now deprecated in favour of queryset_modifiers. * values in divided chart now are filtered by other criteria choices 1.3.1 (2024-04-12) diff --git a/README.rst b/README.rst index 8894a286..b9f29fea 100644 --- a/README.rst +++ b/README.rst @@ -44,6 +44,16 @@ Requirements * PostgreSQL (MySQL is experimental, other databases probably not working but PRs are welcome) * ``simplejson`` for charts based on ``DecimalField`` values +======= +Warning +======= + +The ``django-admin-charts`` application intended usage is mainly for system admins with access to Django admin interface. +The application is not intended to be used by untrusted users, as it is exposing some Django functionality to the user, especially in the chart configuration. + +It has not been examined whether some malicious user with access to the charts could exploit the application to gain access to the system or data. + + ============ Installation ============ diff --git a/admin_tools_stats/admin.py b/admin_tools_stats/admin.py index 870e4bad..e05f4c4e 100644 --- a/admin_tools_stats/admin.py +++ b/admin_tools_stats/admin.py @@ -108,7 +108,7 @@ class DashboardStatsAdmin(admin.ModelAdmin): "fields": ( "graph_key", "graph_title", - ("model_app_name", "model_name", "date_field_name"), + ("model_app_name", "model_name", "date_field_name", "queryset_modifiers"), ("operation_field_name", "distinct"), ("user_field_name", "show_to_users"), ("allowed_type_operation_field_name", "type_operation_field_name"), @@ -140,6 +140,7 @@ class DashboardStatsAdmin(admin.ModelAdmin): "created_date", "date_field_name", "operation_field_name", + "queryset_modifiers", "default_chart_type", ) list_filter = [ diff --git a/admin_tools_stats/migrations/0022_dashboardstats_queryset_modifiers.py b/admin_tools_stats/migrations/0022_dashboardstats_queryset_modifiers.py new file mode 100644 index 00000000..8091d0a5 --- /dev/null +++ b/admin_tools_stats/migrations/0022_dashboardstats_queryset_modifiers.py @@ -0,0 +1,49 @@ +# Generated by Django 4.2.3 on 2023-10-12 11:26 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("admin_tools_stats", "0021_auto_20230210_1102"), + ] + + operations = [ + migrations.AddField( + model_name="dashboardstats", + name="queryset_modifiers", + field=models.JSONField( + blank=True, + help_text=( + "Additional queryset modifiers in JSON format:
" + "
"
+                    "[
" + ' {"filter": {"status": "active"}},
' + ' {"exclude": {"status": "deleted"}}
' + ' {"my_annotion_function": {}}
' + "]" + "
" + "Ensure the format is a valid JSON array of objects." + "
" + "The format of the dict on each line is:" + "
" + '{"function_name": {"arg1": "value1", "arg2": "value2"}}' + "
" + "Where the arg/value pairs are the arguments to the function" + "that will be called on the queryset in order given by the list." + ), + null=True, + verbose_name="Queryset modifiers", + ), + ), + migrations.AlterField( + model_name="dashboardstatscriteria", + name="criteria_fix_mapping", + field=models.JSONField( + blank=True, + help_text="DEPRECATED.
Use queryset modifiers instead
A JSON dictionary of key-value pairs that will be used for the criteria", + null=True, + verbose_name="fixed criteria / value", + ), + ), + ] diff --git a/admin_tools_stats/models.py b/admin_tools_stats/models.py index 392836f9..2c56fafe 100644 --- a/admin_tools_stats/models.py +++ b/admin_tools_stats/models.py @@ -189,7 +189,10 @@ class DashboardStatsCriteria(models.Model): null=True, blank=True, verbose_name=_("fixed criteria / value"), - help_text=_("a JSON dictionary of key-value pairs that will be used for the criteria"), + help_text=_( + "DEPRECATED.
Use queryset modifiers instead
" + "A JSON dictionary of key-value pairs that will be used for the criteria" + ), ) dynamic_criteria_field_name = models.CharField( max_length=90, @@ -315,6 +318,29 @@ class DashboardStats(models.Model): "Can contain multiple fields divided by comma.", ), ) + queryset_modifiers = JSONField( + verbose_name=_("Queryset modifiers"), + null=True, + blank=True, + help_text=mark_safe( + "Additional queryset modifiers in JSON format:
" + "
"
+            "[
" + ' {"filter": {"status": "active"}},
' + ' {"exclude": {"status": "deleted"}}
' + ' {"my_annotion_function": {}}
' + "]" + "
" + "Ensure the format is a valid JSON array of objects." + "
" + "The format of the dict on each line is:" + "
" + '{"function_name": {"arg1": "value1", "arg2": "value2"}}' + "
" + "Where the arg/value pairs are the arguments to the function" + "that will be called on the queryset in order given by the list." + ), + ) distinct = models.BooleanField( default=False, null=False, @@ -439,6 +465,14 @@ def get_model(self): def get_queryset(self): qs = self.get_model().objects + if self.queryset_modifiers: + for modifier in self.queryset_modifiers: + method_name = list(modifier.keys())[0] + method_args = modifier[method_name] + if isinstance(method_args, dict): + qs = getattr(qs, method_name)(**method_args) + else: + qs = getattr(qs, method_name)(*method_args) return qs def get_operation_field(self, operation): @@ -535,6 +569,7 @@ def get_series_query_parameters( criteria = m2m.criteria # fixed mapping value passed info queryset_filters if criteria.criteria_fix_mapping: + logger.warning("criteria_fix_mapping is deprecated. Use queryset_modifiers instead") for key in criteria.criteria_fix_mapping: # value => criteria.criteria_fix_mapping[key] queryset_filters[key] = criteria.criteria_fix_mapping[key] diff --git a/admin_tools_stats/tests/test_models.py b/admin_tools_stats/tests/test_models.py index 50dceb92..d3309235 100644 --- a/admin_tools_stats/tests/test_models.py +++ b/admin_tools_stats/tests/test_models.py @@ -21,7 +21,12 @@ from django.utils import timezone as dj_timezone from model_bakery import baker -from admin_tools_stats.models import CachedValue, Interval, truncate_ceiling +from admin_tools_stats.models import ( + CachedValue, + DashboardStats, + Interval, + truncate_ceiling, +) try: @@ -1730,3 +1735,33 @@ def test_get_multi_series_cached_last_value(self): datetime(2010, 10, 12, 0, 0).astimezone(current_tz): {"": 1}, } self.assertDictEqual(serie, testing_data) + + +class DashboardStatsTest(TestCase): + def setUp(self): + self.user = baker.make("User", username="testuser", password="12345") + self.user2 = baker.make("User", username="testuser2", password="12345") + + # Create test data using model bakery + self.kid1 = baker.make("TestKid", happy=True, age=10, height=140, author=self.user) + self.kid2 = baker.make("TestKid", happy=False, age=8, height=130, author=self.user2) + self.kid3 = baker.make("TestKid", happy=True, age=7, height=120, author=self.user) + + # Create a DashboardStats instance with queryset modifiers + self.dashboard_stats = DashboardStats.objects.create( + graph_key="test_graph", + graph_title="Test Graph", + model_app_name="demoproject", + model_name="TestKid", + date_field_name="birthday", + queryset_modifiers=[ + {"filter": {"happy": True}}, + {"exclude": {"height__lt": 130}}, + {"order_by": ["-age"]}, + ], + ) + + def test_get_queryset(self): + qs = self.dashboard_stats.get_queryset() + self.assertEqual(qs.count(), 1) + self.assertEqual(qs.first(), self.kid1)