The comeback

This commit is contained in:
2024-12-09 06:18:57 +01:00
parent dc19efc536
commit eb283451a8
37 changed files with 813 additions and 2402 deletions

22
.vscode/launch.json vendored
View File

@ -12,28 +12,6 @@
],
"django": true,
"justMyCode": true
},
{
"name": "scrape_twitch",
"type": "debugpy",
"request": "launch",
"program": "${workspaceFolder}/manage.py",
"args": [
"scrape_twitch"
],
"django": true,
"justMyCode": true
},
{
"name": "scrape_local",
"type": "debugpy",
"request": "launch",
"program": "${workspaceFolder}/manage.py",
"args": [
"scrape_local"
],
"django": true,
"justMyCode": true
}
]
}

28
.vscode/settings.json vendored
View File

@ -3,30 +3,56 @@
"adownload",
"aimport",
"allauth",
"appauthor",
"appendonly",
"appname",
"asgiref",
"Behaviour",
"cacd",
"cellspacing",
"collectstatic",
"djade",
"docstrings",
"dotenv",
"dropcampaign",
"dungeonborne",
"endfor",
"forloop",
"Hellsén",
"isort",
"Joakim",
"logdir",
"Lovinator",
"lvthalo",
"memlock",
"Monkeypatching",
"mypy",
"networkidle",
"nostatic",
"PGID",
"platformdirs",
"psycopg",
"PUID",
"pydocstyle",
"pyupgrade",
"requirepass",
"rewardcampaign",
"sitewide",
"socialaccount",
"staticfiles",
"Stresss",
"stylesheet",
"tabindex",
"templatetags",
"timebaseddrop",
"timesince",
"timeuntil",
"tocs",
"ttvdrops",
"ulimits",
"Valair",
"whitenoise",
"xdefiant"
]
],
"python.analysis.typeCheckingMode": "basic",
}

View File

@ -1,6 +1,6 @@
from django.contrib import admin
from .models import Benefit, DropCampaign, Game, Owner, Reward, RewardCampaign, TimeBasedDrop
from core.models import Benefit, DropCampaign, Game, Owner, Reward, RewardCampaign, TimeBasedDrop
admin.site.register(Game)
admin.site.register(Owner)

View File

@ -2,13 +2,7 @@ from django.apps import AppConfig
class CoreConfig(AppConfig):
"""Core app configuration."""
default_auto_field: str = "django.db.models.BigAutoField"
name = "core"
@staticmethod
def ready() -> None:
"""Ready runs on app startup.
We import signals here so that they are registered when the app starts.
"""
import core.signals # noqa: F401, PLC0415

View File

@ -1,54 +0,0 @@
import datetime
from dataclasses import dataclass
@dataclass
class WebhookData:
"""The webhook data."""
name: str | None = None
url: str | None = None
avatar: str | None = None
status: str | None = None
response: str | None = None
@dataclass
class DropContext:
"""The drop."""
drops_id: str | None = None
image_url: str | None = None
name: str | None = None
limit: int | None = None
required_minutes_watched: int | None = None
required_subs: int | None = None
@dataclass
class CampaignContext:
"""Drops are grouped into campaigns."""
drop_id: str | None = None
name: str | None = None
image_url: str | None = None
status: str | None = None
account_link_url: str | None = None
description: str | None = None
details_url: str | None = None
ios_available: bool | None = None
start_at: datetime.datetime | None = None
end_at: datetime.datetime | None = None
drops: list[DropContext] | None = None
@dataclass
class GameContext:
"""Campaigns are under a game."""
game_id: str | None = None
campaigns: list[CampaignContext] | None = None
image_url: str | None = None
display_name: str | None = None
twitch_url: str | None = None
slug: str | None = None

View File

@ -1,17 +0,0 @@
import logging
from django.utils import timezone
logger: logging.Logger = logging.getLogger(__name__)
def convert_time_to_discord_timestamp(time: timezone.datetime | None) -> str:
"""Discord uses <t:UNIX_TIMESTAMP:R> for timestamps.
Args:
time: The time to convert to a Discord timestamp.
Returns:
str: The Discord timestamp string. If time is None, returns "Unknown".
"""
return f"<t:{int(time.timestamp())}:R>" if time else "Unknown"

View File

@ -1,20 +0,0 @@
from django import forms
from django.core.validators import URLValidator
class DiscordSettingForm(forms.Form):
webhook_url = forms.URLField(
label="Webhook URL",
required=True,
validators=[
URLValidator(
schemes=["https"],
message="The URL must be a valid HTTPS URL.",
),
URLValidator(
regex=r"https://discord.com/api/webhooks/\d{18}/[a-zA-Z0-9_-]{68}",
message="The URL must be a valid Discord webhook URL.",
),
],
help_text="The URL can be found by right-clicking on the channel and selecting 'Edit Channel', then 'Integrations', and 'Webhooks'.", # noqa: E501
)

View File

@ -1,37 +0,0 @@
import asyncio
import json
import logging
from pathlib import Path
from django.core.management.base import BaseCommand
from core.management.commands.scrape_twitch import process_json_data
logger: logging.Logger = logging.getLogger(__name__)
class Command(BaseCommand):
help = "Scrape local files."
def handle(self, *args, **kwargs) -> None: # noqa: ANN002, ANN003, ARG002, PLR6301
"""Scrape local files.
Args:
*args: Variable length argument list.
**kwargs: Arbitrary keyword arguments.
"""
dir_name = Path("json2")
for num, file in enumerate(Path(dir_name).rglob("*.json")):
logger.info("Processing %s", file)
with file.open(encoding="utf-8") as f:
try:
load_json = json.load(f)
except json.JSONDecodeError:
logger.exception("Failed to load JSON from %s", file)
continue
asyncio.run(main=process_json_data(num=num, campaign=load_json, local=True))
if __name__ == "__main__":
Command().handle()

View File

