- {% if item.game.box_art_best_url %}
-

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
diff --git a/twitch/management/commands/update_search_vectors.py b/twitch/management/commands/update_search_vectors.py
deleted file mode 100644
index ad71127..0000000
--- a/twitch/management/commands/update_search_vectors.py
+++ /dev/null
@@ -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."))
diff --git a/twitch/migrations/0001_initial.py b/twitch/migrations/0001_initial.py
index 55a2a7a..47bf8c8 100644
--- a/twitch/migrations/0001_initial.py
+++ b/twitch/migrations/0001_initial.py
@@ -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"),
),
]
diff --git a/twitch/migrations/0002_game_name_alter_game_display_name_and_more.py b/twitch/migrations/0002_game_name_alter_game_display_name_and_more.py
deleted file mode 100644
index a4075e7..0000000
--- a/twitch/migrations/0002_game_name_alter_game_display_name_and_more.py
+++ /dev/null
@@ -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'),
- ),
- ]
diff --git a/twitch/migrations/0003_alter_dropbenefit_created_at_and_more.py b/twitch/migrations/0003_alter_dropbenefit_created_at_and_more.py
deleted file mode 100644
index 8aed1ac..0000000
--- a/twitch/migrations/0003_alter_dropbenefit_created_at_and_more.py
+++ /dev/null
@@ -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'),
- ),
- ]
diff --git a/twitch/migrations/0004_alter_dropcampaign_end_at_and_more.py b/twitch/migrations/0004_alter_dropcampaign_end_at_and_more.py
deleted file mode 100644
index ee944da..0000000
--- a/twitch/migrations/0004_alter_dropcampaign_end_at_and_more.py
+++ /dev/null
@@ -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),
- ),
- ]
diff --git a/twitch/migrations/0005_alter_timebaseddrop_end_at_and_more.py b/twitch/migrations/0005_alter_timebaseddrop_end_at_and_more.py
deleted file mode 100644
index a9e8e0f..0000000
--- a/twitch/migrations/0005_alter_timebaseddrop_end_at_and_more.py
+++ /dev/null
@@ -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),
- ),
- ]
diff --git a/twitch/migrations/0006_alter_dropbenefit_options_alter_dropcampaign_options_and_more.py b/twitch/migrations/0006_alter_dropbenefit_options_alter_dropcampaign_options_and_more.py
deleted file mode 100644
index 3e4e9da..0000000
--- a/twitch/migrations/0006_alter_dropbenefit_options_alter_dropcampaign_options_and_more.py
+++ /dev/null
@@ -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'),
- ),
- ]
diff --git a/twitch/migrations/0007_remove_game_unique_game_slug.py b/twitch/migrations/0007_remove_game_unique_game_slug.py
deleted file mode 100644
index 14ca989..0000000
--- a/twitch/migrations/0007_remove_game_unique_game_slug.py
+++ /dev/null
@@ -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',
- ),
- ]
diff --git a/twitch/migrations/0008_rename_created_at_dropcampaign_added_at_and_more.py b/twitch/migrations/0008_rename_created_at_dropcampaign_added_at_and_more.py
deleted file mode 100644
index 76dffaa..0000000
--- a/twitch/migrations/0008_rename_created_at_dropcampaign_added_at_and_more.py
+++ /dev/null
@@ -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.'),
- ),
- ]
diff --git a/twitch/migrations/0009_postgresql_optimizations_fixed.py b/twitch/migrations/0009_postgresql_optimizations_fixed.py
deleted file mode 100644
index 26e4b98..0000000
--- a/twitch/migrations/0009_postgresql_optimizations_fixed.py
+++ /dev/null
@@ -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'),
- ),
- ]
diff --git a/twitch/migrations/0010_alter_dropbenefit_id_alter_game_id_and_more.py b/twitch/migrations/0010_alter_dropbenefit_id_alter_game_id_and_more.py
deleted file mode 100644
index 58f33eb..0000000
--- a/twitch/migrations/0010_alter_dropbenefit_id_alter_game_id_and_more.py
+++ /dev/null
@@ -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),
- ),
- ]
diff --git a/twitch/migrations/0011_remove_timebaseddrop_drop_positive_minutes_and_more.py b/twitch/migrations/0011_remove_timebaseddrop_drop_positive_minutes_and_more.py
deleted file mode 100644
index ff323d4..0000000
--- a/twitch/migrations/0011_remove_timebaseddrop_drop_positive_minutes_and_more.py
+++ /dev/null
@@ -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'),
- ),
- ]
diff --git a/twitch/migrations/0012_dropbenefit_search_vector_game_search_vector_and_more.py b/twitch/migrations/0012_dropbenefit_search_vector_game_search_vector_and_more.py
deleted file mode 100644
index f7c7fc8..0000000
--- a/twitch/migrations/0012_dropbenefit_search_vector_game_search_vector_and_more.py
+++ /dev/null
@@ -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'),
- ),
- ]
diff --git a/twitch/migrations/0013_dropcampaign_allow_is_enabled_channel_and_more.py b/twitch/migrations/0013_dropcampaign_allow_is_enabled_channel_and_more.py
deleted file mode 100644
index aeb5d59..0000000
--- a/twitch/migrations/0013_dropcampaign_allow_is_enabled_channel_and_more.py
+++ /dev/null
@@ -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'),
- ),
- ]
diff --git a/twitch/migrations/0014_alter_channel_options_alter_dropbenefit_options_and_more.py b/twitch/migrations/0014_alter_channel_options_alter_dropbenefit_options_and_more.py
deleted file mode 100644
index a7757d1..0000000
--- a/twitch/migrations/0014_alter_channel_options_alter_dropbenefit_options_and_more.py
+++ /dev/null
@@ -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()),
- ],
- ),
- ]
diff --git a/twitch/migrations/0015_alter_dropbenefitedge_benefit_and_more.py b/twitch/migrations/0015_alter_dropbenefitedge_benefit_and_more.py
deleted file mode 100644
index 3b25f0f..0000000
--- a/twitch/migrations/0015_alter_dropbenefitedge_benefit_and_more.py
+++ /dev/null
@@ -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'),
- ),
- ]
diff --git a/twitch/migrations/0016_add_local_image_fields.py b/twitch/migrations/0016_add_local_image_fields.py
deleted file mode 100644
index a596af9..0000000
--- a/twitch/migrations/0016_add_local_image_fields.py
+++ /dev/null
@@ -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/"),
- ),
- ]
diff --git a/twitch/migrations/0017_alter_dropbenefit_image_file_and_more.py b/twitch/migrations/0017_alter_dropbenefit_image_file_and_more.py
deleted file mode 100644
index 7981713..0000000
--- a/twitch/migrations/0017_alter_dropbenefit_image_file_and_more.py
+++ /dev/null
@@ -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/'),
- ),
- ]
diff --git a/twitch/models.py b/twitch/models.py
index 6564692..be6217c 100644
--- a/twitch/models.py
+++ b/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"
diff --git a/twitch/urls.py b/twitch/urls.py
index ff88a9d..bdc855b 100644
--- a/twitch/urls.py
+++ b/twitch/urls.py
@@ -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/
/", views.GameDetailView.as_view(), name="game_detail"),
- path("games//subscribe/", views.subscribe_game_notifications, name="subscribe_notifications"),
path("organizations/", views.OrgListView.as_view(), name="org_list"),
path("organizations//", views.OrgDetailView.as_view(), name="organization_detail"),
- path("organizations//subscribe/", views.subscribe_org_notifications, name="subscribe_org_notifications"),
path("channels/", views.ChannelListView.as_view(), name="channel_list"),
path("channels//", views.ChannelDetailView.as_view(), name="channel_detail"),
path("rss/organizations/", OrganizationFeed(), name="organization_feed"),
diff --git a/twitch/utils/__init__.py b/twitch/utils/__init__.py
deleted file mode 100644
index efaab6d..0000000
--- a/twitch/utils/__init__.py
+++ /dev/null
@@ -1,3 +0,0 @@
-from __future__ import annotations
-
-# Utility package for twitch app
diff --git a/twitch/utils/images.py b/twitch/utils/images.py
deleted file mode 100644
index 8df1204..0000000
--- a/twitch/utils/images.py
+++ /dev/null
@@ -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
diff --git a/twitch/views.py b/twitch/views.py
index 4a99f65..264f778 100644
--- a/twitch/views.py
+++ b/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//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//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."""