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)