Rewrite everything
This commit is contained in:
4
.vscode/settings.json
vendored
4
.vscode/settings.json
vendored
@ -1,10 +1,12 @@
|
||||
{
|
||||
"cSpell.words": [
|
||||
"aimport",
|
||||
"allauth",
|
||||
"appendonly",
|
||||
"asgiref",
|
||||
"Behaviour",
|
||||
"cacd",
|
||||
"dropcampaign",
|
||||
"dungeonborne",
|
||||
"forloop",
|
||||
"logdir",
|
||||
@ -14,10 +16,12 @@
|
||||
"PGID",
|
||||
"PUID",
|
||||
"requirepass",
|
||||
"rewardcampaign",
|
||||
"sitewide",
|
||||
"socialaccount",
|
||||
"Stresss",
|
||||
"templatetags",
|
||||
"timebaseddrop",
|
||||
"tocs",
|
||||
"ttvdrops",
|
||||
"ulimits",
|
||||
|
@ -20,19 +20,17 @@ class Command(BaseCommand):
|
||||
*args: Variable length argument list.
|
||||
**kwargs: Arbitrary keyword arguments.
|
||||
"""
|
||||
dirs: list[str] = ["drop_campaigns", "reward_campaigns", "drop_campaigns"]
|
||||
for dir_name in dirs:
|
||||
logger.info("Scraping %s", dir_name)
|
||||
for num, file in enumerate(Path(dir_name).rglob("*.json")):
|
||||
logger.info("Processing %s", file)
|
||||
dir_name = Path("json")
|
||||
for num, file in enumerate(Path(dir_name).rglob("*.json")):
|
||||
logger.info("Processing %s", file)
|
||||
|
||||
with file.open(encoding="utf-8") as f:
|
||||
try:
|
||||
load_json = json.load(f)
|
||||
except json.JSONDecodeError:
|
||||
logger.exception("Failed to load JSON from %s", file)
|
||||
continue
|
||||
asyncio.run(main=process_json_data(num=num, campaign=load_json, local=True))
|
||||
with file.open(encoding="utf-8") as f:
|
||||
try:
|
||||
load_json = json.load(f)
|
||||
except json.JSONDecodeError:
|
||||
logger.exception("Failed to load JSON from %s", file)
|
||||
continue
|
||||
asyncio.run(main=process_json_data(num=num, campaign=load_json, local=True))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
@ -10,7 +10,7 @@ from platformdirs import user_data_dir
|
||||
from playwright.async_api import Playwright, async_playwright
|
||||
from playwright.async_api._generated import Response
|
||||
|
||||
from core.models import Benefit, DropCampaign, Game, Owner, Reward, RewardCampaign, TimeBasedDrop
|
||||
from core.models import Benefit, DropCampaign, Game, Owner, RewardCampaign, TimeBasedDrop
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from playwright.async_api._generated import BrowserContext, Page
|
||||
@ -19,46 +19,36 @@ if TYPE_CHECKING:
|
||||
logger: logging.Logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_data_dir() -> Path:
|
||||
"""Get the data directory.
|
||||
|
||||
Returns:
|
||||
Path: The data directory.
|
||||
"""
|
||||
return Path(
|
||||
user_data_dir(
|
||||
appname="TTVDrops",
|
||||
appauthor="TheLovinator",
|
||||
roaming=True,
|
||||
ensure_exists=True,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def get_profile_dir() -> Path:
|
||||
"""Get the profile directory for the browser.
|
||||
|
||||
Returns:
|
||||
Path: The profile directory.
|
||||
"""
|
||||
profile_dir = Path(get_data_dir() / "chrome-profile")
|
||||
data_dir = Path(
|
||||
user_data_dir(appname="TTVDrops", appauthor="TheLovinator", roaming=True, ensure_exists=True),
|
||||
)
|
||||
profile_dir: Path = data_dir / "chrome-profile"
|
||||
profile_dir.mkdir(parents=True, exist_ok=True)
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
logger.debug("Launching Chrome browser with user data directory: %s", profile_dir)
|
||||
return profile_dir
|
||||
|
||||
|
||||
def save_json(campaign: dict | None, dir_name: str) -> None:
|
||||
def save_json(campaign: dict | None, *, local: bool) -> None:
|
||||
"""Save JSON data to a file.
|
||||
|
||||
Args:
|
||||
campaign (dict): The JSON data to save.
|
||||
dir_name (Path): The directory to save the JSON data to.
|
||||
local (bool): Only save JSON data if we are scraping from the web.
|
||||
"""
|
||||
if local:
|
||||
return
|
||||
|
||||
if not campaign:
|
||||
return
|
||||
|
||||
save_dir = Path(dir_name)
|
||||
save_dir = Path("json")
|
||||
save_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# File name is the hash of the JSON data
|
||||
@ -68,27 +58,19 @@ def save_json(campaign: dict | None, dir_name: str) -> None:
|
||||
json.dump(campaign, f, indent=4)
|
||||
|
||||
|
||||
async def add_reward_campaign(campaign: dict | None) -> None:
|
||||
async def add_reward_campaign(reward_campaign: dict | None) -> None:
|
||||
"""Add a reward campaign to the database.
|
||||
|
||||
Args:
|
||||
campaign (dict): The reward campaign to add.
|
||||
reward_campaign (dict): The reward campaign to add.
|
||||
"""
|
||||
if not campaign:
|
||||
if not reward_campaign:
|
||||
return
|
||||
if "data" in campaign and "rewardCampaignsAvailableToUser" in campaign["data"]:
|
||||
for reward_campaign in campaign["data"]["rewardCampaignsAvailableToUser"]:
|
||||
our_reward_campaign, created = await RewardCampaign.objects.aupdate_or_create(id=reward_campaign["id"])
|
||||
await our_reward_campaign.import_json(reward_campaign)
|
||||
if created:
|
||||
logger.info("Added reward campaign %s", our_reward_campaign)
|
||||
|
||||
if "rewards" in reward_campaign:
|
||||
for reward in reward_campaign["rewards"]:
|
||||
reward_instance, created = await Reward.objects.aupdate_or_create(id=reward["id"])
|
||||
await reward_instance.import_json(reward, our_reward_campaign)
|
||||
if created:
|
||||
logger.info("Added reward %s", reward_instance)
|
||||
our_reward_campaign, created = await RewardCampaign.objects.aupdate_or_create(twitch_id=reward_campaign["id"])
|
||||
await our_reward_campaign.aimport_json(reward_campaign)
|
||||
if created:
|
||||
logger.info("Added reward campaign %s", our_reward_campaign)
|
||||
|
||||
|
||||
async def add_drop_campaign(drop_campaign: dict | None) -> None:
|
||||
@ -100,23 +82,37 @@ async def add_drop_campaign(drop_campaign: dict | None) -> None:
|
||||
if not drop_campaign:
|
||||
return
|
||||
|
||||
if drop_campaign.get("game"):
|
||||
owner, created = await Owner.objects.aupdate_or_create(id=drop_campaign["owner"]["id"])
|
||||
owner.import_json(drop_campaign["owner"])
|
||||
|
||||
game, created = await Game.objects.aupdate_or_create(id=drop_campaign["game"]["id"])
|
||||
await game.import_json(drop_campaign["game"], owner)
|
||||
if created:
|
||||
logger.info("Added game %s", game)
|
||||
|
||||
our_drop_campaign, created = await DropCampaign.objects.aupdate_or_create(id=drop_campaign["id"])
|
||||
await our_drop_campaign.import_json(drop_campaign, game)
|
||||
if not drop_campaign.get("owner", {}):
|
||||
logger.error("Owner not found in drop campaign %s", drop_campaign)
|
||||
return
|
||||
|
||||
owner, created = await Owner.objects.aupdate_or_create(twitch_id=drop_campaign["owner"]["id"])
|
||||
await owner.aimport_json(data=drop_campaign["owner"])
|
||||
if created:
|
||||
logger.info("Added drop campaign %s", our_drop_campaign.id)
|
||||
logger.info("Added owner %s", owner.twitch_id)
|
||||
|
||||
if not drop_campaign.get("game", {}):
|
||||
logger.error("Game not found in drop campaign %s", drop_campaign)
|
||||
return
|
||||
|
||||
game, created = await Game.objects.aupdate_or_create(twitch_id=drop_campaign["game"]["id"])
|
||||
await game.aimport_json(data=drop_campaign["game"], owner=owner)
|
||||
if created:
|
||||
logger.info("Added game %s", game)
|
||||
|
||||
our_drop_campaign, created = await DropCampaign.objects.aupdate_or_create(twitch_id=drop_campaign["id"])
|
||||
await our_drop_campaign.aimport_json(drop_campaign, game)
|
||||
if created:
|
||||
logger.info("Added drop campaign %s", our_drop_campaign.twitch_id)
|
||||
|
||||
await add_time_based_drops(drop_campaign, our_drop_campaign)
|
||||
|
||||
# Check if eventBasedDrops exist
|
||||
if drop_campaign.get("eventBasedDrops"):
|
||||
# TODO(TheLovinator): Add event-based drops # noqa: TD003
|
||||
msg = "Not implemented: Add event-based drops"
|
||||
raise NotImplementedError(msg)
|
||||
|
||||
|
||||
async def add_time_based_drops(drop_campaign: dict, our_drop_campaign: DropCampaign) -> None:
|
||||
"""Add time-based drops to the database.
|
||||
@ -126,33 +122,61 @@ async def add_time_based_drops(drop_campaign: dict, our_drop_campaign: DropCampa
|
||||
our_drop_campaign (DropCampaign): The drop campaign object in the database.
|
||||
"""
|
||||
for time_based_drop in drop_campaign.get("timeBasedDrops", []):
|
||||
time_based_drop: dict[str, typing.Any]
|
||||
if time_based_drop.get("preconditionDrops"):
|
||||
# TODO(TheLovinator): Add precondition drops to time-based drop # noqa: TD003
|
||||
# TODO(TheLovinator): Send JSON to Discord # noqa: TD003
|
||||
msg = "Not implemented: Add precondition drops to time-based drop"
|
||||
raise NotImplementedError(msg)
|
||||
|
||||
our_time_based_drop, created = await TimeBasedDrop.objects.aupdate_or_create(id=time_based_drop["id"])
|
||||
await our_time_based_drop.import_json(time_based_drop, our_drop_campaign)
|
||||
our_time_based_drop, created = await TimeBasedDrop.objects.aupdate_or_create(twitch_id=time_based_drop["id"])
|
||||
await our_time_based_drop.aimport_json(time_based_drop, our_drop_campaign)
|
||||
|
||||
if created:
|
||||
logger.info("Added time-based drop %s", our_time_based_drop.id)
|
||||
logger.info("Added time-based drop %s", our_time_based_drop.twitch_id)
|
||||
|
||||
if our_time_based_drop and time_based_drop.get("benefitEdges"):
|
||||
for benefit_edge in time_based_drop["benefitEdges"]:
|
||||
benefit, created = await Benefit.objects.aupdate_or_create(id=benefit_edge["benefit"])
|
||||
await benefit.import_json(benefit_edge["benefit"], our_time_based_drop)
|
||||
benefit, created = await Benefit.objects.aupdate_or_create(twitch_id=benefit_edge["benefit"])
|
||||
await benefit.aimport_json(benefit_edge["benefit"], our_time_based_drop)
|
||||
if created:
|
||||
logger.info("Added benefit %s", benefit.id)
|
||||
logger.info("Added benefit %s", benefit.twitch_id)
|
||||
|
||||
|
||||
async def process_json_data(num: int, campaign: dict | None) -> None:
|
||||
async def handle_drop_campaigns(drop_campaign: dict) -> None:
|
||||
"""Handle drop campaigns.
|
||||
|
||||
We need to grab the game image in data.currentUser.dropCampaigns.game.boxArtURL.
|
||||
|
||||
Args:
|
||||
drop_campaign (dict): The drop campaign to handle.
|
||||
"""
|
||||
if not drop_campaign:
|
||||
return
|
||||
|
||||
if drop_campaign.get("game", {}).get("boxArtURL"):
|
||||
owner_id = drop_campaign.get("owner", {}).get("id")
|
||||
if not owner_id:
|
||||
logger.error("Owner ID not found in drop campaign %s", drop_campaign)
|
||||
return
|
||||
|
||||
owner, created = await Owner.objects.aupdate_or_create(twitch_id=drop_campaign["owner"]["id"])
|
||||
await owner.aimport_json(drop_campaign["owner"])
|
||||
if created:
|
||||
logger.info("Added owner %s", owner.twitch_id)
|
||||
|
||||
game_obj, created = await Game.objects.aupdate_or_create(twitch_id=drop_campaign["game"]["id"])
|
||||
await game_obj.aimport_json(data=drop_campaign["game"], owner=owner)
|
||||
if created:
|
||||
logger.info("Added game %s", game_obj.twitch_id)
|
||||
|
||||
|
||||
async def process_json_data(num: int, campaign: dict | None, *, local: bool) -> None:
|
||||
"""Process JSON data.
|
||||
|
||||
Args:
|
||||
num (int): The number of the JSON data.
|
||||
campaign (dict): The JSON data to process.
|
||||
local (bool): Only save JSON data if we are scraping from the web.
|
||||
"""
|
||||
logger.info("Processing JSON %d", num)
|
||||
if not campaign:
|
||||
@ -163,20 +187,18 @@ async def process_json_data(num: int, campaign: dict | None) -> None:
|
||||
logger.warning("Campaign is not a dictionary. %s", campaign)
|
||||
return
|
||||
|
||||
# This is a Reward Campaign
|
||||
if "rewardCampaignsAvailableToUser" in campaign.get("data", {}):
|
||||
save_json(campaign=campaign, dir_name="reward_campaigns")
|
||||
await add_reward_campaign(campaign=campaign)
|
||||
save_json(campaign=campaign, local=local)
|
||||
|
||||
if "dropCampaign" in campaign.get("data", {}).get("user", {}):
|
||||
save_json(campaign=campaign, dir_name="drop_campaign")
|
||||
if campaign.get("data", {}).get("user", {}).get("dropCampaign"):
|
||||
await add_drop_campaign(drop_campaign=campaign["data"]["user"]["dropCampaign"])
|
||||
if campaign.get("data", {}).get("rewardCampaignsAvailableToUser"):
|
||||
for reward_campaign in campaign["data"]["rewardCampaignsAvailableToUser"]:
|
||||
await add_reward_campaign(reward_campaign=reward_campaign)
|
||||
|
||||
if "dropCampaigns" in campaign.get("data", {}).get("currentUser", {}):
|
||||
if campaign.get("data", {}).get("user", {}).get("dropCampaign"):
|
||||
await add_drop_campaign(drop_campaign=campaign["data"]["user"]["dropCampaign"])
|
||||
|
||||
if campaign.get("data", {}).get("currentUser", {}).get("dropCampaigns"):
|
||||
for drop_campaign in campaign["data"]["currentUser"]["dropCampaigns"]:
|
||||
save_json(campaign=campaign, dir_name="drop_campaigns")
|
||||
await add_drop_campaign(drop_campaign=drop_campaign)
|
||||
await handle_drop_campaigns(drop_campaign=drop_campaign)
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
@ -232,7 +254,7 @@ class Command(BaseCommand):
|
||||
await browser.close()
|
||||
|
||||
for num, campaign in enumerate(json_data, start=1):
|
||||
await process_json_data(num=num, campaign=campaign)
|
||||
await process_json_data(num=num, campaign=campaign, local=True)
|
||||
|
||||
return json_data
|
||||
|
||||
|
@ -1,4 +1,4 @@
|
||||
# Generated by Django 5.1 on 2024-08-16 02:38
|
||||
# Generated by Django 5.1 on 2024-09-01 22:36
|
||||
|
||||
import django.contrib.auth.models
|
||||
import django.contrib.auth.validators
|
||||
@ -16,26 +16,12 @@ class Migration(migrations.Migration):
|
||||
]
|
||||
|
||||
operations: list[Operation] = [
|
||||
migrations.CreateModel(
|
||||
name="DropCampaign",
|
||||
fields=[
|
||||
("created_at", models.DateTimeField(auto_created=True, null=True)),
|
||||
("id", models.TextField(primary_key=True, serialize=False)),
|
||||
("modified_at", models.DateTimeField(auto_now=True, null=True)),
|
||||
("account_link_url", models.URLField(null=True)),
|
||||
("description", models.TextField(null=True)),
|
||||
("details_url", models.URLField(null=True)),
|
||||
("ends_at", models.DateTimeField(null=True)),
|
||||
("starts_at", models.DateTimeField(null=True)),
|
||||
("image_url", models.URLField(null=True)),
|
||||
("name", models.TextField(null=True)),
|
||||
("status", models.TextField(null=True)),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="Game",
|
||||
fields=[
|
||||
("created_at", models.DateTimeField(auto_created=True, null=True)),
|
||||
("twitch_id", models.TextField(primary_key=True, serialize=False)),
|
||||
("modified_at", models.DateTimeField(auto_now=True, null=True)),
|
||||
("game_url", models.URLField(default="https://www.twitch.tv/", null=True)),
|
||||
("name", models.TextField(default="Game name unknown", null=True)),
|
||||
(
|
||||
@ -48,8 +34,10 @@ class Migration(migrations.Migration):
|
||||
migrations.CreateModel(
|
||||
name="Owner",
|
||||
fields=[
|
||||
("id", models.TextField(primary_key=True, serialize=False)),
|
||||
("name", models.TextField(null=True)),
|
||||
("created_at", models.DateTimeField(auto_created=True, null=True)),
|
||||
("twitch_id", models.TextField(primary_key=True, serialize=False)),
|
||||
("modified_at", models.DateTimeField(auto_now=True, null=True)),
|
||||
("name", models.TextField(default="Unknown", null=True)),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
@ -130,26 +118,30 @@ class Migration(migrations.Migration):
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="Channel",
|
||||
name="DropCampaign",
|
||||
fields=[
|
||||
("twitch_id", models.TextField(primary_key=True, serialize=False)),
|
||||
("display_name", models.TextField(default="Channel name unknown", null=True)),
|
||||
("name", models.TextField(null=True)),
|
||||
("twitch_url", models.URLField(default="https://www.twitch.tv/", null=True)),
|
||||
("live", models.BooleanField(default=False)),
|
||||
("drop_campaigns", models.ManyToManyField(related_name="channels", to="core.dropcampaign")),
|
||||
("created_at", models.DateTimeField(auto_created=True, null=True)),
|
||||
("id", models.TextField(primary_key=True, serialize=False)),
|
||||
("modified_at", models.DateTimeField(auto_now=True, null=True)),
|
||||
("account_link_url", models.URLField(null=True)),
|
||||
("description", models.TextField(null=True)),
|
||||
("details_url", models.URLField(null=True)),
|
||||
("ends_at", models.DateTimeField(null=True)),
|
||||
("starts_at", models.DateTimeField(null=True)),
|
||||
("image_url", models.URLField(null=True)),
|
||||
("name", models.TextField(default="Unknown", null=True)),
|
||||
("status", models.TextField(null=True)),
|
||||
(
|
||||
"game",
|
||||
models.ForeignKey(
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="drop_campaigns",
|
||||
to="core.game",
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="dropcampaign",
|
||||
name="game",
|
||||
field=models.ForeignKey(
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="drop_campaigns",
|
||||
to="core.game",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="game",
|
||||
name="org",
|
||||
@ -164,7 +156,7 @@ class Migration(migrations.Migration):
|
||||
name="RewardCampaign",
|
||||
fields=[
|
||||
("created_at", models.DateTimeField(auto_created=True, null=True)),
|
||||
("id", models.TextField(primary_key=True, serialize=False)),
|
||||
("twitch_id", models.TextField(primary_key=True, serialize=False)),
|
||||
("modified_at", models.DateTimeField(auto_now=True, null=True)),
|
||||
("name", models.TextField(null=True)),
|
||||
("brand", models.TextField(null=True)),
|
||||
@ -194,7 +186,9 @@ class Migration(migrations.Migration):
|
||||
migrations.CreateModel(
|
||||
name="Reward",
|
||||
fields=[
|
||||
("id", models.TextField(primary_key=True, serialize=False)),
|
||||
("created_at", models.DateTimeField(auto_created=True, null=True)),
|
||||
("twitch_id", models.TextField(primary_key=True, serialize=False)),
|
||||
("modified_at", models.DateTimeField(auto_now=True, null=True)),
|
||||
("name", models.TextField(null=True)),
|
||||
("banner_image_url", models.URLField(null=True)),
|
||||
("thumbnail_image_url", models.URLField(null=True)),
|
||||
@ -216,11 +210,11 @@ class Migration(migrations.Migration):
|
||||
name="TimeBasedDrop",
|
||||
fields=[
|
||||
("created_at", models.DateTimeField(auto_created=True, null=True)),
|
||||
("id", models.TextField(primary_key=True, serialize=False)),
|
||||
("twitch_id", models.TextField(primary_key=True, serialize=False)),
|
||||
("modified_at", models.DateTimeField(auto_now=True, null=True)),
|
||||
("required_subs", models.PositiveBigIntegerField(null=True)),
|
||||
("ends_at", models.DateTimeField(null=True)),
|
||||
("name", models.TextField(null=True)),
|
||||
("name", models.TextField(default="Unknown", null=True)),
|
||||
("required_minutes_watched", models.PositiveBigIntegerField(null=True)),
|
||||
("starts_at", models.DateTimeField(null=True)),
|
||||
(
|
||||
@ -238,7 +232,7 @@ class Migration(migrations.Migration):
|
||||
name="Benefit",
|
||||
fields=[
|
||||
("created_at", models.DateTimeField(auto_created=True, null=True)),
|
||||
("id", models.TextField(primary_key=True, serialize=False)),
|
||||
("twitch_id", models.TextField(primary_key=True, serialize=False)),
|
||||
("modified_at", models.DateTimeField(auto_now=True, null=True)),
|
||||
("twitch_created_at", models.DateTimeField(null=True)),
|
||||
("entitlement_limit", models.PositiveBigIntegerField(null=True)),
|
||||
@ -259,14 +253,16 @@ class Migration(migrations.Migration):
|
||||
migrations.CreateModel(
|
||||
name="Webhook",
|
||||
fields=[
|
||||
("created_at", models.DateTimeField(auto_created=True, null=True)),
|
||||
("id", models.TextField(primary_key=True, serialize=False)),
|
||||
("avatar", models.TextField(null=True)),
|
||||
("channel_id", models.TextField(null=True)),
|
||||
("guild_id", models.TextField(null=True)),
|
||||
("id", models.TextField(primary_key=True, serialize=False)),
|
||||
("name", models.TextField(null=True)),
|
||||
("type", models.TextField(null=True)),
|
||||
("token", models.TextField()),
|
||||
("url", models.TextField()),
|
||||
("modified_at", models.DateTimeField(auto_now=True, null=True)),
|
||||
("seen_drops", models.ManyToManyField(related_name="seen_drops", to="core.dropcampaign")),
|
||||
(
|
||||
"subscribed_live_games",
|
||||
|
@ -1,15 +0,0 @@
|
||||
# Generated by Django 5.1 on 2024-09-01 02:59
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("core", "0001_initial"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.DeleteModel(
|
||||
name="Channel",
|
||||
),
|
||||
]
|
18
core/migrations/0002_rename_id_dropcampaign_twitch_id.py
Normal file
18
core/migrations/0002_rename_id_dropcampaign_twitch_id.py
Normal file
@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.1 on 2024-09-02 23:28
|
||||
|
||||
from django.db import migrations
|
||||
from django.db.migrations.operations.base import Operation
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies: list[tuple[str, str]] = [
|
||||
("core", "0001_initial"),
|
||||
]
|
||||
|
||||
operations: list[Operation] = [
|
||||
migrations.RenameField(
|
||||
model_name="dropcampaign",
|
||||
old_name="id",
|
||||
new_name="twitch_id",
|
||||
),
|
||||
]
|
@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.1 on 2024-09-07 19:19
|
||||
|
||||
from django.db import migrations
|
||||
from django.db.migrations.operations.base import Operation
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies: list[tuple[str, str]] = [
|
||||
("core", "0002_rename_id_dropcampaign_twitch_id"),
|
||||
]
|
||||
|
||||
operations: list[Operation] = [
|
||||
migrations.RenameField(
|
||||
model_name="rewardcampaign",
|
||||
old_name="sub_goal",
|
||||
new_name="subs_goal",
|
||||
),
|
||||
]
|
@ -0,0 +1,43 @@
|
||||
# Generated by Django 5.1 on 2024-09-09 02:34
|
||||
|
||||
from django.db import migrations, models
|
||||
from django.db.migrations.operations.base import Operation
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies: list[tuple[str, str]] = [
|
||||
("core", "0003_rename_sub_goal_rewardcampaign_subs_goal"),
|
||||
]
|
||||
|
||||
operations: list[Operation] = [
|
||||
migrations.AlterField(
|
||||
model_name="dropcampaign",
|
||||
name="name",
|
||||
field=models.TextField(null=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="game",
|
||||
name="box_art_url",
|
||||
field=models.URLField(null=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="game",
|
||||
name="game_url",
|
||||
field=models.URLField(null=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="game",
|
||||
name="name",
|
||||
field=models.TextField(null=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="owner",
|
||||
name="name",
|
||||
field=models.TextField(null=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="timebaseddrop",
|
||||
name="name",
|
||||
field=models.TextField(null=True),
|
||||
),
|
||||
]
|
576
core/models.py
576
core/models.py
@ -1,8 +1,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import ClassVar, Self
|
||||
|
||||
from asgiref.sync import sync_to_async
|
||||
from django.contrib.auth.models import AbstractUser
|
||||
from django.db import models
|
||||
|
||||
@ -18,18 +20,28 @@ class Owner(models.Model):
|
||||
Drops will be grouped by the owner. Users can also subscribe to owners.
|
||||
"""
|
||||
|
||||
id = models.TextField(primary_key=True) # "ad299ac0-f1a5-417d-881d-952c9aed00e9"
|
||||
name = models.TextField(null=True) # "Microsoft"
|
||||
# "ad299ac0-f1a5-417d-881d-952c9aed00e9"
|
||||
twitch_id = models.TextField(primary_key=True)
|
||||
|
||||
# When the owner was first added to the database.
|
||||
created_at = models.DateTimeField(null=True, auto_created=True)
|
||||
|
||||
# When the owner was last modified.
|
||||
modified_at = models.DateTimeField(null=True, auto_now=True)
|
||||
|
||||
# "Microsoft"
|
||||
name = models.TextField(null=True)
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.name or "Owner name unknown"
|
||||
return self.name or self.twitch_id
|
||||
|
||||
def import_json(self, data: dict | None) -> Self:
|
||||
async def aimport_json(self, data: dict | None) -> Self:
|
||||
if not data:
|
||||
return self
|
||||
|
||||
self.name = data.get("name", self.name)
|
||||
self.save()
|
||||
if data.get("name") and data["name"] != self.name:
|
||||
self.name = data["name"]
|
||||
await self.asave()
|
||||
|
||||
return self
|
||||
|
||||
@ -37,16 +49,23 @@ class Owner(models.Model):
|
||||
class Game(models.Model):
|
||||
"""This is the game we will see on the front end."""
|
||||
|
||||
twitch_id = models.TextField(primary_key=True) # "509658"
|
||||
# "509658"
|
||||
twitch_id = models.TextField(primary_key=True)
|
||||
|
||||
# When the game was first added to the database.
|
||||
created_at = models.DateTimeField(null=True, auto_created=True)
|
||||
|
||||
# When the game was last modified.
|
||||
modified_at = models.DateTimeField(null=True, auto_now=True)
|
||||
|
||||
# "https://www.twitch.tv/directory/category/halo-infinite"
|
||||
game_url = models.URLField(null=True, default="https://www.twitch.tv/")
|
||||
game_url = models.URLField(null=True)
|
||||
|
||||
# "Halo Infinite"
|
||||
name = models.TextField(null=True, default="Game name unknown")
|
||||
name = models.TextField(null=True)
|
||||
|
||||
# "https://static-cdn.jtvnw.net/ttv-boxart/Halo%20Infinite.jpg"
|
||||
box_art_url = models.URLField(null=True, default="https://static-cdn.jtvnw.net/ttv-static/404_boxart.jpg")
|
||||
box_art_url = models.URLField(null=True)
|
||||
|
||||
# "halo-infinite"
|
||||
slug = models.TextField(null=True)
|
||||
@ -56,22 +75,37 @@ class Game(models.Model):
|
||||
def __str__(self) -> str:
|
||||
return self.name or self.twitch_id
|
||||
|
||||
async def import_json(self, data: dict | None, owner: Owner | None) -> Self:
|
||||
async def aimport_json(self, data: dict | None, owner: Owner | None) -> Self:
|
||||
# Only update if the data is different.
|
||||
dirty = 0
|
||||
|
||||
if not data:
|
||||
logger.error("No data provided for %s.", self)
|
||||
return self
|
||||
|
||||
self.name = data.get("name", self.name)
|
||||
self.box_art_url = data.get("boxArtURL", self.box_art_url)
|
||||
self.slug = data.get("slug", self.slug)
|
||||
if data["__typename"] != "Game":
|
||||
logger.error("Not a game? %s", data)
|
||||
return self
|
||||
|
||||
if data.get("slug"):
|
||||
if data.get("displayName") and data["displayName"] != self.name:
|
||||
self.name = data["displayName"]
|
||||
dirty += 1
|
||||
|
||||
if data.get("boxArtURL") and data["boxArtURL"] != self.box_art_url:
|
||||
self.box_art_url = data["boxArtURL"]
|
||||
dirty += 1
|
||||
|
||||
if data.get("slug") and data["slug"] != self.slug:
|
||||
self.slug = data["slug"]
|
||||
self.game_url = f"https://www.twitch.tv/directory/game/{data["slug"]}"
|
||||
dirty += 1
|
||||
|
||||
if owner:
|
||||
await owner.games.aadd(self) # type: ignore # noqa: PGH003
|
||||
|
||||
self.save()
|
||||
if dirty > 0:
|
||||
await self.asave()
|
||||
logger.info("Updated game %s", self)
|
||||
|
||||
return self
|
||||
|
||||
@ -80,8 +114,12 @@ class DropCampaign(models.Model):
|
||||
"""This is the drop campaign we will see on the front end."""
|
||||
|
||||
# "f257ce6e-502a-11ef-816e-0a58a9feac02"
|
||||
id = models.TextField(primary_key=True)
|
||||
twitch_id = models.TextField(primary_key=True)
|
||||
|
||||
# When the drop campaign was first added to the database.
|
||||
created_at = models.DateTimeField(null=True, auto_created=True)
|
||||
|
||||
# When the drop campaign was last modified.
|
||||
modified_at = models.DateTimeField(null=True, auto_now=True)
|
||||
|
||||
# "https://www.halowaypoint.com/settings/linked-accounts"
|
||||
@ -95,41 +133,86 @@ class DropCampaign(models.Model):
|
||||
|
||||
# "2024-08-12T05:59:59.999Z"
|
||||
ends_at = models.DateTimeField(null=True)
|
||||
|
||||
# "2024-08-11T11:00:00Z""
|
||||
starts_at = models.DateTimeField(null=True)
|
||||
|
||||
game = models.ForeignKey(Game, on_delete=models.CASCADE, related_name="drop_campaigns", null=True)
|
||||
|
||||
# "https://static-cdn.jtvnw.net/twitch-quests-assets/CAMPAIGN/c8e02666-8b86-471f-bf38-7ece29a758e4.png"
|
||||
image_url = models.URLField(null=True)
|
||||
|
||||
# "HCS Open Series - Week 1 - DAY 2 - AUG11"
|
||||
name = models.TextField(null=True, default="Unknown")
|
||||
name = models.TextField(null=True)
|
||||
|
||||
# "ACTIVE"
|
||||
status = models.TextField(null=True)
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.name or self.id
|
||||
game = models.ForeignKey(Game, on_delete=models.CASCADE, related_name="drop_campaigns", null=True)
|
||||
|
||||
class Meta:
|
||||
ordering: ClassVar[list[str]] = ["ends_at"]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.name or self.twitch_id
|
||||
|
||||
async def aimport_json(self, data: dict | None, game: Game | None) -> Self:
|
||||
# Only update if the data is different.
|
||||
dirty = 0
|
||||
|
||||
async def import_json(self, data: dict | None, game: Game) -> Self:
|
||||
if not data:
|
||||
logger.error("No data provided for %s.", self)
|
||||
return self
|
||||
|
||||
self.name = data.get("name", self.name)
|
||||
self.account_link_url = data.get("accountLinkURL", self.account_link_url)
|
||||
self.description = data.get("description", self.description)
|
||||
self.details_url = data.get("detailsURL", self.details_url)
|
||||
self.ends_at = data.get("endAt", self.ends_at)
|
||||
self.starts_at = data.get("startAt", self.starts_at)
|
||||
self.status = data.get("status", self.status)
|
||||
self.image_url = data.get("imageURL", self.image_url)
|
||||
if data.get("__typename") and data["__typename"] != "DropCampaign":
|
||||
logger.error("Not a drop campaign? %s", data)
|
||||
return self
|
||||
|
||||
if game:
|
||||
if data.get("name") and data["name"] != self.name:
|
||||
self.name = data["name"]
|
||||
dirty += 1
|
||||
|
||||
if data.get("accountLinkURL") and data["accountLinkURL"] != self.account_link_url:
|
||||
self.account_link_url = data["accountLinkURL"]
|
||||
dirty += 1
|
||||
|
||||
if data.get("description") and data["description"] != self.description:
|
||||
self.description = data["description"]
|
||||
dirty += 1
|
||||
|
||||
if data.get("detailsURL") and data["detailsURL"] != self.details_url:
|
||||
self.details_url = data["detailsURL"]
|
||||
dirty += 1
|
||||
|
||||
end_at_str = data.get("endAt")
|
||||
if end_at_str:
|
||||
end_at: datetime = datetime.fromisoformat(end_at_str.replace("Z", "+00:00"))
|
||||
if end_at != self.ends_at:
|
||||
self.ends_at = end_at
|
||||
dirty += 1
|
||||
|
||||
start_at_str = data.get("startAt")
|
||||
if start_at_str:
|
||||
start_at: datetime = datetime.fromisoformat(start_at_str.replace("Z", "+00:00"))
|
||||
if start_at != self.starts_at:
|
||||
self.starts_at = start_at
|
||||
dirty += 1
|
||||
|
||||
status = data.get("status")
|
||||
if status and status != self.status and status == "ACTIVE" and self.status != "EXPIRED":
|
||||
# If it is EXPIRED, we should not set it to ACTIVE again.
|
||||
# TODO(TheLovinator): Set ACTIVE if ACTIVE on Twitch? # noqa: TD003
|
||||
self.status = status
|
||||
dirty += 1
|
||||
|
||||
if data.get("imageURL") and data["imageURL"] != self.image_url:
|
||||
self.image_url = data["imageURL"]
|
||||
dirty += 1
|
||||
|
||||
if game and await sync_to_async(lambda: game != self.game)():
|
||||
self.game = game
|
||||
|
||||
self.save()
|
||||
if dirty > 0:
|
||||
await self.asave()
|
||||
logger.info("Updated drop campaign %s", self)
|
||||
|
||||
return self
|
||||
|
||||
@ -137,36 +220,89 @@ class DropCampaign(models.Model):
|
||||
class TimeBasedDrop(models.Model):
|
||||
"""This is the drop we will see on the front end."""
|
||||
|
||||
id = models.TextField(primary_key=True) # "d5cdf372-502b-11ef-bafd-0a58a9feac02"
|
||||
created_at = models.DateTimeField(null=True, auto_created=True) # "2024-08-11T00:00:00Z"
|
||||
modified_at = models.DateTimeField(null=True, auto_now=True) # "2024-08-12T00:00:00Z"
|
||||
# "d5cdf372-502b-11ef-bafd-0a58a9feac02"
|
||||
twitch_id = models.TextField(primary_key=True)
|
||||
|
||||
required_subs = models.PositiveBigIntegerField(null=True) # "1"
|
||||
ends_at = models.DateTimeField(null=True) # "2024-08-12T05:59:59.999Z"
|
||||
name = models.TextField(null=True) # "Cosmic Nexus Chimera"
|
||||
required_minutes_watched = models.PositiveBigIntegerField(null=True) # "120"
|
||||
starts_at = models.DateTimeField(null=True) # "2024-08-11T11:00:00Z"
|
||||
# When the drop was first added to the database.
|
||||
created_at = models.DateTimeField(null=True, auto_created=True)
|
||||
|
||||
# When the drop was last modified.
|
||||
modified_at = models.DateTimeField(null=True, auto_now=True)
|
||||
|
||||
# "1"
|
||||
required_subs = models.PositiveBigIntegerField(null=True)
|
||||
|
||||
# "2024-08-12T05:59:59.999Z"
|
||||
ends_at = models.DateTimeField(null=True)
|
||||
|
||||
# "Cosmic Nexus Chimera"
|
||||
name = models.TextField(null=True)
|
||||
|
||||
# "120"
|
||||
required_minutes_watched = models.PositiveBigIntegerField(null=True)
|
||||
|
||||
# "2024-08-11T11:00:00Z"
|
||||
starts_at = models.DateTimeField(null=True)
|
||||
|
||||
drop_campaign = models.ForeignKey(DropCampaign, on_delete=models.CASCADE, related_name="drops", null=True)
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.name or "Drop name unknown"
|
||||
class Meta:
|
||||
ordering: ClassVar[list[str]] = ["required_minutes_watched"]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.name or self.twitch_id
|
||||
|
||||
async def aimport_json(self, data: dict | None, drop_campaign: DropCampaign | None) -> Self:
|
||||
dirty = 0
|
||||
|
||||
async def import_json(self, data: dict | None, drop_campaign: DropCampaign) -> Self:
|
||||
if not data:
|
||||
logger.error("No data provided for %s.", self)
|
||||
return self
|
||||
|
||||
self.name = data.get("name", self.name)
|
||||
self.required_subs = data.get("requiredSubs", self.required_subs)
|
||||
self.required_minutes_watched = data.get("requiredMinutesWatched", self.required_minutes_watched)
|
||||
self.starts_at = data.get("startAt", self.starts_at)
|
||||
self.ends_at = data.get("endAt", self.ends_at)
|
||||
if data.get("__typename") and data["__typename"] != "TimeBasedDrop":
|
||||
logger.error("Not a time-based drop? %s", data)
|
||||
return self
|
||||
|
||||
if drop_campaign:
|
||||
if data.get("name") and data["name"] != self.name:
|
||||
logger.debug("%s: Old name: %s, new name: %s", self, self.name, data["name"])
|
||||
self.name = data["name"]
|
||||
dirty += 1
|
||||
|
||||
if data.get("requiredSubs") and data["requiredSubs"] != self.required_subs:
|
||||
logger.debug(
|
||||
"%s: Old required subs: %s, new required subs: %s",
|
||||
self,
|
||||
self.required_subs,
|
||||
data["requiredSubs"],
|
||||
)
|
||||
self.required_subs = data["requiredSubs"]
|
||||
dirty += 1
|
||||
|
||||
if data.get("requiredMinutesWatched") and data["requiredMinutesWatched"] != self.required_minutes_watched:
|
||||
self.required_minutes_watched = data["requiredMinutesWatched"]
|
||||
dirty += 1
|
||||
|
||||
start_at_str = data.get("startAt")
|
||||
if start_at_str:
|
||||
start_at: datetime = datetime.fromisoformat(start_at_str.replace("Z", "+00:00"))
|
||||
if start_at != self.starts_at:
|
||||
self.starts_at = start_at
|
||||
dirty += 1
|
||||
|
||||
end_at_str = data.get("endAt")
|
||||
if end_at_str:
|
||||
end_at: datetime = datetime.fromisoformat(end_at_str.replace("Z", "+00:00"))
|
||||
if end_at != self.ends_at:
|
||||
self.ends_at = end_at
|
||||
dirty += 1
|
||||
|
||||
if drop_campaign and await sync_to_async(lambda: drop_campaign != self.drop_campaign)():
|
||||
self.drop_campaign = drop_campaign
|
||||
dirty += 1
|
||||
|
||||
self.save()
|
||||
if dirty > 0:
|
||||
await self.asave()
|
||||
logger.info("Updated time-based drop %s", self)
|
||||
|
||||
return self
|
||||
|
||||
@ -174,20 +310,30 @@ class TimeBasedDrop(models.Model):
|
||||
class Benefit(models.Model):
|
||||
"""Benefits are the rewards for the drops."""
|
||||
|
||||
id = models.TextField(primary_key=True) # "d5cdf372-502b-11ef-bafd-0a58a9feac02"
|
||||
created_at = models.DateTimeField(null=True, auto_created=True) # "2024-08-11T00:00:00Z"
|
||||
modified_at = models.DateTimeField(null=True, auto_now=True) # "2024-08-12T00:00:00Z"
|
||||
# "d5cdf372-502b-11ef-bafd-0a58a9feac02"
|
||||
twitch_id = models.TextField(primary_key=True)
|
||||
|
||||
# When the benefit was first added to the database.
|
||||
created_at = models.DateTimeField(null=True, auto_created=True)
|
||||
|
||||
# When the benefit was last modified.
|
||||
modified_at = models.DateTimeField(null=True, auto_now=True)
|
||||
|
||||
# Note: This is Twitch's created_at from the API.
|
||||
twitch_created_at = models.DateTimeField(null=True) # "2023-11-09T01:18:00.126Z"
|
||||
# "2023-11-09T01:18:00.126Z"
|
||||
twitch_created_at = models.DateTimeField(null=True)
|
||||
|
||||
entitlement_limit = models.PositiveBigIntegerField(null=True) # "1"
|
||||
# "1"
|
||||
entitlement_limit = models.PositiveBigIntegerField(null=True)
|
||||
|
||||
# "https://static-cdn.jtvnw.net/twitch-quests-assets/REWARD/e58ad175-73f6-4392-80b8-fb0223163733.png"
|
||||
image_url = models.URLField(null=True)
|
||||
is_ios_available = models.BooleanField(null=True) # "True"
|
||||
|
||||
name = models.TextField(null=True) # "Cosmic Nexus Chimera"
|
||||
# "True" or "False". None if unknown.
|
||||
is_ios_available = models.BooleanField(null=True)
|
||||
|
||||
# "Cosmic Nexus Chimera"
|
||||
name = models.TextField(null=True)
|
||||
|
||||
time_based_drop = models.ForeignKey(
|
||||
TimeBasedDrop,
|
||||
@ -196,24 +342,53 @@ class Benefit(models.Model):
|
||||
null=True,
|
||||
)
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.name or "Benefit name unknown"
|
||||
class Meta:
|
||||
ordering: ClassVar[list[str]] = ["-twitch_created_at"]
|
||||
|
||||
async def import_json(self, data: dict | None, time_based_drop: TimeBasedDrop) -> Self:
|
||||
def __str__(self) -> str:
|
||||
return self.name or self.twitch_id
|
||||
|
||||
async def aimport_json(self, data: dict | None, time_based_drop: TimeBasedDrop | None) -> Self:
|
||||
dirty = 0
|
||||
if not data:
|
||||
logger.error("No data provided for %s.", self)
|
||||
return self
|
||||
|
||||
self.name = data.get("name", self.name)
|
||||
self.entitlement_limit = data.get("entitlementLimit", self.entitlement_limit)
|
||||
self.is_ios_available = data.get("isIosAvailable", self.is_ios_available)
|
||||
self.image_url = data.get("imageAssetURL", self.image_url)
|
||||
self.twitch_created_at = data.get("createdAt", self.twitch_created_at)
|
||||
if data.get("__typename") and data["__typename"] != "DropBenefit":
|
||||
logger.error("Not a benefit? %s", data)
|
||||
return self
|
||||
|
||||
if time_based_drop:
|
||||
if data.get("name") and data["name"] != self.name:
|
||||
self.name = data["name"]
|
||||
dirty += 1
|
||||
|
||||
if data.get("imageAssetURL") and data["imageAssetURL"] != self.image_url:
|
||||
self.image_url = data["imageAssetURL"]
|
||||
dirty += 1
|
||||
|
||||
if data.get("entitlementLimit") and data["entitlementLimit"] != self.entitlement_limit:
|
||||
self.entitlement_limit = data["entitlementLimit"]
|
||||
dirty += 1
|
||||
|
||||
if data.get("isIOSAvailable") and data["isIOSAvailable"] != self.is_ios_available:
|
||||
self.is_ios_available = data["isIOSAvailable"]
|
||||
dirty += 1
|
||||
|
||||
twitch_created_at_str = data.get("createdAt")
|
||||
|
||||
if twitch_created_at_str:
|
||||
twitch_created_at: datetime = datetime.fromisoformat(twitch_created_at_str.replace("Z", "+00:00"))
|
||||
if twitch_created_at != self.twitch_created_at:
|
||||
self.twitch_created_at = twitch_created_at
|
||||
dirty += 1
|
||||
|
||||
if time_based_drop and await sync_to_async(lambda: time_based_drop != self.time_based_drop)():
|
||||
await time_based_drop.benefits.aadd(self) # type: ignore # noqa: PGH003
|
||||
dirty += 1
|
||||
|
||||
self.save()
|
||||
if dirty > 0:
|
||||
await self.asave()
|
||||
logger.info("Updated benefit %s", self)
|
||||
|
||||
return self
|
||||
|
||||
@ -221,64 +396,157 @@ class Benefit(models.Model):
|
||||
class RewardCampaign(models.Model):
|
||||
"""Buy subscriptions to earn rewards."""
|
||||
|
||||
id = models.TextField(primary_key=True) # "dc4ff0b4-4de0-11ef-9ec3-621fb0811846"
|
||||
created_at = models.DateTimeField(null=True, auto_created=True) # "2024-08-11T00:00:00Z"
|
||||
modified_at = models.DateTimeField(null=True, auto_now=True) # "2024-08-12T00:00:00Z"
|
||||
# "dc4ff0b4-4de0-11ef-9ec3-621fb0811846"
|
||||
twitch_id = models.TextField(primary_key=True)
|
||||
|
||||
name = models.TextField(null=True) # "Buy 1 new sub, get 3 months of Apple TV+"
|
||||
brand = models.TextField(null=True) # "Apple TV+"
|
||||
starts_at = models.DateTimeField(null=True) # "2024-08-11T11:00:00Z"
|
||||
ends_at = models.DateTimeField(null=True) # "2024-08-12T05:59:59.999Z"
|
||||
status = models.TextField(null=True) # "UNKNOWN"
|
||||
summary = models.TextField(null=True) # "Get 3 months of Apple TV+ with the purchase of a new sub"
|
||||
instructions = models.TextField(null=True) # "Buy a new sub to get 3 months of Apple TV+"
|
||||
reward_value_url_param = models.TextField(null=True) # ""
|
||||
external_url = models.URLField(null=True) # "https://tv.apple.com/includes/commerce/redeem/code-entry"
|
||||
about_url = models.URLField(null=True) # "https://blog.twitch.tv/2024/07/26/sub-and-get-apple-tv/"
|
||||
is_site_wide = models.BooleanField(null=True) # "True"
|
||||
game = models.ForeignKey(Game, on_delete=models.CASCADE, related_name="reward_campaigns", null=True)
|
||||
# When the reward campaign was first added to the database.
|
||||
created_at = models.DateTimeField(null=True, auto_created=True)
|
||||
|
||||
sub_goal = models.PositiveBigIntegerField(null=True) # "1"
|
||||
minute_watched_goal = models.PositiveBigIntegerField(null=True) # "0"
|
||||
# When the reward campaign was last modified.
|
||||
modified_at = models.DateTimeField(null=True, auto_now=True)
|
||||
|
||||
# "Buy 1 new sub, get 3 months of Apple TV+"
|
||||
name = models.TextField(null=True)
|
||||
|
||||
# "Apple TV+"
|
||||
brand = models.TextField(null=True)
|
||||
|
||||
# "2024-08-11T11:00:00Z"
|
||||
starts_at = models.DateTimeField(null=True)
|
||||
|
||||
# "2024-08-12T05:59:59.999Z"
|
||||
ends_at = models.DateTimeField(null=True)
|
||||
|
||||
# "UNKNOWN"
|
||||
status = models.TextField(null=True)
|
||||
|
||||
# "Get 3 months of Apple TV+ with the purchase of a new sub"
|
||||
summary = models.TextField(null=True)
|
||||
|
||||
# "Buy a new sub to get 3 months of Apple TV+"
|
||||
instructions = models.TextField(null=True)
|
||||
|
||||
# ""
|
||||
reward_value_url_param = models.TextField(null=True)
|
||||
|
||||
# "https://tv.apple.com/includes/commerce/redeem/code-entry"
|
||||
external_url = models.URLField(null=True)
|
||||
|
||||
# "https://blog.twitch.tv/2024/07/26/sub-and-get-apple-tv/"
|
||||
about_url = models.URLField(null=True)
|
||||
|
||||
# "True" or "False". None if unknown.
|
||||
is_site_wide = models.BooleanField(null=True)
|
||||
|
||||
# "1"
|
||||
subs_goal = models.PositiveBigIntegerField(null=True)
|
||||
|
||||
# "0"
|
||||
minute_watched_goal = models.PositiveBigIntegerField(null=True)
|
||||
|
||||
# "https://static-cdn.jtvnw.net/twitch-quests-assets/CAMPAIGN/quests_appletv_q3_2024/apple_150x200.png"
|
||||
image_url = models.URLField(null=True)
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.name or "Reward campaign name unknown"
|
||||
game = models.ForeignKey(Game, on_delete=models.CASCADE, related_name="reward_campaigns", null=True)
|
||||
|
||||
async def import_json(self, data: dict | None) -> Self:
|
||||
class Meta:
|
||||
ordering: ClassVar[list[str]] = ["-starts_at"]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.name or self.twitch_id
|
||||
|
||||
async def aimport_json(self, data: dict | None) -> Self:
|
||||
dirty = 0
|
||||
if not data:
|
||||
logger.error("No data provided for %s.", self)
|
||||
return self
|
||||
|
||||
self.name = data.get("name", self.name)
|
||||
self.brand = data.get("brand", self.brand)
|
||||
self.starts_at = data.get("startsAt", self.starts_at)
|
||||
self.ends_at = data.get("endsAt", self.ends_at)
|
||||
self.status = data.get("status", self.status)
|
||||
self.summary = data.get("summary", self.summary)
|
||||
self.instructions = data.get("instructions", self.instructions)
|
||||
self.reward_value_url_param = data.get("rewardValueURLParam", self.reward_value_url_param)
|
||||
self.external_url = data.get("externalURL", self.external_url)
|
||||
self.about_url = data.get("aboutURL", self.about_url)
|
||||
self.is_site_wide = data.get("isSiteWide", self.is_site_wide)
|
||||
if data.get("__typename") and data["__typename"] != "RewardCampaign":
|
||||
logger.error("Not a reward campaign? %s", data)
|
||||
return self
|
||||
|
||||
unlock_requirements: dict = data.get("unlockRequirements", {})
|
||||
if unlock_requirements:
|
||||
self.sub_goal = unlock_requirements.get("subsGoal", self.sub_goal)
|
||||
self.minute_watched_goal = unlock_requirements.get("minuteWatchedGoal", self.minute_watched_goal)
|
||||
if data.get("name") and data["name"] != self.name:
|
||||
self.name = data["name"]
|
||||
dirty += 1
|
||||
|
||||
image = data.get("image", {})
|
||||
if image:
|
||||
self.image_url = image.get("image1xURL", self.image_url)
|
||||
if data.get("brand") and data["brand"] != self.brand:
|
||||
self.brand = data["brand"]
|
||||
dirty += 1
|
||||
|
||||
if data.get("game"):
|
||||
game: Game | None = Game.objects.filter(twitch_id=data["game"]["id"]).first()
|
||||
if game:
|
||||
starts_at_str = data.get("startsAt")
|
||||
if starts_at_str:
|
||||
starts_at: datetime = datetime.fromisoformat(starts_at_str.replace("Z", "+00:00"))
|
||||
if starts_at != self.starts_at:
|
||||
self.starts_at = starts_at
|
||||
dirty += 1
|
||||
|
||||
ends_at_str = data.get("endsAt")
|
||||
if ends_at_str:
|
||||
ends_at: datetime = datetime.fromisoformat(ends_at_str.replace("Z", "+00:00"))
|
||||
if ends_at != self.ends_at:
|
||||
self.ends_at = ends_at
|
||||
dirty += 1
|
||||
|
||||
if data.get("status") and data["status"] != self.status:
|
||||
self.status = data["status"]
|
||||
dirty += 1
|
||||
|
||||
if data.get("summary") and data["summary"] != self.summary:
|
||||
self.summary = data["summary"]
|
||||
dirty += 1
|
||||
|
||||
if data.get("instructions") and data["instructions"] != self.instructions:
|
||||
self.instructions = data["instructions"]
|
||||
dirty += 1
|
||||
|
||||
if data.get("rewardValueURLParam") and data["rewardValueURLParam"] != self.reward_value_url_param:
|
||||
self.reward_value_url_param = data["rewardValueURLParam"]
|
||||
logger.warning("What the duck this this? Reward value URL param: %s", self.reward_value_url_param)
|
||||
dirty += 1
|
||||
|
||||
if data.get("externalURL") and data["externalURL"] != self.external_url:
|
||||
self.external_url = data["externalURL"]
|
||||
dirty += 1
|
||||
|
||||
if data.get("aboutURL") and data["aboutURL"] != self.about_url:
|
||||
self.about_url = data["aboutURL"]
|
||||
dirty += 1
|
||||
|
||||
if data.get("isSitewide") and data["isSitewide"] != self.is_site_wide:
|
||||
self.is_site_wide = data["isSitewide"]
|
||||
dirty += 1
|
||||
|
||||
subs_goal = data.get("unlockRequirements", {}).get("subsGoal")
|
||||
if subs_goal and subs_goal != self.subs_goal:
|
||||
self.subs_goal = subs_goal
|
||||
dirty += 1
|
||||
|
||||
minutes_watched_goal = data.get("unlockRequirements", {}).get("minuteWatchedGoal")
|
||||
if minutes_watched_goal and minutes_watched_goal != self.minute_watched_goal:
|
||||
self.minute_watched_goal = minutes_watched_goal
|
||||
dirty += 1
|
||||
|
||||
image_url = data.get("image", {}).get("image1xURL")
|
||||
if image_url and image_url != self.image_url:
|
||||
self.image_url = image_url
|
||||
dirty += 1
|
||||
|
||||
if data.get("game") and data["game"].get("id"):
|
||||
game, _ = await Game.objects.aget_or_create(twitch_id=data["game"]["id"])
|
||||
if await sync_to_async(lambda: game != self.game)():
|
||||
await game.reward_campaigns.aadd(self) # type: ignore # noqa: PGH003
|
||||
dirty += 1
|
||||
|
||||
self.save()
|
||||
if "rewards" in data:
|
||||
for reward in data["rewards"]:
|
||||
reward_instance, created = await Reward.objects.aupdate_or_create(twitch_id=reward["id"])
|
||||
await reward_instance.aimport_json(reward, self)
|
||||
if created:
|
||||
logger.info("Added reward %s", reward_instance)
|
||||
|
||||
if dirty > 0:
|
||||
await self.asave()
|
||||
logger.info("Updated reward campaign %s", self)
|
||||
|
||||
return self
|
||||
|
||||
@ -286,45 +554,89 @@ class RewardCampaign(models.Model):
|
||||
class Reward(models.Model):
|
||||
"""This from the RewardCampaign."""
|
||||
|
||||
id = models.TextField(primary_key=True) # "dc2e9810-4de0-11ef-9ec3-621fb0811846"
|
||||
name = models.TextField(null=True) # "3 months of Apple TV+"
|
||||
# "dc2e9810-4de0-11ef-9ec3-621fb0811846"
|
||||
twitch_id = models.TextField(primary_key=True)
|
||||
|
||||
# When the reward was first added to the database.
|
||||
created_at = models.DateTimeField(null=True, auto_created=True)
|
||||
|
||||
# When the reward was last modified.
|
||||
modified_at = models.DateTimeField(null=True, auto_now=True)
|
||||
|
||||
# "3 months of Apple TV+"
|
||||
name = models.TextField(null=True)
|
||||
|
||||
# "https://static-cdn.jtvnw.net/twitch-quests-assets/CAMPAIGN/quests_appletv_q3_2024/apple_200x200.png"
|
||||
banner_image_url = models.URLField(null=True)
|
||||
|
||||
# "https://static-cdn.jtvnw.net/twitch-quests-assets/CAMPAIGN/quests_appletv_q3_2024/apple_200x200.png"
|
||||
thumbnail_image_url = models.URLField(null=True)
|
||||
|
||||
earnable_until = models.DateTimeField(null=True) # "2024-08-19T19:00:00Z"
|
||||
redemption_instructions = models.TextField(null=True) # ""
|
||||
redemption_url = models.URLField(null=True) # "https://tv.apple.com/includes/commerce/redeem/code-entry"
|
||||
# "2024-08-19T19:00:00Z"
|
||||
earnable_until = models.DateTimeField(null=True)
|
||||
|
||||
# ""
|
||||
redemption_instructions = models.TextField(null=True)
|
||||
|
||||
# "https://tv.apple.com/includes/commerce/redeem/code-entry"
|
||||
redemption_url = models.URLField(null=True)
|
||||
|
||||
campaign = models.ForeignKey(RewardCampaign, on_delete=models.CASCADE, related_name="rewards", null=True)
|
||||
|
||||
class Meta:
|
||||
ordering: ClassVar[list[str]] = ["-earnable_until"]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.name or "Reward name unknown"
|
||||
|
||||
async def import_json(self, data: dict | None, reward_campaign: RewardCampaign) -> Self:
|
||||
async def aimport_json(self, data: dict | None, reward_campaign: RewardCampaign | None) -> Self:
|
||||
dirty = 0
|
||||
if not data:
|
||||
logger.error("No data provided for %s.", self)
|
||||
return self
|
||||
|
||||
self.name = data.get("name", self.name)
|
||||
self.earnable_until = data.get("earnableUntil", self.earnable_until)
|
||||
self.redemption_instructions = data.get("redemptionInstructions", self.redemption_instructions)
|
||||
self.redemption_url = data.get("redemptionURL", self.redemption_url)
|
||||
if data.get("__typename") and data["__typename"] != "Reward":
|
||||
logger.error("Not a reward? %s", data)
|
||||
return self
|
||||
|
||||
banner_image = data.get("bannerImage", {})
|
||||
if banner_image:
|
||||
self.banner_image_url = banner_image.get("image1xURL", self.banner_image_url)
|
||||
if data.get("name") and data["name"] != self.name:
|
||||
self.name = data["name"]
|
||||
dirty += 1
|
||||
|
||||
thumbnail_image = data.get("thumbnailImage", {})
|
||||
if thumbnail_image:
|
||||
self.thumbnail_image_url = thumbnail_image.get("image1xURL", self.thumbnail_image_url)
|
||||
earnable_until_str = data.get("earnableUntil")
|
||||
if earnable_until_str:
|
||||
earnable_until: datetime = datetime.fromisoformat(earnable_until_str.replace("Z", "+00:00"))
|
||||
if earnable_until != self.earnable_until:
|
||||
self.earnable_until = earnable_until
|
||||
dirty += 1
|
||||
|
||||
if reward_campaign:
|
||||
if data.get("redemptionInstructions") and data["redemptionInstructions"] != self.redemption_instructions:
|
||||
# TODO(TheLovinator): We should archive this URL. # noqa: TD003
|
||||
self.redemption_instructions = data["redemptionInstructions"]
|
||||
dirty += 1
|
||||
|
||||
if data.get("redemptionURL") and data["redemptionURL"] != self.redemption_url:
|
||||
# TODO(TheLovinator): We should archive this URL. # noqa: TD003
|
||||
self.redemption_url = data["redemptionURL"]
|
||||
dirty += 1
|
||||
|
||||
banner_image_url = data.get("bannerImage", {}).get("image1xURL")
|
||||
if banner_image_url and banner_image_url != self.banner_image_url:
|
||||
self.banner_image_url = banner_image_url
|
||||
dirty += 1
|
||||
|
||||
thumbnail_image_url = data.get("thumbnailImage", {}).get("image1xURL")
|
||||
if thumbnail_image_url and thumbnail_image_url != self.thumbnail_image_url:
|
||||
self.thumbnail_image_url = thumbnail_image_url
|
||||
dirty += 1
|
||||
|
||||
if reward_campaign and await sync_to_async(lambda: reward_campaign != self.campaign)():
|
||||
self.campaign = reward_campaign
|
||||
dirty += 1
|
||||
|
||||
self.save()
|
||||
if dirty > 0:
|
||||
await self.asave()
|
||||
logger.info("Updated reward %s", self)
|
||||
|
||||
return self
|
||||
|
||||
@ -332,15 +644,21 @@ class Reward(models.Model):
|
||||
class Webhook(models.Model):
|
||||
"""Discord webhook."""
|
||||
|
||||
id = models.TextField(primary_key=True)
|
||||
avatar = models.TextField(null=True)
|
||||
channel_id = models.TextField(null=True)
|
||||
guild_id = models.TextField(null=True)
|
||||
id = models.TextField(primary_key=True)
|
||||
name = models.TextField(null=True)
|
||||
type = models.TextField(null=True)
|
||||
token = models.TextField()
|
||||
url = models.TextField()
|
||||
|
||||
# When the webhook was first added to the database.
|
||||
created_at = models.DateTimeField(null=True, auto_created=True)
|
||||
|
||||
# When the webhook was last modified.
|
||||
modified_at = models.DateTimeField(null=True, auto_now=True)
|
||||
|
||||
# Get notified when the site finds a new game.
|
||||
subscribed_new_games = models.ManyToManyField(Game, related_name="subscribed_new_games")
|
||||
|
||||
|
@ -129,7 +129,7 @@ DATABASES = {
|
||||
"ENGINE": "django.db.backends.sqlite3",
|
||||
"NAME": DATA_DIR / "ttvdrops.sqlite3",
|
||||
"OPTIONS": {
|
||||
# "init_command": "PRAGMA journal_mode=wal; PRAGMA synchronous=1; PRAGMA mmap_size=134217728; PRAGMA journal_size_limit=67108864; PRAGMA cache_size=2000;", # noqa: E501
|
||||
"init_command": "PRAGMA journal_mode=wal; PRAGMA synchronous=1; PRAGMA mmap_size=134217728; PRAGMA journal_size_limit=67108864; PRAGMA cache_size=2000;", # noqa: E501
|
||||
},
|
||||
},
|
||||
}
|
||||
|
@ -1,98 +1,94 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<div class="container my-5">
|
||||
<div class="text-center">
|
||||
<header class="h2 mt-4">
|
||||
{{ game.name }}
|
||||
</header>
|
||||
<img src="{{ game.box_art_url }}" alt="{{ game.name }} box art" class="img-fluid rounded" height="283"
|
||||
width="212" loading="lazy">
|
||||
</div>
|
||||
<div class="container">
|
||||
<h2>{{ game.name }}</h2>
|
||||
<img src="{{ game.box_art_url }}" alt="{{ game.name }} box art" height="283" width="212">
|
||||
|
||||
<div class="mt-5">
|
||||
<h3 class="h4">Game Details</h3>
|
||||
<ul class="list-group">
|
||||
<li class="list-group-item"><strong>Twitch ID:</strong> {{ game.twitch_id }}</li>
|
||||
<li class="list-group-item"><strong>Game URL:</strong> <a href="{{ game.url }}"
|
||||
target="_blank">{{ game.url }}</a></li>
|
||||
<li class="list-group-item"><strong>Game name:</strong> {{ game.name }}</li>
|
||||
<li class="list-group-item"><strong>Game box art URL:</strong> <a href="{{ game.box_art_url }}"
|
||||
target="_blank">{{ game.box_art_url }}</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
<h3>Game Details</h3>
|
||||
<table class="table table-hover table-sm table-striped" cellspacing="0">
|
||||
<tr>
|
||||
<td><strong>Twitch ID:</strong></td>
|
||||
<td>{{ game.pk }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Game URL:</strong></td>
|
||||
<td><a href="{{ game.game_url }}" target="_blank">{{ game.game_url }}</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Game name:</strong></td>
|
||||
<td>{{ game.name }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Game box art URL:</strong></td>
|
||||
<td><a href="{{ game.box_art_url }}" target="_blank">{{ game.box_art_url }}</a></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<div class="mt-5">
|
||||
<h3 class="h4">Organization</h3>
|
||||
<ul class="list-group">
|
||||
<h3>Organization</h3>
|
||||
<table class="table table-hover table-sm table-striped" cellspacing="0">
|
||||
<tr>
|
||||
{% if game.org %}
|
||||
<li class="list-group-item">
|
||||
<a href="#">{{ game.org.name }} - <span class="text-muted">{{ game.org.id }}</span></a>
|
||||
</li>
|
||||
<td><a href="#">{{ game.org.name }} - <span class="text-muted">{{ game.org.pk }}</span></a></td>
|
||||
{% else %}
|
||||
<li class="list-group-item">No organization associated with this game.</li>
|
||||
<td>No organization associated with this game.</td>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</div>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<div class="mt-5">
|
||||
<h3 class="h4">Drop Campaigns</h3>
|
||||
{% if game.drop_campaigns.all %}
|
||||
<div>
|
||||
{% for drop_campaign in game.drop_campaigns.all %}
|
||||
<div>
|
||||
<h2>
|
||||
{{ drop_campaign.name }}
|
||||
</h2>
|
||||
<div>
|
||||
<div>
|
||||
<img src="{{ drop_campaign.image_url }}" alt="{{ drop_campaign.name }} image"
|
||||
class="img-fluid mb-3 rounded">
|
||||
<p><strong>Status:</strong> {{ drop_campaign.status }}</p>
|
||||
<p>{{ drop_campaign.description }}</p>
|
||||
<p><strong>Starts at:</strong> {{ drop_campaign.starts_at }}</p>
|
||||
<p><strong>Ends at:</strong> {{ drop_campaign.ends_at }}</p>
|
||||
<p><strong>More details:</strong> <a href="{{ drop_campaign.details_url }}"
|
||||
target="_blank">{{ drop_campaign.details_url }}</a></p>
|
||||
<p><strong>Account Link:</strong> <a href="{{ drop_campaign.account_link_url }}"
|
||||
target="_blank">{{ drop_campaign.account_link_url }}</a></p>
|
||||
<h3>Drop Campaigns</h3>
|
||||
{% if game.drop_campaigns.all %}
|
||||
{% for drop_campaign in game.drop_campaigns.all %}
|
||||
<br>
|
||||
<h2>{{ drop_campaign.name }}</h2>
|
||||
<table class="table table-hover table-sm table-striped" cellspacing="0">
|
||||
<tr>
|
||||
<td><strong>Campaign Name:</strong></td>
|
||||
<td>{{ drop_campaign.name }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><img src="{{ drop_campaign.image_url }}" alt="{{ drop_campaign.name }} image"></td>
|
||||
<td>
|
||||
<p><strong>Status:</strong> {{ drop_campaign.status }}</p>
|
||||
<p><strong>Description:</strong> {{ drop_campaign.description }}</p>
|
||||
<p><strong>Starts at:</strong> {{ drop_campaign.starts_at }}</p>
|
||||
<p><strong>Ends at:</strong> {{ drop_campaign.ends_at }}</p>
|
||||
<p><strong>More details:</strong> <a href="{{ drop_campaign.details_url }}"
|
||||
target="_blank">{{ drop_campaign.details_url }}</a></p>
|
||||
<p><strong>Account Link:</strong> <a href="{{ drop_campaign.account_link_url }}"
|
||||
target="_blank">{{ drop_campaign.account_link_url }}</a></p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<h2 class="mt-4">Time-Based Drops</h2>
|
||||
{% if drop_campaign.drops.all %}
|
||||
<div>
|
||||
{% for drop in drop_campaign.drops.all %}
|
||||
<hr>
|
||||
<div>
|
||||
<h3 class="mb-2">{{ drop.name }}</h3>
|
||||
{% for benefit in drop.benefits.all %}
|
||||
<img src="{{ benefit.image_url }}" alt="{{ benefit.name }} image"
|
||||
class="img-fluid rounded mb-2">
|
||||
<p><strong>Required Subscriptions:</strong> {{ drop.required_subs }}</p>
|
||||
<p><strong>Required Minutes Watched:</strong> {{ drop.required_minutes_watched }}</p>
|
||||
<p><strong>Starts at:</strong> {{ drop.starts_at }}</p>
|
||||
<p><strong>Ends at:</strong> {{ drop.ends_at }}</p>
|
||||
|
||||
<p><strong>Entitlement Limit:</strong> {{ benefit.entitlement_limit }}</p>
|
||||
<p><strong>Available on iOS:</strong> {{ benefit.is_ios_available }}</p>
|
||||
<p><strong>Twitch Created At:</strong> {{ benefit.twitch_created_at }}</p>
|
||||
{% empty %}
|
||||
<div>No benefits available for this drop.</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% empty %}
|
||||
<div>No time-based drops available for this campaign.</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p>No time-based drops available for this campaign.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% if drop_campaign.drops.all %}
|
||||
<table class="table table-hover table-sm table-striped" cellspacing="0">
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Item Name</th>
|
||||
<th>Minutes</th>
|
||||
<th>Image</th>
|
||||
<th>Benefit Name</th>
|
||||
</tr>
|
||||
{% for item in drop_campaign.drops.all %}
|
||||
<tr>
|
||||
<td>{{ item.pk }}</td>
|
||||
<td>{{ item.name }}</td>
|
||||
<td>{{ item.required_minutes_watched }}</td>
|
||||
{% for benefit in item.benefits.all %}
|
||||
<td><img src="{{ benefit.image_url }}" alt="{{ benefit.name }} reward image" height="50" width="50">
|
||||
</td>
|
||||
<td>{{ benefit.name }}</td>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p>No drop campaigns available for this game.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
{% else %}
|
||||
<p>No items associated with this drop campaign.</p>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<p>No drop campaigns associated with this game.</p>
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
{% endblock content %}
|
||||
|
@ -1,27 +1,176 @@
|
||||
{% extends "base.html" %}
|
||||
{% load static %}
|
||||
{% load campaign_tags %}
|
||||
{% load game_tags %}
|
||||
{% load custom_filters %}
|
||||
{% load time_filters %}
|
||||
{% block content %}
|
||||
<div class="container mt-4">
|
||||
{% include "partials/info_box.html" %}
|
||||
{% include "partials/news.html" %}
|
||||
<h2>
|
||||
Reward campaign -
|
||||
<span class="d-inline text-muted">
|
||||
{{ reward_campaigns.count }}
|
||||
campaign{{ reward_campaigns.count|pluralize }}
|
||||
</span>
|
||||
</h2>
|
||||
{% for campaign in reward_campaigns %}
|
||||
{% render_campaign campaign %}
|
||||
{% endfor %}
|
||||
<h2>
|
||||
Drop campaigns -
|
||||
<span class="d-inline text-muted ">{{ games.count }} game{{ games.count|pluralize }}</span>
|
||||
</h2>
|
||||
{% for game in games %}
|
||||
{% render_game_card game %}
|
||||
{% endfor %}
|
||||
|
||||
<!-- Reward Campaigns Section -->
|
||||
<section class="reward-campaigns">
|
||||
<h2>
|
||||
Reward Campaigns -
|
||||
<span class="d-inline text-muted">
|
||||
{{ reward_campaigns.count }} campaign{{ reward_campaigns.count|pluralize }}
|
||||
</span>
|
||||
</h2>
|
||||
|
||||
<!-- Loop through reward campaigns -->
|
||||
{% for campaign in reward_campaigns %}
|
||||
<div class="card mb-4 shadow-sm" id="campaign-{{ campaign.twitch_id }}">
|
||||
<div class="row g-0">
|
||||
<!-- Campaign Image -->
|
||||
<div class="col-md-2">
|
||||
<img src="{{ campaign.image_url }}" alt="{{ campaign.name }}" class="img-fluid rounded-start"
|
||||
height="283" width="212" loading="lazy">
|
||||
</div>
|
||||
|
||||
<!-- Campaign Details -->
|
||||
<div class="col-md-10">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title h5">
|
||||
<a href="#campaign-{{ campaign.twitch_id }}" class="plain-text-item">
|
||||
{{ campaign.name }}
|
||||
</a>
|
||||
</h2>
|
||||
<p class="card-text text-muted">{{ campaign.summary }}</p>
|
||||
<p class="mb-2 text-muted">
|
||||
Ends in:
|
||||
<abbr
|
||||
title="{{ campaign.starts_at|date:'l d F H:i %Z' }} - {{ campaign.ends_at|date:'l d F H:i %Z' }}">
|
||||
{{ campaign.ends_at|timesince }}
|
||||
</abbr>
|
||||
</p>
|
||||
<a href="{{ campaign.external_url }}" class="btn btn-primary" target="_blank">Learn More</a>
|
||||
|
||||
<!-- Instructions (if any) -->
|
||||
{% if campaign.instructions %}
|
||||
<div class="mt-3">
|
||||
<h3 class="h6">Instructions</h3>
|
||||
<p>{{ campaign.instructions|safe }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Rewards (if any) -->
|
||||
{% if campaign.rewards.all %}
|
||||
<div class="mt-3">
|
||||
<h3 class="h6">Rewards</h3>
|
||||
<div class="row row-cols-1 row-cols-md-2 row-cols-lg-3 g-2">
|
||||
{% for reward in campaign.rewards.all %}
|
||||
<div class="col d-flex align-items-center position-relative">
|
||||
<img src="{{ reward.thumbnail_image_url }}" alt="{{ reward.name }} reward image"
|
||||
class="img-fluid rounded me-3" height="50" width="50" loading="lazy">
|
||||
<div><strong>{{ reward.name }}</strong></div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</section>
|
||||
|
||||
<!-- Drop Campaigns Section -->
|
||||
<section class="drop-campaigns">
|
||||
<h2>
|
||||
Drop Campaigns -
|
||||
<span class="d-inline text-muted">{{ games.count }} game{{ games.count|pluralize }}</span>
|
||||
</h2>
|
||||
|
||||
<!-- Loop through games -->
|
||||
{% for game in games %}
|
||||
<div class="card mb-4 shadow-sm">
|
||||
<div class="row g-0">
|
||||
<!-- Game Box Art -->
|
||||
<div class="col-md-2">
|
||||
<img src="{{ game.box_art_url|default:'https://static-cdn.jtvnw.net/ttv-static/404_boxart.jpg' }}"
|
||||
alt="{{ game.name|default:'Game name unknown' }} box art" class="img-fluid rounded-start"
|
||||
height="283" width="212" loading="lazy">
|
||||
</div>
|
||||
|
||||
<!-- Game Details -->
|
||||
<div class="col-md-10">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title h5">
|
||||
<a href="{% url 'game' game.twitch_id %}" class="text-decoration-none">
|
||||
{{ game.name|default:'Unknown' }}
|
||||
</a>
|
||||
-
|
||||
<a href="https://www.twitch.tv/directory/category/{{ game.slug|default:'game-name-unknown' }}"
|
||||
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 %}
|
||||
|
||||
<p class="mb-2 text-muted">
|
||||
Ends in:
|
||||
<abbr
|
||||
title="{{ campaign.starts_at|date:'l d F H:i' }} - {{ campaign.ends_at|date:'l d F H:i' }}">
|
||||
{{ campaign.ends_at|timeuntil }}
|
||||
</abbr>
|
||||
</p>
|
||||
|
||||
<!-- Drop Benefits Table -->
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped table-hover align-middle">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Benefit Image</th>
|
||||
<th>Benefit Name</th>
|
||||
<th>Required Minutes Watched</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for drop in campaign.drops.all %}
|
||||
{% if drop.benefits.exists %}
|
||||
{% for benefit in drop.benefits.all %}
|
||||
<tr>
|
||||
<td>
|
||||
<img src="{{ benefit.image_url|default:'https://static-cdn.jtvnw.net/ttv-static/404_boxart.jpg' }}"
|
||||
alt="{{ benefit.name|default:'Unknown' }}" class="img-fluid rounded"
|
||||
height="50" width="50" loading="lazy">
|
||||
</td>
|
||||
<td>
|
||||
<abbr title="{{ drop.name|default:'Unknown' }}">
|
||||
{{ benefit.name|default:'Unknown' }}
|
||||
</abbr>
|
||||
</td>
|
||||
<td>{{ drop.required_minutes_watched|minutes_to_hours }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<tr>
|
||||
<td>
|
||||
<img src="https://static-cdn.jtvnw.net/ttv-static/404_boxart.jpg"
|
||||
alt="{{ drop.name|default:'Unknown' }}" class="img-fluid rounded"
|
||||
height="50" width="50" loading="lazy">
|
||||
</td>
|
||||
<td>{{ drop.name|default:'Unknown' }}</td>
|
||||
<td>N/A</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</section>
|
||||
</div>
|
||||
{% endblock content %}
|
||||
|
@ -1,93 +0,0 @@
|
||||
from django import template
|
||||
from django.utils.html import format_html
|
||||
from django.utils.safestring import SafeText
|
||||
from django.utils.timesince import timesince
|
||||
from django.utils.timezone import now
|
||||
|
||||
from core.models import Reward, RewardCampaign
|
||||
|
||||
register = template.Library()
|
||||
|
||||
|
||||
@register.simple_tag
|
||||
def render_campaign(campaign: RewardCampaign) -> SafeText:
|
||||
"""Render the campaign HTML.
|
||||
|
||||
Args:
|
||||
campaign: The campaign object.
|
||||
|
||||
Returns:
|
||||
The rendered HTML string.
|
||||
"""
|
||||
time_remaining: str = timesince(now(), campaign.ends_at)
|
||||
ends_in: str = f'{campaign.ends_at.strftime("%A %d %B %H:%M %Z")}' if campaign.ends_at else ""
|
||||
starts_in: str = f'{campaign.starts_at.strftime("%A %d %B %H:%M %Z")}' if campaign.starts_at else ""
|
||||
|
||||
# Start building the HTML
|
||||
html: str = f"""
|
||||
<div class="card mb-4 shadow-sm" id="campaign-{campaign.id}">
|
||||
<div class="row g-0">
|
||||
<div class="col-md-2">
|
||||
<img src="{campaign.image_url}"
|
||||
alt="{campaign.name}"
|
||||
class="img-fluid rounded-start"
|
||||
height="283"
|
||||
width="212"
|
||||
loading="lazy">
|
||||
</div>
|
||||
<div class="col-md-10">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title h5" id="#reward-{campaign.id}">
|
||||
<a href="#campaign-{campaign.id}" class="plain-text-item">{campaign.name}</a>
|
||||
</h2>
|
||||
<p class="card-text text-muted">{campaign.summary}</p>
|
||||
<p class="mb-2 text-muted">
|
||||
Ends in: <abbr title="{starts_in} - {ends_in}">{time_remaining}</abbr>
|
||||
</p>
|
||||
<a href="{campaign.external_url}"
|
||||
class="btn btn-primary"
|
||||
target="_blank">Learn More</a>
|
||||
"""
|
||||
|
||||
# Add instructions if present
|
||||
if campaign.instructions:
|
||||
html += f"""
|
||||
<div class="mt-3">
|
||||
<h3 class="h6">Instructions</h3>
|
||||
<p>{campaign.instructions}</p>
|
||||
</div>
|
||||
"""
|
||||
|
||||
# Add rewards if present
|
||||
if campaign.rewards.exists(): # type: ignore # noqa: PGH003
|
||||
html += """
|
||||
<div class="mt-3">
|
||||
<h3 class="h6">Rewards</h3>
|
||||
<div class="row row-cols-1 row-cols-md-2 row-cols-lg-3 g-2">
|
||||
"""
|
||||
for reward in campaign.rewards.all(): # type: ignore # noqa: PGH003
|
||||
reward: Reward
|
||||
html += f"""
|
||||
<div class="col d-flex align-items-center position-relative">
|
||||
<img src="{reward.thumbnail_image_url}"
|
||||
alt="{reward.name} reward image"
|
||||
class="img-fluid rounded me-3"
|
||||
height="50"
|
||||
width="50"
|
||||
loading="lazy">
|
||||
<div>
|
||||
<strong>{reward.name}</strong>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
html += "</div></div>"
|
||||
|
||||
# Close the main divs
|
||||
html += """
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
|
||||
return format_html(html)
|
16
core/templatetags/custom_filters.py
Normal file
16
core/templatetags/custom_filters.py
Normal file
@ -0,0 +1,16 @@
|
||||
from django import template
|
||||
|
||||
register = template.Library()
|
||||
|
||||
|
||||
@register.filter(name="trim")
|
||||
def trim(value: str) -> str:
|
||||
"""Trim the value.
|
||||
|
||||
Args:
|
||||
value: The value to trim.
|
||||
|
||||
Returns:
|
||||
The trimmed value.
|
||||
"""
|
||||
return value.strip()
|
@ -1,131 +0,0 @@
|
||||
from django import template
|
||||
from django.utils.html import format_html
|
||||
from django.utils.safestring import SafeText
|
||||
from django.utils.timesince import timesince
|
||||
from django.utils.timezone import now
|
||||
|
||||
from core.models import Benefit, DropCampaign, Game, TimeBasedDrop
|
||||
|
||||
register = template.Library()
|
||||
|
||||
|
||||
@register.simple_tag
|
||||
def render_game_card(game: Game) -> SafeText:
|
||||
"""Render the game card HTML.
|
||||
|
||||
Args:
|
||||
game: The game object.
|
||||
|
||||
Returns:
|
||||
The rendered HTML string.
|
||||
"""
|
||||
box_art_url: str = game.box_art_url or "https://static-cdn.jtvnw.net/ttv-static/404_boxart.jpg"
|
||||
name: str = game.name or "Game name unknown"
|
||||
slug: str = game.slug or "game-name-unknown"
|
||||
drop_campaigns: list[DropCampaign] = game.drop_campaigns.all() # type: ignore # noqa: PGH003
|
||||
return format_html(
|
||||
"""
|
||||
<div class="card mb-4 shadow-sm">
|
||||
<div class="row g-0">
|
||||
<div class="col-md-2">
|
||||
<img src="{}" alt="{} box art" class="img-fluid rounded-start" height="283" width="212" loading="lazy">
|
||||
</div>
|
||||
<div class="col-md-10">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title h5">
|
||||
<span>
|
||||
<a href="/game/{}" class="text-decoration-none">{}</a> - <a href="https://www.twitch.tv/directory/category/{}" class="text-decoration-none text-muted">Twitch</a>
|
||||
</span>
|
||||
</h2>
|
||||
<div class="mt-auto">
|
||||
<!-- Insert nice buttons -->
|
||||
</div>
|
||||
{}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
""", # noqa: E501
|
||||
box_art_url,
|
||||
name,
|
||||
game.twitch_id,
|
||||
name,
|
||||
slug,
|
||||
render_campaigns(drop_campaigns),
|
||||
)
|
||||
|
||||
|
||||
def render_campaigns(campaigns: list[DropCampaign]) -> SafeText:
|
||||
"""Render the campaigns HTML.
|
||||
|
||||
Args:
|
||||
campaigns: The list of campaigns.
|
||||
|
||||
Returns:
|
||||
The rendered HTML string.
|
||||
"""
|
||||
campaign_html: str = ""
|
||||
for campaign in campaigns:
|
||||
if campaign.details_url == campaign.account_link_url:
|
||||
link_html: SafeText = format_html(
|
||||
'<a href="{}" class="text-decoration-none">Details</a>',
|
||||
campaign.details_url,
|
||||
)
|
||||
else:
|
||||
link_html: SafeText = format_html(
|
||||
'<a href="{}" class="text-decoration-none">Details</a> | <a href="{}" class="text-decoration-none">Link Account</a>', # noqa: E501
|
||||
campaign.details_url,
|
||||
campaign.account_link_url,
|
||||
)
|
||||
|
||||
remaining_time: str = timesince(now(), campaign.ends_at) if campaign.ends_at else "Failed to calculate time"
|
||||
starts_at: str = campaign.starts_at.strftime("%A %d %B %H:%M") if campaign.starts_at else ""
|
||||
ends_at: str = campaign.ends_at.strftime("%A %d %B %H:%M") if campaign.ends_at else ""
|
||||
drops: list[TimeBasedDrop] = campaign.drops.all() # type: ignore # noqa: PGH003
|
||||
campaign_html += format_html(
|
||||
"""
|
||||
<div class="mt-3">
|
||||
{}
|
||||
<p class="mb-2 text-muted">Ends in: <abbr title="{} - {}">{}</abbr></p>
|
||||
<div class="row row-cols-1 row-cols-md-2 row-cols-lg-3">
|
||||
{}
|
||||
</div>
|
||||
</div>
|
||||
""",
|
||||
link_html,
|
||||
starts_at,
|
||||
ends_at,
|
||||
remaining_time,
|
||||
render_drops(drops),
|
||||
)
|
||||
|
||||
return format_html(campaign_html)
|
||||
|
||||
|
||||
def render_drops(drops: list[TimeBasedDrop]) -> SafeText:
|
||||
"""Render the drops HTML.
|
||||
|
||||
Args:
|
||||
drops: The list of drops.
|
||||
|
||||
Returns:
|
||||
The rendered HTML string.
|
||||
"""
|
||||
drop_html: str = ""
|
||||
for drop in drops:
|
||||
benefits: list[Benefit] = drop.benefits.all() # type: ignore # noqa: PGH003
|
||||
for benefit in benefits:
|
||||
image_url: str = benefit.image_url or "https://static-cdn.jtvnw.net/ttv-static/404_boxart.jpg"
|
||||
name: str = benefit.name or "Drop name unknown"
|
||||
drop_html += format_html(
|
||||
"""
|
||||
<div class="col d-flex align-items-center position-relative">
|
||||
<img src="{}" alt="{} drop image" class="img-fluid rounded me-3" height="50" width="50" loading="lazy">
|
||||
{}
|
||||
</div>
|
||||
""",
|
||||
image_url,
|
||||
name,
|
||||
name,
|
||||
)
|
||||
return format_html(drop_html)
|
27
core/templatetags/time_filters.py
Normal file
27
core/templatetags/time_filters.py
Normal file
@ -0,0 +1,27 @@
|
||||
from django import template
|
||||
|
||||
register = template.Library()
|
||||
|
||||
|
||||
@register.filter
|
||||
def minutes_to_hours(minutes: int | None) -> str:
|
||||
"""Converts minutes into 'X hours Y minutes'.
|
||||
|
||||
Args:
|
||||
minutes: The number of minutes.
|
||||
|
||||
Returns:
|
||||
The formatted string.
|
||||
"""
|
||||
if not isinstance(minutes, int):
|
||||
return "N/A"
|
||||
|
||||
hours: int = minutes // 60
|
||||
remaining_minutes: int = minutes % 60
|
||||
if hours > 0:
|
||||
if remaining_minutes > 0:
|
||||
return f"{hours}h {remaining_minutes}m"
|
||||
return f"{hours}h"
|
||||
if remaining_minutes > 0:
|
||||
return f"{remaining_minutes}m"
|
||||
return "0m"
|
@ -4,7 +4,7 @@ import logging
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import requests_cache
|
||||
from django.db.models import Prefetch
|
||||
from django.db.models import F, Prefetch
|
||||
from django.db.models.manager import BaseManager
|
||||
from django.http import HttpRequest, HttpResponse
|
||||
from django.template.response import TemplateResponse
|
||||
@ -30,32 +30,32 @@ def get_reward_campaigns() -> BaseManager[RewardCampaign]:
|
||||
|
||||
|
||||
def get_games_with_drops() -> BaseManager[Game]:
|
||||
"""Get the games with drops.
|
||||
"""Get the games with drops, sorted by when the drop campaigns end.
|
||||
|
||||
Returns:
|
||||
BaseManager[Game]: The games with drops.
|
||||
"""
|
||||
# Prefetch the benefits for the active drops.
|
||||
# Benefits have more information about the drop. Used for getting image_url.
|
||||
benefits: BaseManager[Benefit] = Benefit.objects.all()
|
||||
benefits_prefetch = Prefetch(lookup="benefits", queryset=benefits)
|
||||
# Prefetch the benefits for the time-based drops.
|
||||
benefits_prefetch = Prefetch(lookup="benefits", queryset=Benefit.objects.all())
|
||||
active_time_based_drops: BaseManager[TimeBasedDrop] = TimeBasedDrop.objects.filter(
|
||||
ends_at__gte=timezone.now(),
|
||||
starts_at__lte=timezone.now(),
|
||||
).prefetch_related(benefits_prefetch)
|
||||
|
||||
# Prefetch the drops for the active campaigns.
|
||||
active_campaigns: BaseManager[DropCampaign] = DropCampaign.objects.filter(ends_at__gte=timezone.now())
|
||||
# Prefetch the active time-based drops for the drop campaigns.
|
||||
drops_prefetch = Prefetch(lookup="drops", queryset=active_time_based_drops)
|
||||
campaigns_prefetch = Prefetch(
|
||||
lookup="drop_campaigns",
|
||||
queryset=active_campaigns.prefetch_related(drops_prefetch),
|
||||
)
|
||||
active_campaigns: BaseManager[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(campaigns_prefetch)
|
||||
.order_by("name")
|
||||
.prefetch_related(Prefetch("drop_campaigns", queryset=active_campaigns))
|
||||
.select_related("org")
|
||||
.order_by("drop_campaign_end")
|
||||
)
|
||||
|
||||
|
||||
|
@ -42,18 +42,21 @@ lint.select = ["ALL"]
|
||||
line-length = 119
|
||||
lint.pydocstyle.convention = "google"
|
||||
lint.ignore = [
|
||||
"CPY001", # Missing copyright notice at top of file
|
||||
"D100", # Checks for undocumented public module definitions.
|
||||
"D101", # Checks for undocumented public class definitions.
|
||||
"D102", # Checks for undocumented public method definitions.
|
||||
"D104", # Missing docstring in public package.
|
||||
"D105", # Missing docstring in magic method.
|
||||
"D106", # Checks for undocumented public class definitions, for nested classes.
|
||||
"ERA001", # Found commented-out code
|
||||
"FIX002", # Line contains TODO
|
||||
"COM812", # Checks for the absence of trailing commas.
|
||||
"ISC001", # Checks for implicitly concatenated strings on a single line.
|
||||
"DJ001", # Checks nullable string-based fields (like CharField and TextField) in Django models.
|
||||
"CPY001", # Missing copyright notice at top of file
|
||||
"D100", # Checks for undocumented public module definitions.
|
||||
"D101", # Checks for undocumented public class definitions.
|
||||
"D102", # Checks for undocumented public method definitions.
|
||||
"D104", # Missing docstring in public package.
|
||||
"D105", # Missing docstring in magic method.
|
||||
"D106", # Checks for undocumented public class definitions, for nested classes.
|
||||
"ERA001", # Found commented-out code
|
||||
"FIX002", # Line contains TODO
|
||||
"COM812", # Checks for the absence of trailing commas.
|
||||
"ISC001", # Checks for implicitly concatenated strings on a single line.
|
||||
"DJ001", # Checks nullable string-based fields (like CharField and TextField) in Django models.
|
||||
"PLR0912", # Too many branches # TODO: Actually fix this instead of ignoring it.
|
||||
"PLR0915", # Too many statements # TODO: Actually fix this instead of ignoring it.
|
||||
"C901", # Function is too complex # TODO: Actually fix this instead of ignoring it.
|
||||
]
|
||||
|
||||
[tool.ruff.lint.per-file-ignores]
|
||||
|
Reference in New Issue
Block a user