diff --git a/.vscode/settings.json b/.vscode/settings.json index 4ae0010..b175a30 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -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", diff --git a/core/management/commands/scrape_local.py b/core/management/commands/scrape_local.py index dfa9797..63e0f85 100644 --- a/core/management/commands/scrape_local.py +++ b/core/management/commands/scrape_local.py @@ -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__": diff --git a/core/management/commands/scrape_twitch.py b/core/management/commands/scrape_twitch.py index 5d90a06..f81af69 100644 --- a/core/management/commands/scrape_twitch.py +++ b/core/management/commands/scrape_twitch.py @@ -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 diff --git a/core/migrations/0001_initial.py b/core/migrations/0001_initial.py index f506a42..95006c6 100644 --- a/core/migrations/0001_initial.py +++ b/core/migrations/0001_initial.py @@ -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", diff --git a/core/migrations/0002_delete_channel.py b/core/migrations/0002_delete_channel.py deleted file mode 100644 index 3265163..0000000 --- a/core/migrations/0002_delete_channel.py +++ /dev/null @@ -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", - ), - ] diff --git a/core/migrations/0002_rename_id_dropcampaign_twitch_id.py b/core/migrations/0002_rename_id_dropcampaign_twitch_id.py new file mode 100644 index 0000000..3174108 --- /dev/null +++ b/core/migrations/0002_rename_id_dropcampaign_twitch_id.py @@ -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", + ), + ] diff --git a/core/migrations/0003_rename_sub_goal_rewardcampaign_subs_goal.py b/core/migrations/0003_rename_sub_goal_rewardcampaign_subs_goal.py new file mode 100644 index 0000000..b830c8b --- /dev/null +++ b/core/migrations/0003_rename_sub_goal_rewardcampaign_subs_goal.py @@ -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", + ), + ] diff --git a/core/migrations/0004_alter_dropcampaign_name_alter_game_box_art_url_and_more.py b/core/migrations/0004_alter_dropcampaign_name_alter_game_box_art_url_and_more.py new file mode 100644 index 0000000..0286d41 --- /dev/null +++ b/core/migrations/0004_alter_dropcampaign_name_alter_game_box_art_url_and_more.py @@ -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), + ), + ] diff --git a/core/models.py b/core/models.py index a89aca9..ee9cb55 100644 --- a/core/models.py +++ b/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") diff --git a/core/settings.py b/core/settings.py index 4e4e9c3..fc25ec1 100644 --- a/core/settings.py +++ b/core/settings.py @@ -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 }, }, } diff --git a/core/templates/game.html b/core/templates/game.html index b6e75bd..3487e23 100644 --- a/core/templates/game.html +++ b/core/templates/game.html @@ -1,98 +1,94 @@ {% extends "base.html" %} {% block content %} -
-
-
- {{ game.name }} -
- {{ game.name }} box art -
+
+

{{ game.name }}

+ {{ game.name }} box art -
-

Game Details

- -
+

Game Details

+ + + + + + + + + + + + + + + + + +
Twitch ID:{{ game.pk }}
Game URL:{{ game.game_url }}
Game name:{{ game.name }}
Game box art URL:{{ game.box_art_url }}
-
-

Organization

