Remove bloat
This commit is contained in:
parent
011c617328
commit
715cbf4bf0
51 changed files with 691 additions and 3032 deletions
|
|
@ -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")
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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."))
|
||||
|
|
@ -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"),
|
||||
),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
),
|
||||
]
|
||||
|
|
@ -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'),
|
||||
),
|
||||
]
|
||||
|
|
@ -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),
|
||||
),
|
||||
]
|
||||
|
|
@ -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),
|
||||
),
|
||||
]
|
||||
|
|
@ -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'),
|
||||
),
|
||||
]
|
||||
|
|
@ -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',
|
||||
),
|
||||
]
|
||||
|
|
@ -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.'),
|
||||
),
|
||||
]
|
||||
|
|
@ -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'),
|
||||
),
|
||||
]
|
||||
|
|
@ -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),
|
||||
),
|
||||
]
|
||||
|
|
@ -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'),
|
||||
),
|
||||
]
|
||||
|
|
@ -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'),
|
||||
),
|
||||
]
|
||||
|
|
@ -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'),
|
||||
),
|
||||
]
|
||||
|
|
@ -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()),
|
||||
],
|
||||
),
|
||||
]
|
||||
|
|
@ -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'),
|
||||
),
|
||||
]
|
||||
|
|
@ -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/"),
|
||||
),
|
||||
]
|
||||
|
|
@ -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/'),
|
||||
),
|
||||
]
|
||||
213
twitch/models.py
213
twitch/models.py
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -1,3 +0,0 @@
|
|||
from __future__ import annotations
|
||||
|
||||
# Utility package for twitch app
|
||||
|
|
@ -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
|
||||
114
twitch/views.py
114
twitch/views.py
|
|
@ -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."""
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue