Compare commits

..

10 Commits

Author SHA1 Message Date
abab9b359f Add custom 404 and 500 error handlers with corresponding templates
All checks were successful
Ruff / ruff (push) Successful in 10s
2025-05-02 04:28:03 +02:00
f5a874c6df Remove unused utility functions from models_utils.py 2025-05-02 03:38:11 +02:00
4e40bc032c Sort drops within campaigns by required minutes watched
All checks were successful
Ruff / ruff (push) Successful in 10s
2025-05-01 21:46:15 +02:00
ef02f7878e Group drops by campaign 2025-05-01 21:19:29 +02:00
ebed72f5fa Refactor drop display structure: replace list with table for benefits 2025-05-01 17:40:55 +02:00
14f2cdc9f9 Use Gitea for workflows; remove SECURITY.md
All checks were successful
Ruff / ruff (push) Successful in 9s
2025-05-01 17:28:35 +02:00
b1bd57bcc2 Update importer 2025-05-01 15:26:33 +02:00
73f1870431 Remove django-auto-prefetch 2025-05-01 02:47:09 +02:00
d7b31e1d42 Refactor models 2025-05-01 02:41:25 +02:00
d137ad61f0 Refactor README 2025-04-13 14:26:06 +02:00
24 changed files with 936 additions and 1612 deletions

11
.github/SECURITY.md vendored
View File

@ -1,11 +0,0 @@
# Reporting a Vulnerability
tl;dr: [open a draft security advisory](https://github.com/TheLovinator1/twitch-drop-notifier/security/advisories/new).
---
You can also email me at [tlovinator@gmail.com](mailto:tlovinator@gmail.com).
I am also available on Discord at `TheLovinator#9276`.
Thanks :-)

View File

@ -3,7 +3,7 @@ default_language_version:
repos: repos:
# A Django template formatter. # A Django template formatter.
- repo: https://github.com/adamchainz/djade-pre-commit - repo: https://github.com/adamchainz/djade-pre-commit
rev: "1.3.2" rev: "1.4.0"
hooks: hooks:
- id: djade - id: djade
args: [--target-version, "5.1"] args: [--target-version, "5.1"]
@ -43,7 +43,7 @@ repos:
# Automatically upgrade your Django project code # Automatically upgrade your Django project code
- repo: https://github.com/adamchainz/django-upgrade - repo: https://github.com/adamchainz/django-upgrade
rev: "1.22.2" rev: "1.24.0"
hooks: hooks:
- id: django-upgrade - id: django-upgrade
args: [--target-version, "5.1"] args: [--target-version, "5.1"]
@ -57,14 +57,8 @@ repos:
# An extremely fast Python linter and formatter. # An extremely fast Python linter and formatter.
- repo: https://github.com/astral-sh/ruff-pre-commit - repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.8.4 rev: v0.11.7
hooks: hooks:
- id: ruff-format - id: ruff-format
- id: ruff - id: ruff
args: [--fix] args: [--fix]
# Static checker for GitHub Actions workflow files.
- repo: https://github.com/rhysd/actionlint
rev: v1.7.4
hooks:
- id: actionlint

12
.vscode/launch.json vendored
View File

@ -19,6 +19,18 @@
], ],
"django": true, "django": true,
"justMyCode": true "justMyCode": true
},
{
"name": "Django: Import Twitch Drops",
"type": "debugpy",
"request": "launch",
"program": "${workspaceFolder}/manage.py",
"args": [
"import_twitch_drops",
"${file}"
],
"django": true,
"justMyCode": true
} }
] ]
} }

View File

@ -16,6 +16,7 @@
"createcachetable", "createcachetable",
"createsuperuser", "createsuperuser",
"djade", "djade",
"djlint",
"docstrings", "docstrings",
"dotenv", "dotenv",
"dropcampaign", "dropcampaign",
@ -43,6 +44,7 @@
"psycopg", "psycopg",
"PUID", "PUID",
"pydocstyle", "pydocstyle",
"pytest",
"pyupgrade", "pyupgrade",
"requirepass", "requirepass",
"rewardcampaign", "rewardcampaign",

View File

