Add working prototype

This commit is contained in:
2024-06-22 05:33:42 +02:00
parent 67dc4639a0
commit e8f7e55135
60 changed files with 982 additions and 19571 deletions

View File

@ -1,12 +1,4 @@
SECRET_KEY=
DJANGO_SECRET_KEY=
DEBUG=True
ADMIN_EMAIL=
EMAIL_HOST_USER=
EMAIL_HOST_PASSWORD=
REDIS_PASSWORD=
REDIS_HOST=192.168.1.2
POSTGRES_USER=
POSTGRES_PASSWORD=
POSTGRES_HOST=192.168.1.2
POSTGRES_PORT=5433
TUNNEL_TOKEN=

View File

@ -32,7 +32,7 @@ repos:
rev: "1.18.0"
hooks:
- id: django-upgrade
args: [--target-version, "5.0"]
args: [--target-version, "5.1"]
# Run Pyupgrade on all Python files. This will upgrade the code to Python 3.12.
- repo: https://github.com/asottile/pyupgrade
@ -43,7 +43,7 @@ repos:
# An extremely fast Python linter and formatter.
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.4.8
rev: v0.4.10
hooks:
- id: ruff-format
- id: ruff

22
.vscode/launch.json vendored
View File

@ -1,16 +1,26 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "Start webserver",
"type": "python",
"name": "Django: Runserver",
"type": "debugpy",
"request": "launch",
"program": "${workspaceFolder}/manage.py",
"args": [
"runserver"
"runserver",
"--noreload",
"--nothreading"
],
"django": true,
"justMyCode": true
},
{
"name": "python manage.py scrape_twitch",
"type": "debugpy",
"request": "launch",
"program": "${workspaceFolder}/manage.py",
"args": [
"scrape_twitch"
],
"django": true,
"justMyCode": true

View File

@ -1,9 +1,14 @@
{
"cSpell.words": [
"appendonly",
"asgiref",
"logdir",
"memlock",
"networkidle",
"PGID",
"PUID",
"requirepass",
"ttvdrops"
"ttvdrops",
"ulimits"
]
}

View File

@ -1,2 +1,3 @@
# twitch-drop-notifier
Get notified when a new drop is available on Twitch

132
config/settings.py Normal file
View File

@ -0,0 +1,132 @@
import os
from pathlib import Path
import sentry_sdk
from dotenv import find_dotenv, load_dotenv
from platformdirs import user_data_dir
load_dotenv(dotenv_path=find_dotenv(), verbose=True)
DATA_DIR = Path(
user_data_dir(
appname="TTVDrops",
appauthor="TheLovinator",
roaming=True,
ensure_exists=True,
),
)
DEBUG: bool = os.getenv(key="DEBUG", default="True").lower() == "true"
sentry_sdk.init(
dsn="https://35519536b56710e51cac49522b2cc29f@o4505228040339456.ingest.sentry.io/4506447308914688",
environment="Development" if DEBUG else "Production",
send_default_pii=True,
traces_sample_rate=0.2,
profiles_sample_rate=0.2,
)
BASE_DIR: Path = Path(__file__).resolve().parent.parent
ADMINS: list[tuple[str, str]] = [("Joakim Hellsén", "tlovinator@gmail.com")]
WSGI_APPLICATION = "config.wsgi.application"
SECRET_KEY: str = os.getenv("DJANGO_SECRET_KEY", default="")
TIME_ZONE = "Europe/Stockholm"
USE_TZ = True
LANGUAGE_CODE = "en-us"
DECIMAL_SEPARATOR = ","
THOUSAND_SEPARATOR = " "
ROOT_URLCONF = "config.urls"
STATIC_URL = "static/"
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
STATICFILES_DIRS: list[Path] = [BASE_DIR / "static"]
STATIC_ROOT: Path = BASE_DIR / "staticfiles"
STATIC_ROOT.mkdir(exist_ok=True)
if not DEBUG:
ALLOWED_HOSTS: list[str] = ["ttvdrops.lovinator.space"]
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_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")
INSTALLED_APPS: list[str] = [
"core.apps.CoreConfig",
"twitch.apps.TwitchConfig",
"whitenoise.runserver_nostatic",
"django.contrib.admin",
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.messages",
"django.contrib.staticfiles",
]
MIDDLEWARE: list[str] = [
"django.middleware.security.SecurityMiddleware",
"whitenoise.middleware.WhiteNoiseMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
]
TEMPLATES = [
{
"BACKEND": "django.template.backends.django.DjangoTemplates",
"DIRS": [BASE_DIR / "templates"],
"OPTIONS": {
"context_processors": [
"django.template.context_processors.debug",
"django.template.context_processors.request",
"django.contrib.auth.context_processors.auth",
"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 = {
"default": {
"ENGINE": "django.db.backends.sqlite3",
"NAME": DATA_DIR / "ttvdrops.sqlite3",
"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
},
},
}
STORAGES: dict[str, dict[str, str]] = {
"staticfiles": {
"BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage",
},
}

10
config/urls.py Normal file
View File

@ -0,0 +1,10 @@
from django.contrib import admin
from django.urls import include, path
from django.urls.resolvers import URLResolver
app_name: str = "config"
urlpatterns: list[URLResolver] = [
path(route="admin/", view=admin.site.urls),
path(route="", view=include(arg="core.urls")),
]

View File

@ -3,6 +3,6 @@ import os
from django.core.handlers.wsgi import WSGIHandler
from django.core.wsgi import get_wsgi_application
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "core.settings")
os.environ.setdefault(key="DJANGO_SETTINGS_MODULE", value="config.settings")
application: WSGIHandler = get_wsgi_application()

6
core/apps.py Normal file
View File

@ -0,0 +1,6 @@
from django.apps import AppConfig
class CoreConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "core"

View File

@ -1,167 +0,0 @@
import os
from pathlib import Path
import sentry_sdk
from dotenv import find_dotenv, load_dotenv
# Load environment variables from a .env file
load_dotenv(dotenv_path=find_dotenv(), verbose=True)
# Run Django in debug mode
DEBUG: bool = os.getenv(key="DEBUG", default="True").lower() == "true"
# Use Sentry for error reporting
USE_SENTRY: bool = os.getenv(key="USE_SENTRY", default="True").lower() == "true"
if USE_SENTRY:
sentry_sdk.init(
dsn="https://35519536b56710e51cac49522b2cc29f@o4505228040339456.ingest.sentry.io/4506447308914688",
environment="Development" if DEBUG else "Production",
send_default_pii=True,
traces_sample_rate=1.0,
profiles_sample_rate=1.0,
)
# Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR: Path = Path(__file__).resolve().parent.parent
# A list of all the people who get code error notifications. When DEBUG=False and a view raises an exception, Django
ADMINS: list[tuple[str, str]] = [
("Joakim Hellsén", os.getenv("ADMIN_EMAIL", default="")),
]
# The secret key is used for cryptographic signing, and should be set to a unique, unpredictable value.
SECRET_KEY: str = os.getenv("SECRET_KEY", default="")
# A list of strings representing the host/domain names that this Django site can serve.
ALLOWED_HOSTS: list[str] = [
"ttvdrops.lovinator.space",
".localhost",
"127.0.0.1",
]
# The time zone that Django will use to display datetimes in templates and to interpret datetimes entered in forms
TIME_ZONE = "Europe/Stockholm"
# If datetimes will be timezone-aware by default. If True, Django will use timezone-aware datetimes internally.
USE_TZ = True
# Decides which translation is served to all users.
LANGUAGE_CODE = "en-us"
# Default decimal separator used when formatting decimal numbers.
DECIMAL_SEPARATOR = ","
# Use a space as the thousand separator instead of a comma
THOUSAND_SEPARATOR = " "
# Use gmail for sending emails
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_SUBJECT_PREFIX = "[Panso] "
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")
INSTALLED_APPS: list[str] = [
# First-party apps
"twitch_drop_notifier.apps.TwitchDropNotifierConfig",
# Third-party apps
"whitenoise.runserver_nostatic",
# Django apps
"django.contrib.admin",
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.messages",
"django.contrib.staticfiles",
]
MIDDLEWARE: list[str] = [
"django.middleware.security.SecurityMiddleware",
"whitenoise.middleware.WhiteNoiseMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
]
ROOT_URLCONF = "core.urls"
# A list containing the settings for all template engines to be used with Django.
TEMPLATES = [
{
"BACKEND": "django.template.backends.django.DjangoTemplates",
"DIRS": [BASE_DIR / "templates"],
"OPTIONS": {
"context_processors": [
"django.template.context_processors.debug",
"django.template.context_processors.request",
"django.contrib.auth.context_processors.auth",
"django.contrib.messages.context_processors.messages",
],
"loaders": [
(
"django.template.loaders.cached.Loader",
[
"django.template.loaders.filesystem.Loader",
"django.template.loaders.app_directories.Loader",
],
),
],
},
},
]
WSGI_APPLICATION = "core.wsgi.application"
# A dictionary containing the settings for how we should connect to our PostgreSQL database.
DATABASES: dict[str, dict[str, str]] = {
"default": {
"ENGINE": "django.db.backends.postgresql",
"NAME": "ttvdrops",
"USER": os.getenv(key="POSTGRES_USER", default=""),
"PASSWORD": os.getenv(key="POSTGRES_PASSWORD", default=""),
"HOST": os.getenv(key="POSTGRES_HOST", default=""),
"PORT": os.getenv(key="POSTGRES_PORT", default="5432"),
},
}
# URL to use when referring to static files located in STATIC_ROOT.
STATIC_URL = "static/"
# Use a 64-bit integer as a primary key for models that don't have a field with primary_key=True.
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
# The absolute path to the directory where 'python manage.py collectstatic' will copy static files for deployment
STATIC_ROOT: Path = BASE_DIR / "staticfiles"
STATICFILES_DIRS: list[Path] = [BASE_DIR / "static"]
# Use WhiteNoise to serve static files. https://whitenoise.readthedocs.io/en/latest/django.html
STORAGES: dict[str, dict[str, str]] = {
"staticfiles": {
"BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage",
},
}
# Use Redis for caching
REDIS_PASSWORD: str = os.getenv(key="REDIS_PASSWORD", default="")
REDIS_HOST: str = os.getenv(key="REDIS_HOST", default="")
REDIS_PORT: str = os.getenv(key="REDIS_PORT", default="6380")
CACHES: dict[str, dict[str, str]] = {
"default": {
"BACKEND": "django.core.cache.backends.redis.RedisCache",
"LOCATION": f"redis://:{REDIS_PASSWORD}@{REDIS_HOST}:{REDIS_PORT}/0",
},
}

120
core/templates/base.html Normal file
View File

@ -0,0 +1,120 @@
{% load static %}
<!DOCTYPE html>
<html lang="en" data-bs-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="description"
content="{% block description %}
Subscribe to TTVDrop for the latest Twitch drops.
{% endblock description %}">
<meta name="keywords"
content="{% block keywords %}
Twitch, Drops, TTVDrop, Twitch Drops, Twitch Drops Tracker
{% endblock keywords %}">
<meta name="author" content="TheLovinator">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
{% block title %}
<title>Twitch drops</title>
{% endblock title %}
<style>
html {
max-width: 88ch;
padding: calc(1vmin + 0.5rem);
margin-inline: auto;
font-size: clamp(1em, 0.909em + 0.45vmin, 1.25em);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
Helvetica, Arial, sans-serif;
color-scheme: light dark;
}
h1 {
font-size: 2.5rem;
font-weight: 600;
margin: 0;
}
.title {
text-align: center;
}
.search {
display: flex;
justify-content: center;
margin-top: 1rem;
margin-inline: auto;
}
.leftright {
display: flex;
justify-content: center;
}
.left {
margin-right: auto;
}
.right {
margin-left: auto;
}
textarea {
width: 100%;
height: 10rem;
resize: vertical;
}
.messages {
list-style-type: none;
}
.error {
color: red;
}
.success {
color: green;
}
</style>
</head>
<body>
{% if messages %}
<ul class="messages">
{% for message in messages %}
<li {% if message.tags %}class="{{ message.tags }}"{% endif %}>{{ message }}</li>
{% endfor %}
</ul>
{% endif %}
<span class="title">
<h1>
<a href="{% url 'core:index' %}">TTVDrop</a>
</h1>
</span>
<nav>
<small>
<div class="leftright">
<div class="left">Hello.</div>
<div class="right">
<a href="/api/v1/docs">API</a> |
<a href="https://github.com/TheLovinator1/twitch-online-notifier">GitHub</a> |
<a href="https://github.com/sponsors/TheLovinator1">Donate</a>
{% if not user.is_authenticated %}| <a href=''>Login</a>{% endif %}
{% if user.is_authenticated %}| <a href=''>{{ user.username }}</a>{% endif %}
</div>
</div>
</small>
</nav>
<hr />
<main>
{% block content %}<!-- default content -->{% endblock %}
</main>
<hr />
<footer>
<small>
<div class="leftright">
<div class="left">TheLovinator#9276 on Discord</div>
<div class="right">No rights reserved.</div>
</div>
</small>
</footer>
</body>
</html>

75
core/templates/index.html Normal file
View File

@ -0,0 +1,75 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<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, Twitch Drops">
<meta name="robots" content="index, follow">
<title>Twitch Drops</title>
<style>
header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 30px;
}
</style>
</head>
<body>
<h1>Twitch Drops</h1>
{% for organization, org_data in orgs_data.items %}
<header>
<a href="{{ organization.url }}">
<h2>{{ organization.name }}</h2>
</a>
<a href="">Subscribe to {{ organization.name }}</a>
</header>
<ul>
{% for game, game_data in org_data.games.items %}
<li>
<header>
<a href="https://www.twitch.tv/directory/category/{{ game.slug }}">
<h2>{{ game.display_name }}</h2>
</a>
<a href="{{ game.url }}">Subscribe to {{ game.display_name }}</a>
</header>
<ul>
{% for drop_benefit in game_data.drop_benefits %}
<dl>
<dt>
<header>
<a href="{{ drop_benefit.details_url }}">{{ drop_benefit.name }}</a>
</header>
</dt>
<dd>
<img src="{{ drop_benefit.image_asset_url }}"
alt="{{ drop_benefit.name }}"
height="100"
width="100">
</dd>
{% if drop_benefit.entitlement_limit > 1 %}
<dt>Entitlement Limit</dt>
<dd>
{{ drop_benefit.entitlement_limit|default:"N/A" }}
</dd>
{% endif %}
{% if drop_benefit.is_ios_available %}
<dt>iOS Available</dt>
<dd>
{{ drop_benefit.is_ios_available|yesno:"Yes,No" }}
</dd>
{% endif %}
</dl>
{% endfor %}
</ul>
</li>
{% endfor %}
</ul>
{% endfor %}
</body>
</html>

0
core/tests/__init__.py Normal file
View File

22
core/tests/test_views.py Normal file
View File

@ -0,0 +1,22 @@
from typing import TYPE_CHECKING
import pytest
from django.test import Client, RequestFactory
from django.urls import reverse
if TYPE_CHECKING:
from django.http import HttpResponse
@pytest.fixture()
def factory() -> RequestFactory:
"""Factory for creating requests."""
return RequestFactory()
@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)
assert response.status_code == 200

