Go back to cookies :(

This commit is contained in:
2024-08-16 06:37:54 +02:00
parent 324f255a8e
commit 3ff3fe157a
14 changed files with 478 additions and 563 deletions

2
.gitignore vendored
View File

@ -162,3 +162,5 @@ cython_debug/
*.json *.json
staticfiles/ staticfiles/
*.sqlite

View File

@ -1,42 +1,10 @@
import logging import logging
from typing import TYPE_CHECKING
from discord_webhook import DiscordWebhook
from django.conf import settings
from django.utils import timezone 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__) 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: def convert_time_to_discord_timestamp(time: timezone.datetime | None) -> str:
"""Discord uses <t:UNIX_TIMESTAMP:R> for timestamps. """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". str: The Discord timestamp string. If time is None, returns "Unknown".
""" """
return f"<t:{int(time.timestamp())}:R>" if time else "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

View File

@ -68,78 +68,99 @@ 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: # noqa: C901 async def add_reward_campaign(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. campaign (dict): The reward campaign to add.
""" """
# sourcery skip: low-code-quality
if not campaign: if not campaign:
return 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"]: 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"]: 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"): if "rewards" in reward_campaign:
defaults["sub_goal"] = reward_campaign["unlockRequirements"]["subsGoal"] 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"): async def handle_rewards(reward: dict, reward_campaign: RewardCampaign | None) -> None:
defaults["image_url"] = reward_campaign["image"]["image1xURL"] """Add or update a reward in the database.
our_reward_campaign, created = await RewardCampaign.objects.aupdate_or_create( Args:
id=reward_campaign["id"], reward (dict): The JSON from Twitch.
defaults=defaults, reward_campaign (RewardCampaign | None): The reward campaign the reward belongs to.
) """
if created: mappings: dict[str, str] = {
logger.info("Added reward campaign %s", our_reward_campaign.id) "name": "name",
"earnableUntil": "earnable_until",
"redemptionInstructions": "redemption_instructions",
"redemptionURL": "redemption_url",
}
if reward_campaign["game"]: defaults: dict = {new_key: reward[key] for key, new_key in mappings.items() if reward.get(key)}
# TODO(TheLovinator): Add game to reward campaign # noqa: TD003 if reward_campaign:
# TODO(TheLovinator): Send JSON to Discord # noqa: TD003 defaults["campaign"] = reward_campaign
logger.error("Not implemented: Add game to reward campaign, JSON: %s", reward_campaign["game"])
# Add rewards if reward.get("bannerImage"):
mappings: dict[str, str] = { defaults["banner_image_url"] = reward["bannerImage"]["image1xURL"]
"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 created: if reward.get("thumbnailImage"):
logger.info("Added reward %s", our_reward.id) defaults["thumbnail_image_url"] = reward["thumbnailImage"]["image1xURL"]
else:
logger.info("Updated reward %s", our_reward.id) 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: 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 return
if not isinstance(campaign, dict): if not isinstance(campaign, dict):
logger.warning("Campaign is not a dictionary") logger.warning("Campaign is not a dictionary. %s", campaign)
return return
# This is a Reward Campaign # 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) await add_reward_campaign(campaign)
if "dropCampaign" in campaign.get("data", {}).get("user", {}): 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") save_json(campaign, "drop_campaign")
if campaign.get("data", {}).get("user", {}).get("dropCampaign"): if campaign.get("data", {}).get("user", {}).get("dropCampaign"):
await add_drop_campaign(campaign["data"]["user"]["dropCampaign"]) await add_drop_campaign(campaign["data"]["user"]["dropCampaign"])

View File

@ -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.models
import django.contrib.auth.validators import django.contrib.auth.validators
import django.db.models.deletion import django.db.models.deletion
import django.utils.timezone import django.utils.timezone
from django.conf import settings
from django.db import migrations, models from django.db import migrations, models
from django.db.migrations.operations.base import Operation 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")), ("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", "groups",
models.ManyToManyField( models.ManyToManyField(
@ -132,22 +129,6 @@ class Migration(migrations.Migration):
("objects", django.contrib.auth.models.UserManager()), ("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( migrations.CreateModel(
name="Channel", name="Channel",
fields=[ fields=[
@ -169,28 +150,6 @@ class Migration(migrations.Migration):
to="core.game", 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( migrations.AddField(
model_name="game", model_name="game",
name="org", name="org",
@ -201,28 +160,6 @@ class Migration(migrations.Migration):
to="core.owner", 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( migrations.CreateModel(
name="RewardCampaign", name="RewardCampaign",
fields=[ 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")},
},
),
] ]

View File

@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
import logging import logging
from typing import ClassVar
from django.contrib.auth.models import AbstractUser from django.contrib.auth.models import AbstractUser
from django.db import models from django.db import models
@ -8,6 +9,9 @@ from django.db import models
logger: logging.Logger = logging.getLogger(__name__) logger: logging.Logger = logging.getLogger(__name__)
class User(AbstractUser): ...
class Owner(models.Model): class Owner(models.Model):
"""The company or person that owns the game. """The company or person that owns the game.
@ -182,62 +186,41 @@ class Reward(models.Model):
return self.name or "Reward name unknown" return self.name or "Reward name unknown"
class User(AbstractUser): class Webhook(models.Model):
"""Extended User model to include subscriptions.""" """Discord webhook."""
subscribed_games = models.ManyToManyField( avatar = models.TextField(null=True)
"Game", channel_id = models.TextField(null=True)
through="GameSubscription", guild_id = models.TextField(null=True)
related_name="subscribed_users", id = models.TextField(primary_key=True)
blank=True, name = models.TextField(null=True)
) type = models.TextField(null=True)
subscribed_owners = models.ManyToManyField( token = models.TextField()
"Owner", url = models.TextField()
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")
def __str__(self) -> str: # Get notified when the site finds a new game.
return self.username 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): # Get notified when the site finds a new drop campaign for a specific organization.
"""A Discord webhook for sending notifications.""" subscribed_new_owners = models.ManyToManyField(Owner, related_name="subscribed_new_owners")
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="discord_webhooks") # Get notified when a drop goes live for a specific organization.
url = models.URLField() subscribed_live_owners = models.ManyToManyField(Owner, related_name="subscribed_live_owners")
name = models.CharField(max_length=255)
def __str__(self) -> str: # So we don't spam the same drop campaign over and over.
return f"{self.name} ({self.user.username})" seen_drops = models.ManyToManyField(DropCampaign, related_name="seen_drops")
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)
class Meta: class Meta:
unique_together = ("user", "game") unique_together: ClassVar[list[str]] = ["id", "token"]
def __str__(self) -> str: def __str__(self) -> str:
return f"{self.user.username} -> {self.game.name} via {self.webhook.name}" return f"{self.name} - {self.get_webhook_url()}"
def get_webhook_url(self) -> str:
class OwnerSubscription(models.Model): try:
"""A subscription to a specific owner with a chosen webhook.""" return f"https://discord.com/api/webhooks/{self.id}/{self.token}"
except AttributeError:
user = models.ForeignKey(User, on_delete=models.CASCADE) return ""
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}"

View File

@ -1,6 +1,5 @@
import os import os
from pathlib import Path from pathlib import Path
from typing import Literal
import sentry_sdk import sentry_sdk
from django.contrib import messages from django.contrib import messages
@ -76,10 +75,6 @@ INSTALLED_APPS: list[str] = [
"django.contrib.messages", "django.contrib.messages",
"django.contrib.staticfiles", "django.contrib.staticfiles",
"django.contrib.sites", "django.contrib.sites",
"allauth",
"allauth.account",
"allauth.socialaccount",
"allauth.socialaccount.providers.twitch",
"simple_history", "simple_history",
"debug_toolbar", "debug_toolbar",
] ]
@ -95,7 +90,6 @@ MIDDLEWARE: list[str] = [
"django.contrib.auth.middleware.AuthenticationMiddleware", "django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware", "django.contrib.messages.middleware.MessageMiddleware",
"simple_history.middleware.HistoryRequestMiddleware", "simple_history.middleware.HistoryRequestMiddleware",
"allauth.account.middleware.AccountMiddleware",
] ]
TEMPLATES = [ TEMPLATES = [
@ -185,24 +179,7 @@ CACHES = {
}, },
} }
SITE_ID = 1 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 = "/" LOGIN_REDIRECT_URL = "/"
ACCOUNT_LOGOUT_REDIRECT_URL = "/" ACCOUNT_LOGOUT_REDIRECT_URL = "/"

View File

@ -3,20 +3,40 @@ import os
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from discord_webhook import DiscordWebhook from discord_webhook import DiscordWebhook
from django.db.models.manager import BaseManager
from django.db.models.signals import post_save from django.db.models.signals import post_save
from django.dispatch import receiver from django.dispatch import receiver
from core.discord import generate_game_message, generate_owner_message from core.discord import convert_time_to_discord_timestamp
from core.models import DropCampaign, Game, GameSubscription, Owner, OwnerSubscription, User from core.models import DropCampaign, Game, Owner, User, Webhook
if TYPE_CHECKING: if TYPE_CHECKING:
import requests import requests
from django.db.models.manager import BaseManager
logger: logging.Logger = logging.getLogger(__name__) 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) @receiver(signal=post_save, sender=User)
def handle_user_signed_up(sender: User, instance: User, created: bool, **kwargs) -> None: # noqa: ANN003, ARG001, FBT001 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. """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) 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 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: Args:
sender (DropCampaign): The model we are sending the signal from. 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 game: Game | None = instance.game
if not game: if not game:
logger.error("No game found. %s", instance)
return 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 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 # Set the webhook as seen so we don't spam it.
game_subs: BaseManager[GameSubscription] = GameSubscription.objects.filter(game=game) hook.seen_drops.add(instance)
for sub in game_subs:
if not sub.webhook.url: # Send the webhook.
webhook_url: str = hook.get_webhook_url()
if not webhook_url:
logger.error("No webhook URL provided.") logger.error("No webhook URL provided.")
return continue
webhook = DiscordWebhook( webhook = DiscordWebhook(
url=sub.webhook.url, url=webhook_url,
content=generate_game_message(instance=instance, game=game, sub=sub), content=generate_message(game, instance),
username=f"{game.name} drop.", username=f"{game.name} Twitch drops",
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.",
rate_limit_retry=True, rate_limit_retry=True,
) )
response: requests.Response = webhook.execute() response: requests.Response = webhook.execute()

