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