From eb7069e54324e58bedb45a6d2c2f9106582bff73 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Petr=20Dlouh=C3=BD?= Date: Wed, 18 Oct 2023 10:52:53 +0200 Subject: [PATCH] make choices_queryset not dependent on the time range and therefore much quicker --- CHANGELOG.rst | 1 + ...atostatsm2m_choices_based_on_time_range.py | 32 ++ admin_tools_stats/models.py | 103 ++-- admin_tools_stats/tests/test_models.py | 530 ++++++++++-------- 4 files changed, 391 insertions(+), 275 deletions(-) create mode 100644 admin_tools_stats/migrations/0023_alter_criteriatostatsm2m_choices_based_on_time_range.py diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 5b73706..6368378 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -9,6 +9,7 @@ Changelog * removed support for other JSONFields than Django's native JSONField, removed ADMIN_CHARTS_USE_JSONFIELD setting * admin charts are loaded by JS including chart controls for quicker admin index load * --time-until option was added to the `recalculate_charts` management command to recalculate charts only until given date +* CriteriaToStatsM2M.choices_based_on_time_range field changed it's meaning. Now choices are always calculated for whole time range. Value of this choice determines the way how the choices are calculated. 1.3.1 (2024-04-12) ------------------ diff --git a/admin_tools_stats/migrations/0023_alter_criteriatostatsm2m_choices_based_on_time_range.py b/admin_tools_stats/migrations/0023_alter_criteriatostatsm2m_choices_based_on_time_range.py new file mode 100644 index 0000000..05477d0 --- /dev/null +++ b/admin_tools_stats/migrations/0023_alter_criteriatostatsm2m_choices_based_on_time_range.py @@ -0,0 +1,32 @@ +# Generated by Django 5.0.7 on 2024-08-02 15:12 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("admin_tools_stats", "0022_dashboardstats_queryset_modifiers"), + ] + + operations = [ + migrations.AlterField( + model_name="criteriatostatsm2m", + name="choices_based_on_time_range", + field=models.BooleanField( + default=False, + help_text=( + "If checked:
\n" + "- divide values will not be cached\n" + "- divide values will change with change of time range\n" + "- other criteria will filter divide values\n" + "\n" + "If unchecked:\n" + "- values will be cached\n" + "- divide values are calculated from related models," + "which can be much quicker for large datasets\n" + ), + verbose_name="Calculate by queryset values", + ), + ), + ] diff --git a/admin_tools_stats/models.py b/admin_tools_stats/models.py index 2882889..5b13b98 100644 --- a/admin_tools_stats/models.py +++ b/admin_tools_stats/models.py @@ -587,8 +587,6 @@ def get_series_query_parameters( for dynamic_value in dynamic_values: try: criteria_value = m2m.get_dynamic_choices( - time_since, - time_until, operation_choice, operation_field_choice, user, @@ -700,8 +698,6 @@ def get_multi_time_series( ) if m2m and m2m.criteria.dynamic_criteria_field_name: choices = m2m.get_dynamic_choices( - time_since, - time_until, operation_choice, operation_field_choice, user, @@ -940,8 +936,22 @@ class Meta: blank=True, ) choices_based_on_time_range = models.BooleanField( - verbose_name=_("Choices are dependend on chart time range"), - help_text=_("Choices are not cached if this is set to true"), + verbose_name=_("Calculate by queryset values"), + help_text=_( + mark_safe( + ( + "If checked:
\n" + "- divide values will not be cached\n" + "- divide values will change with change of time range\n" + "- other criteria will filter divide values\n" + "\n" + "If unchecked:\n" + "- values will be cached\n" + "- divide values are calculated from related models," + "which can be much quicker for large datasets\n" + ) + ) + ), default=False, ) count_limit = models.PositiveIntegerField( @@ -960,11 +970,18 @@ def get_dynamic_field(self): query = self.stats.get_queryset().all().query return query.resolve_ref(field_name).field + def get_related_model_and_field(self, field_name): + """Traverse the field_name to get the related model and end target field.""" + base_model = self.stats.get_queryset().model + fields = field_name.split("__") + for rel in fields[:-1]: # omit the last segment since it's the field in the target model + relation = base_model._meta.get_field(rel) + base_model = relation.related_model + return base_model, fields[-1] # returns target model and target field name + @memoize(60 * 60 * 24 * 7) def _get_dynamic_choices( self, - time_since: datetime.datetime, - time_until: datetime.datetime, count_limit: Optional[int] = None, operation_choice=None, operation_field_choice=None, @@ -995,41 +1012,40 @@ def _get_dynamic_choices( else: choices: OrderedDict[str, Tuple[Union[str, bool, List[str]], str]] = OrderedDict() fchoices: Dict[str, str] = dict(field.choices or []) - date_filters = {} - if not self.stats.cache_values: - if time_since is not None: - if ( - time_since.tzinfo is None - or time_since.tzinfo.utcoffset(time_since) is None - ): - time_since = time_since.astimezone(get_charts_timezone()) - date_filters["%s__gte" % self.stats.date_field_name] = time_since - if time_until is not None: - if ( - time_until.tzinfo is None - or time_until.tzinfo.utcoffset(time_until) is None - ): - time_until = time_until.astimezone(get_charts_timezone()).replace( - hour=23, minute=59 + if self.choices_based_on_time_range: + choices_queryset = self.stats.get_queryset() + if queryset_filter: + choices_queryset = choices_queryset.filter(**queryset_filter) + if user and not user.has_perm("admin_tools_stats.view_dashboardstats"): + if not self.stats.user_field_name: + raise Exception( + "User field must be defined to enable charts for non-superusers" ) - end_time = time_until - date_filters["%s__lte" % self.stats.date_field_name] = end_time - choices_queryset = self.stats.get_queryset().filter( - **date_filters, - ) - if queryset_filter: - choices_queryset = choices_queryset.filter(**queryset_filter) - if user and not user.has_perm("admin_tools_stats.view_dashboardstats"): - if not self.stats.user_field_name: - raise Exception( - "User field must be defined to enable charts for non-superusers" + choices_queryset = choices_queryset.filter( + **{self.stats.user_field_name: user} ) - choices_queryset = choices_queryset.filter(**{self.stats.user_field_name: user}) - choices_queryset = choices_queryset.values_list( - field_name, - flat=True, - ).distinct() + choices_queryset = choices_queryset.values_list( + field_name, + flat=True, + ).distinct() + else: + # Obtain the related model and the target field dynamically from the field_name + related_model, field_name = self.get_related_model_and_field(field_name) + + choices_queryset = related_model.objects.values_list( + field_name, # targeting the final field in the related model + flat=True, + ).distinct() + if count_limit: + choices_queryset = ( + self.stats.get_queryset() + .values_list( + field_name, + flat=True, + ) + .distinct() + ) choices_queryset = choices_queryset.annotate( f_count=self.stats.get_operation(operation_choice, operation_field_choice), ).order_by( @@ -1056,8 +1072,6 @@ def __str__(self): def get_dynamic_choices( self, - time_since=None, - time_until=None, operation_choice=None, operation_field_choice=None, user=None, @@ -1065,12 +1079,7 @@ def get_dynamic_choices( ): if not self.count_limit: # We don't have to cache different operation choices operation_choice = None - if not self.choices_based_on_time_range or self.stats.cache_values: - time_since = None - time_until = None choices = self._get_dynamic_choices( - time_since, - time_until, self.count_limit, operation_choice, operation_field_choice, diff --git a/admin_tools_stats/tests/test_models.py b/admin_tools_stats/tests/test_models.py index d330923..fb48644 100644 --- a/admin_tools_stats/tests/test_models.py +++ b/admin_tools_stats/tests/test_models.py @@ -13,6 +13,7 @@ from unittest import skipIf from django.conf import settings +from django.contrib.auth.models import User from django.core.cache import cache from django.core.exceptions import ValidationError from django.db.models.aggregates import Avg, Count, Max, Min, StdDev, Sum, Variance @@ -23,7 +24,9 @@ from admin_tools_stats.models import ( CachedValue, + CriteriaToStatsM2M, DashboardStats, + DashboardStatsCriteria, Interval, truncate_ceiling, ) @@ -106,25 +109,24 @@ def test__get_dynamic_choices_time_values(self): """ Test _get_dynamic_choices() function """ - baker.make("User", first_name="user1", last_name="bar_1", date_joined=date(2014, 1, 1)) - criteria_m2m = baker.make( - "CriteriaToStatsM2M", - stats__graph_title="Graph", - criteria__criteria_name="Foo", - criteria__dynamic_criteria_field_name="first_name", - stats__model_app_name="auth", - stats__model_name="User", - stats__cache_values=False, - stats__date_field_name="date_joined", - ) - result = criteria_m2m._get_dynamic_choices( - datetime(2014, 1, 1, tzinfo=UTC), datetime(2014, 1, 2, tzinfo=UTC) - ) - self.assertEqual(result, OrderedDict([("user1", ("user1", "user1"))])) - result = criteria_m2m._get_dynamic_choices( - datetime(2014, 1, 1, tzinfo=None), datetime(2014, 1, 2, tzinfo=None) - ) - self.assertEqual(result, OrderedDict([("user1", ("user1", "user1"))])) + for choices_time_range in (True, False): + with self.subTest(choices_time_range): + baker.make( + "User", first_name="user1", last_name="bar_1", date_joined=date(2014, 1, 1) + ) + criteria_m2m = baker.make( + "CriteriaToStatsM2M", + stats__graph_title="Graph", + criteria__criteria_name="Foo", + criteria__dynamic_criteria_field_name="first_name", + stats__model_app_name="auth", + stats__model_name="User", + stats__cache_values=False, + stats__date_field_name="date_joined", + choices_based_on_time_range=choices_time_range, + ) + result = criteria_m2m._get_dynamic_choices() + self.assertEqual(result, OrderedDict([("user1", ("user1", "user1"))])) def test__get_dynamic_choices_caching(self): """ @@ -136,82 +138,88 @@ def test__get_dynamic_choices_caching(self): This didn't work with cache_utils, and had to be dealt with slef parameter containing another self """ - baker.make("User", first_name="user1", last_name="bar_1") - criteria_m2m = baker.make( - "CriteriaToStatsM2M", - stats__graph_title="Graph", - criteria__criteria_name="Foo", - criteria__dynamic_criteria_field_name="first_name", - stats__model_app_name="auth", - stats__model_name="User", - stats__cache_values=False, - ) - self.assertEqual( - criteria_m2m._get_dynamic_choices(None, None), - OrderedDict([("user1", ("user1", "user1"))]), - ) - - criteria_m2m_1 = baker.make( - "CriteriaToStatsM2M", - criteria__dynamic_criteria_field_name="last_name", - stats__model_app_name="auth", - stats__model_name="User", - ) - self.assertEqual( - criteria_m2m_1._get_dynamic_choices(None, None), - OrderedDict([("bar_1", ("bar_1", "bar_1"))]), - ) - - user2 = baker.make("User", first_name="user2", last_name="bar_2") - self.assertEqual( # Value is cached, so it doesn't change - criteria_m2m._get_dynamic_choices(None, None), - OrderedDict([("user1", ("user1", "user1"))]), - ) - - criteria_m2m.criteria.save() - self.assertEqual( # Criteria save invalidates cache, so returned value changes - criteria_m2m._get_dynamic_choices(None, None), - OrderedDict([("user1", ("user1", "user1")), ("user2", ("user2", "user2"))]), - ) - - user2.first_name = "user3" - user2.save() - self.assertEqual( # Cache is not invalidated, so returned value doesn't change - criteria_m2m._get_dynamic_choices(None, None), - OrderedDict([("user1", ("user1", "user1")), ("user2", ("user2", "user2"))]), - ) - - criteria_m2m.save() - self.assertEqual( # Criteria save invalidates cache, so returned value changes - criteria_m2m._get_dynamic_choices(None, None), - OrderedDict([("user1", ("user1", "user1")), ("user3", ("user3", "user3"))]), - ) - - user2.first_name = "user4" - user2.save() - self.assertEqual( # Cache is not invalidated, so returned value doesn't change - criteria_m2m._get_dynamic_choices(None, None), - OrderedDict([("user1", ("user1", "user1")), ("user3", ("user3", "user3"))]), - ) - - criteria_m2m.stats.save() - self.assertEqual( # Criteria save invalidates cache, so returned value changes - criteria_m2m._get_dynamic_choices(None, None), - OrderedDict([("user1", ("user1", "user1")), ("user4", ("user4", "user4"))]), - ) - - # Value for different criteria didn't invalidate the whole time - self.assertEqual( - criteria_m2m_1._get_dynamic_choices(None, None), - OrderedDict([("bar_1", ("bar_1", "bar_1"))]), - ) - - # But now they will - criteria_m2m_1.save() - self.assertEqual( - criteria_m2m_1._get_dynamic_choices(None, None), - OrderedDict([("bar_1", ("bar_1", "bar_1")), ("bar_2", ("bar_2", "bar_2"))]), - ) + for choices_time_range in (True, False): + with self.subTest(choices_time_range): + baker.make("User", first_name="user1", last_name="bar_1") + criteria_m2m = baker.make( + "CriteriaToStatsM2M", + stats__graph_title="Graph", + criteria__criteria_name="Foo", + criteria__dynamic_criteria_field_name="first_name", + stats__model_app_name="auth", + stats__model_name="User", + stats__cache_values=False, + ) + self.assertEqual( + criteria_m2m._get_dynamic_choices(None, None), + OrderedDict([("user1", ("user1", "user1"))]), + ) + + criteria_m2m_1 = baker.make( + "CriteriaToStatsM2M", + criteria__dynamic_criteria_field_name="last_name", + stats__model_app_name="auth", + stats__model_name="User", + choices_based_on_time_range=choices_time_range, + ) + self.assertEqual( + criteria_m2m_1._get_dynamic_choices(None, None), + OrderedDict([("bar_1", ("bar_1", "bar_1"))]), + ) + + user2 = baker.make("User", first_name="user2", last_name="bar_2") + self.assertEqual( # Value is cached, so it doesn't change + criteria_m2m._get_dynamic_choices(None, None), + OrderedDict([("user1", ("user1", "user1"))]), + ) + + criteria_m2m.criteria.save() + self.assertEqual( # Criteria save invalidates cache, so returned value changes + criteria_m2m._get_dynamic_choices(None, None), + OrderedDict([("user1", ("user1", "user1")), ("user2", ("user2", "user2"))]), + ) + + user2.first_name = "user3" + user2.save() + self.assertEqual( # Cache is not invalidated, so returned value doesn't change + criteria_m2m._get_dynamic_choices(None, None), + OrderedDict([("user1", ("user1", "user1")), ("user2", ("user2", "user2"))]), + ) + + criteria_m2m.save() + self.assertEqual( # Criteria save invalidates cache, so returned value changes + criteria_m2m._get_dynamic_choices(None, None), + OrderedDict([("user1", ("user1", "user1")), ("user3", ("user3", "user3"))]), + ) + + user2.first_name = "user4" + user2.save() + self.assertEqual( # Cache is not invalidated, so returned value doesn't change + criteria_m2m._get_dynamic_choices(None, None), + OrderedDict([("user1", ("user1", "user1")), ("user3", ("user3", "user3"))]), + ) + + criteria_m2m.stats.save() + self.assertEqual( # Criteria save invalidates cache, so returned value changes + criteria_m2m._get_dynamic_choices(None, None), + OrderedDict([("user1", ("user1", "user1")), ("user4", ("user4", "user4"))]), + ) + + # Value for different criteria didn't invalidate the whole time + self.assertEqual( + criteria_m2m_1._get_dynamic_choices(None, None), + OrderedDict([("bar_1", ("bar_1", "bar_1"))]), + ) + + # But now they will + criteria_m2m_1.save() + self.assertEqual( + criteria_m2m_1._get_dynamic_choices(None, None), + OrderedDict([("bar_1", ("bar_1", "bar_1")), ("bar_2", ("bar_2", "bar_2"))]), + ) + + # Clear for next run + User.objects.all().delete() class ModelTests(TestCase): @@ -468,11 +476,11 @@ def test_get_multi_series_time_based_choices(self): user, ) testing_data = { - datetime(2010, 10, 8, 0, 0): {"Adam": 0, "Petr": 0}, - datetime(2010, 10, 9, 0, 0): {"Adam": 1, "Petr": 0}, - datetime(2010, 10, 10, 0, 0): {"Adam": 0, "Petr": 1}, - datetime(2010, 10, 11, 0, 0): {"Adam": 0, "Petr": 0}, - datetime(2010, 10, 12, 0, 0): {"Adam": 0, "Petr": 0}, + datetime(2010, 10, 8, 0, 0): {"Adam": 0, "Jirka": 0, "Petr": 0}, + datetime(2010, 10, 9, 0, 0): {"Adam": 1, "Jirka": 0, "Petr": 0}, + datetime(2010, 10, 10, 0, 0): {"Adam": 0, "Jirka": 0, "Petr": 1}, + datetime(2010, 10, 11, 0, 0): {"Adam": 0, "Jirka": 0, "Petr": 0}, + datetime(2010, 10, 12, 0, 0): {"Adam": 0, "Jirka": 0, "Petr": 0}, } self.assertDictEqual(serie, testing_data) @@ -486,47 +494,53 @@ def test_get_multi_series_count_limit(self): Test function to check DashboardStats.get_multi_time_series() Choices are limited by count """ - criteria = baker.make( - "DashboardStatsCriteria", - criteria_name="name", - dynamic_criteria_field_name="first_name", - ) - m2m = baker.make( - "CriteriaToStatsM2M", - criteria=criteria, - stats=self.stats, - use_as="multiple_series", - count_limit=1, - ) - user = baker.make( - "User", - date_joined=date(2010, 10, 10), - first_name="Petr", - is_superuser=True, - ) - baker.make("User", date_joined=date(2010, 10, 10), first_name="Petr") - baker.make("User", date_joined=date(2010, 10, 9), first_name="Adam") - baker.make("User", date_joined=date(2010, 10, 11), first_name="Jirka") - time_since = datetime(2010, 10, 8) - time_until = datetime(2010, 10, 12) - - serie = self.stats.get_multi_time_series( - {"select_box_multiple_series": m2m.id}, - time_since, - time_until, - Interval.days, - None, - None, - user, - ) - testing_data = { - datetime(2010, 10, 8, 0, 0): {"other": 0, "Petr": 0}, - datetime(2010, 10, 9, 0, 0): {"other": 1, "Petr": 0}, - datetime(2010, 10, 10, 0, 0): {"other": 0, "Petr": 2}, - datetime(2010, 10, 11, 0, 0): {"other": 1, "Petr": 0}, - datetime(2010, 10, 12, 0, 0): {"other": 0, "Petr": 0}, - } - self.assertDictEqual(serie, testing_data) + for choices_time_range in (True, False): + with self.subTest(choices_time_range): + criteria = baker.make( + "DashboardStatsCriteria", + criteria_name="name", + dynamic_criteria_field_name="first_name", + ) + m2m = baker.make( + "CriteriaToStatsM2M", + criteria=criteria, + stats=self.stats, + use_as="multiple_series", + count_limit=1, + choices_based_on_time_range=choices_time_range, + ) + user = baker.make( + "User", + date_joined=date(2010, 10, 10), + first_name="Petr", + is_superuser=True, + ) + baker.make("User", date_joined=date(2010, 10, 10), first_name="Petr") + baker.make("User", date_joined=date(2010, 10, 9), first_name="Adam") + baker.make("User", date_joined=date(2010, 10, 11), first_name="Jirka") + time_since = datetime(2010, 10, 8) + time_until = datetime(2010, 10, 12) + + serie = self.stats.get_multi_time_series( + {"select_box_multiple_series": m2m.id}, + time_since, + time_until, + Interval.days, + None, + None, + user, + ) + testing_data = { + datetime(2010, 10, 8, 0, 0): {"other": 0, "Petr": 0}, + datetime(2010, 10, 9, 0, 0): {"other": 1, "Petr": 0}, + datetime(2010, 10, 10, 0, 0): {"other": 0, "Petr": 2}, + datetime(2010, 10, 11, 0, 0): {"other": 1, "Petr": 0}, + datetime(2010, 10, 12, 0, 0): {"other": 0, "Petr": 0}, + } + self.assertDictEqual(serie, testing_data) + + # Clear for next run + User.objects.all().delete() @skipIf( settings.DATABASES["default"]["ENGINE"] == "django.db.backends.mysql", @@ -975,41 +989,47 @@ def test_get_multi_series_criteria_without_dynamic_mapping_choices(self): DashboardStatsCriteria is set, but without dynamic mapping, so the values are autogenerated on CharField. """ - criteria = baker.make( - "DashboardStatsCriteria", - criteria_name="name", - dynamic_criteria_field_name="last_name", - ) - m2m = baker.make( - "CriteriaToStatsM2M", - criteria=criteria, - stats=self.stats, - use_as="multiple_series", - ) - baker.make("User", date_joined=date(2010, 10, 12), last_name="Foo") - baker.make("User", date_joined=date(2010, 10, 13), last_name="Bar") - baker.make("User", date_joined=date(2010, 10, 14)) - time_since = datetime(2010, 10, 10) - time_until = datetime(2010, 10, 14) - - user = baker.make("User", is_superuser=True) - serie = self.stats.get_multi_time_series( - {"select_box_multiple_series": m2m.id}, - time_since, - time_until, - Interval.days, - None, - None, - user, - ) - testing_data = { - datetime(2010, 10, 10, 0, 0): OrderedDict((("Bar", 0), ("Foo", 0))), - datetime(2010, 10, 11, 0, 0): OrderedDict((("Bar", 0), ("Foo", 0))), - datetime(2010, 10, 12, 0, 0): OrderedDict((("Bar", 0), ("Foo", 1))), - datetime(2010, 10, 13, 0, 0): OrderedDict((("Bar", 1), ("Foo", 0))), - datetime(2010, 10, 14, 0, 0): OrderedDict((("Bar", 0), ("Foo", 0))), - } - self.assertDictEqual(serie, testing_data) + for choices_time_range in (True, False): + with self.subTest(choices_time_range): + criteria = baker.make( + "DashboardStatsCriteria", + criteria_name="name", + dynamic_criteria_field_name="last_name", + ) + m2m = baker.make( + "CriteriaToStatsM2M", + criteria=criteria, + stats=self.stats, + use_as="multiple_series", + choices_based_on_time_range=choices_time_range, + ) + baker.make("User", date_joined=date(2010, 10, 12), last_name="Foo") + baker.make("User", date_joined=date(2010, 10, 13), last_name="Bar") + baker.make("User", date_joined=date(2010, 10, 14)) + time_since = datetime(2010, 10, 10) + time_until = datetime(2010, 10, 14) + + user = baker.make("User", is_superuser=True) + serie = self.stats.get_multi_time_series( + {"select_box_multiple_series": m2m.id}, + time_since, + time_until, + Interval.days, + None, + None, + user, + ) + testing_data = { + datetime(2010, 10, 10, 0, 0): OrderedDict((("Bar", 0), ("Foo", 0))), + datetime(2010, 10, 11, 0, 0): OrderedDict((("Bar", 0), ("Foo", 0))), + datetime(2010, 10, 12, 0, 0): OrderedDict((("Bar", 0), ("Foo", 1))), + datetime(2010, 10, 13, 0, 0): OrderedDict((("Bar", 1), ("Foo", 0))), + datetime(2010, 10, 14, 0, 0): OrderedDict((("Bar", 0), ("Foo", 0))), + } + self.assertDictEqual(serie, testing_data) + + # Clear for next run + User.objects.all().delete() @skipIf( settings.DATABASES["default"]["ENGINE"] == "django.db.backends.mysql", @@ -1036,6 +1056,7 @@ def test_get_multi_series_criteria_combine(self): criteria=criteria, stats=self.stats, use_as="multiple_series", + choices_based_on_time_range=True, ) m2m_active = baker.make( "CriteriaToStatsM2M", @@ -1112,6 +1133,7 @@ def test_get_multi_series_criteria_combine_user_exception(self): criteria=criteria, stats=self.stats, use_as="multiple_series", + choices_based_on_time_range=True, ) time_since = datetime(2010, 10, 10) time_until = datetime(2010, 10, 14) @@ -1132,58 +1154,65 @@ def test_get_multi_series_criteria_user(self): Test function to check DashboardStats.get_multi_time_series() Check results, if stats are displayed for user """ - stats = baker.make( - "DashboardStats", - model_name="TestKid", - date_field_name="appointment", - model_app_name="demoproject", - type_operation_field_name="Sum", - distinct=True, - operation_field_name="age", - user_field_name="author", - ) - criteria = baker.make( - "DashboardStatsCriteria", - criteria_name="name", - dynamic_criteria_field_name="name", - ) - m2m = baker.make( - "CriteriaToStatsM2M", - criteria=criteria, - stats=stats, - use_as="multiple_series", - ) - user = baker.make("User") - baker.make( - "TestKid", - appointment=date(2010, 10, 12), - name="Foo", - age=5, - author=user, - ) - baker.make( - "TestKid", - appointment=date(2010, 10, 13), - name="Bar", - age=7, - author=user, - ) - baker.make("TestKid", appointment=date(2010, 10, 13), name="Bar", age=7) - time_since = datetime(2010, 10, 10) - time_until = datetime(2010, 10, 14) - - arguments = {"select_box_multiple_series": m2m.id} - serie = stats.get_multi_time_series( - arguments, time_since, time_until, Interval.days, None, None, user - ) - testing_data = { - datetime(2010, 10, 10, 0, 0, tzinfo=UTC): OrderedDict((("Bar", 0), ("Foo", 0))), - datetime(2010, 10, 11, 0, 0, tzinfo=UTC): OrderedDict((("Bar", 0), ("Foo", 0))), - datetime(2010, 10, 12, 0, 0, tzinfo=UTC): OrderedDict((("Bar", None), ("Foo", 5))), - datetime(2010, 10, 13, 0, 0, tzinfo=UTC): OrderedDict((("Bar", 7), ("Foo", None))), - datetime(2010, 10, 14, 0, 0, tzinfo=UTC): OrderedDict((("Bar", 0), ("Foo", 0))), - } - self.assertDictEqual(serie, testing_data) + for choices_time_range in (True, False): + with self.subTest(choices_time_range): + stats = baker.make( + "DashboardStats", + model_name="TestKid", + date_field_name="appointment", + model_app_name="demoproject", + type_operation_field_name="Sum", + distinct=True, + operation_field_name="age", + user_field_name="author", + ) + criteria = baker.make( + "DashboardStatsCriteria", + criteria_name="name", + dynamic_criteria_field_name="name", + ) + m2m = baker.make( + "CriteriaToStatsM2M", + criteria=criteria, + stats=stats, + use_as="multiple_series", + choices_based_on_time_range=choices_time_range, + ) + user = baker.make("User") + baker.make( + "TestKid", + appointment=date(2010, 10, 12), + name="Foo", + age=5, + author=user, + ) + baker.make( + "TestKid", + appointment=date(2010, 10, 13), + name="Bar", + age=7, + author=user, + ) + baker.make("TestKid", appointment=date(2010, 10, 13), name="Bar", age=7) + time_since = datetime(2010, 10, 10) + time_until = datetime(2010, 10, 14) + + arguments = {"select_box_multiple_series": m2m.id} + serie = stats.get_multi_time_series( + arguments, time_since, time_until, Interval.days, None, None, user + ) + testing_data = { + datetime(2010, 10, 10, 0, 0, tzinfo=UTC): OrderedDict((("Bar", 0), ("Foo", 0))), + datetime(2010, 10, 11, 0, 0, tzinfo=UTC): OrderedDict((("Bar", 0), ("Foo", 0))), + datetime(2010, 10, 12, 0, 0, tzinfo=UTC): OrderedDict( + (("Bar", None), ("Foo", 5)) + ), + datetime(2010, 10, 13, 0, 0, tzinfo=UTC): OrderedDict( + (("Bar", 7), ("Foo", None)) + ), + datetime(2010, 10, 14, 0, 0, tzinfo=UTC): OrderedDict((("Bar", 0), ("Foo", 0))), + } + self.assertDictEqual(serie, testing_data) @override_settings(USE_TZ=True, TIME_ZONE="UTC") def test_get_multi_series_criteria_isnull(self): @@ -1310,12 +1339,14 @@ def test_get_multi_series_fixed_criteria(self): criteria=criteria, stats=self.stats, use_as="multiple_series", + choices_based_on_time_range=True, ) baker.make( "CriteriaToStatsM2M", criteria=criteria_active, stats=self.stats, use_as="chart_filter", + choices_based_on_time_range=True, ) baker.make( "User", @@ -1765,3 +1796,46 @@ def test_get_queryset(self): qs = self.dashboard_stats.get_queryset() self.assertEqual(qs.count(), 1) self.assertEqual(qs.first(), self.kid1) + + +class CriteriaToStatsM2MTest(TestCase): + def setUp(self): + self.user = User.objects.create_user(username="testuser", 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.user) + self.kid3 = baker.make("TestKid", happy=True, age=7, height=120, author=self.user) + + # Create a DashboardStats instance + 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", + ) + + # Create a DashboardStatsCriteria instance + self.criteria = DashboardStatsCriteria.objects.create( + criteria_name="author", + dynamic_criteria_field_name="author__username", + ) + + # Create a CriteriaToStatsM2M instance + self.criteria_to_stats = CriteriaToStatsM2M.objects.create( + criteria=self.criteria, + stats=self.dashboard_stats, + order=1, + prefix="", + use_as="chart_filter", + ) + + def test_get_related_model_and_field(self): + model, field = self.criteria_to_stats.get_related_model_and_field("author__username") + self.assertEqual(model, User) + self.assertEqual(field, "username") + + model, field = self.criteria_to_stats.get_related_model_and_field("happy") + self.assertEqual(model.__name__, "TestKid") + self.assertEqual(field, "happy")