@ -7,38 +7,15 @@ Get notified when a new drop is available on Twitch
- [Python 3.13](https://www.python.org/downloads/) is required to run this project. - [Python 3.13](https://www.python.org/downloads/) is required to run this project.
```bash ```bash
# Create and activate a virtual environment. uv sync
python -m venv .venv
source .venv/bin/activate
# Remember to run `source .venv/bin/activate` before running the following commands:
# You will need to run this command every time you open a new terminal.
# VSCode will automatically activate the virtual environment if you have the Python extension installed.
# Install dependencies.
pip install -r requirements.txt
pip install -r requirements-dev.txt
# Run the following to get passwords for the .env file. # Run the following to get passwords for the .env file.
python -c 'from django.core.management.utils import get_random_secret_key; print(get_random_secret_key())' uv run python -c 'from django.core.management.utils import get_random_secret_key; print(get_random_secret_key())'
# Rename .env.example to .env and fill in the required values. # Commands
# Only DJANGO_SECRET_KEY is required to run the server. uv run python manage.py migrate
# EMAIL_HOST_USER, EMAIL_HOST_PASSWORD and DISCORD_WEBHOOK_URL can be left empty if not needed. uv run python manage.py createcachetable
mv .env.example .env uv run python manage.py createsuperuser
uv run python manage.py runserver
# Run the migrations. uv run pytest
python manage.py migrate
# Create cache table.
python manage.py createcachetable
# Create a superuser.
python manage.py createsuperuser
# Run the server.
python manage.py runserver
# Run the tests.
pytest
``` ```

View File

@ -2,10 +2,10 @@ from __future__ import annotations
from django.contrib import admin from django.contrib import admin
from core.models import Benefit, DropCampaign, Game, Owner, TimeBasedDrop from core.models import Benefit, DropCampaign, Game, Organization, TimeBasedDrop
admin.site.register(Game) admin.site.register(Game)
admin.site.register(Owner) admin.site.register(Organization)
admin.site.register(DropCampaign) admin.site.register(DropCampaign)
admin.site.register(TimeBasedDrop) admin.site.register(TimeBasedDrop)
admin.site.register(Benefit) admin.site.register(Benefit)

View File

@ -1,187 +0,0 @@
from __future__ import annotations
import logging
from typing import Any, Literal
from core.models import Benefit, DropCampaign, Game, Owner, TimeBasedDrop
logger: logging.Logger = logging.getLogger(__name__)
type_names = Literal["Organization", "Game", "DropCampaign", "TimeBasedDrop", "DropBenefit", "RewardCampaign", "Reward"]
def import_data(data: dict[str, Any]) -> None:
"""Import the data from the JSON object.
This looks for '__typename' with the value 'DropCampaign' in the JSON object and imports the data.
Args:
data (dict[str, Any]): The data to import.
"""
drop_campaigns: list[dict[str, Any]] = find_typename_in_json(json_obj=data, typename_to_find="DropCampaign")
for drop_campaign_json in drop_campaigns:
import_drop_campaigns(drop_campaigns=drop_campaign_json)
def import_drop_campaigns(drop_campaigns: dict[str, Any]) -> DropCampaign | None:
"""Import the drop campaigns from the data.
Args:
drop_campaigns (dict[str, Any]): The drop campaign data.
Returns:
DropCampaign | None: The drop campaign instance if created, otherwise None
"""
twitch_id: str = drop_campaigns.get("id", "")
logger.info("\tProcessing drop campaign: %s", twitch_id)
if not twitch_id:
logger.error("\tDrop campaign has no ID: %s", drop_campaigns)
return None
drop_campaign, created = DropCampaign.objects.get_or_create(twitch_id=twitch_id)
if created:
logger.info("\tCreated drop campaign: %s", drop_campaign)
owner: Owner = import_owner_data(drop_campaign=drop_campaigns)
game: Game = import_game_data(drop_campaign=drop_campaigns, owner=owner)
drop_campaign.import_json(data=drop_campaigns, game=game)
import_time_based_drops(drop_campaigns, drop_campaign)
return drop_campaign
def import_time_based_drops(drop_campaign_json: dict[str, Any], drop_campaign: DropCampaign) -> list[TimeBasedDrop]:
"""Import the time-based drops from a drop campaign.
Args:
drop_campaign_json (dict[str, Any]): The drop campaign data.
drop_campaign (DropCampaign): The drop campaign instance.
Returns:
list[TimeBasedDrop]: The imported time-based drops.
"""
imported_drops: list[TimeBasedDrop] = []
time_based_drops: list[dict[str, Any]] = find_typename_in_json(drop_campaign_json, "TimeBasedDrop")
for time_based_drop_json in time_based_drops:
time_based_drop_id: str = time_based_drop_json.get("id", "")
if not time_based_drop_id:
logger.error("\tTime-based drop has no ID: %s", time_based_drop_json)
continue
time_based_drop, created = TimeBasedDrop.objects.get_or_create(twitch_id=time_based_drop_id)
if created:
logger.info("\tCreated time-based drop: %s", time_based_drop)
time_based_drop.import_json(time_based_drop_json, drop_campaign)
import_drop_benefits(time_based_drop_json, time_based_drop)
imported_drops.append(time_based_drop)
return imported_drops
def import_drop_benefits(time_based_drop_json: dict[str, Any], time_based_drop: TimeBasedDrop) -> list[Benefit]:
"""Import the drop benefits from a time-based drop.
Args:
time_based_drop_json (dict[str, Any]): The time-based drop data.
time_based_drop (TimeBasedDrop): The time-based drop instance.
Returns:
list[Benefit]: The imported drop benefits.
"""
drop_benefits: list[Benefit] = []
benefits: list[dict[str, Any]] = find_typename_in_json(time_based_drop_json, "DropBenefit")
for benefit_json in benefits:
benefit_id: str = benefit_json.get("id", "")
if not benefit_id:
logger.error("\tBenefit has no ID: %s", benefit_json)
continue
benefit, created = Benefit.objects.get_or_create(twitch_id=benefit_id)
if created:
logger.info("\tCreated benefit: %s", benefit)
benefit.import_json(benefit_json, time_based_drop)
drop_benefits.append(benefit)
return drop_benefits
def import_owner_data(drop_campaign: dict[str, Any]) -> Owner:
"""Import the owner data from a drop campaign.
Args:
drop_campaign (dict[str, Any]): The drop campaign data.
Returns:
Owner: The owner instance.
"""
owner_data_list: list[dict[str, Any]] = find_typename_in_json(drop_campaign, "Organization")
for owner_data in owner_data_list:
owner_id: str = owner_data.get("id", "")
if not owner_id:
logger.error("\tOwner has no ID: %s", owner_data)
continue
owner, created = Owner.objects.get_or_create(twitch_id=owner_id)
if created:
logger.info("\tCreated owner: %s", owner)
owner.import_json(owner_data)
return owner
def import_game_data(drop_campaign: dict[str, Any], owner: Owner) -> Game:
"""Import the game data from a drop campaign.
Args:
drop_campaign (dict[str, Any]): The drop campaign data.
owner (Owner): The owner of the game.
Returns:
Game: The game instance.
"""
game_data_list: list[dict[str, Any]] = find_typename_in_json(drop_campaign, "Game")
for game_data in game_data_list:
game_id: str = game_data.get("id", "")
if not game_id:
logger.error("\tGame has no ID: %s", game_data)
continue
game, created = Game.objects.get_or_create(twitch_id=game_id)
if created:
logger.info("\tCreated game: %s", game)
game.import_json(game_data, owner)
return game
def find_typename_in_json(json_obj: list | dict, typename_to_find: type_names) -> list[dict[str, Any]]:
"""Recursively search for '__typename' in a JSON object and return dictionaries where '__typename' equals the specified value.
Args:
json_obj (list | dict): The JSON object to search.
typename_to_find (str): The '__typename' value to search for.
Returns:
A list of dictionaries where '__typename' equals the specified value.
""" # noqa: E501
matching_dicts: list[dict[str, Any]] = []
if isinstance(json_obj, dict):
# Check if '__typename' exists and matches the value
if json_obj.get("__typename") == typename_to_find:
matching_dicts.append(json_obj)
# Recurse into the dictionary
for value in json_obj.values():
matching_dicts.extend(find_typename_in_json(value, typename_to_find))
elif isinstance(json_obj, list):
# Recurse into each item in the list
for item in json_obj:
matching_dicts.extend(find_typename_in_json(item, typename_to_find))
return matching_dicts

View File

View File

View File

@ -0,0 +1,417 @@
from __future__ import annotations
import json
import logging
from pathlib import Path
from typing import Any
from django.core.management.base import BaseCommand, CommandError, CommandParser
from django.db import transaction
from django.utils import timezone
from django.utils.dateparse import parse_datetime
from core.models import Benefit, DropCampaign, Game, Organization, TimeBasedDrop
# Configure logger for this module
logger = logging.getLogger(__name__)
class Command(BaseCommand):
"""Imports Twitch Drop campaign data from a directory of JSON files into the database."""
help = "Imports Twitch Drop campaign data from a directory of JSON files."
def add_arguments(self, parser: CommandParser) -> None: # noqa: PLR6301
"""Add command line arguments for the command.
Args:
parser (CommandParser): The command parser to add arguments to.
"""
parser.add_argument("json_dir", type=str, help="Path to the directory containing Twitch Drop JSON files")
parser.add_argument(
"--pattern",
type=str,
default="*.json",
help="Glob pattern to filter JSON files (default: *.json)",
)
def handle(self, *args: tuple[str, ...], **options: dict[str, Any]) -> None:
"""Import data to DB from multiple JSON files in a directory.
Args:
*args (tuple[str, ...]): Positional arguments passed to the command.
**options (dict[str, Any]): Keyword arguments passed to the command.
Raises:
CommandError: If the directory does not exist or is not a directory.
"""
self.stdout.write(self.style.NOTICE("Starting import process..."))
self.stdout.write(self.style.NOTICE(f"Arguments: {args}; Options: {options}"))
json_dir_path = options["json_dir"]
pattern = options["pattern"]
dir_path = Path(json_dir_path) # pyright: ignore[reportArgumentType]
# Check if directory exists
if not dir_path.exists():
msg = f"Error: Directory not found at {json_dir_path}"
logger.error(msg)
raise CommandError(msg)
if not dir_path.is_dir():
msg = f"Error: {json_dir_path} is not a directory"
logger.error(msg)
raise CommandError(msg)
# Find JSON files in the directory
json_files = list(dir_path.glob(pattern)) # pyright: ignore[reportArgumentType]
if not json_files:
msg = f"No JSON files found in {json_dir_path} matching pattern '{pattern}'"
self.stdout.write(self.style.WARNING(msg))
logger.warning(msg)
return
self.stdout.write(f"Found {len(json_files)} JSON files to process in {json_dir_path}")
logger.info("Found %s JSON files to process in %s", len(json_files), json_dir_path)
# Tracking statistics
total_stats = {
"files_processed": 0,
"files_with_errors": 0,
"games_created": 0,
"games_updated": 0,
"orgs_created": 0,
"orgs_updated": 0,
"campaigns_created": 0,
"campaigns_updated": 0,
"drops_created": 0,
"drops_updated": 0,
"benefits_created": 0,
"benefits_updated": 0,
}
# Process each file
for json_file in sorted(json_files):
try:
file_stats = self._process_json_file(json_file)
total_stats["files_processed"] += 1
# Update totals
for key, value in file_stats.items():
if key in total_stats:
total_stats[key] += value
except CommandError as e:
self.stdout.write(self.style.ERROR(str(e)))
total_stats["files_with_errors"] += 1
logger.exception("Error processing %s", json_file)
except Exception as e:
msg = f"Unexpected error processing {json_file}: {e}"
self.stdout.write(self.style.ERROR(msg))
logger.exception(msg)
total_stats["files_with_errors"] += 1
# Print summary
self.stdout.write(self.style.SUCCESS("Import process completed."))
self.stdout.write(f"Files processed: {total_stats['files_processed']}")
self.stdout.write(f"Files with errors: {total_stats['files_with_errors']}")
self.stdout.write(f"Games created: {total_stats['games_created']}, updated: {total_stats['games_updated']}")
self.stdout.write(
f"Organizations created: {total_stats['orgs_created']}, updated: {total_stats['orgs_updated']}",
)
self.stdout.write(
f"Campaigns created: {total_stats['campaigns_created']}, updated: {total_stats['campaigns_updated']}",
)
self.stdout.write(f"Drops created: {total_stats['drops_created']}, updated: {total_stats['drops_updated']}")
self.stdout.write(
f"Benefits created: {total_stats['benefits_created']}, updated: {total_stats['benefits_updated']}",
)
logger.info(
"Import completed. Processed: %s, Errors: %s",
total_stats["files_processed"],
total_stats["files_with_errors"],
)
def _process_json_file(self, json_file: Path) -> dict[str, int]: # noqa: C901, PLR0912, PLR0914, PLR0915
"""Process a single JSON file and import its data to the database.
Args:
json_file: Path object pointing to the JSON file.
Returns:
Dict with counts of created and updated records.
Raises:
CommandError: If the JSON file cannot be decoded or if there are
issues with the JSON structure.
"""
self.stdout.write(f"Processing file: {json_file}")
logger.info("Processing file: %s", json_file)
# Initialize stats for this file
stats = {
"games_created": 0,
"games_updated": 0,
"orgs_created": 0,
"orgs_updated": 0,
"campaigns_created": 0,
"campaigns_updated": 0,
"drops_created": 0,
"drops_updated": 0,
"benefits_created": 0,
"benefits_updated": 0,
}
try:
with json_file.open(encoding="utf-8") as f:
raw_data = json.load(f)
except FileNotFoundError as e:
msg = f"Error: File not found at {json_file}"
logger.exception(msg)
raise CommandError(msg) from e
except json.JSONDecodeError as e:
msg = f"Error: Could not decode JSON from {json_file}"
logger.exception(msg)
raise CommandError(msg) from e
except Exception as e:
msg = f"Error reading file: {e}"
logger.exception(msg)
raise CommandError(msg) from e
# Navigate to the relevant part of the JSON structure
try:
user_data = raw_data.get("data", {}).get("user", {})
campaign_data = user_data.get("dropCampaign")
if not campaign_data:
msg = f"Error: 'dropCampaign' key not found or is null in the JSON data: {json_file}"
logger.error(msg)
raise CommandError(msg)
except AttributeError as e:
msg = f"Error: Unexpected JSON structure in {json_file}. Could not find 'data' or 'user'."
logger.exception(msg)
raise CommandError(msg) from e
# Process the campaign data within a transaction
try: # noqa: PLR1702
# Use a transaction to ensure atomicity
with transaction.atomic():
# --- 1. Process Game ---
game_data = campaign_data.get("game")
game_obj = None
if game_data and game_data.get("id"):
game_defaults = {
"slug": game_data.get("slug"),
"display_name": game_data.get("displayName"),
}
game_obj, created = Game.objects.update_or_create(
game_id=game_data["id"],
defaults=game_defaults,
)
if created:
self.stdout.write(self.style.SUCCESS(f"Created Game: {game_obj.display_name}"))
stats["games_created"] += 1
else:
self.stdout.write(f"Updated/Found Game: {game_obj.display_name}")
stats["games_updated"] += 1
else:
self.stdout.write(self.style.WARNING("No game data found in campaign."))
logger.warning("No game data found in campaign.")
# --- 2. Process Owner Organization ---
owner_data = campaign_data.get("owner")
owner_obj = None
if owner_data and owner_data.get("id"):
owner_defaults = {"name": owner_data.get("name")}
owner_obj, created = Organization.objects.update_or_create(
org_id=owner_data["id"],
defaults=owner_defaults,
)
if created:
self.stdout.write(self.style.SUCCESS(f"Created Organization: {owner_obj.name}"))
stats["orgs_created"] += 1
else:
self.stdout.write(f"Updated/Found Organization: {owner_obj.name}")
stats["orgs_updated"] += 1
else:
self.stdout.write(self.style.WARNING("No owner organization data found in campaign."))
logger.warning("No owner organization data found in campaign.")
# --- 3. Process Drop Campaign ---
campaign_id = campaign_data.get("id")
if not campaign_id:
msg = "Error: Campaign ID is missing."
logger.error(msg)
raise CommandError(msg) # noqa: TRY301
start_at_str = campaign_data.get("startAt")
end_at_str = campaign_data.get("endAt")
start_at_dt = parse_datetime(start_at_str) if start_at_str else None
end_at_dt = parse_datetime(end_at_str) if end_at_str else None
if not start_at_dt or not end_at_dt:
self.stdout.write(self.style.WARNING(f"Campaign {campaign_id} missing start or end date."))
logger.warning("Campaign %s missing start or end date.", campaign_id)
# Decide if you want to skip or handle this case differently
campaign_defaults = {
"name": campaign_data.get("name"),
"description": campaign_data.get("description"),
"status": campaign_data.get("status"),
"start_at": start_at_dt,
"end_at": end_at_dt,
"image_url": campaign_data.get("imageURL"),
"details_url": campaign_data.get("detailsURL"),
"account_link_url": campaign_data.get("accountLinkURL"),
"game": game_obj,
"owner": owner_obj,
}
campaign_obj, created = DropCampaign.objects.update_or_create(
campaign_id=campaign_id,
defaults=campaign_defaults,
)
if created:
self.stdout.write(self.style.SUCCESS(f"Created Campaign: {campaign_obj.name}"))
stats["campaigns_created"] += 1
else:
self.stdout.write(f"Updated/Found Campaign: {campaign_obj.name}")
stats["campaigns_updated"] += 1
# --- 4. Process Time Based Drops ---
time_drops_data = campaign_data.get("timeBasedDrops", [])
if not time_drops_data:
self.stdout.write(self.style.NOTICE("No timeBasedDrops found in campaign data."))
logger.info("No timeBasedDrops found in campaign data.")
for drop_data in time_drops_data:
drop_id = drop_data.get("id")
if not drop_id:
self.stdout.write(self.style.WARNING("Skipping drop with missing ID."))
logger.warning("Skipping drop with missing ID.")
continue
drop_start_str = drop_data.get("startAt")
drop_end_str = drop_data.get("endAt")
drop_start_dt = parse_datetime(drop_start_str) if drop_start_str else None
drop_end_dt = parse_datetime(drop_end_str) if drop_end_str else None
if not drop_start_dt or not drop_end_dt:
self.stdout.write(self.style.WARNING(f"Drop {drop_id} missing start or end date. Skipping."))
logger.warning("Drop %s missing start or end date. Skipping.", drop_id)
continue
drop_defaults = {
"name": drop_data.get("name"),
"start_at": drop_start_dt,
"end_at": drop_end_dt,
"required_minutes_watched": drop_data.get("requiredMinutesWatched"),
"campaign": campaign_obj,
# Add required_subs if needed: 'required_subs': drop_data.get('requiredSubs', 0)
}
drop_obj, created = TimeBasedDrop.objects.update_or_create(drop_id=drop_id, defaults=drop_defaults)
if created:
self.stdout.write(self.style.SUCCESS(f" Created Time Drop: {drop_obj.name}"))
stats["drops_created"] += 1
else:
self.stdout.write(f" Updated/Found Time Drop: {drop_obj.name}")
stats["drops_updated"] += 1
# --- 5. Process Benefits within the Drop ---
benefits_data = drop_data.get("benefitEdges", [])
benefit_objs = []
for edge in benefits_data:
benefit_info = edge.get("benefit")
if not benefit_info or not benefit_info.get("id"):
self.stdout.write(self.style.WARNING(" Skipping benefit edge with missing data or ID."))
logger.warning("Skipping benefit edge with missing data or ID.")
continue
benefit_id = benefit_info["id"]
benefit_game_data = benefit_info.get("game")
benefit_owner_data = benefit_info.get("ownerOrganization")
benefit_created_str = benefit_info.get("createdAt")
benefit_created_dt = parse_datetime(benefit_created_str) if benefit_created_str else None
benefit_game_obj = None
if benefit_game_data and benefit_game_data.get("id"):
# Assuming game ID is unique, get it directly
try:
benefit_game_obj = Game.objects.get(game_id=benefit_game_data["id"])
except Game.DoesNotExist:
warning_msg = (
f"Game {benefit_game_data.get('name')} for benefit {benefit_id} "
f"not found. Creating minimally."
)
self.stdout.write(self.style.WARNING(f" {warning_msg}"))
logger.warning(warning_msg)
# Optionally create a minimal game entry here if desired
benefit_game_obj, game_created = Game.objects.update_or_create(
game_id=benefit_game_data["id"],
defaults={"display_name": benefit_game_data.get("name", "Unknown Game")},
)
if game_created:
stats["games_created"] += 1
benefit_owner_obj = None
if benefit_owner_data and benefit_owner_data.get("id"):
try:
benefit_owner_obj = Organization.objects.get(org_id=benefit_owner_data["id"])
except Organization.DoesNotExist:
warning_msg = (
f"Organization {benefit_owner_data.get('name')} for benefit {benefit_id} "
f"not found. Creating minimally."
)
self.stdout.write(self.style.WARNING(f" {warning_msg}"))
logger.warning(warning_msg)
benefit_owner_obj, org_created = Organization.objects.update_or_create(
org_id=benefit_owner_data["id"],
defaults={"name": benefit_owner_data.get("name", "Unknown Org")},
)
if org_created:
stats["orgs_created"] += 1
benefit_defaults = {
"name": benefit_info.get("name"),
"image_asset_url": benefit_info.get("imageAssetURL"),
"entitlement_limit": edge.get("entitlementLimit", 1), # Get limit from edge
"distribution_type": benefit_info.get("distributionType"),
"twitch_created_at": benefit_created_dt or timezone.now(), # Provide a default if missing
"game": benefit_game_obj,
"owner_organization": benefit_owner_obj,
"is_ios_available": benefit_info.get("isIosAvailable", False),
}
benefit_obj, b_created = Benefit.objects.update_or_create(
benefit_id=benefit_id,
defaults=benefit_defaults,
)
if b_created:
self.stdout.write(self.style.SUCCESS(f" Created Benefit: {benefit_obj.name}"))
stats["benefits_created"] += 1
else:
self.stdout.write(f" Updated/Found Benefit: {benefit_obj.name}")
stats["benefits_updated"] += 1
benefit_objs.append(benefit_obj)
# Set the ManyToMany relationship for the drop
if benefit_objs:
drop_obj.benefits.set(benefit_objs)
drops_msg = f"Associated {len(benefit_objs)} benefits with drop {drop_obj.name}."
self.stdout.write(f" {drops_msg}")
logger.info("Associated %s benefits with drop %s.", len(benefit_objs), drop_obj.name)
except KeyError as e:
msg = f"Error: Missing expected key in JSON data - {e}"
logger.exception(msg)
raise CommandError(msg) from e
except Exception as e:
# The transaction will be rolled back automatically on exception
msg = f"An error occurred during import: {e}"
logger.exception(msg)
raise CommandError(msg) from e
# Return statistics from this file
return stats

View File

@ -1,40 +1,64 @@
# Generated by Django 5.1.4 on 2024-12-11 04:58 # Generated by Django 5.2 on 2025-05-01 00:45
from __future__ import annotations from __future__ import annotations
from typing import TYPE_CHECKING
import auto_prefetch
import django.contrib.auth.models import django.contrib.auth.models
import django.contrib.auth.validators import django.contrib.auth.validators
import django.db.models.deletion import django.db.models.deletion
import django.db.models.manager
import django.utils.timezone import django.utils.timezone
from django.db import migrations, models from django.db import migrations, models
if TYPE_CHECKING:
from django.db.migrations.operations.base import Operation
class Migration(migrations.Migration): class Migration(migrations.Migration):
"""Initial migration for the core app. """Initial migration for the core app."""
This add the following models:
- ScrapedJson
- User
- Owner
- Game
- DropCampaign
- TimeBasedDrop
- Benefit
"""
initial = True initial = True
dependencies: list[tuple[str, str]] = [ dependencies = [
("auth", "0012_alter_user_first_name_max_length"), ("auth", "0012_alter_user_first_name_max_length"),
] ]
operations: list[Operation] = [ operations = [
migrations.CreateModel(
name="Game",
fields=[
(
"game_id",
models.TextField(help_text="The Twitch ID of the game.", primary_key=True, serialize=False),
),
("game_url", models.URLField(blank=True, help_text="The URL to the game on Twitch.")),
("display_name", models.TextField(blank=True, help_text="The display name of the game.")),
("box_art_url", models.URLField(blank=True, help_text="URL to the box art of the game.")),
("slug", models.SlugField(blank=True, help_text="The slug for the game.")),
(
"created_at",
models.DateTimeField(auto_now_add=True, help_text="When the game was first added to the database."),
),
("modified_at", models.DateTimeField(auto_now=True, help_text="When the game was last modified.")),
],
options={
"ordering": ["display_name"],
},
),
migrations.CreateModel(
name="Organization",
fields=[
(
"org_id",
models.TextField(
help_text="The Twitch ID of the owner.",
primary_key=True,
serialize=False,
unique=True,
),
),
("name", models.TextField(blank=True, help_text="The name of the owner.")),
("created_at", models.DateTimeField(auto_now_add=True)),
("modified_at", models.DateTimeField(auto_now=True)),
],
options={
"ordering": ["name"],
},
),
migrations.CreateModel( migrations.CreateModel(
name="ScrapedJson", name="ScrapedJson",
fields=[ fields=[
@ -46,13 +70,7 @@ class Migration(migrations.Migration):
], ],
options={ options={
"ordering": ["-created_at"], "ordering": ["-created_at"],
"abstract": False,
"base_manager_name": "prefetch_manager",
}, },
managers=[
("objects", django.db.models.manager.Manager()),
("prefetch_manager", django.db.models.manager.Manager()),
],
), ),
migrations.CreateModel( migrations.CreateModel(
name="User", name="User",
@ -94,10 +112,7 @@ class Migration(migrations.Migration):
"is_active", "is_active",
models.BooleanField( models.BooleanField(
default=True, default=True,
help_text=( help_text="Designates whether this user should be treated as active. Unselect this instead of deleting accounts.", # noqa: E501
"Designates whether this user should be treated as active. Unselect this instead of"
" deleting accounts."
),
verbose_name="active", verbose_name="active",
), ),
), ),
@ -106,10 +121,7 @@ class Migration(migrations.Migration):
"groups", "groups",
models.ManyToManyField( models.ManyToManyField(
blank=True, blank=True,
help_text=( help_text="The groups this user belongs to. A user will get all permissions granted to each of their groups.", # noqa: E501
"The groups this user belongs to. A user will get all permissions granted to each of their"
" groups."
),
related_name="user_set", related_name="user_set",
related_query_name="user", related_query_name="user",
to="auth.group", to="auth.group",
@ -130,187 +142,82 @@ class Migration(migrations.Migration):
], ],
options={ options={
"ordering": ["username"], "ordering": ["username"],
"abstract": False,
"base_manager_name": "prefetch_manager",
}, },
managers=[ managers=[
("objects", django.contrib.auth.models.UserManager()), ("objects", django.contrib.auth.models.UserManager()),
], ],
), ),
migrations.CreateModel(
name="Owner",
fields=[
("created_at", models.DateTimeField(auto_created=True)),
(
"twitch_id",
models.TextField(help_text="The Twitch ID of the owner.", primary_key=True, serialize=False),
),
("modified_at", models.DateTimeField(auto_now=True)),
("name", models.TextField(blank=True, help_text="The name of the owner.")),
],
options={
"ordering": ["name"],
"abstract": False,
"base_manager_name": "prefetch_manager",
"indexes": [
models.Index(fields=["name"], name="owner_name_idx"),
models.Index(fields=["created_at"], name="owner_created_at_idx"),
],
},
managers=[
("objects", django.db.models.manager.Manager()),
("prefetch_manager", django.db.models.manager.Manager()),
],
),
migrations.CreateModel(
name="Game",
fields=[
(
"created_at",
models.DateTimeField(auto_created=True, help_text="When the game was first added to the database."),
),
(
"twitch_id",
models.TextField(help_text="The Twitch ID of the game.", primary_key=True, serialize=False),
),
("modified_at", models.DateTimeField(auto_now=True, help_text="When the game was last modified.")),
("game_url", models.URLField(blank=True, help_text="The URL to the game on Twitch.")),
("display_name", models.TextField(blank=True, help_text="The display name of the game.")),
("name", models.TextField(blank=True, help_text="The name of the game.")),
("box_art_url", models.URLField(blank=True, help_text="URL to the box art of the game.")),
("slug", models.TextField(blank=True)),
(
"org",
auto_prefetch.ForeignKey(
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="games",
to="core.owner",
),
),
],
options={
"ordering": ["display_name"],
"abstract": False,
"base_manager_name": "prefetch_manager",
},
managers=[
("objects", django.db.models.manager.Manager()),
("prefetch_manager", django.db.models.manager.Manager()),
],
),
migrations.CreateModel( migrations.CreateModel(
name="DropCampaign", name="DropCampaign",
fields=[ fields=[
( (
"created_at", "campaign_id",
models.DateTimeField(
auto_created=True,
help_text="When the drop campaign was first added to the database.",
),
),
(
"twitch_id",
models.TextField( models.TextField(
help_text="The Twitch ID of the drop campaign.", help_text="The Twitch ID of the drop campaign.",
primary_key=True, primary_key=True,
serialize=False, serialize=False,
unique=True,
), ),
), ),
(
"modified_at",
models.DateTimeField(auto_now=True, help_text="When the drop campaign was last modified."),
),
( (
"account_link_url", "account_link_url",
models.URLField(blank=True, help_text="The URL to link accounts for the drop campaign."), models.URLField(blank=True, help_text="The URL to link accounts for the drop campaign."),
), ),
("description", models.TextField(blank=True, help_text="The description of the drop campaign.")), ("description", models.TextField(blank=True, help_text="The description of the drop campaign.")),
("details_url", models.URLField(blank=True, help_text="The URL to the details of the drop campaign.")), ("details_url", models.URLField(blank=True, help_text="The URL to the details of the drop campaign.")),
("ends_at", models.DateTimeField(help_text="When the drop campaign ends.", null=True)), ("end_at", models.DateTimeField(help_text="When the drop campaign ends.", null=True)),
("starts_at", models.DateTimeField(help_text="When the drop campaign starts.", null=True)), ("start_at", models.DateTimeField(help_text="When the drop campaign starts.", null=True)),
("image_url", models.URLField(blank=True, help_text="The URL to the image for the drop campaign.")), ("image_url", models.URLField(blank=True, help_text="The URL to the image for the drop campaign.")),
("name", models.TextField(blank=True, help_text="The name of the drop campaign.")), ("name", models.TextField(blank=True, help_text="The name of the drop campaign.")),
("status", models.TextField(blank=True, help_text="The status of the drop campaign.")), ("status", models.TextField(blank=True, help_text="The status of the drop campaign.")),
(
"created_at",
models.DateTimeField(
auto_now_add=True,
help_text="When the drop campaign was first added to the database.",
),
),
(
"modified_at",
models.DateTimeField(auto_now=True, help_text="When the drop campaign was last modified."),
),
( (
"game", "game",
auto_prefetch.ForeignKey( models.ForeignKey(
help_text="The game associated with this campaign",
null=True, null=True,
on_delete=django.db.models.deletion.CASCADE, on_delete=django.db.models.deletion.SET_NULL,
related_name="drop_campaigns", related_name="drop_campaigns",
to="core.game", to="core.game",
), ),
), ),
( (
"scraped_json", "owner",
auto_prefetch.ForeignKey( models.ForeignKey(
help_text="Reference to the JSON data from the Twitch API.", help_text="The organization running this campaign",
null=True, null=True,
on_delete=django.db.models.deletion.SET_NULL, on_delete=django.db.models.deletion.SET_NULL,
to="core.scrapedjson", related_name="drop_campaigns",
to="core.organization",
), ),
), ),
], ],
options={ options={
"ordering": ["ends_at"], "ordering": ["end_at"],
"abstract": False,
"base_manager_name": "prefetch_manager",
}, },
managers=[
("objects", django.db.models.manager.Manager()),
("prefetch_manager", django.db.models.manager.Manager()),
],
),
migrations.CreateModel(
name="TimeBasedDrop",
fields=[
(
"created_at",
models.DateTimeField(auto_created=True, help_text="When the drop was first added to the database."),
),
(
"twitch_id",
models.TextField(help_text="The Twitch ID of the drop.", primary_key=True, serialize=False),
),
("modified_at", models.DateTimeField(auto_now=True, help_text="When the drop was last modified.")),
(
"required_subs",
models.PositiveBigIntegerField(help_text="The number of subs required for the drop.", null=True),
),
("ends_at", models.DateTimeField(help_text="When the drop ends.", null=True)),
("name", models.TextField(blank=True, help_text="The name of the drop.")),
(
"required_minutes_watched",
models.PositiveBigIntegerField(help_text="The number of minutes watched required.", null=True),
),
("starts_at", models.DateTimeField(help_text="When the drop starts.", null=True)),
(
"drop_campaign",
auto_prefetch.ForeignKey(
help_text="The drop campaign this drop is part of.",
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="drops",
to="core.dropcampaign",
),
),
],
options={
"ordering": ["required_minutes_watched"],
"abstract": False,
"base_manager_name": "prefetch_manager",
},
managers=[
("objects", django.db.models.manager.Manager()),
("prefetch_manager", django.db.models.manager.Manager()),
],
), ),
migrations.CreateModel( migrations.CreateModel(
name="Benefit", name="Benefit",
fields=[ fields=[
("created_at", models.DateTimeField(auto_created=True, null=True)), (
("twitch_id", models.TextField(primary_key=True, serialize=False)), "benefit_id",
("modified_at", models.DateTimeField(auto_now=True)), models.TextField(
help_text="Twitch's unique ID for the benefit",
primary_key=True,
serialize=False,
unique=True,
),
),
( (
"twitch_created_at", "twitch_created_at",
models.DateTimeField(help_text="When the benefit was created on Twitch.", null=True), models.DateTimeField(help_text="When the benefit was created on Twitch.", null=True),
@ -318,103 +225,88 @@ class Migration(migrations.Migration):
( (
"entitlement_limit", "entitlement_limit",
models.PositiveBigIntegerField( models.PositiveBigIntegerField(
help_text="The number of times the benefit can be claimed.", default=1,
null=True, help_text="How many times this benefit can be claimed per user",
), ),
), ),
("image_asset_url", models.URLField(blank=True, help_text="The URL to the image for the benefit.")), ("image_asset_url", models.URLField(blank=True, help_text="The URL to the image for the benefit.")),
("is_ios_available", models.BooleanField(help_text="If the benefit is farmable on iOS.", null=True)), ("is_ios_available", models.BooleanField(help_text="If the benefit is farmable on iOS.", null=True)),
("name", models.TextField(blank=True, help_text="The name of the benefit.")), ("name", models.TextField(blank=True, help_text="Name of the benefit/reward")),
("distribution_type", models.TextField(blank=True, help_text="The distribution type of the benefit.")), ("distribution_type", models.TextField(blank=True, help_text="The distribution type of the benefit.")),
("created_at", models.DateTimeField(auto_now_add=True)),
("modified_at", models.DateTimeField(auto_now=True)),
( (
"game", "game",
auto_prefetch.ForeignKey( models.ForeignKey(
null=True, null=True,
on_delete=django.db.models.deletion.CASCADE, on_delete=django.db.models.deletion.SET_NULL,
related_name="benefits", related_name="benefits",
to="core.game", to="core.game",
), ),
), ),
( (
"owner_organization", "owner_organization",
auto_prefetch.ForeignKey( models.ForeignKey(
null=True, null=True,
on_delete=django.db.models.deletion.CASCADE, on_delete=django.db.models.deletion.SET_NULL,
related_name="benefits", related_name="benefits",
to="core.owner", to="core.organization",
),
),
(
"time_based_drop",
auto_prefetch.ForeignKey(
help_text="The time based drop this benefit is for.",
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="benefits",
to="core.timebaseddrop",
), ),
), ),
], ],
options={ options={
"ordering": ["-twitch_created_at"], "ordering": ["-twitch_created_at"],
"abstract": False,
"base_manager_name": "prefetch_manager",
}, },
managers=[ ),
("objects", django.db.models.manager.Manager()), migrations.CreateModel(
("prefetch_manager", django.db.models.manager.Manager()), name="TimeBasedDrop",
fields=[
(
"drop_id",
models.TextField(
help_text="The Twitch ID of the drop.",
primary_key=True,
serialize=False,
unique=True,
),
),
(
"required_subs",
models.PositiveBigIntegerField(help_text="The number of subs required for the drop.", null=True),
),
("ends_at", models.DateTimeField(help_text="When the drop ends.", null=True)),
("name", models.TextField(blank=True, help_text="Name of the time-based drop")),
(
"required_minutes_watched",
models.PositiveBigIntegerField(help_text="The number of minutes watched required.", null=True),
),
("start_at", models.DateTimeField(help_text="Drop start time")),
("end_at", models.DateTimeField(help_text="Drop end time")),
(
"created_at",
models.DateTimeField(auto_now_add=True, help_text="When the drop was first added to the database."),
),
("modified_at", models.DateTimeField(auto_now=True, help_text="When the drop was last modified.")),
(
"benefits",
models.ManyToManyField(
help_text="Benefits awarded by this drop",
related_name="time_based_drops",
to="core.benefit",
),
),
(
"campaign",
models.ForeignKey(
help_text="The campaign this drop belongs to",
on_delete=django.db.models.deletion.CASCADE,
related_name="time_based_drops",
to="core.dropcampaign",
),
),
], ],
), options={
migrations.AddIndex( "ordering": ["required_minutes_watched"],
model_name="game", },
index=models.Index(fields=["display_name"], name="game_display_name_idx"),
),
migrations.AddIndex(
model_name="game",
index=models.Index(fields=["name"], name="game_name_idx"),
),
migrations.AddIndex(
model_name="game",
index=models.Index(fields=["created_at"], name="game_created_at_idx"),
),
migrations.AddIndex(
model_name="dropcampaign",
index=models.Index(fields=["name"], name="drop_campaign_name_idx"),
),
migrations.AddIndex(
model_name="dropcampaign",
index=models.Index(fields=["starts_at"], name="drop_campaign_starts_at_idx"),
),
migrations.AddIndex(
model_name="dropcampaign",
index=models.Index(fields=["ends_at"], name="drop_campaign_ends_at_idx"),
),
migrations.AddIndex(
model_name="timebaseddrop",
index=models.Index(fields=["name"], name="time_based_drop_name_idx"),
),
migrations.AddIndex(
model_name="timebaseddrop",
index=models.Index(fields=["starts_at"], name="time_based_drop_starts_at_idx"),
),
migrations.AddIndex(
model_name="timebaseddrop",
index=models.Index(fields=["ends_at"], name="time_based_drop_ends_at_idx"),
),
migrations.AddIndex(
model_name="benefit",
index=models.Index(fields=["name"], name="benefit_name_idx"),
),
migrations.AddIndex(
model_name="benefit",
index=models.Index(fields=["twitch_created_at"], name="benefit_twitch_created_at_idx"),
),
migrations.AddIndex(
model_name="benefit",
index=models.Index(fields=["created_at"], name="benefit_created_at_idx"),
),
migrations.AddIndex(
model_name="benefit",
index=models.Index(fields=["is_ios_available"], name="benefit_is_ios_available_idx"),
), ),
] ]

View File

@ -1,24 +0,0 @@
# Generated by Django 5.1.4 on 2024-12-16 18:28
from __future__ import annotations
from typing import TYPE_CHECKING
from django.db import migrations
if TYPE_CHECKING:
from django.db.migrations.operations.base import Operation
class Migration(migrations.Migration):
"""This migration alters the options of the User model to order by username."""
dependencies: list[tuple[str, str]] = [
("core", "0001_initial"),
]
operations: list[Operation] = [
migrations.AlterModelOptions(
name="user",
options={"ordering": ["username"]},
),
]

View File

@ -1,48 +0,0 @@
# Generated by Django 5.1.4 on 2024-12-16 18:31
from __future__ import annotations
import datetime
from typing import TYPE_CHECKING
from django.db import migrations, models
if TYPE_CHECKING:
from django.db.migrations.operations.base import Operation
class Migration(migrations.Migration):
"""This migration alters the created_at field on the Benefit, DropCampaign, Owner, and TimeBasedDrop models."""
dependencies: list[tuple[str, str]] = [
("core", "0002_alter_user_options"),
]
operations: list[Operation] = [
migrations.AlterField(
model_name="benefit",
name="created_at",
field=models.DateTimeField(
auto_now_add=True,
default=datetime.datetime(2024, 12, 16, 18, 31, 14, 851533, tzinfo=datetime.UTC),
),
preserve_default=False,
),
migrations.AlterField(
model_name="dropcampaign",
name="created_at",
field=models.DateTimeField(
auto_now_add=True,
help_text="When the drop campaign was first added to the database.",
),
),
migrations.AlterField(
model_name="owner",
name="created_at",
field=models.DateTimeField(auto_now_add=True),
),
migrations.AlterField(
model_name="timebaseddrop",
name="created_at",
field=models.DateTimeField(auto_now_add=True, help_text="When the drop was first added to the database."),
),
]

View File

@ -1,25 +0,0 @@
# Generated by Django 5.1.4 on 2024-12-18 22:35
from __future__ import annotations
from typing import TYPE_CHECKING
from django.db import migrations, models
if TYPE_CHECKING:
from django.db.migrations.operations.base import Operation
class Migration(migrations.Migration):
"""Fix created_at using auto_created instead of auto_now_add."""
dependencies: list[tuple[str, str]] = [
("core", "0003_alter_benefit_created_at_and_more"),
]
operations: list[Operation] = [
migrations.AlterField(
model_name="game",
name="created_at",
field=models.DateTimeField(auto_now_add=True, help_text="When the game was first added to the database."),
),
]

View File

@ -1,17 +1,11 @@
from __future__ import annotations from __future__ import annotations
import logging import logging
from typing import TYPE_CHECKING, ClassVar, Self from typing import ClassVar
import auto_prefetch
from django.contrib.auth.models import AbstractUser from django.contrib.auth.models import AbstractUser
from django.db import models from django.db import models
from core.models_utils import update_fields, wrong_typename
if TYPE_CHECKING:
from django.db.models import Index
logger: logging.Logger = logging.getLogger(__name__) logger: logging.Logger = logging.getLogger(__name__)
@ -26,7 +20,7 @@ class User(AbstractUser):
return self.username return self.username
class ScrapedJson(auto_prefetch.Model): class ScrapedJson(models.Model):
"""The JSON data from the Twitch API. """The JSON data from the Twitch API.
This data is from https://github.com/TheLovinator1/TwitchDropsMiner. This data is from https://github.com/TheLovinator1/TwitchDropsMiner.
@ -37,7 +31,7 @@ class ScrapedJson(auto_prefetch.Model):
modified_at = models.DateTimeField(auto_now=True) modified_at = models.DateTimeField(auto_now=True)
imported_at = models.DateTimeField(null=True) imported_at = models.DateTimeField(null=True)
class Meta(auto_prefetch.Model.Meta): class Meta:
ordering: ClassVar[list[str]] = ["-created_at"] ordering: ClassVar[list[str]] = ["-created_at"]
def __str__(self) -> str: def __str__(self) -> str:
@ -45,481 +39,148 @@ class ScrapedJson(auto_prefetch.Model):
return f"{'' if self.imported_at else 'Not imported - '}{self.created_at}" return f"{'' if self.imported_at else 'Not imported - '}{self.created_at}"
class Owner(auto_prefetch.Model): class Organization(models.Model):
"""The company or person that owns the game. """Represents the owner/organization of a Drop Campaign."""
Drops will be grouped by the owner. Users can also subscribe to owners. org_id = models.TextField(primary_key=True, unique=True, help_text="The Twitch ID of the owner.")
name = models.TextField(blank=True, help_text="The name of the owner.")
JSON:
{
"data": {
"user": {
"dropCampaign": {
"owner": {
"id": "36c4e21d-bdf3-410c-97c3-5a5a4bf1399b",
"name": "The Pok\u00e9mon Company",
"__typename": "Organization"
}
}
}
}
}
"""
# Django fields
# Example: "36c4e21d-bdf3-410c-97c3-5a5a4bf1399b"
twitch_id = models.TextField(primary_key=True, help_text="The Twitch ID of the owner.")
created_at = models.DateTimeField(auto_now_add=True) created_at = models.DateTimeField(auto_now_add=True)
modified_at = models.DateTimeField(auto_now=True) modified_at = models.DateTimeField(auto_now=True)
# Twitch fields class Meta:
# Example: "The Pokémon Company"
name = models.TextField(blank=True, help_text="The name of the owner.")
class Meta(auto_prefetch.Model.Meta):
ordering: ClassVar[list[str]] = ["name"] ordering: ClassVar[list[str]] = ["name"]
indexes: ClassVar[list[Index]] = [
models.Index(fields=["name"], name="owner_name_idx"),
models.Index(fields=["created_at"], name="owner_created_at_idx"),
]
def __str__(self) -> str: def __str__(self) -> str:
"""Return the name of the owner.""" """Return the name of the owner."""
return f"{self.name or self.twitch_id} - {self.created_at}" return f"{self.name or self.org_id} - {self.created_at}"
def import_json(self, data: dict) -> Self:
"""Import the data from the Twitch API."""
if wrong_typename(data, "Organization"):
return self
field_mapping: dict[str, str] = {"name": "name"}
updated: int = update_fields(instance=self, data=data, field_mapping=field_mapping)
if updated > 0:
logger.info("Updated %s fields for %s", updated, self)
return self
class Game(auto_prefetch.Model): class Game(models.Model):
"""The game the drop campaign is for. Note that some reward campaigns are not tied to a game. """The game the drop campaign is for. Note that some reward campaigns are not tied to a game."""
JSON: game_id = models.TextField(primary_key=True, help_text="The Twitch ID of the game.")
"game": { game_url = models.URLField(blank=True, help_text="The URL to the game on Twitch.")
"id": "155409827", display_name = models.TextField(blank=True, help_text="The display name of the game.")
"slug": "pokemon-trading-card-game-live", box_art_url = models.URLField(blank=True, help_text="URL to the box art of the game.")
"displayName": "Pok\u00e9mon Trading Card Game Live", slug = models.SlugField(blank=True, help_text="The slug for the game.")
"__typename": "Game"
}
Secondary JSON:
"game": {
"id": "155409827",
"displayName": "Pok\u00e9mon Trading Card Game Live",
"boxArtURL": "https://static-cdn.jtvnw.net/ttv-boxart/155409827_IGDB-120x160.jpg",
"__typename": "Game"
}
Tertiary JSON:
"game": {
"id": "155409827",
"name": "Pok\u00e9mon Trading Card Game Live",
"__typename": "Game"
}
"""
# Django fields
# "155409827"
twitch_id = models.TextField(primary_key=True, help_text="The Twitch ID of the game.")
created_at = models.DateTimeField(auto_now_add=True, help_text="When the game was first added to the database.") created_at = models.DateTimeField(auto_now_add=True, help_text="When the game was first added to the database.")
modified_at = models.DateTimeField(auto_now=True, help_text="When the game was last modified.") modified_at = models.DateTimeField(auto_now=True, help_text="When the game was last modified.")
# Twitch fields class Meta:
# "https://www.twitch.tv/directory/category/pokemon-trading-card-game-live"
# This is created when the game is created.
game_url = models.URLField(blank=True, help_text="The URL to the game on Twitch.")
# "Pokémon Trading Card Game Live"
display_name = models.TextField(blank=True, help_text="The display name of the game.")
# "Pokémon Trading Card Game Live"
name = models.TextField(blank=True, help_text="The name of the game.")
# "https://static-cdn.jtvnw.net/ttv-boxart/155409827_IGDB-120x160.jpg"
box_art_url = models.URLField(blank=True, help_text="URL to the box art of the game.")
# "pokemon-trading-card-game-live"
slug = models.TextField(blank=True)
# The owner of the game.
# This is optional because some games are not tied to an owner.
org = auto_prefetch.ForeignKey(Owner, on_delete=models.CASCADE, related_name="games", null=True)
class Meta(auto_prefetch.Model.Meta):
ordering: ClassVar[list[str]] = ["display_name"] ordering: ClassVar[list[str]] = ["display_name"]
indexes: ClassVar[list[Index]] = [
models.Index(fields=["display_name"], name="game_display_name_idx"),
models.Index(fields=["name"], name="game_name_idx"),
models.Index(fields=["created_at"], name="game_created_at_idx"),
]
def __str__(self) -> str: def __str__(self) -> str:
"""Return the name of the game and when it was created.""" """Return the name of the game and when it was created."""
return f"{self.display_name or self.twitch_id} - {self.created_at}" return f"{self.display_name or self.game_id} - {self.created_at}"
def import_json(self, data: dict, owner: Owner | None) -> Self:
"""Import the data from the Twitch API."""
if wrong_typename(data, "Game"):
return self
if not owner:
logger.error("Owner is required for %s: %s", self, data)
return self
# Map the fields from the JSON data to the Django model fields.
field_mapping: dict[str, str] = {
"displayName": "display_name",
"name": "name",
"boxArtURL": "box_art_url",
"slug": "slug",
}
updated: int = update_fields(instance=self, data=data, field_mapping=field_mapping)
if updated > 0:
logger.info("Updated %s fields for %s", updated, self)
# Update the owner if the owner is different or not set.
if owner != self.org:
self.org = owner
logger.info("Updated owner %s for %s", owner, self)
self.game_url = f"https://www.twitch.tv/directory/category/{self.slug}"
self.save()
return self
class DropCampaign(auto_prefetch.Model): class DropCampaign(models.Model):
"""This is the drop campaign we will see on the front end.""" """This is the drop campaign we will see on the front end."""
# Django fields campaign_id = models.TextField(primary_key=True, unique=True, help_text="The Twitch ID of the drop campaign.")
# "f257ce6e-502a-11ef-816e-0a58a9feac02" account_link_url = models.URLField(blank=True, help_text="The URL to link accounts for the drop campaign.")
twitch_id = models.TextField(primary_key=True, help_text="The Twitch ID of the drop campaign.") description = models.TextField(blank=True, help_text="The description of the drop campaign.")
details_url = models.URLField(blank=True, help_text="The URL to the details of the drop campaign.")
end_at = models.DateTimeField(null=True, help_text="When the drop campaign ends.")
start_at = models.DateTimeField(null=True, help_text="When the drop campaign starts.")
image_url = models.URLField(blank=True, help_text="The URL to the image for the drop campaign.")
name = models.TextField(blank=True, help_text="The name of the drop campaign.")
status = models.TextField(blank=True, help_text="The status of the drop campaign.")
game = models.ForeignKey(
to=Game,
help_text="The game associated with this campaign",
null=True,
on_delete=models.SET_NULL,
related_name="drop_campaigns",
)
owner = models.ForeignKey(
Organization,
help_text="The organization running this campaign",
null=True,
on_delete=models.SET_NULL,
related_name="drop_campaigns",
)
created_at = models.DateTimeField( created_at = models.DateTimeField(
auto_now_add=True, auto_now_add=True,
help_text="When the drop campaign was first added to the database.", help_text="When the drop campaign was first added to the database.",
) )
modified_at = models.DateTimeField(auto_now=True, help_text="When the drop campaign was last modified.") modified_at = models.DateTimeField(auto_now=True, help_text="When the drop campaign was last modified.")
# Twitch fields class Meta:
# "https://www.halowaypoint.com/settings/linked-accounts" ordering: ClassVar[list[str]] = ["end_at"]
account_link_url = models.URLField(blank=True, help_text="The URL to link accounts for the drop campaign.")
# "Tune into this HCS Grassroots event to earn Halo Infinite in-game content!"
description = models.TextField(blank=True, help_text="The description of the drop campaign.")
# "https://www.halowaypoint.com"
details_url = models.URLField(blank=True, help_text="The URL to the details of the drop campaign.")
# "2024-08-12T05:59:59.999Z"
ends_at = models.DateTimeField(null=True, help_text="When the drop campaign ends.")
# "2024-08-11T11:00:00Z""
starts_at = models.DateTimeField(null=True, help_text="When the drop campaign starts.")
# "https://static-cdn.jtvnw.net/twitch-quests-assets/CAMPAIGN/c8e02666-8b86-471f-bf38-7ece29a758e4.png"
image_url = models.URLField(blank=True, help_text="The URL to the image for the drop campaign.")
# "HCS Open Series - Week 1 - DAY 2 - AUG11"
name = models.TextField(blank=True, help_text="The name of the drop campaign.")
# "ACTIVE"
status = models.TextField(blank=True, help_text="The status of the drop campaign.")
# The game this drop campaign is for.
game = auto_prefetch.ForeignKey(to=Game, on_delete=models.CASCADE, related_name="drop_campaigns", null=True)
# The JSON data from the Twitch API.
# We use this to find out where the game came from.
scraped_json = auto_prefetch.ForeignKey(
to=ScrapedJson,
null=True,
on_delete=models.SET_NULL,
help_text="Reference to the JSON data from the Twitch API.",
)
class Meta(auto_prefetch.Model.Meta):
ordering: ClassVar[list[str]] = ["ends_at"]
indexes: ClassVar[list[Index]] = [
models.Index(fields=["name"], name="drop_campaign_name_idx"),
models.Index(fields=["starts_at"], name="drop_campaign_starts_at_idx"),
models.Index(fields=["ends_at"], name="drop_campaign_ends_at_idx"),
]
def __str__(self) -> str: def __str__(self) -> str:
"""Return the name of the drop campaign and when it was created.""" """Return the name of the drop campaign and when it was created."""
return f"{self.name or self.twitch_id} - {self.created_at}" return f"{self.name or self.campaign_id} - {self.created_at}"
def import_json(self, data: dict, game: Game | None, *, scraping_local_files: bool = False) -> Self:
"""Import the data from the Twitch API.
Args:
data (dict | None): The data from the Twitch API.
game (Game | None): The game this drop campaign is for.
scraping_local_files (bool, optional): If this was scraped from local data. Defaults to True.
Returns:
Self: The updated drop campaign.
"""
if wrong_typename(data, "DropCampaign"):
return self
field_mapping: dict[str, str] = {
"name": "name",
"accountLinkURL": "account_link_url", # TODO(TheLovinator): Should archive site. # noqa: TD003
"description": "description",
"endAt": "ends_at",
"startAt": "starts_at",
"detailsURL": "details_url", # TODO(TheLovinator): Should archive site. # noqa: TD003
"imageURL": "image_url",
}
updated: int = update_fields(instance=self, data=data, field_mapping=field_mapping)
if updated > 0:
logger.info("Updated %s fields for %s", updated, self)
if not scraping_local_files:
status = data.get("status")
if status and status != self.status:
self.status = status
self.save()
# Update the game if the game is different or not set.
if game and game != self.game:
self.game = game
logger.info("Updated game %s for %s", game, self)
self.save()
return self
class TimeBasedDrop(auto_prefetch.Model): class Benefit(models.Model):
"""This is the drop we will see on the front end. """Represents a specific reward/benefit within a Drop."""
JSON: benefit_id = models.TextField(primary_key=True, unique=True, help_text="Twitch's unique ID for the benefit")
{ twitch_created_at = models.DateTimeField(null=True, help_text="When the benefit was created on Twitch.")
"id": "bd663e10-b297-11ef-a6a3-0a58a9feac02", entitlement_limit = models.PositiveBigIntegerField(
"requiredSubs": 0, default=1,
"benefitEdges": [ help_text="How many times this benefit can be claimed per user",
{ )
"benefit": { image_asset_url = models.URLField(blank=True, help_text="The URL to the image for the benefit.")
"id": "f751ba67-7c8b-4c41-b6df-bcea0914f3ad_CUSTOM_ID_EnergisingBoltFlaskEffect", is_ios_available = models.BooleanField(null=True, help_text="If the benefit is farmable on iOS.")
"createdAt": "2024-12-04T23:25:50.995Z", name = models.TextField(blank=True, help_text="Name of the benefit/reward")
"entitlementLimit": 1, game = models.ForeignKey(Game, on_delete=models.SET_NULL, related_name="benefits", null=True)
"game": { owner_organization = models.ForeignKey(
"id": "1702520304", Organization,
"name": "Path of Exile 2", on_delete=models.SET_NULL,
"__typename": "Game" related_name="benefits",
}, null=True,
"imageAssetURL": "https://static-cdn.jtvnw.net/twitch-quests-assets/REWARD/d70e4e75-7237-4730-9a10-b6016aaaa795.png", )
"isIosAvailable": false, distribution_type = models.TextField(blank=True, help_text="The distribution type of the benefit.")
"name": "Energising Bolt Flask",
"ownerOrganization": {
"id": "f751ba67-7c8b-4c41-b6df-bcea0914f3ad",
"name": "Grinding Gear Games",
"__typename": "Organization"
},
"distributionType": "DIRECT_ENTITLEMENT",
"__typename": "DropBenefit"
},
"entitlementLimit": 1,
"__typename": "DropBenefitEdge"
}
],
"endAt": "2024-12-14T07:59:59.996Z",
"name": "Early Access Bundle",
"preconditionDrops": null,
"requiredMinutesWatched": 180,
"startAt": "2024-12-06T19:00:00Z",
"__typename": "TimeBasedDrop"
}
"""
# Django fields created_at = models.DateTimeField(auto_now_add=True)
# "d5cdf372-502b-11ef-bafd-0a58a9feac02" modified_at = models.DateTimeField(auto_now=True)
twitch_id = models.TextField(primary_key=True, help_text="The Twitch ID of the drop.")
created_at = models.DateTimeField(auto_now_add=True, help_text="When the drop was first added to the database.")
modified_at = models.DateTimeField(auto_now=True, help_text="When the drop was last modified.")
# Twitch fields class Meta:
# "1" ordering: ClassVar[list[str]] = ["-twitch_created_at"]
def __str__(self) -> str:
"""Return the name of the benefit and when it was created."""
return f"{self.name or self.benefit_id} - {self.twitch_created_at}"
class TimeBasedDrop(models.Model):
"""Represents a time-based drop within a Campaign."""
drop_id = models.TextField(primary_key=True, unique=True, help_text="The Twitch ID of the drop.")
required_subs = models.PositiveBigIntegerField(null=True, help_text="The number of subs required for the drop.") required_subs = models.PositiveBigIntegerField(null=True, help_text="The number of subs required for the drop.")
# "2024-08-12T05:59:59.999Z"
ends_at = models.DateTimeField(null=True, help_text="When the drop ends.") ends_at = models.DateTimeField(null=True, help_text="When the drop ends.")
name = models.TextField(blank=True, help_text="Name of the time-based drop")
# "Cosmic Nexus Chimera"
name = models.TextField(blank=True, help_text="The name of the drop.")
# "120"
required_minutes_watched = models.PositiveBigIntegerField( required_minutes_watched = models.PositiveBigIntegerField(
null=True, null=True,
help_text="The number of minutes watched required.", help_text="The number of minutes watched required.",
) )
start_at = models.DateTimeField(help_text="Drop start time")
end_at = models.DateTimeField(help_text="Drop end time")
# "2024-08-11T11:00:00Z" campaign = models.ForeignKey(
starts_at = models.DateTimeField(null=True, help_text="When the drop starts.")
# The drop campaign this drop is part of.
drop_campaign = auto_prefetch.ForeignKey(
DropCampaign, DropCampaign,
help_text="The campaign this drop belongs to",
on_delete=models.CASCADE, on_delete=models.CASCADE,
related_name="drops", related_name="time_based_drops",
null=True, )
help_text="The drop campaign this drop is part of.", benefits = models.ManyToManyField(
Benefit,
related_name="time_based_drops",
help_text="Benefits awarded by this drop",
) )
class Meta(auto_prefetch.Model.Meta): created_at = models.DateTimeField(auto_now_add=True, help_text="When the drop was first added to the database.")
modified_at = models.DateTimeField(auto_now=True, help_text="When the drop was last modified.")
class Meta:
ordering: ClassVar[list[str]] = ["required_minutes_watched"] ordering: ClassVar[list[str]] = ["required_minutes_watched"]
indexes: ClassVar[list[Index]] = [
models.Index(fields=["name"], name="time_based_drop_name_idx"),
models.Index(fields=["starts_at"], name="time_based_drop_starts_at_idx"),
models.Index(fields=["ends_at"], name="time_based_drop_ends_at_idx"),
]
def __str__(self) -> str: def __str__(self) -> str:
"""Return the name of the drop and when it was created.""" """Return the name of the drop and when it was created."""
return f"{self.name or self.twitch_id} - {self.created_at}" return f"{self.name or self.drop_id} - {self.created_at}"
def import_json(self, data: dict, drop_campaign: DropCampaign | None) -> Self:
"""Import the data from the Twitch API."""
if wrong_typename(data, "TimeBasedDrop"):
return self
# preconditionDrops is null in the JSON. We probably should use it when we know what it is.
if data.get("preconditionDrops"):
logger.error("preconditionDrops is not None for %s", self)
field_mapping: dict[str, str] = {
"name": "name",
"requiredSubs": "required_subs",
"requiredMinutesWatched": "required_minutes_watched",
"startAt": "starts_at",
"endAt": "ends_at",
}
updated: int = update_fields(instance=self, data=data, field_mapping=field_mapping)
if updated > 0:
logger.info("Updated %s fields for %s", updated, self)
if drop_campaign and drop_campaign != self.drop_campaign:
self.drop_campaign = drop_campaign
logger.info("Updated drop campaign %s for %s", drop_campaign, self)
self.save()
return self
class Benefit(auto_prefetch.Model):
"""Benefits are the rewards for the drops."""
# Django fields
# "d5cdf372-502b-11ef-bafd-0a58a9feac02"
twitch_id = models.TextField(primary_key=True)
created_at = models.DateTimeField(auto_now_add=True)
modified_at = models.DateTimeField(auto_now=True)
# Twitch fields
# Note: This is Twitch's created_at from the API and not our created_at.
# "2023-11-09T01:18:00.126Z"
twitch_created_at = models.DateTimeField(null=True, help_text="When the benefit was created on Twitch.")
# "1"
entitlement_limit = models.PositiveBigIntegerField(
null=True,
help_text="The number of times the benefit can be claimed.",
)
# "https://static-cdn.jtvnw.net/twitch-quests-assets/REWARD/e58ad175-73f6-4392-80b8-fb0223163733.png"
image_asset_url = models.URLField(blank=True, help_text="The URL to the image for the benefit.")
# "True" or "False". None if unknown.
is_ios_available = models.BooleanField(null=True, help_text="If the benefit is farmable on iOS.")
# "Cosmic Nexus Chimera"
name = models.TextField(blank=True, help_text="The name of the benefit.")
# The game this benefit is for.
time_based_drop = auto_prefetch.ForeignKey(
TimeBasedDrop,
on_delete=models.CASCADE,
related_name="benefits",
null=True,
help_text="The time based drop this benefit is for.",
)
# The game this benefit is for.
game = auto_prefetch.ForeignKey(Game, on_delete=models.CASCADE, related_name="benefits", null=True)
# The owner of the benefit.
owner_organization = auto_prefetch.ForeignKey(Owner, on_delete=models.CASCADE, related_name="benefits", null=True)
# Distribution type.
# "DIRECT_ENTITLEMENT"
distribution_type = models.TextField(blank=True, help_text="The distribution type of the benefit.")
class Meta(auto_prefetch.Model.Meta):
ordering: ClassVar[list[str]] = ["-twitch_created_at"]
indexes: ClassVar[list[Index]] = [
models.Index(fields=["name"], name="benefit_name_idx"),
models.Index(fields=["twitch_created_at"], name="benefit_twitch_created_at_idx"),
models.Index(fields=["created_at"], name="benefit_created_at_idx"),
models.Index(fields=["is_ios_available"], name="benefit_is_ios_available_idx"),
]
def __str__(self) -> str:
"""Return the name of the benefit and when it was created."""
return f"{self.name or self.twitch_id} - {self.twitch_created_at}"
def import_json(self, data: dict, time_based_drop: TimeBasedDrop | None) -> Self:
"""Import the data from the Twitch API."""
if wrong_typename(data, "DropBenefit"):
return self
field_mapping: dict[str, str] = {
"name": "name",
"imageAssetURL": "image_asset_url",
"entitlementLimit": "entitlement_limit",
"isIosAvailable": "is_ios_available",
"createdAt": "twitch_created_at",
"distributionType": "distribution_type",
}
updated: int = update_fields(instance=self, data=data, field_mapping=field_mapping)
if updated > 0:
logger.info("Updated %s fields for %s", updated, self)
if not time_based_drop:
logger.error("TimeBasedDrop is required for %s", self)
return self
if time_based_drop != self.time_based_drop:
self.time_based_drop = time_based_drop
logger.info("Updated time based drop %s for %s", time_based_drop, self)
self.save()
if data.get("game") and data["game"].get("id"):
game_instance, created = Game.objects.update_or_create(twitch_id=data["game"]["id"])
game_instance.import_json(data["game"], None)
if created:
logger.info("Added game %s to %s", game_instance, self)
if data.get("ownerOrganization") and data["ownerOrganization"].get("id"):
owner_instance, created = Owner.objects.update_or_create(twitch_id=data["ownerOrganization"]["id"])
owner_instance.import_json(data["ownerOrganization"])
if created:
logger.info("Added owner %s to %s", owner_instance, self)
return self

