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,89 +0,0 @@
from __future__ import annotations
from django.contrib import admin
from twitch.models import DropBenefit, DropBenefitEdge, DropCampaign, Game, Organization, TimeBasedDrop
# MARK: Game
@admin.register(Game)
class GameAdmin(admin.ModelAdmin):
"""Admin configuration for Game model."""
list_display = ("id", "display_name", "slug")
search_fields = ("id", "display_name", "slug")
readonly_fields = ("added_at", "updated_at")
# MARK: Organization
@admin.register(Organization)
class OrganizationAdmin(admin.ModelAdmin):
"""Admin configuration for Organization model."""
list_display = ("id", "name")
search_fields = ("id", "name")
readonly_fields = ("added_at", "updated_at")
class TimeBasedDropInline(admin.TabularInline):
"""Inline admin for TimeBasedDrop model."""
model = TimeBasedDrop
extra = 0
# MARK: DropCampaign
@admin.register(DropCampaign)
class DropCampaignAdmin(admin.ModelAdmin):
"""Admin configuration for DropCampaign model."""
list_display = ("id", "name", "game", "start_at", "end_at", "is_active")
list_filter = ("game",)
search_fields = ("id", "name", "description")
inlines = [TimeBasedDropInline]
readonly_fields = ("added_at", "updated_at")
class DropBenefitEdgeInline(admin.TabularInline):
"""Inline admin for DropBenefitEdge model."""
model = DropBenefitEdge
extra = 0
# MARK: TimeBasedDrop
@admin.register(TimeBasedDrop)
class TimeBasedDropAdmin(admin.ModelAdmin):
"""Admin configuration for TimeBasedDrop model."""
list_display = (
"id",
"name",
"campaign",
"required_minutes_watched",
"required_subs",
"start_at",
"end_at",
)
list_filter = ("campaign__game", "campaign")
readonly_fields = ("added_at", "updated_at")
search_fields = ("id", "name")
inlines = [DropBenefitEdgeInline]
# MARK: DropBenefit
@admin.register(DropBenefit)
class DropBenefitAdmin(admin.ModelAdmin):
"""Admin configuration for DropBenefit model."""
list_display = (
"id",
"name",
"distribution_type",
"entitlement_limit",
"created_at",
)
list_filter = ("distribution_type",)
search_fields = ("id", "name")
readonly_fields = ("added_at", "updated_at")

View file

