The comeback
This commit is contained in:
22
.vscode/launch.json
vendored
22
.vscode/launch.json
vendored
@ -12,28 +12,6 @@
|
|||||||
],
|
],
|
||||||
"django": true,
|
"django": true,
|
||||||
"justMyCode": 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
28
.vscode/settings.json
vendored
@ -3,30 +3,56 @@
|
|||||||
"adownload",
|
"adownload",
|
||||||
"aimport",
|
"aimport",
|
||||||
"allauth",
|
"allauth",
|
||||||
|
"appauthor",
|
||||||
"appendonly",
|
"appendonly",
|
||||||
|
"appname",
|
||||||
"asgiref",
|
"asgiref",
|
||||||
"Behaviour",
|
"Behaviour",
|
||||||
"cacd",
|
"cacd",
|
||||||
|
"cellspacing",
|
||||||
|
"collectstatic",
|
||||||
|
"djade",
|
||||||
|
"docstrings",
|
||||||
|
"dotenv",
|
||||||
"dropcampaign",
|
"dropcampaign",
|
||||||
"dungeonborne",
|
"dungeonborne",
|
||||||
|
"endfor",
|
||||||
"forloop",
|
"forloop",
|
||||||
|
"Hellsén",
|
||||||
|
"isort",
|
||||||
|
"Joakim",
|
||||||
"logdir",
|
"logdir",
|
||||||
|
"Lovinator",
|
||||||
"lvthalo",
|
"lvthalo",
|
||||||
"memlock",
|
"memlock",
|
||||||
|
"Monkeypatching",
|
||||||
|
"mypy",
|
||||||
"networkidle",
|
"networkidle",
|
||||||
|
"nostatic",
|
||||||
"PGID",
|
"PGID",
|
||||||
|
"platformdirs",
|
||||||
|
"psycopg",
|
||||||
"PUID",
|
"PUID",
|
||||||
|
"pydocstyle",
|
||||||
|
"pyupgrade",
|
||||||
"requirepass",
|
"requirepass",
|
||||||
"rewardcampaign",
|
"rewardcampaign",
|
||||||
"sitewide",
|
"sitewide",
|
||||||
"socialaccount",
|
"socialaccount",
|
||||||
|
"staticfiles",
|
||||||
"Stresss",
|
"Stresss",
|
||||||
|
"stylesheet",
|
||||||
|
"tabindex",
|
||||||
"templatetags",
|
"templatetags",
|
||||||
"timebaseddrop",
|
"timebaseddrop",
|
||||||
|
"timesince",
|
||||||
|
"timeuntil",
|
||||||
"tocs",
|
"tocs",
|
||||||
"ttvdrops",
|
"ttvdrops",
|
||||||
"ulimits",
|
"ulimits",
|
||||||
"Valair",
|
"Valair",
|
||||||
|
"whitenoise",
|
||||||
"xdefiant"
|
"xdefiant"
|
||||||
]
|
],
|
||||||
|
"python.analysis.typeCheckingMode": "basic",
|
||||||
}
|
}
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
from django.contrib import admin
|
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(Game)
|
||||||
admin.site.register(Owner)
|
admin.site.register(Owner)
|
||||||
|
10
core/apps.py
10
core/apps.py
@ -2,13 +2,7 @@ from django.apps import AppConfig
|
|||||||
|
|
||||||
|
|
||||||
class CoreConfig(AppConfig):
|
class CoreConfig(AppConfig):
|
||||||
|
"""Core app configuration."""
|
||||||
|
|
||||||
default_auto_field: str = "django.db.models.BigAutoField"
|
default_auto_field: str = "django.db.models.BigAutoField"
|
||||||
name = "core"
|
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
|
|
||||||
|
54
core/data.py
54
core/data.py
@ -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
|
|
@ -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"
|
|
@ -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
|
|
||||||
)
|
|
@ -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()
|
|
@ -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()
|
|
@ -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")},
|
|
||||||
},
|
|
||||||
),
|
|
||||||
]
|
|
@ -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",
|
|
||||||
),
|
|
||||||
]
|
|
@ -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",
|
|
||||||
),
|
|
||||||
]
|
|
@ -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),
|
|
||||||
),
|
|
||||||
]
|
|
@ -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"]},
|
|
||||||
),
|
|
||||||
]
|
|
@ -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),
|
|
||||||
),
|
|
||||||
]
|
|
@ -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),
|
|
||||||
),
|
|
||||||
]
|
|
@ -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),
|
|
||||||
),
|
|
||||||
]
|
|
@ -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",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
]
|
|
721
core/models.py
721
core/models.py
File diff suppressed because it is too large
Load Diff
107
core/models_utils.py
Normal file
107
core/models_utils.py
Normal 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
|
170
core/settings.py
170
core/settings.py
@ -1,13 +1,21 @@
|
|||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
import sentry_sdk
|
import django_stubs_ext
|
||||||
from django.contrib import messages
|
from django.contrib import messages
|
||||||
from dotenv import find_dotenv, load_dotenv
|
from dotenv import load_dotenv
|
||||||
from platformdirs import user_data_dir
|
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(
|
DATA_DIR = Path(
|
||||||
user_data_dir(
|
user_data_dir(
|
||||||
appname="TTVDrops",
|
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"
|
DEBUG: bool = os.getenv(key="DEBUG", default="True").lower() == "true"
|
||||||
|
|
||||||
if not DEBUG:
|
# The base directory of the project.
|
||||||
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,
|
|
||||||
)
|
|
||||||
|
|
||||||
BASE_DIR: Path = Path(__file__).resolve().parent.parent
|
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")]
|
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"
|
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="")
|
SECRET_KEY: str = os.getenv("DJANGO_SECRET_KEY", default="")
|
||||||
TIME_ZONE = "Europe/Stockholm"
|
|
||||||
USE_TZ = True
|
# A string representing the full Python import path to your root URLconf
|
||||||
LANGUAGE_CODE = "en-us"
|
|
||||||
DECIMAL_SEPARATOR = ","
|
|
||||||
THOUSAND_SEPARATOR = " "
|
|
||||||
ROOT_URLCONF = "core.urls"
|
ROOT_URLCONF = "core.urls"
|
||||||
|
|
||||||
|
# URL to use when referring to static files located in STATIC_ROOT.
|
||||||
STATIC_URL = "static/"
|
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"
|
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: 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_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:
|
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:
|
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"]
|
ALLOWED_HOSTS: list[str] = ["ttvdrops.lovinator.space", "localhost"]
|
||||||
|
|
||||||
|
# The host to use for sending email.
|
||||||
EMAIL_HOST = "smtp.gmail.com"
|
EMAIL_HOST = "smtp.gmail.com"
|
||||||
EMAIL_PORT = 587
|
EMAIL_PORT = 587
|
||||||
EMAIL_USE_TLS = True
|
EMAIL_USE_TLS = True
|
||||||
EMAIL_HOST_USER: str = os.getenv(key="EMAIL_HOST_USER", default="webmaster@localhost")
|
EMAIL_HOST_USER: str | None = os.getenv(key="EMAIL_HOST_USER", default=None)
|
||||||
EMAIL_HOST_PASSWORD: str = os.getenv(key="EMAIL_HOST_PASSWORD", default="")
|
EMAIL_HOST_PASSWORD: str | None = os.getenv(key="EMAIL_HOST_PASSWORD", default=None)
|
||||||
EMAIL_SUBJECT_PREFIX = "[TTVDrops] "
|
EMAIL_SUBJECT_PREFIX = "[TTVDrops] "
|
||||||
EMAIL_USE_LOCALTIME = True
|
EMAIL_USE_LOCALTIME = True
|
||||||
EMAIL_TIMEOUT = 10
|
EMAIL_TIMEOUT = 10
|
||||||
DEFAULT_FROM_EMAIL: str = os.getenv(
|
DEFAULT_FROM_EMAIL: str | None = os.getenv(key="EMAIL_HOST_USER", default=None)
|
||||||
key="EMAIL_HOST_USER",
|
SERVER_EMAIL: str | None = os.getenv(key="EMAIL_HOST_USER", default=None)
|
||||||
default="webmaster@localhost",
|
|
||||||
)
|
# Discord webhook URL for sending notifications.
|
||||||
SERVER_EMAIL: str = os.getenv(key="EMAIL_HOST_USER", default="webmaster@localhost")
|
|
||||||
DISCORD_WEBHOOK_URL: str = os.getenv(key="DISCORD_WEBHOOK_URL", default="")
|
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] = [
|
INSTALLED_APPS: list[str] = [
|
||||||
"core.apps.CoreConfig",
|
"core.apps.CoreConfig",
|
||||||
"whitenoise.runserver_nostatic",
|
|
||||||
"django.contrib.admin",
|
"django.contrib.admin",
|
||||||
"django.contrib.auth",
|
"django.contrib.auth",
|
||||||
"django.contrib.contenttypes",
|
"django.contrib.contenttypes",
|
||||||
@ -81,28 +115,35 @@ INSTALLED_APPS: list[str] = [
|
|||||||
"django.contrib.messages",
|
"django.contrib.messages",
|
||||||
"django.contrib.staticfiles",
|
"django.contrib.staticfiles",
|
||||||
"django.contrib.sites",
|
"django.contrib.sites",
|
||||||
"simple_history",
|
|
||||||
"debug_toolbar",
|
"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] = [
|
MIDDLEWARE: list[str] = [
|
||||||
"django.middleware.gzip.GZipMiddleware",
|
"django.middleware.gzip.GZipMiddleware",
|
||||||
"debug_toolbar.middleware.DebugToolbarMiddleware",
|
"debug_toolbar.middleware.DebugToolbarMiddleware",
|
||||||
"whitenoise.middleware.WhiteNoiseMiddleware",
|
|
||||||
"django.middleware.security.SecurityMiddleware",
|
"django.middleware.security.SecurityMiddleware",
|
||||||
"django.contrib.sessions.middleware.SessionMiddleware",
|
"django.contrib.sessions.middleware.SessionMiddleware",
|
||||||
"django.middleware.common.CommonMiddleware",
|
"django.middleware.common.CommonMiddleware",
|
||||||
"django.middleware.csrf.CsrfViewMiddleware",
|
"django.middleware.csrf.CsrfViewMiddleware",
|
||||||
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
||||||
"django.contrib.messages.middleware.MessageMiddleware",
|
"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",
|
"BACKEND": "django.template.backends.django.DjangoTemplates",
|
||||||
|
# Directories where the engine should look for template source files, in search order.
|
||||||
"DIRS": [BASE_DIR / "templates"],
|
"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": {
|
"OPTIONS": {
|
||||||
|
# Callables that are used to populate the context when a template is rendered with a request.
|
||||||
"context_processors": [
|
"context_processors": [
|
||||||
"django.contrib.auth.context_processors.auth",
|
"django.contrib.auth.context_processors.auth",
|
||||||
"django.template.context_processors.i18n",
|
"django.template.context_processors.i18n",
|
||||||
@ -110,46 +151,28 @@ TEMPLATES = [
|
|||||||
"django.template.context_processors.request",
|
"django.template.context_processors.request",
|
||||||
"django.contrib.messages.context_processors.messages",
|
"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
|
# TODO(TheLovinator): Run psycopg[c] in production.
|
||||||
if DEBUG:
|
# https://www.psycopg.org/psycopg3/docs/basic/install.html#local-installation
|
||||||
TEMPLATES[0]["OPTIONS"]["loaders"] = [
|
DATABASES: dict[str, dict[str, str | dict[str, bool]]] = {
|
||||||
"django.template.loaders.filesystem.Loader",
|
|
||||||
"django.template.loaders.app_directories.Loader",
|
|
||||||
]
|
|
||||||
|
|
||||||
DATABASES = {
|
|
||||||
"default": {
|
"default": {
|
||||||
"ENGINE": "django.db.backends.sqlite3",
|
"ENGINE": "django.db.backends.postgresql",
|
||||||
"NAME": DATA_DIR / "ttvdrops.sqlite3",
|
"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": {
|
"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,
|
"version": 1,
|
||||||
"disable_existing_loggers": False,
|
"disable_existing_loggers": False,
|
||||||
"handlers": {
|
"handlers": {
|
||||||
@ -172,6 +195,7 @@ LOGGING = {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Bootstrap alert classes for Django messages
|
||||||
MESSAGE_TAGS: dict[int, str] = {
|
MESSAGE_TAGS: dict[int, str] = {
|
||||||
messages.DEBUG: "alert-info",
|
messages.DEBUG: "alert-info",
|
||||||
messages.INFO: "alert-info",
|
messages.INFO: "alert-info",
|
||||||
@ -180,15 +204,15 @@ MESSAGE_TAGS: dict[int, str] = {
|
|||||||
messages.ERROR: "alert-danger",
|
messages.ERROR: "alert-danger",
|
||||||
}
|
}
|
||||||
|
|
||||||
# CACHE_MIDDLEWARE_SECONDS = 60 * 60 * 24 # 1 day
|
# The ID, as an integer, of the current site in the django_site database table.
|
||||||
# CACHES = {
|
# This is used so that application data can hook into specific sites and a
|
||||||
# "default": {
|
# single database can manage content for multiple sites.
|
||||||
# "BACKEND": "django.core.cache.backends.filebased.FileBasedCache",
|
|
||||||
# "LOCATION": DATA_DIR / "django_cache",
|
|
||||||
# },
|
|
||||||
# }
|
|
||||||
SITE_ID = 1
|
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 = "/"
|
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 = "/"
|
||||||
|
164
core/signals.py
164
core/signals.py
@ -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)
|
|
@ -1,21 +1,22 @@
|
|||||||
{% load static %}
|
{% load static %}
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en" data-bs-theme="dark">
|
<html lang="en" data-bs-theme="dark">
|
||||||
|
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8" />
|
||||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
|
||||||
<meta name="description" content="Twitch drops">
|
<meta name="description" content="Twitch drops" />
|
||||||
<meta name="author" content="TheLovinator">
|
<meta name="author" content="TheLovinator" />
|
||||||
<meta name="keywords" content="Twitch, drops">
|
<meta name="keywords" content="Twitch, drops" />
|
||||||
<meta name="robots" content="index, follow">
|
<meta name="robots" content="index, follow" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<title>Twitch drops</title>
|
<title>Twitch drops</title>
|
||||||
<link rel="stylesheet" href="{% static 'css/bootstrap.min.css' %}">
|
<link rel="stylesheet" href="{% static 'css/bootstrap.min.css' %}" />
|
||||||
<link rel="stylesheet" href="{% static 'css/style.css' %}">
|
<link rel="stylesheet" href="{% static 'css/style.css' %}" />
|
||||||
</head>
|
</head>
|
||||||
|
<body data-bs-spy="scroll"
|
||||||
<body data-bs-spy="scroll" data-bs-target=".toc" data-bs-offset="-200" tabindex="0">
|
data-bs-target=".toc"
|
||||||
|
data-bs-offset="-200"
|
||||||
|
tabindex="0">
|
||||||
{% include "partials/alerts.html" %}
|
{% include "partials/alerts.html" %}
|
||||||
<article class="container mt-5">
|
<article class="container mt-5">
|
||||||
{% include "partials/header.html" %}
|
{% include "partials/header.html" %}
|
||||||
@ -24,5 +25,4 @@
|
|||||||
</article>
|
</article>
|
||||||
<script src="{% static 'js/bootstrap.min.js' %}"></script>
|
<script src="{% static 'js/bootstrap.min.js' %}"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
|
@ -1,106 +1,141 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<h2>{{ game.name }}</h2>
|
<h2>{{ game.name }}</h2>
|
||||||
<img src="{{ game.box_art_url }}" alt="{{ game.name }} box art" height="283" width="212">
|
<img src="{{ game.box_art_url }}"
|
||||||
<h3>Game Details</h3>
|
alt="{{ game.name }} box art"
|
||||||
<table class="table table-hover table-sm table-striped" cellspacing="0">
|
height="283"
|
||||||
<tr>
|
width="212" />
|
||||||
<td><strong>Twitch ID:</strong></td>
|
<h3>Game Details</h3>
|
||||||
<td>{{ game.pk }}</td>
|
<table class="table table-hover table-sm table-striped" cellspacing="0">
|
||||||
</tr>
|
<tr>
|
||||||
<tr>
|
<td>
|
||||||
<td><strong>Game URL:</strong></td>
|
<strong>Twitch ID:</strong>
|
||||||
<td><a href="{{ game.game_url }}" target="_blank">{{ game.game_url }}</a></td>
|
</td>
|
||||||
</tr>
|
<td>{{ game.pk }}</td>
|
||||||
<tr>
|
</tr>
|
||||||
<td><strong>Game name:</strong></td>
|
<tr>
|
||||||
<td>{{ game.name }}</td>
|
<td>
|
||||||
</tr>
|
<strong>Game URL:</strong>
|
||||||
<tr>
|
</td>
|
||||||
<td><strong>Game box art URL:</strong></td>
|
<td>
|
||||||
<td><a href="{{ game.box_art_url }}" target="_blank">{{ game.box_art_url }}</a></td>
|
<a href="{{ game.game_url }}" target="_blank">{{ game.game_url }}</a>
|
||||||
</tr>
|
</td>
|
||||||
</table>
|
</tr>
|
||||||
<h3>Organization</h3>
|
<tr>
|
||||||
<table class="table table-hover table-sm table-striped" cellspacing="0">
|
<td>
|
||||||
<tr>
|
<strong>Game name:</strong>
|
||||||
{% if game.org %}
|
</td>
|
||||||
<td><a href="#">{{ game.org.name }} -
|
<td>{{ game.name }}</td>
|
||||||
<span class="text-muted">{{ game.org.pk }}</span></a></td>
|
</tr>
|
||||||
{% else %}
|
<tr>
|
||||||
<td>No organization associated with this game.</td>
|
<td>
|
||||||
{% endif %}
|
<strong>Game box art URL:</strong>
|
||||||
</tr>
|
</td>
|
||||||
</table>
|
<td>
|
||||||
<h3>Drop Campaigns</h3>
|
<a href="{{ game.box_art_url }}" target="_blank">{{ game.box_art_url }}</a>
|
||||||
{% if game.drop_campaigns.all %}
|
</td>
|
||||||
{% for drop_campaign in game.drop_campaigns.all %}
|
</tr>
|
||||||
<br>
|
</table>
|
||||||
<h2>{{ drop_campaign.name }}</h2>
|
<h3>Organization</h3>
|
||||||
<table class="table table-hover table-sm table-striped" cellspacing="0">
|
<table class="table table-hover table-sm table-striped" cellspacing="0">
|
||||||
<tr>
|
<tr>
|
||||||
<td><strong>Campaign Name:</strong></td>
|
{% if game.org %}
|
||||||
<td>{{ drop_campaign.name }}</td>
|
<td>
|
||||||
</tr>
|
<a href="#">{{ game.org.name }} -
|
||||||
<tr>
|
<span class="text-muted">{{ game.org.pk }}</span></a>
|
||||||
<td><img src="{{ drop_campaign.image_url }}" alt="{{ drop_campaign.name }} image"></td>
|
</td>
|
||||||
<td>
|
{% else %}
|
||||||
<p><strong>Created at:</strong>
|
<td>No organization associated with this game.</td>
|
||||||
{{ drop_campaign.created_at }}
|
{% endif %}
|
||||||
</p>
|
</tr>
|
||||||
<p><strong>Modified at:</strong>
|
</table>
|
||||||
{{ drop_campaign.modified_at }}
|
<h3>Drop Campaigns</h3>
|
||||||
</p>
|
{% if game.drop_campaigns.all %}
|
||||||
<p><strong>Status:</strong>
|
{% for drop_campaign in game.drop_campaigns.all %}
|
||||||
{{ drop_campaign.status }}
|
<br />
|
||||||
</p>
|
<h2>{{ drop_campaign.name }}</h2>
|
||||||
<p><strong>Description:</strong>
|
<table class="table table-hover table-sm table-striped" cellspacing="0">
|
||||||
{{ drop_campaign.description }}
|
<tr>
|
||||||
</p>
|
<td>
|
||||||
<p><strong>Starts at:</strong>
|
<strong>Campaign Name:</strong>
|
||||||
{{ drop_campaign.starts_at }}
|
</td>
|
||||||
</p>
|
<td>{{ drop_campaign.name }}</td>
|
||||||
<p><strong>Ends at:</strong>
|
</tr>
|
||||||
{{ drop_campaign.ends_at }}
|
<tr>
|
||||||
</p>
|
<td>
|
||||||
<p><strong>More details:</strong>
|
<img src="{{ drop_campaign.image_url }}"
|
||||||
<a href="{{ drop_campaign.details_url }}" target="_blank">{{ drop_campaign.details_url }}</a>
|
alt="{{ drop_campaign.name }} image" />
|
||||||
</p>
|
</td>
|
||||||
<p><strong>Account Link:</strong>
|
<td>
|
||||||
<a href="{{ drop_campaign.account_link_url }}"
|
<p>
|
||||||
target="_blank">{{ drop_campaign.account_link_url }}</a>
|
<strong>Created at:</strong>
|
||||||
</p>
|
{{ drop_campaign.created_at }}
|
||||||
</td>
|
</p>
|
||||||
</tr>
|
<p>
|
||||||
</table>
|
<strong>Modified at:</strong>
|
||||||
{% if drop_campaign.drops.all %}
|
{{ drop_campaign.modified_at }}
|
||||||
<table class="table table-hover table-sm table-striped" cellspacing="0">
|
</p>
|
||||||
<tr>
|
<p>
|
||||||
<th>ID</th>
|
<strong>Status:</strong>
|
||||||
<th>Item Name</th>
|
{{ drop_campaign.status }}
|
||||||
<th>Minutes</th>
|
</p>
|
||||||
<th>Image</th>
|
<p>
|
||||||
<th>Benefit Name</th>
|
<strong>Description:</strong>
|
||||||
</tr>
|
{{ drop_campaign.description }}
|
||||||
{% for item in drop_campaign.drops.all %}
|
</p>
|
||||||
<tr>
|
<p>
|
||||||
<td>{{ item.pk }}</td>
|
<strong>Starts at:</strong>
|
||||||
<td>{{ item.name }}</td>
|
{{ drop_campaign.starts_at }}
|
||||||
<td>{{ item.required_minutes_watched }}</td>
|
</p>
|
||||||
{% for benefit in item.benefits.all %}
|
<p>
|
||||||
<td><img src="{{ benefit.image_url }}" alt="{{ benefit.name }} reward image" height="50" width="50"></td>
|
<strong>Ends at:</strong>
|
||||||
<td>{{ benefit.name }}</td>
|
{{ 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 %}
|
{% endfor %}
|
||||||
</tr>
|
{% else %}
|
||||||
{% endfor %}
|
<p>No drop campaigns associated with this game.</p>
|
||||||
</table>
|
{% endif %}
|
||||||
{% else %}
|
</div>
|
||||||
<p>No items associated with this drop campaign.</p>
|
|
||||||
{% endif %}
|
|
||||||
{% endfor %}
|
|
||||||
{% else %}
|
|
||||||
<p>No drop campaigns associated with this game.</p>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
{% endblock content %}
|
{% endblock content %}
|
||||||
|
@ -1,34 +1,37 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="container mt-4">
|
<div class="container mt-4">
|
||||||
{% for game in games %}
|
{% for game in games %}
|
||||||
<div class="card mb-4 shadow-sm">
|
<div class="card mb-4 shadow-sm">
|
||||||
<div class="row g-0">
|
<div class="row g-0">
|
||||||
<div class="col-md-2">
|
<div class="col-md-2">
|
||||||
<img src="{{ game.box_art_url }}" alt="{{ game.name }} box art" class="img-fluid rounded-start"
|
<img src="{{ game.box_art_url }}"
|
||||||
height="283" width="212" loading="lazy">
|
alt="{{ game.name }} box art"
|
||||||
</div>
|
class="img-fluid rounded-start"
|
||||||
<div class="col-md-10">
|
height="283"
|
||||||
<div class="card-body">
|
width="212"
|
||||||
<h2 class="card-title h5">
|
loading="lazy" />
|
||||||
<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>
|
||||||
<div>
|
<div class="col-md-10">
|
||||||
<a href="" class="text-decoration-none">Subscribe to new drops</a>
|
<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>
|
||||||
<div>
|
|
||||||
<a href="" class="text-decoration-none">Subscribe to active drops</a>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
{% endblock content %}
|
{% endblock content %}
|
||||||
|
@ -1,176 +1,176 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% load static %}
|
{% load custom_filters static time_filters %}
|
||||||
{% load custom_filters %}
|
|
||||||
{% load time_filters %}
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="container mt-4">
|
<div class="container mt-4">
|
||||||
{% include "partials/info_box.html" %}
|
{% include "partials/info_box.html" %}
|
||||||
{% include "partials/news.html" %}
|
{% include "partials/news.html" %}
|
||||||
|
<!-- Reward Campaigns Section -->
|
||||||
<!-- Reward Campaigns Section -->
|
<section class="reward-campaigns">
|
||||||
<section class="reward-campaigns">
|
<h2>
|
||||||
<h2>
|
Reward Campaigns -
|
||||||
Reward Campaigns -
|
<span class="d-inline text-muted">{{ reward_campaigns.count }} campaign{{ reward_campaigns.count|pluralize }}</span>
|
||||||
<span class="d-inline text-muted">
|
</h2>
|
||||||
{{ reward_campaigns.count }} campaign{{ reward_campaigns.count|pluralize }}
|
<!-- Loop through reward campaigns -->
|
||||||
</span>
|
{% for campaign in reward_campaigns %}
|
||||||
</h2>
|
<div class="card mb-4 shadow-sm" id="campaign-{{ campaign.twitch_id }}">
|
||||||
|
<div class="row g-0">
|
||||||
<!-- Loop through reward campaigns -->
|
<!-- Campaign Image -->
|
||||||
{% for campaign in reward_campaigns %}
|
<div class="col-md-2">
|
||||||
<div class="card mb-4 shadow-sm" id="campaign-{{ campaign.twitch_id }}">
|
<img src="{{ campaign.image_url }}"
|
||||||
<div class="row g-0">
|
alt="{{ campaign.name }}"
|
||||||
<!-- Campaign Image -->
|
class="img-fluid rounded-start"
|
||||||
<div class="col-md-2">
|
height="283"
|
||||||
<img src="{{ campaign.image_url }}" alt="{{ campaign.name }}" class="img-fluid rounded-start"
|
width="212"
|
||||||
height="283" width="212" loading="lazy">
|
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>
|
</div>
|
||||||
{% endif %}
|
<!-- Campaign Details -->
|
||||||
|
<div class="col-md-10">
|
||||||
<!-- Rewards (if any) -->
|
<div class="card-body">
|
||||||
{% if campaign.rewards.all %}
|
<h2 class="card-title h5">
|
||||||
<div class="mt-3">
|
<a href="#campaign-{{ campaign.twitch_id }}" class="plain-text-item">{{ campaign.name }}</a>
|
||||||
<h3 class="h6">Rewards</h3>
|
</h2>
|
||||||
<div class="row row-cols-1 row-cols-md-2 row-cols-lg-3 g-2">
|
<p class="card-text text-muted">{{ campaign.summary }}</p>
|
||||||
{% for reward in campaign.rewards.all %}
|
<p class="mb-2 text-muted">
|
||||||
<div class="col d-flex align-items-center position-relative">
|
Ends in:
|
||||||
<img src="{{ reward.thumbnail_image_url }}" alt="{{ reward.name }} reward image"
|
<abbr title="{{ campaign.starts_at|date:'l d F H:i %Z' }} - {{ campaign.ends_at|date:'l d F H:i %Z' }}">
|
||||||
class="img-fluid rounded me-3" height="50" width="50" loading="lazy">
|
{{ campaign.ends_at|timesince }}
|
||||||
<div><strong>{{ reward.name }}</strong></div>
|
</abbr>
|
||||||
</div>
|
</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 %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
{% endfor %}
|
||||||
</div>
|
</section>
|
||||||
{% endfor %}
|
</div>
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- Drop Campaigns Section -->
|
|
||||||
<section class="drop-campaigns">
|
|
||||||
<h2>
|
|
||||||
Drop Campaigns -
|
|
||||||
<span class="d-inline text-muted">{{ games.count }} game{{ games.count|pluralize }}</span>
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
<!-- Loop through games -->
|
|
||||||
{% for game in games %}
|
|
||||||
<div class="card mb-4 shadow-sm">
|
|
||||||
<div class="row g-0">
|
|
||||||
<!-- Game Box Art -->
|
|
||||||
<div class="col-md-2">
|
|
||||||
<img src="{{ game.box_art_url|default:'https://static-cdn.jtvnw.net/ttv-static/404_boxart.jpg' }}"
|
|
||||||
alt="{{ game.name|default:'Game name unknown' }} box art" class="img-fluid rounded-start"
|
|
||||||
height="283" width="212" loading="lazy">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Game Details -->
|
|
||||||
<div class="col-md-10">
|
|
||||||
<div class="card-body">
|
|
||||||
<h2 class="card-title h5">
|
|
||||||
<a href="{% url 'game' game.twitch_id %}" class="text-decoration-none">
|
|
||||||
{{ game.name|default:'Unknown' }}
|
|
||||||
</a>
|
|
||||||
-
|
|
||||||
<a href="https://www.twitch.tv/directory/category/{{ game.slug|default:'game-name-unknown' }}"
|
|
||||||
class="text-decoration-none text-muted">Twitch</a>
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
<!-- Loop through campaigns for each game -->
|
|
||||||
{% for campaign in game.drop_campaigns.all %}
|
|
||||||
<div class="mt-4">
|
|
||||||
<h4 class="h6">{{ campaign.name }}</h4>
|
|
||||||
<a href="{{ campaign.details_url }}" class="text-decoration-none">Details</a>
|
|
||||||
{% if campaign.details_url != campaign.account_link_url %}
|
|
||||||
| <a href="{{ campaign.account_link_url }}" class="text-decoration-none">Link Account</a>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<p class="mb-2 text-muted">
|
|
||||||
Ends in:
|
|
||||||
<abbr
|
|
||||||
title="{{ campaign.starts_at|date:'l d F H:i' }} - {{ campaign.ends_at|date:'l d F H:i' }}">
|
|
||||||
{{ campaign.ends_at|timeuntil }}
|
|
||||||
</abbr>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<!-- Drop Benefits Table -->
|
|
||||||
<div class="table-responsive">
|
|
||||||
<table class="table table-striped table-hover align-middle">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Benefit Image</th>
|
|
||||||
<th>Benefit Name</th>
|
|
||||||
<th>Required Minutes Watched</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{% for drop in campaign.drops.all %}
|
|
||||||
{% if drop.benefits.exists %}
|
|
||||||
{% for benefit in drop.benefits.all %}
|
|
||||||
<tr>
|
|
||||||
<td>
|
|
||||||
<img src="{{ benefit.image_url|default:'https://static-cdn.jtvnw.net/ttv-static/404_boxart.jpg' }}"
|
|
||||||
alt="{{ benefit.name|default:'Unknown' }}" class="img-fluid rounded"
|
|
||||||
height="50" width="50" loading="lazy">
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<abbr title="{{ drop.name|default:'Unknown' }}">
|
|
||||||
{{ benefit.name|default:'Unknown' }}
|
|
||||||
</abbr>
|
|
||||||
</td>
|
|
||||||
<td>{{ drop.required_minutes_watched|minutes_to_hours }}</td>
|
|
||||||
</tr>
|
|
||||||
{% endfor %}
|
|
||||||
{% else %}
|
|
||||||
<tr>
|
|
||||||
<td>
|
|
||||||
<img src="https://static-cdn.jtvnw.net/ttv-static/404_boxart.jpg"
|
|
||||||
alt="{{ drop.name|default:'Unknown' }}" class="img-fluid rounded"
|
|
||||||
height="50" width="50" loading="lazy">
|
|
||||||
</td>
|
|
||||||
<td>{{ drop.name|default:'Unknown' }}</td>
|
|
||||||
<td>N/A</td>
|
|
||||||
</tr>
|
|
||||||
{% endif %}
|
|
||||||
{% endfor %}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
{% endblock content %}
|
{% endblock content %}
|
||||||
|
@ -1,6 +1,10 @@
|
|||||||
{% for message in messages %}
|
{% for message in messages %}
|
||||||
<div class="alert alert-dismissible {{ message.tags }} fade show" role="alert">
|
<div class="alert alert-dismissible {{ message.tags }} fade show"
|
||||||
<div>{{ message | safe }}</div>
|
role="alert">
|
||||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
<div>{{ message|safe }}</div>
|
||||||
</div>
|
<button type="button"
|
||||||
|
class="btn-close"
|
||||||
|
data-bs-dismiss="alert"
|
||||||
|
aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
@ -1,50 +1,53 @@
|
|||||||
{% if webhooks %}
|
{% if webhooks %}
|
||||||
<div class="card mb-4 shadow-sm" id="info-box">
|
<div class="card mb-4 shadow-sm" id="info-box">
|
||||||
<div class="row g-0">
|
<div class="row g-0">
|
||||||
<div class="col-md-10">
|
<div class="col-md-10">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<h2 class="card-title h2">Site news</h2>
|
<h2 class="card-title h2">Site news</h2>
|
||||||
<div class="mt-auto">
|
<div class="mt-auto">
|
||||||
{% for webhook in webhooks %}
|
{% for webhook in webhooks %}
|
||||||
<div class="mt-3">
|
<div class="mt-3">
|
||||||
<img src="{{ webhook.avatar }}?size=32" alt="{{ webhook.name }}" class="rounded-circle"
|
<img src="{{ webhook.avatar }}?size=32"
|
||||||
height="32" width="32">
|
alt="{{ webhook.name }}"
|
||||||
<a href="{{ webhook.url }}" target="_blank">{{ webhook.name }}</a>
|
class="rounded-circle"
|
||||||
<div class="form-check form-switch">
|
height="32"
|
||||||
<input class="form-check-input" type="checkbox" id="new-drop-switch-daily">
|
width="32" />
|
||||||
<label class="form-check-label" for="new-drop-switch-daily">Daily notification of newly
|
<a href="{{ webhook.url }}" target="_blank">{{ webhook.name }}</a>
|
||||||
added games to TTVdrops</label>
|
<div class="form-check form-switch">
|
||||||
</div>
|
<input class="form-check-input" type="checkbox" id="new-drop-switch-daily" />
|
||||||
<div class="form-check form-switch">
|
<label class="form-check-label" for="new-drop-switch-daily">
|
||||||
<input class="form-check-input" type="checkbox" id="new-drop-switch-weekly">
|
Daily notification of newly
|
||||||
<label class="form-check-label" for="new-drop-switch-weekly">
|
added games to TTVdrops
|
||||||
Weekly notification of newly added games to TTVdrops
|
</label>
|
||||||
</label>
|
</div>
|
||||||
</div>
|
<div class="form-check form-switch">
|
||||||
<br>
|
<input class="form-check-input" type="checkbox" id="new-drop-switch-weekly" />
|
||||||
<div class="form-check form-switch">
|
<label class="form-check-label" for="new-drop-switch-weekly">
|
||||||
<input class="form-check-input" type="checkbox" id="new-org-switch-daily">
|
Weekly notification of newly added games to TTVdrops
|
||||||
<label class="form-check-label" for="new-org-switch-daily">
|
</label>
|
||||||
Daily notification of newly added <abbr
|
</div>
|
||||||
title="Organizations are the companies that own the games.">organizations</abbr> to
|
<br />
|
||||||
TTVdrops
|
<div class="form-check form-switch">
|
||||||
</label>
|
<input class="form-check-input" type="checkbox" id="new-org-switch-daily" />
|
||||||
</div>
|
<label class="form-check-label" for="new-org-switch-daily">
|
||||||
<div class="form-check form-switch">
|
Daily notification of newly added <abbr title="Organizations are the companies that own the games.">organizations</abbr> to
|
||||||
<input class="form-check-input" type="checkbox" id="new-org-switch-weekly">
|
TTVdrops
|
||||||
<label class="form-check-label" for="new-org-switch-weekly">
|
</label>
|
||||||
Weekly notification of newly added <abbr
|
</div>
|
||||||
title="Organizations are the companies that own the games.">organizations</abbr> to
|
<div class="form-check form-switch">
|
||||||
TTVdrops
|
<input class="form-check-input" type="checkbox" id="new-org-switch-weekly" />
|
||||||
</label>
|
<label class="form-check-label" for="new-org-switch-weekly">
|
||||||
</div>
|
Weekly notification of newly added <abbr title="Organizations are the companies that own the games.">organizations</abbr> to
|
||||||
|
TTVdrops
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
{% else %}
|
{% else %}
|
||||||
<p class="text-muted">No webhooks added yet.</p>
|
<p class="text-muted">No webhooks added yet.</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
@ -1,14 +1,12 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% load static %}
|
{% load campaign_tags game_tags static %}
|
||||||
{% load campaign_tags %}
|
|
||||||
{% load game_tags %}
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="container mt-4">
|
<div class="container mt-4">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<h2>Reward Campaigns</h2>
|
<h2>Reward Campaigns</h2>
|
||||||
{% for campaign in reward_campaigns %}
|
{% for campaign in reward_campaigns %}
|
||||||
{% render_campaign campaign %}
|
{% render_campaign campaign %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
{% endblock content %}
|
{% endblock content %}
|
||||||
|
@ -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 %}
|
|
@ -22,6 +22,4 @@ def minutes_to_hours(minutes: int | None) -> str:
|
|||||||
if remaining_minutes > 0:
|
if remaining_minutes > 0:
|
||||||
return f"{hours}h {remaining_minutes}m"
|
return f"{hours}h {remaining_minutes}m"
|
||||||
return f"{hours}h"
|
return f"{hours}h"
|
||||||
if remaining_minutes > 0:
|
return f"{remaining_minutes}m" if remaining_minutes > 0 else "0m"
|
||||||
return f"{remaining_minutes}m"
|
|
||||||
return "0m"
|
|
||||||
|
@ -1,16 +1,21 @@
|
|||||||
|
"""Tests for the views in the core app."""
|
||||||
|
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
from django.http import HttpResponse
|
||||||
from django.test import Client
|
from django.test import Client
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from django.http import HttpResponse
|
from django.test.client import _MonkeyPatchedWSGIResponse # type: ignore[import]
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
def test_index_view(client: Client) -> None:
|
def test_index_view(client: Client) -> None:
|
||||||
"""Test index view."""
|
"""Test index view."""
|
||||||
url: str = reverse(viewname="core:index")
|
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
|
assert response.status_code == 200
|
@ -1,17 +1,16 @@
|
|||||||
from __future__ import annotations
|
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.contrib import admin
|
||||||
from django.urls import URLPattern, URLResolver, path
|
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"
|
app_name: str = "core"
|
||||||
|
|
||||||
urlpatterns: list[URLPattern | URLResolver] = [
|
urlpatterns: list[URLPattern | URLResolver] = [
|
||||||
path(route="admin/", view=admin.site.urls),
|
path(route="admin/", view=admin.site.urls),
|
||||||
path(route="", view=index, name="index"),
|
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="game/<int:twitch_id>/", view=game_view, name="game"),
|
||||||
path(route="games/", view=games_view, name="games"),
|
path(route="games/", view=games_view, name="games"),
|
||||||
path(route="reward_campaigns/", view=reward_campaign_view, name="reward_campaigns"),
|
path(route="reward_campaigns/", view=reward_campaign_view, name="reward_campaigns"),
|
||||||
|
161
core/views.py
161
core/views.py
@ -3,48 +3,45 @@ from __future__ import annotations
|
|||||||
import logging
|
import logging
|
||||||
from typing import TYPE_CHECKING, Any
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
import requests_cache
|
|
||||||
from django.db.models import F, Prefetch
|
from django.db.models import F, Prefetch
|
||||||
from django.db.models.manager import BaseManager
|
|
||||||
from django.http import HttpRequest, HttpResponse
|
from django.http import HttpRequest, HttpResponse
|
||||||
from django.template.response import TemplateResponse
|
from django.template.response import TemplateResponse
|
||||||
from django.utils import timezone
|
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:
|
if TYPE_CHECKING:
|
||||||
from django.db.models.manager import BaseManager
|
from django.db.models.query import QuerySet
|
||||||
from django.http import HttpRequest
|
from django.http import HttpRequest
|
||||||
|
|
||||||
logger: logging.Logger = logging.getLogger(__name__)
|
logger: logging.Logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def get_reward_campaigns() -> BaseManager[RewardCampaign]:
|
def get_reward_campaigns() -> QuerySet[RewardCampaign]:
|
||||||
"""Get the reward campaigns.
|
"""Get the reward campaigns.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
BaseManager[RewardCampaign]: The reward campaigns.
|
QuerySet[RewardCampaign]: The reward campaigns.
|
||||||
"""
|
"""
|
||||||
return RewardCampaign.objects.all().prefetch_related("rewards").order_by("-created_at")
|
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.
|
"""Get the games with drops, sorted by when the drop campaigns end.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
BaseManager[Game]: The games with drops.
|
QuerySet[Game]: The games with drops.
|
||||||
"""
|
"""
|
||||||
# Prefetch the benefits for the time-based drops.
|
# Prefetch the benefits for the time-based drops.
|
||||||
benefits_prefetch = Prefetch(lookup="benefits", queryset=Benefit.objects.all())
|
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(),
|
ends_at__gte=timezone.now(),
|
||||||
starts_at__lte=timezone.now(),
|
starts_at__lte=timezone.now(),
|
||||||
).prefetch_related(benefits_prefetch)
|
).prefetch_related(benefits_prefetch)
|
||||||
|
|
||||||
# Prefetch the active time-based drops for the drop campaigns.
|
# Prefetch the active time-based drops for the drop campaigns.
|
||||||
drops_prefetch = Prefetch(lookup="drops", queryset=active_time_based_drops)
|
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(),
|
ends_at__gte=timezone.now(),
|
||||||
starts_at__lte=timezone.now(),
|
starts_at__lte=timezone.now(),
|
||||||
).prefetch_related(drops_prefetch)
|
).prefetch_related(drops_prefetch)
|
||||||
@ -69,8 +66,8 @@ def index(request: HttpRequest) -> HttpResponse:
|
|||||||
HttpResponse: The response object
|
HttpResponse: The response object
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
reward_campaigns: BaseManager[RewardCampaign] = get_reward_campaigns()
|
reward_campaigns: QuerySet[RewardCampaign] = get_reward_campaigns()
|
||||||
games: BaseManager[Game] = get_games_with_drops()
|
games: QuerySet[Game] = get_games_with_drops()
|
||||||
|
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("Error fetching reward campaigns or games.")
|
logger.exception("Error fetching reward campaigns or games.")
|
||||||
@ -107,9 +104,9 @@ def game_view(request: HttpRequest, twitch_id: int) -> HttpResponse:
|
|||||||
)
|
)
|
||||||
|
|
||||||
except Game.DoesNotExist:
|
except Game.DoesNotExist:
|
||||||
return HttpResponse(status=404)
|
return HttpResponse(status=404, content="Game not found.")
|
||||||
except Game.MultipleObjectsReturned:
|
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}
|
context: dict[str, Any] = {"game": game}
|
||||||
return TemplateResponse(request=request, template="game.html", context=context)
|
return TemplateResponse(request=request, template="game.html", context=context)
|
||||||
@ -124,9 +121,9 @@ def games_view(request: HttpRequest) -> HttpResponse:
|
|||||||
Returns:
|
Returns:
|
||||||
HttpResponse: The response object.
|
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)
|
return TemplateResponse(request=request, template="games.html", context=context)
|
||||||
|
|
||||||
|
|
||||||
@ -139,132 +136,6 @@ def reward_campaign_view(request: HttpRequest) -> HttpResponse:
|
|||||||
Returns:
|
Returns:
|
||||||
HttpResponse: The response object.
|
HttpResponse: The response object.
|
||||||
"""
|
"""
|
||||||
reward_campaigns: BaseManager[RewardCampaign] = RewardCampaign.objects.all()
|
reward_campaigns: QuerySet[RewardCampaign] = RewardCampaign.objects.all()
|
||||||
context: dict[str, BaseManager[RewardCampaign]] = {"reward_campaigns": reward_campaigns}
|
context: dict[str, QuerySet[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)
|
|
||||||
|
19
manage.py
Normal file → Executable file
19
manage.py
Normal file → Executable file
@ -4,19 +4,20 @@
|
|||||||
import os
|
import os
|
||||||
import sys
|
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:
|
def main() -> None:
|
||||||
"""Run administrative tasks."""
|
"""Run administrative tasks."""
|
||||||
os.environ.setdefault(key="DJANGO_SETTINGS_MODULE", value="core.settings")
|
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)
|
execute_from_command_line(sys.argv)
|
||||||
|
|
||||||
|
|
||||||
|
Reference in New Issue
Block a user