View File

@ -1,7 +1,11 @@
from django.contrib import admin
from django.urls import include, path
from __future__ import annotations
urlpatterns = [
path("admin/", admin.site.urls),
path("", include("twitch_drop_notifier.urls")),
from django.urls import URLPattern, path
from . import views
app_name: str = "core"
urlpatterns: list[URLPattern] = [
path(route="", view=views.index, name="index"),
]

69
core/views.py Normal file
View File

@ -0,0 +1,69 @@
from typing import TYPE_CHECKING
from django.http import HttpRequest, HttpResponse
from django.template.response import TemplateResponse
from twitch.models import (
DropBenefit,
DropCampaign,
Game,
Organization,
TimeBasedDrop,
)
if TYPE_CHECKING:
from django.db.models.manager import BaseManager
def index(request: HttpRequest) -> HttpResponse:
"""/ index page.
Args:
request: The request.
Returns:
HttpResponse: Returns the index page.
"""
organizations: BaseManager[Organization] = Organization.objects.all()
# Organize the data
orgs_data = {}
for org in organizations:
orgs_data[org] = {"games": {}, "drop_campaigns": []}
# Populate the games under each organization through DropBenefit and DropCampaign
for org in organizations:
drop_benefits: BaseManager[DropBenefit] = DropBenefit.objects.filter(
owner_organization=org,
)
games: set[Game] = {benefit.game for benefit in drop_benefits}
for game in games:
if game not in orgs_data[org]["games"]:
orgs_data[org]["games"][game] = {
"drop_benefits": [],
"time_based_drops": [],
}
for benefit in drop_benefits:
orgs_data[org]["games"][benefit.game]["drop_benefits"].append(benefit)
time_based_drops = TimeBasedDrop.objects.filter(
benefits__in=drop_benefits,
).distinct()
for drop in time_based_drops:
for benefit in drop.benefits.all():
if benefit.game in orgs_data[org]["games"]:
orgs_data[org]["games"][benefit.game]["time_based_drops"].append(
drop,
)
drop_campaigns = DropCampaign.objects.filter(owner=org)
for campaign in drop_campaigns:
orgs_data[org]["drop_campaigns"].append(campaign)
return TemplateResponse(
request=request,
template="index.html",
context={"orgs_data": orgs_data},
)

View File

@ -1,58 +1,24 @@
services:
# ttvdrops:
# container_name: ttvdrops
# image: ghcr.io/thelovinator1/ttvdrops:latest
# restart: always
# networks:
# - ttvdrops_redis
# - ttvdrops_db
# - ttvdrops_web
# environment:
# - SECRET_KEY=${SECRET_KEY}
# - DEBUG=${DEBUG}
# - ADMIN_EMAIL=${ADMIN_EMAIL}
# - EMAIL_HOST_USER=${EMAIL_HOST_USER}
# - EMAIL_HOST_PASSWORD=${EMAIL_HOST_PASSWORD}
# - REDIS_HOST=redis
# - REDIS_PASSWORD=${REDIS_PASSWORD}
# - POSTGRES_HOST=postgres
# - POSTGRES_PORT=5432
# - POSTGRES_DB=${POSTGRES_DB}
# - POSTGRES_USER=${POSTGRES_USER}
# - POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
# volumes:
# - /mnt/Fourteen/Docker/ttvdrops/staticfiles:/app/staticfiles
web:
container_name: ttvdrops_web
image: lscr.io/linuxserver/nginx:latest
restart: always
environment:
- PUID=1000
- PGID=1000
- TZ=Europe/Stockholm
expose:
- 80
- 443
volumes:
- /mnt/Fourteen/Docker/ttvdrops/Nginx:/config
networks:
- ttvdrops_web
redis:
container_name: ttvdrops_redis
image: redis:latest
restart: always
garnet:
container_name: garnet
image: "ghcr.io/microsoft/garnet"
user: "1000:1000"
restart: always
ulimits:
memlock: -1
command: ["--auth", "Password", "--password", "${GARNET_PASSWORD}", "--storage-tier", "--logdir", "/logs", "--aof", "--port", "6380"]
ports:
- 6380:6379
command: /bin/sh -c 'redis-server --requirepass ${REDIS_PASSWORD} --appendonly yes'
- "6380:6380"
volumes:
- /mnt/Fourteen/Docker/ttvdrops/Redis:/data
- /Docker/ttvdrops/Garnet/data:/data
- /Docker/ttvdrops/Garnet/logs:/logs
networks:
- ttvdrops_redis
- ttvdrops_garnet
postgres:
container_name: ttvdrops_postgres
image: postgres:16
# user: "1000:1000"
user: "1000:1000"
ports:
- 5433:5432
restart: always
@ -61,14 +27,10 @@ services:
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
- POSTGRES_DB=${POSTGRES_DB}
volumes:
- /mnt/Fourteen/Docker/ttvdrops/Postgres:/var/lib/postgresql/data
- /Docker/ttvdrops/Postgres:/var/lib/postgresql/data
networks:
- ttvdrops_db
networks:
ttvdrops_redis:
driver: bridge
ttvdrops_garnet:
ttvdrops_db:
driver: bridge
ttvdrops_web:
driver: bridge

View File

@ -7,7 +7,7 @@ import sys
def main() -> None:
"""Run administrative tasks."""
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "core.settings")
os.environ.setdefault(key="DJANGO_SETTINGS_MODULE", value="config.settings")
try:
from django.core.management import execute_from_command_line # noqa: PLC0415
except ImportError as exc:
@ -16,9 +16,7 @@ def main() -> None:
"available on your PYTHONPATH environment variable? Did you "
"forget to activate a virtual environment?"
)
raise ImportError(
msg,
) from exc
raise ImportError(msg) from exc
execute_from_command_line(sys.argv)