@ -2,25 +2,24 @@ from __future__ import annotations
import concurrent.futures
import shutil
import time
import threading
import traceback
from datetime import timedelta
from functools import lru_cache
from pathlib import Path
from typing import TYPE_CHECKING, Any
import dateparser
import json_repair
from django.core.exceptions import MultipleObjectsReturned
from django.core.management.base import BaseCommand, CommandError, CommandParser
from django.db import transaction
from django.db import DatabaseError, IntegrityError, transaction
from django.utils import timezone
from tqdm import tqdm
from twitch.models import Channel, DropBenefit, DropBenefitEdge, DropCampaign, Game, Organization, TimeBasedDrop
from twitch.utils.images import cache_remote_image
if TYPE_CHECKING:
from datetime import datetime
from typing import Literal
@lru_cache(maxsize=4096)
@ -58,6 +57,22 @@ class Command(BaseCommand):
help = "Import Twitch drop campaign data from a JSON file or directory"
requires_migrations_checks = True
# In-memory caches
_game_cache: dict[str, Game] = {}
_organization_cache: dict[str, Organization] = {}
_drop_campaign_cache: dict[str, DropCampaign] = {}
_channel_cache: dict[str, Channel] = {}
_benefit_cache: dict[str, DropBenefit] = {}
# Locks for thread-safety
_cache_locks: dict[str, threading.RLock] = {
"game": threading.RLock(),
"org": threading.RLock(),
"campaign": threading.RLock(),
"channel": threading.RLock(),
"benefit": threading.RLock(),
}
def add_arguments(self, parser: CommandParser) -> None:
"""Add command arguments.
@ -81,6 +96,11 @@ class Command(BaseCommand):
action="store_true",
help="Continue processing if an error occurs.",
)
parser.add_argument(
"--no-preload",
action="store_true",
help="Do not preload existing DB objects into memory (default: preload).",
)
def handle(self, **options) -> None:
"""Execute the command.
@ -89,17 +109,40 @@ class Command(BaseCommand):
**options: Arbitrary keyword arguments.
Raises:
CommandError: If the file/directory doesn't exist, isn't a JSON file,
or has an invalid JSON structure.
ValueError: If the JSON file has an invalid structure.
TypeError: If the JSON file has an invalid JSON structure.
AttributeError: If the JSON file has an invalid JSON structure.
KeyError: If the JSON file has an invalid JSON structure.
IndexError: If the JSON file has an invalid JSON structure.
CommandError: If a critical error occurs and --continue-on-error is not set.
ValueError: If the input data is invalid.
TypeError: If the input data is of an unexpected type.
AttributeError: If expected attributes are missing in the data.
KeyError: If expected keys are missing in the data.
IndexError: If list indices are out of range in the data.
"""
paths: list[str] = options["paths"]
processed_dir: str = options["processed_dir"]
continue_on_error: bool = options["continue_on_error"]
no_preload: bool = options.get("no_preload", False)
# Preload DB objects into caches (unless disabled)
if not no_preload:
try:
self.stdout.write("Preloading existing database objects into memory...")
self._preload_caches()
self.stdout.write(
f"Preloaded {len(self._game_cache)} games, "
f"{len(self._organization_cache)} orgs, "
f"{len(self._drop_campaign_cache)} campaigns, "
f"{len(self._channel_cache)} channels, "
f"{len(self._benefit_cache)} benefits."
)
except (FileNotFoundError, OSError, RuntimeError):
# If preload fails for any reason, continue without it
self.stdout.write(self.style.WARNING("Preloading caches failed — continuing without preload."))
self.stdout.write(self.style.ERROR(traceback.format_exc()))
self._game_cache = {}
self._organization_cache = {}
self._drop_campaign_cache = {}
self._channel_cache = {}
self._benefit_cache = {}
for p in paths:
try:
@ -129,6 +172,20 @@ class Command(BaseCommand):
self.stdout.write(self.style.WARNING("Interrupted by user, exiting import."))
return
def _preload_caches(self) -> None:
"""Load existing DB objects into in-memory caches to avoid repeated queries."""
# These queries may be heavy if DB is huge — safe because optional via --no-preload
with self._cache_locks["game"]:
self._game_cache = {str(g.id): g for g in Game.objects.all()}
with self._cache_locks["org"]:
self._organization_cache = {str(o.id): o for o in Organization.objects.all()}
with self._cache_locks["campaign"]:
self._drop_campaign_cache = {str(c.id): c for c in DropCampaign.objects.all()}
with self._cache_locks["channel"]:
self._channel_cache = {str(ch.id): ch for ch in Channel.objects.all()}
with self._cache_locks["benefit"]:
self._benefit_cache = {str(b.id): b for b in DropBenefit.objects.all()}
def process_drops(self, *, continue_on_error: bool, path: Path, processed_path: Path) -> None:
"""Process drops from a file or directory.
@ -138,8 +195,7 @@ class Command(BaseCommand):
processed_path: Name of subdirectory to move processed files to.
Raises:
CommandError: If the file/directory doesn't exist, isn't a JSON file,
or has an invalid JSON structure.
CommandError: If the path is neither a file nor a directory.
"""
if path.is_file():
self._process_file(file_path=path, processed_path=processed_path)
@ -170,18 +226,18 @@ class Command(BaseCommand):
"""Process all JSON files in a directory using parallel processing.
Args:
directory: Path to the directory containing JSON files.
processed_path: Path to the subdirectory where processed files will be moved.
continue_on_error: Whether to continue processing remaining files if an error occurs.
directory: The directory containing JSON files.
processed_path: Name of subdirectory to move processed files to.
continue_on_error: Continue processing if an error occurs.
Raises:
CommandError: If the path is invalid or moving files fails.
ValueError: If a JSON file has an invalid structure.
TypeError: If a JSON file has an invalid structure.
AttributeError: If a JSON file has an invalid structure.
KeyError: If a JSON file has an invalid structure.
IndexError: If a JSON file has an invalid structure.
KeyboardInterrupt: If processing is interrupted by the user.
AttributeError: If expected attributes are missing in the data.
CommandError: If a critical error occurs and --continue-on-error is not set.
IndexError: If list indices are out of range in the data.
KeyboardInterrupt: If the process is interrupted by the user.
KeyError: If expected keys are missing in the data.
TypeError: If the input data is of an unexpected type.
ValueError: If the input data is invalid.
"""
json_files: list[Path] = list(directory.glob("*.json"))
if not json_files:
@ -190,51 +246,39 @@ class Command(BaseCommand):
total_files: int = len(json_files)
self.stdout.write(f"Found {total_files} JSON files to process")
start_time: float = time.time()
processed = 0
try:
with concurrent.futures.ThreadPoolExecutor() as executor:
with concurrent.futures.ThreadPoolExecutor() as executor:
try:
future_to_file: dict[concurrent.futures.Future[None], Path] = {
executor.submit(self._process_file, json_file, processed_path): json_file for json_file in json_files
}
for future in concurrent.futures.as_completed(future_to_file):
# Wrap the as_completed iterator with tqdm for a progress bar
for future in tqdm(concurrent.futures.as_completed(future_to_file), total=total_files, desc="Processing files"):
json_file: Path = future_to_file[future]
self.stdout.write(f"Processing file {json_file.name}...")
try:
future.result()
except CommandError as e:
if not continue_on_error:
# To stop all processing, we shut down the executor and re-raise
executor.shutdown(wait=False, cancel_futures=True)
raise
self.stdout.write(self.style.ERROR(f"Error processing {json_file}: {e}"))
except (ValueError, TypeError, AttributeError, KeyError, IndexError):
if not continue_on_error:
# To stop all processing, we shut down the executor and re-raise
executor.shutdown(wait=False, cancel_futures=True)
raise
self.stdout.write(self.style.ERROR(f"Data error processing {json_file}"))
self.stdout.write(self.style.ERROR(traceback.format_exc()))
self.update_processing_progress(total_files=total_files, start_time=start_time, processed=processed)
except KeyboardInterrupt:
self.stdout.write(self.style.WARNING("Interrupted by user, exiting import."))
raise
else:
msg: str = f"Processed {total_files} JSON files in {directory}. Moved processed files to {processed_path}."
self.stdout.write(self.style.SUCCESS(msg))
msg: str = f"Processed {total_files} JSON files in {directory}. Moved processed files to {processed_path}."
self.stdout.write(self.style.SUCCESS(msg))
def update_processing_progress(self, total_files: int, start_time: float, processed: int) -> None:
"""Update and display processing progress.
Args:
total_files: Total number of files to process.
start_time: Timestamp when processing started.
processed: Number of files processed so far.
"""
processed += 1
elapsed: float = time.time() - start_time
rate: float | Literal[0] = processed / elapsed if elapsed > 0 else 0
remaining: int = total_files - processed
eta: timedelta = timedelta(seconds=int(remaining / rate)) if rate > 0 else timedelta(seconds=0)
self.stdout.write(f"Progress: {processed}/{total_files} files - {rate:.2f} files/sec - ETA {eta}")
except KeyboardInterrupt:
self.stdout.write(self.style.WARNING("Interruption received, shutting down threads immediately..."))
executor.shutdown(wait=False, cancel_futures=True)
# Re-raise the exception to allow the main `handle` method to catch it and exit
raise
def _process_file(self, file_path: Path, processed_path: Path) -> None:
"""Process a single JSON file.
@ -276,7 +320,7 @@ class Command(BaseCommand):
target_dir.mkdir(parents=True, exist_ok=True)
self.move_file(file_path, target_dir / file_path.name)
self.stdout.write(f"Moved {file_path} to {target_dir} (matched '{keyword}')")
tqdm.write(f"Moved {file_path} to {target_dir} (matched '{keyword}')")
return
# Some responses have errors:
@ -286,7 +330,7 @@ class Command(BaseCommand):
actual_error_dir: Path = processed_path / "actual_error"
actual_error_dir.mkdir(parents=True, exist_ok=True)
self.move_file(file_path, actual_error_dir / file_path.name)
self.stdout.write(f"Moved {file_path} to {actual_error_dir} (contains Twitch errors)")
tqdm.write(f"Moved {file_path} to {actual_error_dir} (contains Twitch errors)")
return
# If file has "__typename": "BroadcastSettings" move it to the "broadcast_settings" directory
@ -305,13 +349,13 @@ class Command(BaseCommand):
and data["data"]["channel"]["viewerDropCampaigns"] is None
):
file_path.unlink()
self.stdout.write(f"Removed {file_path} (only contains empty viewerDropCampaigns)")
tqdm.write(f"Removed {file_path} (only contains empty viewerDropCampaigns)")
return
# If file only contains {"data": {"user": null}} remove the file
if isinstance(data, dict) and data.get("data", {}).keys() == {"user"} and data["data"]["user"] is None:
file_path.unlink()
self.stdout.write(f"Removed {file_path} (only contains empty user)")
tqdm.write(f"Removed {file_path} (only contains empty user)")
return
# If file only contains {"data": {"game": {}}} remove the file
@ -319,7 +363,7 @@ class Command(BaseCommand):
game_data = data["data"]["game"]
if isinstance(game_data, dict) and game_data.get("__typename") == "Game":
file_path.unlink()
self.stdout.write(f"Removed {file_path} (only contains game data)")
tqdm.write(f"Removed {file_path} (only contains game data)")
return
# If file has "__typename": "DropCurrentSession" move it to the "drop_current_session" directory so we can process it separately.
@ -338,7 +382,7 @@ class Command(BaseCommand):
and data[0]["data"]["user"] is None
):
file_path.unlink()
self.stdout.write(f"Removed {file_path} (list with one item: empty user)")
tqdm.write(f"Removed {file_path} (list with one item: empty user)")
return
if isinstance(data, list):
@ -363,30 +407,28 @@ class Command(BaseCommand):
shutil.move(str(file_path), str(processed_path))
except FileExistsError:
# Rename the file if contents is different than the existing one
with (
file_path.open("rb") as f1,
(processed_path / file_path.name).open("rb") as f2,
):
if f1.read() != f2.read():
new_name: Path = processed_path / f"{file_path.stem}_duplicate{file_path.suffix}"
shutil.move(str(file_path), str(new_name))
self.stdout.write(f"Moved {file_path!s} to {new_name!s} (content differs)")
else:
self.stdout.write(f"{file_path!s} already exists in {processed_path!s}, removing original file.")
file_path.unlink()
try:
with (
file_path.open("rb") as f1,
(processed_path / file_path.name).open("rb") as f2,
):
if f1.read() != f2.read():
new_name: Path = processed_path / f"{file_path.stem}_duplicate{file_path.suffix}"
shutil.move(str(file_path), str(new_name))
tqdm.write(f"Moved {file_path!s} to {new_name!s} (content differs)")
else:
tqdm.write(f"{file_path!s} already exists in {processed_path!s}, removing original file.")
file_path.unlink()
except FileNotFoundError:
tqdm.write(f"{file_path!s} not found when handling duplicate case, skipping.")
except FileNotFoundError:
self.stdout.write(f"{file_path!s} not found, skipping.")
tqdm.write(f"{file_path!s} not found, skipping.")
except (PermissionError, OSError, shutil.Error) as e:
self.stdout.write(self.style.ERROR(f"Error moving {file_path!s} to {processed_path!s}: {e}"))
traceback.print_exc()
def import_drop_campaign(self, data: dict[str, Any], file_path: Path) -> None:
"""Find and import drop campaign data from various JSON structures.
Args:
data: The JSON data.
file_path: The path to the file being processed.
"""
"""Find and import drop campaign data from various JSON structures."""
# Add this check: If this is a known "empty" response, ignore it silently.
if (
"data" in data
@ -403,7 +445,7 @@ class Command(BaseCommand):
d: The dictionary to check for drop campaign data.
Returns:
True if any drop campaign data was imported, False otherwise.
True if import was attempted, False otherwise.
"""
if not isinstance(d, dict):
return False
@ -454,7 +496,7 @@ class Command(BaseCommand):
self.import_to_db(data, file_path=file_path)
return
self.stdout.write(self.style.WARNING(f"No valid drop campaign data found in {file_path.name}"))
tqdm.write(self.style.WARNING(f"No valid drop campaign data found in {file_path.name}"))
def import_to_db(self, campaign_data: dict[str, Any], file_path: Path) -> None:
"""Import drop campaign data into the database with retry logic for SQLite locks.
@ -467,7 +509,7 @@ class Command(BaseCommand):
game: Game = self.game_update_or_create(campaign_data=campaign_data)
organization: Organization | None = self.owner_update_or_create(campaign_data=campaign_data)
if organization:
if organization and game.owner != organization:
game.owner = organization
game.save(update_fields=["owner"])
@ -476,14 +518,12 @@ class Command(BaseCommand):
for drop_data in campaign_data.get("timeBasedDrops", []):
self._process_time_based_drop(drop_data, drop_campaign, file_path)
self.stdout.write(self.style.SUCCESS(f"Successfully imported drop campaign {drop_campaign.name} (ID: {drop_campaign.id})"))
def _process_time_based_drop(self, drop_data: dict[str, Any], drop_campaign: DropCampaign, file_path: Path) -> None:
time_based_drop: TimeBasedDrop = self.create_time_based_drop(drop_campaign=drop_campaign, drop_data=drop_data)
benefit_edges: list[dict[str, Any]] = drop_data.get("benefitEdges", [])
if not benefit_edges:
self.stdout.write(self.style.WARNING(f"No benefit edges found for drop {time_based_drop.name} (ID: {time_based_drop.id})"))
tqdm.write(self.style.WARNING(f"No benefit edges found for drop {time_based_drop.name} (ID: {time_based_drop.id})"))
self.move_file(file_path, Path("no_benefit_edges") / file_path.name)
return
@ -499,30 +539,31 @@ class Command(BaseCommand):
}
# Run .strip() on all string fields to remove leading/trailing whitespace
for key, value in benefit_defaults.items():
for key, value in list(benefit_defaults.items()):
if isinstance(value, str):
benefit_defaults[key] = value.strip()
# Filter out None values to avoid overwriting with them
benefit_defaults = {k: v for k, v in benefit_defaults.items() if v is not None}
benefit, _ = DropBenefit.objects.update_or_create(
id=benefit_data["id"],
defaults=benefit_defaults,
)
# Use cached create/update for benefits
benefit = self._get_or_create_benefit(benefit_data["id"], benefit_defaults)
# Cache benefit image if available and not already cached
if (not benefit.image_file) and benefit.image_asset_url:
rel_path: str | None = cache_remote_image(benefit.image_asset_url, "benefits/images")
if rel_path:
benefit.image_file.name = rel_path
benefit.save(update_fields=["image_file"])
DropBenefitEdge.objects.update_or_create(
drop=time_based_drop,
benefit=benefit,
defaults={"entitlement_limit": benefit_edge.get("entitlementLimit", 1)},
)
try:
with transaction.atomic():
drop_benefit_edge, created = DropBenefitEdge.objects.update_or_create(
drop=time_based_drop,
benefit=benefit,
defaults={"entitlement_limit": benefit_edge.get("entitlementLimit", 1)},
)
if created:
tqdm.write(f"Added {drop_benefit_edge}")
except MultipleObjectsReturned as e:
msg = f"Error: Multiple DropBenefitEdge objects found for drop {time_based_drop.id} and benefit {benefit.id}. Cannot update or create."
raise CommandError(msg) from e
except (IntegrityError, DatabaseError, TypeError, ValueError) as e:
msg = f"Database or validation error creating DropBenefitEdge for drop {time_based_drop.id} and benefit {benefit.id}: {e}"
raise CommandError(msg) from e
def create_time_based_drop(self, drop_campaign: DropCampaign, drop_data: dict[str, Any]) -> TimeBasedDrop:
"""Creates or updates a TimeBasedDrop instance based on the provided drop data.
@ -537,9 +578,11 @@ class Command(BaseCommand):
- "startAt" (str, optional): ISO 8601 datetime string for when the drop starts.
- "endAt" (str, optional): ISO 8601 datetime string for when the drop ends.
Raises:
CommandError: If there is a database error or multiple objects are returned.
Returns:
TimeBasedDrop: The created or updated TimeBasedDrop instance.
"""
time_based_drop_defaults: dict[str, Any] = {
"campaign": drop_campaign,
@ -551,35 +594,182 @@ class Command(BaseCommand):
}
# Run .strip() on all string fields to remove leading/trailing whitespace
for key, value in time_based_drop_defaults.items():
for key, value in list(time_based_drop_defaults.items()):
if isinstance(value, str):
time_based_drop_defaults[key] = value.strip()
# Filter out None values to avoid overwriting with them
time_based_drop_defaults = {k: v for k, v in time_based_drop_defaults.items() if v is not None}
time_based_drop, created = TimeBasedDrop.objects.update_or_create(id=drop_data["id"], defaults=time_based_drop_defaults)
if created:
self.stdout.write(self.style.SUCCESS(f"Successfully imported time-based drop {time_based_drop.name} (ID: {time_based_drop.id})"))
try:
with transaction.atomic():
time_based_drop, created = TimeBasedDrop.objects.update_or_create(id=drop_data["id"], defaults=time_based_drop_defaults)
if created:
tqdm.write(f"Added {time_based_drop}")
except MultipleObjectsReturned as e:
msg = f"Error: Multiple TimeBasedDrop objects found for drop {drop_data['id']}. Cannot update or create."
raise CommandError(msg) from e
except (IntegrityError, DatabaseError, TypeError, ValueError) as e:
msg = f"Database or validation error creating TimeBasedDrop for drop {drop_data['id']}: {e}"
raise CommandError(msg) from e
return time_based_drop
def drop_campaign_update_or_get(
def _get_or_create_cached(
self,
campaign_data: dict[str, Any],
game: Game,
) -> DropCampaign:
"""Update or create a drop campaign.
model_name: str,
model_class: type[Game | Organization | DropCampaign | Channel | DropBenefit],
obj_id: str | int,
defaults: dict[str, Any] | None = None,
) -> Game | Organization | DropCampaign | Channel | DropBenefit | str | int | None:
"""Generic get-or-create that uses the in-memory cache and writes only if needed.
This implementation is thread-safe and transaction-aware.
Args:
campaign_data: The drop campaign data to import.
game: The game this drop campaign is for.
organization: The company that owns the game. If None, the campaign will not have an owner.
model_name: The name of the model (used for cache and lock).
model_class: The Django model class.
obj_id: The ID of the object to get or create.
defaults: A dictionary of fields to set on creation or update.
Returns:
Returns the DropCampaign object.
The retrieved or created object.
"""
sid = str(obj_id)
defaults = defaults or {}
lock = self._cache_locks.get(model_name)
if lock is None:
# Fallback for models without a dedicated cache/lock
obj, created = model_class.objects.update_or_create(id=obj_id, defaults=defaults)
if created:
tqdm.write(f"Added {obj}")
return obj
with lock:
cache = getattr(self, f"_{model_name}_cache", None)
if cache is None:
cache = {}
setattr(self, f"_{model_name}_cache", cache)
# First, check the cache.
cached_obj = cache.get(sid)
if cached_obj:
return cached_obj
# Not in cache, so we need to go to the database.
# Use get_or_create which is safer in a race. It might still fail if two threads
# try to create at the exact same time, so we wrap it.
try:
obj, created = model_class.objects.get_or_create(id=obj_id, defaults=defaults)
except IntegrityError:
# Another thread created it between our `get` and `create` attempt.
# The object is guaranteed to exist now, so we can just fetch it.
obj = model_class.objects.get(id=obj_id)
created = False
if not created:
# The object already existed, check if our data is newer and update if needed.
changed = False
update_fields = []
for key, val in defaults.items():
if hasattr(obj, key) and getattr(obj, key) != val:
setattr(obj, key, val)
changed = True
update_fields.append(key)
if changed:
obj.save(update_fields=update_fields)
# IMPORTANT: Defer the cache update until the transaction is successful.
# This is the key to preventing the race condition.
transaction.on_commit(lambda: cache.update({sid: obj}))
if created:
tqdm.write(f"Added {obj}")
return obj
def _get_or_create_benefit(self, benefit_id: str | int, defaults: dict[str, Any]) -> DropBenefit:
return self._get_or_create_cached("benefit", DropBenefit, benefit_id, defaults) # pyright: ignore[reportReturnType]
def game_update_or_create(self, campaign_data: dict[str, Any]) -> Game:
"""Update or create a game with caching.
Args:
campaign_data: The campaign data containing game information.
Raises:
TypeError: If the retrieved object is not a Game instance.
Returns:
The retrieved or created Game object.
"""
game_data: dict[str, Any] = campaign_data["game"]
game_defaults: dict[str, Any] = {
"name": game_data.get("name"),
"display_name": game_data.get("displayName"),
"box_art": game_data.get("boxArtURL"),
"slug": game_data.get("slug"),
}
# Filter out None values to avoid overwriting with them
game_defaults = {k: v for k, v in game_defaults.items() if v is not None}
game: Game | Organization | DropCampaign | Channel | DropBenefit | str | int | None = self._get_or_create_cached(
model_name="game",
model_class=Game,
obj_id=game_data["id"],
defaults=game_defaults,
)
if not isinstance(game, Game):
msg = "Expected a Game instance from _get_or_create_cached"
raise TypeError(msg)
return game
def owner_update_or_create(self, campaign_data: dict[str, Any]) -> Organization | None:
"""Update or create an organization with caching.
Args:
campaign_data: The campaign data containing owner information.
Raises:
TypeError: If the retrieved object is not an Organization instance.
Returns:
The retrieved or created Organization object, or None if no owner data is present.
"""
org_data: dict[str, Any] = campaign_data.get("owner", {})
if org_data:
org_defaults: dict[str, Any] = {"name": org_data.get("name")}
org_defaults = {k: v.strip() if isinstance(v, str) else v for k, v in org_defaults.items() if v is not None}
owner = self._get_or_create_cached(
model_name="org",
model_class=Organization,
obj_id=org_data["id"],
defaults=org_defaults,
)
if not isinstance(owner, Organization):
msg = "Expected an Organization instance from _get_or_create_cached"
raise TypeError(msg)
return owner
return None
def drop_campaign_update_or_get(self, campaign_data: dict[str, Any], game: Game) -> DropCampaign:
"""Update or create a drop campaign with caching and channel handling.
Args:
campaign_data: The campaign data containing drop campaign information.
game: The associated Game object.
Raises:
TypeError: If the retrieved object is not a DropCampaign instance.
Returns:
The retrieved or created DropCampaign object.
"""
# Extract allow data from campaign_data
allow_data = campaign_data.get("allow", {})
allow_is_enabled = allow_data.get("isEnabled")
@ -595,18 +785,24 @@ class Command(BaseCommand):
"is_account_connected": campaign_data.get("self", {}).get("isAccountConnected"),
"allow_is_enabled": allow_is_enabled,
}
# Run .strip() on all string fields to remove leading/trailing whitespace
for key, value in drop_campaign_defaults.items():
for key, value in list(drop_campaign_defaults.items()):
if isinstance(value, str):
drop_campaign_defaults[key] = value.strip()
# Filter out None values to avoid overwriting with them
drop_campaign_defaults = {k: v for k, v in drop_campaign_defaults.items() if v is not None}
drop_campaign, created = DropCampaign.objects.update_or_create(
id=campaign_data["id"],
drop_campaign = self._get_or_create_cached(
model_name="campaign",
model_class=DropCampaign,
obj_id=campaign_data["id"],
defaults=drop_campaign_defaults,
)
if not isinstance(drop_campaign, DropCampaign):
msg = "Expected a DropCampaign instance from _get_or_create_cached"
raise TypeError(msg)
# Handle allow_channels (many-to-many relationship)
allow_channels: list[dict[str, str]] = allow_data.get("channels", [])
@ -625,87 +821,23 @@ class Command(BaseCommand):
# Filter out None values
channel_defaults = {k: v for k, v in channel_defaults.items() if v is not None}
channel, _ = Channel.objects.update_or_create(
id=channel_data["id"],
# Use cached helper for channels
channel = self._get_or_create_cached(
model_name="channel",
model_class=Channel,
obj_id=channel_data["id"],
defaults=channel_defaults,
)
if not isinstance(channel, Channel):
msg = "Expected a Channel instance from _get_or_create_cached"
raise TypeError(msg)
channel_objects.append(channel)
# Set the many-to-many relationship
drop_campaign.allow_channels.set(channel_objects)
# Set the many-to-many relationship (save only if different)
current_ids = set(drop_campaign.allow_channels.values_list("id", flat=True))
new_ids = {ch.id for ch in channel_objects}
if current_ids != new_ids:
drop_campaign.allow_channels.set(channel_objects)
if created:
self.stdout.write(self.style.SUCCESS(f"Created new drop campaign: {drop_campaign.name} (ID: {drop_campaign.id})"))
# Cache campaign image if available and not already cached
if (not drop_campaign.image_file) and drop_campaign.image_url:
rel_path: str | None = cache_remote_image(drop_campaign.image_url, "campaigns/images")
if rel_path:
drop_campaign.image_file.name = rel_path
drop_campaign.save(update_fields=["image_file"]) # type: ignore[list-item]
return drop_campaign
def owner_update_or_create(self, campaign_data: dict[str, Any]) -> Organization | None:
"""Update or create an organization.
Args:
campaign_data: The drop campaign data to import.
Returns:
Returns the Organization object.
"""
org_data: dict[str, Any] = campaign_data.get("owner", {})
if org_data:
org_defaults: dict[str, Any] = {"name": org_data.get("name")}
# Run .strip() on all string fields to remove leading/trailing whitespace
for key, value in org_defaults.items():
if isinstance(value, str):
org_defaults[key] = value.strip()
# Filter out None values to avoid overwriting with them
org_defaults = {k: v for k, v in org_defaults.items() if v is not None}
organization, created = Organization.objects.update_or_create(
id=org_data["id"],
defaults=org_defaults,
)
if created:
self.stdout.write(self.style.SUCCESS(f"Created new organization: {organization.name} (ID: {organization.id})"))
return organization
return None
def game_update_or_create(self, campaign_data: dict[str, Any]) -> Game:
"""Update or create a game.
Args:
campaign_data: The drop campaign data to import.
Returns:
Returns the Game object.
"""
game_data: dict[str, Any] = campaign_data["game"]
game_defaults: dict[str, Any] = {
"name": game_data.get("name"),
"display_name": game_data.get("displayName"),
"box_art": game_data.get("boxArtURL"),
"slug": game_data.get("slug"),
}
# Filter out None values to avoid overwriting with them
game_defaults = {k: v for k, v in game_defaults.items() if v is not None}
game, created = Game.objects.update_or_create(
id=game_data["id"],
defaults=game_defaults,
)
if created:
self.stdout.write(self.style.SUCCESS(f"Created new game: {game.display_name} (ID: {game.id})"))
# Cache game box art if available and not already cached
if (not game.box_art_file) and game.box_art:
rel_path: str | None = cache_remote_image(game.box_art, "games/box_art")
if rel_path:
game.box_art_file.name = rel_path
game.save(update_fields=["box_art_file"])
return game

View file

@ -1,40 +0,0 @@
"""Management command to update PostgreSQL search vectors."""
from __future__ import annotations
from django.contrib.postgres.search import SearchVector
from django.core.management.base import BaseCommand
from twitch.models import DropBenefit, DropCampaign, Game, Organization, TimeBasedDrop
class Command(BaseCommand):
"""Update search vectors for existing records."""
help = "Update PostgreSQL search vectors for existing records"
def handle(self, *_args, **_options) -> None:
"""Update search vectors for all models."""
self.stdout.write("Updating search vectors...")
# Update Organizations
org_count = Organization.objects.update(search_vector=SearchVector("name"))
self.stdout.write(self.style.SUCCESS(f"Successfully updated search vectors for {org_count} organizations"))
# Update Games
game_count = Game.objects.update(search_vector=SearchVector("name", "display_name"))
self.stdout.write(self.style.SUCCESS(f"Successfully updated search vectors for {game_count} games"))
# Update DropCampaigns
campaign_count = DropCampaign.objects.update(search_vector=SearchVector("name", "description"))
self.stdout.write(self.style.SUCCESS(f"Successfully updated search vectors for {campaign_count} campaigns"))
# Update TimeBasedDrops
drop_count = TimeBasedDrop.objects.update(search_vector=SearchVector("name"))
self.stdout.write(self.style.SUCCESS(f"Successfully updated search vectors for {drop_count} time-based drops"))
# Update DropBenefits
benefit_count = DropBenefit.objects.update(search_vector=SearchVector("name"))
self.stdout.write(self.style.SUCCESS(f"Successfully updated search vectors for {benefit_count} drop benefits"))
self.stdout.write(self.style.SUCCESS("All search vectors updated."))

View file

@ -1,179 +1,344 @@
# Generated by Django 5.2.4 on 2025-08-06 04:12
# Generated by Django 5.2.7 on 2025-10-13 00:00
from __future__ import annotations
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
"""Initial migration.
Args:
migrations (migrations.Migration): The base class for all migrations.
"""
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
dependencies = []
operations = [
migrations.CreateModel(
name='DropBenefit',
name="Game",
fields=[
('id', models.TextField(primary_key=True, serialize=False)),
('name', models.TextField(db_index=True)),
('image_asset_url', models.URLField(blank=True, default='', max_length=500)),
('created_at', models.DateTimeField(db_index=True)),
('entitlement_limit', models.PositiveIntegerField(default=1)),
('is_ios_available', models.BooleanField(default=False)),
('distribution_type', models.TextField(db_index=True)),
],
),
migrations.CreateModel(
name='DropBenefitEdge',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('entitlement_limit', models.PositiveIntegerField(default=1)),
('benefit', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='twitch.dropbenefit')),
],
),
migrations.CreateModel(
name='Game',
fields=[
('id', models.TextField(primary_key=True, serialize=False)),
('slug', models.TextField(blank=True, db_index=True, default='')),
('display_name', models.TextField(db_index=True)),
('box_art', models.URLField(blank=True, default='', max_length=500)),
("id", models.TextField(primary_key=True, serialize=False, verbose_name="Game ID")),
(
"slug",
models.TextField(
blank=True, db_index=True, default="", help_text="Short unique identifier for the game.", max_length=200, verbose_name="Slug"
),
),
("name", models.TextField(blank=True, db_index=True, default="", verbose_name="Name")),
("display_name", models.TextField(blank=True, db_index=True, default="", verbose_name="Display name")),
("box_art", models.URLField(blank=True, default="", max_length=500, verbose_name="Box art URL")),
(
"box_art_file",
models.FileField(blank=True, help_text="Locally cached box art image served from this site.", null=True, upload_to="games/box_art/"),
),
("added_at", models.DateTimeField(auto_now_add=True, db_index=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.")),
],
options={
'indexes': [models.Index(fields=['slug'], name='twitch_game_slug_a02d3c_idx'), models.Index(fields=['display_name'], name='twitch_game_display_a35ba3_idx')],
},
),
migrations.AddField(
model_name='dropbenefit',
name='game',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='drop_benefits', to='twitch.game'),
),
migrations.CreateModel(
name='Organization',
fields=[
('id', models.TextField(primary_key=True, serialize=False)),
('name', models.TextField(db_index=True)),
],
options={
'indexes': [models.Index(fields=['name'], name='twitch_orga_name_febe72_idx')],
"ordering": ["display_name"],
},
),
migrations.CreateModel(
name='DropCampaign',
name="Channel",
fields=[
('id', models.TextField(primary_key=True, serialize=False)),
('name', models.TextField(db_index=True)),
('description', models.TextField(blank=True)),
('details_url', models.URLField(blank=True, default='', max_length=500)),
('account_link_url', models.URLField(blank=True, default='', max_length=500)),
('image_url', models.URLField(blank=True, default='', max_length=500)),
('start_at', models.DateTimeField(db_index=True)),
('end_at', models.DateTimeField(db_index=True)),
('is_account_connected', models.BooleanField(default=False)),
('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='drop_campaigns', to='twitch.game')),
('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='drop_campaigns', to='twitch.organization')),
],
),
migrations.AddField(
model_name='dropbenefit',
name='owner_organization',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='drop_benefits', to='twitch.organization'),
),
migrations.CreateModel(
name='TimeBasedDrop',
fields=[
('id', models.TextField(primary_key=True, serialize=False)),
('name', models.TextField(db_index=True)),
('required_minutes_watched', models.PositiveIntegerField(db_index=True)),
('required_subs', models.PositiveIntegerField(default=0)),
('start_at', models.DateTimeField(db_index=True)),
('end_at', models.DateTimeField(db_index=True)),
('benefits', models.ManyToManyField(related_name='drops', through='twitch.DropBenefitEdge', to='twitch.dropbenefit')),
('campaign', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='time_based_drops', to='twitch.dropcampaign')),
],
),
migrations.AddField(
model_name='dropbenefitedge',
name='drop',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='twitch.timebaseddrop'),
),
migrations.CreateModel(
name='NotificationSubscription',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('notify_found', models.BooleanField(default=False)),
('notify_live', models.BooleanField(default=False)),
('game', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='twitch.game')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
('organization', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='twitch.organization')),
(
"id",
models.TextField(help_text="The unique Twitch identifier for the channel.", primary_key=True, serialize=False, verbose_name="Channel ID"),
),
("name", models.TextField(db_index=True, help_text="The lowercase username of the channel.", verbose_name="Username")),
(
"display_name",
models.TextField(db_index=True, help_text="The display name of the channel (with proper capitalization).", verbose_name="Display Name"),
),
("added_at", models.DateTimeField(auto_now_add=True, db_index=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.")),
],
options={
'unique_together': {('user', 'game'), ('user', 'organization')},
"ordering": ["display_name"],
"indexes": [
models.Index(fields=["name"], name="twitch_chan_name_15d566_idx"),
models.Index(fields=["display_name"], name="twitch_chan_display_2bf213_idx"),
],
},
),
migrations.CreateModel(
name="DropBenefit",
fields=[
("id", models.TextField(help_text="Unique Twitch identifier for the benefit.", primary_key=True, serialize=False)),
("name", models.TextField(blank=True, db_index=True, default="N/A", help_text="Name of the drop benefit.")),
("image_asset_url", models.URLField(blank=True, default="", help_text="URL to the benefit's image asset.", max_length=500)),
(
"image_file",
models.FileField(blank=True, help_text="Locally cached benefit image served from this site.", null=True, upload_to="benefits/images/"),
),
(
"created_at",
models.DateTimeField(
db_index=True, help_text="Timestamp when the benefit was created. This is from Twitch API and not auto-generated.", null=True
),
),
("entitlement_limit", models.PositiveIntegerField(default=1, help_text="Maximum number of times this benefit can be earned.")),
("is_ios_available", models.BooleanField(default=False, help_text="Whether the benefit is available on iOS.")),
(
"distribution_type",
models.TextField(blank=True, db_index=True, default="", help_text="Type of distribution for this benefit.", max_length=50),
),
("added_at", models.DateTimeField(auto_now_add=True, db_index=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.")),
],
options={
"ordering": ["-created_at"],
"indexes": [
models.Index(fields=["name"], name="twitch_drop_name_7125ff_idx"),
models.Index(fields=["created_at"], name="twitch_drop_created_a3563e_idx"),
models.Index(fields=["distribution_type"], name="twitch_drop_distrib_08b224_idx"),
models.Index(condition=models.Q(("is_ios_available", True)), fields=["is_ios_available"], name="benefit_ios_available_idx"),
],
},
),
migrations.CreateModel(
name="DropBenefitEdge",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("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, db_index=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.")),
(
"benefit",
models.ForeignKey(help_text="The benefit in this relationship.", on_delete=django.db.models.deletion.CASCADE, to="twitch.dropbenefit"),
),
],
),
migrations.CreateModel(
name="DropCampaign",
fields=[
("id", models.TextField(help_text="Unique Twitch identifier for the campaign.", primary_key=True, serialize=False)),
("name", models.TextField(db_index=True, help_text="Name of the drop campaign.")),
("description", models.TextField(blank=True, help_text="Detailed description of the campaign.")),
("details_url", models.URLField(blank=True, default="", help_text="URL with campaign details.", max_length=500)),
("account_link_url", models.URLField(blank=True, default="", help_text="URL to link a Twitch account for the campaign.", max_length=500)),
("image_url", models.URLField(blank=True, default="", help_text="URL to an image representing the campaign.", max_length=500)),
(
"image_file",
models.FileField(blank=True, help_text="Locally cached campaign image served from this site.", null=True, upload_to="campaigns/images/"),
),
("start_at", models.DateTimeField(blank=True, db_index=True, help_text="Datetime when the campaign starts.", null=True)),
("end_at", models.DateTimeField(blank=True, db_index=True, help_text="Datetime when the campaign ends.", null=True)),
("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.")),
("added_at", models.DateTimeField(auto_now_add=True, db_index=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.")),
(
"allow_channels",
models.ManyToManyField(
blank=True,
help_text="Channels that are allowed to participate in this campaign.",
related_name="allowed_campaigns",
to="twitch.channel",
),
),
(
"game",
models.ForeignKey(
help_text="Game associated with this campaign.",
on_delete=django.db.models.deletion.CASCADE,
related_name="drop_campaigns",
to="twitch.game",
verbose_name="Game",
),
),
],
options={
"ordering": ["-start_at"],
},
),
migrations.CreateModel(
name="Organization",
fields=[
(
"id",
models.TextField(
help_text="The unique Twitch identifier for the organization.", primary_key=True, serialize=False, verbose_name="Organization ID"
),
),
("name", models.TextField(db_index=True, help_text="Display name of the organization.", unique=True, verbose_name="Name")),
("added_at", models.DateTimeField(auto_now_add=True, db_index=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.")),
],
options={
"ordering": ["name"],
"indexes": [models.Index(fields=["name"], name="twitch_orga_name_febe72_idx")],
},
),
migrations.AddField(
model_name="game",
name="owner",
field=models.ForeignKey(
blank=True,
help_text="The organization that owns this game.",
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="games",
to="twitch.organization",
verbose_name="Organization",
),
),
migrations.CreateModel(
name="TimeBasedDrop",
fields=[
("id", models.TextField(help_text="Unique Twitch identifier for the time-based drop.", primary_key=True, serialize=False)),
("name", models.TextField(db_index=True, help_text="Name of the time-based drop.")),
(
"required_minutes_watched",
models.PositiveIntegerField(blank=True, db_index=True, help_text="Minutes required to watch before earning this drop.", null=True),
),
("required_subs", models.PositiveIntegerField(default=0, help_text="Number of subscriptions required to unlock this drop.")),
("start_at", models.DateTimeField(blank=True, db_index=True, help_text="Datetime when this drop becomes available.", null=True)),
("end_at", models.DateTimeField(blank=True, db_index=True, help_text="Datetime when this drop expires.", null=True)),
("added_at", models.DateTimeField(auto_now_add=True, db_index=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.")),
(
"benefits",
models.ManyToManyField(
help_text="Benefits unlocked by this drop.", related_name="drops", through="twitch.DropBenefitEdge", to="twitch.dropbenefit"
),
),
(
"campaign",
models.ForeignKey(
help_text="The campaign this drop belongs to.",
on_delete=django.db.models.deletion.CASCADE,
related_name="time_based_drops",
to="twitch.dropcampaign",
),
),
],
options={
"ordering": ["start_at"],
},
),
migrations.AddField(
model_name="dropbenefitedge",
name="drop",
field=models.ForeignKey(
help_text="The time-based drop in this relationship.", on_delete=django.db.models.deletion.CASCADE, to="twitch.timebaseddrop"
),
),
migrations.CreateModel(
name="TwitchGameData",
fields=[
("id", models.TextField(primary_key=True, serialize=False, verbose_name="Twitch Game ID")),
("name", models.TextField(blank=True, db_index=True, default="", verbose_name="Name")),
(
"box_art_url",
models.URLField(
blank=True,
default="",
help_text="URL template with {width}x{height} placeholders for the box art image.",
max_length=500,
verbose_name="Box art URL",
),
),
("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.")),
(
"game",
models.ForeignKey(
blank=True,
help_text="Optional link to the local Game record for this Twitch game.",
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="twitch_game_data",
to="twitch.game",
verbose_name="Game",
),
),
],
options={
"ordering": ["name"],
},
),
migrations.AddIndex(
model_name='dropcampaign',
index=models.Index(fields=['name'], name='twitch_drop_name_3b70b3_idx'),
model_name="dropcampaign",
index=models.Index(fields=["name"], name="twitch_drop_name_3b70b3_idx"),
),
migrations.AddIndex(
model_name='dropcampaign',
index=models.Index(fields=['start_at', 'end_at'], name='twitch_drop_start_a_6e5fb6_idx'),
model_name="dropcampaign",
index=models.Index(fields=["start_at", "end_at"], name="twitch_drop_start_a_6e5fb6_idx"),
),
migrations.AddIndex(
model_name='dropcampaign',
index=models.Index(fields=['game'], name='twitch_drop_game_id_868e70_idx'),
model_name="dropcampaign",
index=models.Index(
condition=models.Q(("end_at__isnull", False), ("start_at__isnull", False)), fields=["start_at", "end_at"], name="campaign_active_partial_idx"
),
),
migrations.AddConstraint(
model_name="dropcampaign",
constraint=models.CheckConstraint(
condition=models.Q(("start_at__isnull", True), ("end_at__isnull", True), ("end_at__gt", models.F("start_at")), _connector="OR"),
name="campaign_valid_date_range",
),
),
migrations.AddIndex(
model_name='dropcampaign',
index=models.Index(fields=['owner'], name='twitch_drop_owner_i_37241d_idx'),
model_name="game",
index=models.Index(fields=["display_name"], name="twitch_game_display_a35ba3_idx"),
),
migrations.AddIndex(
model_name='dropbenefit',
index=models.Index(fields=['name'], name='twitch_drop_name_7125ff_idx'),
model_name="game",
index=models.Index(fields=["name"], name="twitch_game_name_c92c15_idx"),
),
migrations.AddIndex(
model_name='dropbenefit',
index=models.Index(fields=['created_at'], name='twitch_drop_created_a3563e_idx'),
model_name="game",
index=models.Index(fields=["slug"], name="twitch_game_slug_a02d3c_idx"),
),
migrations.AddIndex(
model_name='dropbenefit',
index=models.Index(fields=['distribution_type'], name='twitch_drop_distrib_08b224_idx'),
model_name="game",
index=models.Index(condition=models.Q(("owner__isnull", False)), fields=["owner"], name="game_owner_partial_idx"),
),
migrations.AddIndex(
model_name='dropbenefit',
index=models.Index(fields=['game'], name='twitch_drop_game_id_a9209e_idx'),
model_name="timebaseddrop",
index=models.Index(fields=["name"], name="twitch_time_name_47c0f4_idx"),
),
migrations.AddIndex(
model_name='dropbenefit',
index=models.Index(fields=['owner_organization'], name='twitch_drop_owner_o_45b4cc_idx'),
model_name="timebaseddrop",
index=models.Index(fields=["start_at", "end_at"], name="twitch_time_start_a_c481f1_idx"),
),
migrations.AddIndex(
model_name='timebaseddrop',
index=models.Index(fields=['name'], name='twitch_time_name_47c0f4_idx'),
model_name="timebaseddrop",
index=models.Index(fields=["required_minutes_watched"], name="twitch_time_require_82c30c_idx"),
),
migrations.AddIndex(
model_name='timebaseddrop',
index=models.Index(fields=['start_at', 'end_at'], name='twitch_time_start_a_c481f1_idx'),
model_name="timebaseddrop",
index=models.Index(fields=["campaign", "start_at", "required_minutes_watched"], name="twitch_time_campaig_4cc3b7_idx"),
),
migrations.AddConstraint(
model_name="timebaseddrop",
constraint=models.CheckConstraint(
condition=models.Q(("start_at__isnull", True), ("end_at__isnull", True), ("end_at__gt", models.F("start_at")), _connector="OR"),
name="drop_valid_date_range",
),
),
migrations.AddConstraint(
model_name="timebaseddrop",
constraint=models.CheckConstraint(
condition=models.Q(("required_minutes_watched__isnull", True), ("required_minutes_watched__gte", 0), _connector="OR"),
name="drop_positive_minutes",
),
),
migrations.AddIndex(
model_name='timebaseddrop',
index=models.Index(fields=['campaign'], name='twitch_time_campaig_bbe349_idx'),
model_name="dropbenefitedge",
index=models.Index(fields=["drop", "benefit"], name="twitch_drop_drop_id_5a574c_idx"),
),
migrations.AddConstraint(
model_name="dropbenefitedge",
constraint=models.UniqueConstraint(fields=("drop", "benefit"), name="unique_drop_benefit"),
),
migrations.AddIndex(
model_name='timebaseddrop',
index=models.Index(fields=['required_minutes_watched'], name='twitch_time_require_82c30c_idx'),
),
migrations.AddIndex(
model_name='dropbenefitedge',
index=models.Index(fields=['drop', 'benefit'], name='twitch_drop_drop_id_5a574c_idx'),
),
migrations.AlterUniqueTogether(
name='dropbenefitedge',
unique_together={('drop', 'benefit')},
model_name="twitchgamedata",
index=models.Index(fields=["name"], name="twitch_twit_name_5dda5f_idx"),
),
]

View file

@ -1,31 +0,0 @@
# Generated by Django 5.2.5 on 2025-08-07 02:35
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('twitch', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='game',
name='name',
field=models.TextField(blank=True, db_index=True, default=''),
),
migrations.AlterField(
model_name='game',
name='display_name',
field=models.TextField(blank=True, db_index=True, default=''),
),
migrations.AddIndex(
model_name='game',
index=models.Index(fields=['name'], name='twitch_game_name_c92c15_idx'),
),
migrations.AddIndex(
model_name='game',
index=models.Index(fields=['box_art'], name='twitch_game_box_art_498a89_idx'),
),
]

View file

@ -1,28 +0,0 @@
# Generated by Django 5.2.5 on 2025-08-10 20:11
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('twitch', '0002_game_name_alter_game_display_name_and_more'),
]
operations = [
migrations.AlterField(
model_name='dropbenefit',
name='created_at',
field=models.DateTimeField(db_index=True, null=True),
),
migrations.AlterField(
model_name='dropbenefit',
name='distribution_type',
field=models.TextField(blank=True, db_index=True, default=''),
),
migrations.AlterField(
model_name='dropbenefit',
name='name',
field=models.TextField(blank=True, db_index=True, default='N/A'),
),
]

View file

@ -1,23 +0,0 @@
# Generated by Django 5.2.5 on 2025-08-10 20:49
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('twitch', '0003_alter_dropbenefit_created_at_and_more'),
]
operations = [
migrations.AlterField(
model_name='dropcampaign',
name='end_at',
field=models.DateTimeField(db_index=True, null=True),
),
migrations.AlterField(
model_name='dropcampaign',
name='start_at',
field=models.DateTimeField(db_index=True, null=True),
),
]

View file

@ -1,28 +0,0 @@
# Generated by Django 5.2.5 on 2025-08-10 20:50
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('twitch', '0004_alter_dropcampaign_end_at_and_more'),
]
operations = [
migrations.AlterField(
model_name='timebaseddrop',
name='end_at',
field=models.DateTimeField(db_index=True, null=True),
),
migrations.AlterField(
model_name='timebaseddrop',
name='required_minutes_watched',
field=models.PositiveIntegerField(db_index=True, null=True),
),
migrations.AlterField(
model_name='timebaseddrop',
name='start_at',
field=models.DateTimeField(db_index=True, null=True),
),
]

View file

@ -1,272 +0,0 @@
# Generated by Django 5.2.5 on 2025-09-01 17:01
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('twitch', '0005_alter_timebaseddrop_end_at_and_more'),
]
operations = [
migrations.AlterModelOptions(
name='dropbenefit',
options={'ordering': ['-created_at']},
),
migrations.AlterModelOptions(
name='dropcampaign',
options={'ordering': ['-start_at']},
),
migrations.AlterModelOptions(
name='game',
options={'ordering': ['display_name']},
),
migrations.AlterModelOptions(
name='organization',
options={'ordering': ['name']},
),
migrations.AlterModelOptions(
name='timebaseddrop',
options={'ordering': ['start_at']},
),
migrations.RemoveIndex(
model_name='dropbenefit',
name='twitch_drop_game_id_a9209e_idx',
),
migrations.RemoveIndex(
model_name='dropbenefit',
name='twitch_drop_owner_o_45b4cc_idx',
),
migrations.RemoveIndex(
model_name='dropcampaign',
name='twitch_drop_game_id_868e70_idx',
),
migrations.RemoveIndex(
model_name='dropcampaign',
name='twitch_drop_owner_i_37241d_idx',
),
migrations.RemoveIndex(
model_name='game',
name='twitch_game_box_art_498a89_idx',
),
migrations.RemoveIndex(
model_name='timebaseddrop',
name='twitch_time_campaig_bbe349_idx',
),
migrations.AlterUniqueTogether(
name='dropbenefitedge',
unique_together=set(),
),
migrations.RemoveField(
model_name='dropbenefit',
name='game',
),
migrations.RemoveField(
model_name='dropbenefit',
name='owner_organization',
),
migrations.RemoveField(
model_name='dropcampaign',
name='owner',
),
migrations.AddField(
model_name='game',
name='owner',
field=models.ForeignKey(blank=True, help_text='The organization that owns this game.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='games', to='twitch.organization', verbose_name='Organization'),
),
migrations.AlterField(
model_name='dropbenefit',
name='created_at',
field=models.DateTimeField(db_index=True, help_text='Timestamp when the benefit was created. This is from Twitch API and not auto-generated.', null=True),
),
migrations.AlterField(
model_name='dropbenefit',
name='distribution_type',
field=models.CharField(blank=True, db_index=True, default='', help_text='Type of distribution for this benefit.', max_length=50),
),
migrations.AlterField(
model_name='dropbenefit',
name='entitlement_limit',
field=models.PositiveIntegerField(default=1, help_text='Maximum number of times this benefit can be earned.'),
),
migrations.AlterField(
model_name='dropbenefit',
name='id',
field=models.CharField(help_text='Unique Twitch identifier for the benefit.', max_length=64, primary_key=True, serialize=False),
),
migrations.AlterField(
model_name='dropbenefit',
name='image_asset_url',
field=models.URLField(blank=True, default='', help_text="URL to the benefit's image asset.", max_length=500),
),
migrations.AlterField(
model_name='dropbenefit',
name='is_ios_available',
field=models.BooleanField(default=False, help_text='Whether the benefit is available on iOS.'),
),
migrations.AlterField(
model_name='dropbenefit',
name='name',
field=models.CharField(blank=True, db_index=True, default='N/A', help_text='Name of the drop benefit.', max_length=255),
),
migrations.AlterField(
model_name='dropbenefitedge',
name='benefit',
field=models.ForeignKey(help_text='The benefit in this relationship.', on_delete=django.db.models.deletion.CASCADE, to='twitch.dropbenefit'),
),
migrations.AlterField(
model_name='dropbenefitedge',
name='drop',
field=models.ForeignKey(help_text='The time-based drop in this relationship.', on_delete=django.db.models.deletion.CASCADE, to='twitch.timebaseddrop'),
),
migrations.AlterField(
model_name='dropbenefitedge',
name='entitlement_limit',
field=models.PositiveIntegerField(default=1, help_text='Max times this benefit can be claimed for this drop.'),
),
migrations.AlterField(
model_name='dropcampaign',
name='account_link_url',
field=models.URLField(blank=True, default='', help_text='URL to link a Twitch account for the campaign.', max_length=500),
),
migrations.AlterField(
model_name='dropcampaign',
name='created_at',
field=models.DateTimeField(auto_now_add=True, db_index=True, help_text='Timestamp when this campaign record was created.'),
),
migrations.AlterField(
model_name='dropcampaign',
name='description',
field=models.TextField(blank=True, help_text='Detailed description of the campaign.'),
),
migrations.AlterField(
model_name='dropcampaign',
name='details_url',
field=models.URLField(blank=True, default='', help_text='URL with campaign details.', max_length=500),
),
migrations.AlterField(
model_name='dropcampaign',
name='end_at',
field=models.DateTimeField(blank=True, db_index=True, help_text='Datetime when the campaign ends.', null=True),
),
migrations.AlterField(
model_name='dropcampaign',
name='game',
field=models.ForeignKey(help_text='Game associated with this campaign.', on_delete=django.db.models.deletion.CASCADE, related_name='drop_campaigns', to='twitch.game', verbose_name='Game'),
),
migrations.AlterField(
model_name='dropcampaign',
name='id',
field=models.CharField(help_text='Unique Twitch identifier for the campaign.', max_length=255, primary_key=True, serialize=False),
),
migrations.AlterField(
model_name='dropcampaign',
name='image_url',
field=models.URLField(blank=True, default='', help_text='URL to an image representing the campaign.', max_length=500),
),
migrations.AlterField(
model_name='dropcampaign',
name='is_account_connected',
field=models.BooleanField(default=False, help_text='Indicates if the user account is linked.'),
),
migrations.AlterField(
model_name='dropcampaign',
name='name',
field=models.CharField(db_index=True, help_text='Name of the drop campaign.', max_length=255),
),
migrations.AlterField(
model_name='dropcampaign',
name='start_at',
field=models.DateTimeField(blank=True, db_index=True, help_text='Datetime when the campaign starts.', null=True),
),
migrations.AlterField(
model_name='dropcampaign',
name='updated_at',
field=models.DateTimeField(auto_now=True, help_text='Timestamp when this campaign record was last updated.'),
),
migrations.AlterField(
model_name='game',
name='box_art',
field=models.URLField(blank=True, default='', max_length=500, verbose_name='Box art URL'),
),
migrations.AlterField(
model_name='game',
name='display_name',
field=models.CharField(blank=True, db_index=True, default='', max_length=255, verbose_name='Display name'),
),
migrations.AlterField(
model_name='game',
name='id',
field=models.CharField(max_length=64, primary_key=True, serialize=False, verbose_name='Game ID'),
),
migrations.AlterField(
model_name='game',
name='name',
field=models.CharField(blank=True, db_index=True, default='', max_length=255, verbose_name='Name'),
),
migrations.AlterField(
model_name='game',
name='slug',
field=models.CharField(blank=True, db_index=True, default='', help_text='Short unique identifier for the game.', max_length=200, verbose_name='Slug'),
),
migrations.AlterField(
model_name='organization',
name='id',
field=models.CharField(help_text='The unique Twitch identifier for the organization.', max_length=255, primary_key=True, serialize=False, verbose_name='Organization ID'),
),
migrations.AlterField(
model_name='organization',
name='name',
field=models.CharField(db_index=True, help_text='Display name of the organization.', max_length=255, unique=True, verbose_name='Name'),
),
migrations.AlterField(
model_name='timebaseddrop',
name='benefits',
field=models.ManyToManyField(help_text='Benefits unlocked by this drop.', related_name='drops', through='twitch.DropBenefitEdge', to='twitch.dropbenefit'),
),
migrations.AlterField(
model_name='timebaseddrop',
name='campaign',
field=models.ForeignKey(help_text='The campaign this drop belongs to.', on_delete=django.db.models.deletion.CASCADE, related_name='time_based_drops', to='twitch.dropcampaign'),
),
migrations.AlterField(
model_name='timebaseddrop',
name='end_at',
field=models.DateTimeField(blank=True, db_index=True, help_text='Datetime when this drop expires.', null=True),
),
migrations.AlterField(
model_name='timebaseddrop',
name='id',
field=models.CharField(help_text='Unique Twitch identifier for the time-based drop.', max_length=64, primary_key=True, serialize=False),
),
migrations.AlterField(
model_name='timebaseddrop',
name='name',
field=models.CharField(db_index=True, help_text='Name of the time-based drop.', max_length=255),
),
migrations.AlterField(
model_name='timebaseddrop',
name='required_minutes_watched',
field=models.PositiveIntegerField(blank=True, db_index=True, help_text='Minutes required to watch before earning this drop.', null=True),
),
migrations.AlterField(
model_name='timebaseddrop',
name='required_subs',
field=models.PositiveIntegerField(default=0, help_text='Number of subscriptions required to unlock this drop.'),
),
migrations.AlterField(
model_name='timebaseddrop',
name='start_at',
field=models.DateTimeField(blank=True, db_index=True, help_text='Datetime when this drop becomes available.', null=True),
),
migrations.AddConstraint(
model_name='dropbenefitedge',
constraint=models.UniqueConstraint(fields=('drop', 'benefit'), name='unique_drop_benefit'),
),
migrations.AddConstraint(
model_name='game',
constraint=models.UniqueConstraint(fields=('slug',), name='unique_game_slug'),
),
]

View file

@ -1,17 +0,0 @@
# Generated by Django 5.2.5 on 2025-09-01 17:06
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('twitch', '0006_alter_dropbenefit_options_alter_dropcampaign_options_and_more'),
]
operations = [
migrations.RemoveConstraint(
model_name='game',
name='unique_game_slug',
),
]

View file

@ -1,85 +0,0 @@
# Generated by Django 5.2.5 on 2025-09-04 21:00
import django.utils.timezone
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('twitch', '0007_remove_game_unique_game_slug'),
]
operations = [
migrations.RenameField(
model_name='dropcampaign',
old_name='created_at',
new_name='added_at',
),
migrations.AddField(
model_name='dropbenefit',
name='added_at',
field=models.DateTimeField(auto_now_add=True, db_index=True, default=django.utils.timezone.now, help_text='Timestamp when this benefit record was created.'),
preserve_default=False,
),
migrations.AddField(
model_name='dropbenefit',
name='updated_at',
field=models.DateTimeField(auto_now=True, help_text='Timestamp when this benefit record was last updated.'),
),
migrations.AddField(
model_name='dropbenefitedge',
name='added_at',
field=models.DateTimeField(auto_now_add=True, db_index=True, default=django.utils.timezone.now, help_text='Timestamp when this drop-benefit edge was created.'),
preserve_default=False,
),
migrations.AddField(
model_name='dropbenefitedge',
name='updated_at',
field=models.DateTimeField(auto_now=True, help_text='Timestamp when this drop-benefit edge was last updated.'),
),
migrations.AddField(
model_name='game',
name='added_at',
field=models.DateTimeField(auto_now_add=True, db_index=True, default=django.utils.timezone.now, help_text='Timestamp when this game record was created.'),
preserve_default=False,
),
migrations.AddField(
model_name='game',
name='updated_at',
field=models.DateTimeField(auto_now=True, help_text='Timestamp when this game record was last updated.'),
),
migrations.AddField(
model_name='notificationsubscription',
name='added_at',
field=models.DateTimeField(auto_now_add=True, db_index=True, default=django.utils.timezone.now),
preserve_default=False,
),
migrations.AddField(
model_name='notificationsubscription',
name='updated_at',
field=models.DateTimeField(auto_now=True),
),
migrations.AddField(
model_name='organization',
name='added_at',
field=models.DateTimeField(auto_now_add=True, db_index=True, default=django.utils.timezone.now, help_text='Timestamp when this organization record was created.'),
preserve_default=False,
),
migrations.AddField(
model_name='organization',
name='updated_at',
field=models.DateTimeField(auto_now=True, help_text='Timestamp when this organization record was last updated.'),
),
migrations.AddField(
model_name='timebaseddrop',
name='added_at',
field=models.DateTimeField(auto_now_add=True, db_index=True, default=django.utils.timezone.now, help_text='Timestamp when this time-based drop record was created.'),
preserve_default=False,
),
migrations.AddField(
model_name='timebaseddrop',
name='updated_at',
field=models.DateTimeField(auto_now=True, help_text='Timestamp when this time-based drop record was last updated.'),
),
]

View file

@ -1,52 +0,0 @@
# Generated by Django 5.2.5 on 2025-09-04 22:22
import django.contrib.postgres.indexes
import django.contrib.postgres.search
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('twitch', '0008_rename_created_at_dropcampaign_added_at_and_more'),
]
operations = [
migrations.AddField(
model_name='dropcampaign',
name='search_vector',
field=django.contrib.postgres.search.SearchVectorField(blank=True, null=True),
),
migrations.AddIndex(
model_name='dropbenefit',
index=models.Index(condition=models.Q(('is_ios_available', True)), fields=['is_ios_available'], name='benefit_ios_available_idx'),
),
migrations.AddIndex(
model_name='dropcampaign',
index=django.contrib.postgres.indexes.GinIndex(fields=['search_vector'], name='campaign_search_vector_idx'),
),
migrations.AddIndex(
model_name='dropcampaign',
index=models.Index(condition=models.Q(('end_at__isnull', False), ('start_at__isnull', False)), fields=['start_at', 'end_at'], name='campaign_active_partial_idx'),
),
migrations.AddIndex(
model_name='game',
index=models.Index(condition=models.Q(('owner__isnull', False)), fields=['owner'], name='game_owner_partial_idx'),
),
migrations.AddIndex(
model_name='timebaseddrop',
index=models.Index(fields=['campaign', 'start_at', 'required_minutes_watched'], name='twitch_time_campaig_4cc3b7_idx'),
),
migrations.AddConstraint(
model_name='dropcampaign',
constraint=models.CheckConstraint(condition=models.Q(('start_at__isnull', True), ('end_at__isnull', True), ('end_at__gt', models.F('start_at')), _connector='OR'), name='campaign_valid_date_range'),
),
migrations.AddConstraint(
model_name='timebaseddrop',
constraint=models.CheckConstraint(condition=models.Q(('start_at__isnull', True), ('end_at__isnull', True), ('end_at__gt', models.F('start_at')), _connector='OR'), name='drop_valid_date_range'),
),
migrations.AddConstraint(
model_name='timebaseddrop',
constraint=models.CheckConstraint(condition=models.Q(('required_minutes_watched__isnull', True), ('required_minutes_watched__gt', 0), _connector='OR'), name='drop_positive_minutes'),
),
]

View file

@ -1,28 +0,0 @@
# Generated by Django 5.2.5 on 2025-09-04 22:53
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('twitch', '0009_postgresql_optimizations_fixed'),
]
operations = [
migrations.AlterField(
model_name='dropbenefit',
name='id',
field=models.CharField(help_text='Unique Twitch identifier for the benefit.', max_length=255, primary_key=True, serialize=False),
),
migrations.AlterField(
model_name='game',
name='id',
field=models.CharField(max_length=255, primary_key=True, serialize=False, verbose_name='Game ID'),
),
migrations.AlterField(
model_name='timebaseddrop',
name='id',
field=models.CharField(help_text='Unique Twitch identifier for the time-based drop.', max_length=255, primary_key=True, serialize=False),
),
]

View file

@ -1,21 +0,0 @@
# Generated by Django 5.2.5 on 2025-09-04 23:03
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('twitch', '0010_alter_dropbenefit_id_alter_game_id_and_more'),
]
operations = [
migrations.RemoveConstraint(
model_name='timebaseddrop',
name='drop_positive_minutes',
),
migrations.AddConstraint(
model_name='timebaseddrop',
constraint=models.CheckConstraint(condition=models.Q(('required_minutes_watched__isnull', True), ('required_minutes_watched__gte', 0), _connector='OR'), name='drop_positive_minutes'),
),
]

View file

@ -1,51 +0,0 @@
# Generated by Django 5.2.5 on 2025-09-05 11:36
import django.contrib.postgres.indexes
import django.contrib.postgres.search
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('twitch', '0011_remove_timebaseddrop_drop_positive_minutes_and_more'),
]
operations = [
migrations.AddField(
model_name='dropbenefit',
name='search_vector',
field=django.contrib.postgres.search.SearchVectorField(blank=True, null=True),
),
migrations.AddField(
model_name='game',
name='search_vector',
field=django.contrib.postgres.search.SearchVectorField(blank=True, null=True),
),
migrations.AddField(
model_name='organization',
name='search_vector',
field=django.contrib.postgres.search.SearchVectorField(blank=True, null=True),
),
migrations.AddField(
model_name='timebaseddrop',
name='search_vector',
field=django.contrib.postgres.search.SearchVectorField(blank=True, null=True),
),
migrations.AddIndex(
model_name='dropbenefit',
index=django.contrib.postgres.indexes.GinIndex(fields=['search_vector'], name='benefit_search_vector_idx'),
),
migrations.AddIndex(
model_name='game',
index=django.contrib.postgres.indexes.GinIndex(fields=['search_vector'], name='game_search_vector_idx'),
),
migrations.AddIndex(
model_name='organization',
index=django.contrib.postgres.indexes.GinIndex(fields=['search_vector'], name='org_search_vector_idx'),
),
migrations.AddIndex(
model_name='timebaseddrop',
index=django.contrib.postgres.indexes.GinIndex(fields=['search_vector'], name='drop_search_vector_idx'),
),
]

View file

@ -1,37 +0,0 @@
# Generated by Django 5.2.5 on 2025-09-08 17:08
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('twitch', '0012_dropbenefit_search_vector_game_search_vector_and_more'),
]
operations = [
migrations.AddField(
model_name='dropcampaign',
name='allow_is_enabled',
field=models.BooleanField(default=True, help_text='Whether the campaign allows participation.'),
),
migrations.CreateModel(
name='Channel',
fields=[
('id', models.CharField(help_text='The unique Twitch identifier for the channel.', max_length=255, primary_key=True, serialize=False, verbose_name='Channel ID')),
('name', models.CharField(db_index=True, help_text='The lowercase username of the channel.', max_length=255, verbose_name='Username')),
('display_name', models.CharField(db_index=True, help_text='The display name of the channel (with proper capitalization).', max_length=255, verbose_name='Display Name')),
('added_at', models.DateTimeField(auto_now_add=True, db_index=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.')),
],
options={
'ordering': ['display_name'],
'indexes': [models.Index(fields=['name'], name='twitch_chan_name_15d566_idx'), models.Index(fields=['display_name'], name='twitch_chan_display_2bf213_idx')],
},
),
migrations.AddField(
model_name='dropcampaign',
name='allow_channels',
field=models.ManyToManyField(blank=True, help_text='Channels that are allowed to participate in this campaign.', related_name='allowed_campaigns', to='twitch.channel'),
),
]

View file

@ -1,102 +0,0 @@
# Generated by Django 5.2.6 on 2025-09-12 22:03
import django.db.models.manager
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('twitch', '0013_dropcampaign_allow_is_enabled_channel_and_more'),
]
operations = [
migrations.AlterModelOptions(
name='channel',
options={'base_manager_name': 'prefetch_manager', 'ordering': ['display_name']},
),
migrations.AlterModelOptions(
name='dropbenefit',
options={'base_manager_name': 'prefetch_manager', 'ordering': ['-created_at']},
),
migrations.AlterModelOptions(
name='dropbenefitedge',
options={'base_manager_name': 'prefetch_manager'},
),
migrations.AlterModelOptions(
name='dropcampaign',
options={'base_manager_name': 'prefetch_manager', 'ordering': ['-start_at']},
),
migrations.AlterModelOptions(
name='game',
options={'base_manager_name': 'prefetch_manager', 'ordering': ['display_name']},
),
migrations.AlterModelOptions(
name='notificationsubscription',
options={'base_manager_name': 'prefetch_manager'},
),
migrations.AlterModelOptions(
name='organization',
options={'base_manager_name': 'prefetch_manager', 'ordering': ['name']},
),
migrations.AlterModelOptions(
name='timebaseddrop',
options={'base_manager_name': 'prefetch_manager', 'ordering': ['start_at']},
),
migrations.AlterModelManagers(
name='channel',
managers=[
('objects', django.db.models.manager.Manager()),
('prefetch_manager', django.db.models.manager.Manager()),
],
),
migrations.AlterModelManagers(
name='dropbenefit',
managers=[
('objects', django.db.models.manager.Manager()),
('prefetch_manager', django.db.models.manager.Manager()),
],
),
migrations.AlterModelManagers(
name='dropbenefitedge',
managers=[
('objects', django.db.models.manager.Manager()),
('prefetch_manager', django.db.models.manager.Manager()),
],
),
migrations.AlterModelManagers(
name='dropcampaign',
managers=[
('objects', django.db.models.manager.Manager()),
('prefetch_manager', django.db.models.manager.Manager()),
],
),
migrations.AlterModelManagers(
name='game',
managers=[
('objects', django.db.models.manager.Manager()),
('prefetch_manager', django.db.models.manager.Manager()),
],
),
migrations.AlterModelManagers(
name='notificationsubscription',
managers=[
('objects', django.db.models.manager.Manager()),
('prefetch_manager', django.db.models.manager.Manager()),
],
),
migrations.AlterModelManagers(
name='organization',
managers=[
('objects', django.db.models.manager.Manager()),
('prefetch_manager', django.db.models.manager.Manager()),
],
),
migrations.AlterModelManagers(
name='timebaseddrop',
managers=[
('objects', django.db.models.manager.Manager()),
('prefetch_manager', django.db.models.manager.Manager()),
],
),
]

View file

@ -1,57 +0,0 @@
# Generated by Django 5.2.6 on 2025-09-12 22:18
import auto_prefetch
import django.db.models.deletion
from django.conf import settings
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('twitch', '0014_alter_channel_options_alter_dropbenefit_options_and_more'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AlterField(
model_name='dropbenefitedge',
name='benefit',
field=auto_prefetch.ForeignKey(help_text='The benefit in this relationship.', on_delete=django.db.models.deletion.CASCADE, to='twitch.dropbenefit'),
),
migrations.AlterField(
model_name='dropbenefitedge',
name='drop',
field=auto_prefetch.ForeignKey(help_text='The time-based drop in this relationship.', on_delete=django.db.models.deletion.CASCADE, to='twitch.timebaseddrop'),
),
migrations.AlterField(
model_name='dropcampaign',
name='game',
field=auto_prefetch.ForeignKey(help_text='Game associated with this campaign.', on_delete=django.db.models.deletion.CASCADE, related_name='drop_campaigns', to='twitch.game', verbose_name='Game'),
),
migrations.AlterField(
model_name='game',
name='owner',
field=auto_prefetch.ForeignKey(blank=True, help_text='The organization that owns this game.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='games', to='twitch.organization', verbose_name='Organization'),
),
migrations.AlterField(
model_name='notificationsubscription',
name='game',
field=auto_prefetch.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='twitch.game'),
),
migrations.AlterField(
model_name='notificationsubscription',
name='organization',
field=auto_prefetch.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='twitch.organization'),
),
migrations.AlterField(
model_name='notificationsubscription',
name='user',
field=auto_prefetch.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL),
),
migrations.AlterField(
model_name='timebaseddrop',
name='campaign',
field=auto_prefetch.ForeignKey(help_text='The campaign this drop belongs to.', on_delete=django.db.models.deletion.CASCADE, related_name='time_based_drops', to='twitch.dropcampaign'),
),
]

View file

@ -1,29 +0,0 @@
from __future__ import annotations
from django.db import migrations, models
class Migration(migrations.Migration):
"""Add local image FileFields to models for caching Twitch images."""
dependencies = [
("twitch", "0015_alter_dropbenefitedge_benefit_and_more"),
]
operations = [
migrations.AddField(
model_name="game",
name="box_art_file",
field=models.FileField(blank=True, null=True, upload_to="games/box_art/"),
),
migrations.AddField(
model_name="dropcampaign",
name="image_file",
field=models.FileField(blank=True, null=True, upload_to="campaigns/images/"),
),
migrations.AddField(
model_name="dropbenefit",
name="image_file",
field=models.FileField(blank=True, null=True, upload_to="benefits/images/"),
),
]

View file

@ -1,28 +0,0 @@
# Generated by Django 5.2.6 on 2025-09-13 00:49
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('twitch', '0016_add_local_image_fields'),
]
operations = [
migrations.AlterField(
model_name='dropbenefit',
name='image_file',
field=models.FileField(blank=True, help_text='Locally cached benefit image served from this site.', null=True, upload_to='benefits/images/'),
),
migrations.AlterField(
model_name='dropcampaign',
name='image_file',
field=models.FileField(blank=True, help_text='Locally cached campaign image served from this site.', null=True, upload_to='campaigns/images/'),
),
migrations.AlterField(
model_name='game',
name='box_art_file',
field=models.FileField(blank=True, help_text='Locally cached box art image served from this site.', null=True, upload_to='games/box_art/'),
),
]

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"

View file

@ -25,10 +25,8 @@ urlpatterns: list[URLPattern] = [
path("games/", views.GamesGridView.as_view(), name="game_list"),
path("games/list/", views.GamesListView.as_view(), name="game_list_simple"),
path("games/<str:pk>/", views.GameDetailView.as_view(), name="game_detail"),
path("games/<str:game_id>/subscribe/", views.subscribe_game_notifications, name="subscribe_notifications"),
path("organizations/", views.OrgListView.as_view(), name="org_list"),
path("organizations/<str:pk>/", views.OrgDetailView.as_view(), name="organization_detail"),
path("organizations/<str:org_id>/subscribe/", views.subscribe_org_notifications, name="subscribe_org_notifications"),
path("channels/", views.ChannelListView.as_view(), name="channel_list"),
path("channels/<str:pk>/", views.ChannelDetailView.as_view(), name="channel_detail"),
path("rss/organizations/", OrganizationFeed(), name="organization_feed"),

View file

@ -1,3 +0,0 @@
from __future__ import annotations
# Utility package for twitch app

View file

@ -1,97 +0,0 @@
from __future__ import annotations
import hashlib
import logging
import mimetypes
import re
from pathlib import Path
from urllib.parse import urlparse
from urllib.request import Request, urlopen
from django.conf import settings
logger: logging.Logger = logging.getLogger(__name__)
def _sanitize_filename(name: str) -> str:
"""Return a filesystem-safe filename."""
name = re.sub(r"[^A-Za-z0-9._-]", "_", name)
return name[:150] or "file"
def _guess_extension(url: str, content_type: str | None) -> str:
"""Guess a file extension from URL or content-type.
Args:
url: Source URL.
content_type: Optional content type from HTTP response.
Returns:
File extension including dot, like ".png".
"""
parsed = urlparse(url)
ext = Path(parsed.path).suffix.lower()
if ext in {".jpg", ".jpeg", ".png", ".gif", ".webp"}:
return ext
if content_type:
guessed = mimetypes.guess_extension(content_type.split(";")[0].strip())
if guessed:
return guessed
return ".bin"
def cache_remote_image(url: str, subdir: str, *, timeout: float = 10.0) -> str | None:
"""Download a remote image and save it under MEDIA_ROOT, returning storage path.
The file name is the SHA256 of the content to de-duplicate downloads.
Args:
url: Remote image URL.
subdir: Sub-directory under MEDIA_ROOT to store the file.
timeout: Network timeout in seconds.
Returns:
Relative storage path (under MEDIA_ROOT) suitable for assigning to FileField.name,
or None if the operation failed.
"""
url = (url or "").strip()
if not url or not url.startswith(("http://", "https://")):
return None
try:
# Enforce allowed schemes at runtime too
parsed = urlparse(url)
if parsed.scheme not in {"http", "https"}:
return None
req = Request(url, headers={"User-Agent": "TTVDrops/1.0"}) # noqa: S310
# nosec: B310 - urlopen allowed because scheme is validated (http/https only)
with urlopen(req, timeout=timeout) as resp: # noqa: S310
content: bytes = resp.read()
content_type = resp.headers.get("Content-Type")
except OSError as exc:
logger.debug("Failed to download image %s: %s", url, exc)
return None
if not content:
return None
sha = hashlib.sha256(content).hexdigest()
ext = _guess_extension(url, content_type)
# Shard into two-level directories by hash for scalability
shard1, shard2 = sha[:2], sha[2:4]
media_subdir = Path(subdir) / shard1 / shard2
target_dir: Path = Path(settings.MEDIA_ROOT) / media_subdir
target_dir.mkdir(parents=True, exist_ok=True)
filename = f"{sha}{ext}"
storage_rel_path = str(media_subdir / _sanitize_filename(filename)).replace("\\", "/")
storage_abs_path = Path(settings.MEDIA_ROOT) / storage_rel_path
if not storage_abs_path.exists():
try:
storage_abs_path.write_bytes(content)
except OSError as exc:
logger.debug("Failed to write image %s: %s", storage_abs_path, exc)
return None
return storage_rel_path

View file

@ -6,28 +6,24 @@ import logging
from collections import OrderedDict, defaultdict
from typing import TYPE_CHECKING, Any
from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.contrib.postgres.search import SearchQuery, SearchRank, SearchVector
from django.core.serializers import serialize
from django.db.models import Count, F, Prefetch, Q
from django.db.models.functions import Trim
from django.db.models.query import QuerySet
from django.http import HttpRequest, HttpResponse
from django.http.response import HttpResponseRedirect
from django.shortcuts import get_object_or_404, redirect, render
from django.shortcuts import render
from django.utils import timezone
from django.views.generic import DetailView, ListView
from pygments import highlight
from pygments.formatters import HtmlFormatter
from pygments.lexers.data import JsonLexer
from twitch.models import Channel, DropBenefit, DropCampaign, Game, NotificationSubscription, Organization, TimeBasedDrop
from twitch.models import Channel, DropBenefit, DropCampaign, Game, Organization, TimeBasedDrop
if TYPE_CHECKING:
from django.db.models import QuerySet
from django.http import HttpRequest, HttpResponse
from django.http.response import HttpResponseRedirect
logger: logging.Logger = logging.getLogger(__name__)
@ -125,12 +121,6 @@ class OrgDetailView(DetailView):
context = super().get_context_data(**kwargs)
organization: Organization = self.object
user = self.request.user
if not user.is_authenticated:
subscription: NotificationSubscription | None = None
else:
subscription = NotificationSubscription.objects.filter(user=user, organization=organization).first()
games: QuerySet[Game, Game] = organization.games.all() # pyright: ignore[reportAttributeAccessIssue]
serialized_org = serialize(
@ -152,7 +142,6 @@ class OrgDetailView(DetailView):
pretty_org_data = json.dumps(org_data[0], indent=4)
context.update({
"subscription": subscription,
"games": games,
"org_data": pretty_org_data,
})
@ -439,12 +428,6 @@ class GameDetailView(DetailView):
context: dict[str, Any] = super().get_context_data(**kwargs)
game: Game = self.get_object()
user = self.request.user
if not user.is_authenticated:
subscription: NotificationSubscription | None = None
else:
subscription = NotificationSubscription.objects.filter(user=user, game=game).first()
now: datetime.datetime = timezone.now()
all_campaigns: QuerySet[DropCampaign, DropCampaign] = (
DropCampaign.objects.filter(game=game)
@ -514,7 +497,6 @@ class GameDetailView(DetailView):
"active_campaigns": active_campaigns,
"upcoming_campaigns": upcoming_campaigns,
"expired_campaigns": expired_campaigns,
"subscription": subscription,
"owner": game.owner,
"now": now,
"game_data": format_and_color_json(game_data[0]),
@ -558,7 +540,7 @@ def dashboard(request: HttpRequest) -> HttpResponse:
if game_id not in campaigns_by_org_game[org_id]["games"]:
campaigns_by_org_game[org_id]["games"][game_id] = {
"name": game_name,
"box_art": campaign.game.box_art_best_url,
"box_art": campaign.game.box_art,
"campaigns": [],
}
@ -583,7 +565,6 @@ def dashboard(request: HttpRequest) -> HttpResponse:
# MARK: /debug/
@login_required
def debug_view(request: HttpRequest) -> HttpResponse:
"""Debug view showing potentially broken or inconsistent data.
@ -644,95 +625,6 @@ def debug_view(request: HttpRequest) -> HttpResponse:
return render(request, "twitch/debug.html", context)
# MARK: /games/<pk>/subscribe/
@login_required
def subscribe_game_notifications(request: HttpRequest, game_id: str) -> HttpResponseRedirect:
"""Update Game notification for a user.
Args:
request: The HTTP request.
game_id: The game we are updating.
Returns:
Redirect back to the twitch:game_detail.
"""
game: Game = get_object_or_404(Game, pk=game_id)
if request.method == "POST":
notify_found = bool(request.POST.get("notify_found"))
notify_live = bool(request.POST.get("notify_live"))
subscription, created = NotificationSubscription.objects.get_or_create(user=request.user, game=game)
changes = []
if not created:
if subscription.notify_found != notify_found:
changes.append(f"{'Enabled' if notify_found else 'Disabled'} notification when drop is found")
if subscription.notify_live != notify_live:
changes.append(f"{'Enabled' if notify_live else 'Disabled'} notification when drop is farmable")
subscription.notify_found = notify_found
subscription.notify_live = notify_live
subscription.save()
if created:
message = f"You have subscribed to notifications for {game.display_name}"
elif changes:
message = "\n".join(changes)
else:
message = ""
messages.success(request, message)
return redirect("twitch:game_detail", pk=game.id)
messages.warning(request, "Only POST is available for this view.")
return redirect("twitch:game_detail", pk=game.id)
# MARK: /organizations/<pk>/subscribe/
@login_required
def subscribe_org_notifications(request: HttpRequest, org_id: str) -> HttpResponseRedirect:
"""Update Organization notification for a user.
Args:
request: The HTTP request.
org_id: The org we are updating.
Returns:
Redirect back to the twitch:organization_detail.
"""
organization: Organization = get_object_or_404(Organization, pk=org_id)
if request.method == "POST":
notify_found = bool(request.POST.get("notify_found"))
notify_live = bool(request.POST.get("notify_live"))
subscription, created = NotificationSubscription.objects.get_or_create(user=request.user, organization=organization)
changes = []
if not created:
if subscription.notify_found != notify_found:
changes.append(f"{'Enabled' if notify_found else 'Disabled'} notification when drop is found")
if subscription.notify_live != notify_live:
changes.append(f"{'Enabled' if notify_live else 'Disabled'} notification when drop is farmable")
subscription.notify_found = notify_found
subscription.notify_live = notify_live
subscription.save()
if created:
message = f"You have subscribed to notifications for this {organization.name}"
elif changes:
message = "\n".join(changes)
else:
message = ""
messages.success(request, message)
return redirect("twitch:organization_detail", pk=organization.id)
messages.warning(request, "Only POST is available for this view.")
return redirect("twitch:organization_detail", pk=organization.id)
# MARK: /games/list/
class GamesListView(GamesGridView):
"""List view for games in simple list format."""