@ -1,271 +0,0 @@
import asyncio
import json
import logging
import typing
from pathlib import Path
from typing import TYPE_CHECKING
from django.core.management.base import BaseCommand
from platformdirs import user_data_dir
from playwright.async_api import Playwright, async_playwright
from playwright.async_api._generated import Response
from core.models import Benefit, DropCampaign, Game, Owner, RewardCampaign, TimeBasedDrop
if TYPE_CHECKING:
from playwright.async_api._generated import BrowserContext, Page
logger: logging.Logger = logging.getLogger(__name__)
def get_profile_dir() -> Path:
"""Get the profile directory for the browser.
Returns:
Path: The profile directory.
"""
data_dir = Path(
user_data_dir(appname="TTVDrops", appauthor="TheLovinator", roaming=True, ensure_exists=True),
)
profile_dir: Path = data_dir / "chrome-profile"
profile_dir.mkdir(parents=True, exist_ok=True)
if logger.isEnabledFor(logging.DEBUG):
logger.debug("Launching Chrome browser with user data directory: %s", profile_dir)
return profile_dir
def save_json(campaign: dict | None, *, local: bool) -> None:
"""Save JSON data to a file.
Args:
campaign (dict): The JSON data to save.
local (bool): Only save JSON data if we are scraping from the web.
"""
if local:
return
if not campaign:
return
save_dir = Path("json")
save_dir.mkdir(parents=True, exist_ok=True)
# File name is the hash of the JSON data
file_name: str = f"{hash(json.dumps(campaign))}.json"
with Path(save_dir / file_name).open(mode="w", encoding="utf-8") as f:
json.dump(campaign, f, indent=4)
async def add_reward_campaign(reward_campaign: dict | None) -> None:
"""Add a reward campaign to the database.
Args:
reward_campaign (dict): The reward campaign to add.
"""
if not reward_campaign:
return
our_reward_campaign, created = await RewardCampaign.objects.aupdate_or_create(twitch_id=reward_campaign["id"])
await our_reward_campaign.aimport_json(reward_campaign)
if created:
logger.info("Added reward campaign %s", our_reward_campaign)
async def add_drop_campaign(drop_campaign: dict | None, *, local: bool) -> None:
"""Add a drop campaign to the database.
Args:
drop_campaign (dict): The drop campaign to add.
local (bool): Only update status if we are scraping from the Twitch directly.
"""
if not drop_campaign:
return
if not drop_campaign.get("owner", {}):
logger.error("Owner not found in drop campaign %s", drop_campaign)
return
owner, created = await Owner.objects.aupdate_or_create(twitch_id=drop_campaign["owner"]["id"])
await owner.aimport_json(data=drop_campaign["owner"])
if created:
logger.info("Added owner %s", owner.twitch_id)
if not drop_campaign.get("game", {}):
logger.error("Game not found in drop campaign %s", drop_campaign)
return
game, created = await Game.objects.aupdate_or_create(twitch_id=drop_campaign["game"]["id"])
await game.aimport_json(data=drop_campaign["game"], owner=owner)
if created:
logger.info("Added game %s", game)
our_drop_campaign, created = await DropCampaign.objects.aupdate_or_create(twitch_id=drop_campaign["id"])
await our_drop_campaign.aimport_json(drop_campaign, game, scraping_local_files=local)
if created:
logger.info("Added drop campaign %s", our_drop_campaign.twitch_id)
await add_time_based_drops(drop_campaign, our_drop_campaign)
# Check if eventBasedDrops exist
if drop_campaign.get("eventBasedDrops"):
# TODO(TheLovinator): Add event-based drops # noqa: TD003
msg = "Not implemented: Add event-based drops"
raise NotImplementedError(msg)
async def add_time_based_drops(drop_campaign: dict, our_drop_campaign: DropCampaign) -> None:
"""Add time-based drops to the database.
Args:
drop_campaign (dict): The drop campaign containing time-based drops.
our_drop_campaign (DropCampaign): The drop campaign object in the database.
"""
for time_based_drop in drop_campaign.get("timeBasedDrops", []):
if time_based_drop.get("preconditionDrops"):
# TODO(TheLovinator): Add precondition drops to time-based drop # noqa: TD003
# TODO(TheLovinator): Send JSON to Discord # noqa: TD003
msg = "Not implemented: Add precondition drops to time-based drop"
raise NotImplementedError(msg)
our_time_based_drop, created = await TimeBasedDrop.objects.aupdate_or_create(twitch_id=time_based_drop["id"])
await our_time_based_drop.aimport_json(data=time_based_drop, drop_campaign=our_drop_campaign)
if created:
logger.info("Added time-based drop %s", our_time_based_drop.twitch_id)
if our_time_based_drop and time_based_drop.get("benefitEdges"):
for benefit_edge in time_based_drop["benefitEdges"]:
benefit, created = await Benefit.objects.aupdate_or_create(twitch_id=benefit_edge["benefit"]["id"])
await benefit.aimport_json(benefit_edge["benefit"], our_time_based_drop)
if created:
logger.info("Added benefit %s", benefit.twitch_id)
async def handle_drop_campaigns(drop_campaign: dict) -> None:
"""Handle drop campaigns.
We need to grab the game image in data.currentUser.dropCampaigns.game.boxArtURL.
Args:
drop_campaign (dict): The drop campaign to handle.
"""
if not drop_campaign:
return
if drop_campaign.get("game", {}).get("boxArtURL"):
owner_id = drop_campaign.get("owner", {}).get("id")
if not owner_id:
logger.error("Owner ID not found in drop campaign %s", drop_campaign)
return
owner, created = await Owner.objects.aupdate_or_create(twitch_id=drop_campaign["owner"]["id"])
await owner.aimport_json(drop_campaign["owner"])
if created:
logger.info("Added owner %s", owner.twitch_id)
game_obj, created = await Game.objects.aupdate_or_create(twitch_id=drop_campaign["game"]["id"])
await game_obj.aimport_json(data=drop_campaign["game"], owner=owner)
if created:
logger.info("Added game %s", game_obj.twitch_id)
async def process_json_data(num: int, campaign: dict | None, *, local: bool) -> None:
"""Process JSON data.
Args:
num (int): The number of the JSON data.
campaign (dict): The JSON data to process.
local (bool): Only save JSON data if we are scraping from the web.
"""
logger.info("Processing JSON %d", num)
if not campaign:
logger.warning("No campaign found for JSON %d", num)
return
if not isinstance(campaign, dict):
logger.warning("Campaign is not a dictionary. %s", campaign)
return
save_json(campaign=campaign, local=local)
if campaign.get("data", {}).get("rewardCampaignsAvailableToUser"):
for reward_campaign in campaign["data"]["rewardCampaignsAvailableToUser"]:
await add_reward_campaign(reward_campaign=reward_campaign)
if campaign.get("data", {}).get("user", {}).get("dropCampaign"):
await add_drop_campaign(drop_campaign=campaign["data"]["user"]["dropCampaign"], local=local)
if campaign.get("data", {}).get("currentUser", {}).get("dropCampaigns"):
for drop_campaign in campaign["data"]["currentUser"]["dropCampaigns"]:
await handle_drop_campaigns(drop_campaign=drop_campaign)
class Command(BaseCommand):
help = "Scrape Twitch Drops Campaigns with login using Firefox"
@staticmethod
async def run(playwright: Playwright) -> list[dict[str, typing.Any]]:
profile_dir: Path = get_profile_dir()
browser: BrowserContext = await playwright.chromium.launch_persistent_context(
channel="chrome",
user_data_dir=profile_dir,
headless=False,
args=["--disable-blink-features=AutomationControlled"],
)
logger.debug("Launched Chrome browser")
page: Page = await browser.new_page()
json_data: list[dict] = []
async def handle_response(response: Response) -> None:
if "https://gql.twitch.tv/gql" in response.url:
try:
body: typing.Any = await response.json()
json_data.extend(body)
except Exception:
logger.exception(
"Failed to parse JSON from %s",
response.url,
)
page.on("response", handle_response)
await page.goto("https://www.twitch.tv/drops/campaigns")
logger.debug("Navigated to Twitch drops campaigns page")
logged_in = False
while not logged_in:
try:
await page.wait_for_selector(
selector='div[data-a-target="top-nav-avatar"]',
timeout=300000,
)
logged_in = True
logger.info("Logged in to Twitch")
except KeyboardInterrupt as e:
raise KeyboardInterrupt from e
except Exception: # noqa: BLE001
await asyncio.sleep(5)
logger.info("Waiting for login")
await page.wait_for_load_state("networkidle")
logger.debug("Page loaded. Scraping data...")
await browser.close()
for num, campaign in enumerate(json_data, start=1):
await process_json_data(num=num, campaign=campaign, local=False)
return json_data
def handle(self, *args, **kwargs) -> None: # noqa: ANN002, ARG002, ANN003
asyncio.run(self.run_with_playwright())
async def run_with_playwright(self) -> None:
async with async_playwright() as playwright:
await self.run(playwright=playwright)
if __name__ == "__main__":
Command().handle()

View File