1226
poetry.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -1,32 +1,3 @@
[tool.poetry]
name = "twitch-drop-notifier"
version = "0.1.0"
description = ""
authors = ["Joakim Hellsén <tlovinator@gmail.com>"]
readme = "README.md"
[tool.poetry.dependencies]
python = "^3.12"
django = "^5.0.6"
whitenoise = { extras = ["brotli"], version = "^6.6.0" }
sentry-sdk = { extras = ["django"], version = "^1.45.0" }
psycopg = { extras = ["binary"], version = "^3.1.19" }
redis = { extras = ["hiredis"], version = "^5.0.5" }
playwright = "^1.44.0"
selectolax = "^0.3.17"
django-simple-history = "^3.7.0"
pillow = "^10.3.0"
python-dotenv = "^1.0.1"
[tool.poetry.group.dev.dependencies]
pre-commit = "^3.7.1"
djlint = "^1.34.1"
[build-system]
build-backend = "poetry.core.masonry.api"
requires = ["poetry-core"]
[tool.ruff]
# https://docs.astral.sh/ruff/settings/
target-version = "py312"
@ -59,7 +30,14 @@ lint.ignore = [
"S101", # asserts allowed in tests...
"S311", # Standard pseudo-random generators are not suitable for cryptographic purposes
]
"**/migrations/**" = [
"RUF012", # Checks for mutable default values in class attributes.
]
[tool.djlint]
profile = "django"
format_attribute_template_tags = true
[tool.pytest.ini_options]
DJANGO_SETTINGS_MODULE = "config.settings"
python_files = ["test_*.py"]

5
requirements-dev.txt Normal file
View File

@ -0,0 +1,5 @@
djlint
pre-commit
ruff
pytest
pytest-django

12
requirements.txt Normal file
View File

@ -0,0 +1,12 @@
django-simple-history
django>=0.0.0.dev0
pillow
playwright
psycopg[binary]
python-dotenv
redis[hiredis]
selectolax
sentry-sdk[django]
whitenoise[brotli]
platformdirs
playwright

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

12068
static/bootstrap.css vendored

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

4494
static/bootstrap.js vendored

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

View File

@ -1,33 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 36 36">
<path fill="#99AAB5"
d="M30.016 33.887c-.804.654-1.278.708-1.357 1.156-.151.855.855.855 1.86.754 1.006-.101 1.106-.905.905-1.86-.201-.955-1.408-.05-1.408-.05zm-16.591 0c-.804.654-1.278.708-1.357 1.156-.151.855.855.855 1.86.754 1.006-.101 1.106-.905.905-1.86s-1.408-.05-1.408-.05z" />
<path fill="#66757F"
d="M25.039 33.133c-.804.654-1.278.708-1.357 1.156-.151.855.855.855 1.86.754 1.006-.101 1.106-.905.905-1.86-.202-.955-1.408-.05-1.408-.05zm-15.938 0c-.804.654-1.278.708-1.357 1.156-.151.855.855.855 1.86.754 1.006-.101 1.106-.905.905-1.86s-1.408-.05-1.408-.05z" />
<path fill="#66757F"
d="M19.446 23.182c1.879.01 2.338.358 2.734 1.367 1.269 3.234 1.958 2.001 2.264 4.419.311 2.453.272 4.449.708 4.676 1.004.523 2.59.817 1.11-4.995-.396-1.555-.783-4.266-.268-5.843.753-2.303.744-4.007.885-4.641-7.49.29-9.145 5.008-7.433 5.017z" />
<path fill="#CCD6DD"
d="M28.256 16.743c1.993-1.268 3.117-1.982 3.117-6.586 0-.529-.073-1.408-.603-1.408s-.352.526-.352 1.056c0 3.669-1.063 4.679-3.005 5.594-1.517-1.249-4.696-1.236-7.734-1.236-4.181 0-7.57 1.582-7.57 4.866s.89 4.571 5.071 4.571c1.45 0 2.856-.284 4.529-.647 1.837-.398 2.335.63 2.941 1.529 1.941 2.882 2.823 1.706 3.646 3.999.836 2.328 1.231 4.284 1.706 4.411 1.094.293 2.705.235 0-5.117-.724-1.432-1.69-3.995-1.529-5.646.235-2.411-.143-4.073-.143-4.723 0-.123-.062-.561-.074-.663z" />
<path fill="#66757F"
d="M9.702 32.226c-1.444-.38.837-6.535-1.191-8.533-1.355-1.334 1.369-7.759 2.854-7.596 1.483.163.692 4.949.484 6.839-.144 1.308-1.288 5.351-.726 7.671.323 1.336-.694 1.811-1.421 1.619z" />
<path fill="#66757F"
d="M12.318 31.59c-.147 1.785-1.27 2.066-2.616 1.955-1.346-.111-2.408-.571-2.261-2.356s1.357-3.143 2.704-3.033c1.347.111 2.32 1.649 2.173 3.434z" />
<path fill="#CCD6DD"
d="M13.772 32.472c-1.489-.111-.364-6.578-2.722-8.174-1.575-1.066-.064-7.879 1.425-7.988 1.488-.109 1.58 4.741 1.719 6.637.096 1.312-.294 5.496.681 7.675.561 1.254-.352 1.906-1.103 1.85z" />
<path fill="#99AAB5"
d="M20.914 18.184c0 4.6-1.198 7.245-5.767 7.791-5.644.675-10.032-2.963-9.608-7.544.528-5.707 3.78-7.425 7.29-7.431 4.601-.008 8.085 2.582 8.085 7.184z" />
<path fill="#99AAB5"
d="M22.575 18.402c0 4.6-2.859 7.027-7.428 7.573-5.644.675-10.032-2.963-9.608-7.544.528-5.707 3.772-7.665 7.282-7.671 2.598-.005 5.43 0 7.179 2.607 2.123.561 2.575 3.032 2.575 5.035zm-6.132 13.409c.18 1.782-.873 2.262-2.217 2.398-1.344.136-2.471-.124-2.652-1.905-.18-1.782.763-3.338 2.108-3.474 1.344-.136 2.581 1.199 2.761 2.981z" />
<path fill="#66757F"
d="M27.758 31.562c-.043 1.79-1.053 2.138-2.287 2.109-1.234-.029-2.226-.425-2.183-2.215.043-1.79 1.078-3.219 2.312-3.19 1.234.029 2.2 1.506 2.158 3.296z" />
<path fill="#99AAB5"
d="M32.689 31.553c.374 1.751-.528 2.324-1.735 2.582s-2.264.103-2.638-1.648.302-3.382 1.509-3.639c1.207-.258 2.49.953 2.864 2.705zm.512-20.467c.093 1.789-1.087 3.305-2.634 3.385-1.547.08-2.878-1.306-2.971-3.095-.093-1.789.245-4.364 1.792-4.444 1.547-.08 3.72 2.365 3.813 4.154z" />
<path fill="#CCD6DD"
d="M13.231 7.67c.328 1.726-1.846 3.731-4.913 4.172-6.15.884-7.16-2.459-7.291-3.511-.104-.834 2.703-1.177 6.395-3.149 4.572-2.442 5.481.762 5.809 2.488z" />
<path fill="#66757F"
d="M14.179 3.118c-.044-1.161-.352-3.039-3.677-3.039-1.397 0-3.713.424-3.814 1.832-1.81-.351-2.883 1.772-2.287 2.815.619 1.082 1.248 1.099 3.683.654.923-.169 2.007.577 1.801 1.493l-.078.333c-.28 1.191-1.561 2.861-1.561 4.308 0 1.396.967 2.42 2.719 2.36-.119 1.515 1.23 3.12 3.414 3.12 3.115 0 4.424-1.961 4.223-5.631-.081-1.481-.654-3.117-1.81-4.072-.049-2.313-.954-3.972-2.613-4.173z" />
<path fill="#292F33"
d="M2.521 8.864c.001.623.022 1.247-.589 1.363-.372.071-1.213-.281-1.423-1.138-.184-.756.286-1.202.898-1.319.611-.116 1.113.305 1.114 1.094z" />
<circle fill="#292F33" cx="7.715" cy="6.871" r="1" />
<path fill="#99AAB5"
d="M23.341 13.677c1.414.069 2.967 1.455 2.402 2.714s-1.616 1.537-2.564 1.408c-1.106-.151-2.492-.819-2.279-2.594.117-.976 1.111-1.593 2.441-1.528z" />
</svg>

