Compare commits

...

2 Commits

Author SHA1 Message Date
lukas 5003b739d3 PR review
Django CI/CD / test (push) Successful in 28s
Django CI/CD / build-and-push (push) Successful in 55s
2026-05-12 14:56:59 +02:00
lukas 4ba3ed555f Add info on statuses 2026-05-12 14:51:59 +02:00
3 changed files with 170 additions and 20 deletions
+157
View File
@@ -0,0 +1,157 @@
# Game & Purchase Status Definitions
## Game Statuses
Games have a `status` field with the following values:
| Status | Code | Description |
|--------|------|-------------|
| **Unplayed** | `u` | Game was purchased but never played |
| **Played** | `p` | Game was played but not yet finished |
| **Finished** | `f` | Game has been completed |
| **Retired** | `r` | Game was intentionally retired (e.g., no longer accessible, collector's item) |
| **Abandoned** | `a` | Game was played but the user gave up on it |
**Setting game status:**
- Users explicitly set game status via the UI (finish/drop purchase buttons, status change form)
- Status changes are tracked in `GameStatusChange` model
- Refunding a purchase always marks its games as abandoned
---
## Purchase-Level Status Concepts
These concepts determine whether a purchase appears in the "unfinished" or "dropped" lists in stats views.
### Finished
A purchase is considered **finished** when:
```
Game.status == "f" OR Purchase.games.* has a PlayEvent with an ended date
```
Either signal indicates the game is complete:
- **Explicit**: User marked the game as finished (`Game.status = "f"`)
- **Implicit**: A PlayEvent exists with `ended` date set (data-driven)
This uses **OR** logic during a transition period. Later, these signals should be kept in sync so only one source of truth is needed.
### Dropped
A purchase is considered **dropped** when:
```
Game.status == "a" OR Purchase.date_refunded IS NOT NULL
```
Either signal indicates the user no longer has an active interest in the game:
- **Explicit**: User marked the game as abandoned (`Game.status = "a"`)
- **Implicit**: User refunded the purchase (which automatically sets games to abandoned)
Note: Refunding a purchase always marks its games as abandoned. There is no option to refund without abandoning.
---
## Unfinished vs. Dropped
The stats views categorize purchases into **unfinished** and **dropped** lists.
### Unfinished
A purchase is **unfinished** when:
1. It was purchased in the relevant time period (this year for yearly stats, all time for all-time stats)
2. It was NOT refunded (only counts toward unfinished/backlog)
3. It is NOT finished (per the finished definition above)
4. It is NOT dropped (per the dropped definition above)
5. It is NOT infinite (subscription, etc.)
6. It IS a game or DLC (not season passes or battle passes)
**Unfinished = Active backlog** — games the user may still play.
### Dropped
A purchase is **dropped** when:
1. It was purchased in the relevant time period
2. It is NOT finished (per the finished definition above)
3. It matches at least one dropped signal (per the dropped definition above)
4. It is NOT infinite
5. It IS a game or DLC
**Dropped = Terminal state** — games the user has given up on or refunded.
### Summary Table
| Category | Includes Refunded? | Key Condition |
|----------|-------------------|---------------|
| **Unfinished** | No | NOT finished, NOT dropped |
| **Dropped** | Yes | Finished OR Abandoned/Retired |
| **Refunded** | Yes | `date_refunded IS NOT NULL` |
| **Infinite** | Yes | `infinite = True` |
---
## Query Patterns
### Checking if a game is finished
```python
game.finished() # Returns True if status="f" or has PlayEvent with ended date
```
### Checking if a game is abandoned
```python
game.abandoned() # Returns True if status="a"
```
### Getting finished purchases
```python
Purchase.objects.finished() # All purchases where games are finished
```
### Getting dropped purchases
```python
Purchase.objects.dropped() # All purchases that are abandoned or refunded
```
---
## Transition State
The system uses **OR logic** for both finished and dropped to catch any mismatch between explicit user actions and data signals:
- **Finished**: `status="f" OR PlayEvent.ended`
- **Dropped**: `status="a" OR date_refunded`
This bridges the gap between the old model (where `date_finished` and `date_dropped` were on the Purchase model) and the new model (where `Game.status` and `PlayEvent` are the sources of truth).
**Future:** These signals should be kept in sync. For example:
- Setting `Game.status = "f"` should create a PlayEvent with `ended` date
- When the sync is reliable, the OR can be simplified to a single check
Note: Refunding a purchase always automatically sets its games' status to Abandoned. This is not optional — there is no way to refund without abandoning.
---
## Edge Cases
### Unplayed games
- Unplayed games (`status="u"`) are considered **unfinished**, not dropped
- They appear in the unfinished/backlog list since they are still games the user may play
- Unplayed games that are refunded DO count as **dropped** (refund signal overrides)
### Multiple games per purchase
- A purchase can have multiple games via `Purchase.games` (many-to-many)
- A purchase is finished if ANY of its games is finished
- A purchase is dropped if ANY of its games is abandoned OR the purchase itself is refunded
### PlayEvents without ended date
- A PlayEvent with `started` but no `ended` does NOT count as finished
- This represents a game that was started but not completed
### Retired games
- Retired games (`status="r"`) are considered **dropped**
- Retirement is for games the user intentionally removed from their collection (collector's items, no longer accessible, etc.)
+11 -11
View File
@@ -14,14 +14,14 @@ currency_to = currency_to.upper()
def _get_exchange_rate(currency_from, currency_to, year): def _get_exchange_rate(currency_from, currency_to, year):
logger.info( logger.debug(
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( rate = ExchangeRate.objects.filter(
currency_from=currency_from, currency_to=currency_to, year=year currency_from=currency_from, currency_to=currency_to, year=year
).first() ).first()
if not exchange_rate: if not rate:
logger.info( logger.debug(
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:
@@ -40,16 +40,16 @@ def _get_exchange_rate(currency_from, currency_to, year):
year=year, year=year,
rate=floatformat(rate, 2), rate=floatformat(rate, 2),
) )
exchange_rate = exchange_rate.rate 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: elif rate:
exchange_rate = exchange_rate.rate rate = rate.rate
return exchange_rate return rate
def _save_converted_price(purchase, converted_price, needs_update): def _save_converted_price(purchase, converted_price, needs_update):
@@ -78,11 +78,11 @@ def convert_prices():
continue continue
year = purchase.date_purchased.year year = purchase.date_purchased.year
currency_from = purchase.price_currency.upper() currency_from = purchase.price_currency.upper()
exchange_rate = _get_exchange_rate(currency_from, currency_to, year) rate = _get_exchange_rate(currency_from, currency_to, year)
if exchange_rate: if rate:
_save_converted_price( _save_converted_price(
purchase, purchase,
floatformat(purchase.price * exchange_rate, 0), floatformat(purchase.price * rate, 0),
needs_update, needs_update,
) )
+2 -9
View File
@@ -1,6 +1,6 @@
from datetime import date from datetime import date
from django.test import TestCase, override_settings from django.test import TestCase
from games.models import Game, Platform, Purchase from games.models import Game, Platform, Purchase
from games.tasks import convert_prices from games.tasks import convert_prices
@@ -29,14 +29,7 @@ class PurchaseNeedsPriceUpdateTest(TestCase):
purchase.games.add(self.game) purchase.games.add(self.game)
self.assertTrue(purchase.needs_price_update) self.assertTrue(purchase.needs_price_update)
with override_settings( convert_prices()
CACHES={
"default": {
"BACKEND": "django.core.cache.backends.locmem.LocMemCache"
}
}
):
convert_prices()
purchase.refresh_from_db() purchase.refresh_from_db()
self.assertFalse(purchase.needs_price_update) self.assertFalse(purchase.needs_price_update)