Remove bloat

This commit is contained in:
Joakim Hellsén 2025-10-13 02:07:33 +02:00
commit 715cbf4bf0
51 changed files with 691 additions and 3032 deletions

View file

@ -1,18 +1,11 @@
from __future__ import annotations
import logging
import re
from typing import TYPE_CHECKING, ClassVar
from urllib.parse import urlsplit, urlunsplit
import auto_prefetch
from django.contrib.postgres.indexes import GinIndex
from django.contrib.postgres.search import SearchVectorField
from django.db import models
from django.utils import timezone
from accounts.models import User
if TYPE_CHECKING:
import datetime
@ -20,17 +13,15 @@ logger: logging.Logger = logging.getLogger("ttvdrops")
# MARK: Organization
class Organization(auto_prefetch.Model):
class Organization(models.Model):
"""Represents an organization on Twitch that can own drop campaigns."""
id = models.CharField(
max_length=255,
id = models.TextField(
primary_key=True,
verbose_name="Organization ID",
help_text="The unique Twitch identifier for the organization.",
)
name = models.CharField(
max_length=255,
name = models.TextField(
db_index=True,
unique=True,
verbose_name="Name",
@ -47,16 +38,10 @@ class Organization(auto_prefetch.Model):
help_text="Timestamp when this organization record was last updated.",
)
# PostgreSQL full-text search field
search_vector = SearchVectorField(null=True, blank=True)
class Meta(auto_prefetch.Model.Meta):
class Meta:
ordering = ["name"]
indexes: ClassVar[list] = [
# Regular B-tree index for name lookups
models.Index(fields=["name"]),
# Full-text search index (GIN works with SearchVectorField)
GinIndex(fields=["search_vector"], name="org_search_vector_idx"),
]
def __str__(self) -> str:
@ -65,11 +50,11 @@ class Organization(auto_prefetch.Model):
# MARK: Game
class Game(auto_prefetch.Model):
class Game(models.Model):
"""Represents a game on Twitch."""
id = models.CharField(max_length=255, primary_key=True, verbose_name="Game ID")
slug = models.CharField(
id = models.TextField(primary_key=True, verbose_name="Game ID")
slug = models.TextField(
max_length=200,
blank=True,
default="",
@ -77,15 +62,13 @@ class Game(auto_prefetch.Model):
verbose_name="Slug",
help_text="Short unique identifier for the game.",
)
name = models.CharField(
max_length=255,
name = models.TextField(
blank=True,
default="",
db_index=True,
verbose_name="Name",
)
display_name = models.CharField(
max_length=255,
display_name = models.TextField(
blank=True,
default="",
db_index=True,
@ -97,7 +80,7 @@ class Game(auto_prefetch.Model):
default="",
verbose_name="Box art URL",
)
# Locally cached image file for the game's box art
box_art_file = models.FileField(
upload_to="games/box_art/",
blank=True,
@ -105,10 +88,7 @@ class Game(auto_prefetch.Model):
help_text="Locally cached box art image served from this site.",
)
# PostgreSQL full-text search field
search_vector = SearchVectorField(null=True, blank=True)
owner = auto_prefetch.ForeignKey(
owner = models.ForeignKey(
Organization,
on_delete=models.SET_NULL,
related_name="games",
@ -128,16 +108,12 @@ class Game(auto_prefetch.Model):
help_text="Timestamp when this game record was last updated.",
)
class Meta(auto_prefetch.Model.Meta):
class Meta:
ordering = ["display_name"]
indexes: ClassVar[list] = [
models.Index(fields=["slug"]),
# Regular B-tree indexes for name lookups
models.Index(fields=["display_name"]),
models.Index(fields=["name"]),
# Full-text search index (GIN works with SearchVectorField)
GinIndex(fields=["search_vector"], name="game_search_vector_idx"),
# Partial index for games with owners only
models.Index(fields=["slug"]),
models.Index(fields=["owner"], condition=models.Q(owner__isnull=False), name="game_owner_partial_idx"),
]
@ -157,36 +133,6 @@ class Game(auto_prefetch.Model):
"""Return all organizations that own games with campaigns for this game."""
return Organization.objects.filter(games__drop_campaigns__game=self).distinct()
@property
def box_art_base_url(self) -> str:
"""Return the base box art URL without Twitch size suffixes."""
if not self.box_art:
return ""
parts = urlsplit(self.box_art)
path = re.sub(
r"(-\d+x\d+)(\.(?:jpg|jpeg|png|gif|webp))$",
r"\2",
parts.path,
flags=re.IGNORECASE,
)
return urlunsplit((parts.scheme, parts.netloc, path, "", ""))
@property
def box_art_best_url(self) -> str:
"""Return the best available URL for the game's box art.
Preference order:
1) Local cached file (MEDIA)
2) Remote Twitch base URL
3) Empty string
"""
try:
if self.box_art_file and getattr(self.box_art_file, "url", None):
return self.box_art_file.url
except (AttributeError, OSError, ValueError) as exc: # storage might not be configured in some contexts
logger.debug("Failed to resolve Game.box_art_file url: %s", exc)
return self.box_art_base_url
@property
def get_game_name(self) -> str:
"""Return the best available name for the game."""
@ -207,7 +153,7 @@ class Game(auto_prefetch.Model):
# MARK: TwitchGame
class TwitchGameData(auto_prefetch.Model):
class TwitchGameData(models.Model):
"""Represents game metadata returned from the Twitch API.
This mirrors the public Twitch API fields for a game and is tied to the local `Game` model where possible.
@ -220,8 +166,8 @@ class TwitchGameData(auto_prefetch.Model):
igdb_id: Optional IGDB id for the game
"""
id = models.CharField(max_length=255, primary_key=True, verbose_name="Twitch Game ID")
game = auto_prefetch.ForeignKey(
id = models.TextField(primary_key=True, verbose_name="Twitch Game ID")
game = models.ForeignKey(
Game,
on_delete=models.SET_NULL,
related_name="twitch_game_data",
@ -231,7 +177,7 @@ class TwitchGameData(auto_prefetch.Model):
help_text="Optional link to the local Game record for this Twitch game.",
)
name = models.CharField(max_length=255, blank=True, default="", db_index=True, verbose_name="Name")
name = models.TextField(blank=True, default="", db_index=True, verbose_name="Name")
box_art_url = models.URLField(
max_length=500,
blank=True,
@ -239,39 +185,36 @@ class TwitchGameData(auto_prefetch.Model):
verbose_name="Box art URL",
help_text="URL template with {width}x{height} placeholders for the box art image.",
)
igdb_id = models.CharField(max_length=255, blank=True, default="", verbose_name="IGDB ID")
igdb_id = models.TextField(blank=True, default="", verbose_name="IGDB ID")
added_at = models.DateTimeField(auto_now_add=True, db_index=True, help_text="Record creation time.")
updated_at = models.DateTimeField(auto_now=True, help_text="Record last update time.")
class Meta(auto_prefetch.Model.Meta):
class Meta:
ordering = ["name"]
indexes: ClassVar[list] = [
models.Index(fields=["name"]),
]
def __str__(self) -> str: # pragma: no cover - trivial
def __str__(self) -> str:
return self.name or self.id
# MARK: Channel
class Channel(auto_prefetch.Model):
class Channel(models.Model):
"""Represents a Twitch channel that can participate in drop campaigns."""
id = models.CharField(
max_length=255,
id = models.TextField(
primary_key=True,
verbose_name="Channel ID",
help_text="The unique Twitch identifier for the channel.",
)
name = models.CharField(
max_length=255,
name = models.TextField(
db_index=True,
verbose_name="Username",
help_text="The lowercase username of the channel.",
)
display_name = models.CharField(
max_length=255,
display_name = models.TextField(
db_index=True,
verbose_name="Display Name",
help_text="The display name of the channel (with proper capitalization).",
@ -287,7 +230,7 @@ class Channel(auto_prefetch.Model):
help_text="Timestamp when this channel record was last updated.",
)
class Meta(auto_prefetch.Model.Meta):
class Meta:
ordering = ["display_name"]
indexes: ClassVar[list] = [
models.Index(fields=["name"]),
@ -300,16 +243,14 @@ class Channel(auto_prefetch.Model):
# MARK: DropCampaign
class DropCampaign(auto_prefetch.Model):
class DropCampaign(models.Model):
"""Represents a Twitch drop campaign."""
id = models.CharField(
max_length=255,
id = models.TextField(
primary_key=True,
help_text="Unique Twitch identifier for the campaign.",
)
name = models.CharField(
max_length=255,
name = models.TextField(
db_index=True,
help_text="Name of the drop campaign.",
)
@ -335,7 +276,6 @@ class DropCampaign(auto_prefetch.Model):
default="",
help_text="URL to an image representing the campaign.",
)
# Locally cached campaign image
image_file = models.FileField(
upload_to="campaigns/images/",
blank=True,
@ -369,10 +309,7 @@ class DropCampaign(auto_prefetch.Model):
help_text="Channels that are allowed to participate in this campaign.",
)
# PostgreSQL full-text search field
search_vector = SearchVectorField(null=True, blank=True)
game = auto_prefetch.ForeignKey(
game = models.ForeignKey(
Game,
on_delete=models.CASCADE,
related_name="drop_campaigns",
@ -390,7 +327,7 @@ class DropCampaign(auto_prefetch.Model):
help_text="Timestamp when this campaign record was last updated.",
)
class Meta(auto_prefetch.Model.Meta):
class Meta:
ordering = ["-start_at"]
constraints = [
# Ensure end_at is after start_at when both are set
@ -400,13 +337,8 @@ class DropCampaign(auto_prefetch.Model):
),
]
indexes: ClassVar[list] = [
# Regular B-tree index for campaign name lookups
models.Index(fields=["name"]),
# Full-text search index (GIN works with SearchVectorField)
GinIndex(fields=["search_vector"], name="campaign_search_vector_idx"),
# Composite index for time range queries
models.Index(fields=["start_at", "end_at"]),
# Partial index for active campaigns
models.Index(fields=["start_at", "end_at"], condition=models.Q(start_at__isnull=False, end_at__isnull=False), name="campaign_active_partial_idx"),
]
@ -462,16 +394,14 @@ class DropCampaign(auto_prefetch.Model):
# MARK: DropBenefit
class DropBenefit(auto_prefetch.Model):
class DropBenefit(models.Model):
"""Represents a benefit that can be earned from a drop."""
id = models.CharField(
max_length=255,
id = models.TextField(
primary_key=True,
help_text="Unique Twitch identifier for the benefit.",
)
name = models.CharField(
max_length=255,
name = models.TextField(
db_index=True,
blank=True,
default="N/A",
@ -483,7 +413,6 @@ class DropBenefit(auto_prefetch.Model):
default="",
help_text="URL to the benefit's image asset.",
)
# Locally cached benefit image
image_file = models.FileField(
upload_to="benefits/images/",
blank=True,
@ -505,7 +434,7 @@ class DropBenefit(auto_prefetch.Model):
default=False,
help_text="Whether the benefit is available on iOS.",
)
distribution_type = models.CharField(
distribution_type = models.TextField(
max_length=50,
db_index=True,
blank=True,
@ -513,9 +442,6 @@ class DropBenefit(auto_prefetch.Model):
help_text="Type of distribution for this benefit.",
)
# PostgreSQL full-text search field
search_vector = SearchVectorField(null=True, blank=True)
added_at = models.DateTimeField(
auto_now_add=True,
db_index=True,
@ -526,16 +452,12 @@ class DropBenefit(auto_prefetch.Model):
help_text="Timestamp when this benefit record was last updated.",
)
class Meta(auto_prefetch.Model.Meta):
class Meta:
ordering = ["-created_at"]
indexes: ClassVar[list] = [
# Regular B-tree index for benefit name lookups
models.Index(fields=["name"]),
# Full-text search index (GIN works with SearchVectorField)
GinIndex(fields=["search_vector"], name="benefit_search_vector_idx"),
models.Index(fields=["created_at"]),
models.Index(fields=["distribution_type"]),
# Partial index for iOS available benefits
models.Index(fields=["is_ios_available"], condition=models.Q(is_ios_available=True), name="benefit_ios_available_idx"),
]
@ -543,28 +465,16 @@ class DropBenefit(auto_prefetch.Model):
"""Return a string representation of the drop benefit."""
return self.name
@property
def image_best_url(self) -> str:
"""Return the best available URL for the benefit image (local first)."""
try:
if self.image_file and getattr(self.image_file, "url", None):
return self.image_file.url
except (AttributeError, OSError, ValueError) as exc:
logger.debug("Failed to resolve DropBenefit.image_file url: %s", exc)
return self.image_asset_url or ""
# MARK: TimeBasedDrop
class TimeBasedDrop(auto_prefetch.Model):
class TimeBasedDrop(models.Model):
"""Represents a time-based drop in a drop campaign."""
id = models.CharField(
max_length=255,
id = models.TextField(
primary_key=True,
help_text="Unique Twitch identifier for the time-based drop.",
)
name = models.CharField(
max_length=255,
name = models.TextField(
db_index=True,
help_text="Name of the time-based drop.",
)
@ -591,11 +501,8 @@ class TimeBasedDrop(auto_prefetch.Model):
help_text="Datetime when this drop expires.",
)
# PostgreSQL full-text search field
search_vector = SearchVectorField(null=True, blank=True)
# Foreign keys
campaign = auto_prefetch.ForeignKey(
campaign = models.ForeignKey(
DropCampaign,
on_delete=models.CASCADE,
related_name="time_based_drops",
@ -618,7 +525,7 @@ class TimeBasedDrop(auto_prefetch.Model):
help_text="Timestamp when this time-based drop record was last updated.",
)
class Meta(auto_prefetch.Model.Meta):
class Meta:
ordering = ["start_at"]
constraints = [
# Ensure end_at is after start_at when both are set
@ -632,13 +539,9 @@ class TimeBasedDrop(auto_prefetch.Model):
),
]
indexes: ClassVar[list] = [
# Regular B-tree index for drop name lookups
models.Index(fields=["name"]),
# Full-text search index (GIN works with SearchVectorField)
GinIndex(fields=["search_vector"], name="drop_search_vector_idx"),
models.Index(fields=["start_at", "end_at"]),
models.Index(fields=["required_minutes_watched"]),
# Covering index for common queries (includes campaign_id from FK)
models.Index(fields=["campaign", "start_at", "required_minutes_watched"]),
]
@ -648,15 +551,15 @@ class TimeBasedDrop(auto_prefetch.Model):
# MARK: DropBenefitEdge
class DropBenefitEdge(auto_prefetch.Model):
class DropBenefitEdge(models.Model):
"""Represents the relationship between a TimeBasedDrop and a DropBenefit."""
drop = auto_prefetch.ForeignKey(
drop = models.ForeignKey(
TimeBasedDrop,
on_delete=models.CASCADE,
help_text="The time-based drop in this relationship.",
)
benefit = auto_prefetch.ForeignKey(
benefit = models.ForeignKey(
DropBenefit,
on_delete=models.CASCADE,
help_text="The benefit in this relationship.",
@ -676,7 +579,7 @@ class DropBenefitEdge(auto_prefetch.Model):
help_text="Timestamp when this drop-benefit edge was last updated.",
)
class Meta(auto_prefetch.Model.Meta):
class Meta:
constraints = [
models.UniqueConstraint(fields=("drop", "benefit"), name="unique_drop_benefit"),
]
@ -687,31 +590,3 @@ class DropBenefitEdge(auto_prefetch.Model):
def __str__(self) -> str:
"""Return a string representation of the drop benefit edge."""
return f"{self.drop.name} - {self.benefit.name}"
# MARK: NotificationSubscription
class NotificationSubscription(auto_prefetch.Model):
"""Users can subscribe to games to get notified."""
user = auto_prefetch.ForeignKey(User, on_delete=models.CASCADE)
game = auto_prefetch.ForeignKey(Game, null=True, blank=True, on_delete=models.CASCADE)
organization = auto_prefetch.ForeignKey(Organization, null=True, blank=True, on_delete=models.CASCADE)
notify_found = models.BooleanField(default=False)
notify_live = models.BooleanField(default=False)
added_at = models.DateTimeField(auto_now_add=True, db_index=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta(auto_prefetch.Model.Meta):
unique_together: ClassVar[list[tuple[str, str]]] = [
("user", "game"),
("user", "organization"),
]
def __str__(self) -> str:
if self.game:
return f"{self.user} subscription to game: {self.game.display_name}"
if self.organization:
return f"{self.user} subscription to organization: {self.organization.name}"
return f"{self.user} subscription"