diff --git a/.gitignore b/.gitignore index b9af219..6eaa1fb 100644 --- a/.gitignore +++ b/.gitignore @@ -162,3 +162,5 @@ cython_debug/ *.json staticfiles/ + +*.sqlite diff --git a/core/discord.py b/core/discord.py index 03556aa..4b3a87a 100644 --- a/core/discord.py +++ b/core/discord.py @@ -1,42 +1,10 @@ import logging -from typing import TYPE_CHECKING -from discord_webhook import DiscordWebhook -from django.conf import settings from django.utils import timezone -from core.models import DropCampaign, Game, GameSubscription, Owner, OwnerSubscription - -if TYPE_CHECKING: - from requests import Response - logger: logging.Logger = logging.getLogger(__name__) -def send(message: str, webhook_url: str | None = None) -> None: - """Send a message to Discord. - - Args: - message: The message to send. - webhook_url: The webhook URL to send the message to. - """ - logger.info("Discord message: %s", message) - - webhook_url = webhook_url or str(settings.DISCORD_WEBHOOK_URL) - if not webhook_url: - logger.error("No webhook URL provided.") - return - - webhook = DiscordWebhook( - url=webhook_url, - content=message, - username="TTVDrops", - rate_limit_retry=True, - ) - response: Response = webhook.execute() - logger.debug(response) - - def convert_time_to_discord_timestamp(time: timezone.datetime | None) -> str: """Discord uses <t:UNIX_TIMESTAMP:R> for timestamps. @@ -47,47 +15,3 @@ def convert_time_to_discord_timestamp(time: timezone.datetime | None) -> str: str: The Discord timestamp string. If time is None, returns "Unknown". """ return f"<t:{int(time.timestamp())}:R>" if time else "Unknown" - - -def generate_game_message(instance: DropCampaign, game: Game, sub: GameSubscription) -> str: - """Generate a message for a drop campaign. - - Args: - instance (DropCampaign): Drop campaign instance. - game (Game): Game instance. - sub (GameSubscription): Game subscription instance. - - Returns: - str: The message to send to Discord. - """ - game_name: str = game.name or "Unknown" - description: str = instance.description or "No description provided." - start_at: str = convert_time_to_discord_timestamp(instance.starts_at) - end_at: str = convert_time_to_discord_timestamp(instance.ends_at) - msg: str = f"{game_name}: {instance.name}\n{description}\nStarts: {start_at}\nEnds: {end_at}" - - logger.info("Discord message: '%s' to '%s'", msg, sub.webhook.url) - - return msg - - -def generate_owner_message(instance: DropCampaign, owner: Owner, sub: OwnerSubscription) -> str: - """Generate a message for a drop campaign. - - Args: - instance (DropCampaign): Drop campaign instance. - owner (Owner): Owner instance. - sub (OwnerSubscription): Owner subscription instance. - - Returns: - str: The message to send to Discord. - """ - owner_name: str = owner.name or "Unknown" - description: str = instance.description or "No description provided." - start_at: str = convert_time_to_discord_timestamp(instance.starts_at) - end_at: str = convert_time_to_discord_timestamp(instance.ends_at) - msg: str = f"{owner_name}: {instance.name}\n{description}\nStarts: {start_at}\nEnds: {end_at}" - - logger.info("Discord message: '%s' to '%s'", msg, sub.webhook.url) - - return msg diff --git a/core/management/commands/scrape_twitch.py b/core/management/commands/scrape_twitch.py index 92ef0b3..50f3e76 100644 --- a/core/management/commands/scrape_twitch.py +++ b/core/management/commands/scrape_twitch.py @@ -68,78 +68,99 @@ def save_json(campaign: dict | None, dir_name: str) -> None: json.dump(campaign, f, indent=4) -async def add_reward_campaign(campaign: dict | None) -> None: # noqa: C901 +async def add_reward_campaign(campaign: dict | None) -> None: """Add a reward campaign to the database. Args: campaign (dict): The reward campaign to add. """ - # sourcery skip: low-code-quality if not campaign: return - logger.info("Adding reward campaign to database") + logger.info("Adding reward campaign to database %s", campaign["id"]) if "data" in campaign and "rewardCampaignsAvailableToUser" in campaign["data"]: - mappings: dict[str, str] = { - "brand": "brand", - "createdAt": "created_at", - "startsAt": "starts_at", - "endsAt": "ends_at", - "status": "status", - "summary": "summary", - "instructions": "instructions", - "rewardValueURLParam": "reward_value_url_param", - "externalURL": "external_url", - "aboutURL": "about_url", - "isSitewide": "is_site_wide", - } - for reward_campaign in campaign["data"]["rewardCampaignsAvailableToUser"]: - defaults = {new_key: reward_campaign[key] for key, new_key in mappings.items() if reward_campaign.get(key)} + our_reward_campaign = await handle_reward_campaign(reward_campaign) - if reward_campaign.get("unlockRequirements", {}).get("subsGoal"): - defaults["sub_goal"] = reward_campaign["unlockRequirements"]["subsGoal"] + if "rewards" in reward_campaign: + for reward in reward_campaign["rewards"]: + await handle_rewards(reward, our_reward_campaign) - if reward_campaign.get("unlockRequirements", {}).get("minuteWatchedGoal"): - defaults["minute_watched_goal"] = reward_campaign["unlockRequirements"]["minuteWatchedGoal"] - if reward_campaign.get("image"): - defaults["image_url"] = reward_campaign["image"]["image1xURL"] +async def handle_rewards(reward: dict, reward_campaign: RewardCampaign | None) -> None: + """Add or update a reward in the database. - our_reward_campaign, created = await RewardCampaign.objects.aupdate_or_create( - id=reward_campaign["id"], - defaults=defaults, - ) - if created: - logger.info("Added reward campaign %s", our_reward_campaign.id) + Args: + reward (dict): The JSON from Twitch. + reward_campaign (RewardCampaign | None): The reward campaign the reward belongs to. + """ + mappings: dict[str, str] = { + "name": "name", + "earnableUntil": "earnable_until", + "redemptionInstructions": "redemption_instructions", + "redemptionURL": "redemption_url", + } - if reward_campaign["game"]: - # TODO(TheLovinator): Add game to reward campaign # noqa: TD003 - # TODO(TheLovinator): Send JSON to Discord # noqa: TD003 - logger.error("Not implemented: Add game to reward campaign, JSON: %s", reward_campaign["game"]) + defaults: dict = {new_key: reward[key] for key, new_key in mappings.items() if reward.get(key)} + if reward_campaign: + defaults["campaign"] = reward_campaign - # Add rewards - mappings: dict[str, str] = { - "name": "name", - "bannerImage": "banner_image_url", - "thumbnailImage": "thumbnail_image_url", - "earnableUntil": "earnable_until", - "redemptionInstructions": "redemption_instructions", - "redemptionURL": "redemption_url", - } - for reward in reward_campaign["rewards"]: - defaults = {new_key: reward[key] for key, new_key in mappings.items() if reward.get(key)} - if our_reward_campaign: - defaults["campaign"] = our_reward_campaign - our_reward, created = await Reward.objects.aupdate_or_create( - id=reward["id"], - defaults=defaults, - ) + if reward.get("bannerImage"): + defaults["banner_image_url"] = reward["bannerImage"]["image1xURL"] - if created: - logger.info("Added reward %s", our_reward.id) - else: - logger.info("Updated reward %s", our_reward.id) + if reward.get("thumbnailImage"): + defaults["thumbnail_image_url"] = reward["thumbnailImage"]["image1xURL"] + + reward_instance, created = await Reward.objects.aupdate_or_create(id=reward["id"], defaults=defaults) + if created: + logger.info("Added reward %s", reward_instance.id) + + +async def handle_reward_campaign(reward_campaign: dict) -> RewardCampaign: + """Add or update a reward campaign in the database. + + Args: + reward_campaign (dict): The reward campaign JSON from Twitch. + + Returns: + RewardCampaign: The reward campaign that was added or updated. + """ + mappings: dict[str, str] = { + "brand": "brand", + "createdAt": "created_at", + "startsAt": "starts_at", + "endsAt": "ends_at", + "status": "status", + "summary": "summary", + "instructions": "instructions", + "rewardValueURLParam": "reward_value_url_param", + "externalURL": "external_url", + "aboutURL": "about_url", + "isSitewide": "is_site_wide", + } + + defaults: dict = {new_key: reward_campaign[key] for key, new_key in mappings.items() if reward_campaign.get(key)} + unlock_requirements: dict = reward_campaign.get("unlockRequirements", {}) + if unlock_requirements.get("subsGoal"): + defaults["sub_goal"] = unlock_requirements["subsGoal"] + if unlock_requirements.get("minuteWatchedGoal"): + defaults["minute_watched_goal"] = unlock_requirements["minuteWatchedGoal"] + + if reward_campaign.get("image"): + defaults["image_url"] = reward_campaign["image"]["image1xURL"] + + reward_campaign_instance, created = await RewardCampaign.objects.aupdate_or_create( + id=reward_campaign["id"], + defaults=defaults, + ) + if created: + logger.info("Added reward campaign %s", reward_campaign_instance.id) + + if reward_campaign["game"]: + # TODO(TheLovinator): Add game to reward campaign # noqa: TD003 + # TODO(TheLovinator): Send JSON to Discord # noqa: TD003 + logger.error("Not implemented: Add game to reward campaign, JSON: %s", reward_campaign["game"]) + return reward_campaign_instance async def add_or_update_game(game_json: dict | None) -> Game | None: @@ -347,7 +368,7 @@ async def process_json_data(num: int, campaign: dict | None) -> None: return if not isinstance(campaign, dict): - logger.warning("Campaign is not a dictionary") + logger.warning("Campaign is not a dictionary. %s", campaign) return # This is a Reward Campaign @@ -356,12 +377,7 @@ async def process_json_data(num: int, campaign: dict | None) -> None: await add_reward_campaign(campaign) if "dropCampaign" in campaign.get("data", {}).get("user", {}): - if not campaign["data"]["user"]["dropCampaign"]: - logger.warning("No drop campaign found") - return - save_json(campaign, "drop_campaign") - if campaign.get("data", {}).get("user", {}).get("dropCampaign"): await add_drop_campaign(campaign["data"]["user"]["dropCampaign"]) diff --git a/core/migrations/0001_initial.py b/core/migrations/0001_initial.py index b993bb5..f506a42 100644 --- a/core/migrations/0001_initial.py +++ b/core/migrations/0001_initial.py @@ -1,10 +1,9 @@ -# Generated by Django 5.1 on 2024-08-15 03:42 +# Generated by Django 5.1 on 2024-08-16 02:38 import django.contrib.auth.models import django.contrib.auth.validators import django.db.models.deletion import django.utils.timezone -from django.conf import settings from django.db import migrations, models from django.db.migrations.operations.base import Operation @@ -98,8 +97,6 @@ class Migration(migrations.Migration): ), ), ("date_joined", models.DateTimeField(default=django.utils.timezone.now, verbose_name="date joined")), - ("subscribe_to_news", models.BooleanField(default=False, help_text="Subscribe to news")), - ("subscribe_to_new_games", models.BooleanField(default=False, help_text="Subscribe to new games")), ( "groups", models.ManyToManyField( @@ -132,22 +129,6 @@ class Migration(migrations.Migration): ("objects", django.contrib.auth.models.UserManager()), ], ), - migrations.CreateModel( - name="DiscordWebhook", - fields=[ - ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), - ("url", models.URLField()), - ("name", models.CharField(max_length=255)), - ( - "user", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - related_name="discord_webhooks", - to=settings.AUTH_USER_MODEL, - ), - ), - ], - ), migrations.CreateModel( name="Channel", fields=[ @@ -169,28 +150,6 @@ class Migration(migrations.Migration): to="core.game", ), ), - migrations.CreateModel( - name="GameSubscription", - fields=[ - ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), - ("game", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="core.game")), - ("user", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), - ("webhook", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="core.discordwebhook")), - ], - options={ - "unique_together": {("user", "game")}, - }, - ), - migrations.AddField( - model_name="user", - name="subscribed_games", - field=models.ManyToManyField( - blank=True, - related_name="subscribed_users", - through="core.GameSubscription", - to="core.game", - ), - ), migrations.AddField( model_name="game", name="org", @@ -201,28 +160,6 @@ class Migration(migrations.Migration): to="core.owner", ), ), - migrations.CreateModel( - name="OwnerSubscription", - fields=[ - ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), - ("owner", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="core.owner")), - ("user", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), - ("webhook", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="core.discordwebhook")), - ], - options={ - "unique_together": {("user", "owner")}, - }, - ), - migrations.AddField( - model_name="user", - name="subscribed_owners", - field=models.ManyToManyField( - blank=True, - related_name="subscribed_users", - through="core.OwnerSubscription", - to="core.owner", - ), - ), migrations.CreateModel( name="RewardCampaign", fields=[ @@ -319,4 +256,34 @@ class Migration(migrations.Migration): ), ], ), + migrations.CreateModel( + name="Webhook", + fields=[ + ("avatar", models.TextField(null=True)), + ("channel_id", models.TextField(null=True)), + ("guild_id", models.TextField(null=True)), + ("id", models.TextField(primary_key=True, serialize=False)), + ("name", models.TextField(null=True)), + ("type", models.TextField(null=True)), + ("token", models.TextField()), + ("url", models.TextField()), + ("seen_drops", models.ManyToManyField(related_name="seen_drops", to="core.dropcampaign")), + ( + "subscribed_live_games", + models.ManyToManyField(related_name="subscribed_live_games", to="core.game"), + ), + ( + "subscribed_live_owners", + models.ManyToManyField(related_name="subscribed_live_owners", to="core.owner"), + ), + ("subscribed_new_games", models.ManyToManyField(related_name="subscribed_new_games", to="core.game")), + ( + "subscribed_new_owners", + models.ManyToManyField(related_name="subscribed_new_owners", to="core.owner"), + ), + ], + options={ + "unique_together": {("id", "token")}, + }, + ), ] diff --git a/core/models.py b/core/models.py index 57e128b..54ad39c 100644 --- a/core/models.py +++ b/core/models.py @@ -1,6 +1,7 @@ from __future__ import annotations import logging +from typing import ClassVar from django.contrib.auth.models import AbstractUser from django.db import models @@ -8,6 +9,9 @@ from django.db import models logger: logging.Logger = logging.getLogger(__name__) +class User(AbstractUser): ... + + class Owner(models.Model): """The company or person that owns the game. @@ -182,62 +186,41 @@ class Reward(models.Model): return self.name or "Reward name unknown" -class User(AbstractUser): - """Extended User model to include subscriptions.""" +class Webhook(models.Model): + """Discord webhook.""" - subscribed_games = models.ManyToManyField( - "Game", - through="GameSubscription", - related_name="subscribed_users", - blank=True, - ) - subscribed_owners = models.ManyToManyField( - "Owner", - through="OwnerSubscription", - related_name="subscribed_users", - blank=True, - ) - subscribe_to_news = models.BooleanField(default=False, help_text="Subscribe to news") - subscribe_to_new_games = models.BooleanField(default=False, help_text="Subscribe to new games") + avatar = models.TextField(null=True) + channel_id = models.TextField(null=True) + guild_id = models.TextField(null=True) + id = models.TextField(primary_key=True) + name = models.TextField(null=True) + type = models.TextField(null=True) + token = models.TextField() + url = models.TextField() - def __str__(self) -> str: - return self.username + # Get notified when the site finds a new game. + subscribed_new_games = models.ManyToManyField(Game, related_name="subscribed_new_games") + # Get notified when a drop goes live. + subscribed_live_games = models.ManyToManyField(Game, related_name="subscribed_live_games") -class DiscordWebhook(models.Model): - """A Discord webhook for sending notifications.""" + # Get notified when the site finds a new drop campaign for a specific organization. + subscribed_new_owners = models.ManyToManyField(Owner, related_name="subscribed_new_owners") - user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="discord_webhooks") - url = models.URLField() - name = models.CharField(max_length=255) + # Get notified when a drop goes live for a specific organization. + subscribed_live_owners = models.ManyToManyField(Owner, related_name="subscribed_live_owners") - def __str__(self) -> str: - return f"{self.name} ({self.user.username})" - - -class GameSubscription(models.Model): - """A subscription to a specific game with a chosen webhook.""" - - user = models.ForeignKey(User, on_delete=models.CASCADE) - game = models.ForeignKey(Game, on_delete=models.CASCADE) - webhook = models.ForeignKey(DiscordWebhook, on_delete=models.CASCADE) + # So we don't spam the same drop campaign over and over. + seen_drops = models.ManyToManyField(DropCampaign, related_name="seen_drops") class Meta: - unique_together = ("user", "game") + unique_together: ClassVar[list[str]] = ["id", "token"] def __str__(self) -> str: - return f"{self.user.username} -> {self.game.name} via {self.webhook.name}" + return f"{self.name} - {self.get_webhook_url()}" - -class OwnerSubscription(models.Model): - """A subscription to a specific owner with a chosen webhook.""" - - user = models.ForeignKey(User, on_delete=models.CASCADE) - owner = models.ForeignKey(Owner, on_delete=models.CASCADE) - webhook = models.ForeignKey(DiscordWebhook, on_delete=models.CASCADE) - - class Meta: - unique_together = ("user", "owner") - - def __str__(self) -> str: - return f"{self.user.username} -> {self.owner.name} via {self.webhook.name}" + def get_webhook_url(self) -> str: + try: + return f"https://discord.com/api/webhooks/{self.id}/{self.token}" + except AttributeError: + return "" diff --git a/core/settings.py b/core/settings.py index 96a1c53..864d908 100644 --- a/core/settings.py +++ b/core/settings.py @@ -1,6 +1,5 @@ import os from pathlib import Path -from typing import Literal import sentry_sdk from django.contrib import messages @@ -76,10 +75,6 @@ INSTALLED_APPS: list[str] = [ "django.contrib.messages", "django.contrib.staticfiles", "django.contrib.sites", - "allauth", - "allauth.account", - "allauth.socialaccount", - "allauth.socialaccount.providers.twitch", "simple_history", "debug_toolbar", ] @@ -95,7 +90,6 @@ MIDDLEWARE: list[str] = [ "django.contrib.auth.middleware.AuthenticationMiddleware", "django.contrib.messages.middleware.MessageMiddleware", "simple_history.middleware.HistoryRequestMiddleware", - "allauth.account.middleware.AccountMiddleware", ] TEMPLATES = [ @@ -185,24 +179,7 @@ CACHES = { }, } SITE_ID = 1 -AUTHENTICATION_BACKENDS: list[str] = [ - "django.contrib.auth.backends.ModelBackend", - "allauth.account.auth_backends.AuthenticationBackend", -] -SOCIALACCOUNT_PROVIDERS = { - "twitch": { - "APP": {"client_id": os.environ["TWITCH_CLIENT_ID"], "secret": os.environ["TWITCH_CLIENT_SECRET"], "key": ""}, - "SCOPE": [], - "AUTH_PARAMS": { - "force_verify": "true", - }, - }, -} -SOCIALACCOUNT_STORE_TOKENS = True -SOCIALACCOUNT_ONLY = True -ACCOUNT_EMAIL_VERIFICATION: Literal["mandatory", "optional", "none"] = "none" -ACCOUNT_SESSION_REMEMBER = True LOGIN_REDIRECT_URL = "/" ACCOUNT_LOGOUT_REDIRECT_URL = "/" diff --git a/core/signals.py b/core/signals.py index c5c5d60..71e3d09 100644 --- a/core/signals.py +++ b/core/signals.py @@ -3,20 +3,40 @@ import os from typing import TYPE_CHECKING from discord_webhook import DiscordWebhook -from django.db.models.manager import BaseManager from django.db.models.signals import post_save from django.dispatch import receiver -from core.discord import generate_game_message, generate_owner_message -from core.models import DropCampaign, Game, GameSubscription, Owner, OwnerSubscription, User +from core.discord import convert_time_to_discord_timestamp +from core.models import DropCampaign, Game, Owner, User, Webhook if TYPE_CHECKING: import requests - from django.db.models.manager import BaseManager logger: logging.Logger = logging.getLogger(__name__) +def generate_message(game: Game, drop: DropCampaign) -> str: + """Generate a message for a game. + + Args: + game (Game): The game to generate a message for. + drop (DropCampaign): The drop campaign to generate a message for. + + Returns: + str: The message. + """ + # TODO(TheLovinator): Add a twitch link to a stream that has drops enabled. # noqa: TD003 + game_name: str = game.name or "Unknown game" + description: str = drop.description or "No description available." + start_at: str = convert_time_to_discord_timestamp(drop.starts_at) + end_at: str = convert_time_to_discord_timestamp(drop.ends_at) + msg: str = f"**{game_name}**\n\n{description}\n\nStarts: {start_at}\nEnds: {end_at}" + + logger.debug(msg) + + return msg + + @receiver(signal=post_save, sender=User) def handle_user_signed_up(sender: User, instance: User, created: bool, **kwargs) -> None: # noqa: ANN003, ARG001, FBT001 """Send a message to Discord when a user signs up. @@ -48,9 +68,8 @@ def handle_user_signed_up(sender: User, instance: User, created: bool, **kwargs) logger.debug(response) -@receiver(signal=post_save, sender=DropCampaign) def notify_users_of_new_drop(sender: DropCampaign, instance: DropCampaign, created: bool, **kwargs) -> None: # noqa: ANN003, ARG001, FBT001 - """Notify users of a new drop campaign. + """Send message to all webhooks subscribed to new drops. Args: sender (DropCampaign): The model we are sending the signal from. @@ -64,37 +83,81 @@ def notify_users_of_new_drop(sender: DropCampaign, instance: DropCampaign, creat game: Game | None = instance.game if not game: + logger.error("No game found. %s", instance) return + if game.owner: # type: ignore # noqa: PGH003 + handle_owner_drops(instance, game) + else: + logger.error("No owner found. %s", instance) + + if game := instance.game: + handle_game_drops(instance, game) + else: + logger.error("No game found. %s", instance) + + +def handle_game_drops(instance: DropCampaign, game: Game) -> None: + """Send message to all webhooks subscribed to new drops for this game. + + Args: + instance (DropCampaign): The drop campaign that was created. + game (Game): The game that the drop campaign is for. + """ + webhooks: list[Webhook] = game.subscribed_new_games.all() # type: ignore # noqa: PGH003 + for hook in webhooks: + # Don't spam the same drop campaign. + if hook in hook.seen_drops.all(): + logger.error("Already seen drop campaign '%s'.", instance.name) + continue + + # Set the webhook as seen so we don't spam it. + hook.seen_drops.add(instance) + + # Send the webhook. + webhook_url: str = hook.get_webhook_url() + if not webhook_url: + logger.error("No webhook URL provided.") + continue + + webhook = DiscordWebhook( + url=webhook_url, + content=generate_message(game, instance), + username=f"{game.name} Twitch drops", + rate_limit_retry=True, + ) + response: requests.Response = webhook.execute() + logger.debug(response) + + +def handle_owner_drops(instance: DropCampaign, game: Game) -> None: + """Send message to all webhooks subscribed to new drops for this owner/organization. + + Args: + instance (DropCampaign): The drop campaign that was created. + game (Game): The game that the drop campaign is for. + """ owner: Owner = game.owner # type: ignore # noqa: PGH003 + webhooks: list[Webhook] = owner.subscribed_new_games.all() # type: ignore # noqa: PGH003 + for hook in webhooks: + # Don't spam the same drop campaign. + if hook in hook.seen_drops.all(): + logger.error("Already seen drop campaign '%s'.", instance.name) + continue - # Notify users subscribed to the game - game_subs: BaseManager[GameSubscription] = GameSubscription.objects.filter(game=game) - for sub in game_subs: - if not sub.webhook.url: + # Set the webhook as seen so we don't spam it. + hook.seen_drops.add(instance) + + # Send the webhook. + webhook_url: str = hook.get_webhook_url() + if not webhook_url: logger.error("No webhook URL provided.") - return + continue webhook = DiscordWebhook( - url=sub.webhook.url, - content=generate_game_message(instance=instance, game=game, sub=sub), - username=f"{game.name} drop.", - rate_limit_retry=True, - ) - response: requests.Response = webhook.execute() - logger.debug(response) - - # Notify users subscribed to the owner - owner_subs: BaseManager[OwnerSubscription] = OwnerSubscription.objects.filter(owner=owner) - for sub in owner_subs: - if not sub.webhook.url: - logger.error("No webhook URL provided.") - return - - webhook = DiscordWebhook( - url=sub.webhook.url, - content=generate_owner_message(instance=instance, owner=owner, sub=sub), - username=f"{owner.name} drop.", + url=webhook_url, + content=generate_message(game, instance), + username=f"{game.name} Twitch drops", rate_limit_retry=True, ) response: requests.Response = webhook.execute() diff --git a/core/templates/partials/game_card.html b/core/templates/partials/game_card.html index c627590..9e2e29d 100644 --- a/core/templates/partials/game_card.html +++ b/core/templates/partials/game_card.html @@ -18,9 +18,7 @@ <!-- Insert nice buttons --> </div> {% for campaign in game.drop_campaigns.all %} - {% if not forloop.first %}<br>{% endif %} <div class="mt-3"> - <h3 class="h6">{{ campaign.name }}</h3> {% if campaign.details_url == campaign.account_link_url %} <a href="{{ campaign.details_url }}" class="text-decoration-none">Details</a> {% else %} @@ -31,26 +29,7 @@ <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 e' }}">{{ campaign.ends_at|timeuntil }}</abbr> </p> - {% if campaign.description != campaign.name %} - {% if campaign.description|length > 300 %} - <p> - <a class="btn btn-link p-0 text-muted" - data-bs-toggle="collapse" - href="#collapseDescription{{ campaign.id }}" - role="button" - aria-expanded="false" - aria-controls="collapseDescription{{ campaign.id }}" - aria-label="Show Description">Show Description</a> - </p> - <div class="collapse" id="collapseDescription{{ campaign.id }}"> - <div class="card card-body">{{ campaign.description }}</div> - <br> - </div> - {% else %} - <p>{{ campaign.description }}</p> - {% endif %} - {% endif %} - <div class="row row-cols-1 row-cols-md-2 row-cols-lg-3 g-2"> + <div class="row row-cols-1 row-cols-md-2 row-cols-lg-3"> {% for drop in campaign.drops.all %} {% for benefit in drop.benefits.all %} <div class="col d-flex align-items-center position-relative"> diff --git a/core/templates/partials/header.html b/core/templates/partials/header.html index 7357a68..06c02a8 100644 --- a/core/templates/partials/header.html +++ b/core/templates/partials/header.html @@ -1,4 +1,3 @@ -{% load socialaccount %} <header class="d-flex justify-content-between align-items-center py-3 border-bottom"> <h1 class="h2"> <a href='{% url "index" %}' class="text-decoration-none nav-title">Twitch drops</a> @@ -17,18 +16,9 @@ <li class="nav-item d-none d-sm-block"> <a class="nav-link" href="https://github.com/sponsors/TheLovinator1">Donate</a> </li> - {% if not user.is_authenticated %} - <li> - <a class="nav-link" href="{% provider_login_url 'twitch' %}">Login with Twitch</a> - </li> - {% else %} - <li> - <form action="{% url 'account_logout' %}" method="post"> - {% csrf_token %} - <button type="submit" class="btn btn-link nav-link">Logout</button> - </form> - </li> - {% endif %} + <li class="nav-item d-none d-sm-block"> + <a class="nav-link" href='{% url "webhooks" %}'>Webhooks</a> + </li> </ul> </nav> </header> diff --git a/core/templates/webhooks.html b/core/templates/webhooks.html index 384d007..4997943 100644 --- a/core/templates/webhooks.html +++ b/core/templates/webhooks.html @@ -24,5 +24,27 @@ </form> </div> <h2 class="mt-5">Webhooks</h2> + {% if webhooks %} + <div class="list-group"> + {% for webhook in webhooks %} + <div class="list-group-item d-flex justify-content-between align-items-center"> + <span> + {% if webhook.avatar %} + <img src="https://cdn.discordapp.com/avatars/{{ webhook.id }}/a_{{ webhook.avatar }}.png" + alt="Avatar of {{ webhook.name }}" + class="rounded-circle" + height="32" + width="32"> + {% endif %} + <a href="https://discord.com/api/webhooks/{{ webhook.id }}/{{ webhook.token }}" + target="_blank" + class="text-decoration-none">{{ webhook.name }}</a> + </span> + </div> + {% endfor %} + </div> + {% else %} + <div class="alert alert-info">No webhooks added</div> + {% endif %} </div> {% endblock content %} diff --git a/core/urls.py b/core/urls.py index 859f866..af38202 100644 --- a/core/urls.py +++ b/core/urls.py @@ -2,25 +2,17 @@ from __future__ import annotations from debug_toolbar.toolbar import debug_toolbar_urls from django.contrib import admin -from django.urls import URLPattern, URLResolver, include, path +from django.urls import URLPattern, URLResolver, path -from core.views import game_view, index, reward_campaign_view +from core.views import WebhooksView, game_view, index, reward_campaign_view app_name: str = "core" urlpatterns: list[URLPattern | URLResolver] = [ - path("admin/", admin.site.urls), - path("accounts/", include("allauth.urls"), name="accounts"), + path(route="admin/", view=admin.site.urls), path(route="", view=index, name="index"), - path( - route="games/", - view=game_view, - name="games", - ), - path( - route="reward_campaigns/", - view=reward_campaign_view, - name="reward_campaigns", - ), + path(route="webhooks/", view=WebhooksView.as_view(), name="webhooks"), + path(route="games/", view=game_view, name="games"), + path(route="reward_campaigns/", view=reward_campaign_view, name="reward_campaigns"), *debug_toolbar_urls(), ] diff --git a/core/views.py b/core/views.py index 6393550..17cb296 100644 --- a/core/views.py +++ b/core/views.py @@ -4,16 +4,19 @@ import logging from dataclasses import dataclass from typing import TYPE_CHECKING +import requests_cache from django.db.models import Prefetch from django.db.models.manager import BaseManager +from django.http import HttpRequest, HttpResponse from django.template.response import TemplateResponse -from django.utils import timezone # type: ignore # noqa: PGH003 +from django.utils import timezone +from django.views import View -from core.models import DropCampaign, Game, RewardCampaign +from core.models import DropCampaign, Game, RewardCampaign, Webhook if TYPE_CHECKING: from django.db.models.manager import BaseManager - from django.http import HttpRequest, HttpResponse + from django.http import HttpRequest logger: logging.Logger = logging.getLogger(__name__) @@ -114,3 +117,129 @@ def reward_campaign_view(request: HttpRequest) -> HttpResponse: reward_campaigns: BaseManager[RewardCampaign] = RewardCampaign.objects.all() context: dict[str, BaseManager[RewardCampaign]] = {"reward_campaigns": reward_campaigns} return TemplateResponse(request=request, template="reward_campaigns.html", context=context) + + +def get_webhook_data(webhook_url: str) -> dict[str, str]: + """Get the webhook data from the URL. + + Args: + webhook_url (str): The webhook URL. + + Returns: + dict[str, str]: The webhook data. + """ + session = requests_cache.CachedSession("webhook_cache") + response: requests_cache.OriginalResponse | requests_cache.CachedResponse = session.get(webhook_url) + return response.json() + + +def split_webhook_url(webhook_url: str) -> tuple[str, str]: + """Split the webhook URL into its components. + + Webhooks are in the format: + https://discord.com/api/webhooks/{id}/{token} + + Args: + webhook_url (str): The webhook URL. + + Returns: + tuple[str, str]: The ID and token. + """ + webhook_id: str = webhook_url.split("/")[-2] + webhook_token: str = webhook_url.split("/")[-1] + return webhook_id, webhook_token + + +class WebhooksView(View): + """Render the webhook view page.""" + + @staticmethod + def post(request: HttpRequest) -> HttpResponse: + """Add a webhook to the list of webhooks. + + Args: + request (HttpRequest): The request object. + + Returns: + HttpResponse: The response object. + """ + webhook_url: str | None = request.POST.get("webhook_url") + if not webhook_url: + return HttpResponse(content="No webhook URL provided.", status=400) + + # Read webhooks from cookie. + webhooks_cookies: str | None = request.COOKIES.get("webhooks") + webhooks_list: list[str] = webhooks_cookies.split(",") if webhooks_cookies else [] + + # Get webhook data. + webhook_id, webhook_token = split_webhook_url(webhook_url) + webhook_data: dict[str, str] = get_webhook_data(webhook_url) + list_of_json_keys: list[str] = ["avatar", "channel_id", "guild_id", "name", "type", "url"] + defaults: dict[str, str | None] = {key: webhook_data.get(key) for key in list_of_json_keys} + + # Warn if JSON has more keys than expected. + if len(webhook_data.keys()) > len(list_of_json_keys): + logger.warning("Unexpected keys in JSON: %s", webhook_data.keys()) + + # Add the webhook to the database. + new_webhook, created = Webhook.objects.update_or_create( + id=webhook_id, + token=webhook_token, + defaults=defaults, + ) + if created: + logger.info("Created webhook '%s'.", new_webhook) + + # Add the new webhook to the list. + webhooks_list.append(webhook_url) + + # Remove duplicates. + webhooks_list = list(set(webhooks_list)) + + # Save the new list of webhooks to the cookie. + response: HttpResponse = HttpResponse() + response.set_cookie("webhooks", ",".join(webhooks_list)) + + # Redirect to the webhooks page. + response["Location"] = "/webhooks/" + response.status_code = 302 + return response + + @staticmethod + def get(request: HttpRequest) -> HttpResponse: + # Read webhooks from cookie. + webhooks_cookies: str | None = request.COOKIES.get("webhooks") + webhooks_list: list[str] = webhooks_cookies.split(",") if webhooks_cookies else [] + + webhooks_from_db: list[Webhook] = [] + # Get the webhooks from the database. + for webhook_url in webhooks_list: + webhook_id, webhook_token = split_webhook_url(webhook_url) + + # Check if the webhook is in the database. + if not Webhook.objects.filter(id=webhook_id, token=webhook_token).exists(): + webhook_data: dict[str, str] = get_webhook_data(webhook_url) + list_of_json_keys: list[str] = ["avatar", "channel_id", "guild_id", "name", "type", "url"] + defaults: dict[str, str | None] = {key: webhook_data.get(key) for key in list_of_json_keys} + + # Warn if JSON has more keys than expected. + if len(webhook_data.keys()) > len(list_of_json_keys): + logger.warning("Unexpected keys in JSON: %s", webhook_data.keys()) + + new_webhook, created = Webhook.objects.update_or_create( + id=webhook_id, + token=webhook_token, + defaults=defaults, + ) + if created: + logger.info("Created webhook '%s'.", new_webhook) + + webhooks_from_db.append(new_webhook) + + # If the webhook is in the database, get it from there. + else: + existing_webhook: Webhook = Webhook.objects.get(id=webhook_id, token=webhook_token) + webhooks_from_db.append(existing_webhook) + + context: dict[str, list[Webhook]] = {"webhooks": webhooks_from_db} + return TemplateResponse(request=request, template="webhooks.html", context=context) diff --git a/poetry.lock b/poetry.lock index 1e5912c..41880ae 100644 --- a/poetry.lock +++ b/poetry.lock @@ -14,6 +14,25 @@ files = [ [package.extras] tests = ["mypy (>=0.800)", "pytest", "pytest-asyncio"] +[[package]] +name = "attrs" +version = "24.2.0" +description = "Classes Without Boilerplate" +optional = false +python-versions = ">=3.7" +files = [ + {file = "attrs-24.2.0-py3-none-any.whl", hash = "sha256:81921eb96de3191c8258c199618104dd27ac608d9366f5e35d011eae1867ede2"}, + {file = "attrs-24.2.0.tar.gz", hash = "sha256:5cfb1b9148b5b086569baec03f20d7b6bf3bcacc9a42bebf87ffaaca362f6346"}, +] + +[package.extras] +benchmark = ["cloudpickle", "hypothesis", "mypy (>=1.11.1)", "pympler", "pytest (>=4.3.0)", "pytest-codspeed", "pytest-mypy-plugins", "pytest-xdist[psutil]"] +cov = ["cloudpickle", "coverage[toml] (>=5.3)", "hypothesis", "mypy (>=1.11.1)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"] +dev = ["cloudpickle", "hypothesis", "mypy (>=1.11.1)", "pre-commit", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"] +docs = ["cogapp", "furo", "myst-parser", "sphinx", "sphinx-notfound-page", "sphinxcontrib-towncrier", "towncrier (<24.7)"] +tests = ["cloudpickle", "hypothesis", "mypy (>=1.11.1)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"] +tests-mypy = ["mypy (>=1.11.1)", "pytest-mypy-plugins"] + [[package]] name = "brotli" version = "1.1.0" @@ -106,6 +125,29 @@ files = [ {file = "Brotli-1.1.0.tar.gz", hash = "sha256:81de08ac11bcb85841e440c13611c00b67d3bf82698314928d0b676362546724"}, ] +[[package]] +name = "cattrs" +version = "23.2.3" +description = "Composable complex class support for attrs and dataclasses." +optional = false +python-versions = ">=3.8" +files = [ + {file = "cattrs-23.2.3-py3-none-any.whl", hash = "sha256:0341994d94971052e9ee70662542699a3162ea1e0c62f7ce1b4a57f563685108"}, + {file = "cattrs-23.2.3.tar.gz", hash = "sha256:a934090d95abaa9e911dac357e3a8699e0b4b14f8529bcc7d2b1ad9d51672b9f"}, +] + +[package.dependencies] +attrs = ">=23.1.0" + +[package.extras] +bson = ["pymongo (>=4.4.0)"] +cbor2 = ["cbor2 (>=5.4.6)"] +msgpack = ["msgpack (>=1.0.5)"] +orjson = ["orjson (>=3.9.2)"] +pyyaml = ["pyyaml (>=6.0)"] +tomlkit = ["tomlkit (>=0.11.8)"] +ujson = ["ujson (>=5.7.0)"] + [[package]] name = "certifi" version = "2024.7.4" @@ -117,85 +159,6 @@ files = [ {file = "certifi-2024.7.4.tar.gz", hash = "sha256:5a1e7645bc0ec61a09e26c36f6106dd4cf40c6db3a1fb6352b0244e7fb057c7b"}, ] -[[package]] -name = "cffi" -version = "1.17.0" -description = "Foreign Function Interface for Python calling C code." -optional = false -python-versions = ">=3.8" -files = [ - {file = "cffi-1.17.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:f9338cc05451f1942d0d8203ec2c346c830f8e86469903d5126c1f0a13a2bcbb"}, - {file = "cffi-1.17.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a0ce71725cacc9ebf839630772b07eeec220cbb5f03be1399e0457a1464f8e1a"}, - {file = "cffi-1.17.0-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c815270206f983309915a6844fe994b2fa47e5d05c4c4cef267c3b30e34dbe42"}, - {file = "cffi-1.17.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d6bdcd415ba87846fd317bee0774e412e8792832e7805938987e4ede1d13046d"}, - {file = "cffi-1.17.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8a98748ed1a1df4ee1d6f927e151ed6c1a09d5ec21684de879c7ea6aa96f58f2"}, - {file = "cffi-1.17.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0a048d4f6630113e54bb4b77e315e1ba32a5a31512c31a273807d0027a7e69ab"}, - {file = "cffi-1.17.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:24aa705a5f5bd3a8bcfa4d123f03413de5d86e497435693b638cbffb7d5d8a1b"}, - {file = "cffi-1.17.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:856bf0924d24e7f93b8aee12a3a1095c34085600aa805693fb7f5d1962393206"}, - {file = "cffi-1.17.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:4304d4416ff032ed50ad6bb87416d802e67139e31c0bde4628f36a47a3164bfa"}, - {file = "cffi-1.17.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:331ad15c39c9fe9186ceaf87203a9ecf5ae0ba2538c9e898e3a6967e8ad3db6f"}, - {file = "cffi-1.17.0-cp310-cp310-win32.whl", hash = "sha256:669b29a9eca6146465cc574659058ed949748f0809a2582d1f1a324eb91054dc"}, - {file = "cffi-1.17.0-cp310-cp310-win_amd64.whl", hash = "sha256:48b389b1fd5144603d61d752afd7167dfd205973a43151ae5045b35793232aa2"}, - {file = "cffi-1.17.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c5d97162c196ce54af6700949ddf9409e9833ef1003b4741c2b39ef46f1d9720"}, - {file = "cffi-1.17.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5ba5c243f4004c750836f81606a9fcb7841f8874ad8f3bf204ff5e56332b72b9"}, - {file = "cffi-1.17.0-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bb9333f58fc3a2296fb1d54576138d4cf5d496a2cc118422bd77835e6ae0b9cb"}, - {file = "cffi-1.17.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:435a22d00ec7d7ea533db494da8581b05977f9c37338c80bc86314bec2619424"}, - {file = "cffi-1.17.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d1df34588123fcc88c872f5acb6f74ae59e9d182a2707097f9e28275ec26a12d"}, - {file = "cffi-1.17.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:df8bb0010fdd0a743b7542589223a2816bdde4d94bb5ad67884348fa2c1c67e8"}, - {file = "cffi-1.17.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a8b5b9712783415695663bd463990e2f00c6750562e6ad1d28e072a611c5f2a6"}, - {file = "cffi-1.17.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:ffef8fd58a36fb5f1196919638f73dd3ae0db1a878982b27a9a5a176ede4ba91"}, - {file = "cffi-1.17.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:4e67d26532bfd8b7f7c05d5a766d6f437b362c1bf203a3a5ce3593a645e870b8"}, - {file = "cffi-1.17.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:45f7cd36186db767d803b1473b3c659d57a23b5fa491ad83c6d40f2af58e4dbb"}, - {file = "cffi-1.17.0-cp311-cp311-win32.whl", hash = "sha256:a9015f5b8af1bb6837a3fcb0cdf3b874fe3385ff6274e8b7925d81ccaec3c5c9"}, - {file = "cffi-1.17.0-cp311-cp311-win_amd64.whl", hash = "sha256:b50aaac7d05c2c26dfd50c3321199f019ba76bb650e346a6ef3616306eed67b0"}, - {file = "cffi-1.17.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:aec510255ce690d240f7cb23d7114f6b351c733a74c279a84def763660a2c3bc"}, - {file = "cffi-1.17.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2770bb0d5e3cc0e31e7318db06efcbcdb7b31bcb1a70086d3177692a02256f59"}, - {file = "cffi-1.17.0-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:db9a30ec064129d605d0f1aedc93e00894b9334ec74ba9c6bdd08147434b33eb"}, - {file = "cffi-1.17.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a47eef975d2b8b721775a0fa286f50eab535b9d56c70a6e62842134cf7841195"}, - {file = "cffi-1.17.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f3e0992f23bbb0be00a921eae5363329253c3b86287db27092461c887b791e5e"}, - {file = "cffi-1.17.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6107e445faf057c118d5050560695e46d272e5301feffda3c41849641222a828"}, - {file = "cffi-1.17.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eb862356ee9391dc5a0b3cbc00f416b48c1b9a52d252d898e5b7696a5f9fe150"}, - {file = "cffi-1.17.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c1c13185b90bbd3f8b5963cd8ce7ad4ff441924c31e23c975cb150e27c2bf67a"}, - {file = "cffi-1.17.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:17c6d6d3260c7f2d94f657e6872591fe8733872a86ed1345bda872cfc8c74885"}, - {file = "cffi-1.17.0-cp312-cp312-win32.whl", hash = "sha256:c3b8bd3133cd50f6b637bb4322822c94c5ce4bf0d724ed5ae70afce62187c492"}, - {file = "cffi-1.17.0-cp312-cp312-win_amd64.whl", hash = "sha256:dca802c8db0720ce1c49cce1149ff7b06e91ba15fa84b1d59144fef1a1bc7ac2"}, - {file = "cffi-1.17.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6ce01337d23884b21c03869d2f68c5523d43174d4fc405490eb0091057943118"}, - {file = "cffi-1.17.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cab2eba3830bf4f6d91e2d6718e0e1c14a2f5ad1af68a89d24ace0c6b17cced7"}, - {file = "cffi-1.17.0-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:14b9cbc8f7ac98a739558eb86fabc283d4d564dafed50216e7f7ee62d0d25377"}, - {file = "cffi-1.17.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b00e7bcd71caa0282cbe3c90966f738e2db91e64092a877c3ff7f19a1628fdcb"}, - {file = "cffi-1.17.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:41f4915e09218744d8bae14759f983e466ab69b178de38066f7579892ff2a555"}, - {file = "cffi-1.17.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e4760a68cab57bfaa628938e9c2971137e05ce48e762a9cb53b76c9b569f1204"}, - {file = "cffi-1.17.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:011aff3524d578a9412c8b3cfaa50f2c0bd78e03eb7af7aa5e0df59b158efb2f"}, - {file = "cffi-1.17.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:a003ac9edc22d99ae1286b0875c460351f4e101f8c9d9d2576e78d7e048f64e0"}, - {file = "cffi-1.17.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:ef9528915df81b8f4c7612b19b8628214c65c9b7f74db2e34a646a0a2a0da2d4"}, - {file = "cffi-1.17.0-cp313-cp313-win32.whl", hash = "sha256:70d2aa9fb00cf52034feac4b913181a6e10356019b18ef89bc7c12a283bf5f5a"}, - {file = "cffi-1.17.0-cp313-cp313-win_amd64.whl", hash = "sha256:b7b6ea9e36d32582cda3465f54c4b454f62f23cb083ebc7a94e2ca6ef011c3a7"}, - {file = "cffi-1.17.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:964823b2fc77b55355999ade496c54dde161c621cb1f6eac61dc30ed1b63cd4c"}, - {file = "cffi-1.17.0-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:516a405f174fd3b88829eabfe4bb296ac602d6a0f68e0d64d5ac9456194a5b7e"}, - {file = "cffi-1.17.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dec6b307ce928e8e112a6bb9921a1cb00a0e14979bf28b98e084a4b8a742bd9b"}, - {file = "cffi-1.17.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e4094c7b464cf0a858e75cd14b03509e84789abf7b79f8537e6a72152109c76e"}, - {file = "cffi-1.17.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2404f3de742f47cb62d023f0ba7c5a916c9c653d5b368cc966382ae4e57da401"}, - {file = "cffi-1.17.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3aa9d43b02a0c681f0bfbc12d476d47b2b2b6a3f9287f11ee42989a268a1833c"}, - {file = "cffi-1.17.0-cp38-cp38-win32.whl", hash = "sha256:0bb15e7acf8ab35ca8b24b90af52c8b391690ef5c4aec3d31f38f0d37d2cc499"}, - {file = "cffi-1.17.0-cp38-cp38-win_amd64.whl", hash = "sha256:93a7350f6706b31f457c1457d3a3259ff9071a66f312ae64dc024f049055f72c"}, - {file = "cffi-1.17.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:1a2ddbac59dc3716bc79f27906c010406155031a1c801410f1bafff17ea304d2"}, - {file = "cffi-1.17.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:6327b572f5770293fc062a7ec04160e89741e8552bf1c358d1a23eba68166759"}, - {file = "cffi-1.17.0-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:dbc183e7bef690c9abe5ea67b7b60fdbca81aa8da43468287dae7b5c046107d4"}, - {file = "cffi-1.17.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5bdc0f1f610d067c70aa3737ed06e2726fd9d6f7bfee4a351f4c40b6831f4e82"}, - {file = "cffi-1.17.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6d872186c1617d143969defeadac5a904e6e374183e07977eedef9c07c8953bf"}, - {file = "cffi-1.17.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0d46ee4764b88b91f16661a8befc6bfb24806d885e27436fdc292ed7e6f6d058"}, - {file = "cffi-1.17.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6f76a90c345796c01d85e6332e81cab6d70de83b829cf1d9762d0a3da59c7932"}, - {file = "cffi-1.17.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:0e60821d312f99d3e1569202518dddf10ae547e799d75aef3bca3a2d9e8ee693"}, - {file = "cffi-1.17.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:eb09b82377233b902d4c3fbeeb7ad731cdab579c6c6fda1f763cd779139e47c3"}, - {file = "cffi-1.17.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:24658baf6224d8f280e827f0a50c46ad819ec8ba380a42448e24459daf809cf4"}, - {file = "cffi-1.17.0-cp39-cp39-win32.whl", hash = "sha256:0fdacad9e0d9fc23e519efd5ea24a70348305e8d7d85ecbb1a5fa66dc834e7fb"}, - {file = "cffi-1.17.0-cp39-cp39-win_amd64.whl", hash = "sha256:7cbc78dc018596315d4e7841c8c3a7ae31cc4d638c9b627f87d52e8abaaf2d29"}, - {file = "cffi-1.17.0.tar.gz", hash = "sha256:f3157624b7558b914cb039fd1af735e5e8049a87c817cc215109ad1c8779df76"}, -] - -[package.dependencies] -pycparser = "*" - [[package]] name = "cfgv" version = "3.4.0" @@ -331,55 +294,6 @@ files = [ {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, ] -[[package]] -name = "cryptography" -version = "43.0.0" -description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." -optional = false -python-versions = ">=3.7" -files = [ - {file = "cryptography-43.0.0-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:64c3f16e2a4fc51c0d06af28441881f98c5d91009b8caaff40cf3548089e9c74"}, - {file = "cryptography-43.0.0-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3dcdedae5c7710b9f97ac6bba7e1052b95c7083c9d0e9df96e02a1932e777895"}, - {file = "cryptography-43.0.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3d9a1eca329405219b605fac09ecfc09ac09e595d6def650a437523fcd08dd22"}, - {file = "cryptography-43.0.0-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:ea9e57f8ea880eeea38ab5abf9fbe39f923544d7884228ec67d666abd60f5a47"}, - {file = "cryptography-43.0.0-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:9a8d6802e0825767476f62aafed40532bd435e8a5f7d23bd8b4f5fd04cc80ecf"}, - {file = "cryptography-43.0.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:cc70b4b581f28d0a254d006f26949245e3657d40d8857066c2ae22a61222ef55"}, - {file = "cryptography-43.0.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:4a997df8c1c2aae1e1e5ac49c2e4f610ad037fc5a3aadc7b64e39dea42249431"}, - {file = "cryptography-43.0.0-cp37-abi3-win32.whl", hash = "sha256:6e2b11c55d260d03a8cf29ac9b5e0608d35f08077d8c087be96287f43af3ccdc"}, - {file = "cryptography-43.0.0-cp37-abi3-win_amd64.whl", hash = "sha256:31e44a986ceccec3d0498e16f3d27b2ee5fdf69ce2ab89b52eaad1d2f33d8778"}, - {file = "cryptography-43.0.0-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:7b3f5fe74a5ca32d4d0f302ffe6680fcc5c28f8ef0dc0ae8f40c0f3a1b4fca66"}, - {file = "cryptography-43.0.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ac1955ce000cb29ab40def14fd1bbfa7af2017cca696ee696925615cafd0dce5"}, - {file = "cryptography-43.0.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:299d3da8e00b7e2b54bb02ef58d73cd5f55fb31f33ebbf33bd00d9aa6807df7e"}, - {file = "cryptography-43.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:ee0c405832ade84d4de74b9029bedb7b31200600fa524d218fc29bfa371e97f5"}, - {file = "cryptography-43.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:cb013933d4c127349b3948aa8aaf2f12c0353ad0eccd715ca789c8a0f671646f"}, - {file = "cryptography-43.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fdcb265de28585de5b859ae13e3846a8e805268a823a12a4da2597f1f5afc9f0"}, - {file = "cryptography-43.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:2905ccf93a8a2a416f3ec01b1a7911c3fe4073ef35640e7ee5296754e30b762b"}, - {file = "cryptography-43.0.0-cp39-abi3-win32.whl", hash = "sha256:47ca71115e545954e6c1d207dd13461ab81f4eccfcb1345eac874828b5e3eaaf"}, - {file = "cryptography-43.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:0663585d02f76929792470451a5ba64424acc3cd5227b03921dab0e2f27b1709"}, - {file = "cryptography-43.0.0-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:2c6d112bf61c5ef44042c253e4859b3cbbb50df2f78fa8fae6747a7814484a70"}, - {file = "cryptography-43.0.0-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:844b6d608374e7d08f4f6e6f9f7b951f9256db41421917dfb2d003dde4cd6b66"}, - {file = "cryptography-43.0.0-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:51956cf8730665e2bdf8ddb8da0056f699c1a5715648c1b0144670c1ba00b48f"}, - {file = "cryptography-43.0.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:aae4d918f6b180a8ab8bf6511a419473d107df4dbb4225c7b48c5c9602c38c7f"}, - {file = "cryptography-43.0.0-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:232ce02943a579095a339ac4b390fbbe97f5b5d5d107f8a08260ea2768be8cc2"}, - {file = "cryptography-43.0.0-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:5bcb8a5620008a8034d39bce21dc3e23735dfdb6a33a06974739bfa04f853947"}, - {file = "cryptography-43.0.0-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:08a24a7070b2b6804c1940ff0f910ff728932a9d0e80e7814234269f9d46d069"}, - {file = "cryptography-43.0.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:e9c5266c432a1e23738d178e51c2c7a5e2ddf790f248be939448c0ba2021f9d1"}, - {file = "cryptography-43.0.0.tar.gz", hash = "sha256:b88075ada2d51aa9f18283532c9f60e72170041bba88d7f37e49cbb10275299e"}, -] - -[package.dependencies] -cffi = {version = ">=1.12", markers = "platform_python_implementation != \"PyPy\""} - -[package.extras] -docs = ["sphinx (>=5.3.0)", "sphinx-rtd-theme (>=1.1.1)"] -docstest = ["pyenchant (>=1.6.11)", "readme-renderer", "sphinxcontrib-spelling (>=4.0.1)"] -nox = ["nox"] -pep8test = ["check-sdist", "click", "mypy", "ruff"] -sdist = ["build"] -ssh = ["bcrypt (>=3.1.5)"] -test = ["certifi", "cryptography-vectors (==43.0.0)", "pretend", "pytest (>=6.2.0)", "pytest-benchmark", "pytest-cov", "pytest-xdist"] -test-randomorder = ["pytest-randomly"] - [[package]] name = "cssbeautifier" version = "1.15.1" @@ -443,29 +357,6 @@ tzdata = {version = "*", markers = "sys_platform == \"win32\""} argon2 = ["argon2-cffi (>=19.1.0)"] bcrypt = ["bcrypt"] -[[package]] -name = "django-allauth" -version = "64.0.0" -description = "Integrated set of Django applications addressing authentication, registration, account management as well as 3rd party (social) account authentication." -optional = false -python-versions = ">=3.8" -files = [ - {file = "django_allauth-64.0.0.tar.gz", hash = "sha256:01952c7540160ef475c12dc881f67a69c7c5e533f4bef6c811bba0417a717588"}, -] - -[package.dependencies] -Django = ">=4.2" -pyjwt = {version = ">=1.7", extras = ["crypto"], optional = true, markers = "extra == \"socialaccount\""} -requests = {version = ">=2.0.0", optional = true, markers = "extra == \"socialaccount\""} -requests-oauthlib = {version = ">=0.3.0", optional = true, markers = "extra == \"socialaccount\""} - -[package.extras] -mfa = ["fido2 (>=1.1.2)", "qrcode (>=7.0.0)"] -openid = ["python3-openid (>=3.0.8)"] -saml = ["python3-saml (>=1.15.0,<2.0.0)"] -socialaccount = ["pyjwt[crypto] (>=1.7)", "requests (>=2.0.0)", "requests-oauthlib (>=0.3.0)"] -steam = ["python3-openid (>=3.0.8)"] - [[package]] name = "django-debug-toolbar" version = "4.4.6" @@ -709,22 +600,6 @@ files = [ {file = "nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f"}, ] -[[package]] -name = "oauthlib" -version = "3.2.2" -description = "A generic, spec-compliant, thorough implementation of the OAuth request-signing logic" -optional = false -python-versions = ">=3.6" -files = [ - {file = "oauthlib-3.2.2-py3-none-any.whl", hash = "sha256:8139f29aac13e25d502680e9e19963e83f16838d48a0d71c287fe40e7067fbca"}, - {file = "oauthlib-3.2.2.tar.gz", hash = "sha256:9859c40929662bec5d64f34d01c99e093149682a3f38915dc0655d5a633dd918"}, -] - -[package.extras] -rsa = ["cryptography (>=3.0.0)"] -signals = ["blinker (>=1.4.0)"] -signedtoken = ["cryptography (>=3.0.0)", "pyjwt (>=2.0.0,<3)"] - [[package]] name = "packaging" version = "24.1" @@ -893,17 +768,6 @@ nodeenv = ">=0.11.1" pyyaml = ">=5.1" virtualenv = ">=20.10.0" -[[package]] -name = "pycparser" -version = "2.22" -description = "C parser in Python" -optional = false -python-versions = ">=3.8" -files = [ - {file = "pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc"}, - {file = "pycparser-2.22.tar.gz", hash = "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6"}, -] - [[package]] name = "pyee" version = "11.0.1" @@ -921,26 +785,6 @@ typing-extensions = "*" [package.extras] dev = ["black", "flake8", "flake8-black", "isort", "jupyter-console", "mkdocs", "mkdocs-include-markdown-plugin", "mkdocstrings[python]", "pytest", "pytest-asyncio", "pytest-trio", "toml", "tox", "trio", "trio", "trio-typing", "twine", "twisted", "validate-pyproject[all]"] -[[package]] -name = "pyjwt" -version = "2.9.0" -description = "JSON Web Token implementation in Python" -optional = false -python-versions = ">=3.8" -files = [ - {file = "PyJWT-2.9.0-py3-none-any.whl", hash = "sha256:3b02fb0f44517787776cf48f2ae25d8e14f300e6d7545a4315cee571a415e850"}, - {file = "pyjwt-2.9.0.tar.gz", hash = "sha256:7e1e5b56cc735432a7369cbfa0efe50fa113ebecdc04ae6922deba8b84582d0c"}, -] - -[package.dependencies] -cryptography = {version = ">=3.4.0", optional = true, markers = "extra == \"crypto\""} - -[package.extras] -crypto = ["cryptography (>=3.4.0)"] -dev = ["coverage[toml] (==5.0.4)", "cryptography (>=3.4.0)", "pre-commit", "pytest (>=6.0.0,<7.0.0)", "sphinx", "sphinx-rtd-theme", "zope.interface"] -docs = ["sphinx", "sphinx-rtd-theme", "zope.interface"] -tests = ["coverage[toml] (==5.0.4)", "pytest (>=6.0.0,<7.0.0)"] - [[package]] name = "pytest" version = "8.3.2" @@ -1179,22 +1023,34 @@ socks = ["PySocks (>=1.5.6,!=1.5.7)"] use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] [[package]] -name = "requests-oauthlib" -version = "2.0.0" -description = "OAuthlib authentication support for Requests." +name = "requests-cache" +version = "1.2.1" +description = "A persistent cache for python requests" optional = false -python-versions = ">=3.4" +python-versions = ">=3.8" files = [ - {file = "requests-oauthlib-2.0.0.tar.gz", hash = "sha256:b3dffaebd884d8cd778494369603a9e7b58d29111bf6b41bdc2dcd87203af4e9"}, - {file = "requests_oauthlib-2.0.0-py2.py3-none-any.whl", hash = "sha256:7dd8a5c40426b779b0868c404bdef9768deccf22749cde15852df527e6269b36"}, + {file = "requests_cache-1.2.1-py3-none-any.whl", hash = "sha256:1285151cddf5331067baa82598afe2d47c7495a1334bfe7a7d329b43e9fd3603"}, + {file = "requests_cache-1.2.1.tar.gz", hash = "sha256:68abc986fdc5b8d0911318fbb5f7c80eebcd4d01bfacc6685ecf8876052511d1"}, ] [package.dependencies] -oauthlib = ">=3.0.0" -requests = ">=2.0.0" +attrs = ">=21.2" +cattrs = ">=22.2" +platformdirs = ">=2.5" +requests = ">=2.22" +url-normalize = ">=1.4" +urllib3 = ">=1.25.5" [package.extras] -rsa = ["oauthlib[signedtoken] (>=3.0.0)"] +all = ["boto3 (>=1.15)", "botocore (>=1.18)", "itsdangerous (>=2.0)", "pymongo (>=3)", "pyyaml (>=6.0.1)", "redis (>=3)", "ujson (>=5.4)"] +bson = ["bson (>=0.5)"] +docs = ["furo (>=2023.3,<2024.0)", "linkify-it-py (>=2.0,<3.0)", "myst-parser (>=1.0,<2.0)", "sphinx (>=5.0.2,<6.0.0)", "sphinx-autodoc-typehints (>=1.19)", "sphinx-automodapi (>=0.14)", "sphinx-copybutton (>=0.5)", "sphinx-design (>=0.2)", "sphinx-notfound-page (>=0.8)", "sphinxcontrib-apidoc (>=0.3)", "sphinxext-opengraph (>=0.9)"] +dynamodb = ["boto3 (>=1.15)", "botocore (>=1.18)"] +json = ["ujson (>=5.4)"] +mongodb = ["pymongo (>=3)"] +redis = ["redis (>=3)"] +security = ["itsdangerous (>=2.0)"] +yaml = ["pyyaml (>=6.0.1)"] [[package]] name = "ruff" @@ -1363,6 +1219,20 @@ files = [ greenlet = "3.0.1" pyee = "11.0.1" +[[package]] +name = "url-normalize" +version = "1.4.3" +description = "URL normalization for Python" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*" +files = [ + {file = "url-normalize-1.4.3.tar.gz", hash = "sha256:d23d3a070ac52a67b83a1c59a0e68f8608d1cd538783b401bc9de2c0fac999b2"}, + {file = "url_normalize-1.4.3-py2.py3-none-any.whl", hash = "sha256:ec3c301f04e5bb676d333a7fa162fa977ad2ca04b7e652bfc9fac4e405728eed"}, +] + +[package.dependencies] +six = "*" + [[package]] name = "urllib3" version = "2.2.2" @@ -1420,4 +1290,4 @@ brotli = ["brotli"] [metadata] lock-version = "2.0" python-versions = "^3.12" -content-hash = "4e9d7d30cf5dae3db8143b3abe1efe4f92d1f24ec5b728ab57c8d568723e24b8" +content-hash = "e0e08bfb89f04f48832324abd7155f3d199021d83afb7dd934aead158af8a7cb" diff --git a/pyproject.toml b/pyproject.toml index 075a244..08042ba 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,12 +10,13 @@ package-mode = false python = "^3.12" discord-webhook = "^1.3.1" django = { version = "^5.1", allow-prereleases = true } -django-allauth = { extras = ["socialaccount"], version = "^64.0.0" } django-debug-toolbar = "^4.4.6" django-simple-history = "^3.7.0" pillow = "^10.4.0" platformdirs = "^4.2.2" python-dotenv = "^1.0.1" +requests = "^2.32.3" +requests-cache = "^1.2.1" sentry-sdk = { extras = ["django"], version = "^2.13.0" } undetected-playwright-patch = "^1.40.0.post1700587210000" whitenoise = { extras = ["brotli"], version = "^6.7.0" }