View File

@ -1,112 +0,0 @@
from __future__ import annotations
import logging
from datetime import datetime
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from django.db import models
logger: logging.Logger = logging.getLogger(__name__)
def wrong_typename(data: dict, expected: str) -> bool:
"""Check if the data is the expected type.
# TODO(TheLovinator): Double check this. # noqa: TD003
Type name examples:
- Game
- DropCampaign
- TimeBasedDrop
- DropBenefit
- RewardCampaign
- Reward
Args:
data (dict): The data to check.
expected (str): The expected type.
Returns:
bool: True if the data is not the expected type.
"""
is_unexpected_type: bool = data.get("__typename", "") != expected
if is_unexpected_type:
logger.error("Not a %s? %s", expected, data)
return is_unexpected_type
def update_field(instance: models.Model, django_field_name: str, new_value: str | datetime | None) -> int:
"""Update a field on an instance if the new value is different from the current value.
Args:
instance (models.Model): The Django model instance.
django_field_name (str): The name of the field to update.
new_value (str | datetime | None): The new value to update the field with.
Returns:
int: If the field was updated, returns 1. Otherwise, returns 0.
"""
# Get the current value of the field.
try:
current_value = getattr(instance, django_field_name)
except AttributeError:
logger.exception("Field %s does not exist on %s", django_field_name, instance)
return 0
# Only update the field if the new value is different from the current value.
if new_value and new_value != current_value:
setattr(instance, django_field_name, new_value)
return 1
# 0 fields updated.
return 0
def get_value(data: dict, key: str) -> datetime | str | None:
"""Get a value from a dictionary.
We have this function so we can handle values that we need to convert to a different type. For example, we might
need to convert a string to a datetime object.
Args:
data (dict): The dictionary to get the value from.
key (str): The key to get the value for.
Returns:
datetime | str | None: The value from the dictionary
"""
data_key: Any | None = data.get(key)
if not data_key:
logger.warning("Key %s not found in %s", key, data)
return None
# Dates are in the format "2024-08-12T05:59:59.999Z"
dates: list[str] = ["endAt", "endsAt,", "startAt", "startsAt", "createdAt", "earnableUntil"]
if key in dates:
logger.debug("Converting %s to datetime", data_key)
return datetime.fromisoformat(data_key.replace("Z", "+00:00"))
return data_key
def update_fields(instance: models.Model, data: dict, field_mapping: dict[str, str]) -> int:
"""Update multiple fields on an instance using a mapping from external field names to model field names.
Args:
instance (models.Model): The Django model instance.
data (dict): The new data to update the fields with.
field_mapping (dict[str, str]): A dictionary mapping external field names to model field names.
Returns:
int: The number of fields updated. Used for only saving the instance if there were changes.
"""
dirty = 0
for json_field, django_field_name in field_mapping.items():
data_key: datetime | str | None = get_value(data, json_field)
dirty += update_field(instance=instance, django_field_name=django_field_name, new_value=data_key)
if dirty > 0:
instance.save()
return dirty

