Rewrite everything

This commit is contained in:
2024-09-09 05:38:43 +02:00
parent 5334b6904e
commit ed0a7755bf
18 changed files with 991 additions and 622 deletions

View File

@ -1,10 +1,12 @@
{ {
"cSpell.words": [ "cSpell.words": [
"aimport",
"allauth", "allauth",
"appendonly", "appendonly",
"asgiref", "asgiref",
"Behaviour", "Behaviour",
"cacd", "cacd",
"dropcampaign",
"dungeonborne", "dungeonborne",
"forloop", "forloop",
"logdir", "logdir",
@ -14,10 +16,12 @@
"PGID", "PGID",
"PUID", "PUID",
"requirepass", "requirepass",
"rewardcampaign",
"sitewide", "sitewide",
"socialaccount", "socialaccount",
"Stresss", "Stresss",
"templatetags", "templatetags",
"timebaseddrop",
"tocs", "tocs",
"ttvdrops", "ttvdrops",
"ulimits", "ulimits",

View File

@ -20,9 +20,7 @@ class Command(BaseCommand):
*args: Variable length argument list. *args: Variable length argument list.
**kwargs: Arbitrary keyword arguments. **kwargs: Arbitrary keyword arguments.
""" """
dirs: list[str] = ["drop_campaigns", "reward_campaigns", "drop_campaigns"] dir_name = Path("json")
for dir_name in dirs:
logger.info("Scraping %s", dir_name)
for num, file in enumerate(Path(dir_name).rglob("*.json")): for num, file in enumerate(Path(dir_name).rglob("*.json")):
logger.info("Processing %s", file) logger.info("Processing %s", file)

View File

@ -10,7 +10,7 @@ from platformdirs import user_data_dir
from playwright.async_api import Playwright, async_playwright from playwright.async_api import Playwright, async_playwright
from playwright.async_api._generated import Response 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: if TYPE_CHECKING:
from playwright.async_api._generated import BrowserContext, Page from playwright.async_api._generated import BrowserContext, Page
@ -19,46 +19,36 @@ if TYPE_CHECKING:
logger: logging.Logger = logging.getLogger(__name__) 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: def get_profile_dir() -> Path:
"""Get the profile directory for the browser. """Get the profile directory for the browser.
Returns: Returns:
Path: The profile directory. 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) profile_dir.mkdir(parents=True, exist_ok=True)
if logger.isEnabledFor(logging.DEBUG): if logger.isEnabledFor(logging.DEBUG):
logger.debug("Launching Chrome browser with user data directory: %s", profile_dir) logger.debug("Launching Chrome browser with user data directory: %s", profile_dir)
return 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. """Save JSON data to a file.
Args: Args:
campaign (dict): The JSON data to save. 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: if not campaign:
return return
save_dir = Path(dir_name) save_dir = Path("json")
save_dir.mkdir(parents=True, exist_ok=True) save_dir.mkdir(parents=True, exist_ok=True)
# File name is the hash of the JSON data # File name is the hash of the JSON data
@ -68,28 +58,20 @@ def save_json(campaign: dict | None, dir_name: str) -> None:
json.dump(campaign, f, indent=4) 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. """Add a reward campaign to the database.
Args: Args:
campaign (dict): The reward campaign to add. reward_campaign (dict): The reward campaign to add.
""" """
if not campaign: if not reward_campaign:
return 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(twitch_id=reward_campaign["id"])
our_reward_campaign, created = await RewardCampaign.objects.aupdate_or_create(id=reward_campaign["id"]) await our_reward_campaign.aimport_json(reward_campaign)
await our_reward_campaign.import_json(reward_campaign)
if created: if created:
logger.info("Added reward campaign %s", our_reward_campaign) 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)
async def add_drop_campaign(drop_campaign: dict | None) -> None: async def add_drop_campaign(drop_campaign: dict | None) -> None:
"""Add a drop campaign to the database. """Add a drop campaign to the database.
@ -100,23 +82,37 @@ async def add_drop_campaign(drop_campaign: dict | None) -> None:
if not drop_campaign: if not drop_campaign:
return return
if drop_campaign.get("game"): if not drop_campaign.get("owner", {}):
owner, created = await Owner.objects.aupdate_or_create(id=drop_campaign["owner"]["id"]) logger.error("Owner not found in drop campaign %s", drop_campaign)
owner.import_json(drop_campaign["owner"]) return
game, created = await Game.objects.aupdate_or_create(id=drop_campaign["game"]["id"]) owner, created = await Owner.objects.aupdate_or_create(twitch_id=drop_campaign["owner"]["id"])
await game.import_json(drop_campaign["game"], owner) await owner.aimport_json(data=drop_campaign["owner"])
if created:
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: if created:
logger.info("Added game %s", game) logger.info("Added game %s", game)
our_drop_campaign, created = await DropCampaign.objects.aupdate_or_create(id=drop_campaign["id"]) our_drop_campaign, created = await DropCampaign.objects.aupdate_or_create(twitch_id=drop_campaign["id"])
await our_drop_campaign.import_json(drop_campaign, game) await our_drop_campaign.aimport_json(drop_campaign, game)
if created: if created:
logger.info("Added drop campaign %s", our_drop_campaign.id) logger.info("Added drop campaign %s", our_drop_campaign.twitch_id)
await add_time_based_drops(drop_campaign, our_drop_campaign) 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: async def add_time_based_drops(drop_campaign: dict, our_drop_campaign: DropCampaign) -> None:
"""Add time-based drops to the database. """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. our_drop_campaign (DropCampaign): The drop campaign object in the database.
""" """
for time_based_drop in drop_campaign.get("timeBasedDrops", []): for time_based_drop in drop_campaign.get("timeBasedDrops", []):
time_based_drop: dict[str, typing.Any]
if time_based_drop.get("preconditionDrops"): if time_based_drop.get("preconditionDrops"):
# TODO(TheLovinator): Add precondition drops to time-based drop # noqa: TD003 # TODO(TheLovinator): Add precondition drops to time-based drop # noqa: TD003
# TODO(TheLovinator): Send JSON to Discord # noqa: TD003 # TODO(TheLovinator): Send JSON to Discord # noqa: TD003
msg = "Not implemented: Add precondition drops to time-based drop" msg = "Not implemented: Add precondition drops to time-based drop"
raise NotImplementedError(msg) raise NotImplementedError(msg)
our_time_based_drop, created = await TimeBasedDrop.objects.aupdate_or_create(id=time_based_drop["id"]) our_time_based_drop, created = await TimeBasedDrop.objects.aupdate_or_create(twitch_id=time_based_drop["id"])
await our_time_based_drop.import_json(time_based_drop, our_drop_campaign) await our_time_based_drop.aimport_json(time_based_drop, our_drop_campaign)
if created: 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"): if our_time_based_drop and time_based_drop.get("benefitEdges"):
for benefit_edge in time_based_drop["benefitEdges"]: for benefit_edge in time_based_drop["benefitEdges"]:
benefit, created = await Benefit.objects.aupdate_or_create(id=benefit_edge["benefit"]) benefit, created = await Benefit.objects.aupdate_or_create(twitch_id=benefit_edge["benefit"])
await benefit.import_json(benefit_edge["benefit"], our_time_based_drop) await benefit.aimport_json(benefit_edge["benefit"], our_time_based_drop)
if created: 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. """Process JSON data.
Args: Args:
num (int): The number of the JSON data. num (int): The number of the JSON data.
campaign (dict): The JSON data to process. 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) logger.info("Processing JSON %d", num)
if not campaign: 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) logger.warning("Campaign is not a dictionary. %s", campaign)
return return
# This is a Reward Campaign save_json(campaign=campaign, local=local)
if "rewardCampaignsAvailableToUser" in campaign.get("data", {}):
save_json(campaign=campaign, dir_name="reward_campaigns") if campaign.get("data", {}).get("rewardCampaignsAvailableToUser"):
await add_reward_campaign(campaign=campaign) for reward_campaign in campaign["data"]["rewardCampaignsAvailableToUser"]:
await add_reward_campaign(reward_campaign=reward_campaign)
if "dropCampaign" in campaign.get("data", {}).get("user", {}):
save_json(campaign=campaign, dir_name="drop_campaign")
if campaign.get("data", {}).get("user", {}).get("dropCampaign"): if campaign.get("data", {}).get("user", {}).get("dropCampaign"):
await add_drop_campaign(drop_campaign=campaign["data"]["user"]["dropCampaign"]) await add_drop_campaign(drop_campaign=campaign["data"]["user"]["dropCampaign"])
if "dropCampaigns" in campaign.get("data", {}).get("currentUser", {}): if campaign.get("data", {}).get("currentUser", {}).get("dropCampaigns"):
for drop_campaign in campaign["data"]["currentUser"]["dropCampaigns"]: for drop_campaign in campaign["data"]["currentUser"]["dropCampaigns"]:
save_json(campaign=campaign, dir_name="drop_campaigns") await handle_drop_campaigns(drop_campaign=drop_campaign)
await add_drop_campaign(drop_campaign=drop_campaign)
class Command(BaseCommand): class Command(BaseCommand):
@ -232,7 +254,7 @@ class Command(BaseCommand):
await browser.close() await browser.close()
for num, campaign in enumerate(json_data, start=1): 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 return json_data

View File

@ -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.models
import django.contrib.auth.validators import django.contrib.auth.validators
@ -16,26 +16,12 @@ class Migration(migrations.Migration):
] ]
operations: list[Operation] = [ 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( migrations.CreateModel(
name="Game", name="Game",
fields=[ fields=[
("created_at", models.DateTimeField(auto_created=True, null=True)),
("twitch_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)),
("game_url", models.URLField(default="https://www.twitch.tv/", null=True)), ("game_url", models.URLField(default="https://www.twitch.tv/", null=True)),
("name", models.TextField(default="Game name unknown", null=True)), ("name", models.TextField(default="Game name unknown", null=True)),
( (
@ -48,8 +34,10 @@ class Migration(migrations.Migration):
migrations.CreateModel( migrations.CreateModel(
name="Owner", name="Owner",
fields=[ fields=[
("id", models.TextField(primary_key=True, serialize=False)), ("created_at", models.DateTimeField(auto_created=True, null=True)),
("name", models.TextField(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( migrations.CreateModel(
@ -130,26 +118,30 @@ class Migration(migrations.Migration):
], ],
), ),
migrations.CreateModel( migrations.CreateModel(
name="Channel", name="DropCampaign",
fields=[ fields=[
("twitch_id", models.TextField(primary_key=True, serialize=False)), ("created_at", models.DateTimeField(auto_created=True, null=True)),
("display_name", models.TextField(default="Channel name unknown", null=True)), ("id", models.TextField(primary_key=True, serialize=False)),
("name", models.TextField(null=True)), ("modified_at", models.DateTimeField(auto_now=True, null=True)),
("twitch_url", models.URLField(default="https://www.twitch.tv/", null=True)), ("account_link_url", models.URLField(null=True)),
("live", models.BooleanField(default=False)), ("description", models.TextField(null=True)),
("drop_campaigns", models.ManyToManyField(related_name="channels", to="core.dropcampaign")), ("details_url", models.URLField(null=True)),
], ("ends_at", models.DateTimeField(null=True)),
), ("starts_at", models.DateTimeField(null=True)),
migrations.AddField( ("image_url", models.URLField(null=True)),
model_name="dropcampaign", ("name", models.TextField(default="Unknown", null=True)),
name="game", ("status", models.TextField(null=True)),
field=models.ForeignKey( (
"game",
models.ForeignKey(
null=True, null=True,
on_delete=django.db.models.deletion.CASCADE, on_delete=django.db.models.deletion.CASCADE,
related_name="drop_campaigns", related_name="drop_campaigns",
to="core.game", to="core.game",
), ),
), ),
],
),
migrations.AddField( migrations.AddField(
model_name="game", model_name="game",
name="org", name="org",
@ -164,7 +156,7 @@ class Migration(migrations.Migration):
name="RewardCampaign", name="RewardCampaign",
fields=[ fields=[
("created_at", models.DateTimeField(auto_created=True, null=True)), ("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)), ("modified_at", models.DateTimeField(auto_now=True, null=True)),
("name", models.TextField(null=True)), ("name", models.TextField(null=True)),
("brand", models.TextField(null=True)), ("brand", models.TextField(null=True)),
@ -194,7 +186,9 @@ class Migration(migrations.Migration):
migrations.CreateModel( migrations.CreateModel(
name="Reward", name="Reward",
fields=[ 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)), ("name", models.TextField(null=True)),
("banner_image_url", models.URLField(null=True)), ("banner_image_url", models.URLField(null=True)),
("thumbnail_image_url", models.URLField(null=True)), ("thumbnail_image_url", models.URLField(null=True)),
@ -216,11 +210,11 @@ class Migration(migrations.Migration):
name="TimeBasedDrop", name="TimeBasedDrop",
fields=[ fields=[
("created_at", models.DateTimeField(auto_created=True, null=True)), ("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)), ("modified_at", models.DateTimeField(auto_now=True, null=True)),
("required_subs", models.PositiveBigIntegerField(null=True)), ("required_subs", models.PositiveBigIntegerField(null=True)),
("ends_at", models.DateTimeField(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)), ("required_minutes_watched", models.PositiveBigIntegerField(null=True)),
("starts_at", models.DateTimeField(null=True)), ("starts_at", models.DateTimeField(null=True)),
( (
@ -238,7 +232,7 @@ class Migration(migrations.Migration):
name="Benefit", name="Benefit",
fields=[ fields=[
("created_at", models.DateTimeField(auto_created=True, null=True)), ("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)), ("modified_at", models.DateTimeField(auto_now=True, null=True)),
("twitch_created_at", models.DateTimeField(null=True)), ("twitch_created_at", models.DateTimeField(null=True)),
("entitlement_limit", models.PositiveBigIntegerField(null=True)), ("entitlement_limit", models.PositiveBigIntegerField(null=True)),
@ -259,14 +253,16 @@ class Migration(migrations.Migration):
migrations.CreateModel( migrations.CreateModel(
name="Webhook", name="Webhook",
fields=[ fields=[
("created_at", models.DateTimeField(auto_created=True, null=True)),
("id", models.TextField(primary_key=True, serialize=False)),
("avatar", models.TextField(null=True)), ("avatar", models.TextField(null=True)),
("channel_id", models.TextField(null=True)), ("channel_id", models.TextField(null=True)),
("guild_id", models.TextField(null=True)), ("guild_id", models.TextField(null=True)),
("id", models.TextField(primary_key=True, serialize=False)),
("name", models.TextField(null=True)), ("name", models.TextField(null=True)),
("type", models.TextField(null=True)), ("type", models.TextField(null=True)),
("token", models.TextField()), ("token", models.TextField()),
("url", 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")), ("seen_drops", models.ManyToManyField(related_name="seen_drops", to="core.dropcampaign")),
( (
"subscribed_live_games", "subscribed_live_games",

View File

@ -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",
),
]

View 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",
),
]

View File

@ -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",
),
]

View File

@ -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),
),
]

View File

@ -1,8 +1,10 @@
from __future__ import annotations from __future__ import annotations
import logging import logging
from datetime import datetime
from typing import ClassVar, Self from typing import ClassVar, Self
from asgiref.sync import sync_to_async
from django.contrib.auth.models import AbstractUser from django.contrib.auth.models import AbstractUser
from django.db import models 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. Drops will be grouped by the owner. Users can also subscribe to owners.
""" """
id = models.TextField(primary_key=True) # "ad299ac0-f1a5-417d-881d-952c9aed00e9" # "ad299ac0-f1a5-417d-881d-952c9aed00e9"
name = models.TextField(null=True) # "Microsoft" 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: 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: if not data:
return self return self
self.name = data.get("name", self.name) if data.get("name") and data["name"] != self.name:
self.save() self.name = data["name"]
await self.asave()
return self return self
@ -37,16 +49,23 @@ class Owner(models.Model):
class Game(models.Model): class Game(models.Model):
"""This is the game we will see on the front end.""" """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" # "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" # "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" # "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" # "halo-infinite"
slug = models.TextField(null=True) slug = models.TextField(null=True)
@ -56,22 +75,37 @@ class Game(models.Model):
def __str__(self) -> str: def __str__(self) -> str:
return self.name or self.twitch_id 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: if not data:
logger.error("No data provided for %s.", self) logger.error("No data provided for %s.", self)
return self return self
self.name = data.get("name", self.name) if data["__typename"] != "Game":
self.box_art_url = data.get("boxArtURL", self.box_art_url) logger.error("Not a game? %s", data)
self.slug = data.get("slug", self.slug) 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"]}" self.game_url = f"https://www.twitch.tv/directory/game/{data["slug"]}"
dirty += 1
if owner: if owner:
await owner.games.aadd(self) # type: ignore # noqa: PGH003 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 return self
@ -80,8 +114,12 @@ 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."""
# "f257ce6e-502a-11ef-816e-0a58a9feac02" # "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) 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) modified_at = models.DateTimeField(null=True, auto_now=True)
# "https://www.halowaypoint.com/settings/linked-accounts" # "https://www.halowaypoint.com/settings/linked-accounts"
@ -95,41 +133,86 @@ class DropCampaign(models.Model):
# "2024-08-12T05:59:59.999Z" # "2024-08-12T05:59:59.999Z"
ends_at = models.DateTimeField(null=True) ends_at = models.DateTimeField(null=True)
# "2024-08-11T11:00:00Z"" # "2024-08-11T11:00:00Z""
starts_at = models.DateTimeField(null=True) 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" # "https://static-cdn.jtvnw.net/twitch-quests-assets/CAMPAIGN/c8e02666-8b86-471f-bf38-7ece29a758e4.png"
image_url = models.URLField(null=True) image_url = models.URLField(null=True)
# "HCS Open Series - Week 1 - DAY 2 - AUG11" # "HCS Open Series - Week 1 - DAY 2 - AUG11"
name = models.TextField(null=True, default="Unknown") name = models.TextField(null=True)
# "ACTIVE" # "ACTIVE"
status = models.TextField(null=True) status = models.TextField(null=True)
def __str__(self) -> str: game = models.ForeignKey(Game, on_delete=models.CASCADE, related_name="drop_campaigns", null=True)
return self.name or self.id
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: if not data:
logger.error("No data provided for %s.", self) logger.error("No data provided for %s.", self)
return self return self
self.name = data.get("name", self.name) if data.get("__typename") and data["__typename"] != "DropCampaign":
self.account_link_url = data.get("accountLinkURL", self.account_link_url) logger.error("Not a drop campaign? %s", data)
self.description = data.get("description", self.description) return self
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 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.game = game
self.save() if dirty > 0:
await self.asave()
logger.info("Updated drop campaign %s", self)
return self return self
@ -137,36 +220,89 @@ class DropCampaign(models.Model):
class TimeBasedDrop(models.Model): class TimeBasedDrop(models.Model):
"""This is the drop we will see on the front end.""" """This is the drop we will see on the front end."""
id = models.TextField(primary_key=True) # "d5cdf372-502b-11ef-bafd-0a58a9feac02" # "d5cdf372-502b-11ef-bafd-0a58a9feac02"
created_at = models.DateTimeField(null=True, auto_created=True) # "2024-08-11T00:00:00Z" twitch_id = models.TextField(primary_key=True)
modified_at = models.DateTimeField(null=True, auto_now=True) # "2024-08-12T00:00:00Z"
required_subs = models.PositiveBigIntegerField(null=True) # "1" # When the drop was first added to the database.
ends_at = models.DateTimeField(null=True) # "2024-08-12T05:59:59.999Z" created_at = models.DateTimeField(null=True, auto_created=True)
name = models.TextField(null=True) # "Cosmic Nexus Chimera"
required_minutes_watched = models.PositiveBigIntegerField(null=True) # "120" # When the drop was last modified.
starts_at = models.DateTimeField(null=True) # "2024-08-11T11:00:00Z" 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) drop_campaign = models.ForeignKey(DropCampaign, on_delete=models.CASCADE, related_name="drops", null=True)
def __str__(self) -> str: class Meta:
return self.name or "Drop name unknown" 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: if not data:
logger.error("No data provided for %s.", self) logger.error("No data provided for %s.", self)
return self return self
self.name = data.get("name", self.name) if data.get("__typename") and data["__typename"] != "TimeBasedDrop":
self.required_subs = data.get("requiredSubs", self.required_subs) logger.error("Not a time-based drop? %s", data)
self.required_minutes_watched = data.get("requiredMinutesWatched", self.required_minutes_watched) return self
self.starts_at = data.get("startAt", self.starts_at)
self.ends_at = data.get("endAt", self.ends_at)
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 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 return self
@ -174,20 +310,30 @@ class TimeBasedDrop(models.Model):
class Benefit(models.Model): class Benefit(models.Model):
"""Benefits are the rewards for the drops.""" """Benefits are the rewards for the drops."""
id = models.TextField(primary_key=True) # "d5cdf372-502b-11ef-bafd-0a58a9feac02" # "d5cdf372-502b-11ef-bafd-0a58a9feac02"
created_at = models.DateTimeField(null=True, auto_created=True) # "2024-08-11T00:00:00Z" twitch_id = models.TextField(primary_key=True)
modified_at = models.DateTimeField(null=True, auto_now=True) # "2024-08-12T00:00:00Z"
# 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. # 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" # "https://static-cdn.jtvnw.net/twitch-quests-assets/REWARD/e58ad175-73f6-4392-80b8-fb0223163733.png"
image_url = models.URLField(null=True) 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( time_based_drop = models.ForeignKey(
TimeBasedDrop, TimeBasedDrop,
@ -196,24 +342,53 @@ class Benefit(models.Model):
null=True, null=True,
) )
def __str__(self) -> str: class Meta:
return self.name or "Benefit name unknown" 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: if not data:
logger.error("No data provided for %s.", self) logger.error("No data provided for %s.", self)
return self return self
self.name = data.get("name", self.name) if data.get("__typename") and data["__typename"] != "DropBenefit":
self.entitlement_limit = data.get("entitlementLimit", self.entitlement_limit) logger.error("Not a benefit? %s", data)
self.is_ios_available = data.get("isIosAvailable", self.is_ios_available) return self
self.image_url = data.get("imageAssetURL", self.image_url)
self.twitch_created_at = data.get("createdAt", self.twitch_created_at)
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 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 return self
@ -221,64 +396,157 @@ class Benefit(models.Model):
class RewardCampaign(models.Model): class RewardCampaign(models.Model):
"""Buy subscriptions to earn rewards.""" """Buy subscriptions to earn rewards."""
id = models.TextField(primary_key=True) # "dc4ff0b4-4de0-11ef-9ec3-621fb0811846" # "dc4ff0b4-4de0-11ef-9ec3-621fb0811846"
created_at = models.DateTimeField(null=True, auto_created=True) # "2024-08-11T00:00:00Z" twitch_id = models.TextField(primary_key=True)
modified_at = models.DateTimeField(null=True, auto_now=True) # "2024-08-12T00:00:00Z"
name = models.TextField(null=True) # "Buy 1 new sub, get 3 months of Apple TV+" # When the reward campaign was first added to the database.
brand = models.TextField(null=True) # "Apple TV+" created_at = models.DateTimeField(null=True, auto_created=True)
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)
sub_goal = models.PositiveBigIntegerField(null=True) # "1" # When the reward campaign was last modified.
minute_watched_goal = models.PositiveBigIntegerField(null=True) # "0" 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" # "https://static-cdn.jtvnw.net/twitch-quests-assets/CAMPAIGN/quests_appletv_q3_2024/apple_150x200.png"
image_url = models.URLField(null=True) image_url = models.URLField(null=True)
def __str__(self) -> str: game = models.ForeignKey(Game, on_delete=models.CASCADE, related_name="reward_campaigns", null=True)
return self.name or "Reward campaign name unknown"
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: if not data:
logger.error("No data provided for %s.", self) logger.error("No data provided for %s.", self)
return self return self
self.name = data.get("name", self.name) if data.get("__typename") and data["__typename"] != "RewardCampaign":
self.brand = data.get("brand", self.brand) logger.error("Not a reward campaign? %s", data)
self.starts_at = data.get("startsAt", self.starts_at) return self
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)
unlock_requirements: dict = data.get("unlockRequirements", {}) if data.get("name") and data["name"] != self.name:
if unlock_requirements: self.name = data["name"]
self.sub_goal = unlock_requirements.get("subsGoal", self.sub_goal) dirty += 1
self.minute_watched_goal = unlock_requirements.get("minuteWatchedGoal", self.minute_watched_goal)
image = data.get("image", {}) if data.get("brand") and data["brand"] != self.brand:
if image: self.brand = data["brand"]
self.image_url = image.get("image1xURL", self.image_url) dirty += 1
if data.get("game"): starts_at_str = data.get("startsAt")
game: Game | None = Game.objects.filter(twitch_id=data["game"]["id"]).first() if starts_at_str:
if game: 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 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 return self
@ -286,45 +554,89 @@ class RewardCampaign(models.Model):
class Reward(models.Model): class Reward(models.Model):
"""This from the RewardCampaign.""" """This from the RewardCampaign."""
id = models.TextField(primary_key=True) # "dc2e9810-4de0-11ef-9ec3-621fb0811846" # "dc2e9810-4de0-11ef-9ec3-621fb0811846"
name = models.TextField(null=True) # "3 months of Apple TV+" 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" # "https://static-cdn.jtvnw.net/twitch-quests-assets/CAMPAIGN/quests_appletv_q3_2024/apple_200x200.png"
banner_image_url = models.URLField(null=True) banner_image_url = models.URLField(null=True)
# "https://static-cdn.jtvnw.net/twitch-quests-assets/CAMPAIGN/quests_appletv_q3_2024/apple_200x200.png" # "https://static-cdn.jtvnw.net/twitch-quests-assets/CAMPAIGN/quests_appletv_q3_2024/apple_200x200.png"
thumbnail_image_url = models.URLField(null=True) thumbnail_image_url = models.URLField(null=True)
earnable_until = models.DateTimeField(null=True) # "2024-08-19T19:00:00Z" # "2024-08-19T19:00:00Z"
redemption_instructions = models.TextField(null=True) # "" earnable_until = models.DateTimeField(null=True)
redemption_url = models.URLField(null=True) # "https://tv.apple.com/includes/commerce/redeem/code-entry"
# ""
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) 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: def __str__(self) -> str:
return self.name or "Reward name unknown" 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: if not data:
logger.error("No data provided for %s.", self) logger.error("No data provided for %s.", self)
return self return self
self.name = data.get("name", self.name) if data.get("__typename") and data["__typename"] != "Reward":
self.earnable_until = data.get("earnableUntil", self.earnable_until) logger.error("Not a reward? %s", data)
self.redemption_instructions = data.get("redemptionInstructions", self.redemption_instructions) return self
self.redemption_url = data.get("redemptionURL", self.redemption_url)
banner_image = data.get("bannerImage", {}) if data.get("name") and data["name"] != self.name:
if banner_image: self.name = data["name"]
self.banner_image_url = banner_image.get("image1xURL", self.banner_image_url) dirty += 1
thumbnail_image = data.get("thumbnailImage", {}) earnable_until_str = data.get("earnableUntil")
if thumbnail_image: if earnable_until_str:
self.thumbnail_image_url = thumbnail_image.get("image1xURL", self.thumbnail_image_url) 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 self.campaign = reward_campaign
dirty += 1
self.save() if dirty > 0:
await self.asave()
logger.info("Updated reward %s", self)
return self return self
@ -332,15 +644,21 @@ class Reward(models.Model):
class Webhook(models.Model): class Webhook(models.Model):
"""Discord webhook.""" """Discord webhook."""
id = models.TextField(primary_key=True)
avatar = models.TextField(null=True) avatar = models.TextField(null=True)
channel_id = models.TextField(null=True) channel_id = models.TextField(null=True)
guild_id = models.TextField(null=True) guild_id = models.TextField(null=True)
id = models.TextField(primary_key=True)
name = models.TextField(null=True) name = models.TextField(null=True)
type = models.TextField(null=True) type = models.TextField(null=True)
token = models.TextField() token = models.TextField()
url = 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. # Get notified when the site finds a new game.
subscribed_new_games = models.ManyToManyField(Game, related_name="subscribed_new_games") subscribed_new_games = models.ManyToManyField(Game, related_name="subscribed_new_games")

View File

@ -129,7 +129,7 @@ DATABASES = {
"ENGINE": "django.db.backends.sqlite3", "ENGINE": "django.db.backends.sqlite3",
"NAME": DATA_DIR / "ttvdrops.sqlite3", "NAME": DATA_DIR / "ttvdrops.sqlite3",
"OPTIONS": { "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
}, },
}, },
} }

View File

@ -1,98 +1,94 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block content %} {% block content %}
<div class="container my-5"> <div class="container">
<div class="text-center"> <h2>{{ game.name }}</h2>
<header class="h2 mt-4"> <img src="{{ game.box_art_url }}" alt="{{ game.name }} box art" height="283" width="212">
{{ 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="mt-5"> <h3>Game Details</h3>
<h3 class="h4">Game Details</h3> <table class="table table-hover table-sm table-striped" cellspacing="0">
<ul class="list-group"> <tr>
<li class="list-group-item"><strong>Twitch ID:</strong> {{ game.twitch_id }}</li> <td><strong>Twitch ID:</strong></td>
<li class="list-group-item"><strong>Game URL:</strong> <a href="{{ game.url }}" <td>{{ game.pk }}</td>
target="_blank">{{ game.url }}</a></li> </tr>
<li class="list-group-item"><strong>Game name:</strong> {{ game.name }}</li> <tr>
<li class="list-group-item"><strong>Game box art URL:</strong> <a href="{{ game.box_art_url }}" <td><strong>Game URL:</strong></td>
target="_blank">{{ game.box_art_url }}</a></li> <td><a href="{{ game.game_url }}" target="_blank">{{ game.game_url }}</a></td>
</ul> </tr>
</div> <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>Organization</h3>
<h3 class="h4">Organization</h3> <table class="table table-hover table-sm table-striped" cellspacing="0">
<ul class="list-group"> <tr>
{% if game.org %} {% if game.org %}
<li class="list-group-item"> <td><a href="#">{{ game.org.name }} - <span class="text-muted">{{ game.org.pk }}</span></a></td>
<a href="#">{{ game.org.name }} - <span class="text-muted">{{ game.org.id }}</span></a>
</li>
{% else %} {% else %}
<li class="list-group-item">No organization associated with this game.</li> <td>No organization associated with this game.</td>
{% endif %} {% endif %}
</ul> </tr>
</div> </table>
<div class="mt-5"> <h3>Drop Campaigns</h3>
<h3 class="h4">Drop Campaigns</h3>
{% if game.drop_campaigns.all %} {% if game.drop_campaigns.all %}
<div>
{% for drop_campaign in game.drop_campaigns.all %} {% for drop_campaign in game.drop_campaigns.all %}
<div> <br>
<h2> <h2>{{ drop_campaign.name }}</h2>
{{ drop_campaign.name }} <table class="table table-hover table-sm table-striped" cellspacing="0">
</h2> <tr>
<div> <td><strong>Campaign Name:</strong></td>
<div> <td>{{ drop_campaign.name }}</td>
<img src="{{ drop_campaign.image_url }}" alt="{{ drop_campaign.name }} image" </tr>
class="img-fluid mb-3 rounded"> <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>Status:</strong> {{ drop_campaign.status }}</p>
<p>{{ drop_campaign.description }}</p> <p><strong>Description:</strong> {{ drop_campaign.description }}</p>
<p><strong>Starts at:</strong> {{ drop_campaign.starts_at }}</p> <p><strong>Starts at:</strong> {{ drop_campaign.starts_at }}</p>
<p><strong>Ends at:</strong> {{ drop_campaign.ends_at }}</p> <p><strong>Ends at:</strong> {{ drop_campaign.ends_at }}</p>
<p><strong>More details:</strong> <a href="{{ drop_campaign.details_url }}" <p><strong>More details:</strong> <a href="{{ drop_campaign.details_url }}"
target="_blank">{{ drop_campaign.details_url }}</a></p> target="_blank">{{ drop_campaign.details_url }}</a></p>
<p><strong>Account Link:</strong> <a href="{{ drop_campaign.account_link_url }}" <p><strong>Account Link:</strong> <a href="{{ drop_campaign.account_link_url }}"
target="_blank">{{ drop_campaign.account_link_url }}</a></p> 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 %} {% if drop_campaign.drops.all %}
<div> <table class="table table-hover table-sm table-striped" cellspacing="0">
{% for drop in drop_campaign.drops.all %} <tr>
<hr> <th>ID</th>
<div> <th>Item Name</th>
<h3 class="mb-2">{{ drop.name }}</h3> <th>Minutes</th>
{% for benefit in drop.benefits.all %} <th>Image</th>
<img src="{{ benefit.image_url }}" alt="{{ benefit.name }} image" <th>Benefit Name</th>
class="img-fluid rounded mb-2"> </tr>
<p><strong>Required Subscriptions:</strong> {{ drop.required_subs }}</p> {% for item in drop_campaign.drops.all %}
<p><strong>Required Minutes Watched:</strong> {{ drop.required_minutes_watched }}</p> <tr>
<p><strong>Starts at:</strong> {{ drop.starts_at }}</p> <td>{{ item.pk }}</td>
<p><strong>Ends at:</strong> {{ drop.ends_at }}</p> <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 %}
</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 %}
<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>
{% endfor %}
</div>
{% else %}
<p>No drop campaigns available for this game.</p>
{% endif %}
</div>
</div> </div>
{% endblock content %} {% endblock content %}

View File

@ -1,27 +1,176 @@
{% extends "base.html" %} {% extends "base.html" %}
{% load static %} {% load static %}
{% load campaign_tags %} {% load custom_filters %}
{% load game_tags %} {% load 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" %}
{% include "partials/news.html" %} {% include "partials/news.html" %}
<!-- Reward Campaigns Section -->
<section class="reward-campaigns">
<h2> <h2>
Reward campaign - Reward Campaigns -
<span class="d-inline text-muted"> <span class="d-inline text-muted">
{{ reward_campaigns.count }} {{ reward_campaigns.count }} campaign{{ reward_campaigns.count|pluralize }}
campaign{{ reward_campaigns.count|pluralize }}
</span> </span>
</h2> </h2>
<!-- Loop through reward campaigns -->
{% for campaign in reward_campaigns %} {% for campaign in reward_campaigns %}
{% render_campaign campaign %} <div class="card mb-4 shadow-sm" id="campaign-{{ campaign.twitch_id }}">
{% endfor %} <div class="row g-0">
<h2> <!-- Campaign Image -->
Drop campaigns - <div class="col-md-2">
<span class="d-inline text-muted ">{{ games.count }} game{{ games.count|pluralize }}</span> <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> </h2>
{% for game in games %} <p class="card-text text-muted">{{ campaign.summary }}</p>
{% render_game_card game %} <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 %} {% 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> </div>
{% endblock content %} {% endblock content %}

View File

@ -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)

View 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()

View File

@ -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)

View 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"

View File

@ -4,7 +4,7 @@ import logging
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any
import requests_cache 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.db.models.manager import BaseManager
from django.http import HttpRequest, HttpResponse from django.http import HttpRequest, HttpResponse
from django.template.response import TemplateResponse from django.template.response import TemplateResponse
@ -30,32 +30,32 @@ def get_reward_campaigns() -> BaseManager[RewardCampaign]:
def get_games_with_drops() -> BaseManager[Game]: 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: Returns:
BaseManager[Game]: The games with drops. BaseManager[Game]: The games with drops.
""" """
# Prefetch the benefits for the active drops. # Prefetch the benefits for the time-based drops.
# Benefits have more information about the drop. Used for getting image_url. benefits_prefetch = Prefetch(lookup="benefits", queryset=Benefit.objects.all())
benefits: BaseManager[Benefit] = Benefit.objects.all()
benefits_prefetch = Prefetch(lookup="benefits", queryset=benefits)
active_time_based_drops: BaseManager[TimeBasedDrop] = TimeBasedDrop.objects.filter( active_time_based_drops: BaseManager[TimeBasedDrop] = TimeBasedDrop.objects.filter(
ends_at__gte=timezone.now(), ends_at__gte=timezone.now(),
starts_at__lte=timezone.now(),
).prefetch_related(benefits_prefetch) ).prefetch_related(benefits_prefetch)
# Prefetch the drops for the active campaigns. # Prefetch the active time-based drops for the drop campaigns.
active_campaigns: BaseManager[DropCampaign] = DropCampaign.objects.filter(ends_at__gte=timezone.now())
drops_prefetch = Prefetch(lookup="drops", queryset=active_time_based_drops) drops_prefetch = Prefetch(lookup="drops", queryset=active_time_based_drops)
campaigns_prefetch = Prefetch( active_campaigns: BaseManager[DropCampaign] = DropCampaign.objects.filter(
lookup="drop_campaigns", ends_at__gte=timezone.now(),
queryset=active_campaigns.prefetch_related(drops_prefetch), starts_at__lte=timezone.now(),
) ).prefetch_related(drops_prefetch)
return ( return (
Game.objects.filter(drop_campaigns__in=active_campaigns) Game.objects.filter(drop_campaigns__in=active_campaigns)
.annotate(drop_campaign_end=F("drop_campaigns__ends_at"))
.distinct() .distinct()
.prefetch_related(campaigns_prefetch) .prefetch_related(Prefetch("drop_campaigns", queryset=active_campaigns))
.order_by("name") .select_related("org")
.order_by("drop_campaign_end")
) )

View File

@ -54,6 +54,9 @@ lint.ignore = [
"COM812", # Checks for the absence of trailing commas. "COM812", # Checks for the absence of trailing commas.
"ISC001", # Checks for implicitly concatenated strings on a single line. "ISC001", # Checks for implicitly concatenated strings on a single line.
"DJ001", # Checks nullable string-based fields (like CharField and TextField) in Django models. "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] [tool.ruff.lint.per-file-ignores]