Before

Width:  |  Height:  |  Size: 4.1 KiB

View File

@ -1,21 +0,0 @@
{
"name": "Twitch drops",
"short_name": "Twitch drops",
"description": "Twitch drops notifier, API and more",
"start_url": "/",
"display": "standalone",
"background_color": "#f69435",
"theme_color": "#f69435",
"icons": [
{
"src": "/icon-192.png",
"type": "image/png",
"sizes": "192x192"
},
{
"src": "/icon-512.png",
"type": "image/png",
"sizes": "512x512"
}
]
}

View File

@ -1,5 +0,0 @@
.container {
width: auto;
max-width: 1280px;
padding: 0 15px;
}

View File

@ -1,90 +0,0 @@
{% load static %}
<!DOCTYPE html>
<html lang="en" data-bs-theme="dark">
<head>
<!-- Head start -->
<meta charset="UTF-8">
<meta name="description"
content="{% block description %}Twitch drops{% endblock %}">
<meta name="keywords"
content="{% block keywords %}Twitch drops{% endblock %}">
<meta name="author" content="TheLovinator">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
{% block title %}<title>Panso</title>{% endblock %}
<link rel="icon" href="{% static 'favicon.ico' %}" sizes="32x32">
<link rel="icon" href="{% static 'icon.svg' %}" type="image/svg+xml">
<link rel="apple-touch-icon" href="{% static 'apple-touch-icon.png' %}">
<link rel="manifest" href="{% static 'manifest.webmanifest' %}">
<link rel="stylesheet" href="{% static 'bootstrap.min.css' %}">
<link rel="stylesheet" href="{% static 'style.css' %}">
<!-- Head end -->
</head>
<body class="d-flex flex-column vh-100">
<!-- Body start -->
<nav class="navbar navbar-expand-md border-bottom border-warning">
<div class="container-fluid">
<button class="navbar-toggler ms-auto"
type="button"
data-bs-toggle="collapse"
data-bs-target="#collapseNavbar">
<span class="navbar-toggler-icon"></span>
</button>
<div class="navbar-collapse collapse" id="collapseNavbar">
<ul class="navbar-nav">
<!-- Home -->
<li class="nav-item">
<a class="nav-link" href="{% url 'twitch_drop_notifier:index' %}">Home</a>
</li>
</ul>
<!-- Donate button, top right corner -->
<ul class="navbar-nav ms-auto">
<li class="nav-item d-none d-md-block">
<a class="nav-link" href="https://github.com/sponsors/TheLovinator1">Donate ❤️</a>
</li>
</ul>
</div>
</div>
</nav>
<!-- Content -->
<div class="container-xl mt-3 mb-3">
{% block content %}<!-- default content -->{% endblock %}
</div>
<!-- Content end -->
<!-- Footer -->
<footer class="footer bg-body-tertiary mt-auto">
<div class="container">
<footer class="d-flex flex-wrap justify-content-between align-items-center">
<!-- License -->
<p class="col-md-4 mb-0 text-muted">
<a href="{% url 'twitch_drop_notifier:terms' %}" class="text-muted">CC BY-SA 4.0</a>
</p>
<!-- Links, right side of footer -->
<ul class="nav col-md-4 justify-content-end">
<!-- GitHub link -->
<li class="nav-item">
<a href="https://github.com/TheLovinator1/panso.se"
class="nav-link px-2 text-muted">GitHub</a>
</li>
<!-- Sitemap link -->
<li class="nav-item">
<a href="/sitemap.xml" class="nav-link px-2 text-muted">Sitemap</a>
</li>
<!-- Privacy link -->
<li class="nav-item">
<a href="{% url 'twitch_drop_notifier:privacy' %}"
class="nav-link px-2 text-muted">Privacy</a>
</li>
<!-- Contact link -->
<li class="nav-item">
<a href="{% url 'twitch_drop_notifier:contact' %}"
class="nav-link px-2 text-muted">Contact</a>
</li>
</ul>
</footer>
</div>
</footer>
<!-- Footer end -->
<script src="{% static 'bootstrap.min.js' %}"></script>
<!-- Body end -->
</body>
</html>

View File

@ -1,42 +0,0 @@
{% extends "base.html" %}
{% block title %}<title>Contact - Panso</title>{% endblock %}
{% block description %}Panso contact page{% endblock %}
{% block keywords %}Panso, Panso.se, Contact{% endblock %}
{% block content %}
<div class="container">
<article>
<header>
<h1>Contact</h1>
</header>
<section>
<p>Feel free to contact me about anything. I will try to respond as soon as possible. Thank you!</p>
</section>
<section>
<h2>GitHub Issues</h2>
<p>
You can create an issue on GitHub <a href="https://github.com/TheLovinator1/panso.se/issues">here</a>.
</p>
</section>
<section>
<h2>Discord</h2>
<p>
You can contact me directly on Discord:
<address>
<code>TheLovinator#9276</code>
</address>
</p>
</section>
<section>
<h2>Email</h2>
<p>
You can contact me via email at either of these addresses:
<address>
<a href="mailto:hello@panso.se">hello@panso.se</a>
<br>
<a href="mailto:tlovinator@gmail.com">tlovinator@gmail.com</a>
</address>
</p>
</section>
</article>
</div>
{% endblock %}

View File

@ -1,20 +0,0 @@
{% extends "base.html" %}
{% block title %}<title>Twitch-drops</title>{% endblock %}
{% block description %}Twitch-drops{% endblock %}
{% block keywords %}Twitch-drops{% endblock %}
{% block content %}
<h1>Index</h1>
<span class="text-body-secondary">This is the index page.</span>
<br>
<br>
{% lorem %} {% lorem %} {% lorem %} {% lorem %}
<br>
<br>
{% lorem %} {% lorem %} {% lorem %} {% lorem %}
<br>
<br>
{% lorem %} {% lorem %} {% lorem %} {% lorem %}
<br>
<br>
{% lorem %} {% lorem %} {% lorem %} {% lorem %}
{% endblock %}

View File