View File

@ -18,9 +18,7 @@
<!-- Insert nice buttons --> <!-- Insert nice buttons -->
</div> </div>
{% for campaign in game.drop_campaigns.all %} {% for campaign in game.drop_campaigns.all %}
{% if not forloop.first %}<br>{% endif %}
<div class="mt-3"> <div class="mt-3">
<h3 class="h6">{{ campaign.name }}</h3>
{% if campaign.details_url == campaign.account_link_url %} {% if campaign.details_url == campaign.account_link_url %}
<a href="{{ campaign.details_url }}" class="text-decoration-none">Details</a> <a href="{{ campaign.details_url }}" class="text-decoration-none">Details</a>
{% else %} {% else %}
@ -31,26 +29,7 @@
<p class="mb-2 text-muted"> <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> 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> </p>
{% if campaign.description != campaign.name %} <div class="row row-cols-1 row-cols-md-2 row-cols-lg-3">
{% 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">
{% for drop in campaign.drops.all %} {% for drop in campaign.drops.all %}
{% for benefit in drop.benefits.all %} {% for benefit in drop.benefits.all %}
<div class="col d-flex align-items-center position-relative"> <div class="col d-flex align-items-center position-relative">

View File

@ -1,4 +1,3 @@
{% load socialaccount %}
<header class="d-flex justify-content-between align-items-center py-3 border-bottom"> <header class="d-flex justify-content-between align-items-center py-3 border-bottom">
<h1 class="h2"> <h1 class="h2">
<a href='{% url "index" %}' class="text-decoration-none nav-title">Twitch drops</a> <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"> <li class="nav-item d-none d-sm-block">
<a class="nav-link" href="https://github.com/sponsors/TheLovinator1">Donate</a> <a class="nav-link" href="https://github.com/sponsors/TheLovinator1">Donate</a>
</li> </li>
{% if not user.is_authenticated %} <li class="nav-item d-none d-sm-block">
<li> <a class="nav-link" href='{% url "webhooks" %}'>Webhooks</a>
<a class="nav-link" href="{% provider_login_url 'twitch' %}">Login with Twitch</a> </li>
</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 %}
</ul> </ul>
</nav> </nav>
</header> </header>