-
    +

    Organization

    + + {% if game.org %} -
  • - {{ game.org.name }} - {{ game.org.id }} -
  • + {% else %} -
  • No organization associated with this game.
  • + {% endif %} - - + +
    {{ game.org.name }} - {{ game.org.pk }}No organization associated with this game.
    -
    -

    Drop Campaigns

    - {% if game.drop_campaigns.all %} -
    - {% for drop_campaign in game.drop_campaigns.all %} -
    -

    - {{ drop_campaign.name }} -

    -
    -
    - {{ drop_campaign.name }} image -

    Status: {{ drop_campaign.status }}

    -

    {{ drop_campaign.description }}

    -

    Starts at: {{ drop_campaign.starts_at }}

    -

    Ends at: {{ drop_campaign.ends_at }}

    -

    More details: {{ drop_campaign.details_url }}

    -

    Account Link: {{ drop_campaign.account_link_url }}

    +

    Drop Campaigns

    + {% if game.drop_campaigns.all %} + {% for drop_campaign in game.drop_campaigns.all %} +
    +

    {{ drop_campaign.name }}

    + + + + + + + + + +
    Campaign Name:{{ drop_campaign.name }}
    {{ drop_campaign.name }} image +

    Status: {{ drop_campaign.status }}

    +

    Description: {{ drop_campaign.description }}

    +

    Starts at: {{ drop_campaign.starts_at }}

    +

    Ends at: {{ drop_campaign.ends_at }}

    +

    More details: {{ drop_campaign.details_url }}

    +

    Account Link: {{ drop_campaign.account_link_url }}

    +
    -

    Time-Based Drops

    - {% if drop_campaign.drops.all %} -
    - {% for drop in drop_campaign.drops.all %} -
    -
    -

    {{ drop.name }}

    - {% for benefit in drop.benefits.all %} - {{ benefit.name }} image -

    Required Subscriptions: {{ drop.required_subs }}

    -

    Required Minutes Watched: {{ drop.required_minutes_watched }}

    -

    Starts at: {{ drop.starts_at }}

    -

    Ends at: {{ drop.ends_at }}

    - -

    Entitlement Limit: {{ benefit.entitlement_limit }}

    -

    Available on iOS: {{ benefit.is_ios_available }}

    -

    Twitch Created At: {{ benefit.twitch_created_at }}

    - {% empty %} -
    No benefits available for this drop.
    - {% endfor %} -
    - {% empty %} -
    No time-based drops available for this campaign.
    - {% endfor %} -
    - {% else %} -

    No time-based drops available for this campaign.

    - {% endif %} -
    -
    -
    + {% if drop_campaign.drops.all %} + + + + + + + + + {% for item in drop_campaign.drops.all %} + + + + + {% for benefit in item.benefits.all %} + + {% endfor %} - - {% else %} -

    No drop campaigns available for this game.

    - {% endif %} - + + {% endfor %} +
    IDItem NameMinutesImageBenefit Name
    {{ item.pk }}{{ item.name }}{{ item.required_minutes_watched }}{{ benefit.name }} reward image + {{ benefit.name }}
    + {% else %} +

    No items associated with this drop campaign.

    + {% endif %} + {% endfor %} + {% else %} +

    No drop campaigns associated with this game.

    + {% endif %} +
    {% endblock content %} diff --git a/core/templates/index.html b/core/templates/index.html index c344942..1d8efa8 100644 --- a/core/templates/index.html +++ b/core/templates/index.html @@ -1,27 +1,176 @@ {% extends "base.html" %} {% load static %} -{% load campaign_tags %} -{% load game_tags %} +{% load custom_filters %} +{% load time_filters %} {% block content %}
    {% include "partials/info_box.html" %} {% include "partials/news.html" %} -

    - Reward campaign - - - {{ reward_campaigns.count }} - campaign{{ reward_campaigns.count|pluralize }} - -

    - {% for campaign in reward_campaigns %} - {% render_campaign campaign %} - {% endfor %} -

    - Drop campaigns - - {{ games.count }} game{{ games.count|pluralize }} -

    - {% for game in games %} - {% render_game_card game %} - {% endfor %} + + +
    +

    + Reward Campaigns - + + {{ reward_campaigns.count }} campaign{{ reward_campaigns.count|pluralize }} + +

    + + + {% for campaign in reward_campaigns %} +
    +
    + +
    + {{ campaign.name }} +
    + + +
    +
    +

    + + {{ campaign.name }} + +

    +

    {{ campaign.summary }}

    +

    + Ends in: + + {{ campaign.ends_at|timesince }} + +

    + Learn More + + + {% if campaign.instructions %} +
    +

    Instructions

    +

    {{ campaign.instructions|safe }}

    +
    + {% endif %} + + + {% if campaign.rewards.all %} +
    +

    Rewards

    +
    + {% for reward in campaign.rewards.all %} +
    + {{ reward.name }} reward image +
    {{ reward.name }}
    +
    + {% endfor %} +
    +
    + {% endif %} +
    +
    +
    +
    + {% endfor %} +
    + + +
    +

    + Drop Campaigns - + {{ games.count }} game{{ games.count|pluralize }} +

    + + + {% for game in games %} +
    +
    + +
    + {{ game.name|default:'Game name unknown' }} box art +
    + + +
    +
    +

    + + {{ game.name|default:'Unknown' }} + + - + Twitch +

    + + + {% for campaign in game.drop_campaigns.all %} +
    +

    {{ campaign.name }}

    + Details + {% if campaign.details_url != campaign.account_link_url %} + | Link Account + {% endif %} + +

    + Ends in: + + {{ campaign.ends_at|timeuntil }} + +

    + + +
    + + + + + + + + + + {% for drop in campaign.drops.all %} + {% if drop.benefits.exists %} + {% for benefit in drop.benefits.all %} + + + + + + {% endfor %} + {% else %} + + + + + + {% endif %} + {% endfor %} + +
    Benefit ImageBenefit NameRequired Minutes Watched
    + {{ benefit.name|default:'Unknown' }} + + + {{ benefit.name|default:'Unknown' }} + + {{ drop.required_minutes_watched|minutes_to_hours }}
    + {{ drop.name|default:'Unknown' }} + {{ drop.name|default:'Unknown' }}N/A
    +
    +
    + {% endfor %} +
    +
    +
    +
    + {% endfor %} +
    {% endblock content %} diff --git a/core/templatetags/campaign_tags.py b/core/templatetags/campaign_tags.py deleted file mode 100644 index 1aa3f6d..0000000 --- a/core/templatetags/campaign_tags.py +++ /dev/null @@ -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""" -
    -
    -
    - {campaign.name} -
    -
    -
    -

    - {campaign.name} -

    -

    {campaign.summary}

    -

    - Ends in: {time_remaining} -

    - Learn More - """ - - # Add instructions if present - if campaign.instructions: - html += f""" -
    -

    Instructions

    -

    {campaign.instructions}

    -
    - """ - - # Add rewards if present - if campaign.rewards.exists(): # type: ignore # noqa: PGH003 - html += """ -
    -

    Rewards

    -
    - """ - for reward in campaign.rewards.all(): # type: ignore # noqa: PGH003 - reward: Reward - html += f""" -
    - {reward.name} reward image -
    - {reward.name} -
    -
    - """ - html += "
    " - - # Close the main divs - html += """ -
    -
    -
    -
    - """ - - return format_html(html) diff --git a/core/templatetags/custom_filters.py b/core/templatetags/custom_filters.py new file mode 100644 index 0000000..e04169b --- /dev/null +++ b/core/templatetags/custom_filters.py @@ -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() diff --git a/core/templatetags/game_tags.py b/core/templatetags/game_tags.py deleted file mode 100644 index dfaf087..0000000 --- a/core/templatetags/game_tags.py +++ /dev/null @@ -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( - """ -
    -
    -
    - {} box art -
    -
    -
    -

    - - {} - Twitch - -

    -
    - -
    - {} -
    -
    -
    -
    - """, # 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( - 'Details', - campaign.details_url, - ) - else: - link_html: SafeText = format_html( - 'Details | Link Account', # 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( - """ -
    - {} -

    Ends in: {}

    -
    - {} -
    -
    - """, - 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( - """ -
    - {} drop image - {} -
    - """, - image_url, - name, - name, - ) - return format_html(drop_html) diff --git a/core/templatetags/time_filters.py b/core/templatetags/time_filters.py new file mode 100644 index 0000000..6beb7eb --- /dev/null +++ b/core/templatetags/time_filters.py @@ -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" diff --git a/core/views.py b/core/views.py index f46a24d..77b4aeb 100644 --- a/core/views.py +++ b/core/views.py @@ -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") ) diff --git a/pyproject.toml b/pyproject.toml index 08042ba..3fee704 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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]