ttvdrops/twitch/management/commands/import_twitch_drops_api.py
Joakim Hellsén 3535d7d2dd
All checks were successful
Deploy to Server / deploy (push) Successful in 29s
Scrape SunkwiBOT/twitch-drops-api for data
2026-06-14 17:35:24 +02:00

1637 lines
59 KiB
Python

"""Import drop and reward campaign data from the SunkwiBOT/twitch-drops-api repo.
This command fetches ``drops.json`` and ``rewards.json`` from the
`SunkwiBOT/twitch-drops-api <https://github.com/SunkwiBOT/twitch-drops-api>`_
GitHub repository and imports the data into the local database.
Usage::
# Current data (latest commit)
uv run python manage.py import_twitch_drops_api
uv run python manage.py import_twitch_drops_api --source "SunkwiBOT/twitch-drops-api"
# Historical data (all commits, oldest-first — captures expired campaigns)
uv run python manage.py import_twitch_drops_api --historical
uv run python manage.py import_twitch_drops_api --historical --max-commits 50
uv run python manage.py import_twitch_drops_api --historical --git-dir /tmp/mirror
The ``--source`` argument controls the value stored in the
``data_source`` field on imported records (defaults to
``"SunkwiBOT/twitch-drops-api"``).
"""
from __future__ import annotations
import contextlib
import hashlib
import json
import os
import pathlib
import subprocess # noqa: S404
import tempfile
from typing import TYPE_CHECKING
from typing import Any
import httpx
from colorama import Fore
from colorama import Style
from colorama import init as colorama_init
from django.core.management.base import BaseCommand
from django.core.management.base import CommandError
from pydantic import ValidationError
from twitch.models import Channel
from twitch.models import DropBenefit
from twitch.models import DropBenefitEdge
from twitch.models import DropCampaign
from twitch.models import Game
from twitch.models import Organization
from twitch.models import RewardCampaign
from twitch.models import TimeBasedDrop
from twitch.schemas import SunkwiBotDropGroupSchema
from twitch.schemas import SunkwiBotRewardCampaignSchema
from twitch.utils import parse_date
if TYPE_CHECKING:
from django.core.management.base import CommandParser
from django.db.models import Model
from twitch.schemas import SunkwiBotBenefitEdgeSchema
from twitch.schemas import SunkwiBotDropCampaignACLSchema
from twitch.schemas import SunkwiBotRewardSchema
from twitch.schemas import SunkwiBotTimeBasedDropSchema
# Default raw GitHub URLs for the SunkwiBOT/twitch-drops-api repo
DEFAULT_DROPS_URL = (
"https://raw.githubusercontent.com/SunkwiBOT/twitch-drops-api/main/drops.json"
)
DEFAULT_REWARDS_URL = (
"https://raw.githubusercontent.com/SunkwiBOT/twitch-drops-api/main/rewards.json"
)
GIT_CLONE_URL = "https://github.com/SunkwiBOT/twitch-drops-api.git"
HTTP_TIMEOUT = 60 # seconds
class Command(BaseCommand):
"""Import drop and reward campaigns from the SunkwiBOT twitch-drops-api repo."""
help = (
"Import drop and reward campaigns from "
"SunkwiBOT/twitch-drops-api (GitHub raw JSON)."
)
requires_migrations_checks = True
def add_arguments(self, parser: CommandParser) -> None:
"""Populate the command with arguments."""
parser.add_argument(
"--source",
type=str,
default="SunkwiBOT/twitch-drops-api",
help=(
"Value to store in the data_source field. "
"Default: SunkwiBOT/twitch-drops-api"
),
)
parser.add_argument(
"--drops-url",
type=str,
default=DEFAULT_DROPS_URL,
help=f"URL to fetch drops.json from. Default: {DEFAULT_DROPS_URL}",
)
parser.add_argument(
"--rewards-url",
type=str,
default=DEFAULT_REWARDS_URL,
help=f"URL to fetch rewards.json from. Default: {DEFAULT_REWARDS_URL}",
)
parser.add_argument(
"--drops-only",
action="store_true",
help="Only import drops.json (skip rewards.json).",
)
parser.add_argument(
"--rewards-only",
action="store_true",
help="Only import rewards.json (skip drops.json).",
)
parser.add_argument(
"--crash-on-error",
action="store_true",
help="Crash on first validation/import error instead of continuing.",
)
parser.add_argument(
"--verbose",
action="store_true",
help="Print per-record success messages.",
)
parser.add_argument(
"--report",
action="store_true",
help=(
"Compare SunkwiBOT data against the database without importing. "
"Prints a summary of what's new, what exists, and field-level "
"differences for overlapping campaigns."
),
)
parser.add_argument(
"--merge",
action="store_true",
help=(
"For existing campaigns, fill in empty/blank fields with "
"SunkwiBOT data without overwriting populated ones. The "
"original data_source is preserved."
),
)
parser.add_argument(
"--update-existing",
action="store_true",
help=(
"By default, campaigns already in the database are skipped "
"(existing data_source is preserved). Use this flag to "
"update existing campaigns with the incoming data instead."
),
)
parser.add_argument(
"--historical",
action="store_true",
help=(
"Iterate through all git commits (oldest first) to capture "
"expired campaigns that are no longer in the latest JSON. "
"Implies --skip-latest unless --no-skip-latest is given."
),
)
parser.add_argument(
"--no-skip-latest",
action="store_true",
help="When used with --historical, also process the latest commit.",
)
parser.add_argument(
"--max-commits",
type=int,
default=0,
help="Maximum number of *unique-content* commits to process (0 = all).",
)
parser.add_argument(
"--git-dir",
type=str,
default="",
help=(
"Path to a local bare clone of the repo. If not set, a temp "
"clone is created and cleaned up."
),
)
parser.add_argument(
"--from-file",
type=str,
default="",
help=(
"Path to a pre-extracted JSON file (e.g. drops_merged.json "
"from tools/extract_historical_drops.py). Import this file "
"directly instead of fetching from git history or URLs. "
"Much faster than --historical for repeated imports."
),
)
def handle(self, *args: Any, **options: Any) -> None: # noqa: ANN401, ARG002, PLR0914, PLR0915
"""Main entry point for the command.
Raises:
CommandError: If the --from-file path does not exist or is invalid.
"""
colorama_init(autoreset=True)
source: str = options["source"]
verbose: bool = options["verbose"]
crash_on_error: bool = options["crash_on_error"]
report_mode: bool = options.get("report", False)
merge_mode: bool = options.get("merge", False)
skip_existing: bool = not options.get("update_existing")
historical: bool = options.get("historical", False)
# Caches for the duration of the command to avoid redundant DB lookups
self._org_cache: dict[str, Organization] = {}
self._game_cache: dict[str, Game] = {}
self._channel_cache: dict[str, Channel] = {}
self._benefit_cache: dict[str, DropBenefit] = {}
# Merge mode: fill empty fields on existing campaigns instead of skipping
self._merge_mode: bool = merge_mode
if merge_mode:
skip_existing = False
drops_count = 0
rewards_count = 0
errors: list[str] = []
if report_mode:
self._generate_report(
drops_url=options["drops_url"],
rewards_url=options["rewards_url"],
crash_on_error=crash_on_error,
)
return
from_file: str = options.get("from_file", "") or ""
if from_file:
# Import from a pre-extracted merged JSON file (fast path).
# The file is produced by tools/extract_historical_drops.py.
from_file_path = pathlib.Path(from_file)
if not from_file_path.exists():
msg = f"File not found: {from_file}"
raise CommandError(msg)
with from_file_path.open(encoding="utf-8") as f:
raw_data = json.load(f)
if not isinstance(raw_data, list):
msg = f"Expected a JSON array in {from_file}, got {type(raw_data).__name__}"
raise CommandError(msg)
# Detect file type by content structure
is_drops = any("gameId" in item for item in raw_data)
if is_drops:
self.stdout.write(
f"Importing drops from {from_file} ({len(raw_data)} groups) …",
)
drops_count = self._parse_and_import_drops(
raw_data=raw_data,
source=source,
verbose=verbose,
crash_on_error=crash_on_error,
label=from_file_path.name,
skip_existing=skip_existing,
)
else:
self.stdout.write(
f"Importing rewards from {from_file} ({len(raw_data)} campaigns) …",
)
rewards_count = self._parse_and_import_rewards(
raw_data=raw_data,
source=source,
verbose=verbose,
crash_on_error=crash_on_error,
label=from_file_path.name,
skip_existing=skip_existing,
)
elif historical:
# Historical mode: clone repo and iterate commits
hist_drops, hist_rewards, hist_errors = self._process_historical(
source=source,
verbose=verbose,
crash_on_error=crash_on_error,
skip_latest=not options["no_skip_latest"],
max_commits=options["max_commits"],
skip_existing=skip_existing,
git_dir=options["git_dir"],
)
drops_count += hist_drops
rewards_count += hist_rewards
errors.extend(hist_errors)
else:
# Normal mode: fetch latest from URLs
if not options["rewards_only"]:
try:
drops_count += self._import_drops(
url=options["drops_url"],
source=source,
verbose=verbose,
crash_on_error=crash_on_error,
skip_existing=skip_existing,
)
except Exception as exc: # noqa: BLE001
msg = f"Failed to import drops.json: {exc}"
self.stderr.write(self.style.ERROR(msg))
errors.append(msg)
if not options["drops_only"]:
try:
rewards_count += self._import_rewards(
url=options["rewards_url"],
source=source,
verbose=verbose,
crash_on_error=crash_on_error,
skip_existing=skip_existing,
)
except Exception as exc: # noqa: BLE001
msg = f"Failed to import rewards.json: {exc}"
self.stderr.write(self.style.ERROR(msg))
errors.append(msg)
# Summary
self.stdout.write("\n" + "=" * 50)
if historical:
self.stdout.write(
self.style.SUCCESS(
f"✓ Historical drops imported/updated: {drops_count}",
),
)
self.stdout.write(
self.style.SUCCESS(
f"✓ Historical rewards imported/updated: {rewards_count}",
),
)
else:
self.stdout.write(
self.style.SUCCESS(
f"✓ Drop campaigns imported/updated: {drops_count}",
),
)
self.stdout.write(
self.style.SUCCESS(
f"✓ Reward campaigns imported/updated: {rewards_count}",
),
)
if errors:
for err in errors:
self.stdout.write(self.style.ERROR(f"{err}"))
self.stdout.write("=" * 50)
# ------------------------------------------------------------------
# Historical import via git clone
# ------------------------------------------------------------------
def _process_historical( # noqa: PLR0913
self,
source: str,
*,
verbose: bool,
crash_on_error: bool,
skip_latest: bool,
max_commits: int,
git_dir: str,
skip_existing: bool = False,
) -> tuple[int, int, list[str]]:
"""Clone the repo and iterate commits oldest-first to import historical data.
Skips commits whose file content is byte-identical to the previously
processed commit (deduplicates bot spam).
Args:
source: data_source label.
verbose: Print per-record messages.
crash_on_error: Raise on first error.
skip_latest: If True, skip the most recent commit (already imported
by the non-historical path).
max_commits: Max unique-content commits to process (0 = all).
git_dir: Path to existing bare clone, or '' to create a temp one.
skip_existing: If True, skip campaigns already in the database.
Returns:
Tuple of (drops_count, rewards_count, error_messages).
"""
own_clone = not git_dir
if own_clone:
git_dir_obj = tempfile.mkdtemp(prefix="twitch-drops-api-")
git_dir = git_dir_obj
self.stdout.write(f"Cloning {GIT_CLONE_URL}")
self._git(
git_dir,
"clone",
"--filter=blob:none",
"--bare",
GIT_CLONE_URL,
git_dir,
)
else:
self.stdout.write(f"Using existing clone at {git_dir}")
drops_count = 0
rewards_count = 0
errors: list[str] = []
try:
drops_count += self._import_historical_file(
git_dir=git_dir,
file_path="drops.json",
source=source,
verbose=verbose,
crash_on_error=crash_on_error,
skip_latest=skip_latest,
max_commits=max_commits,
skip_existing=skip_existing,
)
rewards_count += self._import_historical_file(
git_dir=git_dir,
file_path="rewards.json",
source=source,
verbose=verbose,
crash_on_error=crash_on_error,
skip_latest=skip_latest,
max_commits=max_commits,
skip_existing=skip_existing,
)
except Exception as exc: # noqa: BLE001
errors.append(str(exc))
finally:
if own_clone:
self._rmtree(git_dir)
return drops_count, rewards_count, errors
def _import_historical_file( # noqa: PLR0913
self,
git_dir: str,
file_path: str,
source: str,
*,
verbose: bool,
crash_on_error: bool,
skip_latest: bool,
max_commits: int,
skip_existing: bool,
) -> int:
"""Import all historical versions of a single file from git history.
Args:
git_dir: Path to bare clone.
file_path: Path within repo (e.g. "drops.json").
source: data_source label.
verbose: Print per-record messages.
crash_on_error: Raise on first error.
skip_latest: If True, skip the newest commit.
max_commits: Max unique-content commits (0 = all).
skip_existing: If True, skip campaigns already in the database.
Returns:
Number of campaigns imported/updated.
"""
label = file_path.replace(".json", "")
self.stdout.write(f"\nCollecting commits for {file_path}")
# Get all commits that touched this file, oldest first
log_output = self._git(
git_dir,
"log",
"--all",
"--format=%H %ct",
"--follow",
"--reverse",
"--",
file_path,
)
lines = [ln.strip() for ln in log_output.strip().split("\n") if ln.strip()]
total = len(lines)
self.stdout.write(f" Found {total} total commits for {file_path}")
if skip_latest and total > 0:
lines = lines[:-1]
self.stdout.write(f" Skipping latest commit, processing {len(lines)}")
if max_commits and len(lines) > max_commits:
lines = lines[:max_commits]
self.stdout.write(f" Limited to {max_commits} commits")
if not lines:
self.stdout.write(f" No commits to process for {file_path}")
return 0
total_campaigns = 0
seen_hashes: set[str] = set()
processed = 0
skipped_identical = 0
for line in lines:
parts = line.split(None, 1)
if not parts:
continue
sha = parts[0]
# Get file content at this commit
try:
content = self._git(
git_dir,
"show",
f"{sha}:{file_path}",
check=True,
)
except subprocess.CalledProcessError:
# File didn't exist at this commit yet
continue
# Skip if content is byte-identical to last processed
content_hash = hashlib.sha256(content.encode()).hexdigest()
if content_hash in seen_hashes:
skipped_identical += 1
continue
seen_hashes.add(content_hash)
try:
data = json.loads(content)
except json.JSONDecodeError:
if verbose:
self.stdout.write(
f"{Fore.YELLOW}{Style.RESET_ALL} {sha[:7]}: "
f"invalid JSON, skipping",
)
continue
if not isinstance(data, list):
continue
short_sha = sha[:7]
self.stdout.write(
f" [{processed + 1}/{len(lines)}] Importing {label} @ {short_sha}",
)
if file_path == "drops.json":
count = self._parse_and_import_drops(
raw_data=data,
source=source,
verbose=verbose,
crash_on_error=crash_on_error,
label=f"{label}@{short_sha}",
skip_existing=skip_existing,
)
else:
count = self._parse_and_import_rewards(
raw_data=data,
source=source,
verbose=verbose,
crash_on_error=crash_on_error,
label=f"{label}@{short_sha}",
skip_existing=skip_existing,
)
total_campaigns += count
processed += 1
self.stdout.write(
f" Done: {processed} versions processed, "
f"{skipped_identical} skipped (identical content), "
f"{total_campaigns} total campaigns imported for {file_path}",
)
return total_campaigns
# ------------------------------------------------------------------
# Git / filesystem helpers
# ------------------------------------------------------------------
def _git(self, git_dir: str, *args: str, check: bool = True) -> str:
"""Run a git command in the bare clone directory.
Args:
git_dir: Path to the bare clone.
*args: Git sub-command and arguments.
check: If True, raise on non-zero exit.
Returns:
Stdout of the git command.
"""
cmd = ["git", "--git-dir", git_dir, *args]
result = subprocess.run(cmd, capture_output=True, text=True, check=check) # noqa: S603
return result.stdout
def _rmtree(self, path: str) -> None:
"""Recursively remove a directory tree.
Args:
path: Path to remove.
"""
try:
for root_str, dirs, files in os.walk(path, topdown=False):
root_path = pathlib.Path(root_str)
for name in files:
(root_path / name).unlink()
for name in dirs:
(root_path / name).rmdir()
pathlib.Path(path).rmdir()
except OSError:
pass
# ------------------------------------------------------------------
# drops.json import
# ------------------------------------------------------------------
def _fetch_json(self, url: str, label: str) -> list[dict[str, Any]]:
"""Fetch a JSON file from a URL and parse it into a list of dicts.
Args:
url: The URL to fetch.
label: Human-readable label for error messages.
Returns:
Parsed JSON data as a list of dicts.
Raises:
TypeError: If the response is not a JSON array.
"""
self.stdout.write(f"Fetching {label} from {url}")
with httpx.Client(timeout=HTTP_TIMEOUT, follow_redirects=True) as client:
response = client.get(url)
response.raise_for_status()
data: Any = response.json()
if not isinstance(data, list):
msg = f"Expected a JSON array from {url}, got {type(data).__name__}"
raise TypeError(msg)
return data
def _import_drops(
self,
url: str,
source: str,
*,
verbose: bool,
crash_on_error: bool,
skip_existing: bool = False,
) -> int:
"""Import drop campaigns from a drops.json URL.
Args:
url: Raw GitHub URL for drops.json.
source: Data source label to store on records.
verbose: Print per-record messages.
crash_on_error: Raise on first error instead of continuing.
skip_existing: If True, skip campaigns already in the database.
Returns:
Number of drop campaigns imported/updated.
"""
raw_data = self._fetch_json(url, "drops.json")
return self._parse_and_import_drops(
raw_data=raw_data,
source=source,
verbose=verbose,
crash_on_error=crash_on_error,
label="drops.json",
skip_existing=skip_existing,
)
@staticmethod
def _strip_typename(data: Any) -> Any: # noqa: ANN401
"""Recursively remove ``__typename`` keys from parsed JSON data.
Old commits in the repo included GraphQL ``__typename`` metadata which
the SunkwiBot schemas reject with ``extra="forbid"``. Stripping them
before validation keeps the schemas strict while still accepting
historical payloads.
Args:
data: Raw parsed JSON (dict, list, or scalar).
Returns:
Data with all ``__typename`` keys removed.
"""
if isinstance(data, dict):
return {
k: Command._strip_typename(v)
for k, v in data.items()
if k != "__typename"
}
if isinstance(data, list):
return [Command._strip_typename(item) for item in data]
return data
def _generate_report( # noqa: PLR0914, PLR0915
self,
drops_url: str,
rewards_url: str,
*,
crash_on_error: bool,
) -> None:
"""Compare SunkwiBOT data against the database without importing.
Fetches drops.json and rewards.json, validates with schemas, then
compares against the database to show:
- How many campaigns are new
- How many already exist
- Field-level differences for overlapping campaigns
- Total counts per source
Args:
drops_url: URL for drops.json.
rewards_url: URL for rewards.json.
crash_on_error: Raise on first error instead of continuing.
Raises:
ValidationError: If any item fails schema validation and crash_on_error is True.
"""
from collections import Counter # noqa: PLC0415
self.stdout.write("=" * 60)
self.stdout.write(self.style.SUCCESS("COMPARISON REPORT"))
self.stdout.write(
"Comparing SunkwiBOT data against local database…",
)
self.stdout.write("=" * 60)
# Track differences for field-level comparison
sample_limit = 5
for label, raw_url, model_cls, id_field in [ # noqa: PLR1702
("drops", drops_url, DropCampaign, "twitch_id"),
("rewards", rewards_url, RewardCampaign, "twitch_id"),
]:
self.stdout.write(f"\n--- {label.upper()} ---")
try:
raw_data = self._fetch_json(raw_url, f"{label}.json")
except Exception as exc: # noqa: BLE001
self.stderr.write(
self.style.ERROR(f" Failed to fetch {label}.json: {exc}"),
)
continue
# Validate
validated_ids: set[str] = set()
validated: list[Any] = []
groups: list[SunkwiBotDropGroupSchema] = []
if label == "drops":
for i, raw_item in enumerate(raw_data):
try:
stripped_item = self._strip_typename(raw_item)
groups.append(
SunkwiBotDropGroupSchema.model_validate(stripped_item),
)
except ValidationError:
if crash_on_error:
raise
self.stdout.write(
f" {Fore.YELLOW}{Style.RESET_ALL} "
f"item [{i}] failed validation, skipping",
)
for group in groups:
validated_ids.update(reward.twitch_id for reward in group.rewards)
else:
for i, raw_item in enumerate(raw_data):
try:
stripped_item = self._strip_typename(raw_item)
validated.append(
SunkwiBotRewardCampaignSchema.model_validate(stripped_item),
)
except ValidationError:
if crash_on_error:
raise
self.stdout.write(
f" {Fore.YELLOW}{Style.RESET_ALL} "
f"item [{i}] failed validation, skipping",
)
validated_ids.update(c.twitch_id for c in validated)
# Compare against DB
existing_ids: set[str] = set(
model_cls.objects.values_list(id_field, flat=True),
)
new_ids = validated_ids - existing_ids
overlap_ids = validated_ids & existing_ids
self.stdout.write(
f" Total in SunkwiBOT data: {len(validated_ids)}",
)
self.stdout.write(
f" Total in local database: {len(existing_ids)}",
)
self.stdout.write(
f" New (SunkwiBOT only): {len(new_ids)}",
)
self.stdout.write(
f" Overlap (both): {len(overlap_ids)}",
)
# Field-level comparison for overlapping campaigns
if overlap_ids and label == "drops":
field_diffs: list[tuple[str, str, str]] = []
fields_to_check = [
"name",
"description",
"image_url",
"start_at",
"end_at",
]
for group in groups:
for reward in group.rewards:
if reward.twitch_id not in overlap_ids:
continue
try:
db_obj = DropCampaign.objects.get(
twitch_id=reward.twitch_id,
)
except DropCampaign.DoesNotExist:
continue
for field in fields_to_check:
ours = getattr(db_obj, field, None)
theirs = getattr(reward, field, None)
# Compare string forms for dates
str_ours = str(ours) if ours is not None else ""
str_theirs = str(theirs) if theirs is not None else ""
if str_ours.strip() != str_theirs.strip():
field_diffs.append(
(field, str_ours, str_theirs),
)
if field_diffs:
diff_counter: Counter = Counter()
for diff_field, _ours_val, _theirs_val in field_diffs:
diff_counter[diff_field] += 1
self.stdout.write(
"\n Field differences (overlapping campaigns):",
)
for field, count in diff_counter.most_common():
ours_empty = sum(
1 for f, o, _ in field_diffs if f == field and not o.strip()
)
theirs_empty = sum(
1
for f, _o, t in field_diffs
if f == field and not t.strip()
)
self.stdout.write(
f" {field}: {count} campaigns differ "
f"(we have empty: {ours_empty}, "
f"they have empty: {theirs_empty})",
)
self.stdout.write(
" Use --merge to fill empty fields from SunkwiBOT data.",
)
else:
self.stdout.write(
" ✓ No field differences in overlapping campaigns.",
)
elif overlap_ids and label == "rewards":
fields_to_check = [
"name",
"brand",
"summary",
"status",
]
reward_diffs = 0
for c_id in overlap_ids:
try:
db_obj = RewardCampaign.objects.get(twitch_id=c_id)
except RewardCampaign.DoesNotExist:
continue
# Re-find the schema entry
for c_entry in validated:
if c_entry.twitch_id == c_id:
for field in fields_to_check:
ours = getattr(db_obj, field, None)
theirs = getattr(c_entry, field, None)
if str(ours or "") != str(theirs or ""):
reward_diffs += 1
break
if reward_diffs:
self.stdout.write(
f" {reward_diffs} field differences found in "
f"overlapping reward campaigns.",
)
else:
self.stdout.write(
" ✓ No field differences in overlapping reward campaigns.",
)
if new_ids:
self.stdout.write(
f"\n {Fore.GREEN}Sample new campaigns:{Style.RESET_ALL}",
)
sample_new = list(new_ids)[:5]
for sid in sample_new:
self.stdout.write(f" - {sid}")
if len(new_ids) > sample_limit:
self.stdout.write(
f" … and {len(new_ids) - sample_limit} more",
)
self.stdout.write("\n" + "=" * 60)
def _parse_and_import_drops( # noqa: PLR0913
self,
raw_data: list[dict[str, Any]],
source: str,
*,
verbose: bool,
crash_on_error: bool,
label: str = "drops.json",
skip_existing: bool = False,
) -> int:
"""Validate and import raw drops.json data (from URL or git history).
Args:
raw_data: Parsed drops.json array.
source: Data source label.
verbose: Print per-record messages.
crash_on_error: Raise on first error.
label: Human label for error messages (e.g. "drops.json@abc1234").
skip_existing: If True, skip campaigns already in the database.
Returns:
Number of drop campaigns imported/updated.
Raises:
ValidationError: If crash_on_error is True and validation fails.
"""
raw_data = self._strip_typename(raw_data)
drop_groups: list[SunkwiBotDropGroupSchema] = []
for i, item in enumerate(raw_data):
try:
group = SunkwiBotDropGroupSchema.model_validate(item)
drop_groups.append(group)
except ValidationError as e:
msg = f"{label} item [{i}] failed validation: {e}"
if crash_on_error:
raise
self.stderr.write(self.style.ERROR(msg))
continue
total_campaigns = 0
for group in drop_groups:
# Get or create the shared Game for this group
# Use the first reward's owner as the game's organization
first_owner = group.rewards[0].owner if group.rewards else None
org_obj = None
if first_owner:
org_obj = self._get_or_create_organization(
twitch_id=first_owner.twitch_id,
name=first_owner.name,
verbose=verbose,
)
game_obj = self._get_or_create_game(
twitch_id=group.game_id,
display_name=group.game_display_name,
box_art_url=group.game_box_art_url,
source=source,
verbose=verbose,
org_obj=org_obj,
)
for reward in group.rewards:
try:
self._process_sunkwibot_reward(
reward=reward,
game_obj=game_obj,
source=source,
verbose=verbose,
skip_existing=skip_existing,
)
total_campaigns += 1
except Exception as exc:
msg = (
f"Failed to import reward {reward.twitch_id} "
f"({reward.name}): {exc}"
)
if crash_on_error:
raise
self.stderr.write(self.style.ERROR(msg))
return total_campaigns
def _process_sunkwibot_reward( # noqa: PLR0915
self,
reward: SunkwiBotRewardSchema,
game_obj: Game,
source: str,
*,
verbose: bool,
skip_existing: bool = False,
) -> None:
"""Process a single reward item from drops.json into a DropCampaign.
Args:
reward: The validated reward schema from drops.json.
game_obj: The Game instance for this campaign.
source: Data source label.
verbose: Print per-record messages.
skip_existing: If True, skip if campaign already exists.
"""
# Owner organization
self._get_or_create_organization(
twitch_id=reward.owner.twitch_id,
name=reward.owner.name,
verbose=verbose,
)
# Parse dates
start_at_dt = parse_date(reward.start_at)
end_at_dt = parse_date(reward.end_at)
defaults: dict[str, Any] = {
"name": reward.name,
"description": reward.description,
"image_url": reward.image_url,
"game": game_obj,
"start_at": start_at_dt,
"end_at": end_at_dt,
"details_url": reward.details_url,
"account_link_url": reward.account_link_url,
"data_source": source,
}
existing: DropCampaign | None = DropCampaign.objects.filter(
twitch_id=reward.twitch_id,
).first()
# Merge mode: fill empty fields, never overwrite populated ones
if existing is not None and getattr(self, "_merge_mode", False):
changed = False
merge_fields: dict[str, str | object | None] = {
"description": reward.description,
"image_url": reward.image_url,
"details_url": reward.details_url,
}
for field, new_value in merge_fields.items():
old_value = getattr(existing, field, None)
if not old_value and new_value:
setattr(existing, field, new_value)
changed = True
if changed:
existing.save(update_fields=list(merge_fields.keys()))
# Track enrichment
if source not in existing.operation_names:
existing.operation_names.append(source)
existing.save(update_fields=["operation_names"])
if verbose:
action = "Merged" if changed else "Skipped (merged)"
self.stdout.write(
f"{Fore.CYAN}{Style.RESET_ALL} {action} campaign: {reward.name}",
)
# Still process time-based drops and channels for existing
if reward.time_based_drops:
for tbd_schema in reward.time_based_drops:
self._process_time_based_drop(
tbd_schema=tbd_schema,
campaign_obj=existing,
source=source,
verbose=verbose,
)
if reward.allow:
self._process_allow_channels(
allow_schema=reward.allow,
campaign_obj=existing,
verbose=verbose,
)
if not existing.is_fully_imported:
existing.is_fully_imported = True
existing.save(update_fields=["is_fully_imported"])
return
if skip_existing and existing is not None:
if verbose:
self.stdout.write(
f"{Fore.YELLOW}{Style.RESET_ALL} Skipped campaign: "
f"{reward.name} (already exists)",
)
# Still accumulate channels and time-based drops for skipped
# campaigns so historical channels are never lost.
if reward.time_based_drops:
for tbd_schema in reward.time_based_drops:
self._process_time_based_drop(
tbd_schema=tbd_schema,
campaign_obj=existing,
source=source,
verbose=verbose,
)
if reward.allow:
self._process_allow_channels(
allow_schema=reward.allow,
campaign_obj=existing,
verbose=verbose,
)
if not existing.is_fully_imported:
existing.is_fully_imported = True
existing.save(update_fields=["is_fully_imported"])
return
campaign_obj, created = DropCampaign.objects.get_or_create(
twitch_id=reward.twitch_id,
defaults=defaults,
)
if not created:
self._save_if_changed(campaign_obj, defaults)
if verbose:
action = "Created" if created else "Updated"
self.stdout.write(
f"{Fore.GREEN}{Style.RESET_ALL} {action} campaign: "
f"{reward.name} (source: {source})",
)
# Process time-based drops
if reward.time_based_drops:
for tbd_schema in reward.time_based_drops:
self._process_time_based_drop(
tbd_schema=tbd_schema,
campaign_obj=campaign_obj,
source=source,
verbose=verbose,
)
# Process allowed channels
if reward.allow:
self._process_allow_channels(
allow_schema=reward.allow,
campaign_obj=campaign_obj,
verbose=verbose,
)
# Mark as fully imported
if not campaign_obj.is_fully_imported:
campaign_obj.is_fully_imported = True
campaign_obj.save(update_fields=["is_fully_imported"])
def _process_time_based_drop(
self,
tbd_schema: SunkwiBotTimeBasedDropSchema,
campaign_obj: DropCampaign,
source: str, # noqa: ARG002
*,
verbose: bool,
) -> None:
"""Process a single TimeBasedDrop from the SunkwiBOT schema.
Args:
tbd_schema: A SunkwiBotTimeBasedDropSchema instance.
campaign_obj: The parent DropCampaign.
source: Data source label.
verbose: Print per-record messages.
"""
start_at_dt = parse_date(tbd_schema.start_at)
end_at_dt = parse_date(tbd_schema.end_at)
drop_defaults: dict[str, Any] = {
"campaign": campaign_obj,
"name": tbd_schema.name,
"required_subs": tbd_schema.required_subs,
}
if tbd_schema.required_minutes_watched is not None:
drop_defaults["required_minutes_watched"] = (
tbd_schema.required_minutes_watched
)
if start_at_dt is not None:
drop_defaults["start_at"] = start_at_dt
if end_at_dt is not None:
drop_defaults["end_at"] = end_at_dt
drop_obj, created = TimeBasedDrop.objects.get_or_create(
twitch_id=tbd_schema.twitch_id,
defaults=drop_defaults,
)
if not created:
self._save_if_changed(drop_obj, drop_defaults)
if verbose and created:
self.stdout.write(
f"{Fore.GREEN}{Style.RESET_ALL} Created TimeBasedDrop: "
f"{tbd_schema.name}",
)
# Process benefit edges
for edge_schema in tbd_schema.benefit_edges:
self._process_benefit_edge(
edge_schema=edge_schema,
drop_obj=drop_obj,
verbose=verbose,
)
def _process_benefit_edge(
self,
edge_schema: SunkwiBotBenefitEdgeSchema,
drop_obj: TimeBasedDrop,
*,
verbose: bool,
) -> None:
"""Process a benefit edge, creating/updating the benefit and edge.
Args:
edge_schema: A SunkwiBotBenefitEdgeSchema instance.
drop_obj: The parent TimeBasedDrop.
verbose: Print per-record messages.
"""
benefit_schema = edge_schema.benefit
# Get or create benefit
cache_key = benefit_schema.twitch_id
cached = self._benefit_cache.get(cache_key)
if cached is not None:
benefit_obj = cached
else:
benefit_obj = DropBenefit.objects.filter(
twitch_id=benefit_schema.twitch_id,
).first()
if benefit_obj is None:
created_at_dt = (
parse_date(benefit_schema.created_at)
if benefit_schema.created_at
else None
)
benefit_obj = DropBenefit.objects.create(
twitch_id=benefit_schema.twitch_id,
name=benefit_schema.name,
image_asset_url=benefit_schema.image_asset_url,
entitlement_limit=benefit_schema.entitlement_limit,
is_ios_available=benefit_schema.is_ios_available,
distribution_type=benefit_schema.distribution_type or "",
created_at=created_at_dt,
)
if verbose:
self.stdout.write(
f"{Fore.GREEN}{Style.RESET_ALL} Created DropBenefit: "
f"{benefit_schema.name}",
)
else:
defaults: dict[str, Any] = {
"name": benefit_schema.name,
"image_asset_url": benefit_schema.image_asset_url,
"entitlement_limit": benefit_schema.entitlement_limit,
"is_ios_available": benefit_schema.is_ios_available,
"distribution_type": benefit_schema.distribution_type or "",
}
if benefit_schema.created_at:
created_at_dt = parse_date(benefit_schema.created_at)
if created_at_dt:
defaults["created_at"] = created_at_dt
self._save_if_changed(benefit_obj, defaults)
self._benefit_cache[cache_key] = benefit_obj
# Create or update the edge
edge_defaults = {"entitlement_limit": edge_schema.entitlement_limit}
edge_obj, edge_created = DropBenefitEdge.objects.get_or_create(
drop=drop_obj,
benefit=benefit_obj,
defaults=edge_defaults,
)
if not edge_created:
self._save_if_changed(edge_obj, edge_defaults)
def _process_allow_channels(
self,
allow_schema: SunkwiBotDropCampaignACLSchema,
campaign_obj: DropCampaign,
*,
verbose: bool,
) -> None:
"""Process the allow.channels list and set on the campaign.
Args:
allow_schema: A SunkwiBotDropCampaignACLSchema instance.
campaign_obj: The DropCampaign to update.
verbose: Print per-record messages.
"""
# Update allow_is_enabled
if campaign_obj.allow_is_enabled != allow_schema.is_enabled:
campaign_obj.allow_is_enabled = allow_schema.is_enabled
campaign_obj.save(update_fields=["allow_is_enabled"])
if not allow_schema.channels:
return
channel_objects: list[Channel] = []
for ch in allow_schema.channels:
display_name: str = ch.display_name or ch.name
channel_obj, created = Channel.objects.get_or_create(
twitch_id=ch.twitch_id,
defaults={
"name": ch.name,
"display_name": display_name,
},
)
if not created:
self._save_if_changed(
channel_obj,
{"name": ch.name, "display_name": display_name},
)
if verbose and created:
self.stdout.write(
f"{Fore.GREEN}{Style.RESET_ALL} Created channel: {display_name}",
)
channel_objects.append(channel_obj)
campaign_obj.allow_channels.add(*channel_objects)
# ------------------------------------------------------------------
# rewards.json import
# ------------------------------------------------------------------
def _import_rewards(
self,
url: str,
source: str,
*,
verbose: bool,
crash_on_error: bool,
skip_existing: bool = False,
) -> int:
"""Import reward campaigns from a rewards.json URL.
Args:
url: Raw GitHub URL for rewards.json.
source: Data source label to store on records.
verbose: Print per-record messages.
crash_on_error: Raise on first error instead of continuing.
skip_existing: If True, skip campaigns already in the database.
Returns:
Number of reward campaigns imported/updated.
"""
raw_data = self._fetch_json(url, "rewards.json")
return self._parse_and_import_rewards(
raw_data=raw_data,
source=source,
verbose=verbose,
crash_on_error=crash_on_error,
label="rewards.json",
skip_existing=skip_existing,
)
def _parse_and_import_rewards( # noqa: PLR0913
self,
raw_data: list[dict[str, Any]],
source: str,
*,
verbose: bool,
crash_on_error: bool,
label: str = "rewards.json",
skip_existing: bool = False,
) -> int:
"""Validate and import raw rewards.json data (from URL or git history).
Args:
raw_data: Parsed rewards.json array.
source: Data source label.
verbose: Print per-record messages.
crash_on_error: Raise on first error.
label: Human label for error messages.
skip_existing: If True, skip campaigns already in the database.
Returns:
Number of reward campaigns imported/updated.
Raises:
ValidationError: If crash_on_error is True and validation fails.
"""
raw_data = self._strip_typename(raw_data)
validated: list[SunkwiBotRewardCampaignSchema] = []
for i, item in enumerate(raw_data):
try:
campaign = SunkwiBotRewardCampaignSchema.model_validate(item)
validated.append(campaign)
except ValidationError as e:
msg = f"{label} item [{i}] failed validation: {e}"
if crash_on_error:
raise
self.stderr.write(self.style.ERROR(msg))
continue
total = 0
for campaign in validated:
try:
self._process_reward_campaign(
campaign=campaign,
source=source,
verbose=verbose,
skip_existing=skip_existing,
)
total += 1
except Exception as exc:
msg = (
f"Failed to import reward campaign {campaign.twitch_id} "
f"({campaign.name}): {exc}"
)
if crash_on_error:
raise
self.stderr.write(self.style.ERROR(msg))
return total
def _process_reward_campaign(
self,
campaign: SunkwiBotRewardCampaignSchema,
source: str,
*,
verbose: bool,
skip_existing: bool = False,
) -> None:
"""Process a single reward campaign from rewards.json.
Args:
campaign: Validated reward campaign schema.
source: Data source label.
verbose: Print per-record messages.
skip_existing: If True, skip if campaign already exists.
"""
starts_at_dt = parse_date(campaign.starts_at)
ends_at_dt = parse_date(campaign.ends_at)
# Resolve game if present
game_obj: Game | None = None
if campaign.game and isinstance(campaign.game, dict):
game_id = campaign.game.get("id")
if game_id:
with contextlib.suppress(Game.DoesNotExist):
game_obj = Game.objects.get(twitch_id=game_id)
defaults: dict[str, Any] = {
"name": campaign.name,
"brand": campaign.brand,
"starts_at": starts_at_dt,
"ends_at": ends_at_dt,
"status": campaign.status,
"summary": campaign.summary,
"instructions": campaign.instructions,
"external_url": campaign.external_url,
"reward_value_url_param": campaign.reward_value_url_param,
"about_url": campaign.about_url,
"is_sitewide": campaign.is_sitewide,
"game": game_obj,
"image_url": campaign.image.image1x_url if campaign.image else "",
"data_source": source,
}
existing_reward: RewardCampaign | None = RewardCampaign.objects.filter(
twitch_id=campaign.twitch_id,
).first()
# Merge mode: fill empty fields, never overwrite populated ones
if existing_reward is not None and getattr(self, "_merge_mode", False):
changed = False
merge_fields = {
"summary": campaign.summary,
"instructions": campaign.instructions,
"image_url": campaign.image.image1x_url if campaign.image else "",
"about_url": campaign.about_url,
"external_url": campaign.external_url,
}
for field, new_value in merge_fields.items():
old_value = getattr(existing_reward, field, None)
if not old_value and new_value:
setattr(existing_reward, field, new_value)
changed = True
if changed:
existing_reward.save(
update_fields=list(merge_fields.keys()),
)
if verbose:
action_str = "Merged" if changed else "Skipped (merged)"
display_name = (
f"{campaign.brand}: {campaign.name}"
if campaign.brand
else campaign.name
)
self.stdout.write(
f"{Fore.CYAN}{Style.RESET_ALL} {action_str} reward "
f"campaign: {display_name}",
)
return
if skip_existing and existing_reward is not None:
if verbose:
self.stdout.write(
f"{Fore.YELLOW}{Style.RESET_ALL} Skipped reward campaign: "
f"{campaign.name} (already exists)",
)
return
reward_obj, created = RewardCampaign.objects.get_or_create(
twitch_id=campaign.twitch_id,
defaults=defaults,
)
updated = self._save_if_changed(reward_obj, defaults) if not created else True
if verbose and (created or updated):
action = "Created" if created else "Updated"
display_name = (
f"{campaign.brand}: {campaign.name}"
if campaign.brand
else campaign.name
)
self.stdout.write(
f"{Fore.GREEN}{Style.RESET_ALL} {action} reward campaign: "
f"{display_name} (source: {source})",
)
# ------------------------------------------------------------------
# Shared helpers
# ------------------------------------------------------------------
def _save_if_changed(self, obj: Model, defaults: dict[str, Any]) -> bool: # type: ignore[valid-type]
"""Save the model instance only when data actually changed.
Args:
obj: The model instance to potentially update.
defaults: Field values to apply.
Returns:
True if the object was saved, False if no changes were detected.
"""
changed_fields: list[str] = []
for field, new_value in defaults.items():
if getattr(obj, field, None) != new_value:
setattr(obj, field, new_value)
changed_fields.append(field)
if not changed_fields:
return False
obj.save(update_fields=changed_fields)
return True
def _get_or_create_organization(
self,
twitch_id: str,
name: str,
*,
verbose: bool,
) -> Organization:
"""Get or create an organization.
Args:
twitch_id: Twitch organization ID.
name: Organization name.
verbose: Print per-record messages.
Returns:
Organization instance.
"""
cached = self._org_cache.get(twitch_id)
if cached is not None:
self._save_if_changed(cached, {"name": name})
return cached
org_obj, created = Organization.objects.get_or_create(
twitch_id=twitch_id,
defaults={"name": name},
)
if not created:
self._save_if_changed(org_obj, {"name": name})
if verbose and created:
self.stdout.write(
f"{Fore.GREEN}{Style.RESET_ALL} Created organization: {name}",
)
self._org_cache[twitch_id] = org_obj
return org_obj
def _get_or_create_game( # noqa: PLR0913
self,
twitch_id: str,
display_name: str,
box_art_url: str,
source: str, # noqa: ARG002
*,
verbose: bool,
org_obj: Organization | None = None,
) -> Game:
"""Get or create a game.
Args:
twitch_id: Twitch game ID.
display_name: Game display name.
box_art_url: Box art URL.
source: Data source label.
verbose: Print per-record messages.
org_obj: Optional organization to assign as owner of the game.
Returns:
Game instance.
"""
cached = self._game_cache.get(twitch_id)
if cached is not None:
self._save_if_changed(
cached,
{
"display_name": display_name,
"box_art": box_art_url,
},
)
return cached
defaults: dict[str, Any] = {
"display_name": display_name,
"name": display_name,
"box_art": box_art_url,
}
game_obj, created = Game.objects.get_or_create(
twitch_id=twitch_id,
defaults=defaults,
)
if not created:
self._save_if_changed(
game_obj,
{
"display_name": display_name,
"name": display_name,
"box_art": box_art_url,
},
)
# Assign owner organization if provided
if (
org_obj is not None
and not game_obj.owners.filter(
pk=org_obj.pk,
).exists()
):
game_obj.owners.add(org_obj)
if verbose:
self.stdout.write(
f"{Fore.GREEN}{Style.RESET_ALL} Assigned owner "
f"{org_obj.name} to game {display_name}",
)
if verbose and created:
self.stdout.write(
f"{Fore.GREEN}{Style.RESET_ALL} Created game: {display_name}",
)
self._game_cache[twitch_id] = game_obj
return game_obj