@ -1,114 +0,0 @@
{% extends "base.html" %}
{% block title %}<title>Privacy - Panso</title>{% endblock %}
{% block description %}Panso privacy policy{% endblock %}
{% block keywords %}Panso, Panso.se, Privacy{% endblock %}
{% block content %}
<div class="container">
<article>
<header>
<h1>Privacy Policy</h1>
</header>
<p>
Last Updated:
<time datetime="2020-12-20">
2023-12-20
</time>
</p>
<section>
<h2>What information do we collect?</h2>
<h3>Cloudflare</h3>
<p>
Our website is protected by Cloudflare for security and performance. Please refer to <a href="https://www.cloudflare.com/privacypolicy/">Cloudflare's Privacy Policy</a> for details on how
Cloudflare handles data. We (Panso) can not access any data that Cloudflare collects except for the
following metrics that are displayed in our Cloudflare dashboard:
</p>
<ul>
<li>Unique Visitors</li>
<li>Total requests</li>
<li>Percentage of requests that were cached</li>
<li>Total bandwidth used</li>
<li>Total data served</li>
<li>Data cached</li>
<li>Top Traffic per country</li>
</ul>
<h3>Sentry</h3>
<p>
We use Sentry to track errors on our website. Errors are stored for 30 days and then automatically
deleted. Information that is collected includes:
</p>
<ul>
<li>
The <a href="https://en.wikipedia.org/wiki/Stack_trace">stack trace</a>
</li>
<li>
Browser information (For example: <code>Firefox 122.0</code>)
</li>
<li>
User agent (For example: <code>Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:122.0) Gecko/20100101
Firefox/122.0</code>)
</li>
<li>
Operating system information (For example: <code>Windows >=10</code>)
</li>
<li>
URL (For example: <code>https://panso.se/about/</code>)
</li>
<li>
Headers (For example: <code>Accept-Language: en-US,en;q=0.5</code>)
</li>
</ul>
</section>
<section>
<h2>Purpose of Data Collection</h2>
<p>The data we collect is used for the following purposes:</p>
<ul>
<li>Log Files: To identify and address website errors and improve performance.</li>
<li>Cloudflare: For security and content delivery.</li>
<li>Sentry: To identify and address website errors.</li>
</ul>
<p>None of the data we collect is used for marketing purposes or sold to third parties.</p>
</section>
<section>
<h2>Cookies and Tracking</h2>
<p>We do not use cookies or tracking technologies on our website.</p>
</section>
<section>
<h2>Data Sharing</h2>
<p>
We do not share your data with third parties. However, please review the privacy policies of Cloudflare
and Sentry for information on how they handle data.
</p>
</section>
<section>
<h2>User Rights</h2>
<p>
You have the right to access, correct, or delete your information. For any privacy-related inquiries,
please <a href="{% url 'products:contact' %}">contact us</a>.
</p>
</section>
<section>
<h2>Retention Period</h2>
<p>
Log files may be retained for a limited time for debugging purposes. We do not retain personal
information unless required for legal or business reasons.
</p>
</section>
<section>
<h2>Changes to the Privacy Policy</h2>
<p>
This privacy policy may be updated. Check the "Last Updated" date at the top of this document to see
when it was last updated.
We will notify you of any material changes.
<br>
You can also check the git commit history for changes to this document <a href="https://github.com/TheLovinator1/panso.se/commits/master/templates/privacy.html">here</a>.
</p>
</section>
<section>
<h2>Contact Information</h2>
<p>
For privacy concerns or questions, please <a href="{% url 'products:contact' %}">contact us</a>.
</p>
</section>
</article>
</div>
{% endblock %}

View File

@ -1,51 +0,0 @@
{% extends "base.html" %}
{% block title %}<title>Terms - Panso</title>{% endblock %}
{% block description %}Panso terms of service{% endblock %}
{% block keywords %}Panso, Panso.se, License, Terms{% endblock %}
{% block content %}
<div class="container">
<article>
<header>
<h1>Terms of Service</h1>
</header>
<section>
<h2>License</h2>
<p>
The content on this website is licensed under the
<a href="https://creativecommons.org/licenses/by-sa/4.0/">Creative Commons Attribution-ShareAlike 4.0 International License</a>.
</p>
</section>
<section>
<h2>You are free to:</h2>
<ul>
<li>Share — copy and redistribute the material in any medium or format</li>
<li>Adapt — remix, transform, and build upon the material for any purpose, even commercially.</li>
</ul>
</section>
<section>
<h2>Under the following terms:</h2>
<ul>
<li>
Attribution — You must give appropriate credit, provide a link to the license, and indicate if
changes were made. You may do so in any reasonable manner, but not in any way that suggests the
licensor endorses you or your use.
</li>
<li>
ShareAlike — If you remix, transform, or build upon the material, you must distribute your
contributions under the same license as the original.
</li>
<li>
No additional restrictions — You may not apply legal terms or technological measures that legally
restrict others from doing anything the license permits.
</li>
</ul>
</section>
<section>
<h2>Legal code:</h2>
<p>
The full legal code of the license can be found <a href="https://creativecommons.org/licenses/by-sa/4.0/legalcode">here</a>.
</p>
</section>
</article>
</div>
{% endblock %}

0
twitch/__init__.py Normal file
View File

View File

@ -1,6 +1,6 @@
from django.apps import AppConfig
class TwitchDropNotifierConfig(AppConfig):
class TwitchConfig(AppConfig):
default_auto_field: str = "django.db.models.BigAutoField"
name: str = "twitch_drop_notifier"
name: str = "twitch"

View File

View File

View File

@ -0,0 +1,218 @@
import asyncio
import typing
from pathlib import Path
from typing import TYPE_CHECKING, Any
from asgiref.sync import sync_to_async
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 twitch.models import (
Channel,
DropBenefit,
DropCampaign,
Game,
Organization,
TimeBasedDrop,
User,
)
if TYPE_CHECKING:
from playwright.async_api._generated import BrowserContext, Page
data_dir = Path(
user_data_dir(
appname="TTVDrops",
appauthor="TheLovinator",
roaming=True,
ensure_exists=True,
),
)
if not data_dir:
msg = "DATA_DIR is not set in settings.py"
raise ValueError(msg)
async def insert_data(data: dict) -> None: # noqa: PLR0914
"""Insert data into the database.
Args:
data: The data from Twitch.
"""
user_data: dict = data.get("data", {}).get("user")
if not user_data:
return
user_id = user_data["id"]
drop_campaign_data = user_data["dropCampaign"]
if not drop_campaign_data:
return
# Create or get the organization
owner_data = drop_campaign_data["owner"]
owner, _ = await sync_to_async(Organization.objects.get_or_create)(
id=owner_data["id"],
defaults={"name": owner_data["name"]},
)
# Create or get the game
game_data = drop_campaign_data["game"]
game, _ = await sync_to_async(Game.objects.get_or_create)(
id=game_data["id"],
defaults={
"slug": game_data["slug"],
"display_name": game_data["displayName"],
},
)
# Create the drop campaign
drop_campaign, _ = await sync_to_async(DropCampaign.objects.get_or_create)(
id=drop_campaign_data["id"],
defaults={
"account_link_url": drop_campaign_data["accountLinkURL"],
"description": drop_campaign_data["description"],
"details_url": drop_campaign_data["detailsURL"],
"end_at": drop_campaign_data["endAt"],
"image_url": drop_campaign_data["imageURL"],
"name": drop_campaign_data["name"],
"start_at": drop_campaign_data["startAt"],
"status": drop_campaign_data["status"],
"game": game,
"owner": owner,
},
)
if not drop_campaign_data["allow"]:
return
if not drop_campaign_data["allow"]["channels"]:
return
# Create channels
for channel_data in drop_campaign_data["allow"]["channels"]:
channel, _ = await sync_to_async(Channel.objects.get_or_create)(
id=channel_data["id"],
defaults={
"display_name": channel_data["displayName"],
"name": channel_data["name"],
},
)
await sync_to_async(drop_campaign.channels.add)(channel)
# Create time-based drops
for drop_data in drop_campaign_data["timeBasedDrops"]:
drop_benefit_edges = drop_data["benefitEdges"]
drop_benefits = []
for edge in drop_benefit_edges:
benefit_data = edge["benefit"]
benefit_owner_data = benefit_data["ownerOrganization"]
benefit_owner, _ = await sync_to_async(Organization.objects.get_or_create)(
id=benefit_owner_data["id"],
defaults={"name": benefit_owner_data["name"]},
)
benefit_game_data = benefit_data["game"]
benefit_game, _ = await sync_to_async(Game.objects.get_or_create)(
id=benefit_game_data["id"],
defaults={"name": benefit_game_data["name"]},
)
benefit, _ = await sync_to_async(DropBenefit.objects.get_or_create)(
id=benefit_data["id"],
defaults={
"created_at": benefit_data["createdAt"],
"entitlement_limit": benefit_data["entitlementLimit"],
"image_asset_url": benefit_data["imageAssetURL"],
"is_ios_available": benefit_data["isIosAvailable"],
"name": benefit_data["name"],
"owner_organization": benefit_owner,
"game": benefit_game,
},
)
drop_benefits.append(benefit)
time_based_drop, _ = await sync_to_async(TimeBasedDrop.objects.get_or_create)(
id=drop_data["id"],
defaults={
"required_subs": drop_data["requiredSubs"],
"end_at": drop_data["endAt"],
"name": drop_data["name"],
"required_minutes_watched": drop_data["requiredMinutesWatched"],
"start_at": drop_data["startAt"],
},
)
await sync_to_async(time_based_drop.benefits.set)(drop_benefits)
await sync_to_async(drop_campaign.time_based_drops.add)(time_based_drop)
# Create or get the user
user, _ = await sync_to_async(User.objects.get_or_create)(id=user_id)
await sync_to_async(user.drop_campaigns.add)(drop_campaign)
class Command(BaseCommand):
help = "Scrape Twitch Drops Campaigns with login using Firefox"
async def run(self, playwright: Playwright) -> list[Any]:
profile_dir: Path = Path(data_dir / "firefox-profile")
profile_dir.mkdir(parents=True, exist_ok=True)
browser: BrowserContext = await playwright.firefox.launch_persistent_context(
user_data_dir=profile_dir,
headless=True,
)
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: # noqa: BLE001
self.stdout.write(f"Failed to parse JSON from {response.url}")
page.on("response", handle_response)
await page.goto("https://www.twitch.tv/drops/campaigns")
logged_in = False
while not logged_in:
try:
await page.wait_for_selector(
'div[data-a-target="top-nav-avatar"]',
timeout=30000,
)
logged_in = True
self.stdout.write("Logged in")
except KeyboardInterrupt as e:
raise KeyboardInterrupt from e
except Exception: # noqa: BLE001
await asyncio.sleep(5)
self.stdout.write("Waiting for login")
await page.wait_for_load_state("networkidle")
await browser.close()
for campaign in json_data:
if not isinstance(campaign, dict):
continue
if "dropCampaign" in campaign.get("data", {}).get("user", {}):
await insert_data(campaign)
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)
if __name__ == "__main__":
Command().handle()

View File

