Add working prototype
This commit is contained in:
10
.env.example
10
.env.example
@ -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=
|
||||
|
@ -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
22
.vscode/launch.json
vendored
@ -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
|
||||
|
7
.vscode/settings.json
vendored
7
.vscode/settings.json
vendored
@ -1,9 +1,14 @@
|
||||
{
|
||||
"cSpell.words": [
|
||||
"appendonly",
|
||||
"asgiref",
|
||||
"logdir",
|
||||
"memlock",
|
||||
"networkidle",
|
||||
"PGID",
|
||||
"PUID",
|
||||
"requirepass",
|
||||
"ttvdrops"
|
||||
"ttvdrops",
|
||||
"ulimits"
|
||||
]
|
||||
}
|
||||
|
@ -1,2 +1,3 @@
|
||||
# twitch-drop-notifier
|
||||
|
||||
Get notified when a new drop is available on Twitch
|
||||
|
132
config/settings.py
Normal file
132
config/settings.py
Normal 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
10
config/urls.py
Normal 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")),
|
||||
]
|
@ -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
6
core/apps.py
Normal file
@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class CoreConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "core"
|
167
core/settings.py
167
core/settings.py
@ -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
120
core/templates/base.html
Normal 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
75
core/templates/index.html
Normal 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
0
core/tests/__init__.py
Normal file
22
core/tests/test_views.py
Normal file
22
core/tests/test_views.py
Normal 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
|
14
core/urls.py
14
core/urls.py
@ -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
69
core/views.py
Normal 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},
|
||||
)
|
@ -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
|
||||
|
@ -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
1226
poetry.lock
generated
File diff suppressed because it is too large
Load Diff
@ -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
5
requirements-dev.txt
Normal file
@ -0,0 +1,5 @@
|
||||
djlint
|
||||
pre-commit
|
||||
ruff
|
||||
pytest
|
||||
pytest-django
|
12
requirements.txt
Normal file
12
requirements.txt
Normal 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
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
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
6
static/bootstrap.min.css
vendored
6
static/bootstrap.min.css
vendored
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
7
static/bootstrap.min.js
vendored
7
static/bootstrap.min.js
vendored
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 |
@ -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 |
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
@ -1,5 +0,0 @@
|
||||
.container {
|
||||
width: auto;
|
||||
max-width: 1280px;
|
||||
padding: 0 15px;
|
||||
}
|
@ -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>
|
@ -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 %}
|
@ -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 %}
|
@ -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 %}
|
@ -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
0
twitch/__init__.py
Normal 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"
|
0
twitch/management/__init__.py
Normal file
0
twitch/management/__init__.py
Normal file
0
twitch/management/commands/__init__.py
Normal file
0
twitch/management/commands/__init__.py
Normal file
218
twitch/management/commands/scrape_twitch.py
Normal file
218
twitch/management/commands/scrape_twitch.py
Normal 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()
|
113
twitch/migrations/0001_initial.py
Normal file
113
twitch/migrations/0001_initial.py
Normal 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")),
|
||||
],
|
||||
),
|
||||
]
|
@ -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",
|
||||
),
|
||||
),
|
||||
]
|
0
twitch/migrations/__init__.py
Normal file
0
twitch/migrations/__init__.py
Normal file
92
twitch/models.py
Normal file
92
twitch/models.py
Normal 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
10
twitch/urls.py
Normal 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] = []
|
@ -1 +0,0 @@
|
||||
# Register your models here.
|
@ -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"],
|
||||
},
|
||||
),
|
||||
]
|
@ -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})"
|
@ -1 +0,0 @@
|
||||
# Create your tests here.
|
@ -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),
|
||||
),
|
||||
]
|
@ -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))
|
Reference in New Issue
Block a user