Introduce game status, playevents
This commit is contained in:
80
games/api.py
Normal file
80
games/api.py
Normal file
@ -0,0 +1,80 @@
|
||||
from datetime import date, datetime
|
||||
from typing import List
|
||||
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.utils.timezone import now as django_timezone_now
|
||||
from ninja import Field, ModelSchema, NinjaAPI, Router, Schema
|
||||
|
||||
from games.models import PlayEvent
|
||||
|
||||
api = NinjaAPI()
|
||||
playevent_router = Router()
|
||||
|
||||
NOW_FACTORY = django_timezone_now
|
||||
|
||||
|
||||
class PlayEventIn(Schema):
|
||||
game_id: int
|
||||
started: date | None = None
|
||||
ended: date | None = None
|
||||
note: str = ""
|
||||
days_to_finish: int | None = None
|
||||
|
||||
|
||||
class AutoPlayEventIn(ModelSchema):
|
||||
class Meta:
|
||||
model = PlayEvent
|
||||
fields = ["game", "started", "ended", "note"]
|
||||
|
||||
|
||||
class UpdatePlayEventIn(Schema):
|
||||
started: date | None = None
|
||||
ended: date | None = None
|
||||
note: str = ""
|
||||
|
||||
|
||||
class PlayEventOut(Schema):
|
||||
id: int
|
||||
game: str = Field(..., alias="game.name")
|
||||
started: date | None = None
|
||||
ended: date | None = None
|
||||
days_to_finish: int | None = None
|
||||
note: str = ""
|
||||
updated_at: datetime
|
||||
created_at: datetime
|
||||
|
||||
|
||||
@playevent_router.get("/", response=List[PlayEventOut])
|
||||
def list_playevents(request):
|
||||
return PlayEvent.objects.all()
|
||||
|
||||
|
||||
@playevent_router.post("/", response={201: PlayEventOut})
|
||||
def create_playevent(request, payload: PlayEventIn):
|
||||
playevent = PlayEvent.objects.create(**payload.dict())
|
||||
return playevent
|
||||
|
||||
|
||||
@playevent_router.get("/{playevent_id}", response=PlayEventOut)
|
||||
def get_playevent(request, playevent_id: int):
|
||||
playevent = get_object_or_404(PlayEvent, id=playevent_id)
|
||||
return playevent
|
||||
|
||||
|
||||
@playevent_router.patch("/{playevent_id}", response=PlayEventOut)
|
||||
def partial_update_playevent(request, playevent_id: int, payload: UpdatePlayEventIn):
|
||||
playevent = get_object_or_404(PlayEvent, id=playevent_id)
|
||||
for attr, value in payload.dict(exclude_unset=True).items():
|
||||
setattr(playevent, attr, value)
|
||||
playevent.save()
|
||||
return playevent
|
||||
|
||||
|
||||
@playevent_router.delete("/{playevent_id}", response={204: None})
|
||||
def delete_playevent(request, playevent_id: int):
|
||||
playevent = get_object_or_404(PlayEvent, id=playevent_id)
|
||||
playevent.delete()
|
||||
return 204, None
|
||||
|
||||
|
||||
api.add_router("/playevent", playevent_router)
|
@ -1,9 +1,10 @@
|
||||
from datetime import timedelta
|
||||
# from datetime import timedelta
|
||||
|
||||
from django.apps import AppConfig
|
||||
from django.core.management import call_command
|
||||
from django.db.models.signals import post_migrate
|
||||
from django.utils.timezone import now
|
||||
|
||||
# from django.utils.timezone import now
|
||||
|
||||
|
||||
class GamesConfig(AppConfig):
|
||||
@ -17,26 +18,26 @@ class GamesConfig(AppConfig):
|
||||
|
||||
|
||||
def schedule_tasks(sender, **kwargs):
|
||||
from django_q.models import Schedule
|
||||
from django_q.tasks import schedule
|
||||
# from django_q.models import Schedule
|
||||
# from django_q.tasks import schedule
|
||||
|
||||
if not Schedule.objects.filter(name="Update converted prices").exists():
|
||||
schedule(
|
||||
"games.tasks.convert_prices",
|
||||
name="Update converted prices",
|
||||
schedule_type=Schedule.MINUTES,
|
||||
next_run=now() + timedelta(seconds=30),
|
||||
catchup=False,
|
||||
)
|
||||
# if not Schedule.objects.filter(name="Update converted prices").exists():
|
||||
# schedule(
|
||||
# "games.tasks.convert_prices",
|
||||
# name="Update converted prices",
|
||||
# schedule_type=Schedule.MINUTES,
|
||||
# next_run=now() + timedelta(seconds=30),
|
||||
# catchup=False,
|
||||
# )
|
||||
|
||||
if not Schedule.objects.filter(name="Update price per game").exists():
|
||||
schedule(
|
||||
"games.tasks.calculate_price_per_game",
|
||||
name="Update price per game",
|
||||
schedule_type=Schedule.MINUTES,
|
||||
next_run=now() + timedelta(seconds=30),
|
||||
catchup=False,
|
||||
)
|
||||
# if not Schedule.objects.filter(name="Update price per game").exists():
|
||||
# schedule(
|
||||
# "games.tasks.calculate_price_per_game",
|
||||
# name="Update price per game",
|
||||
# schedule_type=Schedule.MINUTES,
|
||||
# next_run=now() + timedelta(seconds=30),
|
||||
# catchup=False,
|
||||
# )
|
||||
|
||||
from games.models import ExchangeRate
|
||||
|
||||
|
@ -2,7 +2,7 @@ from django import forms
|
||||
from django.urls import reverse
|
||||
|
||||
from common.utils import safe_getattr
|
||||
from games.models import Device, Game, Platform, Purchase, Session
|
||||
from games.models import Device, Game, Platform, PlayEvent, Purchase, Session
|
||||
|
||||
custom_date_widget = forms.DateInput(attrs={"type": "date"})
|
||||
custom_datetime_widget = forms.DateTimeInput(
|
||||
@ -103,8 +103,6 @@ class PurchaseForm(forms.ModelForm):
|
||||
widgets = {
|
||||
"date_purchased": custom_date_widget,
|
||||
"date_refunded": custom_date_widget,
|
||||
"date_finished": custom_date_widget,
|
||||
"date_dropped": custom_date_widget,
|
||||
}
|
||||
model = Purchase
|
||||
fields = [
|
||||
@ -112,8 +110,6 @@ class PurchaseForm(forms.ModelForm):
|
||||
"platform",
|
||||
"date_purchased",
|
||||
"date_refunded",
|
||||
"date_finished",
|
||||
"date_dropped",
|
||||
"infinite",
|
||||
"price",
|
||||
"price_currency",
|
||||
@ -171,6 +167,7 @@ class GameForm(forms.ModelForm):
|
||||
"name",
|
||||
"sort_name",
|
||||
"platform",
|
||||
"original_year_released",
|
||||
"year_released",
|
||||
"status",
|
||||
"mastered",
|
||||
@ -195,3 +192,23 @@ class DeviceForm(forms.ModelForm):
|
||||
model = Device
|
||||
fields = ["name", "type"]
|
||||
widgets = {"name": autofocus_input_widget}
|
||||
|
||||
|
||||
class PlayEventForm(forms.ModelForm):
|
||||
game = GameModelChoiceField(
|
||||
queryset=Game.objects.order_by("sort_name"),
|
||||
widget=forms.Select(attrs={"autofocus": "autofocus"}),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = PlayEvent
|
||||
fields = [
|
||||
"game",
|
||||
"started",
|
||||
"ended",
|
||||
"note",
|
||||
]
|
||||
widgets = {
|
||||
"started": custom_date_widget,
|
||||
"ended": custom_date_widget,
|
||||
}
|
||||
|
@ -0,0 +1,190 @@
|
||||
# Generated by Django 5.1.7 on 2025-03-19 13:11
|
||||
|
||||
import django.db.models.deletion
|
||||
import django.db.models.expressions
|
||||
from django.db import migrations, models
|
||||
from django.db.models import F, Min
|
||||
|
||||
|
||||
def copy_year_released(apps, schema_editor):
|
||||
Game = apps.get_model("games", "Game")
|
||||
Game.objects.update(original_year_released=F("year_released"))
|
||||
|
||||
|
||||
def set_abandoned_status(apps, schema_editor):
|
||||
Game = apps.get_model("games", "Game")
|
||||
Game = apps.get_model("games", "Game")
|
||||
PlayEvent = apps.get_model("games", "PlayEvent")
|
||||
|
||||
Game.objects.filter(purchases__date_refunded__isnull=False).update(status="a")
|
||||
Game.objects.filter(purchases__date_dropped__isnull=False).update(status="a")
|
||||
|
||||
finished = Game.objects.filter(purchases__date_finished__isnull=False)
|
||||
|
||||
for game in finished:
|
||||
for purchase in game.purchases.all():
|
||||
first_session = game.sessions.filter(
|
||||
timestamp_start__gte=purchase.date_purchased
|
||||
).aggregate(Min("timestamp_start"))["timestamp_start__min"]
|
||||
first_session_date = first_session.date() if first_session else None
|
||||
if purchase.date_finished:
|
||||
play_event = PlayEvent(
|
||||
game=game,
|
||||
started=first_session_date
|
||||
if first_session_date
|
||||
else purchase.date_purchased,
|
||||
ended=purchase.date_finished,
|
||||
)
|
||||
play_event.save()
|
||||
|
||||
|
||||
def create_game_status_changes(apps, schema_editor):
|
||||
Game = apps.get_model("games", "Game")
|
||||
GameStatusChange = apps.get_model("games", "GameStatusChange")
|
||||
|
||||
# if game has any sessions, find the earliest session and create a status change from unplayed to played with that sessions's timestamp_start
|
||||
for game in Game.objects.filter(sessions__isnull=False).distinct():
|
||||
if game.sessions.exists():
|
||||
earliest_session = game.sessions.earliest()
|
||||
GameStatusChange.objects.create(
|
||||
game=game,
|
||||
old_status="u",
|
||||
new_status="p",
|
||||
timestamp=earliest_session.timestamp_start,
|
||||
)
|
||||
|
||||
for game in Game.objects.filter(purchases__date_dropped__isnull=False):
|
||||
GameStatusChange.objects.create(
|
||||
game=game,
|
||||
old_status="p",
|
||||
new_status="a",
|
||||
timestamp=game.purchases.first().date_dropped,
|
||||
)
|
||||
|
||||
for game in Game.objects.filter(purchases__date_refunded__isnull=False):
|
||||
GameStatusChange.objects.create(
|
||||
game=game,
|
||||
old_status="p",
|
||||
new_status="a",
|
||||
timestamp=game.purchases.first().date_refunded,
|
||||
)
|
||||
|
||||
# check if game has any playevents, if so create a status change from current status to finished based on playevent's ended date
|
||||
# consider only the first playevent
|
||||
for game in Game.objects.filter(playevents__isnull=False):
|
||||
first_playevent = game.playevents.first()
|
||||
GameStatusChange.objects.create(
|
||||
game=game,
|
||||
old_status="p",
|
||||
new_status="f",
|
||||
timestamp=first_playevent.ended,
|
||||
)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("games", "0007_game_updated_at"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="game",
|
||||
name="original_year_released",
|
||||
field=models.IntegerField(blank=True, default=None, null=True),
|
||||
),
|
||||
migrations.RunPython(copy_year_released),
|
||||
migrations.CreateModel(
|
||||
name="GameStatusChange",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
(
|
||||
"old_status",
|
||||
models.CharField(
|
||||
blank=True,
|
||||
choices=[
|
||||
("u", "Unplayed"),
|
||||
("p", "Played"),
|
||||
("f", "Finished"),
|
||||
("r", "Retired"),
|
||||
("a", "Abandoned"),
|
||||
],
|
||||
max_length=1,
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
(
|
||||
"new_status",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("u", "Unplayed"),
|
||||
("p", "Played"),
|
||||
("f", "Finished"),
|
||||
("r", "Retired"),
|
||||
("a", "Abandoned"),
|
||||
],
|
||||
max_length=1,
|
||||
),
|
||||
),
|
||||
("timestamp", models.DateTimeField(null=True)),
|
||||
(
|
||||
"game",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="status_changes",
|
||||
to="games.game",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"ordering": ["-timestamp"],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="PlayEvent",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("started", models.DateField(blank=True, null=True)),
|
||||
("ended", models.DateField(blank=True, null=True)),
|
||||
(
|
||||
"days_to_finish",
|
||||
models.GeneratedField(
|
||||
db_persist=True,
|
||||
expression=django.db.models.expressions.RawSQL(
|
||||
"\n COALESCE(\n CASE \n WHEN date(ended) = date(started) THEN 1\n ELSE julianday(ended) - julianday(started)\n END, 0\n )\n ",
|
||||
[],
|
||||
),
|
||||
output_field=models.IntegerField(),
|
||||
),
|
||||
),
|
||||
("note", models.CharField(blank=True, default="", max_length=255)),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("updated_at", models.DateTimeField(auto_now=True)),
|
||||
(
|
||||
"game",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="playevents",
|
||||
to="games.game",
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
migrations.RunPython(set_abandoned_status),
|
||||
migrations.RunPython(create_game_status_changes),
|
||||
]
|
@ -0,0 +1,21 @@
|
||||
# Generated by Django 5.1.7 on 2025-03-20 11:35
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('games', '0008_game_original_year_released_gamestatuschange_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='purchase',
|
||||
name='date_dropped',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='purchase',
|
||||
name='date_finished',
|
||||
),
|
||||
]
|
17
games/migrations/0010_remove_purchase_price_per_game.py
Normal file
17
games/migrations/0010_remove_purchase_price_per_game.py
Normal file
@ -0,0 +1,17 @@
|
||||
# Generated by Django 5.1.7 on 2025-03-22 17:46
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('games', '0009_remove_purchase_date_dropped_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='purchase',
|
||||
name='price_per_game',
|
||||
),
|
||||
]
|
20
games/migrations/0011_purchase_price_per_game.py
Normal file
20
games/migrations/0011_purchase_price_per_game.py
Normal file
@ -0,0 +1,20 @@
|
||||
# Generated by Django 5.1.7 on 2025-03-22 17:46
|
||||
|
||||
import django.db.models.expressions
|
||||
import django.db.models.functions.comparison
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('games', '0010_remove_purchase_price_per_game'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='purchase',
|
||||
name='price_per_game',
|
||||
field=models.GeneratedField(db_persist=True, expression=django.db.models.expressions.CombinedExpression(django.db.models.functions.comparison.Coalesce(models.F('converted_price'), models.F('price'), 0), '/', models.F('num_purchases')), output_field=models.FloatField()),
|
||||
),
|
||||
]
|
155
games/models.py
155
games/models.py
@ -1,13 +1,20 @@
|
||||
import logging
|
||||
from datetime import timedelta
|
||||
|
||||
import requests
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import models
|
||||
from django.db.models import F, Sum
|
||||
from django.db.models.expressions import RawSQL
|
||||
from django.db.models.fields.generated import GeneratedField
|
||||
from django.db.models.functions import Coalesce
|
||||
from django.template.defaultfilters import floatformat, pluralize, slugify
|
||||
from django.utils import timezone
|
||||
|
||||
from common.time import format_duration
|
||||
|
||||
logger = logging.getLogger("games")
|
||||
|
||||
|
||||
class Game(models.Model):
|
||||
class Meta:
|
||||
@ -16,6 +23,7 @@ class Game(models.Model):
|
||||
name = models.CharField(max_length=255)
|
||||
sort_name = models.CharField(max_length=255, blank=True, default="")
|
||||
year_released = models.IntegerField(null=True, blank=True, default=None)
|
||||
original_year_released = models.IntegerField(null=True, blank=True, default=None)
|
||||
wikidata = models.CharField(max_length=50, blank=True, default="")
|
||||
platform = models.ForeignKey(
|
||||
"Platform", on_delete=models.SET_DEFAULT, null=True, blank=True, default=None
|
||||
@ -55,6 +63,21 @@ class Game(models.Model):
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
def finished(self):
|
||||
return self.status == self.Status.FINISHED
|
||||
|
||||
def abandoned(self):
|
||||
return self.status == self.Status.ABANDONED
|
||||
|
||||
def retired(self):
|
||||
return self.status == self.Status.RETIRED
|
||||
|
||||
def played(self):
|
||||
return self.status == self.Status.PLAYED
|
||||
|
||||
def unplayed(self):
|
||||
return self.status == self.Status.UNPLAYED
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if self.platform is None:
|
||||
self.platform = get_sentinel_platform()
|
||||
@ -89,9 +112,6 @@ class PurchaseQueryset(models.QuerySet):
|
||||
def not_refunded(self):
|
||||
return self.filter(date_refunded__isnull=True)
|
||||
|
||||
def finished(self):
|
||||
return self.filter(date_finished__isnull=False)
|
||||
|
||||
def games_only(self):
|
||||
return self.filter(type=Purchase.GAME)
|
||||
|
||||
@ -135,14 +155,22 @@ class Purchase(models.Model):
|
||||
)
|
||||
date_purchased = models.DateField()
|
||||
date_refunded = models.DateField(blank=True, null=True)
|
||||
date_finished = models.DateField(blank=True, null=True)
|
||||
date_dropped = models.DateField(blank=True, null=True)
|
||||
# move date_finished to PlayEvent model's Finished field
|
||||
# also set Game's model Status field to Finished
|
||||
# date_finished = models.DateField(blank=True, null=True)
|
||||
# move date_dropped to Game model's field Status (Abandoned)
|
||||
# date_dropped = models.DateField(blank=True, null=True)
|
||||
infinite = models.BooleanField(default=False)
|
||||
price = models.FloatField(default=0)
|
||||
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="")
|
||||
price_per_game = models.FloatField(null=True)
|
||||
price_per_game = GeneratedField(
|
||||
expression=Coalesce(F("converted_price"), F("price"), 0) / F("num_purchases"),
|
||||
output_field=models.FloatField(),
|
||||
db_persist=True,
|
||||
editable=False,
|
||||
)
|
||||
num_purchases = models.IntegerField(default=0)
|
||||
ownership_type = models.CharField(
|
||||
max_length=2, choices=OWNERSHIP_TYPES, default=DIGITAL
|
||||
@ -198,6 +226,12 @@ 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 save(self, *args, **kwargs):
|
||||
if self.type != Purchase.GAME and not self.related_purchase:
|
||||
raise ValidationError(
|
||||
@ -207,12 +241,15 @@ class Purchase(models.Model):
|
||||
# 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 != self.price
|
||||
or existing_purchase.price_currency != self.price_currency
|
||||
):
|
||||
self.converted_price = None
|
||||
self.converted_currency = ""
|
||||
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)
|
||||
|
||||
|
||||
@ -351,3 +388,97 @@ class ExchangeRate(models.Model):
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.currency_from}/{self.currency_to} - {self.rate} ({self.year})"
|
||||
|
||||
|
||||
def get_or_create_rate(currency_from: str, currency_to: str, year: int) -> float | None:
|
||||
exchange_rate = None
|
||||
result = ExchangeRate.objects.filter(
|
||||
currency_from=currency_from, currency_to=currency_to, year=year
|
||||
)
|
||||
if result:
|
||||
exchange_rate = result[0].rate
|
||||
else:
|
||||
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),
|
||||
)
|
||||
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}"
|
||||
)
|
||||
return exchange_rate
|
||||
|
||||
|
||||
class PlayEvent(models.Model):
|
||||
game = models.ForeignKey(Game, related_name="playevents", on_delete=models.CASCADE)
|
||||
started = models.DateField(null=True, blank=True)
|
||||
ended = models.DateField(null=True, blank=True)
|
||||
days_to_finish = GeneratedField(
|
||||
# special cases:
|
||||
# missing ended, started, or both = 0
|
||||
# same day = 1 day to finish
|
||||
expression=RawSQL(
|
||||
"""
|
||||
COALESCE(
|
||||
CASE
|
||||
WHEN date(ended) = date(started) THEN 1
|
||||
ELSE julianday(ended) - julianday(started)
|
||||
END, 0
|
||||
)
|
||||
""",
|
||||
[],
|
||||
),
|
||||
output_field=models.IntegerField(),
|
||||
db_persist=True,
|
||||
editable=False,
|
||||
blank=True,
|
||||
)
|
||||
note = models.CharField(max_length=255, blank=True, default="")
|
||||
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
|
||||
# class PlayMarker(models.Model):
|
||||
# game = models.ForeignKey(Game, related_name="markers", on_delete=models.CASCADE)
|
||||
# played_since = models.DurationField()
|
||||
# played_total = models.DurationField()
|
||||
# note = models.CharField(max_length=255)
|
||||
|
||||
|
||||
class GameStatusChange(models.Model):
|
||||
"""
|
||||
Tracks changes to the status of a Game.
|
||||
"""
|
||||
|
||||
game = models.ForeignKey(
|
||||
Game, on_delete=models.CASCADE, related_name="status_changes"
|
||||
)
|
||||
old_status = models.CharField(
|
||||
max_length=1, choices=Game.Status.choices, blank=True, null=True
|
||||
)
|
||||
new_status = models.CharField(max_length=1, choices=Game.Status.choices)
|
||||
timestamp = models.DateTimeField(null=True)
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.game.name}: {self.old_status or 'None'} -> {self.new_status} at {self.timestamp}"
|
||||
|
||||
class Meta:
|
||||
ordering = ["-timestamp"]
|
||||
|
@ -1,8 +1,8 @@
|
||||
from django.db.models.signals import m2m_changed
|
||||
from django.db.models.signals import m2m_changed, pre_save
|
||||
from django.dispatch import receiver
|
||||
from django.utils.timezone import now
|
||||
|
||||
from games.models import Purchase
|
||||
from games.models import Game, GameStatusChange, Purchase
|
||||
|
||||
|
||||
@receiver(m2m_changed, sender=Purchase.games.through)
|
||||
@ -10,3 +10,31 @@ def update_num_purchases(sender, instance, **kwargs):
|
||||
instance.num_purchases = instance.games.count()
|
||||
instance.updated_at = now()
|
||||
instance.save(update_fields=["num_purchases"])
|
||||
|
||||
|
||||
@receiver(pre_save, sender=Game)
|
||||
def game_status_changed(sender, instance, **kwargs):
|
||||
"""
|
||||
Signal handler to create a GameStatusChange record whenever a Game's status is updated.
|
||||
"""
|
||||
try:
|
||||
old_instance = sender.objects.get(pk=instance.pk)
|
||||
old_status = old_instance.status
|
||||
print("Got old instance")
|
||||
except sender.DoesNotExist:
|
||||
# Handle the case where the instance was deleted before the signal was sent
|
||||
print("Instance does not exist")
|
||||
return
|
||||
|
||||
if old_status != instance.status:
|
||||
print("Status changed")
|
||||
GameStatusChange.objects.create(
|
||||
game=instance,
|
||||
old_status=old_status,
|
||||
new_status=instance.status,
|
||||
timestamp=instance.updated_at,
|
||||
)
|
||||
else:
|
||||
print("Status not changed")
|
||||
print(f"{old_instance.status}")
|
||||
print(f"{instance.status}")
|
||||
|
@ -1303,6 +1303,10 @@ input:checked + .toggle-bg {
|
||||
left: -0.75rem;
|
||||
}
|
||||
|
||||
.-left-\[1px\] {
|
||||
left: -1px;
|
||||
}
|
||||
|
||||
.bottom-0 {
|
||||
bottom: 0px;
|
||||
}
|
||||
@ -1347,6 +1351,10 @@ input:checked + .toggle-bg {
|
||||
top: 0.75rem;
|
||||
}
|
||||
|
||||
.top-\[100\%\] {
|
||||
top: 100%;
|
||||
}
|
||||
|
||||
.z-10 {
|
||||
z-index: 10;
|
||||
}
|
||||
@ -1552,6 +1560,10 @@ input:checked + .toggle-bg {
|
||||
width: 6rem;
|
||||
}
|
||||
|
||||
.w-3 {
|
||||
width: 0.75rem;
|
||||
}
|
||||
|
||||
.w-4 {
|
||||
width: 1rem;
|
||||
}
|
||||
@ -1576,6 +1588,10 @@ input:checked + .toggle-bg {
|
||||
width: 20rem;
|
||||
}
|
||||
|
||||
.w-auto {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.w-full {
|
||||
width: 100%;
|
||||
}
|
||||
@ -1744,6 +1760,10 @@ input:checked + .toggle-bg {
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
.gap-y-4 {
|
||||
row-gap: 1rem;
|
||||
}
|
||||
|
||||
.-space-x-px > :not([hidden]) ~ :not([hidden]) {
|
||||
--tw-space-x-reverse: 0;
|
||||
margin-right: calc(-1px * var(--tw-space-x-reverse));
|
||||
@ -1819,6 +1839,11 @@ input:checked + .toggle-bg {
|
||||
border-radius: 0.75rem;
|
||||
}
|
||||
|
||||
.rounded-b-md {
|
||||
border-bottom-right-radius: 0.375rem;
|
||||
border-bottom-left-radius: 0.375rem;
|
||||
}
|
||||
|
||||
.rounded-e-lg {
|
||||
border-start-end-radius: 0.5rem;
|
||||
border-end-end-radius: 0.5rem;
|
||||
@ -1839,6 +1864,14 @@ input:checked + .toggle-bg {
|
||||
border-end-start-radius: 0.5rem;
|
||||
}
|
||||
|
||||
.rounded-tl-none {
|
||||
border-top-left-radius: 0px;
|
||||
}
|
||||
|
||||
.rounded-tr-md {
|
||||
border-top-right-radius: 0.375rem;
|
||||
}
|
||||
|
||||
.border {
|
||||
border-width: 1px;
|
||||
}
|
||||
@ -1847,6 +1880,18 @@ input:checked + .toggle-bg {
|
||||
border-width: 0px;
|
||||
}
|
||||
|
||||
.border-b {
|
||||
border-bottom-width: 1px;
|
||||
}
|
||||
|
||||
.border-e {
|
||||
border-inline-end-width: 1px;
|
||||
}
|
||||
|
||||
.border-t {
|
||||
border-top-width: 1px;
|
||||
}
|
||||
|
||||
.border-blue-600 {
|
||||
--tw-border-opacity: 1;
|
||||
border-color: rgb(28 100 242 / var(--tw-border-opacity));
|
||||
@ -1922,6 +1967,10 @@ input:checked + .toggle-bg {
|
||||
background-color: rgb(31 41 55 / var(--tw-bg-opacity));
|
||||
}
|
||||
|
||||
.bg-gray-800\/20 {
|
||||
background-color: rgb(31 41 55 / 0.2);
|
||||
}
|
||||
|
||||
.bg-gray-900\/50 {
|
||||
background-color: rgb(17 24 39 / 0.5);
|
||||
}
|
||||
@ -2087,6 +2136,10 @@ input:checked + .toggle-bg {
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.align-middle {
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.font-mono {
|
||||
font-family: IBM Plex Mono, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
||||
}
|
||||
@ -2311,6 +2364,12 @@ input:checked + .toggle-bg {
|
||||
filter: var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow);
|
||||
}
|
||||
|
||||
.backdrop-blur-lg {
|
||||
--tw-backdrop-blur: blur(16px);
|
||||
-webkit-backdrop-filter: var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia);
|
||||
backdrop-filter: var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia);
|
||||
}
|
||||
|
||||
.transition {
|
||||
transition-property: color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, -webkit-backdrop-filter;
|
||||
transition-property: color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, backdrop-filter;
|
||||
|
@ -21,11 +21,6 @@ function setupElementHandlers() {
|
||||
"#id_name",
|
||||
"#id_related_purchase",
|
||||
]);
|
||||
disableElementsWhenValueNotEqual(
|
||||
"#id_type",
|
||||
["game", "dlc"],
|
||||
["#id_date_finished"]
|
||||
);
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", setupElementHandlers);
|
||||
|
@ -12,6 +12,10 @@
|
||||
{% django_htmx_script %}
|
||||
<link rel="stylesheet" href="{% static 'base.css' %}" />
|
||||
<script src="https://cdn.jsdelivr.net/npm/flowbite@2.4.1/dist/flowbite.min.js"></script>
|
||||
{% comment %} <script src="//unpkg.com/alpinejs" defer></script>
|
||||
<script src="//unpkg.com/@alpinejs/mask" defer></script> {% endcomment %}
|
||||
<script defer src="https://cdn.jsdelivr.net/npm/@alpinejs/mask@3.x.x/dist/cdn.min.js"></script>
|
||||
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
|
||||
<script>
|
||||
// On page load or when changing themes, best to add inline in `head` to avoid FOUC
|
||||
if (localStorage.getItem('color-theme') === 'dark' || (!('color-theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
|
||||
|
6
games/templates/list_playevents.html
Normal file
6
games/templates/list_playevents.html
Normal file
@ -0,0 +1,6 @@
|
||||
<c-layouts.base>
|
||||
{% load static %}
|
||||
<div class="2xl:max-w-screen-2xl xl:max-w-screen-xl md:max-w-screen-md sm:max-w-screen-sm self-center">
|
||||
<c-simple-table :columns=data.columns :rows=data.rows :page_obj=page_obj :elided_page_range=elided_page_range :header_action=data.header_action />
|
||||
</div>
|
||||
</c-layouts.base>
|
@ -106,6 +106,10 @@
|
||||
<a href="{% url 'list_platforms' %}"
|
||||
class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Platforms</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{% url 'list_playevents' %}"
|
||||
class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Play events</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{% url 'list_purchases' %}"
|
||||
class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Purchases</a>
|
||||
|
@ -196,7 +196,7 @@
|
||||
<tbody>
|
||||
{% for purchase in all_finished_this_year %}
|
||||
<tr>
|
||||
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{% partial purchase-name %}</td>
|
||||
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ purchase.game_name }}</td>
|
||||
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ purchase.date_finished | date:"d/m/Y" }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
@ -209,14 +209,14 @@
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="px-2 sm:px-4 md:px-6 md:py-2 purchase-name truncate max-w-20char">Name</th>
|
||||
<th class="px-2 sm:px-4 md:px-6 md:py-2">Date</th>
|
||||
{% comment %} <th class="px-2 sm:px-4 md:px-6 md:py-2">Date</th> {% endcomment %}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for purchase in this_year_finished_this_year %}
|
||||
<tr>
|
||||
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{% partial purchase-name %}</td>
|
||||
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ purchase.date_finished | date:"d/m/Y" }}</td>
|
||||
{% comment %} <td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ purchase.date_finished | date:"d/m/Y" }}</td> {% endcomment %}
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
@ -228,14 +228,14 @@
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="px-2 sm:px-4 md:px-6 md:py-2 purchase-name truncate max-w-20char">Name</th>
|
||||
<th class="px-2 sm:px-4 md:px-6 md:py-2">Date</th>
|
||||
{% comment %} <th class="px-2 sm:px-4 md:px-6 md:py-2">Date</th> {% endcomment %}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for purchase in purchased_this_year_finished_this_year %}
|
||||
<tr>
|
||||
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{% partial purchase-name %}</td>
|
||||
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ purchase.date_finished | date:"d/m/Y" }}</td>
|
||||
{% comment %} <td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ purchase.date_finished | date:"d/m/Y" }}</td> {% endcomment %}
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
|
@ -52,17 +52,66 @@
|
||||
{{ playrange }}
|
||||
</c-popover>
|
||||
</div>
|
||||
<div class="mb-6 text-slate-400">
|
||||
<div class="flex flex-col mb-6 text-slate-400 gap-y-4">
|
||||
<div class="flex gap-2 items-center">
|
||||
<span class="uppercase font-bold text-slate-300">Status</span>
|
||||
<c-gamestatus :status="game.status">
|
||||
{{ game.get_status_display }}
|
||||
</c-gamestatus>
|
||||
{% if game.mastered %}👑{% endif %}
|
||||
<span class="uppercase">Original year</span>
|
||||
<span class="text-slate-300">{{ game.original_year_released }}</span>
|
||||
</div>
|
||||
<div class="flex gap-2 items-center">
|
||||
<span class="uppercase font-bold text-slate-300">Platform</span>
|
||||
<span>{{ game.platform }}</span>
|
||||
<span class="uppercase">Status</span>
|
||||
<c-gamestatus :status="game.status" class="text-slate-300">
|
||||
{{ game.get_status_display }}
|
||||
</c-gamestatus>
|
||||
{% if game.mastered %}👑{% endif %}
|
||||
</div>
|
||||
<div class="flex gap-2 items-center"
|
||||
x-data="{ open: false }"
|
||||
>
|
||||
<span class="uppercase">Played</span>
|
||||
<div class="inline-flex rounded-md shadow-xs" role="group" x-data="{ played: {{ game.playevents.count }} }">
|
||||
<a href="{% url 'add_playevent' %}">
|
||||
<button type="button" class="px-4 py-2 text-sm font-medium text-gray-900 bg-white border border-gray-200 rounded-s-lg hover:bg-gray-100 hover:text-blue-700 focus:z-10 focus:ring-2 focus:ring-blue-700 focus:text-blue-700 dark:bg-gray-800 dark:border-gray-700 dark:text-white dark:hover:text-white dark:hover:bg-gray-700 dark:focus:ring-blue-500 dark:focus:text-white">
|
||||
<span x-text="played"></span> times
|
||||
</button>
|
||||
</a>
|
||||
<button type="button" x-on:click="open = !open" class="relative px-4 py-2 text-sm font-medium text-gray-900 bg-white border-e border-b border-t border-gray-200 rounded-e-lg hover:bg-gray-100 hover:text-blue-700 focus:z-10 focus:ring-2 focus:ring-blue-700 focus:text-blue-700 dark:bg-gray-800 dark:border-gray-700 dark:text-white dark:hover:text-white dark:hover:bg-gray-700 dark:focus:ring-blue-500 dark:focus:text-white align-middle">
|
||||
<svg class="text-white w-3" viewBox="5 8 14 8" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M6 9L12 15L18 9" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
<div
|
||||
class="absolute top-[100%] -left-[1px] w-auto whitespace-nowrap z-10 text-sm font-medium bg-gray-800/20 backdrop-blur-lg rounded-md rounded-tl-none border border-gray-200 dark:border-gray-700"
|
||||
x-show="open"
|
||||
>
|
||||
<ul
|
||||
class=""
|
||||
>
|
||||
<li class="px-4 py-2 dark:hover:text-white dark:hover:bg-gray-700 dark:focus:ring-blue-500 dark:focus:text-white rounded-tr-md">
|
||||
<a href="{% url 'add_playevent_for_game' game.id %}">Add playthrough...</a>
|
||||
</li>
|
||||
<li
|
||||
x-on:click="createPlayEvent"
|
||||
class="relative px-4 py-2 dark:hover:text-white dark:hover:bg-gray-700 dark:focus:ring-blue-500 dark:focus:text-white rounded-b-md"
|
||||
>
|
||||
Played times +1
|
||||
</li>
|
||||
<script>
|
||||
function createPlayEvent() {
|
||||
this.played++;
|
||||
fetch('{% url 'api-1.0.0:create_playevent' %}', { method: 'POST', headers: { 'X-CSRFToken': '{{ csrf_token }}' }, body: '{"game_id": {{ game.id }}}'})
|
||||
}
|
||||
</script>
|
||||
</ul>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
<div class="flex gap-2 items-center">
|
||||
<span class="uppercase">Platform</span>
|
||||
<span class="text-slate-300">{{ game.platform }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="inline-flex rounded-md shadow-sm mb-3" role="group">
|
||||
@ -96,6 +145,24 @@
|
||||
No sessions yet.
|
||||
{% endif %}
|
||||
</div>
|
||||
<!-- list all playevents -->
|
||||
<div class="mb-6">
|
||||
<c-h1 :badge="playevent_count">Play Events</c-h1>
|
||||
{% if playevent_count %}
|
||||
<c-simple-table :rows=playevent_data.rows :columns=playevent_data.columns />
|
||||
{% else %}
|
||||
No play events yet.
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="mb-6">
|
||||
<c-h1 :badge="statuschange_count">History</c-h1>
|
||||
<ul class="list-disc list-inside">
|
||||
{% for change in statuschanges %}
|
||||
<li class="text-slate-500">
|
||||
{% if change.timestamp %}{{ change.timestamp | date:"d/m/Y H:i" }}: Changed{% else %}At some point changed{% endif %} status from <c-gamestatus :status="change.old_status" class="text-white">{{ change.get_old_status_display }}</c-gamestatus> to <c-gamestatus :status="change.new_status" class="text-white">{{ change.get_new_status_display }}</c-gamestatus></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
function getSessionCount() {
|
||||
|
@ -1,6 +1,7 @@
|
||||
from django.urls import path
|
||||
|
||||
from games.views import device, game, general, platform, purchase, session
|
||||
from games.api import api
|
||||
from games.views import device, game, general, platform, playevent, purchase, session
|
||||
|
||||
urlpatterns = [
|
||||
path("", general.index, name="index"),
|
||||
@ -25,6 +26,23 @@ urlpatterns = [
|
||||
name="delete_platform",
|
||||
),
|
||||
path("platform/list", platform.list_platforms, name="list_platforms"),
|
||||
path("playevent/list", playevent.list_playevents, name="list_playevents"),
|
||||
path("playevent/add", playevent.add_playevent, name="add_playevent"),
|
||||
path(
|
||||
"playevent/add/for-game/<int:game_id>",
|
||||
playevent.add_playevent,
|
||||
name="add_playevent_for_game",
|
||||
),
|
||||
path(
|
||||
"playevent/edit/<int:playevent_id>",
|
||||
playevent.edit_playevent,
|
||||
name="edit_playevent",
|
||||
),
|
||||
path(
|
||||
"playevent/delete/<int:playevent_id>",
|
||||
playevent.delete_playevent,
|
||||
name="delete_playevent",
|
||||
),
|
||||
path("purchase/add", purchase.add_purchase, name="add_purchase"),
|
||||
path(
|
||||
"purchase/add/for-game/<int:game_id>",
|
||||
@ -115,4 +133,5 @@ urlpatterns = [
|
||||
general.stats,
|
||||
name="stats_by_year",
|
||||
),
|
||||
path("api/", api.urls),
|
||||
]
|
||||
|
@ -32,6 +32,7 @@ from common.utils import build_dynamic_filter, safe_division, truncate
|
||||
from games.forms import GameForm
|
||||
from games.models import Game, Purchase
|
||||
from games.views.general import use_custom_redirect
|
||||
from games.views.playevent import create_playevent_tabledata
|
||||
|
||||
|
||||
@login_required
|
||||
@ -351,7 +352,34 @@ def view_game(request: HttpRequest, game_id: int) -> HttpResponse:
|
||||
],
|
||||
}
|
||||
|
||||
playevents = game.playevents.all()
|
||||
playevent_count = playevents.count()
|
||||
playevent_data = create_playevent_tabledata(playevents, exclude_columns=["Game"])
|
||||
|
||||
statuschanges = game.status_changes.all()
|
||||
statuschange_count = statuschanges.count()
|
||||
statuschange_data = {
|
||||
"columns": [
|
||||
"Old Status",
|
||||
"New Status",
|
||||
"Timestamp",
|
||||
],
|
||||
"rows": [
|
||||
[
|
||||
statuschange.get_old_status_display()
|
||||
if statuschange.old_status
|
||||
else "-",
|
||||
statuschange.get_new_status_display(),
|
||||
local_strftime(statuschange.timestamp, dateformat),
|
||||
]
|
||||
for statuschange in statuschanges
|
||||
],
|
||||
}
|
||||
|
||||
context: dict[str, Any] = {
|
||||
"statuschange_data": statuschange_data,
|
||||
"statuschange_count": statuschange_count,
|
||||
"statuschanges": statuschanges,
|
||||
"game": game,
|
||||
"playrange": playrange,
|
||||
"purchase_count": game.purchases.count(),
|
||||
@ -366,6 +394,8 @@ def view_game(request: HttpRequest, game_id: int) -> HttpResponse:
|
||||
"title": f"Game Overview - {game.name}",
|
||||
"hours_sum": total_hours,
|
||||
"purchase_data": purchase_data,
|
||||
"playevent_data": playevent_data,
|
||||
"playevent_count": playevent_count,
|
||||
"session_data": session_data,
|
||||
"session_page_obj": session_page_obj,
|
||||
"session_elided_page_range": (
|
||||
|
@ -305,27 +305,39 @@ def stats(request: HttpRequest, year: int = 0) -> HttpResponse:
|
||||
sessions__in=this_year_sessions
|
||||
).distinct()
|
||||
|
||||
this_year_purchases = Purchase.objects.filter(date_purchased__year=year)
|
||||
this_year_purchases_with_currency = this_year_purchases.prefetch_related("games")
|
||||
this_year_purchases_without_refunded = this_year_purchases_with_currency.filter(
|
||||
date_refunded=None
|
||||
).exclude(ownership_type=Purchase.DEMO)
|
||||
this_year_purchases_refunded = this_year_purchases_with_currency.refunded()
|
||||
this_year_purchases = Purchase.objects.filter(
|
||||
date_purchased__year=year
|
||||
).prefetch_related("games")
|
||||
# purchased this year
|
||||
# not refunded
|
||||
this_year_purchases_without_refunded = Purchase.objects.filter(
|
||||
date_refunded=None, date_purchased__year=year
|
||||
)
|
||||
|
||||
# purchased this year
|
||||
# not refunded
|
||||
# not finished
|
||||
# not infinite
|
||||
# only Game and DLC
|
||||
this_year_purchases_unfinished_dropped_nondropped = (
|
||||
this_year_purchases_without_refunded.filter(date_finished__isnull=True)
|
||||
this_year_purchases_without_refunded.exclude(
|
||||
games__in=Game.objects.filter(status="f")
|
||||
)
|
||||
.filter(infinite=False)
|
||||
.filter(Q(type=Purchase.GAME) | Q(type=Purchase.DLC))
|
||||
) # do not count battle passes etc.
|
||||
)
|
||||
|
||||
# not finished
|
||||
this_year_purchases_unfinished = (
|
||||
this_year_purchases_unfinished_dropped_nondropped.filter(
|
||||
date_dropped__isnull=True
|
||||
this_year_purchases_unfinished_dropped_nondropped.exclude(
|
||||
games__status__in="ura"
|
||||
)
|
||||
)
|
||||
# abandoned
|
||||
# retired
|
||||
this_year_purchases_dropped = (
|
||||
this_year_purchases_unfinished_dropped_nondropped.filter(
|
||||
date_dropped__isnull=False
|
||||
this_year_purchases_unfinished_dropped_nondropped.exclude(
|
||||
games__in=Game.objects.filter(status="ar")
|
||||
)
|
||||
)
|
||||
|
||||
@ -341,15 +353,17 @@ def stats(request: HttpRequest, year: int = 0) -> HttpResponse:
|
||||
* 100
|
||||
)
|
||||
|
||||
purchases_finished_this_year = Purchase.objects.filter(date_finished__year=year)
|
||||
purchases_finished_this_year = Purchase.objects.filter(
|
||||
games__playevents__ended__year=2025
|
||||
).annotate(game_name=F("games__name"), date_finished=F("games__playevents__ended"))
|
||||
purchases_finished_this_year_released_this_year = (
|
||||
purchases_finished_this_year.filter(games__year_released=year).order_by(
|
||||
"date_finished"
|
||||
"games__playevents__ended"
|
||||
)
|
||||
)
|
||||
purchased_this_year_finished_this_year = (
|
||||
this_year_purchases_without_refunded.filter(date_finished__year=year)
|
||||
).order_by("date_finished")
|
||||
this_year_purchases_without_refunded.filter(games__playevents__ended__year=year)
|
||||
).order_by("games__playevents__ended")
|
||||
|
||||
this_year_spendings = this_year_purchases_without_refunded.aggregate(
|
||||
total_spent=Sum(F("converted_price"))
|
||||
@ -395,8 +409,9 @@ def stats(request: HttpRequest, year: int = 0) -> HttpResponse:
|
||||
item["formatted_playtime"] = format_duration(item["total_playtime"], "%2.0H")
|
||||
|
||||
backlog_decrease_count = (
|
||||
Purchase.objects.filter(date_purchased__year__lt=year)
|
||||
.intersection(purchases_finished_this_year)
|
||||
Purchase.objects.filter(date_purchased__year__lt=2025)
|
||||
.filter(games__status="f")
|
||||
.filter(games__playevents__ended__year=2025)
|
||||
.count()
|
||||
)
|
||||
|
||||
@ -412,7 +427,10 @@ def stats(request: HttpRequest, year: int = 0) -> HttpResponse:
|
||||
last_play_game = last_session.game
|
||||
last_play_date = last_session.timestamp_start.strftime(dateformat)
|
||||
|
||||
all_purchased_this_year_count = this_year_purchases_with_currency.count()
|
||||
all_purchased_this_year_count = this_year_purchases.count()
|
||||
this_year_purchases_refunded = Purchase.objects.exclude(date_refunded=None).filter(
|
||||
date_purchased__year=year
|
||||
)
|
||||
all_purchased_refunded_this_year_count = this_year_purchases_refunded.count()
|
||||
|
||||
this_year_purchases_dropped_count = this_year_purchases_dropped.count()
|
||||
@ -439,15 +457,15 @@ def stats(request: HttpRequest, year: int = 0) -> HttpResponse:
|
||||
),
|
||||
"all_finished_this_year": purchases_finished_this_year.prefetch_related(
|
||||
"games"
|
||||
).order_by("date_finished"),
|
||||
).order_by("games__playevents__ended"),
|
||||
"all_finished_this_year_count": purchases_finished_this_year.count(),
|
||||
"this_year_finished_this_year": purchases_finished_this_year_released_this_year.prefetch_related(
|
||||
"games"
|
||||
).order_by("date_finished"),
|
||||
).order_by("games__playevents__ended"),
|
||||
"this_year_finished_this_year_count": purchases_finished_this_year_released_this_year.count(),
|
||||
"purchased_this_year_finished_this_year": purchased_this_year_finished_this_year.prefetch_related(
|
||||
"games"
|
||||
).order_by("date_finished"),
|
||||
).order_by("games__playevents__ended"),
|
||||
"total_sessions": this_year_sessions.count(),
|
||||
"unique_days": unique_days["dates"],
|
||||
"unique_days_percent": int(unique_days["dates"] / 365 * 100),
|
||||
@ -465,9 +483,7 @@ def stats(request: HttpRequest, year: int = 0) -> HttpResponse:
|
||||
),
|
||||
"all_purchased_refunded_this_year": this_year_purchases_refunded,
|
||||
"all_purchased_refunded_this_year_count": all_purchased_refunded_this_year_count,
|
||||
"all_purchased_this_year": this_year_purchases_with_currency.order_by(
|
||||
"date_purchased"
|
||||
),
|
||||
"all_purchased_this_year": this_year_purchases.order_by("date_purchased"),
|
||||
"all_purchased_this_year_count": all_purchased_this_year_count,
|
||||
"backlog_decrease_count": backlog_decrease_count,
|
||||
"longest_session_time": (
|
||||
|
147
games/views/playevent.py
Normal file
147
games/views/playevent.py
Normal file
@ -0,0 +1,147 @@
|
||||
import logging
|
||||
from typing import Any, Callable, TypedDict
|
||||
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.core.paginator import Paginator
|
||||
from django.db.models import QuerySet
|
||||
from django.db.models.manager import BaseManager
|
||||
from django.http import HttpRequest, HttpResponse, HttpResponseRedirect
|
||||
from django.shortcuts import get_object_or_404, render
|
||||
from django.template.loader import render_to_string
|
||||
from django.urls import reverse
|
||||
|
||||
from common.components import A, Button, Icon
|
||||
from common.time import dateformat, local_strftime
|
||||
from games.forms import PlayEventForm
|
||||
from games.models import Game, PlayEvent
|
||||
|
||||
logger = logging.getLogger("games")
|
||||
|
||||
|
||||
class TableData(TypedDict):
|
||||
header_action: Callable[..., Any]
|
||||
columns: list[str]
|
||||
rows: list[list[Any]]
|
||||
|
||||
|
||||
def create_playevent_tabledata(
|
||||
playevents: list[PlayEvent] | BaseManager[PlayEvent] | QuerySet[PlayEvent],
|
||||
exclude_columns: list[str] = [],
|
||||
request: HttpRequest | None = None,
|
||||
) -> TableData:
|
||||
column_list = [
|
||||
"Game",
|
||||
"Started",
|
||||
"Ended",
|
||||
"Days to finish",
|
||||
"Note",
|
||||
"Created",
|
||||
"Actions",
|
||||
]
|
||||
filtered_column_list = filter(
|
||||
lambda x: x not in exclude_columns,
|
||||
column_list,
|
||||
)
|
||||
excluded_column_indexes = [column_list.index(column) for column in exclude_columns]
|
||||
|
||||
row_list = [
|
||||
[
|
||||
playevent.game,
|
||||
playevent.started.strftime(dateformat) if playevent.started else "-",
|
||||
playevent.ended.strftime(dateformat) if playevent.ended else "-",
|
||||
playevent.days_to_finish if playevent.days_to_finish else "-",
|
||||
playevent.note,
|
||||
local_strftime(playevent.created_at, dateformat),
|
||||
render_to_string(
|
||||
"cotton/button_group.html",
|
||||
{
|
||||
"buttons": [
|
||||
{
|
||||
"href": reverse("edit_playevent", args=[playevent.pk]),
|
||||
"slot": Icon("edit"),
|
||||
"color": "gray",
|
||||
},
|
||||
{
|
||||
"href": reverse("delete_playevent", args=[playevent.pk]),
|
||||
"slot": Icon("delete"),
|
||||
"color": "red",
|
||||
},
|
||||
]
|
||||
},
|
||||
),
|
||||
]
|
||||
for playevent in playevents
|
||||
]
|
||||
filtered_row_list = [
|
||||
[column for idx, column in enumerate(row) if idx not in excluded_column_indexes]
|
||||
for row in row_list
|
||||
]
|
||||
return {
|
||||
"header_action": A([], Button([], "Add play event"), url="add_playevent"),
|
||||
"columns": list(filtered_column_list),
|
||||
"rows": filtered_row_list,
|
||||
}
|
||||
|
||||
|
||||
@login_required
|
||||
def list_playevents(request: HttpRequest) -> HttpResponse:
|
||||
page_number = request.GET.get("page", 1)
|
||||
limit = request.GET.get("limit", 10)
|
||||
playevents = PlayEvent.objects.order_by("-created_at")
|
||||
page_obj = None
|
||||
if int(limit) != 0:
|
||||
paginator = Paginator(playevents, limit)
|
||||
page_obj = paginator.get_page(page_number)
|
||||
playevents = page_obj.object_list
|
||||
context: dict[str, Any] = {
|
||||
"title": "Manage play events",
|
||||
"page_obj": page_obj or None,
|
||||
"elided_page_range": (
|
||||
page_obj.paginator.get_elided_page_range(
|
||||
page_number, on_each_side=1, on_ends=1
|
||||
)
|
||||
if page_obj
|
||||
else None
|
||||
),
|
||||
"data": create_playevent_tabledata(playevents, request=request),
|
||||
}
|
||||
return render(request, "list_playevents.html", context)
|
||||
|
||||
|
||||
@login_required
|
||||
def add_playevent(request: HttpRequest, game_id: int = 0) -> HttpResponse:
|
||||
initial: dict[str, Any] = {}
|
||||
if game_id:
|
||||
# coming from add_playevent_for_game url path
|
||||
game = get_object_or_404(Game, id=game_id)
|
||||
initial["game"] = game
|
||||
form = PlayEventForm(request.POST or None, initial=initial)
|
||||
if form.is_valid():
|
||||
form.save()
|
||||
if not game_id:
|
||||
# coming from add_playevent url path
|
||||
game_id = form.instance.game.id
|
||||
return HttpResponseRedirect(reverse("view_game", args=[game_id]))
|
||||
|
||||
return render(request, "add.html", {"form": form, "title": "Add new playthrough"})
|
||||
|
||||
|
||||
def edit_playevent(request: HttpRequest, playevent_id: int) -> HttpResponse:
|
||||
context: dict[str, Any] = {}
|
||||
playevent = get_object_or_404(PlayEvent, id=playevent_id)
|
||||
form = PlayEventForm(request.POST or None, instance=playevent)
|
||||
if form.is_valid():
|
||||
form.save()
|
||||
return HttpResponseRedirect(reverse("view_game", args=[playevent.game.id]))
|
||||
|
||||
context = {
|
||||
"form": form,
|
||||
"title": "Edit Play Event",
|
||||
}
|
||||
return render(request, "add.html", context)
|
||||
|
||||
|
||||
def delete_playevent(request: HttpRequest, playevent_id: int) -> HttpResponse:
|
||||
playevent = get_object_or_404(PlayEvent, id=playevent_id)
|
||||
playevent.delete()
|
||||
return HttpResponseRedirect(request.META.get("HTTP_REFERER", "/"))
|
@ -51,8 +51,6 @@ def list_purchases(request: HttpRequest) -> HttpResponse:
|
||||
"Infinite",
|
||||
"Purchased",
|
||||
"Refunded",
|
||||
"Finished",
|
||||
"Dropped",
|
||||
"Created",
|
||||
"Actions",
|
||||
],
|
||||
@ -68,39 +66,11 @@ def list_purchases(request: HttpRequest) -> HttpResponse:
|
||||
if purchase.date_refunded
|
||||
else "-"
|
||||
),
|
||||
(
|
||||
purchase.date_finished.strftime(dateformat)
|
||||
if purchase.date_finished
|
||||
else "-"
|
||||
),
|
||||
(
|
||||
purchase.date_dropped.strftime(dateformat)
|
||||
if purchase.date_dropped
|
||||
else "-"
|
||||
),
|
||||
purchase.created_at.strftime(dateformat),
|
||||
render_to_string(
|
||||
"cotton/button_group.html",
|
||||
{
|
||||
"buttons": [
|
||||
{
|
||||
"href": reverse(
|
||||
"finish_purchase", args=[purchase.pk]
|
||||
),
|
||||
"slot": Icon("checkmark"),
|
||||
"title": "Mark as finished",
|
||||
}
|
||||
if not purchase.date_finished
|
||||
else {},
|
||||
{
|
||||
"href": reverse(
|
||||
"drop_purchase", args=[purchase.pk]
|
||||
),
|
||||
"slot": Icon("eject"),
|
||||
"title": "Mark as dropped",
|
||||
}
|
||||
if not purchase.date_dropped
|
||||
else {},
|
||||
{
|
||||
"href": reverse(
|
||||
"refund_purchase", args=[purchase.pk]
|
||||
|
Reference in New Issue
Block a user