diff --git a/config/settings.py b/config/settings.py index 3f3bfa7..4b7a406 100644 --- a/config/settings.py +++ b/config/settings.py @@ -39,6 +39,7 @@ "django_filters", # django-filter "drf_spectacular", # drf-spectacular "django_q", # django-q2 + "solo", # django-solo # "debug_toolbar", # django-debug-toolbar (see below) "django_extensions", # django-extensions ] @@ -50,6 +51,7 @@ "open_prices.proofs", "open_prices.prices", "open_prices.users", + "open_prices.stats", "open_prices.api", "open_prices.www", ] diff --git a/open_prices/stats/__init__.py b/open_prices/stats/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/open_prices/stats/migrations/0001_initial.py b/open_prices/stats/migrations/0001_initial.py new file mode 100644 index 0000000..9f71ab7 --- /dev/null +++ b/open_prices/stats/migrations/0001_initial.py @@ -0,0 +1,44 @@ +# Generated by Django 5.1 on 2024-09-28 07:30 + +import django.utils.timezone +from django.db import migrations, models + + +class Migration(migrations.Migration): + initial = True + + dependencies = [] + + operations = [ + migrations.CreateModel( + name="TotalStats", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("price_count", models.PositiveIntegerField(default=0)), + ("price_barcode_count", models.PositiveIntegerField(default=0)), + ("price_category_count", models.PositiveIntegerField(default=0)), + ("product_count", models.PositiveIntegerField(default=0)), + ("product_with_price_count", models.PositiveIntegerField(default=0)), + ("location_count", models.PositiveIntegerField(default=0)), + ("location_with_price_count", models.PositiveIntegerField(default=0)), + ("proof_count", models.PositiveIntegerField(default=0)), + ("proof_with_price_count", models.PositiveIntegerField(default=0)), + ("user_count", models.PositiveIntegerField(default=0)), + ("user_with_price_count", models.PositiveIntegerField(default=0)), + ("created", models.DateTimeField(default=django.utils.timezone.now)), + ("updated", models.DateTimeField(auto_now=True)), + ], + options={ + "verbose_name": "Total Stats", + "verbose_name_plural": "Total Stats", + }, + ), + ] diff --git a/open_prices/stats/migrations/__init__.py b/open_prices/stats/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/open_prices/stats/models.py b/open_prices/stats/models.py new file mode 100644 index 0000000..ce55a2f --- /dev/null +++ b/open_prices/stats/models.py @@ -0,0 +1,79 @@ +from django.db import models +from django.utils import timezone +from solo.models import SingletonModel + + +class TotalStats(SingletonModel): + PRICE_COUNT_FIELDS = ["price_count", "price_barcode_count", "price_category_count"] + PRODUCT_COUNT_FIELDS = ["product_count", "product_with_price_count"] + LOCATION_COUNT_FIELDS = ["location_count", "location_with_price_count"] + PROOF_COUNT_FIELDS = ["proof_count", "proof_with_price_count"] + USER_COUNT_FIELDS = ["user_count", "user_with_price_count"] + + price_count = models.PositiveIntegerField(default=0) + price_barcode_count = models.PositiveIntegerField(default=0) + price_category_count = models.PositiveIntegerField(default=0) + product_count = models.PositiveIntegerField(default=0) + product_with_price_count = models.PositiveIntegerField(default=0) + location_count = models.PositiveIntegerField(default=0) + location_with_price_count = models.PositiveIntegerField(default=0) + proof_count = models.PositiveIntegerField(default=0) + proof_with_price_count = models.PositiveIntegerField(default=0) + user_count = models.PositiveIntegerField(default=0) + user_with_price_count = models.PositiveIntegerField(default=0) + + created = models.DateTimeField(default=timezone.now) + updated = models.DateTimeField(auto_now=True) + + class Meta: + verbose_name = "Total Stats" + verbose_name_plural = "Total Stats" + + def update_price_stats(self): + from open_prices.prices.models import Price + + self.price_count = Price.objects.count() + self.price_barcode_count = Price.objects.filter( + product_code__isnull=False + ).count() + self.price_category_count = Price.objects.filter( + category_tag__isnull=False + ).count() + self.save( + update_fields=self.PRICE_COUNT_FIELDS + + [ + "updated", + ] + ) + + def update_product_stats(self): + from open_prices.products.models import Product + + self.product_count = Product.objects.count() + self.product_with_price_count = Product.objects.has_prices().count() + # self.product_with_price_count = User.objects.values_list("product_id", flat=True).distinct().count() # noqa + self.save(update_fields=self.PRODUCT_COUNT_FIELDS + ["updated"]) + + def update_location_stats(self): + from open_prices.locations.models import Location + + self.location_count = Location.objects.count() + self.location_with_price_count = Location.objects.has_prices().count() + # self.location_with_price_count = User.objects.values_list("location_id", flat=True).distinct().count() # noqa + self.save(update_fields=self.LOCATION_COUNT_FIELDS + ["updated"]) + + def update_proof_stats(self): + from open_prices.proofs.models import Proof + + self.proof_count = Proof.objects.count() + self.proof_with_price_count = Proof.objects.has_prices().count() + # self.proof_with_price_count = User.objects.values_list("proof_id", flat=True).distinct().count() # noqa + self.save(update_fields=self.PROOF_COUNT_FIELDS + ["updated"]) + + def update_user_stats(self): + from open_prices.users.models import User + + self.user_count = User.objects.count() + self.user_with_price_count = User.objects.has_prices().count() + # self.user_with_price_count = User.objects.values_list("owner", flat=True).distinct().count() # noqa + self.save(update_fields=self.USER_COUNT_FIELDS + ["updated"]) diff --git a/open_prices/stats/tests.py b/open_prices/stats/tests.py new file mode 100644 index 0000000..d4b21cf --- /dev/null +++ b/open_prices/stats/tests.py @@ -0,0 +1,101 @@ +from django.db import IntegrityError +from django.test import TestCase + +from open_prices.locations.factories import LocationFactory +from open_prices.prices.factories import PriceFactory +from open_prices.proofs.factories import ProofFactory +from open_prices.stats.models import TotalStats +from open_prices.users.factories import UserFactory + +LOCATION_NODE_652825274 = { + "osm_id": 652825274, + "osm_type": "NODE", + "osm_name": "Monoprix", +} + + +class TotalStatsSaveTest(TestCase): + @classmethod + def setUpTestData(cls): + cls.total_stats = TotalStats.get_solo() + + def test_total_stats_singleton(self): + # cannot create another TotalStats instance + self.assertRaises(IntegrityError, TotalStats.objects.create) + + +class TotalStatsTest(TestCase): + @classmethod + def setUpTestData(cls): + cls.total_stats = TotalStats.get_solo() + cls.user = UserFactory() + cls.user_2 = UserFactory() + cls.location = LocationFactory(**LOCATION_NODE_652825274) + cls.location_2 = LocationFactory() + cls.proof = ProofFactory( + location_osm_id=cls.location.osm_id, + location_osm_type=cls.location.osm_type, + owner=cls.user.user_id, + ) + cls.proof_2 = ProofFactory( + location_osm_id=cls.location_2.osm_id, + location_osm_type=cls.location_2.osm_type, + owner=cls.user_2.user_id, + ) + PriceFactory( + product_code="0123456789100", + location_osm_id=cls.location.osm_id, + location_osm_type=cls.location.osm_type, + proof_id=cls.proof.id, + price=1.0, + owner=cls.user.user_id, + ) + PriceFactory( + product_code="0123456789101", + location_osm_id=cls.location.osm_id, + location_osm_type=cls.location.osm_type, + price=2.0, + owner=cls.user_2.user_id, + ) + + def test_update_price_stats(self): + self.assertEqual(self.total_stats.price_count, 0) + self.assertEqual(self.total_stats.price_barcode_count, 0) + self.assertEqual(self.total_stats.price_category_count, 0) + # update_price_stats() will update price_counts + self.total_stats.update_price_stats() + self.assertEqual(self.total_stats.price_count, 2) + self.assertEqual(self.total_stats.price_barcode_count, 2) + self.assertEqual(self.total_stats.price_category_count, 0) + + def test_update_product_stats(self): + self.assertEqual(self.total_stats.product_count, 0) + self.assertEqual(self.total_stats.product_with_price_count, 0) + # update_product_stats() will update product_counts + self.total_stats.update_product_stats() + self.assertEqual(self.total_stats.product_count, 2) + self.assertEqual(self.total_stats.product_with_price_count, 2) + + def test_update_location_stats(self): + self.assertEqual(self.total_stats.location_count, 0) + self.assertEqual(self.total_stats.location_with_price_count, 0) + # update_location_stats() will update location_counts + self.total_stats.update_location_stats() + self.assertEqual(self.total_stats.location_count, 2) + self.assertEqual(self.total_stats.location_with_price_count, 1) + + def test_update_proof_stats(self): + self.assertEqual(self.total_stats.proof_count, 0) + self.assertEqual(self.total_stats.proof_with_price_count, 0) + # update_proof_stats() will update proof_counts + self.total_stats.update_proof_stats() + self.assertEqual(self.total_stats.proof_count, 2) + self.assertEqual(self.total_stats.proof_with_price_count, 1) + + def test_update_user_stats(self): + self.assertEqual(self.total_stats.user_count, 0) + self.assertEqual(self.total_stats.user_with_price_count, 0) + # update_user_stats() will update user_counts + self.total_stats.update_user_stats() + self.assertEqual(self.total_stats.user_count, 2) + self.assertEqual(self.total_stats.user_with_price_count, 2) diff --git a/poetry.lock b/poetry.lock index 1b71ef4..a726932 100644 --- a/poetry.lock +++ b/poetry.lock @@ -680,6 +680,20 @@ rollbar = ["django-q-rollbar (>=0.1)"] sentry = ["django-q-sentry (>=0.1)"] testing = ["blessed (>=1.19.1,<2.0.0)", "boto3 (>=1.24.92,<2.0.0)", "croniter (>=2.0.1,<3.0.0)", "django-redis (>=5.2.0,<6.0.0)", "hiredis (>=2.0.0,<3.0.0)", "iron-mq (>=0.9,<0.10)", "psutil (>=5.9.2,<6.0.0)", "pymongo (>=4.2.0,<5.0.0)", "redis (>=4.3.4,<5.0.0)", "setproctitle (>=1.3.2,<2.0.0)"] +[[package]] +name = "django-solo" +version = "2.3.0" +description = "Django Solo helps working with singletons" +optional = false +python-versions = ">=3.8" +files = [ + {file = "django_solo-2.3.0-py3-none-any.whl", hash = "sha256:8069319fc9a3dc1080dc47b134d42526bf71749127eca1bf94bd7eb831c02fb2"}, + {file = "django_solo-2.3.0.tar.gz", hash = "sha256:e82ee8b0aeccb97c401dc722bf01f665c93484c880e929f2a0ea53f5cbf2bf61"}, +] + +[package.dependencies] +django = ">=3.2" + [[package]] name = "djangorestframework" version = "3.15.2" @@ -3368,4 +3382,4 @@ viz = ["matplotlib", "nc-time-axis", "seaborn"] [metadata] lock-version = "2.0" python-versions = "~3.11" -content-hash = "1774e7a2db12e23fbab83c68b7bdfa7e826ea34fca4093db18df8c5611853804" +content-hash = "3314770e1ee6292311a52bf32bc85b27188df48d844a58a1c125bccf087a6441" diff --git a/pyproject.toml b/pyproject.toml index 440d51b..3309937 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -63,6 +63,7 @@ ipython = "^8.26.0" gunicorn = "^22.0.0" django-cors-headers = "^4.4.0" sentry-sdk = {extras = ["django"], version = "^2.13.0"} +django-solo = "^2.3.0" [tool.poetry.group.dev.dependencies] black = "~23.12.1"