View File

@ -24,5 +24,27 @@
</form> </form>
</div> </div>
<h2 class="mt-5">Webhooks</h2> <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> </div>
{% endblock content %} {% endblock content %}

View File

@ -2,25 +2,17 @@ from __future__ import annotations
from debug_toolbar.toolbar import debug_toolbar_urls from debug_toolbar.toolbar import debug_toolbar_urls
from django.contrib import admin 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" app_name: str = "core"
urlpatterns: list[URLPattern | URLResolver] = [ urlpatterns: list[URLPattern | URLResolver] = [
path("admin/", admin.site.urls), path(route="admin/", view=admin.site.urls),
path("accounts/", include("allauth.urls"), name="accounts"),
path(route="", view=index, name="index"), path(route="", view=index, name="index"),
path( path(route="webhooks/", view=WebhooksView.as_view(), name="webhooks"),
route="games/", path(route="games/", view=game_view, name="games"),
view=game_view, path(route="reward_campaigns/", view=reward_campaign_view, name="reward_campaigns"),
name="games",
),
path(
route="reward_campaigns/",
view=reward_campaign_view,
name="reward_campaigns",
),
*debug_toolbar_urls(), *debug_toolbar_urls(),
] ]

View File

@ -4,16 +4,19 @@ import logging
from dataclasses import dataclass from dataclasses import dataclass
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
import requests_cache
from django.db.models import Prefetch from django.db.models import Prefetch
from django.db.models.manager import BaseManager from django.db.models.manager import BaseManager
from django.http import HttpRequest, HttpResponse
from django.template.response import TemplateResponse 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: if TYPE_CHECKING:
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
logger: logging.Logger = logging.getLogger(__name__) logger: logging.Logger = logging.getLogger(__name__)
@ -114,3 +117,129 @@ def reward_campaign_view(request: HttpRequest) -> HttpResponse:
reward_campaigns: BaseManager[RewardCampaign] = RewardCampaign.objects.all() reward_campaigns: BaseManager[RewardCampaign] = RewardCampaign.objects.all()
context: dict[str, BaseManager[RewardCampaign]] = {"reward_campaigns": reward_campaigns} context: dict[str, BaseManager[RewardCampaign]] = {"reward_campaigns": reward_campaigns}
return TemplateResponse(request=request, template="reward_campaigns.html", context=context) 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)