14
core/templates/404.html Normal file
View File

@ -0,0 +1,14 @@
{% extends "base.html" %}
{% block content %}
<div class="row justify-content-center">
<div class="col-md-8 text-center">
<div class="alert-danger mt-5">
<h1 class="display-1">404</h1>
<h2>Page Not Found</h2>
<p class="lead">The page you're looking for doesn't exist or has been moved.</p>
<p>Check the URL or return to the <a href="{% url 'index' %}" class="alert-link">homepage</a>.</p>
</div>
</div>
</div>
{% endblock content %}

14
core/templates/500.html Normal file
View File

@ -0,0 +1,14 @@
{% extends "base.html" %}
{% block content %}
<div class="row justify-content-center">
<div class="col-md-8 text-center">
<div class="alert-danger mt-5">
<h1 class="display-1">500</h1>
<h2>Internal Server Error</h2>
<p class="lead">An unexpected error occurred while processing your request.</p>
<p>Check the URL or return to the <a href="{% url 'index' %}" class="alert-link">homepage</a>.</p>
</div>
</div>
</div>
{% endblock content %}

View File

@ -1,93 +1,107 @@
{% extends "base.html" %} {% extends "base.html" %}
{% load custom_filters static time_filters %} {% load custom_filters static time_filters %}
{% block content %} {% block content %}
<div class="container mt-4"> <div class="container mt-4">
{% include "partials/info_box.html" %} {% include "partials/info_box.html" %}
<!-- Drop Campaigns Section --> <!-- Drop Campaigns Section -->
<section class="drop-campaigns"> <section class="drop-campaigns">
<h2> <h2>
Drop Campaigns - Drop Campaigns -
<span class="d-inline text-muted">{{ games.count }} game{{ games.count|pluralize }}</span> <span class="d-inline text-muted">{{ grouped_drops|length }} game{{ grouped_drops|length|pluralize }}</span>
</h2> </h2>
<!-- Loop through games --> {% if grouped_drops %}
{% for game in games %} {% for game, campaigns in grouped_drops.items %}
<div class="card mb-4 shadow-sm"> <div class="card mb-4 shadow-sm">
<div class="row g-0"> <div class="row g-0">
<!-- Game Box Art --> <!-- Game Box Art -->
<div class="col-md-2"> <div class="col-md-2">
<img src="{{ game.box_art_url|default:'https://static-cdn.jtvnw.net/ttv-static/404_boxart.jpg' }}" {% if game and game.box_art_url %}
alt="{{ game.name|default:'Game name unknown' }} box art" <img src="{{ game.box_art_url }}" alt="{{ game.display_name|default:'Game name unknown' }} box art"
class="img-fluid rounded-start" class="img-fluid rounded-start" height="283" width="212" loading="lazy" />
height="283" {% else %}
width="212" <img src="https://static-cdn.jtvnw.net/ttv-static/404_boxart.jpg"
loading="lazy" /> alt="{% if game %}{{ game.display_name }}{% else %}Unknown Game{% endif %} box art"
class="img-fluid rounded-start" height="283" width="212" loading="lazy" />
{% endif %}
</div> </div>
<!-- Game Details --> <!-- Game Details -->
<div class="col-md-10"> <div class="col-md-10">
<div class="card-body"> <div class="card-body">
<h2 class="card-title h5"> <h2 class="card-title h5">
<a href="{% url 'game' game.twitch_id %}" class="text-decoration-none">{{ game.name|default:'Unknown' }}</a> {% if game %}
- <a href="{% if game.game_id %}{% url 'game_detail' game.game_id %}{% else %}#{% endif %}"
<a href="https://www.twitch.tv/directory/category/{{ game.slug|default:'game-name-unknown' }}" class="text-decoration-none">{{ game.display_name|default:'Unknown Game' }}</a>
{% if game.slug %}
- <a href="https://www.twitch.tv/directory/category/{{ game.slug }}"
class="text-decoration-none text-muted">Twitch</a> class="text-decoration-none text-muted">Twitch</a>
</h2>
<!-- Loop through campaigns for each game -->
{% for campaign in game.drop_campaigns.all %}
<div class="mt-4">
<h4 class="h6">{{ campaign.name }}</h4>
<a href="{{ campaign.details_url }}" class="text-decoration-none">Details</a>
{% if campaign.details_url != campaign.account_link_url %}
| <a href="{{ campaign.account_link_url }}" class="text-decoration-none">Link Account</a>
{% endif %} {% endif %}
<p class="mb-2 text-muted"> {% else %}
Ends in: Drops without an associated Game
<abbr title="{{ campaign.starts_at|date:'l d F H:i' }} - {{ campaign.ends_at|date:'l d F H:i' }}"> {% endif %}
{{ campaign.ends_at|timeuntil }} </h2>
</abbr>
{% if campaigns %}
<div class="mt-4">
{% for campaign, drops_list in campaigns.items %}
<div class="card mb-3">
<div class="card-header bg-secondary bg-opacity-25">
<h4 class="h6 mb-0"><a
href="{{ campaign.details_url|default:'#' }}">{{ campaign.name|default:"Unknown Campaign" }}</a>
</h4>
<div class="mt-1">
{% if campaign.details_url != campaign.account_link_url and campaign.account_link_url %}
<a href="{{ campaign.account_link_url }}" class="text-decoration-none">Link
Account</a>
{% endif %}
</div>
<p class="mb-0 mt-1 text-muted small">
{{ campaign.start_at|date:'l d F H:i' }} -
{{ campaign.end_at|date:'l d F H:i' }} | {{ campaign.end_at|timeuntil }}
</p> </p>
<!-- Drop Benefits Table --> </div>
<div class="card-body p-0">
<div class="table-responsive"> <div class="table-responsive">
<table class="table table-striped table-hover align-middle"> <table class="table table-striped table-hover align-middle mb-0">
<thead> <thead>
<tr> <tr>
<th>Benefit Image</th> <th>Benefit Image</th>
<th>Drop Name</th>
<th>Benefit Name</th> <th>Benefit Name</th>
<th>Required Minutes Watched</th> <th>Required Minutes Watched</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{% for drop in campaign.drops.all %} {% for drop in drops_list %}
{% if drop.benefits.exists %} {% if drop.benefits.exists %}
{% for benefit in drop.benefits.all %} {% for benefit in drop.benefits.all %}
<tr> <tr>
<td> <td>
<img src="{{ benefit.image_url|default:'https://static-cdn.jtvnw.net/ttv-static/404_boxart.jpg' }}" <img src="{{ benefit.image_asset_url|default:'https://static-cdn.jtvnw.net/ttv-static/404_boxart.jpg' }}"
alt="{{ benefit.name|default:'Unknown' }}" alt="{{ benefit.name|default:'Unknown Benefit' }}"
class="img-fluid rounded" class="img-fluid rounded" height="50" width="50"
height="50"
width="50"
loading="lazy" /> loading="lazy" />
</td> </td>
<td>{{ drop.name|default:'Unknown Drop' }}</td>
<td> <td>
<abbr title="{{ drop.name|default:'Unknown' }}"> {{ benefit.name|default:'Unknown Benefit' }}
{{ benefit.name|default:'Unknown' }} </td>
</abbr> <td>
{{ drop.required_minutes_watched|minutes_to_hours }}
</td> </td>
<td>{{ drop.required_minutes_watched|minutes_to_hours }}</td>
</tr> </tr>
{% endfor %} {% endfor %}
{% else %} {% else %}
<tr> <tr>
<td> <td>
<img src="https://static-cdn.jtvnw.net/ttv-static/404_boxart.jpg" <img src="https://static-cdn.jtvnw.net/ttv-static/404_boxart.jpg"
alt="{{ drop.name|default:'Unknown' }}" alt="{{ drop.name|default:'Unknown Drop' }}"
class="img-fluid rounded" class="img-fluid rounded" height="50" width="50"
height="50"
width="50"
loading="lazy" /> loading="lazy" />
</td> </td>
<td>{{ drop.name|default:'Unknown' }}</td> <td>{{ drop.name|default:'Unknown Drop' }}</td>
<td>N/A</td> <td>N/A</td>
<td>{{ drop.required_minutes_watched|minutes_to_hours|default:'N/A' }}
</td>
</tr> </tr>
{% endif %} {% endif %}
{% endfor %} {% endfor %}
@ -95,12 +109,26 @@
</table> </table>
</div> </div>
</div> </div>
{% endfor %}
</div>
</div>
</div>
</div> </div>
{% endfor %} {% endfor %}
</div>
{% else %}
<p class="mt-3 text-muted">No active drops found for this game currently.</p>
{% endif %}
</div>
</div>
</div>
</div>
{% empty %}
<div class="alert alert-info" role="alert">
There are currently no active Twitch drops found matching the criteria.
</div>
{% endfor %}
{% else %}
<div class="alert alert-info" role="alert">
There are currently no active Twitch drops found.
</div>
{% endif %}
</section> </section>
</div> </div>
{% endblock content %} {% endblock content %}

