Add needs_price_update field to Purchase model
Replace fragile price change detection in Purchase.save() with a lazy dirty flag approach. A pre_save/post_save signal pair detects price/currency changes without extra DB queries, and convert_prices() uses the flag to determine which purchases need conversion. - Add needs_price_update BooleanField with db_index - Add pre_save signal to store old price/currency values - Add post_save signal to set needs_price_update=True when price/currency changes - Simplify Purchase.save() to remove DB reload + comparison logic - Remove price_or_currency_differ_from() method - Update convert_prices() to filter on needs_price_update flag - Extract _get_exchange_rate() and _save_converted_price() helpers - Add tests for the new behavior
This commit is contained in:
@@ -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 != ''",
|
||||||
|
),
|
||||||
|
]
|
||||||
+1
-19
@@ -179,6 +179,7 @@ class Purchase(models.Model):
|
|||||||
price_currency = models.CharField(max_length=3, default="USD")
|
price_currency = models.CharField(max_length=3, default="USD")
|
||||||
converted_price = models.FloatField(null=True)
|
converted_price = models.FloatField(null=True)
|
||||||
converted_currency = models.CharField(max_length=3, blank=True, default="")
|
converted_currency = models.CharField(max_length=3, blank=True, default="")
|
||||||
|
needs_price_update = models.BooleanField(default=True, db_index=True)
|
||||||
price_per_game = GeneratedField(
|
price_per_game = GeneratedField(
|
||||||
expression=Coalesce(F("converted_price"), F("price"), 0) / F("num_purchases"),
|
expression=Coalesce(F("converted_price"), F("price"), 0) / F("num_purchases"),
|
||||||
output_field=models.FloatField(),
|
output_field=models.FloatField(),
|
||||||
@@ -240,12 +241,6 @@ class Purchase(models.Model):
|
|||||||
def is_game(self):
|
def is_game(self):
|
||||||
return self.type == self.GAME
|
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):
|
def refund(self):
|
||||||
self.date_refunded = timezone.now()
|
self.date_refunded = timezone.now()
|
||||||
self.save()
|
self.save()
|
||||||
@@ -255,19 +250,6 @@ class Purchase(models.Model):
|
|||||||
raise ValidationError(
|
raise ValidationError(
|
||||||
f"{self.get_type_display()} must have a related purchase."
|
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)
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,29 @@ from games.models import Game, GameStatusChange, Purchase, Session
|
|||||||
logger = logging.getLogger("games")
|
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)
|
@receiver(m2m_changed, sender=Purchase.games.through)
|
||||||
def update_num_purchases(sender, instance, action, reverse, **kwargs):
|
def update_num_purchases(sender, instance, action, reverse, **kwargs):
|
||||||
if not reverse and action.startswith("post_"):
|
if not reverse and action.startswith("post_"):
|
||||||
|
|||||||
+41
-31
@@ -1,6 +1,7 @@
|
|||||||
import logging
|
import logging
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
|
from django.db import models
|
||||||
from django.template.defaultfilters import floatformat
|
from django.template.defaultfilters import floatformat
|
||||||
|
|
||||||
logger = logging.getLogger("games")
|
logger = logging.getLogger("games")
|
||||||
@@ -12,41 +13,18 @@ currency_to = "CZK"
|
|||||||
currency_to = currency_to.upper()
|
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})"
|
|
||||||
)
|
|
||||||
purchase.converted_price = converted_price
|
|
||||||
purchase.converted_currency = converted_currency
|
|
||||||
purchase.save()
|
|
||||||
|
|
||||||
|
|
||||||
def convert_prices():
|
|
||||||
purchases = Purchase.objects.filter(
|
|
||||||
converted_price__isnull=True, converted_currency=""
|
|
||||||
)
|
|
||||||
if purchases.count() == 0:
|
|
||||||
logger.info("[convert_prices]: No prices to convert.")
|
|
||||||
|
|
||||||
for purchase in purchases:
|
|
||||||
if purchase.price_currency.upper() == currency_to or purchase.price == 0:
|
|
||||||
save_converted_info(purchase, purchase.price, currency_to)
|
|
||||||
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(
|
logger.info(
|
||||||
f"[convert_prices]: Looking for exchange rate in database: {currency_from}->{currency_to}"
|
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:
|
if not exchange_rate:
|
||||||
logger.info(
|
logger.info(
|
||||||
f"[convert_prices]: Getting exchange rate from {currency_from} to {currency_to} for {year}..."
|
f"[convert_prices]: Getting exchange rate from {currency_from} to {currency_to} for {year}..."
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
# this API endpoint only accepts lowercase currency string
|
|
||||||
response = requests.get(
|
response = requests.get(
|
||||||
f"https://cdn.jsdelivr.net/npm/@fawazahmed0/currency-api@{year}-01-01/v1/currencies/{currency_from.lower()}.json"
|
f"https://cdn.jsdelivr.net/npm/@fawazahmed0/currency-api@{year}-01-01/v1/currencies/{currency_from.lower()}.json"
|
||||||
)
|
)
|
||||||
@@ -54,7 +32,6 @@ def convert_prices():
|
|||||||
data = response.json()
|
data = response.json()
|
||||||
currency_from_data = data.get(currency_from.lower())
|
currency_from_data = data.get(currency_from.lower())
|
||||||
rate = currency_from_data.get(currency_to.lower())
|
rate = currency_from_data.get(currency_to.lower())
|
||||||
|
|
||||||
if rate:
|
if rate:
|
||||||
logger.info(f"[convert_prices]: Got {rate}, saving...")
|
logger.info(f"[convert_prices]: Got {rate}, saving...")
|
||||||
exchange_rate = ExchangeRate.objects.create(
|
exchange_rate = ExchangeRate.objects.create(
|
||||||
@@ -63,17 +40,50 @@ def convert_prices():
|
|||||||
year=year,
|
year=year,
|
||||||
rate=floatformat(rate, 2),
|
rate=floatformat(rate, 2),
|
||||||
)
|
)
|
||||||
|
exchange_rate = exchange_rate.rate
|
||||||
else:
|
else:
|
||||||
logger.info("[convert_prices]: Could not get an exchange rate.")
|
logger.info("[convert_prices]: Could not get an exchange rate.")
|
||||||
except requests.RequestException as e:
|
except requests.RequestException as e:
|
||||||
logger.info(
|
logger.info(
|
||||||
f"[convert_prices]: Failed to fetch exchange rate for {currency_from}->{currency_to} in {year}: {e}"
|
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 = 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(
|
||||||
|
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_price(purchase, purchase.price, needs_update)
|
||||||
|
continue
|
||||||
|
year = purchase.date_purchased.year
|
||||||
|
currency_from = purchase.price_currency.upper()
|
||||||
|
exchange_rate = _get_exchange_rate(currency_from, currency_to, year)
|
||||||
if exchange_rate:
|
if exchange_rate:
|
||||||
save_converted_info(
|
_save_converted_price(
|
||||||
purchase,
|
purchase,
|
||||||
floatformat(purchase.price * exchange_rate.rate, 0),
|
floatformat(purchase.price * exchange_rate, 0),
|
||||||
currency_to,
|
needs_update,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+108
-2
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user