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