View File

@ -4,12 +4,12 @@ from debug_toolbar.toolbar import debug_toolbar_urls # type: ignore[import-unty
from django.contrib import admin from django.contrib import admin
from django.urls import URLPattern, URLResolver, path from django.urls import URLPattern, URLResolver, path
from core.views import get_game, get_games, get_home, get_import from core.views import get_game, get_games, get_home
app_name: str = "core" app_name: str = "core"
# TODO(TheLovinator): Add a 404 page and a 500 page. handler404 = "core.views.handler404"
# https://docs.djangoproject.com/en/dev/topics/http/views/#customizing-error-views handler500 = "core.views.handler500"
# TODO(TheLovinator): Add a robots.txt file. # TODO(TheLovinator): Add a robots.txt file.
# https://developers.google.com/search/docs/crawling-indexing/robots/intro # https://developers.google.com/search/docs/crawling-indexing/robots/intro
@ -33,8 +33,7 @@ app_name: str = "core"
urlpatterns: list[URLPattern | URLResolver] = [ urlpatterns: list[URLPattern | URLResolver] = [
path(route="admin/", view=admin.site.urls), path(route="admin/", view=admin.site.urls),
path(route="", view=get_home, name="index"), path(route="", view=get_home, name="index"),
path(route="game/<int:twitch_id>/", view=get_game, name="game"), path(route="game/<int:twitch_id>/", view=get_game, name="game_detail"),
path(route="games/", view=get_games, name="games"), path(route="games/", view=get_games, name="games"),
path(route="import/", view=get_import, name="import"),
*debug_toolbar_urls(), *debug_toolbar_urls(),
] ]

