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: unique_together = [["name", "platform", "year_released"]] 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 ) playtime = models.DurationField(blank=True, editable=False, default=timedelta(0)) created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) class Status(models.TextChoices): UNPLAYED = ( "u", "Unplayed", ) PLAYED = ( "p", "Played", ) FINISHED = ( "f", "Finished", ) RETIRED = ( "r", "Retired", ) ABANDONED = ( "a", "Abandoned", ) status = models.CharField(max_length=1, choices=Status, default=Status.UNPLAYED) mastered = models.BooleanField(default=False) session_average: float | int | timedelta | None session_count: int | None 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 playtime_formatted(self): return format_duration(self.playtime, "%2.1H") def save(self, *args, **kwargs): if self.platform is None: self.platform = get_sentinel_platform() super().save(*args, **kwargs) def get_sentinel_platform(): return Platform.objects.get_or_create( name="Unspecified", icon="unspecified", group="Unspecified" )[0] class Platform(models.Model): name = models.CharField(max_length=255) group = models.CharField(max_length=255, blank=True, default="") icon = models.SlugField(blank=True) created_at = models.DateTimeField(auto_now_add=True) def __str__(self): return self.name def save(self, *args, **kwargs): if not self.icon: self.icon = slugify(self.name) super().save(*args, **kwargs) class PurchaseQueryset(models.QuerySet): def refunded(self): return self.filter(date_refunded__isnull=False) def not_refunded(self): return self.filter(date_refunded__isnull=True) def games_only(self): return self.filter(type=Purchase.GAME) class Purchase(models.Model): PHYSICAL = "ph" DIGITAL = "di" DIGITALUPGRADE = "du" RENTED = "re" BORROWED = "bo" TRIAL = "tr" DEMO = "de" PIRATED = "pi" OWNERSHIP_TYPES = [ (PHYSICAL, "Physical"), (DIGITAL, "Digital"), (DIGITALUPGRADE, "Digital Upgrade"), (RENTED, "Rented"), (BORROWED, "Borrowed"), (TRIAL, "Trial"), (DEMO, "Demo"), (PIRATED, "Pirated"), ] GAME = "game" DLC = "dlc" SEASONPASS = "season_pass" BATTLEPASS = "battle_pass" TYPES = [ (GAME, "Game"), (DLC, "DLC"), (SEASONPASS, "Season Pass"), (BATTLEPASS, "Battle Pass"), ] objects = PurchaseQueryset().as_manager() games = models.ManyToManyField(Game, related_name="purchases") platform = models.ForeignKey( Platform, on_delete=models.CASCADE, default=None, null=True, blank=True ) date_purchased = models.DateField(verbose_name="Purchased") date_refunded = models.DateField(blank=True, null=True, verbose_name="Refunded") 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 = 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 ) type = models.CharField(max_length=255, choices=TYPES, default=GAME) name = models.CharField(max_length=255, blank=True, default="") related_purchase = models.ForeignKey( "self", on_delete=models.SET_NULL, default=None, null=True, related_name="related_purchases", ) created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) @property def standardized_price(self): return ( f"{floatformat(self.converted_price, 0)} {self.converted_currency}" if self.converted_price else None ) @property def has_one_item(self): return self.games.count() == 1 @property def standardized_name(self): return self.name or self.first_game.name @property def first_game(self): return self.games.first() def __str__(self): return self.standardized_name @property def full_name(self): additional_info = [ str(item) for item in [ f"{self.num_purchases} game{pluralize(self.num_purchases)}", self.date_purchased, self.standardized_price, ] if item ] return f"{self.standardized_name} ({', '.join(additional_info)})" 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( 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) class SessionQuerySet(models.QuerySet): def total_duration_formatted(self): return format_duration(self.total_duration_unformatted()) def total_duration_unformatted(self): result = self.aggregate( duration=Sum(F("duration_calculated") + F("duration_manual")) ) return result["duration"] def calculated_duration_formatted(self): return format_duration(self.calculated_duration_unformatted()) def calculated_duration_unformatted(self): result = self.aggregate(duration=Sum(F("duration_calculated"))) return result["duration"] def without_manual(self): return self.exclude(duration_calculated__iexact=0) def only_manual(self): return self.filter(duration_calculated__iexact=0) class Session(models.Model): class Meta: get_latest_by = "timestamp_start" game = models.ForeignKey( Game, on_delete=models.CASCADE, null=True, default=None, related_name="sessions", ) timestamp_start = models.DateTimeField(verbose_name="Start") timestamp_end = models.DateTimeField(blank=True, null=True, verbose_name="End") duration_manual = models.DurationField( blank=True, null=True, default=timedelta(0), verbose_name="Manual duration" ) duration_calculated = GeneratedField( expression=Coalesce(F("timestamp_end") - F("timestamp_start"), 0), output_field=models.DurationField(), db_persist=True, editable=False, ) duration_total = GeneratedField( expression=F("duration_calculated") + F("duration_manual"), output_field=models.DurationField(), db_persist=True, editable=False, ) device = models.ForeignKey( "Device", on_delete=models.SET_DEFAULT, null=True, blank=True, default=None, ) note = models.TextField(blank=True, default="") emulated = models.BooleanField(default=False) created_at = models.DateTimeField(auto_now_add=True) modified_at = models.DateTimeField(auto_now=True) objects = SessionQuerySet.as_manager() def __str__(self): mark = "*" if self.is_manual() else "" return f"{str(self.game)} {str(self.timestamp_start.date())} ({self.duration_formatted()}{mark})" def finish_now(self): self.timestamp_end = timezone.now() def start_now(): self.timestamp_start = timezone.now() def duration_formatted(self) -> str: result = format_duration(self.duration_total, "%02.1H") return result def duration_formatted_with_mark(self) -> str: mark = "*" if self.is_manual() else "" return f"{self.duration_formatted()}{mark}" def is_manual(self) -> bool: return not self.duration_manual == timedelta(0) def save(self, *args, **kwargs) -> None: if not isinstance(self.duration_manual, timedelta): self.duration_manual = timedelta(0) if not self.device: default_device, _ = Device.objects.get_or_create( type=Device.UNKNOWN, defaults={"name": "Unknown"} ) self.device = default_device super(Session, self).save(*args, **kwargs) class Device(models.Model): PC = "PC" CONSOLE = "Console" HANDHELD = "Handheld" MOBILE = "Mobile" SBC = "Single-board computer" UNKNOWN = "Unknown" DEVICE_TYPES = [ (PC, "PC"), (CONSOLE, "Console"), (HANDHELD, "Handheld"), (MOBILE, "Mobile"), (SBC, "Single-board computer"), (UNKNOWN, "Unknown"), ] name = models.CharField(max_length=255) type = models.CharField(max_length=255, choices=DEVICE_TYPES, default=UNKNOWN) created_at = models.DateTimeField(auto_now_add=True) def __str__(self): return f"{self.name} ({self.type})" class ExchangeRate(models.Model): currency_from = models.CharField(max_length=255) currency_to = models.CharField(max_length=255) year = models.PositiveIntegerField() rate = models.FloatField() class Meta: unique_together = ("currency_from", "currency_to", "year") 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"]