@ -0,0 +1,113 @@
# Generated by Django 5.1a1 on 2024-06-20 21:44
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = []
operations = [
migrations.CreateModel(
name="Channel",
fields=[
("id", models.TextField(primary_key=True, serialize=False)),
("display_name", models.TextField(blank=True, null=True)),
("name", models.TextField(blank=True, null=True)),
],
),
migrations.CreateModel(
name="Game",
fields=[
("id", models.TextField(primary_key=True, serialize=False)),
("slug", models.TextField(blank=True, null=True)),
("display_name", models.TextField(blank=True, null=True)),
],
),
migrations.CreateModel(
name="Organization",
fields=[
("id", models.TextField(primary_key=True, serialize=False)),
("name", models.TextField(blank=True, null=True)),
],
),
migrations.CreateModel(
name="DropBenefit",
fields=[
("id", models.TextField(primary_key=True, serialize=False)),
("created_at", models.DateTimeField(blank=True, null=True)),
("entitlement_limit", models.IntegerField(blank=True, null=True)),
("image_asset_url", models.URLField(blank=True, null=True)),
("is_ios_available", models.BooleanField(blank=True, null=True)),
("name", models.TextField(blank=True, null=True)),
(
"game",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to="twitch.game",
),
),
(
"owner_organization",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to="twitch.organization",
),
),
],
),
migrations.CreateModel(
name="TimeBasedDrop",
fields=[
("id", models.TextField(primary_key=True, serialize=False)),
("required_subs", models.IntegerField(blank=True, null=True)),
("end_at", models.DateTimeField(blank=True, null=True)),
("name", models.TextField(blank=True, null=True)),
(
"required_minutes_watched",
models.IntegerField(blank=True, null=True),
),
("start_at", models.DateTimeField(blank=True, null=True)),
("benefits", models.ManyToManyField(to="twitch.dropbenefit")),
],
),
migrations.CreateModel(
name="DropCampaign",
fields=[
("id", models.TextField(primary_key=True, serialize=False)),
("account_link_url", models.URLField(blank=True, null=True)),
("description", models.TextField(blank=True, null=True)),
("details_url", models.URLField(blank=True, null=True)),
("end_at", models.DateTimeField(blank=True, null=True)),
("image_url", models.URLField(blank=True, null=True)),
("name", models.TextField(blank=True, null=True)),
("start_at", models.DateTimeField(blank=True, null=True)),
("status", models.TextField(blank=True, null=True)),
("channels", models.ManyToManyField(to="twitch.channel")),
(
"game",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to="twitch.game",
),
),
(
"owner",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to="twitch.organization",
),
),
("time_based_drops", models.ManyToManyField(to="twitch.timebaseddrop")),
],
),
migrations.CreateModel(
name="User",
fields=[
("id", models.TextField(primary_key=True, serialize=False)),
("drop_campaigns", models.ManyToManyField(to="twitch.dropcampaign")),
],
),
]

View File

@ -0,0 +1,35 @@
# Generated by Django 5.1a1 on 2024-06-22 03:14
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("twitch", "0001_initial"),
]
operations = [
migrations.AlterModelOptions(
name="dropcampaign",
options={"verbose_name_plural": "Drop Campaigns"},
),
migrations.AlterField(
model_name="dropcampaign",
name="game",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="drop_campaigns",
to="twitch.game",
),
),
migrations.AlterField(
model_name="dropcampaign",
name="owner",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="drop_campaigns",
to="twitch.organization",
),
),
]

View File

92
twitch/models.py Normal file
View File

@ -0,0 +1,92 @@
from django.db import models
class Organization(models.Model):
id = models.TextField(primary_key=True)
name = models.TextField(blank=True, null=True)
def __str__(self) -> str:
return self.name or self.id
class Game(models.Model):
id = models.TextField(primary_key=True)
slug = models.TextField(blank=True, null=True)
display_name = models.TextField(blank=True, null=True)
def __str__(self) -> str:
return self.display_name or self.slug or self.id
class Channel(models.Model):
id = models.TextField(primary_key=True)
display_name = models.TextField(blank=True, null=True)
name = models.TextField(blank=True, null=True)
def __str__(self) -> str:
return self.display_name or self.name or self.id
class DropBenefit(models.Model):
id = models.TextField(primary_key=True)
created_at = models.DateTimeField(blank=True, null=True)
entitlement_limit = models.IntegerField(blank=True, null=True)
image_asset_url = models.URLField(blank=True, null=True)
is_ios_available = models.BooleanField(blank=True, null=True)
name = models.TextField(blank=True, null=True)
owner_organization = models.ForeignKey(Organization, on_delete=models.CASCADE)
game = models.ForeignKey(Game, on_delete=models.CASCADE)
def __str__(self) -> str:
return self.name or self.id
class TimeBasedDrop(models.Model):
id = models.TextField(primary_key=True)
required_subs = models.IntegerField(blank=True, null=True)
end_at = models.DateTimeField(blank=True, null=True)
name = models.TextField(blank=True, null=True)
required_minutes_watched = models.IntegerField(blank=True, null=True)
start_at = models.DateTimeField(blank=True, null=True)
benefits = models.ManyToManyField(DropBenefit)
def __str__(self) -> str:
return self.name or self.id
class DropCampaign(models.Model):
id = models.TextField(primary_key=True)
account_link_url = models.URLField(blank=True, null=True)
description = models.TextField(blank=True, null=True)
details_url = models.URLField(blank=True, null=True)
end_at = models.DateTimeField(blank=True, null=True)
image_url = models.URLField(blank=True, null=True)
name = models.TextField(blank=True, null=True)
start_at = models.DateTimeField(blank=True, null=True)
status = models.TextField(blank=True, null=True)
game = models.ForeignKey(
Game,
on_delete=models.CASCADE,
related_name="drop_campaigns",
)
owner = models.ForeignKey(
Organization,
on_delete=models.CASCADE,
related_name="drop_campaigns",
)
channels = models.ManyToManyField(Channel)
time_based_drops = models.ManyToManyField(TimeBasedDrop)
class Meta:
verbose_name_plural = "Drop Campaigns"
def __str__(self) -> str:
return self.name or self.id
class User(models.Model):
id = models.TextField(primary_key=True)
drop_campaigns = models.ManyToManyField(DropCampaign)
def __str__(self) -> str:
return self.id

10
twitch/urls.py Normal file
View File

@ -0,0 +1,10 @@
from __future__ import annotations
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from django.urls import URLPattern
app_name: str = "twitch"
urlpatterns: list[URLPattern] = []

View File

@ -1 +0,0 @@
# Register your models here.

View File