View File

@ -1,17 +1,16 @@
from __future__ import annotations from __future__ import annotations
import json
import logging import logging
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any
from django.db.models import F, Prefetch from django.db.models import Prefetch
from django.http import HttpRequest, HttpResponse, JsonResponse from django.http import HttpRequest, HttpResponse
from django.shortcuts import render
from django.template.response import TemplateResponse from django.template.response import TemplateResponse
from django.utils import timezone from django.utils import timezone
from django.views.decorators.http import require_http_methods from django.views.decorators.http import require_http_methods
from core.import_json import import_data from core.models import DropCampaign, Game, TimeBasedDrop
from core.models import Benefit, DropCampaign, Game, TimeBasedDrop
if TYPE_CHECKING: if TYPE_CHECKING:
from django.db.models.query import QuerySet from django.db.models.query import QuerySet
@ -20,54 +19,111 @@ if TYPE_CHECKING:
logger: logging.Logger = logging.getLogger(__name__) logger: logging.Logger = logging.getLogger(__name__)
def get_games_with_drops() -> QuerySet[Game]: def handler404(request: HttpRequest, exception: Exception | None = None) -> HttpResponse:
"""Get the games with drops, sorted by when the drop campaigns end. """Custom 404 error handler.
Args:
request (HttpRequest): The request object that caused the 404.
exception (Exception, optional): The exception that caused the 404. Defaults to None.
Returns: Returns:
QuerySet[Game]: The games with drops. HttpResponse: The rendered 404 template.
""" """
# Prefetch the benefits for the time-based drops. logger.warning(
benefits_prefetch = Prefetch(lookup="benefits", queryset=Benefit.objects.all()) "404 error occurred",
active_time_based_drops: QuerySet[TimeBasedDrop] = TimeBasedDrop.objects.filter( extra={"path": request.path, "exception": str(exception) if exception else None},
ends_at__gte=timezone.now(),
starts_at__lte=timezone.now(),
).prefetch_related(benefits_prefetch)
# Prefetch the active time-based drops for the drop campaigns.
drops_prefetch = Prefetch(lookup="drops", queryset=active_time_based_drops)
active_campaigns: QuerySet[DropCampaign] = DropCampaign.objects.filter(
ends_at__gte=timezone.now(),
starts_at__lte=timezone.now(),
).prefetch_related(drops_prefetch)
return (
Game.objects.filter(drop_campaigns__in=active_campaigns)
.annotate(drop_campaign_end=F("drop_campaigns__ends_at"))
.distinct()
.prefetch_related(Prefetch("drop_campaigns", queryset=active_campaigns))
.select_related("org")
.order_by("drop_campaign_end")
) )
return render(request=request, template_name="404.html", status=404)
def handler500(request: HttpRequest) -> HttpResponse:
"""Custom 500 error handler.
Args:
request (HttpRequest): The request object that caused the 500.
Returns:
HttpResponse: The rendered 500 template.
"""
logger.error("500 error occurred", extra={"path": request.path})
return render(request=request, template_name="500.html", status=500)
@require_http_methods(request_method_list=["GET", "HEAD"]) @require_http_methods(request_method_list=["GET", "HEAD"])
def get_home(request: HttpRequest) -> HttpResponse: def get_home(request: HttpRequest) -> HttpResponse:
"""Render the index page. """Render the index page with drops grouped hierarchically by game and campaign.
This view fetches all currently active drops (where current time is between start_at and end_at),
and organizes them by game and campaign for display. Drops within each campaign are sorted by
required minutes watched.
Args: Args:
request (HttpRequest): The request object. request (HttpRequest): The request object.
Returns: Returns:
HttpResponse: The response object HttpResponse: The rendered index template with grouped drops.
""" """
try: now: timezone.datetime = timezone.now()
games: QuerySet[Game] = get_games_with_drops()
except Exception:
logger.exception("Error fetching reward campaigns or games.")
return HttpResponse(status=500)
context: dict[str, Any] = {"games": games} try:
return TemplateResponse(request, "index.html", context) # Dictionary structure: {Game: {Campaign: [TimeBasedDrop, ...]}}
grouped_drops: dict[Game, dict[DropCampaign, list[TimeBasedDrop]]] = {}
# Fetch all currently active drops with optimized queries
current_drops_qs = (
TimeBasedDrop.objects.filter(start_at__lte=now, end_at__gte=now)
.select_related("campaign", "campaign__game", "campaign__owner")
.prefetch_related("benefits")
.order_by("campaign__game__display_name", "campaign__name", "required_minutes_watched")
)
# Drops without associated games or campaigns
orphaned_drops: list[TimeBasedDrop] = []
for drop in current_drops_qs:
# Check if drop has both campaign and game
if drop.campaign and drop.campaign.game:
game: Game = drop.campaign.game
campaign: DropCampaign = drop.campaign
# Initialize the game entry if it doesn't exist
if game not in grouped_drops:
grouped_drops[game] = {}
# Initialize the campaign entry if it doesn't exist
if campaign not in grouped_drops[game]:
grouped_drops[game][campaign] = []
# Add the drop to the appropriate campaign
grouped_drops[game][campaign].append(drop)
else:
# Store drops without proper association separately
orphaned_drops.append(drop)
logger.warning("Drop %s does not have an associated game or campaign.", drop.name or drop.drop_id)
# Make sure drops within each campaign are sorted by required_minutes_watched
for campaigns in grouped_drops.values():
for drops in campaigns.values():
drops.sort(key=lambda drop: drop.required_minutes_watched or 0)
# Also sort orphaned drops if any
if orphaned_drops:
orphaned_drops.sort(key=lambda drop: drop.required_minutes_watched or 0)
context: dict[str, Any] = {
"grouped_drops": grouped_drops,
"orphaned_drops": orphaned_drops,
"current_time": now,
}
return render(request, "index.html", context)
except Exception:
logger.exception("Error in get_home view")
return HttpResponse(
status=500,
content="An error occurred while processing your request. Please try again later.",
)
@require_http_methods(request_method_list=["GET", "HEAD"]) @require_http_methods(request_method_list=["GET", "HEAD"])
@ -117,25 +173,3 @@ def get_games(request: HttpRequest) -> HttpResponse:
context: dict[str, QuerySet[Game] | str] = {"games": games} context: dict[str, QuerySet[Game] | str] = {"games": games}
return TemplateResponse(request=request, template="games.html", context=context) return TemplateResponse(request=request, template="games.html", context=context)
@require_http_methods(request_method_list=["POST"])
def get_import(request: HttpRequest) -> HttpResponse:
"""Import data that are sent from Twitch Drop Miner.
Args:
request (HttpRequest): The request object.
Returns:
HttpResponse: The response object.
"""
try:
data = json.loads(request.body)
logger.info(data)
# Import the data.
import_data(data)
return JsonResponse({"status": "success"}, status=200)
except json.JSONDecodeError as e:
return JsonResponse({"status": "error", "message": str(e)}, status=400)