@ -1,290 +0,0 @@
# Generated by Django 5.1 on 2024-09-01 22:36
from __future__ import annotations
from typing import TYPE_CHECKING
import django.contrib.auth.models
import django.contrib.auth.validators
import django.db.models.deletion
import django.utils.timezone
from django.db import migrations, models
if TYPE_CHECKING:
from django.db.migrations.operations.base import Operation
class Migration(migrations.Migration):
initial = True
dependencies: list[tuple[str, str]] = [
("auth", "0012_alter_user_first_name_max_length"),
]
operations: list[Operation] = [
migrations.CreateModel(
name="Game",
fields=[
("created_at", models.DateTimeField(auto_created=True, null=True)),
("twitch_id", models.TextField(primary_key=True, serialize=False)),
("modified_at", models.DateTimeField(auto_now=True, null=True)),
("game_url", models.URLField(default="https://www.twitch.tv/", null=True)),
("name", models.TextField(default="Game name unknown", null=True)),
(
"box_art_url",
models.URLField(default="https://static-cdn.jtvnw.net/ttv-static/404_boxart.jpg", null=True),
),
("slug", models.TextField(null=True)),
],
),
migrations.CreateModel(
name="Owner",
fields=[
("created_at", models.DateTimeField(auto_created=True, null=True)),
("twitch_id", models.TextField(primary_key=True, serialize=False)),
("modified_at", models.DateTimeField(auto_now=True, null=True)),
("name", models.TextField(default="Unknown", null=True)),
],
),
migrations.CreateModel(
name="User",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("password", models.CharField(max_length=128, verbose_name="password")),
("last_login", models.DateTimeField(blank=True, null=True, verbose_name="last login")),
(
"is_superuser",
models.BooleanField(
default=False,
help_text="Designates that this user has all permissions without explicitly assigning them.",
verbose_name="superuser status",
),
),
(
"username",
models.CharField(
error_messages={"unique": "A user with that username already exists."},
help_text="Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.",
max_length=150,
unique=True,
validators=[django.contrib.auth.validators.UnicodeUsernameValidator()],
verbose_name="username",
),
),
("first_name", models.CharField(blank=True, max_length=150, verbose_name="first name")),
("last_name", models.CharField(blank=True, max_length=150, verbose_name="last name")),
("email", models.EmailField(blank=True, max_length=254, verbose_name="email address")),
(
"is_staff",
models.BooleanField(
default=False,
help_text="Designates whether the user can log into this admin site.",
verbose_name="staff status",
),
),
(
"is_active",
models.BooleanField(
default=True,
help_text="Designates whether this user should be treated as active. Unselect this instead of deleting accounts.", # noqa: E501
verbose_name="active",
),
),
("date_joined", models.DateTimeField(default=django.utils.timezone.now, verbose_name="date joined")),
(
"groups",
models.ManyToManyField(
blank=True,
help_text="The groups this user belongs to. A user will get all permissions granted to each of their groups.", # noqa: E501
related_name="user_set",
related_query_name="user",
to="auth.group",
verbose_name="groups",
),
),
(
"user_permissions",
models.ManyToManyField(
blank=True,
help_text="Specific permissions for this user.",
related_name="user_set",
related_query_name="user",
to="auth.permission",
verbose_name="user permissions",
),
),
],
options={
"verbose_name": "user",
"verbose_name_plural": "users",
"abstract": False,
},
managers=[
("objects", django.contrib.auth.models.UserManager()),
],
),
migrations.CreateModel(
name="DropCampaign",
fields=[
("created_at", models.DateTimeField(auto_created=True, null=True)),
("id", models.TextField(primary_key=True, serialize=False)),
("modified_at", models.DateTimeField(auto_now=True, null=True)),
("account_link_url", models.URLField(null=True)),
("description", models.TextField(null=True)),
("details_url", models.URLField(null=True)),
("ends_at", models.DateTimeField(null=True)),
("starts_at", models.DateTimeField(null=True)),
("image_url", models.URLField(null=True)),
("name", models.TextField(default="Unknown", null=True)),
("status", models.TextField(null=True)),
(
"game",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="drop_campaigns",
to="core.game",
),
),
],
),
migrations.AddField(
model_name="game",
name="org",
field=models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="games",
to="core.owner",
),
),
migrations.CreateModel(
name="RewardCampaign",
fields=[
("created_at", models.DateTimeField(auto_created=True, null=True)),
("twitch_id", models.TextField(primary_key=True, serialize=False)),
("modified_at", models.DateTimeField(auto_now=True, null=True)),
("name", models.TextField(null=True)),
("brand", models.TextField(null=True)),
("starts_at", models.DateTimeField(null=True)),
("ends_at", models.DateTimeField(null=True)),
("status", models.TextField(null=True)),
("summary", models.TextField(null=True)),
("instructions", models.TextField(null=True)),
("reward_value_url_param", models.TextField(null=True)),
("external_url", models.URLField(null=True)),
("about_url", models.URLField(null=True)),
("is_site_wide", models.BooleanField(null=True)),
("sub_goal", models.PositiveBigIntegerField(null=True)),
("minute_watched_goal", models.PositiveBigIntegerField(null=True)),
("image_url", models.URLField(null=True)),
(
"game",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="reward_campaigns",
to="core.game",
),
),
],
),
migrations.CreateModel(
name="Reward",
fields=[
("created_at", models.DateTimeField(auto_created=True, null=True)),
("twitch_id", models.TextField(primary_key=True, serialize=False)),
("modified_at", models.DateTimeField(auto_now=True, null=True)),
("name", models.TextField(null=True)),
("banner_image_url", models.URLField(null=True)),
("thumbnail_image_url", models.URLField(null=True)),
("earnable_until", models.DateTimeField(null=True)),
("redemption_instructions", models.TextField(null=True)),
("redemption_url", models.URLField(null=True)),
(
"campaign",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="rewards",
to="core.rewardcampaign",
),
),
],
),
migrations.CreateModel(
name="TimeBasedDrop",
fields=[
("created_at", models.DateTimeField(auto_created=True, null=True)),
("twitch_id", models.TextField(primary_key=True, serialize=False)),
("modified_at", models.DateTimeField(auto_now=True, null=True)),
("required_subs", models.PositiveBigIntegerField(null=True)),
("ends_at", models.DateTimeField(null=True)),
("name", models.TextField(default="Unknown", null=True)),
("required_minutes_watched", models.PositiveBigIntegerField(null=True)),
("starts_at", models.DateTimeField(null=True)),
(
"drop_campaign",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="drops",
to="core.dropcampaign",
),
),
],
),
migrations.CreateModel(
name="Benefit",
fields=[
("created_at", models.DateTimeField(auto_created=True, null=True)),
("twitch_id", models.TextField(primary_key=True, serialize=False)),
("modified_at", models.DateTimeField(auto_now=True, null=True)),
("twitch_created_at", models.DateTimeField(null=True)),
("entitlement_limit", models.PositiveBigIntegerField(null=True)),
("image_url", models.URLField(null=True)),
("is_ios_available", models.BooleanField(null=True)),
("name", models.TextField(null=True)),
(
"time_based_drop",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="benefits",
to="core.timebaseddrop",
),
),
],
),
migrations.CreateModel(
name="Webhook",
fields=[
("created_at", models.DateTimeField(auto_created=True, null=True)),
("id", models.TextField(primary_key=True, serialize=False)),
("avatar", models.TextField(null=True)),
("channel_id", models.TextField(null=True)),
("guild_id", models.TextField(null=True)),
("name", models.TextField(null=True)),
("type", models.TextField(null=True)),
("token", models.TextField()),
("url", models.TextField()),
("modified_at", models.DateTimeField(auto_now=True, null=True)),
("seen_drops", models.ManyToManyField(related_name="seen_drops", to="core.dropcampaign")),
(
"subscribed_live_games",
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,23 +0,0 @@
# Generated by Django 5.1 on 2024-09-02 23:28
from __future__ import annotations
from typing import TYPE_CHECKING
from django.db import migrations
if TYPE_CHECKING:
from django.db.migrations.operations.base import Operation
class Migration(migrations.Migration):
dependencies: list[tuple[str, str]] = [
("core", "0001_initial"),
]
operations: list[Operation] = [
migrations.RenameField(
model_name="dropcampaign",
old_name="id",
new_name="twitch_id",
),
]

View File

@ -1,23 +0,0 @@
# Generated by Django 5.1 on 2024-09-07 19:19
from __future__ import annotations
from typing import TYPE_CHECKING
from django.db import migrations
if TYPE_CHECKING:
from django.db.migrations.operations.base import Operation
class Migration(migrations.Migration):
dependencies: list[tuple[str, str]] = [
("core", "0002_rename_id_dropcampaign_twitch_id"),
]
operations: list[Operation] = [
migrations.RenameField(
model_name="rewardcampaign",
old_name="sub_goal",
new_name="subs_goal",
),
]

View File

@ -1,48 +0,0 @@
# Generated by Django 5.1 on 2024-09-09 02:34
from __future__ import annotations
from typing import TYPE_CHECKING
from django.db import migrations, models
if TYPE_CHECKING:
from django.db.migrations.operations.base import Operation
class Migration(migrations.Migration):
dependencies: list[tuple[str, str]] = [
("core", "0003_rename_sub_goal_rewardcampaign_subs_goal"),
]
operations: list[Operation] = [
migrations.AlterField(
model_name="dropcampaign",
name="name",
field=models.TextField(null=True),
),
migrations.AlterField(
model_name="game",
name="box_art_url",
field=models.URLField(null=True),
),
migrations.AlterField(
model_name="game",
name="game_url",
field=models.URLField(null=True),
),
migrations.AlterField(
model_name="game",
name="name",
field=models.TextField(null=True),
),
migrations.AlterField(
model_name="owner",
name="name",
field=models.TextField(null=True),
),
migrations.AlterField(
model_name="timebaseddrop",
name="name",
field=models.TextField(null=True),
),
]

View File

@ -1,38 +0,0 @@
# Generated by Django 5.1 on 2024-09-15 19:40
from __future__ import annotations
from typing import TYPE_CHECKING
from django.db import migrations
if TYPE_CHECKING:
from django.db.migrations.operations.base import Operation
class Migration(migrations.Migration):
dependencies: list[tuple[str, str]] = [
("core", "0004_alter_dropcampaign_name_alter_game_box_art_url_and_more"),
]
operations: list[Operation] = [
migrations.AlterModelOptions(
name="benefit",
options={"ordering": ["-twitch_created_at"]},
),
migrations.AlterModelOptions(
name="dropcampaign",
options={"ordering": ["ends_at"]},
),
migrations.AlterModelOptions(
name="reward",
options={"ordering": ["-earnable_until"]},
),
migrations.AlterModelOptions(
name="rewardcampaign",
options={"ordering": ["-starts_at"]},
),
migrations.AlterModelOptions(
name="timebaseddrop",
options={"ordering": ["required_minutes_watched"]},
),
]

View File

@ -1,50 +0,0 @@
# Generated by Django 5.1.1 on 2024-09-16 19:32
from __future__ import annotations
from typing import TYPE_CHECKING
from django.db import migrations, models
import core.models
if TYPE_CHECKING:
from django.db.migrations.operations.base import Operation
class Migration(migrations.Migration):
dependencies: list[tuple[str, str]] = [
("core", "0005_alter_benefit_options_alter_dropcampaign_options_and_more"),
]
operations: list[Operation] = [
migrations.AddField(
model_name="benefit",
name="image",
field=models.ImageField(null=True, upload_to=core.models.get_benefit_image_path),
),
migrations.AddField(
model_name="dropcampaign",
name="image",
field=models.ImageField(null=True, upload_to=core.models.get_drop_campaign_image_path),
),
migrations.AddField(
model_name="game",
name="image",
field=models.ImageField(null=True, upload_to=core.models.get_game_image_path),
),
migrations.AddField(
model_name="reward",
name="banner_image",
field=models.ImageField(null=True, upload_to=core.models.get_reward_banner_image_path),
),
migrations.AddField(
model_name="reward",
name="thumbnail_image",
field=models.ImageField(null=True, upload_to=core.models.get_reward_thumbnail_image_path),
),
migrations.AddField(
model_name="rewardcampaign",
name="image",
field=models.ImageField(null=True, upload_to=core.models.get_reward_image_path),
),
]

View File

@ -1,31 +0,0 @@
# Generated by Django 5.1.1 on 2024-09-21 00:08
from __future__ import annotations
from typing import TYPE_CHECKING
from django.db import migrations, models
if TYPE_CHECKING:
from django.db.migrations.operations.base import Operation
class Migration(migrations.Migration):
dependencies: list[tuple[str, str]] = [
("core", "0006_benefit_image_dropcampaign_image_game_image_and_more"),
]
operations: list[Operation] = [
migrations.AlterModelOptions(
name="game",
options={"ordering": ["name"]},
),
migrations.AlterModelOptions(
name="owner",
options={"ordering": ["name"]},
),
migrations.AlterField(
model_name="game",
name="slug",
field=models.TextField(null=True, unique=True),
),
]

View File

@ -1,28 +0,0 @@
# Generated by Django 5.1.1 on 2024-09-21 16:51
from __future__ import annotations
from typing import TYPE_CHECKING
from django.db import migrations, models
if TYPE_CHECKING:
from django.db.migrations.operations.base import Operation
class Migration(migrations.Migration):
dependencies: list[tuple[str, str]] = [
("core", "0007_alter_game_options_alter_owner_options_and_more"),
]
operations: list[Operation] = [
migrations.AddField(
model_name="dropcampaign",
name="scraped_json",
field=models.JSONField(help_text="The JSON data from the Twitch API.", null=True),
),
migrations.AddField(
model_name="rewardcampaign",
name="scraped_json",
field=models.JSONField(help_text="The JSON data from the Twitch API.", null=True),
),
]

View File

@ -1,50 +0,0 @@
# Generated by Django 5.1.1 on 2024-09-21 23:56
from __future__ import annotations
from typing import TYPE_CHECKING
import django.db.models.deletion
from django.db import migrations, models
if TYPE_CHECKING:
from django.db.migrations.operations.base import Operation
class Migration(migrations.Migration):
dependencies: list[tuple[str, str]] = [
("core", "0008_dropcampaign_scraped_json_and_more"),
]
operations: list[Operation] = [
migrations.CreateModel(
name="ScrapedJson",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("json_data", models.JSONField(help_text="The JSON data from the Twitch API.", unique=True)),
("created_at", models.DateTimeField(auto_now_add=True)),
],
options={
"ordering": ["-created_at"],
},
),
migrations.AlterField(
model_name="dropcampaign",
name="scraped_json",
field=models.ForeignKey(
help_text="Reference to the JSON data from the Twitch API.",
null=True,
on_delete=django.db.models.deletion.SET_NULL,
to="core.scrapedjson",
),
),
migrations.AlterField(
model_name="rewardcampaign",
name="scraped_json",
field=models.ForeignKey(
help_text="Reference to the JSON data from the Twitch API.",
null=True,
on_delete=django.db.models.deletion.SET_NULL,
to="core.scrapedjson",
),
),
]

File diff suppressed because it is too large Load Diff

107
core/models_utils.py Normal file
View File

@ -0,0 +1,107 @@
import logging
from datetime import datetime
from typing import Any
from django.db import models
logger: logging.Logger = logging.getLogger(__name__)
def wrong_typename(data: dict, expected: str) -> bool:
"""Check if the data is the expected type.
# TODO(TheLovinator): Double check this. # noqa: TD003
Type name examples:
- Game
- DropCampaign
- TimeBasedDrop
- DropBenefit
- RewardCampaign
- Reward
Args:
data (dict): The data to check.
expected (str): The expected type.
Returns:
bool: True if the data is not the expected type.
"""
is_unexpected_type: bool = data.get("__typename", "") != expected
if is_unexpected_type:
logger.error("Not a %s? %s", expected, data)
return is_unexpected_type
def update_field(instance: models.Model, django_field_name: str, new_value: str | datetime | None) -> int:
"""Update a field on an instance if the new value is different from the current value.
Args:
instance (models.Model): The Django model instance.
django_field_name (str): The name of the field to update.
new_value (str | datetime | None): The new value to update the field with.
Returns:
int: If the field was updated, returns 1. Otherwise, returns 0.
"""
# Get the current value of the field.
try:
current_value = getattr(instance, django_field_name)
except AttributeError:
logger.exception("Field %s does not exist on %s", django_field_name, instance)
return 0
# Only update the field if the new value is different from the current value.
if new_value and new_value != current_value:
setattr(instance, django_field_name, new_value)
return 1
# 0 fields updated.
return 0
def get_value(data: dict, key: str) -> datetime | str | None:
"""Get a value from a dictionary.
We have this function so we can handle values that we need to convert to a different type. For example, we might
need to convert a string to a datetime object.
Args:
data (dict): The dictionary to get the value from.
key (str): The key to get the value for.
Returns:
datetime | str | None: The value from the dictionary
"""
data_key: Any | None = data.get(key)
if not data_key:
return None
# Dates are in the format "2024-08-12T05:59:59.999Z"
dates: list[str] = ["endAt", "endsAt,", "startAt", "startsAt", "createdAt", "earnableUntil"]
if key in dates:
return datetime.fromisoformat(data_key.replace("Z", "+00:00"))
return data_key
def update_fields(instance: models.Model, data: dict, field_mapping: dict[str, str]) -> int:
"""Update multiple fields on an instance using a mapping from external field names to model field names.
Args:
instance (models.Model): The Django model instance.
data (dict): The new data to update the fields with.
field_mapping (dict[str, str]): A dictionary mapping external field names to model field names.
Returns:
int: The number of fields updated. Used for only saving the instance if there were changes.
"""
dirty = 0
for json_field, django_field_name in field_mapping.items():
data_key: datetime | str | None = get_value(data, json_field)
dirty += update_field(instance=instance, django_field_name=django_field_name, new_value=data_key)
if dirty > 0:
instance.save()
return dirty

View File

@ -1,13 +1,21 @@
import os
from pathlib import Path
from typing import Literal
import sentry_sdk
import django_stubs_ext
from django.contrib import messages
from dotenv import find_dotenv, load_dotenv
from dotenv import load_dotenv
from platformdirs import user_data_dir
load_dotenv(dotenv_path=find_dotenv(), verbose=True)
# Monkeypatching Django, so stubs will work for all generics,
# see: https://github.com/typeddjango/django-stubs
django_stubs_ext.monkeypatch()
# Parse a .env file and then load all the variables found as environment variables.
load_dotenv(verbose=True)
# Store data in %APPDATA%/TheLovinator/TTVDrops on Windows and ~/.config/TheLovinator/TTVDrops on Linux.
# Sqlite database and images will be stored here.
DATA_DIR = Path(
user_data_dir(
appname="TTVDrops",
@ -17,63 +25,89 @@ DATA_DIR = Path(
),
)
# Default to DEBUG=True if not set.
# Turn off with DEBUG=False in .env file or environment variable.
DEBUG: bool = os.getenv(key="DEBUG", default="True").lower() == "true"
if not DEBUG:
sentry_sdk.init(
dsn="https://35519536b56710e51cac49522b2cc29f@o4505228040339456.ingest.sentry.io/4506447308914688",
environment="Production",
send_default_pii=True,
traces_sample_rate=0.2,
profiles_sample_rate=0.2,
)
# The base directory of the project.
BASE_DIR: Path = Path(__file__).resolve().parent.parent
# A list of all the people who get code error notifications. When DEBUG=False and AdminEmailHandler is configured in
# LOGGING (done by default), Django emails these people the details of exceptions raised in the request/response cycle.
ADMINS: list[tuple[str, str]] = [("Joakim Hellsén", "tlovinator@gmail.com")]
# The full Python path of the WSGI application object that Django's built-in servers (e.g. runserver) will use.
WSGI_APPLICATION = "core.wsgi.application"
# A secret key for a particular Django installation. This is used to provide cryptographic signing,
# and should be set to a unique, unpredictable value.
SECRET_KEY: str = os.getenv("DJANGO_SECRET_KEY", default="")
TIME_ZONE = "Europe/Stockholm"
USE_TZ = True
LANGUAGE_CODE = "en-us"
DECIMAL_SEPARATOR = ","
THOUSAND_SEPARATOR = " "
# A string representing the full Python import path to your root URLconf
ROOT_URLCONF = "core.urls"
# URL to use when referring to static files located in STATIC_ROOT.
STATIC_URL = "static/"
# Default primary key field type to use for models that don't have a field with primary_key=True.
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
STATICFILES_DIRS: list[Path] = [BASE_DIR / "static"]
# This setting defines the additional locations the staticfiles app will traverse if the FileSystemFinder finder is
# enabled, e.g. if you use the "collectstatic" or "findstatic" management command or use the static file serving view.
STATICFILES_DIRS: list[Path] = [
BASE_DIR / "static",
]
# The absolute path to the directory where collectstatic will collect static files for deployment.
STATIC_ROOT: Path = BASE_DIR / "staticfiles"
STATIC_ROOT.mkdir(exist_ok=True)
STATIC_ROOT.mkdir(exist_ok=True) # Create the directory if it doesn't exist.
# URL that handles the media served from MEDIA_ROOT, used for managing stored files.
# It must end in a slash if set to a non-empty value.
MEDIA_URL = "/media/"
MEDIA_ROOT: Path = DATA_DIR / "media"
MEDIA_ROOT.mkdir(exist_ok=True)
AUTH_USER_MODEL = "core.User"
# Absolute filesystem path to the directory that will hold user-uploaded files.
MEDIA_ROOT: Path = DATA_DIR / "media"
MEDIA_ROOT.mkdir(exist_ok=True) # Create the directory if it doesn't exist.
# The model to use to represent a User.
# ! You cannot change the AUTH_USER_MODEL setting during the lifetime of a project
# ! (i.e. once you have made and migrated models that depend on it) without serious effort.
# ! It is intended to be set at the project start, and the model it refers to must be available
# ! in the first migration of the app that it lives in.
AUTH_USER_MODEL: Literal["core.User"] = "core.User"
if DEBUG:
INTERNAL_IPS: list[str] = ["127.0.0.1"]
# A list of IP addresses, as strings, that:
# - Allow the debug() context processor to add some variables to the template context.
# - Can use the admindocs bookmarklets even if not logged in as a staff user.
# - Are marked as “internal” (as opposed to “EXTERNAL”) in AdminEmailHandler emails.
# This is needed for the Django Debug Toolbar to work.
INTERNAL_IPS: list[str] = ["127.0.0.1", "192.168.1.129"]
if not DEBUG:
# List of strings representing the host/domain names that this Django site can serve
ALLOWED_HOSTS: list[str] = ["ttvdrops.lovinator.space", "localhost"]
# The host to use for sending email.
EMAIL_HOST = "smtp.gmail.com"
EMAIL_PORT = 587
EMAIL_USE_TLS = True
EMAIL_HOST_USER: str = os.getenv(key="EMAIL_HOST_USER", default="webmaster@localhost")
EMAIL_HOST_PASSWORD: str = os.getenv(key="EMAIL_HOST_PASSWORD", default="")
EMAIL_HOST_USER: str | None = os.getenv(key="EMAIL_HOST_USER", default=None)
EMAIL_HOST_PASSWORD: str | None = os.getenv(key="EMAIL_HOST_PASSWORD", default=None)
EMAIL_SUBJECT_PREFIX = "[TTVDrops] "
EMAIL_USE_LOCALTIME = True
EMAIL_TIMEOUT = 10
DEFAULT_FROM_EMAIL: str = os.getenv(
key="EMAIL_HOST_USER",
default="webmaster@localhost",
)
SERVER_EMAIL: str = os.getenv(key="EMAIL_HOST_USER", default="webmaster@localhost")
DEFAULT_FROM_EMAIL: str | None = os.getenv(key="EMAIL_HOST_USER", default=None)
SERVER_EMAIL: str | None = os.getenv(key="EMAIL_HOST_USER", default=None)
# Discord webhook URL for sending notifications.
DISCORD_WEBHOOK_URL: str = os.getenv(key="DISCORD_WEBHOOK_URL", default="")
# The list of all installed applications that Django knows about.
INSTALLED_APPS: list[str] = [
"core.apps.CoreConfig",
"whitenoise.runserver_nostatic",
"django.contrib.admin",
"django.contrib.auth",
"django.contrib.contenttypes",
@ -81,28 +115,35 @@ INSTALLED_APPS: list[str] = [
"django.contrib.messages",
"django.contrib.staticfiles",
"django.contrib.sites",
"simple_history",
"debug_toolbar",
]
# Middleware is a framework of hooks into Django's request/response processing.
# https://docs.djangoproject.com/en/dev/topics/http/middleware/
MIDDLEWARE: list[str] = [
"django.middleware.gzip.GZipMiddleware",
"debug_toolbar.middleware.DebugToolbarMiddleware",
"whitenoise.middleware.WhiteNoiseMiddleware",
"django.middleware.security.SecurityMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"simple_history.middleware.HistoryRequestMiddleware",
]
TEMPLATES = [
# Settings for the template engine.
TEMPLATES: list[dict[str, str | list[Path] | bool | dict[str, list[str] | list[tuple[str, list[str]]]]]] = [
{
# Use the Django template backend instead of Jinja2.
"BACKEND": "django.template.backends.django.DjangoTemplates",
# Directories where the engine should look for template source files, in search order.
"DIRS": [BASE_DIR / "templates"],
# Whether the engine should look for template source files inside installed applications.
"APP_DIRS": True,
# Extra parameters to pass to the template backend.
# https://docs.djangoproject.com/en/dev/topics/templates/#django.template.backends.django.DjangoTemplates
"OPTIONS": {
# Callables that are used to populate the context when a template is rendered with a request.
"context_processors": [
"django.contrib.auth.context_processors.auth",
"django.template.context_processors.i18n",
@ -110,46 +151,28 @@ TEMPLATES = [
"django.template.context_processors.request",
"django.contrib.messages.context_processors.messages",
],
"loaders": [
(
"django.template.loaders.cached.Loader",
[
"django.template.loaders.filesystem.Loader",
"django.template.loaders.app_directories.Loader",
],
),
],
},
},
]
# Don't cache templates in development
if DEBUG:
TEMPLATES[0]["OPTIONS"]["loaders"] = [
"django.template.loaders.filesystem.Loader",
"django.template.loaders.app_directories.Loader",
]
DATABASES = {
# TODO(TheLovinator): Run psycopg[c] in production.
# https://www.psycopg.org/psycopg3/docs/basic/install.html#local-installation
DATABASES: dict[str, dict[str, str | dict[str, bool]]] = {
"default": {
"ENGINE": "django.db.backends.sqlite3",
"NAME": DATA_DIR / "ttvdrops.sqlite3",
"ENGINE": "django.db.backends.postgresql",
"NAME": os.getenv(key="DB_NAME", default=""),
"USER": os.getenv(key="DB_USER", default=""),
"PASSWORD": os.getenv(key="DB_PASSWORD", default=""),
"HOST": os.getenv(key="DB_HOST", default=""),
"PORT": os.getenv(key="DB_PORT", default=""),
"OPTIONS": {
"init_command": "PRAGMA journal_mode=wal; PRAGMA synchronous=1; PRAGMA mmap_size=134217728; PRAGMA journal_size_limit=67108864; PRAGMA cache_size=2000;", # noqa: E501
"pool": True, # TODO(TheLovinator): Benchmark this. # noqa: TD003
},
},
}
STORAGES: dict[str, dict[str, str]] = {
"default": {
"BACKEND": "django.core.files.storage.FileSystemStorage",
},
"staticfiles": {
"BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage",
},
}
LOGGING = {
LOGGING: dict[str, int | bool | dict[str, dict[str, str | list[str] | bool]]] = {
"version": 1,
"disable_existing_loggers": False,
"handlers": {
@ -172,6 +195,7 @@ LOGGING = {
},
}
# Bootstrap alert classes for Django messages
MESSAGE_TAGS: dict[int, str] = {
messages.DEBUG: "alert-info",
messages.INFO: "alert-info",
@ -180,15 +204,15 @@ MESSAGE_TAGS: dict[int, str] = {
messages.ERROR: "alert-danger",
}
# CACHE_MIDDLEWARE_SECONDS = 60 * 60 * 24 # 1 day
# CACHES = {
# "default": {
# "BACKEND": "django.core.cache.backends.filebased.FileBasedCache",
# "LOCATION": DATA_DIR / "django_cache",
# },
# }
# The ID, as an integer, of the current site in the django_site database table.
# This is used so that application data can hook into specific sites and a
# single database can manage content for multiple sites.
SITE_ID = 1
# The URL or named URL pattern where requests are redirected after login when the LoginView doesn't
# get a next GET parameter. Defaults to /accounts/profile/.
LOGIN_REDIRECT_URL = "/"
ACCOUNT_LOGOUT_REDIRECT_URL = "/"
# The URL or named URL pattern where requests are redirected after logout if LogoutView doesn't have
# a next_page attribute.
LOGOUT_REDIRECT_URL = "/"

View File

@ -1,164 +0,0 @@
import logging
import os
from typing import TYPE_CHECKING
from discord_webhook import DiscordWebhook
from django.db.models.signals import post_save
from django.dispatch import receiver
from core.discord import convert_time_to_discord_timestamp
from core.models import DropCampaign, Game, Owner, User, Webhook
if TYPE_CHECKING:
import requests
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.
Webhook URL is read from .env file.
Args:
sender (User): The model we are sending the signal from.
instance (User): The instance of the model that was created.
created (bool): Whether the instance was created or updated.
**kwargs: Additional keyword arguments.
"""
if not created:
logger.debug("User '%s' was updated.", instance.username)
return
webhook_url: str | None = os.getenv("DISCORD_WEBHOOK_URL")
if not webhook_url:
logger.error("No webhook URL provided.")
return
webhook = DiscordWebhook(
url=webhook_url,
content=f"New user signed up: '{instance.username}'",
username="TTVDrops",
rate_limit_retry=True,
)
response: requests.Response = webhook.execute()
logger.debug(response)
def notify_users_of_new_drop(sender: DropCampaign, instance: DropCampaign, created: bool, **kwargs) -> None: # noqa: ANN003, ARG001, FBT001
"""Send message to all webhooks subscribed to new drops.
Args:
sender (DropCampaign): The model we are sending the signal from.
instance (DropCampaign): The instance of the model that was created.
created (bool): Whether the instance was created or updated.
**kwargs: Additional keyword arguments.
"""
if not created:
logger.debug("Drop campaign '%s' was updated.", instance.name)
return
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
# 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)

View File

@ -1,21 +1,22 @@
{% load static %}
<!DOCTYPE html>
<html lang="en" data-bs-theme="dark">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="description" content="Twitch drops">
<meta name="author" content="TheLovinator">
<meta name="keywords" content="Twitch, drops">
<meta name="robots" content="index, follow">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta charset="utf-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="description" content="Twitch drops" />
<meta name="author" content="TheLovinator" />
<meta name="keywords" content="Twitch, drops" />
<meta name="robots" content="index, follow" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Twitch drops</title>
<link rel="stylesheet" href="{% static 'css/bootstrap.min.css' %}">
<link rel="stylesheet" href="{% static 'css/style.css' %}">
<link rel="stylesheet" href="{% static 'css/bootstrap.min.css' %}" />
<link rel="stylesheet" href="{% static 'css/style.css' %}" />
</head>
<body data-bs-spy="scroll" data-bs-target=".toc" data-bs-offset="-200" tabindex="0">
<body data-bs-spy="scroll"
data-bs-target=".toc"
data-bs-offset="-200"
tabindex="0">
{% include "partials/alerts.html" %}
<article class="container mt-5">
{% include "partials/header.html" %}
@ -24,5 +25,4 @@
</article>
<script src="{% static 'js/bootstrap.min.js' %}"></script>
</body>
</html>

View File

@ -1,106 +1,141 @@
{% extends "base.html" %}
{% block content %}
<div class="container">
<h2>{{ game.name }}</h2>
<img src="{{ game.box_art_url }}" alt="{{ game.name }} box art" height="283" width="212">
<h3>Game Details</h3>
<table class="table table-hover table-sm table-striped" cellspacing="0">
<tr>
<td><strong>Twitch ID:</strong></td>
<td>{{ game.pk }}</td>
</tr>
<tr>
<td><strong>Game URL:</strong></td>
<td><a href="{{ game.game_url }}" target="_blank">{{ game.game_url }}</a></td>
</tr>
<tr>
<td><strong>Game name:</strong></td>
<td>{{ game.name }}</td>
</tr>
<tr>
<td><strong>Game box art URL:</strong></td>
<td><a href="{{ game.box_art_url }}" target="_blank">{{ game.box_art_url }}</a></td>
</tr>
</table>
<h3>Organization</h3>
<table class="table table-hover table-sm table-striped" cellspacing="0">
<tr>
{% if game.org %}
<td><a href="#">{{ game.org.name }} -
<span class="text-muted">{{ game.org.pk }}</span></a></td>
{% else %}
<td>No organization associated with this game.</td>
{% endif %}
</tr>
</table>
<h3>Drop Campaigns</h3>
{% if game.drop_campaigns.all %}
{% for drop_campaign in game.drop_campaigns.all %}
<br>
<h2>{{ drop_campaign.name }}</h2>
<table class="table table-hover table-sm table-striped" cellspacing="0">
<tr>
<td><strong>Campaign Name:</strong></td>
<td>{{ drop_campaign.name }}</td>
</tr>
<tr>
<td><img src="{{ drop_campaign.image_url }}" alt="{{ drop_campaign.name }} image"></td>
<td>
<p><strong>Created at:</strong>
{{ drop_campaign.created_at }}
</p>
<p><strong>Modified at:</strong>
{{ drop_campaign.modified_at }}
</p>
<p><strong>Status:</strong>
{{ drop_campaign.status }}
</p>
<p><strong>Description:</strong>
{{ drop_campaign.description }}
</p>
<p><strong>Starts at:</strong>
{{ drop_campaign.starts_at }}
</p>
<p><strong>Ends at:</strong>
{{ drop_campaign.ends_at }}
</p>
<p><strong>More details:</strong>
<a href="{{ drop_campaign.details_url }}" target="_blank">{{ drop_campaign.details_url }}</a>
</p>
<p><strong>Account Link:</strong>
<a href="{{ drop_campaign.account_link_url }}"
target="_blank">{{ drop_campaign.account_link_url }}</a>
</p>
</td>
</tr>
</table>
{% if drop_campaign.drops.all %}
<table class="table table-hover table-sm table-striped" cellspacing="0">
<tr>
<th>ID</th>
<th>Item Name</th>
<th>Minutes</th>
<th>Image</th>
<th>Benefit Name</th>
</tr>
{% for item in drop_campaign.drops.all %}
<tr>
<td>{{ item.pk }}</td>
<td>{{ item.name }}</td>
<td>{{ item.required_minutes_watched }}</td>
{% for benefit in item.benefits.all %}
<td><img src="{{ benefit.image_url }}" alt="{{ benefit.name }} reward image" height="50" width="50"></td>
<td>{{ benefit.name }}</td>
<div class="container">
<h2>{{ game.name }}</h2>
<img src="{{ game.box_art_url }}"
alt="{{ game.name }} box art"
height="283"
width="212" />
<h3>Game Details</h3>
<table class="table table-hover table-sm table-striped" cellspacing="0">
<tr>
<td>
<strong>Twitch ID:</strong>
</td>
<td>{{ game.pk }}</td>
</tr>
<tr>
<td>
<strong>Game URL:</strong>
</td>
<td>
<a href="{{ game.game_url }}" target="_blank">{{ game.game_url }}</a>
</td>
</tr>
<tr>
<td>
<strong>Game name:</strong>
</td>
<td>{{ game.name }}</td>
</tr>
<tr>
<td>
<strong>Game box art URL:</strong>
</td>
<td>
<a href="{{ game.box_art_url }}" target="_blank">{{ game.box_art_url }}</a>
</td>
</tr>
</table>
<h3>Organization</h3>
<table class="table table-hover table-sm table-striped" cellspacing="0">
<tr>
{% if game.org %}
<td>
<a href="#">{{ game.org.name }} -
<span class="text-muted">{{ game.org.pk }}</span></a>
</td>
{% else %}
<td>No organization associated with this game.</td>
{% endif %}
</tr>
</table>
<h3>Drop Campaigns</h3>
{% if game.drop_campaigns.all %}
{% for drop_campaign in game.drop_campaigns.all %}
<br />
<h2>{{ drop_campaign.name }}</h2>
<table class="table table-hover table-sm table-striped" cellspacing="0">
<tr>
<td>
<strong>Campaign Name:</strong>
</td>
<td>{{ drop_campaign.name }}</td>
</tr>
<tr>
<td>
<img src="{{ drop_campaign.image_url }}"
alt="{{ drop_campaign.name }} image" />
</td>
<td>
<p>
<strong>Created at:</strong>
{{ drop_campaign.created_at }}
</p>
<p>
<strong>Modified at:</strong>
{{ drop_campaign.modified_at }}
</p>
<p>
<strong>Status:</strong>
{{ drop_campaign.status }}
</p>
<p>
<strong>Description:</strong>
{{ drop_campaign.description }}
</p>
<p>
<strong>Starts at:</strong>
{{ drop_campaign.starts_at }}
</p>
<p>
<strong>Ends at:</strong>
{{ drop_campaign.ends_at }}
</p>
<p>
<strong>More details:</strong>
<a href="{{ drop_campaign.details_url }}" target="_blank">{{ drop_campaign.details_url }}</a>
</p>
<p>
<strong>Account Link:</strong>
<a href="{{ drop_campaign.account_link_url }}" target="_blank">{{ drop_campaign.account_link_url }}</a>
</p>
</td>
</tr>
</table>
{% if drop_campaign.drops.all %}
<table class="table table-hover table-sm table-striped" cellspacing="0">
<tr>
<th>ID</th>
<th>Item Name</th>
<th>Minutes</th>
<th>Image</th>
<th>Benefit Name</th>
</tr>
{% for item in drop_campaign.drops.all %}
<tr>
<td>{{ item.pk }}</td>
<td>{{ item.name }}</td>
<td>{{ item.required_minutes_watched }}</td>
{% for benefit in item.benefits.all %}
<td>
<img src="{{ benefit.image_url }}"
alt="{{ benefit.name }} reward image"
height="50"
width="50" />
</td>
<td>{{ benefit.name }}</td>
{% endfor %}
</tr>
{% endfor %}
</table>
{% else %}
<p>No items associated with this drop campaign.</p>
{% endif %}
{% endfor %}
</tr>
{% endfor %}
</table>
{% else %}
<p>No items associated with this drop campaign.</p>
{% endif %}
{% endfor %}
{% else %}
<p>No drop campaigns associated with this game.</p>
{% endif %}
</div>
{% else %}
<p>No drop campaigns associated with this game.</p>
{% endif %}
</div>
{% endblock content %}

View File

@ -1,34 +1,37 @@
{% extends "base.html" %}
{% block content %}
<div class="container mt-4">
{% for game in games %}
<div class="card mb-4 shadow-sm">
<div class="row g-0">
<div class="col-md-2">
<img src="{{ game.box_art_url }}" alt="{{ game.name }} box art" class="img-fluid rounded-start"
height="283" width="212" loading="lazy">
</div>
<div class="col-md-10">
<div class="card-body">
<h2 class="card-title h5">
<a href="https://www.twitch.tv/directory/category/{{ game.slug }}"
class="text-decoration-none">{{ game.name }}</a>
</h2>
<div>
<a href="{% url 'game' game.pk %}" class="text-decoration-none">See previous drops</a>
<div class="container mt-4">
{% for game in games %}
<div class="card mb-4 shadow-sm">
<div class="row g-0">
<div class="col-md-2">
<img src="{{ game.box_art_url }}"
alt="{{ game.name }} box art"
class="img-fluid rounded-start"
height="283"
width="212"
loading="lazy" />
</div>
<div>
<a href="" class="text-decoration-none">Subscribe to new drops</a>
<div class="col-md-10">
<div class="card-body">
<h2 class="card-title h5">
<a href="https://www.twitch.tv/directory/category/{{ game.slug }}"
class="text-decoration-none">{{ game.name }}</a>
</h2>
<div>
<a href="{% url 'game' game.pk %}" class="text-decoration-none">See previous drops</a>
</div>
<div>
<a href="" class="text-decoration-none">Subscribe to new drops</a>
</div>
<div>
<a href="" class="text-decoration-none">Subscribe to active drops</a>
</div>
</div>
</div>
<div>
<a href="" class="text-decoration-none">Subscribe to active drops</a>
</div>
</div>
</div>
</div>
{% endfor %}
</div>
{% endfor %}
</div>
{% endblock content %}

View File

@ -1,176 +1,176 @@
{% extends "base.html" %}
{% load static %}
{% load custom_filters %}
{% load time_filters %}
{% load custom_filters static time_filters %}
{% block content %}
<div class="container mt-4">
{% include "partials/info_box.html" %}
{% include "partials/news.html" %}
<!-- Reward Campaigns Section -->
<section class="reward-campaigns">
<h2>
Reward Campaigns -
<span class="d-inline text-muted">
{{ reward_campaigns.count }} campaign{{ reward_campaigns.count|pluralize }}
</span>
</h2>
<!-- Loop through reward campaigns -->
{% for campaign in reward_campaigns %}
<div class="card mb-4 shadow-sm" id="campaign-{{ campaign.twitch_id }}">
<div class="row g-0">
<!-- Campaign Image -->
<div class="col-md-2">
<img src="{{ campaign.image_url }}" alt="{{ campaign.name }}" class="img-fluid rounded-start"
height="283" width="212" loading="lazy">
</div>
<!-- Campaign Details -->
<div class="col-md-10">
<div class="card-body">
<h2 class="card-title h5">
<a href="#campaign-{{ campaign.twitch_id }}" class="plain-text-item">
{{ campaign.name }}
</a>
</h2>
<p class="card-text text-muted">{{ campaign.summary }}</p>
<p class="mb-2 text-muted">
Ends in:
<abbr
title="{{ campaign.starts_at|date:'l d F H:i %Z' }} - {{ campaign.ends_at|date:'l d F H:i %Z' }}">
{{ campaign.ends_at|timesince }}
</abbr>
</p>
<a href="{{ campaign.external_url }}" class="btn btn-primary" target="_blank">Learn More</a>
<!-- Instructions (if any) -->
{% if campaign.instructions %}
<div class="mt-3">
<h3 class="h6">Instructions</h3>
<p>{{ campaign.instructions|safe }}</p>
<div class="container mt-4">
{% include "partials/info_box.html" %}
{% include "partials/news.html" %}
<!-- Reward Campaigns Section -->
<section class="reward-campaigns">
<h2>
Reward Campaigns -
<span class="d-inline text-muted">{{ reward_campaigns.count }} campaign{{ reward_campaigns.count|pluralize }}</span>
</h2>
<!-- Loop through reward campaigns -->
{% for campaign in reward_campaigns %}
<div class="card mb-4 shadow-sm" id="campaign-{{ campaign.twitch_id }}">
<div class="row g-0">
<!-- Campaign Image -->
<div class="col-md-2">
<img src="{{ campaign.image_url }}"
alt="{{ campaign.name }}"
class="img-fluid rounded-start"
height="283"
width="212"
loading="lazy" />
</div>
{% endif %}
<!-- Rewards (if any) -->
{% if campaign.rewards.all %}
<div class="mt-3">
<h3 class="h6">Rewards</h3>
<div class="row row-cols-1 row-cols-md-2 row-cols-lg-3 g-2">
{% for reward in campaign.rewards.all %}
<div class="col d-flex align-items-center position-relative">
<img src="{{ reward.thumbnail_image_url }}" alt="{{ reward.name }} reward image"
class="img-fluid rounded me-3" height="50" width="50" loading="lazy">
<div><strong>{{ reward.name }}</strong></div>
</div>
<!-- Campaign Details -->
<div class="col-md-10">
<div class="card-body">
<h2 class="card-title h5">
<a href="#campaign-{{ campaign.twitch_id }}" class="plain-text-item">{{ campaign.name }}</a>
</h2>
<p class="card-text text-muted">{{ campaign.summary }}</p>
<p class="mb-2 text-muted">
Ends in:
<abbr title="{{ campaign.starts_at|date:'l d F H:i %Z' }} - {{ campaign.ends_at|date:'l d F H:i %Z' }}">
{{ campaign.ends_at|timesince }}
</abbr>
</p>
<a href="{{ campaign.external_url }}"
class="btn btn-primary"
target="_blank">Learn More</a>
<!-- Instructions (if any) -->
{% if campaign.instructions %}
<div class="mt-3">
<h3 class="h6">Instructions</h3>
<p>{{ campaign.instructions|safe }}</p>
</div>
{% endif %}
<!-- Rewards (if any) -->
{% if campaign.rewards.all %}
<div class="mt-3">
<h3 class="h6">Rewards</h3>
<div class="row row-cols-1 row-cols-md-2 row-cols-lg-3 g-2">
{% for reward in campaign.rewards.all %}
<div class="col d-flex align-items-center position-relative">
<img src="{{ reward.thumbnail_image_url }}"
alt="{{ reward.name }} reward image"
class="img-fluid rounded me-3"
height="50"
width="50"
loading="lazy" />
<div>
<strong>{{ reward.name }}</strong>
</div>
</div>
{% endfor %}
</div>
</div>
{% endif %}
</div>
</div>
</div>
</div>
{% endfor %}
</section>
<!-- Drop Campaigns Section -->
<section class="drop-campaigns">
<h2>
Drop Campaigns -
<span class="d-inline text-muted">{{ games.count }} game{{ games.count|pluralize }}</span>
</h2>
<!-- Loop through games -->
{% for game in games %}
<div class="card mb-4 shadow-sm">
<div class="row g-0">
<!-- Game Box Art -->
<div class="col-md-2">
<img src="{{ game.box_art_url|default:'https://static-cdn.jtvnw.net/ttv-static/404_boxart.jpg' }}"
alt="{{ game.name|default:'Game name unknown' }} box art"
class="img-fluid rounded-start"
height="283"
width="212"
loading="lazy" />
</div>
<!-- Game Details -->
<div class="col-md-10">
<div class="card-body">
<h2 class="card-title h5">
<a href="{% url 'game' game.twitch_id %}" class="text-decoration-none">{{ game.name|default:'Unknown' }}</a>
-
<a href="https://www.twitch.tv/directory/category/{{ game.slug|default:'game-name-unknown' }}"
class="text-decoration-none text-muted">Twitch</a>
</h2>
<!-- Loop through campaigns for each game -->
{% for campaign in game.drop_campaigns.all %}
<div class="mt-4">
<h4 class="h6">{{ campaign.name }}</h4>
<a href="{{ campaign.details_url }}" class="text-decoration-none">Details</a>
{% if campaign.details_url != campaign.account_link_url %}
| <a href="{{ campaign.account_link_url }}" class="text-decoration-none">Link Account</a>
{% endif %}
<p class="mb-2 text-muted">
Ends in:
<abbr title="{{ campaign.starts_at|date:'l d F H:i' }} - {{ campaign.ends_at|date:'l d F H:i' }}">
{{ campaign.ends_at|timeuntil }}
</abbr>
</p>
<!-- Drop Benefits Table -->
<div class="table-responsive">
<table class="table table-striped table-hover align-middle">
<thead>
<tr>
<th>Benefit Image</th>
<th>Benefit Name</th>
<th>Required Minutes Watched</th>
</tr>
</thead>
<tbody>
{% for drop in campaign.drops.all %}
{% if drop.benefits.exists %}
{% for benefit in drop.benefits.all %}
<tr>
<td>
<img src="{{ benefit.image_url|default:'https://static-cdn.jtvnw.net/ttv-static/404_boxart.jpg' }}"
alt="{{ benefit.name|default:'Unknown' }}"
class="img-fluid rounded"
height="50"
width="50"
loading="lazy" />
</td>
<td>
<abbr title="{{ drop.name|default:'Unknown' }}">
{{ benefit.name|default:'Unknown' }}
</abbr>
</td>
<td>{{ drop.required_minutes_watched|minutes_to_hours }}</td>
</tr>
{% endfor %}
{% else %}
<tr>
<td>
<img src="https://static-cdn.jtvnw.net/ttv-static/404_boxart.jpg"
alt="{{ drop.name|default:'Unknown' }}"
class="img-fluid rounded"
height="50"
width="50"
loading="lazy" />
</td>
<td>{{ drop.name|default:'Unknown' }}</td>
<td>N/A</td>
</tr>
{% endif %}
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endfor %}
</div>
</div>
{% endif %}
</div>
</div>
</div>
</div>
{% endfor %}
</section>
<!-- Drop Campaigns Section -->
<section class="drop-campaigns">
<h2>
Drop Campaigns -
<span class="d-inline text-muted">{{ games.count }} game{{ games.count|pluralize }}</span>
</h2>
<!-- Loop through games -->
{% for game in games %}
<div class="card mb-4 shadow-sm">
<div class="row g-0">
<!-- Game Box Art -->
<div class="col-md-2">
<img src="{{ game.box_art_url|default:'https://static-cdn.jtvnw.net/ttv-static/404_boxart.jpg' }}"
alt="{{ game.name|default:'Game name unknown' }} box art" class="img-fluid rounded-start"
height="283" width="212" loading="lazy">
</div>
<!-- Game Details -->
<div class="col-md-10">
<div class="card-body">
<h2 class="card-title h5">
<a href="{% url 'game' game.twitch_id %}" class="text-decoration-none">
{{ game.name|default:'Unknown' }}
</a>
-
<a href="https://www.twitch.tv/directory/category/{{ game.slug|default:'game-name-unknown' }}"
class="text-decoration-none text-muted">Twitch</a>
</h2>
<!-- Loop through campaigns for each game -->
{% for campaign in game.drop_campaigns.all %}
<div class="mt-4">
<h4 class="h6">{{ campaign.name }}</h4>
<a href="{{ campaign.details_url }}" class="text-decoration-none">Details</a>
{% if campaign.details_url != campaign.account_link_url %}
| <a href="{{ campaign.account_link_url }}" class="text-decoration-none">Link Account</a>
{% endif %}
<p class="mb-2 text-muted">
Ends in:
<abbr
title="{{ campaign.starts_at|date:'l d F H:i' }} - {{ campaign.ends_at|date:'l d F H:i' }}">
{{ campaign.ends_at|timeuntil }}
</abbr>
</p>
<!-- Drop Benefits Table -->
<div class="table-responsive">
<table class="table table-striped table-hover align-middle">
<thead>
<tr>
<th>Benefit Image</th>
<th>Benefit Name</th>
<th>Required Minutes Watched</th>
</tr>
</thead>
<tbody>
{% for drop in campaign.drops.all %}
{% if drop.benefits.exists %}
{% for benefit in drop.benefits.all %}
<tr>
<td>
<img src="{{ benefit.image_url|default:'https://static-cdn.jtvnw.net/ttv-static/404_boxart.jpg' }}"
alt="{{ benefit.name|default:'Unknown' }}" class="img-fluid rounded"
height="50" width="50" loading="lazy">
</td>
<td>
<abbr title="{{ drop.name|default:'Unknown' }}">
{{ benefit.name|default:'Unknown' }}
</abbr>
</td>
<td>{{ drop.required_minutes_watched|minutes_to_hours }}</td>
</tr>
{% endfor %}
{% else %}
<tr>
<td>
<img src="https://static-cdn.jtvnw.net/ttv-static/404_boxart.jpg"
alt="{{ drop.name|default:'Unknown' }}" class="img-fluid rounded"
height="50" width="50" loading="lazy">
</td>
<td>{{ drop.name|default:'Unknown' }}</td>
<td>N/A</td>
</tr>
{% endif %}
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endfor %}
</div>
</div>
</div>
</div>
{% endfor %}
</section>
</div>
{% endfor %}
</section>
</div>
{% endblock content %}

View File

@ -1,6 +1,10 @@
{% for message in messages %}
<div class="alert alert-dismissible {{ message.tags }} fade show" role="alert">
<div>{{ message | safe }}</div>
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
<div class="alert alert-dismissible {{ message.tags }} fade show"
role="alert">
<div>{{ message|safe }}</div>
<button type="button"
class="btn-close"
data-bs-dismiss="alert"
aria-label="Close"></button>
</div>
{% endfor %}

View File

@ -1,50 +1,53 @@
{% if webhooks %}
<div class="card mb-4 shadow-sm" id="info-box">
<div class="row g-0">
<div class="col-md-10">
<div class="card-body">
<h2 class="card-title h2">Site news</h2>
<div class="mt-auto">
{% for webhook in webhooks %}
<div class="mt-3">
<img src="{{ webhook.avatar }}?size=32" alt="{{ webhook.name }}" class="rounded-circle"
height="32" width="32">
<a href="{{ webhook.url }}" target="_blank">{{ webhook.name }}</a>
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="new-drop-switch-daily">
<label class="form-check-label" for="new-drop-switch-daily">Daily notification of newly
added games to TTVdrops</label>
</div>
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="new-drop-switch-weekly">
<label class="form-check-label" for="new-drop-switch-weekly">
Weekly notification of newly added games to TTVdrops
</label>
</div>
<br>
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="new-org-switch-daily">
<label class="form-check-label" for="new-org-switch-daily">
Daily notification of newly added <abbr
title="Organizations are the companies that own the games.">organizations</abbr> to
TTVdrops
</label>
</div>
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="new-org-switch-weekly">
<label class="form-check-label" for="new-org-switch-weekly">
Weekly notification of newly added <abbr
title="Organizations are the companies that own the games.">organizations</abbr> to
TTVdrops
</label>
</div>
<div class="card mb-4 shadow-sm" id="info-box">
<div class="row g-0">
<div class="col-md-10">
<div class="card-body">
<h2 class="card-title h2">Site news</h2>
<div class="mt-auto">
{% for webhook in webhooks %}
<div class="mt-3">
<img src="{{ webhook.avatar }}?size=32"
alt="{{ webhook.name }}"
class="rounded-circle"
height="32"
width="32" />
<a href="{{ webhook.url }}" target="_blank">{{ webhook.name }}</a>
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="new-drop-switch-daily" />
<label class="form-check-label" for="new-drop-switch-daily">
Daily notification of newly
added games to TTVdrops
</label>
</div>
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="new-drop-switch-weekly" />
<label class="form-check-label" for="new-drop-switch-weekly">
Weekly notification of newly added games to TTVdrops
</label>
</div>
<br />
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="new-org-switch-daily" />
<label class="form-check-label" for="new-org-switch-daily">
Daily notification of newly added <abbr title="Organizations are the companies that own the games.">organizations</abbr> to
TTVdrops
</label>
</div>
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="new-org-switch-weekly" />
<label class="form-check-label" for="new-org-switch-weekly">
Weekly notification of newly added <abbr title="Organizations are the companies that own the games.">organizations</abbr> to
TTVdrops
</label>
</div>
</div>
{% endfor %}
</div>
{% endfor %}
</div>
</div>
</div>
</div>
</div>
{% else %}
<p class="text-muted">No webhooks added yet.</p>
<p class="text-muted">No webhooks added yet.</p>
{% endif %}

View File

@ -1,14 +1,12 @@
{% extends "base.html" %}
{% load static %}
{% load campaign_tags %}
{% load game_tags %}
{% load campaign_tags game_tags static %}
{% block content %}
<div class="container mt-4">
<div class="row">
<h2>Reward Campaigns</h2>
{% for campaign in reward_campaigns %}
{% render_campaign campaign %}
{% endfor %}
<div class="container mt-4">
<div class="row">
<h2>Reward Campaigns</h2>
{% for campaign in reward_campaigns %}
{% render_campaign campaign %}
{% endfor %}
</div>
</div>
</div>
{% endblock content %}

View File

@ -1,42 +0,0 @@
{% extends "base.html" %}
{% block content %}
<div class="container">
<h1 class="my-4">Add Discord Webhook</h1>
<div class="card card-body mb-3">
Webhooks will be saved in a cookie and will be sent to the server when you subscribe to a drop.
</div>
<div>
<form method="post" class="needs-validation" novalidate>
{% csrf_token %}
{{ form.non_field_errors }}
<div class="mb-3">
{{ form.webhook_url.errors }}
<label for="{{ form.webhook_url.id_for_label }}" class="form-label">{{ form.webhook_url.label }}</label>
<input type="url" name="webhook_url" required="" class="form-control"
aria-describedby="id_webhook_url_helptext" id="id_webhook_url">
<div class="form-text text-muted">{{ form.webhook_url.help_text }}</div>
</div>
<button type="submit" class="btn btn-primary">Add Webhook</button>
</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 %}

View File

@ -22,6 +22,4 @@ def minutes_to_hours(minutes: int | None) -> str:
if remaining_minutes > 0:
return f"{hours}h {remaining_minutes}m"
return f"{hours}h"
if remaining_minutes > 0:
return f"{remaining_minutes}m"
return "0m"
return f"{remaining_minutes}m" if remaining_minutes > 0 else "0m"

View File

@ -1,16 +1,21 @@
"""Tests for the views in the core app."""
from typing import TYPE_CHECKING
import pytest
from django.http import HttpResponse
from django.test import Client
from django.urls import reverse
if TYPE_CHECKING:
from django.http import HttpResponse
from django.test.client import _MonkeyPatchedWSGIResponse # type: ignore[import]
@pytest.mark.django_db
def test_index_view(client: Client) -> None:
"""Test index view."""
url: str = reverse(viewname="core:index")
response: HttpResponse = client.get(url)
response: _MonkeyPatchedWSGIResponse = client.get(url)
assert isinstance(response, HttpResponse)
assert response.status_code == 200

View File

@ -1,17 +1,16 @@
from __future__ import annotations
from debug_toolbar.toolbar import debug_toolbar_urls
from debug_toolbar.toolbar import debug_toolbar_urls # type: ignore[import-untyped]
from django.contrib import admin
from django.urls import URLPattern, URLResolver, path
from core.views import WebhooksView, game_view, games_view, index, reward_campaign_view
from core.views import game_view, games_view, index, reward_campaign_view
app_name: str = "core"
urlpatterns: list[URLPattern | URLResolver] = [
path(route="admin/", view=admin.site.urls),
path(route="", view=index, name="index"),
path(route="webhooks/", view=WebhooksView.as_view(), name="webhooks"),
path(route="game/<int:twitch_id>/", view=game_view, name="game"),
path(route="games/", view=games_view, name="games"),
path(route="reward_campaigns/", view=reward_campaign_view, name="reward_campaigns"),

View File

@ -3,48 +3,45 @@ from __future__ import annotations
import logging
from typing import TYPE_CHECKING, Any
import requests_cache
from django.db.models import F, Prefetch
from django.db.models.manager import BaseManager
from django.http import HttpRequest, HttpResponse
from django.template.response import TemplateResponse
from django.utils import timezone
from django.views import View
from core.models import Benefit, DropCampaign, Game, RewardCampaign, TimeBasedDrop, Webhook
from core.models import Benefit, DropCampaign, Game, RewardCampaign, TimeBasedDrop
if TYPE_CHECKING:
from django.db.models.manager import BaseManager
from django.db.models.query import QuerySet
from django.http import HttpRequest
logger: logging.Logger = logging.getLogger(__name__)
def get_reward_campaigns() -> BaseManager[RewardCampaign]:
def get_reward_campaigns() -> QuerySet[RewardCampaign]:
"""Get the reward campaigns.
Returns:
BaseManager[RewardCampaign]: The reward campaigns.
QuerySet[RewardCampaign]: The reward campaigns.
"""
return RewardCampaign.objects.all().prefetch_related("rewards").order_by("-created_at")
def get_games_with_drops() -> BaseManager[Game]:
def get_games_with_drops() -> QuerySet[Game]:
"""Get the games with drops, sorted by when the drop campaigns end.
Returns:
BaseManager[Game]: The games with drops.
QuerySet[Game]: The games with drops.
"""
# Prefetch the benefits for the time-based drops.
benefits_prefetch = Prefetch(lookup="benefits", queryset=Benefit.objects.all())
active_time_based_drops: BaseManager[TimeBasedDrop] = TimeBasedDrop.objects.filter(
active_time_based_drops: QuerySet[TimeBasedDrop] = TimeBasedDrop.objects.filter(
ends_at__gte=timezone.now(),
starts_at__lte=timezone.now(),
).prefetch_related(benefits_prefetch)
# Prefetch the active time-based drops for the drop campaigns.
drops_prefetch = Prefetch(lookup="drops", queryset=active_time_based_drops)
active_campaigns: BaseManager[DropCampaign] = DropCampaign.objects.filter(
active_campaigns: QuerySet[DropCampaign] = DropCampaign.objects.filter(
ends_at__gte=timezone.now(),
starts_at__lte=timezone.now(),
).prefetch_related(drops_prefetch)
@ -69,8 +66,8 @@ def index(request: HttpRequest) -> HttpResponse:
HttpResponse: The response object
"""
try:
reward_campaigns: BaseManager[RewardCampaign] = get_reward_campaigns()
games: BaseManager[Game] = get_games_with_drops()
reward_campaigns: QuerySet[RewardCampaign] = get_reward_campaigns()
games: QuerySet[Game] = get_games_with_drops()
except Exception:
logger.exception("Error fetching reward campaigns or games.")
@ -107,9 +104,9 @@ def game_view(request: HttpRequest, twitch_id: int) -> HttpResponse:
)
except Game.DoesNotExist:
return HttpResponse(status=404)
return HttpResponse(status=404, content="Game not found.")
except Game.MultipleObjectsReturned:
return HttpResponse(status=500)
return HttpResponse(status=500, content="Multiple games found with the same Twitch ID.")
context: dict[str, Any] = {"game": game}
return TemplateResponse(request=request, template="game.html", context=context)
@ -124,9 +121,9 @@ def games_view(request: HttpRequest) -> HttpResponse:
Returns:
HttpResponse: The response object.
"""
games: BaseManager[Game] = Game.objects.all()
games: QuerySet[Game] = Game.objects.all()
context: dict[str, BaseManager[Game] | str] = {"games": games}
context: dict[str, QuerySet[Game] | str] = {"games": games}
return TemplateResponse(request=request, template="games.html", context=context)
@ -139,132 +136,6 @@ def reward_campaign_view(request: HttpRequest) -> HttpResponse:
Returns:
HttpResponse: The response object.
"""
reward_campaigns: BaseManager[RewardCampaign] = RewardCampaign.objects.all()
context: dict[str, BaseManager[RewardCampaign]] = {"reward_campaigns": reward_campaigns}
reward_campaigns: QuerySet[RewardCampaign] = RewardCampaign.objects.all()
context: dict[str, QuerySet[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)

19
manage.py Normal file → Executable file
View File

@ -4,19 +4,20 @@
import os
import sys
try:
from django.core.management import execute_from_command_line
except ImportError as exc:
msg = (
"Couldn't import Django. Are you sure it's installed and "
"available on your PYTHONPATH environment variable? Did you "
"forget to activate a virtual environment?"
)
raise ImportError(msg) from exc
def main() -> None:
"""Run administrative tasks."""
os.environ.setdefault(key="DJANGO_SETTINGS_MODULE", value="core.settings")
try:
from django.core.management import execute_from_command_line # noqa: PLC0415
except ImportError as exc:
msg = (
"Couldn't import Django. Are you sure it's installed and "
"available on your PYTHONPATH environment variable? Did you "
"forget to activate a virtual environment?"
)
raise ImportError(msg) from exc # noqa: DOC501, RUF100
execute_from_command_line(sys.argv)