@ -1,767 +0,0 @@
# Generated by Django 5.0 on 2023-12-24 17:53
import django.db.models.deletion
import simple_history.models
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name="Game",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"game_id",
models.TextField(
help_text="The ID of the game.",
verbose_name="Game ID",
),
),
(
"display_name",
models.TextField(
help_text="The display name of the game.",
verbose_name="Game Name",
),
),
(
"slug",
models.TextField(
help_text="The slug of the game.",
verbose_name="Game Slug",
),
),
("image", models.ImageField(blank=True, upload_to="images/")),
("model_created", models.DateTimeField(auto_now_add=True)),
("model_updated", models.DateTimeField(auto_now=True)),
],
options={
"verbose_name": "Game",
"db_table": "game",
"db_table_comment": "A game.",
"ordering": ["display_name"],
},
),
migrations.CreateModel(
name="Owner",
fields=[
(
"owner_id",
models.UUIDField(
editable=False,
help_text="The ID of the owner.",
primary_key=True,
serialize=False,
verbose_name="Owner ID",
),
),
(
"name",
models.TextField(
help_text="The name of the owner.",
verbose_name="Developer Name",
),
),
("image", models.ImageField(blank=True, upload_to="images/")),
("model_created", models.DateTimeField(auto_now_add=True)),
("model_updated", models.DateTimeField(auto_now=True)),
],
options={
"verbose_name": "Owner",
"db_table": "owner",
"db_table_comment": "An owner.",
"ordering": ["name"],
},
),
migrations.CreateModel(
name="Reward",
fields=[
(
"reward_id",
models.UUIDField(
editable=False,
help_text="The ID of the reward.",
primary_key=True,
serialize=False,
verbose_name="Reward ID",
),
),
(
"name",
models.TextField(
help_text="The name of the reward.",
verbose_name="Reward Name",
),
),
(
"required_minutes_watched",
models.IntegerField(
help_text="The required minutes watched to earn the reward.",
verbose_name="Required Minutes Watched",
),
),
(
"is_available_on_ios",
models.BooleanField(
default=False,
help_text="If the reward is available on iOS.",
verbose_name="Available on iOS",
),
),
("image", models.ImageField(blank=True, upload_to="images/")),
(
"start_at",
models.DateTimeField(
help_text="The date and time the reward starts.",
verbose_name="Start At",
),
),
(
"end_at",
models.DateTimeField(
help_text="The date and time the reward ends.",
verbose_name="End At",
),
),
(
"created",
models.DateTimeField(
help_text="The date and time the reward was model_created. From Twitch JSON.",
),
),
(
"model_created",
models.DateTimeField(
auto_now_add=True,
help_text="The date and time the reward was model_created in the database.",
),
),
("model_model_updated", models.DateTimeField(auto_now=True)),
],
options={
"verbose_name": "Reward",
"db_table": "reward",
"db_table_comment": "A reward.",
"ordering": ["name"],
},
),
migrations.CreateModel(
name="TwitchChannel",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"name",
models.TextField(
help_text="The name of the Twitch channel.",
verbose_name="Twitch Channel Name",
),
),
("image", models.ImageField(blank=True, upload_to="images/")),
("is_live", models.BooleanField(default=False)),
("model_created", models.DateTimeField(auto_now_add=True)),
("model_updated", models.DateTimeField(auto_now=True)),
],
options={
"verbose_name": "Twitch Channel",
"db_table": "twitch_channel",
"db_table_comment": "A Twitch channel.",
"ordering": ["name"],
},
),
migrations.CreateModel(
name="HistoricalGame",
fields=[
(
"id",
models.BigIntegerField(
auto_created=True,
blank=True,
db_index=True,
verbose_name="ID",
),
),
(
"game_id",
models.TextField(
help_text="The ID of the game.",
verbose_name="Game ID",
),
),
(
"display_name",
models.TextField(
help_text="The display name of the game.",
verbose_name="Game Name",
),
),
(
"slug",
models.TextField(
help_text="The slug of the game.",
verbose_name="Game Slug",
),
),
("image", models.TextField(blank=True, max_length=100)),
("history_id", models.AutoField(primary_key=True, serialize=False)),
("history_date", models.DateTimeField(db_index=True)),
("history_change_reason", models.CharField(max_length=100, null=True)),
(
"history_type",
models.CharField(
choices=[("+", "Created"), ("~", "Changed"), ("-", "Deleted")],
max_length=1,
),
),
(
"history_user",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="+",
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"verbose_name": "historical Game",
"verbose_name_plural": "historical Games",
"db_table": "game_history",
"ordering": ("-history_date", "-history_id"),
"get_latest_by": ("history_date", "history_id"),
},
bases=(simple_history.models.HistoricalChanges, models.Model),
),
migrations.CreateModel(
name="HistoricalOwner",
fields=[
(
"owner_id",
models.UUIDField(
db_index=True,
editable=False,
help_text="The ID of the owner.",
verbose_name="Owner ID",
),
),
(
"name",
models.TextField(
help_text="The name of the owner.",
verbose_name="Developer Name",
),
),
("image", models.TextField(blank=True, max_length=100)),
("history_id", models.AutoField(primary_key=True, serialize=False)),
("history_date", models.DateTimeField(db_index=True)),
("history_change_reason", models.CharField(max_length=100, null=True)),
(
"history_type",
models.CharField(
choices=[("+", "Created"), ("~", "Changed"), ("-", "Deleted")],
max_length=1,
),
),
(
"history_user",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="+",
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"verbose_name": "historical Owner",
"verbose_name_plural": "historical Owners",
"db_table": "owner_history",
"ordering": ("-history_date", "-history_id"),
"get_latest_by": ("history_date", "history_id"),
},
bases=(simple_history.models.HistoricalChanges, models.Model),
),
migrations.CreateModel(
name="HistoricalReward",
fields=[
(
"reward_id",
models.UUIDField(
db_index=True,
editable=False,
help_text="The ID of the reward.",
verbose_name="Reward ID",
),
),
(
"name",
models.TextField(
help_text="The name of the reward.",
verbose_name="Reward Name",
),
),
(
"required_minutes_watched",
models.IntegerField(
help_text="The required minutes watched to earn the reward.",
verbose_name="Required Minutes Watched",
),
),
(
"is_available_on_ios",
models.BooleanField(
default=False,
help_text="If the reward is available on iOS.",
verbose_name="Available on iOS",
),
),
("image", models.TextField(blank=True, max_length=100)),
(
"start_at",
models.DateTimeField(
help_text="The date and time the reward starts.",
verbose_name="Start At",
),
),
(
"end_at",
models.DateTimeField(
help_text="The date and time the reward ends.",
verbose_name="End At",
),
),
(
"created",
models.DateTimeField(
help_text="The date and time the reward was model_created. From Twitch JSON.",
),
),
(
"model_created",
models.DateTimeField(
blank=True,
editable=False,
help_text="The date and time the reward was model_created in the database.",
),
),
(
"model_model_updated",
models.DateTimeField(blank=True, editable=False),
),
("history_id", models.AutoField(primary_key=True, serialize=False)),
("history_date", models.DateTimeField(db_index=True)),
("history_change_reason", models.CharField(max_length=100, null=True)),
(
"history_type",
models.CharField(
choices=[("+", "Created"), ("~", "Changed"), ("-", "Deleted")],
max_length=1,
),
),
(
"history_user",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="+",
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"verbose_name": "historical Reward",
"verbose_name_plural": "historical Rewards",
"db_table": "reward_history",
"ordering": ("-history_date", "-history_id"),
"get_latest_by": ("history_date", "history_id"),
},
bases=(simple_history.models.HistoricalChanges, models.Model),
),
migrations.CreateModel(
name="HistoricalTwitchChannel",
fields=[
(
"id",
models.BigIntegerField(
auto_created=True,
blank=True,
db_index=True,
verbose_name="ID",
),
),
(
"name",
models.TextField(
help_text="The name of the Twitch channel.",
verbose_name="Twitch Channel Name",
),
),
("image", models.TextField(blank=True, max_length=100)),
("is_live", models.BooleanField(default=False)),
("history_id", models.AutoField(primary_key=True, serialize=False)),
("history_date", models.DateTimeField(db_index=True)),
("history_change_reason", models.CharField(max_length=100, null=True)),
(
"history_type",
models.CharField(
choices=[("+", "Created"), ("~", "Changed"), ("-", "Deleted")],
max_length=1,
),
),
(
"history_user",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="+",
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"verbose_name": "historical Twitch Channel",
"verbose_name_plural": "historical Twitch Channels",
"db_table": "twitch_channel_history",
"ordering": ("-history_date", "-history_id"),
"get_latest_by": ("history_date", "history_id"),
},
bases=(simple_history.models.HistoricalChanges, models.Model),
),
migrations.CreateModel(
name="HistoricalTwitchDrop",
fields=[
(
"id",
models.BigIntegerField(
auto_created=True,
blank=True,
db_index=True,
verbose_name="ID",
),
),
(
"drop_id",
models.TextField(
help_text="The ID of the drop.",
verbose_name="Drop ID",
),
),
(
"name",
models.TextField(
help_text="The name of the drop.",
verbose_name="Drop Name",
),
),
(
"description",
models.TextField(
help_text="The description of the drop.",
verbose_name="Description",
),
),
(
"details_url",
models.URLField(
help_text="The URL to the drop details.",
verbose_name="Details URL",
),
),
(
"how_to_earn",
models.TextField(
help_text="How to earn the drop.",
verbose_name="How to Earn",
),
),
("image", models.TextField(blank=True, max_length=100)),
(
"start_date",
models.DateTimeField(
help_text="The date and time the drop starts.",
verbose_name="Start Date",
),
),
(
"end_date",
models.DateTimeField(
help_text="The date and time the drop ends.",
verbose_name="End Date",
),
),
(
"is_event_based",
models.BooleanField(
default=False,
help_text="If the drop is event based.",
verbose_name="Event Based",
),
),
(
"is_time_based",
models.BooleanField(
default=False,
help_text="If the drop is time based.",
verbose_name="Time Based",
),
),
(
"account_link_url",
models.URLField(
help_text="The URL to link the Twitch account.",
verbose_name="Connection",
),
),
(
"participating_channels",
models.URLField(
help_text="The URL to the Twitch stream.",
verbose_name="Participating Channels",
),
),
(
"status",
models.BooleanField(
default=False,
help_text="If the drop is active.",
verbose_name="Status",
),
),
("history_id", models.AutoField(primary_key=True, serialize=False)),
("history_date", models.DateTimeField(db_index=True)),
("history_change_reason", models.CharField(max_length=100, null=True)),
(
"history_type",
models.CharField(
choices=[("+", "Created"), ("~", "Changed"), ("-", "Deleted")],
max_length=1,
),
),
(
"game",
models.ForeignKey(
blank=True,
db_constraint=False,
null=True,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
to="twitch_drop_notifier.game",
verbose_name="Game",
),
),
(
"history_user",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="+",
to=settings.AUTH_USER_MODEL,
),
),
(
"developer",
models.ForeignKey(
blank=True,
db_constraint=False,
null=True,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
to="twitch_drop_notifier.owner",
verbose_name="Developer",
),
),
(
"reward",
models.ForeignKey(
blank=True,
db_constraint=False,
null=True,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
to="twitch_drop_notifier.reward",
verbose_name="Reward",
),
),
],
options={
"verbose_name": "historical Twitch Drop",
"verbose_name_plural": "historical Twitch Drops",
"db_table": "twitch_drop_history",
"ordering": ("-history_date", "-history_id"),
"get_latest_by": ("history_date", "history_id"),
},
bases=(simple_history.models.HistoricalChanges, models.Model),
),
migrations.CreateModel(
name="TwitchDrop",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"drop_id",
models.TextField(
help_text="The ID of the drop.",
verbose_name="Drop ID",
),
),
(
"name",
models.TextField(
help_text="The name of the drop.",
verbose_name="Drop Name",
),
),
(
"description",
models.TextField(
help_text="The description of the drop.",
verbose_name="Description",
),
),
(
"details_url",
models.URLField(
help_text="The URL to the drop details.",
verbose_name="Details URL",
),
),
(
"how_to_earn",
models.TextField(
help_text="How to earn the drop.",
verbose_name="How to Earn",
),
),
("image", models.ImageField(blank=True, upload_to="images/")),
(
"start_date",
models.DateTimeField(
help_text="The date and time the drop starts.",
verbose_name="Start Date",
),
),
(
"end_date",
models.DateTimeField(
help_text="The date and time the drop ends.",
verbose_name="End Date",
),
),
(
"is_event_based",
models.BooleanField(
default=False,
help_text="If the drop is event based.",
verbose_name="Event Based",
),
),
(
"is_time_based",
models.BooleanField(
default=False,
help_text="If the drop is time based.",
verbose_name="Time Based",
),
),
(
"account_link_url",
models.URLField(
help_text="The URL to link the Twitch account.",
verbose_name="Connection",
),
),
(
"participating_channels",
models.URLField(
help_text="The URL to the Twitch stream.",
verbose_name="Participating Channels",
),
),
(
"status",
models.BooleanField(
default=False,
help_text="If the drop is active.",
verbose_name="Status",
),
),
(
"model_created",
models.DateTimeField(
auto_now_add=True,
help_text="The date and time the drop was model_created.",
),
),
(
"model_updated",
models.DateTimeField(
auto_now=True,
help_text="The date and time the drop was last model_updated.",
),
),
(
"developer",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="twitch_drops",
to="twitch_drop_notifier.owner",
verbose_name="Developer",
),
),
(
"game",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="twitch_drops",
to="twitch_drop_notifier.game",
verbose_name="Game",
),
),
(
"reward",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="twitch_drops",
to="twitch_drop_notifier.reward",
verbose_name="Reward",
),
),
],
options={
"verbose_name": "Twitch Drop",
"db_table": "twitch_drop",
"db_table_comment": "A Twitch Drop.",
"ordering": ["name"],
},
),
]