View File

@ -10,27 +10,20 @@ dependencies = [
"django", "django",
"platformdirs", "platformdirs",
"python-dotenv", "python-dotenv",
"django-auto-prefetch",
] ]
# You can install development dependencies with `uv install --dev`.
# Or you can install them with `uv install --dev -r requirements-dev.txt`.
# uv can be replaced with `pip`if you don't have uv installed.
[dependency-groups] [dependency-groups]
dev = ["pre-commit", "pytest", "pytest-django", "ruff"] dev = ["pytest", "pytest-django"]
# https://docs.astral.sh/ruff/settings/
[tool.ruff] [tool.ruff]
# Enable all rules
lint.select = ["ALL"] lint.select = ["ALL"]
preview = true
# https://sphinxcontrib-napoleon.readthedocs.io/en/latest/example_google.html unsafe-fixes = true
fix = true
lint.pydocstyle.convention = "google" lint.pydocstyle.convention = "google"
# Add "from __future__ import annotations" to all files
lint.isort.required-imports = ["from __future__ import annotations"] lint.isort.required-imports = ["from __future__ import annotations"]
line-length = 120
# Ignore some rules
lint.ignore = [ lint.ignore = [
"CPY001", # Checks for the absence of copyright notices within Python files. "CPY001", # Checks for the absence of copyright notices within Python files.
"D100", # Checks for undocumented public module definitions. "D100", # Checks for undocumented public module definitions.
@ -57,9 +50,6 @@ lint.ignore = [
"W191", # Checks for indentation that uses tabs. "W191", # Checks for indentation that uses tabs.
] ]
# Default is 88 characters
line-length = 120
[tool.ruff.lint.per-file-ignores] [tool.ruff.lint.per-file-ignores]
"**/tests/**" = [ "**/tests/**" = [
"ARG", # Unused function args -> fixtures nevertheless are functionally relevant... "ARG", # Unused function args -> fixtures nevertheless are functionally relevant...
@ -72,51 +62,22 @@ line-length = 120
"RUF012", # Checks for mutable default values in class attributes. "RUF012", # Checks for mutable default values in class attributes.
] ]
# https://www.djlint.com/
[tool.djlint] [tool.djlint]
# Set a profile for the template language. The profile will enable linter rules that apply to your template language, and may also change reformatting.
profile = "django" profile = "django"
# Formatter will attempt to format template syntax inside of tag attributes.
format_attribute_template_tags = true format_attribute_template_tags = true
# Format contents of style tags using css-beautify
format_css = true format_css = true
# Format contents of script tags using js-beautify.
format_js = true format_js = true
ignore = "H006"
# Ignore some rules
ignore = "H006" # Img tag should have height and width attributes.
# https://pytest-django.readthedocs.io/en/latest/
[tool.pytest.ini_options] [tool.pytest.ini_options]
# Where our Django settings are located.
DJANGO_SETTINGS_MODULE = "core.settings" DJANGO_SETTINGS_MODULE = "core.settings"
# Only run tests in files that match this pattern.
python_files = ["*_test.py"] python_files = ["*_test.py"]
# Enable logging in the console.
log_cli = true log_cli = true
log_cli_level = "INFO" log_cli_level = "INFO"
log_cli_format = "%(asctime)s [%(levelname)8s] %(message)s (%(filename)s:%(lineno)s)" log_cli_format = "%(asctime)s [%(levelname)8s] %(message)s (%(filename)s:%(lineno)s)"
log_cli_date_format = "%Y-%m-%d %H:%M:%S" log_cli_date_format = "%Y-%m-%d %H:%M:%S"
# Only check /tests/ directory for tests.
# This will speed up the test run significantly. (5.16s -> 0.25s)
testpaths = ["tests"] testpaths = ["tests"]
[tool.mypy]
plugins = ["mypy_django_plugin.main"]
[tool.django-stubs] [tool.django-stubs]
django_settings_module = "core.settings" django_settings_module = "core.settings"
[tool.black]
line-length = 120
preview = true
unstable = true
[tool.isort]
profile = "black"

View File

@ -1,274 +0,0 @@
from __future__ import annotations
import json
from pathlib import Path
from typing import Any
from unittest.mock import MagicMock, patch
import pytest
from core.import_json import (
find_typename_in_json,
import_data,
import_drop_benefits,
import_drop_campaigns,
import_game_data,
import_owner_data,
import_time_based_drops,
type_names,
)
from core.models import Benefit, DropCampaign, Game, Owner, TimeBasedDrop
def _validate_extraction(json: dict, typename: type_names, no_result_err_msg: str, id_err_msg: str) -> dict[str, Any]:
result: dict[str, Any] = find_typename_in_json(json, typename)[0]
assert result, no_result_err_msg
assert result.get("id"), id_err_msg
return result
def test_find_typename_in_json() -> None:
"""Test the find_typename_in_json function."""
json_file_raw: str = Path("tests/response.json").read_text(encoding="utf-8")
json_file: dict = json.loads(json_file_raw)
result: list[dict[str, Any]] = find_typename_in_json(json_file, typename_to_find="DropCampaign")
assert len(result) == 20
assert result[0]["__typename"] == "DropCampaign"
assert result[0]["id"] == "5b5816c8-a533-11ef-9266-0a58a9feac02"
@pytest.mark.django_db
def test_import_game_data() -> None:
"""Test the import_game_data function."""
json_file_raw: str = Path("tests/response.json").read_text(encoding="utf-8")
json_file: dict = json.loads(json_file_raw)
game_json: dict[str, Any] = _validate_extraction(
json=json_file,
typename="Game",
no_result_err_msg="Game JSON not found",
id_err_msg="Game ID not found",
)
assert game_json.get("id") == "155409827", f"Game ID does not match expected value: {game_json.get('id')}"
assert game_json.get("slug") == "pokemon-trading-card-game-live", "Game slug does not match expected value"
assert_msg: str = f"Game display name does not match expected value: {game_json.get('displayName')}"
assert game_json.get("displayName") == "Pokémon Trading Card Game Live", assert_msg
assert_msg: str = f"Game URL does not match expected value: {game_json.get('gameUrl')}"
assert game_json.get("__typename") == "Game", assert_msg
owner_json: dict[str, Any] = _validate_extraction(
json=json_file,
typename="Organization",
no_result_err_msg="Owner JSON not found",
id_err_msg="Owner ID not found",
)
owner, created = Owner.objects.get_or_create(twitch_id=owner_json.get("id"))
assert_msg: str = f"Owner was not created: {owner=} != {owner_json.get('id')}. This means the old database was used instead of a new one." # noqa: E501
assert created, assert_msg
assert owner
game: Game = import_game_data(drop_campaign=game_json, owner=owner)
assert game, f"Failed to import JSON data into Game model: {game_json=}"
assert game.org == owner, f"Owner was not set on the Game model: {game.org=} != {owner=}"
assert game.display_name == game_json.get("displayName"), "Game display name was not set on the Game model"
assert_msg: str = f"Game slug was not set on the Game model: {game.slug=} != {game_json.get('slug')}"
assert game.slug == game_json.get("slug"), assert_msg
assert_msg: str = f"Game ID was not set on the Game model: {game.twitch_id=} != {game_json.get('id')}"
assert game.twitch_id == game_json.get("id"), assert_msg
assert_msg: str = f"Game URL was not set on the Game model: {game.game_url} != https://www.twitch.tv/directory/category/{game.slug}"
assert game.game_url == f"https://www.twitch.tv/directory/category/{game.slug}", assert_msg
assert game.created_at, "Game created_at was not set on the Game model"
assert game.modified_at, "Game modified_at was not set on the Game model"
@pytest.mark.django_db
def test_import_owner_data() -> None:
"""Test the import_owner_data function."""
json_file_raw: str = Path("tests/response.json").read_text(encoding="utf-8")
json_file: dict = json.loads(json_file_raw)
owner_json: dict[str, Any] = _validate_extraction(
json=json_file,
typename="Organization",
no_result_err_msg="Owner JSON not found",
id_err_msg="Owner ID not found",
)
owner: Owner = import_owner_data(drop_campaign=owner_json)
assert owner, f"Failed to import JSON data into Owner model: {owner_json=}"
assert_msg: str = f"Owner ID was not set on the Owner model: {owner.twitch_id=} != {owner_json.get('id')}"
assert owner.twitch_id == owner_json.get("id"), assert_msg
assert_msg: str = f"Owner name was not set on the Owner model: {owner.name=} != {owner_json.get('name')}"
assert owner.name == owner_json.get("name"), assert_msg
assert owner.created_at, "Owner created_at was not set on the Owner model"
assert owner.modified_at, "Owner modified_at was not set on the Owner model"
@pytest.mark.django_db
def test_import_drop_benefits() -> None:
"""Test the import_drop_benefits function."""
json_file_raw: str = Path("tests/response.json").read_text(encoding="utf-8")
json_file: dict = json.loads(json_file_raw)
drop_campaign_json: dict[str, Any] = _validate_extraction(
json=json_file,
typename="DropCampaign",
no_result_err_msg="DropCampaign JSON not found",
id_err_msg="DropCampaign ID not found",
)
assert drop_campaign_json
drop_campaign: DropCampaign | None = import_drop_campaigns(drop_campaigns=drop_campaign_json)
assert drop_campaign, f"Failed to import JSON data into DropCampaign model: {drop_campaign_json=}"
assert find_typename_in_json(drop_campaign_json, "TimeBasedDrop"), "TimeBasedDrop JSON not found"
time_based_drop_json: dict[str, Any] = _validate_extraction(
json=drop_campaign_json,
typename="TimeBasedDrop",
no_result_err_msg="TimeBasedDrop JSON not found",
id_err_msg="TimeBasedDrop ID not found",
)
assert time_based_drop_json
time_based_drop: list[TimeBasedDrop] = import_time_based_drops(
drop_campaign_json=time_based_drop_json,
drop_campaign=drop_campaign,
)
assert time_based_drop, f"Failed to import JSON data into TimeBasedDrop model: {time_based_drop_json=}"
drop_benefit_json: dict[str, Any] = _validate_extraction(
json=drop_campaign_json,
typename="DropBenefit",
no_result_err_msg="DropBenefit JSON not found",
id_err_msg="DropBenefit ID not found",
)
drop_benefit: list[Benefit] = import_drop_benefits(drop_benefit_json, time_based_drop[0])
assert drop_benefit, f"Failed to import JSON data into DropBenefit model: {drop_benefit_json=}"
assert drop_benefit[0].twitch_id == drop_benefit_json.get("id"), "Benefit ID was not set on the Benefit model"
assert_msg: str = f"DropBenefit created_at was not set on the Benefit model: {drop_benefit[0].created_at=}"
assert drop_benefit[0].created_at, assert_msg
assert_msg = f"DropBenefit modified_at was not set on the Benefit model: {drop_benefit[0].modified_at=}"
assert drop_benefit[0].modified_at, assert_msg
assert drop_benefit[0].time_based_drop == time_based_drop[0], "TimeBasedDrop was not set on the Benefit model"
@pytest.mark.django_db
def test_import_time_based_drops() -> None:
"""Test the import_time_based_drops function."""
json_file_raw: str = Path("tests/response.json").read_text(encoding="utf-8")
json_file: dict = json.loads(json_file_raw)
drop_campaign_json: dict[str, Any] = _validate_extraction(
json=json_file,
typename="DropCampaign",
no_result_err_msg="DropCampaign JSON not found",
id_err_msg="DropCampaign ID not found",
)
assert drop_campaign_json
drop_campaign: DropCampaign | None = import_drop_campaigns(drop_campaigns=drop_campaign_json)
assert drop_campaign, f"Failed to import JSON data into DropCampaign model: {drop_campaign_json=}"
time_based_drop_json: dict[str, Any] = _validate_extraction(
json=drop_campaign_json,
typename="TimeBasedDrop",
no_result_err_msg="TimeBasedDrop JSON not found",
id_err_msg="TimeBasedDrop ID not found",
)
assert time_based_drop_json
time_based_drop: list[TimeBasedDrop] = import_time_based_drops(
drop_campaign_json=time_based_drop_json,
drop_campaign=drop_campaign,
)
assert time_based_drop, f"Failed to import JSON data into TimeBasedDrop model: {time_based_drop_json=}"
assert time_based_drop[0].twitch_id == time_based_drop_json.get(
"id",
), "TimeBasedDrop ID was not set on the TimeBasedDrop model"
assert_msg: str = (
f"TimeBasedDrop created_at was not set on the TimeBasedDrop model: {time_based_drop[0].created_at=}"
)
assert time_based_drop[0].created_at, assert_msg
assert_msg = f"TimeBasedDrop modified_at was not set on the TimeBasedDrop model: {time_based_drop[0].modified_at=}"
assert time_based_drop[0].modified_at, assert_msg
assert time_based_drop[0].drop_campaign == drop_campaign, "DropCampaign was not set on the TimeBasedDrop model"
@pytest.mark.django_db
def test_import_drop_campaigns() -> None:
"""Test the import_drop_campaigns function."""
json_file_raw: str = Path("tests/response.json").read_text(encoding="utf-8")
json_file: dict = json.loads(json_file_raw)
drop_campaign_json: dict[str, Any] = _validate_extraction(
json=json_file,
typename="DropCampaign",
no_result_err_msg="DropCampaign JSON not found",
id_err_msg="DropCampaign ID not found",
)
assert drop_campaign_json
drop_campaign: DropCampaign | None = import_drop_campaigns(drop_campaigns=drop_campaign_json)
assert drop_campaign, f"Failed to import JSON data into DropCampaign model: {drop_campaign_json=}"
assert drop_campaign.twitch_id == drop_campaign_json.get(
"id",
), "DropCampaign ID was not set on the DropCampaign model"
assert_msg: str = f"DropCampaign created_at was not set on the DropCampaign model: {drop_campaign.created_at=}"
assert drop_campaign.created_at, assert_msg
assert_msg = f"DropCampaign modified_at was not set on the DropCampaign model: {drop_campaign.modified_at=}"
assert drop_campaign.modified_at, assert_msg
@pytest.fixture
def sample_data() -> dict[str, Any]:
"""Sample data for testing import_data."""
return {
"__typename": "Root",
"data": [
{"__typename": "DropCampaign", "id": "campaign1", "name": "Campaign 1"},
{"__typename": "DropCampaign", "id": "campaign2", "name": "Campaign 2"},
],
}
@pytest.fixture
def empty_data() -> dict[str, Any]:
"""Empty data for testing import_data."""
return {"__typename": "Root", "data": []}
@patch("core.import_json.import_drop_campaigns")
def test_import_data(mock_import_drop_campaigns: MagicMock, sample_data: dict[str, Any]) -> None:
"""Test the import_data function with valid data."""
import_data(sample_data)
assert mock_import_drop_campaigns.call_count == 2
def test_import_data_no_campaigns(empty_data: dict[str, Any]) -> None:
"""Test the import_data function with no drop campaigns."""
with patch("core.import_json.import_drop_campaigns") as mock_import_drop_campaigns:
import_data(empty_data)
mock_import_drop_campaigns.assert_not_called()