286
poetry.lock generated
View File

@ -14,6 +14,25 @@ files = [
[package.extras] [package.extras]
tests = ["mypy (>=0.800)", "pytest", "pytest-asyncio"] 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]] [[package]]
name = "brotli" name = "brotli"
version = "1.1.0" version = "1.1.0"
@ -106,6 +125,29 @@ files = [
{file = "Brotli-1.1.0.tar.gz", hash = "sha256:81de08ac11bcb85841e440c13611c00b67d3bf82698314928d0b676362546724"}, {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]] [[package]]
name = "certifi" name = "certifi"
version = "2024.7.4" version = "2024.7.4"
@ -117,85 +159,6 @@ files = [
{file = "certifi-2024.7.4.tar.gz", hash = "sha256:5a1e7645bc0ec61a09e26c36f6106dd4cf40c6db3a1fb6352b0244e7fb057c7b"}, {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]] [[package]]
name = "cfgv" name = "cfgv"
version = "3.4.0" version = "3.4.0"
@ -331,55 +294,6 @@ files = [
{file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, {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]] [[package]]
name = "cssbeautifier" name = "cssbeautifier"
version = "1.15.1" version = "1.15.1"
@ -443,29 +357,6 @@ tzdata = {version = "*", markers = "sys_platform == \"win32\""}
argon2 = ["argon2-cffi (>=19.1.0)"] argon2 = ["argon2-cffi (>=19.1.0)"]
bcrypt = ["bcrypt"] 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]] [[package]]
name = "django-debug-toolbar" name = "django-debug-toolbar"
version = "4.4.6" version = "4.4.6"
@ -709,22 +600,6 @@ files = [
{file = "nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f"}, {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]] [[package]]
name = "packaging" name = "packaging"
version = "24.1" version = "24.1"
@ -893,17 +768,6 @@ nodeenv = ">=0.11.1"
pyyaml = ">=5.1" pyyaml = ">=5.1"
virtualenv = ">=20.10.0" 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]] [[package]]
name = "pyee" name = "pyee"
version = "11.0.1" version = "11.0.1"
@ -921,26 +785,6 @@ typing-extensions = "*"
[package.extras] [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]"] 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]] [[package]]
name = "pytest" name = "pytest"
version = "8.3.2" 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)"] use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"]
[[package]] [[package]]
name = "requests-oauthlib" name = "requests-cache"
version = "2.0.0" version = "1.2.1"
description = "OAuthlib authentication support for Requests." description = "A persistent cache for python requests"
optional = false optional = false
python-versions = ">=3.4" python-versions = ">=3.8"
files = [ files = [
{file = "requests-oauthlib-2.0.0.tar.gz", hash = "sha256:b3dffaebd884d8cd778494369603a9e7b58d29111bf6b41bdc2dcd87203af4e9"}, {file = "requests_cache-1.2.1-py3-none-any.whl", hash = "sha256:1285151cddf5331067baa82598afe2d47c7495a1334bfe7a7d329b43e9fd3603"},
{file = "requests_oauthlib-2.0.0-py2.py3-none-any.whl", hash = "sha256:7dd8a5c40426b779b0868c404bdef9768deccf22749cde15852df527e6269b36"}, {file = "requests_cache-1.2.1.tar.gz", hash = "sha256:68abc986fdc5b8d0911318fbb5f7c80eebcd4d01bfacc6685ecf8876052511d1"},
] ]
[package.dependencies] [package.dependencies]
oauthlib = ">=3.0.0" attrs = ">=21.2"
requests = ">=2.0.0" cattrs = ">=22.2"
platformdirs = ">=2.5"
requests = ">=2.22"
url-normalize = ">=1.4"
urllib3 = ">=1.25.5"
[package.extras] [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]] [[package]]
name = "ruff" name = "ruff"
@ -1363,6 +1219,20 @@ files = [
greenlet = "3.0.1" greenlet = "3.0.1"
pyee = "11.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]] [[package]]
name = "urllib3" name = "urllib3"
version = "2.2.2" version = "2.2.2"
@ -1420,4 +1290,4 @@ brotli = ["brotli"]
[metadata] [metadata]
lock-version = "2.0" lock-version = "2.0"
python-versions = "^3.12" python-versions = "^3.12"
content-hash = "4e9d7d30cf5dae3db8143b3abe1efe4f92d1f24ec5b728ab57c8d568723e24b8" content-hash = "e0e08bfb89f04f48832324abd7155f3d199021d83afb7dd934aead158af8a7cb"

View File

@ -10,12 +10,13 @@ package-mode = false
python = "^3.12" python = "^3.12"
discord-webhook = "^1.3.1" discord-webhook = "^1.3.1"
django = { version = "^5.1", allow-prereleases = true } django = { version = "^5.1", allow-prereleases = true }
django-allauth = { extras = ["socialaccount"], version = "^64.0.0" }
django-debug-toolbar = "^4.4.6" django-debug-toolbar = "^4.4.6"
django-simple-history = "^3.7.0" django-simple-history = "^3.7.0"
pillow = "^10.4.0" pillow = "^10.4.0"
platformdirs = "^4.2.2" platformdirs = "^4.2.2"
python-dotenv = "^1.0.1" python-dotenv = "^1.0.1"
requests = "^2.32.3"
requests-cache = "^1.2.1"
sentry-sdk = { extras = ["django"], version = "^2.13.0" } sentry-sdk = { extras = ["django"], version = "^2.13.0" }
undetected-playwright-patch = "^1.40.0.post1700587210000" undetected-playwright-patch = "^1.40.0.post1700587210000"
whitenoise = { extras = ["brotli"], version = "^6.7.0" } whitenoise = { extras = ["brotli"], version = "^6.7.0" }