View File

@ -1,238 +0,0 @@
from typing import ClassVar
from django.db import models
from simple_history.models import HistoricalRecords
class Owner(models.Model):
owner_id = models.UUIDField(
primary_key=True,
help_text="The ID of the owner.",
verbose_name="Owner ID",
editable=False,
)
name = models.TextField(
help_text="The name of the owner.",
verbose_name="Developer Name",
)
image = models.ImageField(upload_to="images/", blank=True)
model_created = models.DateTimeField(auto_now_add=True)
model_updated = models.DateTimeField(auto_now=True)
history = HistoricalRecords(
table_name="owner_history",
excluded_fields=["model_created", "model_updated"],
)
class Meta:
ordering: ClassVar[list[str]] = ["name"]
verbose_name: str = "Owner"
db_table: str = "owner"
db_table_comment: str = "An owner."
def __str__(self) -> str:
return self.name
class Game(models.Model):
game_id = models.TextField(
help_text="The ID of the game.",
verbose_name="Game ID",
)
display_name = models.TextField(
help_text="The display name of the game.",
verbose_name="Game Name",
)
slug = models.TextField(
help_text="The slug of the game.",
verbose_name="Game Slug",
)
image = models.ImageField(upload_to="images/", blank=True)
model_created = models.DateTimeField(auto_now_add=True)
model_updated = models.DateTimeField(auto_now=True)
history = HistoricalRecords(
table_name="game_history",
excluded_fields=["model_created", "model_updated"],
)
class Meta:
ordering: ClassVar[list[str]] = ["display_name"]
verbose_name: str = "Game"
db_table: str = "game"
db_table_comment: str = "A game."
def __str__(self) -> str:
return f"{self.display_name} (www.twitch.tv/directory/category/{self.slug})"
class Reward(models.Model):
reward_id = models.UUIDField(
primary_key=True,
help_text="The ID of the reward.",
verbose_name="Reward ID",
editable=False,
)
name = models.TextField(
help_text="The name of the reward.",
verbose_name="Reward Name",
)
required_minutes_watched = models.IntegerField(
help_text="The required minutes watched to earn the reward.",
verbose_name="Required Minutes Watched",
)
is_available_on_ios = models.BooleanField(
default=False,
help_text="If the reward is available on iOS.",
verbose_name="Available on iOS",
)
image = models.ImageField(upload_to="images/", blank=True)
start_at = models.DateTimeField(
help_text="The date and time the reward starts.",
verbose_name="Start At",
)
end_at = models.DateTimeField(
help_text="The date and time the reward ends.",
verbose_name="End At",
)
created = models.DateTimeField(
help_text="The date and time the reward was model_created. From Twitch JSON.",
)
model_created = models.DateTimeField(
auto_now_add=True,
help_text="The date and time the reward was model_created in the database.",
)
model_model_updated = models.DateTimeField(auto_now=True)
history = HistoricalRecords(
table_name="reward_history",
excluded_fields=["model_model_created", "model_updated"],
)
class Meta:
ordering: ClassVar[list[str]] = ["name"]
verbose_name: str = "Reward"
db_table: str = "reward"
db_table_comment: str = "A reward."
def __str__(self) -> str:
return self.name
class TwitchChannel(models.Model):
name = models.TextField(
help_text="The name of the Twitch channel.",
verbose_name="Twitch Channel Name",
)
image = models.ImageField(upload_to="images/", blank=True)
is_live = models.BooleanField(default=False)
model_created = models.DateTimeField(auto_now_add=True)
model_updated = models.DateTimeField(auto_now=True)
history = HistoricalRecords(
table_name="twitch_channel_history",
excluded_fields=["model_created", "model_updated"],
)
class Meta:
ordering: ClassVar[list[str]] = ["name"]
verbose_name: str = "Twitch Channel"
db_table: str = "twitch_channel"
db_table_comment: str = "A Twitch channel."
def __str__(self) -> str:
return self.name
class TwitchDrop(models.Model):
drop_id = models.TextField(
help_text="The ID of the drop.",
verbose_name="Drop ID",
)
name = models.TextField(
help_text="The name of the drop.",
verbose_name="Drop Name",
)
description = models.TextField(
help_text="The description of the drop.",
verbose_name="Description",
)
details_url = models.URLField(
help_text="The URL to the drop details.",
verbose_name="Details URL",
)
how_to_earn = models.TextField(
help_text="How to earn the drop.",
verbose_name="How to Earn",
)
image = models.ImageField(upload_to="images/", blank=True)
start_date = models.DateTimeField(
help_text="The date and time the drop starts.",
verbose_name="Start Date",
)
end_date = models.DateTimeField(
help_text="The date and time the drop ends.",
verbose_name="End Date",
)
is_event_based = models.BooleanField(
default=False,
help_text="If the drop is event based.",
verbose_name="Event Based",
)
is_time_based = models.BooleanField(
default=False,
help_text="If the drop is time based.",
verbose_name="Time Based",
)
reward = models.ForeignKey(
Reward,
on_delete=models.CASCADE,
related_name="twitch_drops",
verbose_name="Reward",
)
account_link_url = models.URLField(
help_text="The URL to link the Twitch account.",
verbose_name="Connection",
)
participating_channels = models.URLField(
help_text="The URL to the Twitch stream.",
verbose_name="Participating Channels",
)
status = models.BooleanField(
default=False,
help_text="If the drop is active.",
verbose_name="Status",
)
model_created = models.DateTimeField(
auto_now_add=True,
editable=False,
help_text="The date and time the drop was model_created.",
)
model_updated = models.DateTimeField(
auto_now=True,
help_text="The date and time the drop was last model_updated.",
)
history = HistoricalRecords(
table_name="twitch_drop_history",
excluded_fields=["model_created", "model_updated"],
)
game = models.ForeignKey(
Game,
on_delete=models.CASCADE,
related_name="twitch_drops",
verbose_name="Game",
)
developer = models.ForeignKey(
Owner,
on_delete=models.CASCADE,
related_name="twitch_drops",
verbose_name="Developer",
)
class Meta:
ordering: ClassVar[list[str]] = ["name"]
verbose_name: str = "Twitch Drop"
db_table: str = "twitch_drop"
db_table_comment: str = "A Twitch Drop."
def __str__(self) -> str:
return f"{self.name} ({self.game.display_name})"

View File

@ -1 +0,0 @@
# Create your tests here.

View File

@ -1,28 +0,0 @@
from __future__ import annotations
from django.urls import URLPattern, path
from django.views.generic.base import RedirectView
from . import views
app_name: str = "twitch_drop_notifier"
urlpatterns: list[URLPattern] = [
path(route="", view=views.index, name="index"),
path(route="privacy", view=views.privacy, name="privacy"),
path(route="terms", view=views.terms, name="terms"),
path(route="contact", view=views.contact, name="contact"),
path(route="robots.txt", view=views.robots_txt, name="robots-txt"),
path(
route="favicon.ico",
view=RedirectView.as_view(url="/static/favicon.ico", permanent=True),
),
path(
route="icon-512.png",
view=RedirectView.as_view(url="/static/icon-512.png", permanent=True),
),
path(
route="icon-192.png",
view=RedirectView.as_view(url="/static/icon-192.png", permanent=True),
),
]

View File

@ -1,75 +0,0 @@
from __future__ import annotations
from django.http import HttpRequest, HttpResponse
from django.template import loader
from django.views.decorators.http import require_GET
def index(request: HttpRequest) -> HttpResponse:
"""/ index page.
Args:
request: The request.
Returns:
HttpResponse: The response.
"""
template = loader.get_template(template_name="index.html")
context = {}
return HttpResponse(content=template.render(context, request))
robots_txt_content = """User-agent: *
Allow: /
"""
@require_GET
def robots_txt(request: HttpRequest) -> HttpResponse: # noqa: ARG001
"""robots.txt page."""
return HttpResponse(robots_txt_content, content_type="text/plain")
@require_GET
def contact(request: HttpRequest) -> HttpResponse:
"""/contact page.
Args:
request: The request.
Returns:
HttpResponse: The response.
"""
template = loader.get_template(template_name="contact.html")
context = {}
return HttpResponse(content=template.render(context, request))
@require_GET
def privacy(request: HttpRequest) -> HttpResponse:
"""/privacy page.
Args:
request: The request.
Returns:
HttpResponse: The response.
"""
template = loader.get_template(template_name="privacy.html")
context = {}
return HttpResponse(content=template.render(context, request))
@require_GET
def terms(request: HttpRequest) -> HttpResponse:
"""/terms page.
Args:
request: The request.
Returns:
HttpResponse: The response.
"""
template = loader.get_template(template_name="terms.html")
context = {}
return HttpResponse(content=template.render(context, request))