Eliminates explicit index definitions and db_index usage from Django models and migrations, relying on default indexes and constraints. This simplifies model definitions and migration files, reducing maintenance overhead and potential redundancy.
515 lines
15 KiB
Python
515 lines
15 KiB
Python
from __future__ import annotations
|
|
|
|
import logging
|
|
from typing import TYPE_CHECKING
|
|
|
|
from django.db import models
|
|
from django.utils import timezone
|
|
|
|
if TYPE_CHECKING:
|
|
import datetime
|
|
|
|
logger: logging.Logger = logging.getLogger("ttvdrops")
|
|
|
|
|
|
# MARK: Organization
|
|
class Organization(models.Model):
|
|
"""Represents an organization on Twitch that can own drop campaigns."""
|
|
|
|
id = models.TextField(
|
|
primary_key=True,
|
|
verbose_name="Organization ID",
|
|
help_text="The unique Twitch identifier for the organization.",
|
|
)
|
|
name = models.TextField(
|
|
unique=True,
|
|
verbose_name="Name",
|
|
help_text="Display name of the organization.",
|
|
)
|
|
|
|
added_at = models.DateTimeField(
|
|
auto_now_add=True,
|
|
help_text="Timestamp when this organization record was created.",
|
|
)
|
|
updated_at = models.DateTimeField(
|
|
auto_now=True,
|
|
help_text="Timestamp when this organization record was last updated.",
|
|
)
|
|
|
|
class Meta:
|
|
ordering = ["name"]
|
|
|
|
def __str__(self) -> str:
|
|
"""Return a string representation of the organization."""
|
|
return self.name or self.id
|
|
|
|
|
|
# MARK: Game
|
|
class Game(models.Model):
|
|
"""Represents a game on Twitch."""
|
|
|
|
id = models.TextField(primary_key=True, verbose_name="Game ID")
|
|
slug = models.TextField(
|
|
max_length=200,
|
|
blank=True,
|
|
default="",
|
|
verbose_name="Slug",
|
|
help_text="Short unique identifier for the game.",
|
|
)
|
|
name = models.TextField(
|
|
blank=True,
|
|
default="",
|
|
verbose_name="Name",
|
|
)
|
|
display_name = models.TextField(
|
|
blank=True,
|
|
default="",
|
|
verbose_name="Display name",
|
|
)
|
|
box_art = models.URLField(
|
|
max_length=500,
|
|
blank=True,
|
|
default="",
|
|
verbose_name="Box art URL",
|
|
)
|
|
|
|
box_art_file = models.FileField(
|
|
upload_to="games/box_art/",
|
|
blank=True,
|
|
null=True,
|
|
help_text="Locally cached box art image served from this site.",
|
|
)
|
|
|
|
owner = models.ForeignKey(
|
|
Organization,
|
|
on_delete=models.SET_NULL,
|
|
related_name="games",
|
|
null=True,
|
|
blank=True,
|
|
verbose_name="Organization",
|
|
help_text="The organization that owns this game.",
|
|
)
|
|
|
|
added_at = models.DateTimeField(
|
|
auto_now_add=True,
|
|
help_text="Timestamp when this game record was created.",
|
|
)
|
|
updated_at = models.DateTimeField(
|
|
auto_now=True,
|
|
help_text="Timestamp when this game record was last updated.",
|
|
)
|
|
|
|
class Meta:
|
|
ordering = ["display_name"]
|
|
|
|
def __str__(self) -> str:
|
|
"""Return a string representation of the game."""
|
|
if (self.display_name and self.name) and (self.display_name != self.name):
|
|
logger.warning(
|
|
"Game display name '%s' does not match name '%s'.",
|
|
self.display_name,
|
|
self.name,
|
|
)
|
|
return f"{self.display_name} ({self.name})"
|
|
return self.display_name or self.name or self.slug or self.id
|
|
|
|
@property
|
|
def organizations(self) -> models.QuerySet[Organization]:
|
|
"""Return all organizations that own games with campaigns for this game."""
|
|
return Organization.objects.filter(games__drop_campaigns__game=self).distinct()
|
|
|
|
@property
|
|
def get_game_name(self) -> str:
|
|
"""Return the best available name for the game."""
|
|
if self.display_name:
|
|
return self.display_name
|
|
if self.name:
|
|
return self.name
|
|
if self.slug:
|
|
return self.slug
|
|
return self.id
|
|
|
|
@property
|
|
def twitch_directory_url(self) -> str:
|
|
"""Return the Twitch directory URL for this game with drops filter if slug is available."""
|
|
if self.slug:
|
|
return f"https://www.twitch.tv/directory/category/{self.slug}?filter=drops"
|
|
return ""
|
|
|
|
|
|
# MARK: TwitchGame
|
|
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.
|
|
|
|
Fields:
|
|
id: Twitch game id (primary key)
|
|
game: Optional FK to the local Game object
|
|
name: Display name of the game
|
|
box_art_url: URL template for box art with {width}x{height} placeholder
|
|
igdb_id: Optional IGDB id for the game
|
|
"""
|
|
|
|
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",
|
|
null=True,
|
|
blank=True,
|
|
verbose_name="Game",
|
|
help_text="Optional link to the local Game record for this Twitch game.",
|
|
)
|
|
|
|
name = models.TextField(blank=True, default="", verbose_name="Name")
|
|
box_art_url = models.URLField(
|
|
max_length=500,
|
|
blank=True,
|
|
default="",
|
|
verbose_name="Box art URL",
|
|
help_text="URL template with {width}x{height} placeholders for the box art image.",
|
|
)
|
|
igdb_id = models.TextField(blank=True, default="", verbose_name="IGDB ID")
|
|
|
|
added_at = models.DateTimeField(auto_now_add=True, help_text="Record creation time.")
|
|
updated_at = models.DateTimeField(auto_now=True, help_text="Record last update time.")
|
|
|
|
class Meta:
|
|
ordering = ["name"]
|
|
|
|
def __str__(self) -> str:
|
|
return self.name or self.id
|
|
|
|
|
|
# MARK: Channel
|
|
class Channel(models.Model):
|
|
"""Represents a Twitch channel that can participate in drop campaigns."""
|
|
|
|
id = models.TextField(
|
|
primary_key=True,
|
|
verbose_name="Channel ID",
|
|
help_text="The unique Twitch identifier for the channel.",
|
|
)
|
|
name = models.TextField(
|
|
verbose_name="Username",
|
|
help_text="The lowercase username of the channel.",
|
|
)
|
|
display_name = models.TextField(
|
|
verbose_name="Display Name",
|
|
help_text="The display name of the channel (with proper capitalization).",
|
|
)
|
|
|
|
added_at = models.DateTimeField(
|
|
auto_now_add=True,
|
|
help_text="Timestamp when this channel record was created.",
|
|
)
|
|
updated_at = models.DateTimeField(
|
|
auto_now=True,
|
|
help_text="Timestamp when this channel record was last updated.",
|
|
)
|
|
|
|
class Meta:
|
|
ordering = ["display_name"]
|
|
|
|
def __str__(self) -> str:
|
|
"""Return a string representation of the channel."""
|
|
return self.display_name or self.name or self.id
|
|
|
|
|
|
# MARK: DropCampaign
|
|
class DropCampaign(models.Model):
|
|
"""Represents a Twitch drop campaign."""
|
|
|
|
id = models.TextField(
|
|
primary_key=True,
|
|
help_text="Unique Twitch identifier for the campaign.",
|
|
)
|
|
name = models.TextField(
|
|
help_text="Name of the drop campaign.",
|
|
)
|
|
description = models.TextField(
|
|
blank=True,
|
|
help_text="Detailed description of the campaign.",
|
|
)
|
|
details_url = models.URLField(
|
|
max_length=500,
|
|
blank=True,
|
|
default="",
|
|
help_text="URL with campaign details.",
|
|
)
|
|
account_link_url = models.URLField(
|
|
max_length=500,
|
|
blank=True,
|
|
default="",
|
|
help_text="URL to link a Twitch account for the campaign.",
|
|
)
|
|
image_url = models.URLField(
|
|
max_length=500,
|
|
blank=True,
|
|
default="",
|
|
help_text="URL to an image representing the campaign.",
|
|
)
|
|
image_file = models.FileField(
|
|
upload_to="campaigns/images/",
|
|
blank=True,
|
|
null=True,
|
|
help_text="Locally cached campaign image served from this site.",
|
|
)
|
|
start_at = models.DateTimeField(
|
|
null=True,
|
|
blank=True,
|
|
help_text="Datetime when the campaign starts.",
|
|
)
|
|
end_at = models.DateTimeField(
|
|
null=True,
|
|
blank=True,
|
|
help_text="Datetime when the campaign ends.",
|
|
)
|
|
is_account_connected = models.BooleanField(
|
|
default=False,
|
|
help_text="Indicates if the user account is linked.",
|
|
)
|
|
allow_is_enabled = models.BooleanField(
|
|
default=True,
|
|
help_text="Whether the campaign allows participation.",
|
|
)
|
|
allow_channels = models.ManyToManyField(
|
|
Channel,
|
|
blank=True,
|
|
related_name="allowed_campaigns",
|
|
help_text="Channels that are allowed to participate in this campaign.",
|
|
)
|
|
|
|
game = models.ForeignKey(
|
|
Game,
|
|
on_delete=models.CASCADE,
|
|
related_name="drop_campaigns",
|
|
verbose_name="Game",
|
|
help_text="Game associated with this campaign.",
|
|
)
|
|
|
|
added_at = models.DateTimeField(
|
|
auto_now_add=True,
|
|
help_text="Timestamp when this campaign record was created.",
|
|
)
|
|
updated_at = models.DateTimeField(
|
|
auto_now=True,
|
|
help_text="Timestamp when this campaign record was last updated.",
|
|
)
|
|
|
|
class Meta:
|
|
ordering = ["-start_at"]
|
|
|
|
def __str__(self) -> str:
|
|
return self.name
|
|
|
|
@property
|
|
def is_active(self) -> bool:
|
|
"""Check if the campaign is currently active."""
|
|
now: datetime.datetime = timezone.now()
|
|
if self.start_at is None or self.end_at is None:
|
|
return False
|
|
return self.start_at <= now <= self.end_at
|
|
|
|
@property
|
|
def clean_name(self) -> str:
|
|
"""Return the campaign name without the game name prefix.
|
|
|
|
Examples:
|
|
"Ravendawn - July 2" -> "July 2"
|
|
"Party Animals Twitch Drop" -> "Twitch Drop"
|
|
"Skull & Bones - Closed Beta" -> "Closed Beta" (& is replaced with "and")
|
|
"""
|
|
if not self.game or not self.game.display_name:
|
|
return self.name
|
|
|
|
game_variations = [self.game.display_name]
|
|
if "&" in self.game.display_name:
|
|
game_variations.append(self.game.display_name.replace("&", "and"))
|
|
if "and" in self.game.display_name:
|
|
game_variations.append(self.game.display_name.replace("and", "&"))
|
|
|
|
for game_name in game_variations:
|
|
# Check for different separators after the game name
|
|
for separator in [" - ", " | ", " "]:
|
|
prefix_to_check = game_name + separator
|
|
|
|
name: str = self.name
|
|
if name.startswith(prefix_to_check):
|
|
return name.removeprefix(prefix_to_check).strip()
|
|
|
|
return self.name
|
|
|
|
@property
|
|
def image_best_url(self) -> str:
|
|
"""Return the best available URL for the campaign 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 DropCampaign.image_file url: %s", exc)
|
|
return self.image_url or ""
|
|
|
|
|
|
# MARK: DropBenefit
|
|
class DropBenefit(models.Model):
|
|
"""Represents a benefit that can be earned from a drop."""
|
|
|
|
id = models.TextField(
|
|
primary_key=True,
|
|
help_text="Unique Twitch identifier for the benefit.",
|
|
)
|
|
name = models.TextField(
|
|
blank=True,
|
|
default="N/A",
|
|
help_text="Name of the drop benefit.",
|
|
)
|
|
image_asset_url = models.URLField(
|
|
max_length=500,
|
|
blank=True,
|
|
default="",
|
|
help_text="URL to the benefit's image asset.",
|
|
)
|
|
image_file = models.FileField(
|
|
upload_to="benefits/images/",
|
|
blank=True,
|
|
null=True,
|
|
help_text="Locally cached benefit image served from this site.",
|
|
)
|
|
created_at = models.DateTimeField(
|
|
null=True,
|
|
help_text="Timestamp when the benefit was created. This is from Twitch API and not auto-generated.",
|
|
)
|
|
entitlement_limit = models.PositiveIntegerField(
|
|
default=1,
|
|
help_text="Maximum number of times this benefit can be earned.",
|
|
)
|
|
|
|
# TODO(TheLovinator): Check if this should be default True or False # noqa: TD003
|
|
is_ios_available = models.BooleanField(
|
|
default=False,
|
|
help_text="Whether the benefit is available on iOS.",
|
|
)
|
|
distribution_type = models.TextField(
|
|
max_length=50,
|
|
blank=True,
|
|
default="",
|
|
help_text="Type of distribution for this benefit.",
|
|
)
|
|
|
|
added_at = models.DateTimeField(
|
|
auto_now_add=True,
|
|
help_text="Timestamp when this benefit record was created.",
|
|
)
|
|
updated_at = models.DateTimeField(
|
|
auto_now=True,
|
|
help_text="Timestamp when this benefit record was last updated.",
|
|
)
|
|
|
|
class Meta:
|
|
ordering = ["-created_at"]
|
|
|
|
def __str__(self) -> str:
|
|
"""Return a string representation of the drop benefit."""
|
|
return self.name
|
|
|
|
|
|
# MARK: TimeBasedDrop
|
|
class TimeBasedDrop(models.Model):
|
|
"""Represents a time-based drop in a drop campaign."""
|
|
|
|
id = models.TextField(
|
|
primary_key=True,
|
|
help_text="Unique Twitch identifier for the time-based drop.",
|
|
)
|
|
name = models.TextField(
|
|
help_text="Name of the time-based drop.",
|
|
)
|
|
required_minutes_watched = models.PositiveIntegerField(
|
|
null=True,
|
|
blank=True,
|
|
help_text="Minutes required to watch before earning this drop.",
|
|
)
|
|
required_subs = models.PositiveIntegerField(
|
|
default=0,
|
|
help_text="Number of subscriptions required to unlock this drop.",
|
|
)
|
|
start_at = models.DateTimeField(
|
|
null=True,
|
|
blank=True,
|
|
help_text="Datetime when this drop becomes available.",
|
|
)
|
|
end_at = models.DateTimeField(
|
|
null=True,
|
|
blank=True,
|
|
help_text="Datetime when this drop expires.",
|
|
)
|
|
|
|
# Foreign keys
|
|
campaign = models.ForeignKey(
|
|
DropCampaign,
|
|
on_delete=models.CASCADE,
|
|
related_name="time_based_drops",
|
|
help_text="The campaign this drop belongs to.",
|
|
)
|
|
benefits = models.ManyToManyField(
|
|
DropBenefit,
|
|
through="DropBenefitEdge",
|
|
related_name="drops",
|
|
help_text="Benefits unlocked by this drop.",
|
|
)
|
|
|
|
added_at = models.DateTimeField(
|
|
auto_now_add=True,
|
|
help_text="Timestamp when this time-based drop record was created.",
|
|
)
|
|
updated_at = models.DateTimeField(
|
|
auto_now=True,
|
|
help_text="Timestamp when this time-based drop record was last updated.",
|
|
)
|
|
|
|
class Meta:
|
|
ordering = ["start_at"]
|
|
|
|
def __str__(self) -> str:
|
|
"""Return a string representation of the time-based drop."""
|
|
return self.name
|
|
|
|
|
|
# MARK: DropBenefitEdge
|
|
class DropBenefitEdge(models.Model):
|
|
"""Represents the relationship between a TimeBasedDrop and a DropBenefit."""
|
|
|
|
drop = models.ForeignKey(
|
|
TimeBasedDrop,
|
|
on_delete=models.CASCADE,
|
|
help_text="The time-based drop in this relationship.",
|
|
)
|
|
benefit = models.ForeignKey(
|
|
DropBenefit,
|
|
on_delete=models.CASCADE,
|
|
help_text="The benefit in this relationship.",
|
|
)
|
|
entitlement_limit = models.PositiveIntegerField(
|
|
default=1,
|
|
help_text="Max times this benefit can be claimed for this drop.",
|
|
)
|
|
|
|
added_at = models.DateTimeField(
|
|
auto_now_add=True,
|
|
help_text="Timestamp when this drop-benefit edge was created.",
|
|
)
|
|
updated_at = models.DateTimeField(
|
|
auto_now=True,
|
|
help_text="Timestamp when this drop-benefit edge was last updated.",
|
|
)
|
|
|
|
class Meta:
|
|
constraints = [
|
|
models.UniqueConstraint(fields=("drop", "benefit"), name="unique_drop_benefit"),
|
|
]
|
|
|
|
def __str__(self) -> str:
|
|
"""Return a string representation of the drop benefit edge."""
|
|
return f"{self.drop.name} - {self.benefit.name}"
|