The comeback
This commit is contained in:
@ -1,6 +1,6 @@
|
||||
from django.contrib import admin
|
||||
|
||||
from .models import Benefit, DropCampaign, Game, Owner, Reward, RewardCampaign, TimeBasedDrop
|
||||
from core.models import Benefit, DropCampaign, Game, Owner, Reward, RewardCampaign, TimeBasedDrop
|
||||
|
||||
admin.site.register(Game)
|
||||
admin.site.register(Owner)
|
||||
|
10
core/apps.py
10
core/apps.py
@ -2,13 +2,7 @@ from django.apps import AppConfig
|
||||
|
||||
|
||||
class CoreConfig(AppConfig):
|
||||
"""Core app configuration."""
|
||||
|
||||
default_auto_field: str = "django.db.models.BigAutoField"
|
||||
name = "core"
|
||||
|
||||
@staticmethod
|
||||
def ready() -> None:
|
||||
"""Ready runs on app startup.
|
||||
|
||||
We import signals here so that they are registered when the app starts.
|
||||
"""
|
||||
import core.signals # noqa: F401, PLC0415
|
||||
|
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
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
|
||||
import sentry_sdk
|
||||
import django_stubs_ext
|
||||
from django.contrib import messages
|
||||
from dotenv import find_dotenv, load_dotenv
|
||||
from dotenv import load_dotenv
|
||||
from platformdirs import user_data_dir
|
||||
|
||||
load_dotenv(dotenv_path=find_dotenv(), verbose=True)
|
||||
# Monkeypatching Django, so stubs will work for all generics,
|
||||
# see: https://github.com/typeddjango/django-stubs
|
||||
django_stubs_ext.monkeypatch()
|
||||
|
||||
# Parse a .env file and then load all the variables found as environment variables.
|
||||
load_dotenv(verbose=True)
|
||||
|
||||
# Store data in %APPDATA%/TheLovinator/TTVDrops on Windows and ~/.config/TheLovinator/TTVDrops on Linux.
|
||||
# Sqlite database and images will be stored here.
|
||||
DATA_DIR = Path(
|
||||
user_data_dir(
|
||||
appname="TTVDrops",
|
||||
@ -17,63 +25,89 @@ DATA_DIR = Path(
|
||||
),
|
||||
)
|
||||
|
||||
# Default to DEBUG=True if not set.
|
||||
# Turn off with DEBUG=False in .env file or environment variable.
|
||||
DEBUG: bool = os.getenv(key="DEBUG", default="True").lower() == "true"
|
||||
|
||||
if not DEBUG:
|
||||
sentry_sdk.init(
|
||||
dsn="https://35519536b56710e51cac49522b2cc29f@o4505228040339456.ingest.sentry.io/4506447308914688",
|
||||
environment="Production",
|
||||
send_default_pii=True,
|
||||
traces_sample_rate=0.2,
|
||||
profiles_sample_rate=0.2,
|
||||
)
|
||||
|
||||
# The base directory of the project.
|
||||
BASE_DIR: Path = Path(__file__).resolve().parent.parent
|
||||
|
||||
# A list of all the people who get code error notifications. When DEBUG=False and AdminEmailHandler is configured in
|
||||
# LOGGING (done by default), Django emails these people the details of exceptions raised in the request/response cycle.
|
||||
ADMINS: list[tuple[str, str]] = [("Joakim Hellsén", "tlovinator@gmail.com")]
|
||||
|
||||
# The full Python path of the WSGI application object that Django's built-in servers (e.g. runserver) will use.
|
||||
WSGI_APPLICATION = "core.wsgi.application"
|
||||
|
||||
# A secret key for a particular Django installation. This is used to provide cryptographic signing,
|
||||
# and should be set to a unique, unpredictable value.
|
||||
SECRET_KEY: str = os.getenv("DJANGO_SECRET_KEY", default="")
|
||||
TIME_ZONE = "Europe/Stockholm"
|
||||
USE_TZ = True
|
||||
LANGUAGE_CODE = "en-us"
|
||||
DECIMAL_SEPARATOR = ","
|
||||
THOUSAND_SEPARATOR = " "
|
||||
|
||||
# A string representing the full Python import path to your root URLconf
|
||||
ROOT_URLCONF = "core.urls"
|
||||
|
||||
# URL to use when referring to static files located in STATIC_ROOT.
|
||||
STATIC_URL = "static/"
|
||||
|
||||
# Default primary key field type to use for models that don't have a field with primary_key=True.
|
||||
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
|
||||
|
||||
STATICFILES_DIRS: list[Path] = [BASE_DIR / "static"]
|
||||
# This setting defines the additional locations the staticfiles app will traverse if the FileSystemFinder finder is
|
||||
# enabled, e.g. if you use the "collectstatic" or "findstatic" management command or use the static file serving view.
|
||||
STATICFILES_DIRS: list[Path] = [
|
||||
BASE_DIR / "static",
|
||||
]
|
||||
|
||||
# The absolute path to the directory where collectstatic will collect static files for deployment.
|
||||
STATIC_ROOT: Path = BASE_DIR / "staticfiles"
|
||||
STATIC_ROOT.mkdir(exist_ok=True)
|
||||
STATIC_ROOT.mkdir(exist_ok=True) # Create the directory if it doesn't exist.
|
||||
|
||||
# URL that handles the media served from MEDIA_ROOT, used for managing stored files.
|
||||
# It must end in a slash if set to a non-empty value.
|
||||
MEDIA_URL = "/media/"
|
||||
MEDIA_ROOT: Path = DATA_DIR / "media"
|
||||
MEDIA_ROOT.mkdir(exist_ok=True)
|
||||
|
||||
AUTH_USER_MODEL = "core.User"
|
||||
# Absolute filesystem path to the directory that will hold user-uploaded files.
|
||||
MEDIA_ROOT: Path = DATA_DIR / "media"
|
||||
MEDIA_ROOT.mkdir(exist_ok=True) # Create the directory if it doesn't exist.
|
||||
|
||||
# The model to use to represent a User.
|
||||
# ! You cannot change the AUTH_USER_MODEL setting during the lifetime of a project
|
||||
# ! (i.e. once you have made and migrated models that depend on it) without serious effort.
|
||||
# ! It is intended to be set at the project start, and the model it refers to must be available
|
||||
# ! in the first migration of the app that it lives in.
|
||||
AUTH_USER_MODEL: Literal["core.User"] = "core.User"
|
||||
|
||||
if DEBUG:
|
||||
INTERNAL_IPS: list[str] = ["127.0.0.1"]
|
||||
# A list of IP addresses, as strings, that:
|
||||
# - Allow the debug() context processor to add some variables to the template context.
|
||||
# - Can use the admindocs bookmarklets even if not logged in as a staff user.
|
||||
# - Are marked as “internal” (as opposed to “EXTERNAL”) in AdminEmailHandler emails.
|
||||
|
||||
# This is needed for the Django Debug Toolbar to work.
|
||||
INTERNAL_IPS: list[str] = ["127.0.0.1", "192.168.1.129"]
|
||||
|
||||
if not DEBUG:
|
||||
# List of strings representing the host/domain names that this Django site can serve
|
||||
ALLOWED_HOSTS: list[str] = ["ttvdrops.lovinator.space", "localhost"]
|
||||
|
||||
# The host to use for sending email.
|
||||
EMAIL_HOST = "smtp.gmail.com"
|
||||
EMAIL_PORT = 587
|
||||
EMAIL_USE_TLS = True
|
||||
EMAIL_HOST_USER: str = os.getenv(key="EMAIL_HOST_USER", default="webmaster@localhost")
|
||||
EMAIL_HOST_PASSWORD: str = os.getenv(key="EMAIL_HOST_PASSWORD", default="")
|
||||
EMAIL_HOST_USER: str | None = os.getenv(key="EMAIL_HOST_USER", default=None)
|
||||
EMAIL_HOST_PASSWORD: str | None = os.getenv(key="EMAIL_HOST_PASSWORD", default=None)
|
||||
EMAIL_SUBJECT_PREFIX = "[TTVDrops] "
|
||||
EMAIL_USE_LOCALTIME = True
|
||||
EMAIL_TIMEOUT = 10
|
||||
DEFAULT_FROM_EMAIL: str = os.getenv(
|
||||
key="EMAIL_HOST_USER",
|
||||
default="webmaster@localhost",
|
||||
)
|
||||
SERVER_EMAIL: str = os.getenv(key="EMAIL_HOST_USER", default="webmaster@localhost")
|
||||
DEFAULT_FROM_EMAIL: str | None = os.getenv(key="EMAIL_HOST_USER", default=None)
|
||||
SERVER_EMAIL: str | None = os.getenv(key="EMAIL_HOST_USER", default=None)
|
||||
|
||||
# Discord webhook URL for sending notifications.
|
||||
DISCORD_WEBHOOK_URL: str = os.getenv(key="DISCORD_WEBHOOK_URL", default="")
|
||||
|
||||
# The list of all installed applications that Django knows about.
|
||||
INSTALLED_APPS: list[str] = [
|
||||
"core.apps.CoreConfig",
|
||||
"whitenoise.runserver_nostatic",
|
||||
"django.contrib.admin",
|
||||
"django.contrib.auth",
|
||||
"django.contrib.contenttypes",
|
||||
@ -81,28 +115,35 @@ INSTALLED_APPS: list[str] = [
|
||||
"django.contrib.messages",
|
||||
"django.contrib.staticfiles",
|
||||
"django.contrib.sites",
|
||||
"simple_history",
|
||||
"debug_toolbar",
|
||||
]
|
||||
|
||||
# Middleware is a framework of hooks into Django's request/response processing.
|
||||
# https://docs.djangoproject.com/en/dev/topics/http/middleware/
|
||||
MIDDLEWARE: list[str] = [
|
||||
"django.middleware.gzip.GZipMiddleware",
|
||||
"debug_toolbar.middleware.DebugToolbarMiddleware",
|
||||
"whitenoise.middleware.WhiteNoiseMiddleware",
|
||||
"django.middleware.security.SecurityMiddleware",
|
||||
"django.contrib.sessions.middleware.SessionMiddleware",
|
||||
"django.middleware.common.CommonMiddleware",
|
||||
"django.middleware.csrf.CsrfViewMiddleware",
|
||||
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
||||
"django.contrib.messages.middleware.MessageMiddleware",
|
||||
"simple_history.middleware.HistoryRequestMiddleware",
|
||||
]
|
||||
|
||||
TEMPLATES = [
|
||||
# Settings for the template engine.
|
||||
TEMPLATES: list[dict[str, str | list[Path] | bool | dict[str, list[str] | list[tuple[str, list[str]]]]]] = [
|
||||
{
|
||||
# Use the Django template backend instead of Jinja2.
|
||||
"BACKEND": "django.template.backends.django.DjangoTemplates",
|
||||
# Directories where the engine should look for template source files, in search order.
|
||||
"DIRS": [BASE_DIR / "templates"],
|
||||
# Whether the engine should look for template source files inside installed applications.
|
||||
"APP_DIRS": True,
|
||||
# Extra parameters to pass to the template backend.
|
||||
# https://docs.djangoproject.com/en/dev/topics/templates/#django.template.backends.django.DjangoTemplates
|
||||
"OPTIONS": {
|
||||
# Callables that are used to populate the context when a template is rendered with a request.
|
||||
"context_processors": [
|
||||
"django.contrib.auth.context_processors.auth",
|
||||
"django.template.context_processors.i18n",
|
||||
@ -110,46 +151,28 @@ TEMPLATES = [
|
||||
"django.template.context_processors.request",
|
||||
"django.contrib.messages.context_processors.messages",
|
||||
],
|
||||
"loaders": [
|
||||
(
|
||||
"django.template.loaders.cached.Loader",
|
||||
[
|
||||
"django.template.loaders.filesystem.Loader",
|
||||
"django.template.loaders.app_directories.Loader",
|
||||
],
|
||||
),
|
||||
],
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
# Don't cache templates in development
|
||||
if DEBUG:
|
||||
TEMPLATES[0]["OPTIONS"]["loaders"] = [
|
||||
"django.template.loaders.filesystem.Loader",
|
||||
"django.template.loaders.app_directories.Loader",
|
||||
]
|
||||
|
||||
DATABASES = {
|
||||
# TODO(TheLovinator): Run psycopg[c] in production.
|
||||
# https://www.psycopg.org/psycopg3/docs/basic/install.html#local-installation
|
||||
DATABASES: dict[str, dict[str, str | dict[str, bool]]] = {
|
||||
"default": {
|
||||
"ENGINE": "django.db.backends.sqlite3",
|
||||
"NAME": DATA_DIR / "ttvdrops.sqlite3",
|
||||
"ENGINE": "django.db.backends.postgresql",
|
||||
"NAME": os.getenv(key="DB_NAME", default=""),
|
||||
"USER": os.getenv(key="DB_USER", default=""),
|
||||
"PASSWORD": os.getenv(key="DB_PASSWORD", default=""),
|
||||
"HOST": os.getenv(key="DB_HOST", default=""),
|
||||
"PORT": os.getenv(key="DB_PORT", default=""),
|
||||
"OPTIONS": {
|
||||
"init_command": "PRAGMA journal_mode=wal; PRAGMA synchronous=1; PRAGMA mmap_size=134217728; PRAGMA journal_size_limit=67108864; PRAGMA cache_size=2000;", # noqa: E501
|
||||
"pool": True, # TODO(TheLovinator): Benchmark this. # noqa: TD003
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
STORAGES: dict[str, dict[str, str]] = {
|
||||
"default": {
|
||||
"BACKEND": "django.core.files.storage.FileSystemStorage",
|
||||
},
|
||||
"staticfiles": {
|
||||
"BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage",
|
||||
},
|
||||
}
|
||||
|
||||
LOGGING = {
|
||||
LOGGING: dict[str, int | bool | dict[str, dict[str, str | list[str] | bool]]] = {
|
||||
"version": 1,
|
||||
"disable_existing_loggers": False,
|
||||
"handlers": {
|
||||
@ -172,6 +195,7 @@ LOGGING = {
|
||||
},
|
||||
}
|
||||
|
||||
# Bootstrap alert classes for Django messages
|
||||
MESSAGE_TAGS: dict[int, str] = {
|
||||
messages.DEBUG: "alert-info",
|
||||
messages.INFO: "alert-info",
|
||||
@ -180,15 +204,15 @@ MESSAGE_TAGS: dict[int, str] = {
|
||||
messages.ERROR: "alert-danger",
|
||||
}
|
||||
|
||||
# CACHE_MIDDLEWARE_SECONDS = 60 * 60 * 24 # 1 day
|
||||
# CACHES = {
|
||||
# "default": {
|
||||
# "BACKEND": "django.core.cache.backends.filebased.FileBasedCache",
|
||||
# "LOCATION": DATA_DIR / "django_cache",
|
||||
# },
|
||||
# }
|
||||
# The ID, as an integer, of the current site in the django_site database table.
|
||||
# This is used so that application data can hook into specific sites and a
|
||||
# single database can manage content for multiple sites.
|
||||
SITE_ID = 1
|
||||
|
||||
|
||||
# The URL or named URL pattern where requests are redirected after login when the LoginView doesn't
|
||||
# get a next GET parameter. Defaults to /accounts/profile/.
|
||||
LOGIN_REDIRECT_URL = "/"
|
||||
ACCOUNT_LOGOUT_REDIRECT_URL = "/"
|
||||
|
||||
# The URL or named URL pattern where requests are redirected after logout if LogoutView doesn't have
|
||||
# a next_page attribute.
|
||||
LOGOUT_REDIRECT_URL = "/"
|
||||
|
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 %}
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" data-bs-theme="dark">
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<meta name="description" content="Twitch drops">
|
||||
<meta name="author" content="TheLovinator">
|
||||
<meta name="keywords" content="Twitch, drops">
|
||||
<meta name="robots" content="index, follow">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta charset="utf-8" />
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
|
||||
<meta name="description" content="Twitch drops" />
|
||||
<meta name="author" content="TheLovinator" />
|
||||
<meta name="keywords" content="Twitch, drops" />
|
||||
<meta name="robots" content="index, follow" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Twitch drops</title>
|
||||
<link rel="stylesheet" href="{% static 'css/bootstrap.min.css' %}">
|
||||
<link rel="stylesheet" href="{% static 'css/style.css' %}">
|
||||
<link rel="stylesheet" href="{% static 'css/bootstrap.min.css' %}" />
|
||||
<link rel="stylesheet" href="{% static 'css/style.css' %}" />
|
||||
</head>
|
||||
|
||||
<body data-bs-spy="scroll" data-bs-target=".toc" data-bs-offset="-200" tabindex="0">
|
||||
<body data-bs-spy="scroll"
|
||||
data-bs-target=".toc"
|
||||
data-bs-offset="-200"
|
||||
tabindex="0">
|
||||
{% include "partials/alerts.html" %}
|
||||
<article class="container mt-5">
|
||||
{% include "partials/header.html" %}
|
||||
@ -24,5 +25,4 @@
|
||||
</article>
|
||||
<script src="{% static 'js/bootstrap.min.js' %}"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
|
@ -1,106 +1,141 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container">
|
||||
<h2>{{ game.name }}</h2>
|
||||
<img src="{{ game.box_art_url }}" alt="{{ game.name }} box art" height="283" width="212">
|
||||
<h3>Game Details</h3>
|
||||
<table class="table table-hover table-sm table-striped" cellspacing="0">
|
||||
<tr>
|
||||
<td><strong>Twitch ID:</strong></td>
|
||||
<td>{{ game.pk }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Game URL:</strong></td>
|
||||
<td><a href="{{ game.game_url }}" target="_blank">{{ game.game_url }}</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Game name:</strong></td>
|
||||
<td>{{ game.name }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Game box art URL:</strong></td>
|
||||
<td><a href="{{ game.box_art_url }}" target="_blank">{{ game.box_art_url }}</a></td>
|
||||
</tr>
|
||||
</table>
|
||||
<h3>Organization</h3>
|
||||
<table class="table table-hover table-sm table-striped" cellspacing="0">
|
||||
<tr>
|
||||
{% if game.org %}
|
||||
<td><a href="#">{{ game.org.name }} -
|
||||
<span class="text-muted">{{ game.org.pk }}</span></a></td>
|
||||
{% else %}
|
||||
<td>No organization associated with this game.</td>
|
||||
{% endif %}
|
||||
</tr>
|
||||
</table>
|
||||
<h3>Drop Campaigns</h3>
|
||||
{% if game.drop_campaigns.all %}
|
||||
{% for drop_campaign in game.drop_campaigns.all %}
|
||||
<br>
|
||||
<h2>{{ drop_campaign.name }}</h2>
|
||||
<table class="table table-hover table-sm table-striped" cellspacing="0">
|
||||
<tr>
|
||||
<td><strong>Campaign Name:</strong></td>
|
||||
<td>{{ drop_campaign.name }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><img src="{{ drop_campaign.image_url }}" alt="{{ drop_campaign.name }} image"></td>
|
||||
<td>
|
||||
<p><strong>Created at:</strong>
|
||||
{{ drop_campaign.created_at }}
|
||||
</p>
|
||||
<p><strong>Modified at:</strong>
|
||||
{{ drop_campaign.modified_at }}
|
||||
</p>
|
||||
<p><strong>Status:</strong>
|
||||
{{ drop_campaign.status }}
|
||||
</p>
|
||||
<p><strong>Description:</strong>
|
||||
{{ drop_campaign.description }}
|
||||
</p>
|
||||
<p><strong>Starts at:</strong>
|
||||
{{ drop_campaign.starts_at }}
|
||||
</p>
|
||||
<p><strong>Ends at:</strong>
|
||||
{{ drop_campaign.ends_at }}
|
||||
</p>
|
||||
<p><strong>More details:</strong>
|
||||
<a href="{{ drop_campaign.details_url }}" target="_blank">{{ drop_campaign.details_url }}</a>
|
||||
</p>
|
||||
<p><strong>Account Link:</strong>
|
||||
<a href="{{ drop_campaign.account_link_url }}"
|
||||
target="_blank">{{ drop_campaign.account_link_url }}</a>
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
{% if drop_campaign.drops.all %}
|
||||
<table class="table table-hover table-sm table-striped" cellspacing="0">
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Item Name</th>
|
||||
<th>Minutes</th>
|
||||
<th>Image</th>
|
||||
<th>Benefit Name</th>
|
||||
</tr>
|
||||
{% for item in drop_campaign.drops.all %}
|
||||
<tr>
|
||||
<td>{{ item.pk }}</td>
|
||||
<td>{{ item.name }}</td>
|
||||
<td>{{ item.required_minutes_watched }}</td>
|
||||
{% for benefit in item.benefits.all %}
|
||||
<td><img src="{{ benefit.image_url }}" alt="{{ benefit.name }} reward image" height="50" width="50"></td>
|
||||
<td>{{ benefit.name }}</td>
|
||||
<div class="container">
|
||||
<h2>{{ game.name }}</h2>
|
||||
<img src="{{ game.box_art_url }}"
|
||||
alt="{{ game.name }} box art"
|
||||
height="283"
|
||||
width="212" />
|
||||
<h3>Game Details</h3>
|
||||
<table class="table table-hover table-sm table-striped" cellspacing="0">
|
||||
<tr>
|
||||
<td>
|
||||
<strong>Twitch ID:</strong>
|
||||
</td>
|
||||
<td>{{ game.pk }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<strong>Game URL:</strong>
|
||||
</td>
|
||||
<td>
|
||||
<a href="{{ game.game_url }}" target="_blank">{{ game.game_url }}</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<strong>Game name:</strong>
|
||||
</td>
|
||||
<td>{{ game.name }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<strong>Game box art URL:</strong>
|
||||
</td>
|
||||
<td>
|
||||
<a href="{{ game.box_art_url }}" target="_blank">{{ game.box_art_url }}</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<h3>Organization</h3>
|
||||
<table class="table table-hover table-sm table-striped" cellspacing="0">
|
||||
<tr>
|
||||
{% if game.org %}
|
||||
<td>
|
||||
<a href="#">{{ game.org.name }} -
|
||||
<span class="text-muted">{{ game.org.pk }}</span></a>
|
||||
</td>
|
||||
{% else %}
|
||||
<td>No organization associated with this game.</td>
|
||||
{% endif %}
|
||||
</tr>
|
||||
</table>
|
||||
<h3>Drop Campaigns</h3>
|
||||
{% if game.drop_campaigns.all %}
|
||||
{% for drop_campaign in game.drop_campaigns.all %}
|
||||
<br />
|
||||
<h2>{{ drop_campaign.name }}</h2>
|
||||
<table class="table table-hover table-sm table-striped" cellspacing="0">
|
||||
<tr>
|
||||
<td>
|
||||
<strong>Campaign Name:</strong>
|
||||
</td>
|
||||
<td>{{ drop_campaign.name }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<img src="{{ drop_campaign.image_url }}"
|
||||
alt="{{ drop_campaign.name }} image" />
|
||||
</td>
|
||||
<td>
|
||||
<p>
|
||||
<strong>Created at:</strong>
|
||||
{{ drop_campaign.created_at }}
|
||||
</p>
|
||||
<p>
|
||||
<strong>Modified at:</strong>
|
||||
{{ drop_campaign.modified_at }}
|
||||
</p>
|
||||
<p>
|
||||
<strong>Status:</strong>
|
||||
{{ drop_campaign.status }}
|
||||
</p>
|
||||
<p>
|
||||
<strong>Description:</strong>
|
||||
{{ drop_campaign.description }}
|
||||
</p>
|
||||
<p>
|
||||
<strong>Starts at:</strong>
|
||||
{{ drop_campaign.starts_at }}
|
||||
</p>
|
||||
<p>
|
||||
<strong>Ends at:</strong>
|
||||
{{ drop_campaign.ends_at }}
|
||||
</p>
|
||||
<p>
|
||||
<strong>More details:</strong>
|
||||
<a href="{{ drop_campaign.details_url }}" target="_blank">{{ drop_campaign.details_url }}</a>
|
||||
</p>
|
||||
<p>
|
||||
<strong>Account Link:</strong>
|
||||
<a href="{{ drop_campaign.account_link_url }}" target="_blank">{{ drop_campaign.account_link_url }}</a>
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
{% if drop_campaign.drops.all %}
|
||||
<table class="table table-hover table-sm table-striped" cellspacing="0">
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Item Name</th>
|
||||
<th>Minutes</th>
|
||||
<th>Image</th>
|
||||
<th>Benefit Name</th>
|
||||
</tr>
|
||||
{% for item in drop_campaign.drops.all %}
|
||||
<tr>
|
||||
<td>{{ item.pk }}</td>
|
||||
<td>{{ item.name }}</td>
|
||||
<td>{{ item.required_minutes_watched }}</td>
|
||||
{% for benefit in item.benefits.all %}
|
||||
<td>
|
||||
<img src="{{ benefit.image_url }}"
|
||||
alt="{{ benefit.name }} reward image"
|
||||
height="50"
|
||||
width="50" />
|
||||
</td>
|
||||
<td>{{ benefit.name }}</td>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
{% else %}
|
||||
<p>No items associated with this drop campaign.</p>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
{% else %}
|
||||
<p>No items associated with this drop campaign.</p>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<p>No drop campaigns associated with this game.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p>No drop campaigns associated with this game.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock content %}
|
||||
|
@ -1,34 +1,37 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container mt-4">
|
||||
{% for game in games %}
|
||||
<div class="card mb-4 shadow-sm">
|
||||
<div class="row g-0">
|
||||
<div class="col-md-2">
|
||||
<img src="{{ game.box_art_url }}" alt="{{ game.name }} box art" class="img-fluid rounded-start"
|
||||
height="283" width="212" loading="lazy">
|
||||
</div>
|
||||
<div class="col-md-10">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title h5">
|
||||
<a href="https://www.twitch.tv/directory/category/{{ game.slug }}"
|
||||
class="text-decoration-none">{{ game.name }}</a>
|
||||
</h2>
|
||||
<div>
|
||||
<a href="{% url 'game' game.pk %}" class="text-decoration-none">See previous drops</a>
|
||||
<div class="container mt-4">
|
||||
{% for game in games %}
|
||||
<div class="card mb-4 shadow-sm">
|
||||
<div class="row g-0">
|
||||
<div class="col-md-2">
|
||||
<img src="{{ game.box_art_url }}"
|
||||
alt="{{ game.name }} box art"
|
||||
class="img-fluid rounded-start"
|
||||
height="283"
|
||||
width="212"
|
||||
loading="lazy" />
|
||||
</div>
|
||||
<div>
|
||||
<a href="" class="text-decoration-none">Subscribe to new drops</a>
|
||||
<div class="col-md-10">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title h5">
|
||||
<a href="https://www.twitch.tv/directory/category/{{ game.slug }}"
|
||||
class="text-decoration-none">{{ game.name }}</a>
|
||||
</h2>
|
||||
<div>
|
||||
<a href="{% url 'game' game.pk %}" class="text-decoration-none">See previous drops</a>
|
||||
</div>
|
||||
<div>
|
||||
<a href="" class="text-decoration-none">Subscribe to new drops</a>
|
||||
</div>
|
||||
<div>
|
||||
<a href="" class="text-decoration-none">Subscribe to active drops</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<a href="" class="text-decoration-none">Subscribe to active drops</a>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endblock content %}
|
||||
|
@ -1,176 +1,176 @@
|
||||
{% extends "base.html" %}
|
||||
{% load static %}
|
||||
{% load custom_filters %}
|
||||
{% load time_filters %}
|
||||
{% load custom_filters static time_filters %}
|
||||
{% block content %}
|
||||
<div class="container mt-4">
|
||||
{% include "partials/info_box.html" %}
|
||||
{% include "partials/news.html" %}
|
||||
|
||||
<!-- Reward Campaigns Section -->
|
||||
<section class="reward-campaigns">
|
||||
<h2>
|
||||
Reward Campaigns -
|
||||
<span class="d-inline text-muted">
|
||||
{{ reward_campaigns.count }} campaign{{ reward_campaigns.count|pluralize }}
|
||||
</span>
|
||||
</h2>
|
||||
|
||||
<!-- Loop through reward campaigns -->
|
||||
{% for campaign in reward_campaigns %}
|
||||
<div class="card mb-4 shadow-sm" id="campaign-{{ campaign.twitch_id }}">
|
||||
<div class="row g-0">
|
||||
<!-- Campaign Image -->
|
||||
<div class="col-md-2">
|
||||
<img src="{{ campaign.image_url }}" alt="{{ campaign.name }}" class="img-fluid rounded-start"
|
||||
height="283" width="212" loading="lazy">
|
||||
</div>
|
||||
|
||||
<!-- Campaign Details -->
|
||||
<div class="col-md-10">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title h5">
|
||||
<a href="#campaign-{{ campaign.twitch_id }}" class="plain-text-item">
|
||||
{{ campaign.name }}
|
||||
</a>
|
||||
</h2>
|
||||
<p class="card-text text-muted">{{ campaign.summary }}</p>
|
||||
<p class="mb-2 text-muted">
|
||||
Ends in:
|
||||
<abbr
|
||||
title="{{ campaign.starts_at|date:'l d F H:i %Z' }} - {{ campaign.ends_at|date:'l d F H:i %Z' }}">
|
||||
{{ campaign.ends_at|timesince }}
|
||||
</abbr>
|
||||
</p>
|
||||
<a href="{{ campaign.external_url }}" class="btn btn-primary" target="_blank">Learn More</a>
|
||||
|
||||
<!-- Instructions (if any) -->
|
||||
{% if campaign.instructions %}
|
||||
<div class="mt-3">
|
||||
<h3 class="h6">Instructions</h3>
|
||||
<p>{{ campaign.instructions|safe }}</p>
|
||||
<div class="container mt-4">
|
||||
{% include "partials/info_box.html" %}
|
||||
{% include "partials/news.html" %}
|
||||
<!-- Reward Campaigns Section -->
|
||||
<section class="reward-campaigns">
|
||||
<h2>
|
||||
Reward Campaigns -
|
||||
<span class="d-inline text-muted">{{ reward_campaigns.count }} campaign{{ reward_campaigns.count|pluralize }}</span>
|
||||
</h2>
|
||||
<!-- Loop through reward campaigns -->
|
||||
{% for campaign in reward_campaigns %}
|
||||
<div class="card mb-4 shadow-sm" id="campaign-{{ campaign.twitch_id }}">
|
||||
<div class="row g-0">
|
||||
<!-- Campaign Image -->
|
||||
<div class="col-md-2">
|
||||
<img src="{{ campaign.image_url }}"
|
||||
alt="{{ campaign.name }}"
|
||||
class="img-fluid rounded-start"
|
||||
height="283"
|
||||
width="212"
|
||||
loading="lazy" />
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Rewards (if any) -->
|
||||
{% if campaign.rewards.all %}
|
||||
<div class="mt-3">
|
||||
<h3 class="h6">Rewards</h3>
|
||||
<div class="row row-cols-1 row-cols-md-2 row-cols-lg-3 g-2">
|
||||
{% for reward in campaign.rewards.all %}
|
||||
<div class="col d-flex align-items-center position-relative">
|
||||
<img src="{{ reward.thumbnail_image_url }}" alt="{{ reward.name }} reward image"
|
||||
class="img-fluid rounded me-3" height="50" width="50" loading="lazy">
|
||||
<div><strong>{{ reward.name }}</strong></div>
|
||||
</div>
|
||||
<!-- Campaign Details -->
|
||||
<div class="col-md-10">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title h5">
|
||||
<a href="#campaign-{{ campaign.twitch_id }}" class="plain-text-item">{{ campaign.name }}</a>
|
||||
</h2>
|
||||
<p class="card-text text-muted">{{ campaign.summary }}</p>
|
||||
<p class="mb-2 text-muted">
|
||||
Ends in:
|
||||
<abbr title="{{ campaign.starts_at|date:'l d F H:i %Z' }} - {{ campaign.ends_at|date:'l d F H:i %Z' }}">
|
||||
{{ campaign.ends_at|timesince }}
|
||||
</abbr>
|
||||
</p>
|
||||
<a href="{{ campaign.external_url }}"
|
||||
class="btn btn-primary"
|
||||
target="_blank">Learn More</a>
|
||||
<!-- Instructions (if any) -->
|
||||
{% if campaign.instructions %}
|
||||
<div class="mt-3">
|
||||
<h3 class="h6">Instructions</h3>
|
||||
<p>{{ campaign.instructions|safe }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
<!-- Rewards (if any) -->
|
||||
{% if campaign.rewards.all %}
|
||||
<div class="mt-3">
|
||||
<h3 class="h6">Rewards</h3>
|
||||
<div class="row row-cols-1 row-cols-md-2 row-cols-lg-3 g-2">
|
||||
{% for reward in campaign.rewards.all %}
|
||||
<div class="col d-flex align-items-center position-relative">
|
||||
<img src="{{ reward.thumbnail_image_url }}"
|
||||
alt="{{ reward.name }} reward image"
|
||||
class="img-fluid rounded me-3"
|
||||
height="50"
|
||||
width="50"
|
||||
loading="lazy" />
|
||||
<div>
|
||||
<strong>{{ reward.name }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</section>
|
||||
<!-- Drop Campaigns Section -->
|
||||
<section class="drop-campaigns">
|
||||
<h2>
|
||||
Drop Campaigns -
|
||||
<span class="d-inline text-muted">{{ games.count }} game{{ games.count|pluralize }}</span>
|
||||
</h2>
|
||||
<!-- Loop through games -->
|
||||
{% for game in games %}
|
||||
<div class="card mb-4 shadow-sm">
|
||||
<div class="row g-0">
|
||||
<!-- Game Box Art -->
|
||||
<div class="col-md-2">
|
||||
<img src="{{ game.box_art_url|default:'https://static-cdn.jtvnw.net/ttv-static/404_boxart.jpg' }}"
|
||||
alt="{{ game.name|default:'Game name unknown' }} box art"
|
||||
class="img-fluid rounded-start"
|
||||
height="283"
|
||||
width="212"
|
||||
loading="lazy" />
|
||||
</div>
|
||||
<!-- Game Details -->
|
||||
<div class="col-md-10">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title h5">
|
||||
<a href="{% url 'game' game.twitch_id %}" class="text-decoration-none">{{ game.name|default:'Unknown' }}</a>
|
||||
-
|
||||
<a href="https://www.twitch.tv/directory/category/{{ game.slug|default:'game-name-unknown' }}"
|
||||
class="text-decoration-none text-muted">Twitch</a>
|
||||
</h2>
|
||||
<!-- Loop through campaigns for each game -->
|
||||
{% for campaign in game.drop_campaigns.all %}
|
||||
<div class="mt-4">
|
||||
<h4 class="h6">{{ campaign.name }}</h4>
|
||||
<a href="{{ campaign.details_url }}" class="text-decoration-none">Details</a>
|
||||
{% if campaign.details_url != campaign.account_link_url %}
|
||||
| <a href="{{ campaign.account_link_url }}" class="text-decoration-none">Link Account</a>
|
||||
{% endif %}
|
||||
<p class="mb-2 text-muted">
|
||||
Ends in:
|
||||
<abbr title="{{ campaign.starts_at|date:'l d F H:i' }} - {{ campaign.ends_at|date:'l d F H:i' }}">
|
||||
{{ campaign.ends_at|timeuntil }}
|
||||
</abbr>
|
||||
</p>
|
||||
<!-- Drop Benefits Table -->
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped table-hover align-middle">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Benefit Image</th>
|
||||
<th>Benefit Name</th>
|
||||
<th>Required Minutes Watched</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for drop in campaign.drops.all %}
|
||||
{% if drop.benefits.exists %}
|
||||
{% for benefit in drop.benefits.all %}
|
||||
<tr>
|
||||
<td>
|
||||
<img src="{{ benefit.image_url|default:'https://static-cdn.jtvnw.net/ttv-static/404_boxart.jpg' }}"
|
||||
alt="{{ benefit.name|default:'Unknown' }}"
|
||||
class="img-fluid rounded"
|
||||
height="50"
|
||||
width="50"
|
||||
loading="lazy" />
|
||||
</td>
|
||||
<td>
|
||||
<abbr title="{{ drop.name|default:'Unknown' }}">
|
||||
{{ benefit.name|default:'Unknown' }}
|
||||
</abbr>
|
||||
</td>
|
||||
<td>{{ drop.required_minutes_watched|minutes_to_hours }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<tr>
|
||||
<td>
|
||||
<img src="https://static-cdn.jtvnw.net/ttv-static/404_boxart.jpg"
|
||||
alt="{{ drop.name|default:'Unknown' }}"
|
||||
class="img-fluid rounded"
|
||||
height="50"
|
||||
width="50"
|
||||
loading="lazy" />
|
||||
</td>
|
||||
<td>{{ drop.name|default:'Unknown' }}</td>
|
||||
<td>N/A</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</section>
|
||||
|
||||
<!-- Drop Campaigns Section -->
|
||||
<section class="drop-campaigns">
|
||||
<h2>
|
||||
Drop Campaigns -
|
||||
<span class="d-inline text-muted">{{ games.count }} game{{ games.count|pluralize }}</span>
|
||||
</h2>
|
||||
|
||||
<!-- Loop through games -->
|
||||
{% for game in games %}
|
||||
<div class="card mb-4 shadow-sm">
|
||||
<div class="row g-0">
|
||||
<!-- Game Box Art -->
|
||||
<div class="col-md-2">
|
||||
<img src="{{ game.box_art_url|default:'https://static-cdn.jtvnw.net/ttv-static/404_boxart.jpg' }}"
|
||||
alt="{{ game.name|default:'Game name unknown' }} box art" class="img-fluid rounded-start"
|
||||
height="283" width="212" loading="lazy">
|
||||
</div>
|
||||
|
||||
<!-- Game Details -->
|
||||
<div class="col-md-10">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title h5">
|
||||
<a href="{% url 'game' game.twitch_id %}" class="text-decoration-none">
|
||||
{{ game.name|default:'Unknown' }}
|
||||
</a>
|
||||
-
|
||||
<a href="https://www.twitch.tv/directory/category/{{ game.slug|default:'game-name-unknown' }}"
|
||||
class="text-decoration-none text-muted">Twitch</a>
|
||||
</h2>
|
||||
|
||||
<!-- Loop through campaigns for each game -->
|
||||
{% for campaign in game.drop_campaigns.all %}
|
||||
<div class="mt-4">
|
||||
<h4 class="h6">{{ campaign.name }}</h4>
|
||||
<a href="{{ campaign.details_url }}" class="text-decoration-none">Details</a>
|
||||
{% if campaign.details_url != campaign.account_link_url %}
|
||||
| <a href="{{ campaign.account_link_url }}" class="text-decoration-none">Link Account</a>
|
||||
{% endif %}
|
||||
|
||||
<p class="mb-2 text-muted">
|
||||
Ends in:
|
||||
<abbr
|
||||
title="{{ campaign.starts_at|date:'l d F H:i' }} - {{ campaign.ends_at|date:'l d F H:i' }}">
|
||||
{{ campaign.ends_at|timeuntil }}
|
||||
</abbr>
|
||||
</p>
|
||||
|
||||
<!-- Drop Benefits Table -->
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped table-hover align-middle">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Benefit Image</th>
|
||||
<th>Benefit Name</th>
|
||||
<th>Required Minutes Watched</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for drop in campaign.drops.all %}
|
||||
{% if drop.benefits.exists %}
|
||||
{% for benefit in drop.benefits.all %}
|
||||
<tr>
|
||||
<td>
|
||||
<img src="{{ benefit.image_url|default:'https://static-cdn.jtvnw.net/ttv-static/404_boxart.jpg' }}"
|
||||
alt="{{ benefit.name|default:'Unknown' }}" class="img-fluid rounded"
|
||||
height="50" width="50" loading="lazy">
|
||||
</td>
|
||||
<td>
|
||||
<abbr title="{{ drop.name|default:'Unknown' }}">
|
||||
{{ benefit.name|default:'Unknown' }}
|
||||
</abbr>
|
||||
</td>
|
||||
<td>{{ drop.required_minutes_watched|minutes_to_hours }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<tr>
|
||||
<td>
|
||||
<img src="https://static-cdn.jtvnw.net/ttv-static/404_boxart.jpg"
|
||||
alt="{{ drop.name|default:'Unknown' }}" class="img-fluid rounded"
|
||||
height="50" width="50" loading="lazy">
|
||||
</td>
|
||||
<td>{{ drop.name|default:'Unknown' }}</td>
|
||||
<td>N/A</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</section>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</section>
|
||||
</div>
|
||||
{% endblock content %}
|
||||
|
@ -1,6 +1,10 @@
|
||||
{% for message in messages %}
|
||||
<div class="alert alert-dismissible {{ message.tags }} fade show" role="alert">
|
||||
<div>{{ message | safe }}</div>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="alert alert-dismissible {{ message.tags }} fade show"
|
||||
role="alert">
|
||||
<div>{{ message|safe }}</div>
|
||||
<button type="button"
|
||||
class="btn-close"
|
||||
data-bs-dismiss="alert"
|
||||
aria-label="Close"></button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
@ -1,50 +1,53 @@
|
||||
{% if webhooks %}
|
||||
<div class="card mb-4 shadow-sm" id="info-box">
|
||||
<div class="row g-0">
|
||||
<div class="col-md-10">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title h2">Site news</h2>
|
||||
<div class="mt-auto">
|
||||
{% for webhook in webhooks %}
|
||||
<div class="mt-3">
|
||||
<img src="{{ webhook.avatar }}?size=32" alt="{{ webhook.name }}" class="rounded-circle"
|
||||
height="32" width="32">
|
||||
<a href="{{ webhook.url }}" target="_blank">{{ webhook.name }}</a>
|
||||
<div class="form-check form-switch">
|
||||
<input class="form-check-input" type="checkbox" id="new-drop-switch-daily">
|
||||
<label class="form-check-label" for="new-drop-switch-daily">Daily notification of newly
|
||||
added games to TTVdrops</label>
|
||||
</div>
|
||||
<div class="form-check form-switch">
|
||||
<input class="form-check-input" type="checkbox" id="new-drop-switch-weekly">
|
||||
<label class="form-check-label" for="new-drop-switch-weekly">
|
||||
Weekly notification of newly added games to TTVdrops
|
||||
</label>
|
||||
</div>
|
||||
<br>
|
||||
<div class="form-check form-switch">
|
||||
<input class="form-check-input" type="checkbox" id="new-org-switch-daily">
|
||||
<label class="form-check-label" for="new-org-switch-daily">
|
||||
Daily notification of newly added <abbr
|
||||
title="Organizations are the companies that own the games.">organizations</abbr> to
|
||||
TTVdrops
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-check form-switch">
|
||||
<input class="form-check-input" type="checkbox" id="new-org-switch-weekly">
|
||||
<label class="form-check-label" for="new-org-switch-weekly">
|
||||
Weekly notification of newly added <abbr
|
||||
title="Organizations are the companies that own the games.">organizations</abbr> to
|
||||
TTVdrops
|
||||
</label>
|
||||
</div>
|
||||
<div class="card mb-4 shadow-sm" id="info-box">
|
||||
<div class="row g-0">
|
||||
<div class="col-md-10">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title h2">Site news</h2>
|
||||
<div class="mt-auto">
|
||||
{% for webhook in webhooks %}
|
||||
<div class="mt-3">
|
||||
<img src="{{ webhook.avatar }}?size=32"
|
||||
alt="{{ webhook.name }}"
|
||||
class="rounded-circle"
|
||||
height="32"
|
||||
width="32" />
|
||||
<a href="{{ webhook.url }}" target="_blank">{{ webhook.name }}</a>
|
||||
<div class="form-check form-switch">
|
||||
<input class="form-check-input" type="checkbox" id="new-drop-switch-daily" />
|
||||
<label class="form-check-label" for="new-drop-switch-daily">
|
||||
Daily notification of newly
|
||||
added games to TTVdrops
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-check form-switch">
|
||||
<input class="form-check-input" type="checkbox" id="new-drop-switch-weekly" />
|
||||
<label class="form-check-label" for="new-drop-switch-weekly">
|
||||
Weekly notification of newly added games to TTVdrops
|
||||
</label>
|
||||
</div>
|
||||
<br />
|
||||
<div class="form-check form-switch">
|
||||
<input class="form-check-input" type="checkbox" id="new-org-switch-daily" />
|
||||
<label class="form-check-label" for="new-org-switch-daily">
|
||||
Daily notification of newly added <abbr title="Organizations are the companies that own the games.">organizations</abbr> to
|
||||
TTVdrops
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-check form-switch">
|
||||
<input class="form-check-input" type="checkbox" id="new-org-switch-weekly" />
|
||||
<label class="form-check-label" for="new-org-switch-weekly">
|
||||
Weekly notification of newly added <abbr title="Organizations are the companies that own the games.">organizations</abbr> to
|
||||
TTVdrops
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-muted">No webhooks added yet.</p>
|
||||
<p class="text-muted">No webhooks added yet.</p>
|
||||
{% endif %}
|
||||
|
@ -1,14 +1,12 @@
|
||||
{% extends "base.html" %}
|
||||
{% load static %}
|
||||
{% load campaign_tags %}
|
||||
{% load game_tags %}
|
||||
{% load campaign_tags game_tags static %}
|
||||
{% block content %}
|
||||
<div class="container mt-4">
|
||||
<div class="row">
|
||||
<h2>Reward Campaigns</h2>
|
||||
{% for campaign in reward_campaigns %}
|
||||
{% render_campaign campaign %}
|
||||
{% endfor %}
|
||||
<div class="container mt-4">
|
||||
<div class="row">
|
||||
<h2>Reward Campaigns</h2>
|
||||
{% for campaign in reward_campaigns %}
|
||||
{% render_campaign campaign %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock content %}
|
||||
|
@ -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:
|
||||
return f"{hours}h {remaining_minutes}m"
|
||||
return f"{hours}h"
|
||||
if remaining_minutes > 0:
|
||||
return f"{remaining_minutes}m"
|
||||
return "0m"
|
||||
return f"{remaining_minutes}m" if remaining_minutes > 0 else "0m"
|
||||
|
@ -1,16 +1,21 @@
|
||||
"""Tests for the views in the core app."""
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import pytest
|
||||
from django.http import HttpResponse
|
||||
from django.test import Client
|
||||
from django.urls import reverse
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from django.http import HttpResponse
|
||||
from django.test.client import _MonkeyPatchedWSGIResponse # type: ignore[import]
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_index_view(client: Client) -> None:
|
||||
"""Test index view."""
|
||||
url: str = reverse(viewname="core:index")
|
||||
response: HttpResponse = client.get(url)
|
||||
response: _MonkeyPatchedWSGIResponse = client.get(url)
|
||||
|
||||
assert isinstance(response, HttpResponse)
|
||||
assert response.status_code == 200
|
@ -1,17 +1,16 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from debug_toolbar.toolbar import debug_toolbar_urls
|
||||
from debug_toolbar.toolbar import debug_toolbar_urls # type: ignore[import-untyped]
|
||||
from django.contrib import admin
|
||||
from django.urls import URLPattern, URLResolver, path
|
||||
|
||||
from core.views import WebhooksView, game_view, games_view, index, reward_campaign_view
|
||||
from core.views import game_view, games_view, index, reward_campaign_view
|
||||
|
||||
app_name: str = "core"
|
||||
|
||||
urlpatterns: list[URLPattern | URLResolver] = [
|
||||
path(route="admin/", view=admin.site.urls),
|
||||
path(route="", view=index, name="index"),
|
||||
path(route="webhooks/", view=WebhooksView.as_view(), name="webhooks"),
|
||||
path(route="game/<int:twitch_id>/", view=game_view, name="game"),
|
||||
path(route="games/", view=games_view, name="games"),
|
||||
path(route="reward_campaigns/", view=reward_campaign_view, name="reward_campaigns"),
|
||||
|
161
core/views.py
161
core/views.py
@ -3,48 +3,45 @@ from __future__ import annotations
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import requests_cache
|
||||
from django.db.models import F, Prefetch
|
||||
from django.db.models.manager import BaseManager
|
||||
from django.http import HttpRequest, HttpResponse
|
||||
from django.template.response import TemplateResponse
|
||||
from django.utils import timezone
|
||||
from django.views import View
|
||||
|
||||
from core.models import Benefit, DropCampaign, Game, RewardCampaign, TimeBasedDrop, Webhook
|
||||
from core.models import Benefit, DropCampaign, Game, RewardCampaign, TimeBasedDrop
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from django.db.models.manager import BaseManager
|
||||
from django.db.models.query import QuerySet
|
||||
from django.http import HttpRequest
|
||||
|
||||
logger: logging.Logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_reward_campaigns() -> BaseManager[RewardCampaign]:
|
||||
def get_reward_campaigns() -> QuerySet[RewardCampaign]:
|
||||
"""Get the reward campaigns.
|
||||
|
||||
Returns:
|
||||
BaseManager[RewardCampaign]: The reward campaigns.
|
||||
QuerySet[RewardCampaign]: The reward campaigns.
|
||||
"""
|
||||
return RewardCampaign.objects.all().prefetch_related("rewards").order_by("-created_at")
|
||||
|
||||
|
||||
def get_games_with_drops() -> BaseManager[Game]:
|
||||
def get_games_with_drops() -> QuerySet[Game]:
|
||||
"""Get the games with drops, sorted by when the drop campaigns end.
|
||||
|
||||
Returns:
|
||||
BaseManager[Game]: The games with drops.
|
||||
QuerySet[Game]: The games with drops.
|
||||
"""
|
||||
# Prefetch the benefits for the time-based drops.
|
||||
benefits_prefetch = Prefetch(lookup="benefits", queryset=Benefit.objects.all())
|
||||
active_time_based_drops: BaseManager[TimeBasedDrop] = TimeBasedDrop.objects.filter(
|
||||
active_time_based_drops: QuerySet[TimeBasedDrop] = TimeBasedDrop.objects.filter(
|
||||
ends_at__gte=timezone.now(),
|
||||
starts_at__lte=timezone.now(),
|
||||
).prefetch_related(benefits_prefetch)
|
||||
|
||||
# Prefetch the active time-based drops for the drop campaigns.
|
||||
drops_prefetch = Prefetch(lookup="drops", queryset=active_time_based_drops)
|
||||
active_campaigns: BaseManager[DropCampaign] = DropCampaign.objects.filter(
|
||||
active_campaigns: QuerySet[DropCampaign] = DropCampaign.objects.filter(
|
||||
ends_at__gte=timezone.now(),
|
||||
starts_at__lte=timezone.now(),
|
||||
).prefetch_related(drops_prefetch)
|
||||
@ -69,8 +66,8 @@ def index(request: HttpRequest) -> HttpResponse:
|
||||
HttpResponse: The response object
|
||||
"""
|
||||
try:
|
||||
reward_campaigns: BaseManager[RewardCampaign] = get_reward_campaigns()
|
||||
games: BaseManager[Game] = get_games_with_drops()
|
||||
reward_campaigns: QuerySet[RewardCampaign] = get_reward_campaigns()
|
||||
games: QuerySet[Game] = get_games_with_drops()
|
||||
|
||||
except Exception:
|
||||
logger.exception("Error fetching reward campaigns or games.")
|
||||
@ -107,9 +104,9 @@ def game_view(request: HttpRequest, twitch_id: int) -> HttpResponse:
|
||||
)
|
||||
|
||||
except Game.DoesNotExist:
|
||||
return HttpResponse(status=404)
|
||||
return HttpResponse(status=404, content="Game not found.")
|
||||
except Game.MultipleObjectsReturned:
|
||||
return HttpResponse(status=500)
|
||||
return HttpResponse(status=500, content="Multiple games found with the same Twitch ID.")
|
||||
|
||||
context: dict[str, Any] = {"game": game}
|
||||
return TemplateResponse(request=request, template="game.html", context=context)
|
||||
@ -124,9 +121,9 @@ def games_view(request: HttpRequest) -> HttpResponse:
|
||||
Returns:
|
||||
HttpResponse: The response object.
|
||||
"""
|
||||
games: BaseManager[Game] = Game.objects.all()
|
||||
games: QuerySet[Game] = Game.objects.all()
|
||||
|
||||
context: dict[str, BaseManager[Game] | str] = {"games": games}
|
||||
context: dict[str, QuerySet[Game] | str] = {"games": games}
|
||||
return TemplateResponse(request=request, template="games.html", context=context)
|
||||
|
||||
|
||||
@ -139,132 +136,6 @@ def reward_campaign_view(request: HttpRequest) -> HttpResponse:
|
||||
Returns:
|
||||
HttpResponse: The response object.
|
||||
"""
|
||||
reward_campaigns: BaseManager[RewardCampaign] = RewardCampaign.objects.all()
|
||||
context: dict[str, BaseManager[RewardCampaign]] = {"reward_campaigns": reward_campaigns}
|
||||
reward_campaigns: QuerySet[RewardCampaign] = RewardCampaign.objects.all()
|
||||
context: dict[str, QuerySet[RewardCampaign]] = {"reward_campaigns": reward_campaigns}
|
||||
return TemplateResponse(request=request, template="reward_campaigns.html", context=context)
|
||||
|
||||
|
||||
def get_webhook_data(webhook_url: str) -> dict[str, str]:
|
||||
"""Get the webhook data from the URL.
|
||||
|
||||
Args:
|
||||
webhook_url (str): The webhook URL.
|
||||
|
||||
Returns:
|
||||
dict[str, str]: The webhook data.
|
||||
"""
|
||||
session = requests_cache.CachedSession("webhook_cache")
|
||||
response: requests_cache.OriginalResponse | requests_cache.CachedResponse = session.get(webhook_url)
|
||||
return response.json()
|
||||
|
||||
|
||||
def split_webhook_url(webhook_url: str) -> tuple[str, str]:
|
||||
"""Split the webhook URL into its components.
|
||||
|
||||
Webhooks are in the format:
|
||||
https://discord.com/api/webhooks/{id}/{token}
|
||||
|
||||
Args:
|
||||
webhook_url (str): The webhook URL.
|
||||
|
||||
Returns:
|
||||
tuple[str, str]: The ID and token.
|
||||
"""
|
||||
webhook_id: str = webhook_url.split("/")[-2]
|
||||
webhook_token: str = webhook_url.split("/")[-1]
|
||||
return webhook_id, webhook_token
|
||||
|
||||
|
||||
class WebhooksView(View):
|
||||
"""Render the webhook view page."""
|
||||
|
||||
@staticmethod
|
||||
def post(request: HttpRequest) -> HttpResponse:
|
||||
"""Add a webhook to the list of webhooks.
|
||||
|
||||
Args:
|
||||
request (HttpRequest): The request object.
|
||||
|
||||
Returns:
|
||||
HttpResponse: The response object.
|
||||
"""
|
||||
webhook_url: str | None = request.POST.get("webhook_url")
|
||||
if not webhook_url:
|
||||
return HttpResponse(content="No webhook URL provided.", status=400)
|
||||
|
||||
# Read webhooks from cookie.
|
||||
webhooks_cookies: str | None = request.COOKIES.get("webhooks")
|
||||
webhooks_list: list[str] = webhooks_cookies.split(",") if webhooks_cookies else []
|
||||
|
||||
# Get webhook data.
|
||||
webhook_id, webhook_token = split_webhook_url(webhook_url)
|
||||
webhook_data: dict[str, str] = get_webhook_data(webhook_url)
|
||||
list_of_json_keys: list[str] = ["avatar", "channel_id", "guild_id", "name", "type", "url"]
|
||||
defaults: dict[str, str | None] = {key: webhook_data.get(key) for key in list_of_json_keys}
|
||||
|
||||
# Warn if JSON has more keys than expected.
|
||||
if len(webhook_data.keys()) > len(list_of_json_keys):
|
||||
logger.warning("Unexpected keys in JSON: %s", webhook_data.keys())
|
||||
|
||||
# Add the webhook to the database.
|
||||
new_webhook, created = Webhook.objects.update_or_create(
|
||||
id=webhook_id,
|
||||
token=webhook_token,
|
||||
defaults=defaults,
|
||||
)
|
||||
if created:
|
||||
logger.info("Created webhook '%s'.", new_webhook)
|
||||
|
||||
# Add the new webhook to the list.
|
||||
webhooks_list.append(webhook_url)
|
||||
|
||||
# Remove duplicates.
|
||||
webhooks_list = list(set(webhooks_list))
|
||||
|
||||
# Save the new list of webhooks to the cookie.
|
||||
response: HttpResponse = HttpResponse()
|
||||
response.set_cookie("webhooks", ",".join(webhooks_list))
|
||||
|
||||
# Redirect to the webhooks page.
|
||||
response["Location"] = "/webhooks/"
|
||||
response.status_code = 302
|
||||
return response
|
||||
|
||||
@staticmethod
|
||||
def get(request: HttpRequest) -> HttpResponse:
|
||||
# Read webhooks from cookie.
|
||||
webhooks_cookies: str | None = request.COOKIES.get("webhooks")
|
||||
webhooks_list: list[str] = webhooks_cookies.split(",") if webhooks_cookies else []
|
||||
|
||||
webhooks_from_db: list[Webhook] = []
|
||||
# Get the webhooks from the database.
|
||||
for webhook_url in webhooks_list:
|
||||
webhook_id, webhook_token = split_webhook_url(webhook_url)
|
||||
|
||||
# Check if the webhook is in the database.
|
||||
if not Webhook.objects.filter(id=webhook_id, token=webhook_token).exists():
|
||||
webhook_data: dict[str, str] = get_webhook_data(webhook_url)
|
||||
list_of_json_keys: list[str] = ["avatar", "channel_id", "guild_id", "name", "type", "url"]
|
||||
defaults: dict[str, str | None] = {key: webhook_data.get(key) for key in list_of_json_keys}
|
||||
|
||||
# Warn if JSON has more keys than expected.
|
||||
if len(webhook_data.keys()) > len(list_of_json_keys):
|
||||
logger.warning("Unexpected keys in JSON: %s", webhook_data.keys())
|
||||
|
||||
new_webhook, created = Webhook.objects.update_or_create(
|
||||
id=webhook_id,
|
||||
token=webhook_token,
|
||||
defaults=defaults,
|
||||
)
|
||||
if created:
|
||||
logger.info("Created webhook '%s'.", new_webhook)
|
||||
|
||||
webhooks_from_db.append(new_webhook)
|
||||
|
||||
# If the webhook is in the database, get it from there.
|
||||
else:
|
||||
existing_webhook: Webhook = Webhook.objects.get(id=webhook_id, token=webhook_token)
|
||||
webhooks_from_db.append(existing_webhook)
|
||||
|
||||
context: dict[str, list[Webhook]] = {"webhooks": webhooks_from_db}
|
||||
return TemplateResponse(request=request, template="webhooks.html", context=context)
|
||||
|
Reference in New Issue
Block a user