diff --git a/games/migrations/0016_add_needs_price_update.py b/games/migrations/0016_add_needs_price_update.py new file mode 100644 index 0000000..83f06fa --- /dev/null +++ b/games/migrations/0016_add_needs_price_update.py @@ -0,0 +1,22 @@ +# Generated by Django 6.0.1 on 2026-05-12 11:57 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('games', '0015_alter_purchase_date_purchased_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='purchase', + name='needs_price_update', + field=models.BooleanField(db_index=True, default=True), + ), + migrations.RunSQL( + "UPDATE games_purchase SET needs_price_update = FALSE WHERE converted_price IS NOT NULL AND converted_currency != ''", + reverse_sql="UPDATE games_purchase SET needs_price_update = TRUE WHERE converted_price IS NOT NULL AND converted_currency != ''", + ), + ] diff --git a/games/models.py b/games/models.py index 777a247..2899672 100644 --- a/games/models.py +++ b/games/models.py @@ -179,6 +179,7 @@ class Purchase(models.Model): price_currency = models.CharField(max_length=3, default="USD") converted_price = models.FloatField(null=True) converted_currency = models.CharField(max_length=3, blank=True, default="") + needs_price_update = models.BooleanField(default=True, db_index=True) price_per_game = GeneratedField( expression=Coalesce(F("converted_price"), F("price"), 0) / F("num_purchases"), output_field=models.FloatField(), @@ -240,12 +241,6 @@ class Purchase(models.Model): def is_game(self): return self.type == self.GAME - def price_or_currency_differ_from(self, purchase_to_compare): - return ( - self.price != purchase_to_compare.price - or self.price_currency != purchase_to_compare.price_currency - ) - def refund(self): self.date_refunded = timezone.now() self.save() @@ -255,19 +250,6 @@ class Purchase(models.Model): raise ValidationError( f"{self.get_type_display()} must have a related purchase." ) - if self.pk is not None: - # Retrieve the existing instance from the database - existing_purchase = Purchase.objects.get(pk=self.pk) - # If price has changed, reset converted fields - if existing_purchase.price_or_currency_differ_from(self): - from games.tasks import currency_to - - exchange_rate = get_or_create_rate( - self.price_currency, currency_to, self.date_purchased.year - ) - if exchange_rate: - self.converted_price = floatformat(self.price * exchange_rate, 0) - self.converted_currency = currency_to super().save(*args, **kwargs) diff --git a/games/signals.py b/games/signals.py index e2f805f..5f17bc6 100644 --- a/games/signals.py +++ b/games/signals.py @@ -17,6 +17,29 @@ from games.models import Game, GameStatusChange, Purchase, Session logger = logging.getLogger("games") +@receiver(pre_save, sender=Purchase) +def store_purchase_price_snapshot(sender, instance, **kwargs): + """Store old price values before save so we can detect changes.""" + if instance.pk is not None: + try: + old_instance = sender.objects.get(pk=instance.pk) + instance._old_price = old_instance.price + instance._old_currency = old_instance.price_currency + except sender.DoesNotExist: + pass + + +@receiver(post_save, sender=Purchase) +def mark_needs_price_update(sender, instance, created, **kwargs): + """Mark purchase for price update if price or currency changed.""" + if not created and hasattr(instance, "_old_price"): + if ( + instance.price != instance._old_price + or instance.price_currency != instance._old_currency + ): + sender.objects.filter(pk=instance.pk).update(needs_price_update=True) + + @receiver(m2m_changed, sender=Purchase.games.through) def update_num_purchases(sender, instance, action, reverse, **kwargs): if not reverse and action.startswith("post_"): diff --git a/games/tasks.py b/games/tasks.py index b21fb35..f59ecb4 100644 --- a/games/tasks.py +++ b/games/tasks.py @@ -1,6 +1,7 @@ import logging import requests +from django.db import models from django.template.defaultfilters import floatformat logger = logging.getLogger("games") @@ -12,68 +13,77 @@ currency_to = "CZK" currency_to = currency_to.upper() -def save_converted_info(purchase, converted_price, converted_currency): +def _get_exchange_rate(currency_from, currency_to, year): logger.info( - f"Setting converted price of {purchase} to {converted_price} {converted_currency} (originally {purchase.price} {purchase.price_currency})" + f"[convert_prices]: Looking for exchange rate in database: {currency_from}->{currency_to}" + ) + exchange_rate = ExchangeRate.objects.filter( + currency_from=currency_from, currency_to=currency_to, year=year + ).first() + if not exchange_rate: + logger.info( + f"[convert_prices]: Getting exchange rate from {currency_from} to {currency_to} for {year}..." + ) + try: + response = requests.get( + f"https://cdn.jsdelivr.net/npm/@fawazahmed0/currency-api@{year}-01-01/v1/currencies/{currency_from.lower()}.json" + ) + response.raise_for_status() + data = response.json() + currency_from_data = data.get(currency_from.lower()) + rate = currency_from_data.get(currency_to.lower()) + if rate: + logger.info(f"[convert_prices]: Got {rate}, saving...") + exchange_rate = ExchangeRate.objects.create( + currency_from=currency_from, + currency_to=currency_to, + year=year, + rate=floatformat(rate, 2), + ) + exchange_rate = exchange_rate.rate + else: + logger.info("[convert_prices]: Could not get an exchange rate.") + except requests.RequestException as e: + logger.info( + f"[convert_prices]: Failed to fetch exchange rate for {currency_from}->{currency_to} in {year}: {e}" + ) + elif exchange_rate: + exchange_rate = exchange_rate.rate + return exchange_rate + + +def _save_converted_price(purchase, converted_price, needs_update): + logger.info( + f"Setting converted price of {purchase} to {converted_price} {currency_to} (originally {purchase.price} {purchase.price_currency})" ) purchase.converted_price = converted_price - purchase.converted_currency = converted_currency - purchase.save() + purchase.converted_currency = currency_to + if needs_update: + purchase.needs_price_update = False + purchase.save(update_fields=["converted_price", "converted_currency", "needs_price_update"]) def convert_prices(): purchases = Purchase.objects.filter( - converted_price__isnull=True, converted_currency="" - ) + models.Q(needs_price_update=True) | models.Q(converted_price__isnull=True) + ).distinct() if purchases.count() == 0: logger.info("[convert_prices]: No prices to convert.") + return for purchase in purchases: + needs_update = purchase.needs_price_update if purchase.price_currency.upper() == currency_to or purchase.price == 0: - save_converted_info(purchase, purchase.price, currency_to) + _save_converted_price(purchase, purchase.price, needs_update) continue year = purchase.date_purchased.year currency_from = purchase.price_currency.upper() - - exchange_rate = ExchangeRate.objects.filter( - currency_from=currency_from, currency_to=currency_to, year=year - ).first() - logger.info( - f"[convert_prices]: Looking for exchange rate in database: {currency_from}->{currency_to}" - ) - if not exchange_rate: - logger.info( - f"[convert_prices]: Getting exchange rate from {currency_from} to {currency_to} for {year}..." - ) - try: - # this API endpoint only accepts lowercase currency string - response = requests.get( - f"https://cdn.jsdelivr.net/npm/@fawazahmed0/currency-api@{year}-01-01/v1/currencies/{currency_from.lower()}.json" - ) - response.raise_for_status() - data = response.json() - currency_from_data = data.get(currency_from.lower()) - rate = currency_from_data.get(currency_to.lower()) - - if rate: - logger.info(f"[convert_prices]: Got {rate}, saving...") - exchange_rate = ExchangeRate.objects.create( - currency_from=currency_from, - currency_to=currency_to, - year=year, - rate=floatformat(rate, 2), - ) - else: - logger.info("[convert_prices]: Could not get an exchange rate.") - except requests.RequestException as e: - logger.info( - f"[convert_prices]: Failed to fetch exchange rate for {currency_from}->{currency_to} in {year}: {e}" - ) + exchange_rate = _get_exchange_rate(currency_from, currency_to, year) if exchange_rate: - save_converted_info( + _save_converted_price( purchase, - floatformat(purchase.price * exchange_rate.rate, 0), - currency_to, + floatformat(purchase.price * exchange_rate, 0), + needs_update, ) diff --git a/games/tests.py b/games/tests.py index 7ce503c..fea225b 100644 --- a/games/tests.py +++ b/games/tests.py @@ -1,3 +1,109 @@ -from django.test import TestCase +from datetime import date -# Create your tests here. +from django.test import TestCase, override_settings + +from games.models import Game, Platform, Purchase +from games.tasks import convert_prices + + +class PurchaseNeedsPriceUpdateTest(TestCase): + def setUp(self): + self.platform = Platform.objects.create(name="PC", icon="pc", group="PC") + self.game = Game.objects.create(name="Test Game", platform=self.platform) + + def test_new_purchase_has_needs_price_update_true(self): + purchase = Purchase.objects.create( + price=50.0, + price_currency="USD", + date_purchased=date(2025, 1, 1), + ) + purchase.games.add(self.game) + self.assertTrue(purchase.needs_price_update) + + def test_convert_prices_sets_flag_to_false(self): + purchase = Purchase.objects.create( + price=50.0, + price_currency="USD", + date_purchased=date(2025, 1, 1), + ) + purchase.games.add(self.game) + self.assertTrue(purchase.needs_price_update) + + with override_settings( + CACHES={ + "default": { + "BACKEND": "django.core.cache.backends.locmem.LocMemCache" + } + } + ): + convert_prices() + + purchase.refresh_from_db() + self.assertFalse(purchase.needs_price_update) + + def test_price_change_sets_needs_price_update(self): + purchase = Purchase.objects.create( + price=50.0, + price_currency="USD", + date_purchased=date(2025, 1, 1), + ) + purchase.games.add(self.game) + purchase.converted_price = 1000 + purchase.converted_currency = "CZK" + purchase.needs_price_update = False + purchase.save() + + purchase.price = 60.0 + purchase.save() + purchase.refresh_from_db() + self.assertTrue(purchase.needs_price_update) + + def test_currency_change_sets_needs_price_update(self): + purchase = Purchase.objects.create( + price=50.0, + price_currency="USD", + date_purchased=date(2025, 1, 1), + ) + purchase.games.add(self.game) + purchase.converted_price = 1000 + purchase.converted_currency = "CZK" + purchase.needs_price_update = False + purchase.save() + + purchase.price_currency = "EUR" + purchase.save() + purchase.refresh_from_db() + self.assertTrue(purchase.needs_price_update) + + def test_name_change_does_not_set_needs_price_update(self): + purchase = Purchase.objects.create( + price=50.0, + price_currency="USD", + date_purchased=date(2025, 1, 1), + ) + purchase.games.add(self.game) + purchase.converted_price = 1000 + purchase.converted_currency = "CZK" + purchase.needs_price_update = False + purchase.save() + + purchase.name = "New Name" + purchase.save() + purchase.refresh_from_db() + self.assertFalse(purchase.needs_price_update) + + def test_convert_prices_skips_already_converted(self): + purchase = Purchase.objects.create( + price=50.0, + price_currency="USD", + date_purchased=date(2025, 1, 1), + ) + purchase.games.add(self.game) + purchase.converted_price = 1000 + purchase.converted_currency = "CZK" + purchase.needs_price_update = False + purchase.save() + + convert_prices() + purchase.refresh_from_db() + self.assertFalse(purchase.needs_price_update)