Add support for Reward campaigns

This commit is contained in:
2024-07-31 03:54:07 +02:00
parent 5433e1d9ce
commit 354d66f7bc
24 changed files with 765 additions and 576 deletions

4
.gitignore vendored
View File

@ -158,3 +158,7 @@ cython_debug/
# and can be added to the global gitignore or merged into this file. For a more nuclear # and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder. # option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/ #.idea/
*.json
staticfiles/

View File

@ -29,21 +29,21 @@ repos:
# Automatically upgrade your Django project code # Automatically upgrade your Django project code
- repo: https://github.com/adamchainz/django-upgrade - repo: https://github.com/adamchainz/django-upgrade
rev: "1.19.0" rev: "1.20.0"
hooks: hooks:
- id: django-upgrade - id: django-upgrade
args: [ --target-version, "5.1" ] args: [ --target-version, "5.1" ]
# Run Pyupgrade on all Python files. This will upgrade the code to Python 3.12. # Run Pyupgrade on all Python files. This will upgrade the code to Python 3.12.
- repo: https://github.com/asottile/pyupgrade - repo: https://github.com/asottile/pyupgrade
rev: v3.16.0 rev: v3.17.0
hooks: hooks:
- id: pyupgrade - id: pyupgrade
args: [ "--py312-plus" ] args: [ "--py312-plus" ]
# An extremely fast Python linter and formatter. # An extremely fast Python linter and formatter.
- repo: https://github.com/astral-sh/ruff-pre-commit - repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.5.1 rev: v0.5.5
hooks: hooks:
- id: ruff-format - id: ruff-format
- id: ruff - id: ruff

View File

@ -10,8 +10,10 @@
"PGID", "PGID",
"PUID", "PUID",
"requirepass", "requirepass",
"sitewide",
"socialaccount", "socialaccount",
"ttvdrops", "ttvdrops",
"ulimits" "ulimits",
"xdefiant"
] ]
} }

View File

@ -7,6 +7,11 @@
<div class="col-lg-9"> <div class="col-lg-9">
{% include "partials/info_box.html" %} {% include "partials/info_box.html" %}
{% include "partials/news.html" %} {% include "partials/news.html" %}
<h2>Reward Campaigns</h2>
{% for campaign in reward_campaigns %}
{% include "partials/reward_campaign_card.html" %}
{% endfor %}
<h2>Organizations</h2>
{% for org in orgs %} {% for org in orgs %}
<h2 id="org-{{ org|slugify }}"> <h2 id="org-{{ org|slugify }}">
<a href="#org-{{ org|slugify }}">{{ org }}</a> <a href="#org-{{ org|slugify }}">{{ org }}</a>

View File

@ -14,7 +14,7 @@
<a class="nav-link" href="https://github.com/sponsors/TheLovinator1">Donate</a> <a class="nav-link" href="https://github.com/sponsors/TheLovinator1">Donate</a>
</li> </li>
<li> <li>
<a class="nav-link" href='{% url "core:webhooks" %}'>Webhooks</a> <a class="nav-link" href='{% url "core:reward_campaigns" %}'>Reward campaigns</a>
</li> </li>
</ul> </ul>
</nav> </nav>

View File

@ -7,9 +7,6 @@
<p> <p>
This site allows users to subscribe to Twitch drops notifications. You can choose to be alerted when new drops are found on Twitch or when the drops become available for farming. This site allows users to subscribe to Twitch drops notifications. You can choose to be alerted when new drops are found on Twitch or when the drops become available for farming.
</p> </p>
<p>
You can add a Discord Webhook <a href="{% url 'core:webhooks' %}">here</a>.
</p>
</div> </div>
</div> </div>
</div> </div>

View File

@ -0,0 +1,56 @@
<div class="card mb-4 shadow-sm" id="campaign-{{ campaign.id }}">
<div class="row g-0">
<div class="col-md-2">
<img src="{{ campaign.image.image1_x_url }}"
alt="{{ campaign.name }}"
class="img-fluid rounded-start"
height="283"
width="212"
loading="lazy">
</div>
<div class="col-md-10">
<div class="card-body">
<h2 class="card-title h5">{{ campaign.name }}</h2>
<p class="card-text text-muted">{{ campaign.summary }}</p>
<p>
Starts at: <abbr title="{{ campaign.starts_at|date:'l d F H:i e' }}">{{ campaign.starts_at }}</abbr>
<br>
Ends at: <abbr title="{{ campaign.ends_at|date:'l d F H:i e' }}">{{ campaign.ends_at|timeuntil }}</abbr>
</p>
<a href="{{ campaign.external_url }}"
class="btn btn-primary"
target="_blank">Learn More</a>
{% if campaign.instructions %}
<div class="mt-3">
<h3 class="h6">Instructions</h3>
<p>{{ campaign.instructions }}</p>
</div>
{% endif %}
{% if campaign.rewards.exists %}
<div class="mt-3">
<h3 class="h6">Rewards</h3>
<div class="row row-cols-1 row-cols-md-2 row-cols-lg-3 g-2">
{% for reward in campaign.rewards.all %}
<div class="col d-flex align-items-center position-relative">
<img src="{{ reward.thumbnail_image.image1_x_url }}"
alt="{{ reward.name }} reward image"
class="img-fluid rounded me-3"
height="50"
width="50"
loading="lazy">
<div>
<strong>{{ reward.name }}</strong>
<br>
<a href="{{ reward.redemption_url }}"
class="btn btn-sm btn-link"
target="_blank">Redeem</a>
</div>
</div>
{% endfor %}
</div>
</div>
{% endif %}
</div>
</div>
</div>
</div>

View File

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

View File

@ -24,35 +24,5 @@
</form> </form>
</div> </div>
<h2 class="mt-5">Webhooks</h2> <h2 class="mt-5">Webhooks</h2>
{% if webhooks %}
<ul class="list-group mt-3">
{% for webhook in webhooks %}
<li class="list-group-item d-flex justify-content-between align-items-center">
<div>
<img src="{{ webhook.avatar }}?size=32"
alt="{{ webhook.name }}"
class="rounded-circle"
height="32"
width="32">
<a href="{{ webhook.url }}" target="_blank">{{ webhook.name }}</a>
{% if webhook.status == 'Success' %}
<span class="badge bg-success">Working</span>
{% else %}
<span class="badge bg-danger">Failed</span>
{% endif %}
</div>
<form method="post" action="" class="mb-0">
{% csrf_token %}
<input type="hidden" name="webhook_id" value="{{ webhook.id }}">
<input type="hidden" name="webhook_name" value="{{ webhook.name }}">
<input type="hidden" name="webhook_url" value="{{ webhook.webhook_url }}">
<button type="submit" class="btn btn-sm btn-danger">Delete</button>
</form>
</li>
{% endfor %}
</ul>
{% else %}
<p class="text-muted">No webhooks added yet.</p>
{% endif %}
</div> </div>
{% endblock content %} {% endblock content %}

View File

@ -2,9 +2,7 @@ from __future__ import annotations
from django.urls import URLPattern, URLResolver, path from django.urls import URLPattern, URLResolver, path
from .views.games import GameView from core.views import GameView, RewardCampaignView, index
from .views.index import index
from .views.webhooks import WebhooksView
app_name: str = "core" app_name: str = "core"
@ -15,5 +13,9 @@ urlpatterns: list[URLPattern | URLResolver] = [
view=GameView.as_view(), view=GameView.as_view(),
name="games", name="games",
), ),
path("webhooks/", WebhooksView.as_view(), name="webhooks"), path(
route="reward_campaigns/",
view=RewardCampaignView.as_view(),
name="reward_campaigns",
),
] ]

View File

@ -5,15 +5,16 @@ from typing import TYPE_CHECKING
import hishel import hishel
from django.conf import settings from django.conf import settings
from django.db.models.manager import BaseManager
from django.template.response import TemplateResponse from django.template.response import TemplateResponse
from django.views.generic import ListView
from core.data import WebhookData from core.data import WebhookData
from twitch_app.models import Organization from twitch_app.models import Game, RewardCampaign
if TYPE_CHECKING: if TYPE_CHECKING:
from pathlib import Path from pathlib import Path
from django.db.models.manager import BaseManager
from django.http import HttpRequest, HttpResponse from django.http import HttpRequest, HttpResponse
from httpx import Response from httpx import Response
@ -59,11 +60,24 @@ def get_webhook_data(webhook: str) -> WebhookData:
def index(request: HttpRequest) -> HttpResponse: def index(request: HttpRequest) -> HttpResponse:
"""Render the index page.""" """Render the index page."""
orgs: BaseManager[Organization] = Organization.objects.all() reward_campaigns: BaseManager[RewardCampaign] = RewardCampaign.objects.all()
webhooks: list[WebhookData] = [get_webhook_data(webhook) for webhook in get_webhooks(request)]
return TemplateResponse( return TemplateResponse(
request=request, request=request,
template="index.html", template="index.html",
context={"orgs": orgs, "webhooks": webhooks}, context={"reward_campaigns": reward_campaigns},
) )
class GameView(ListView):
model = Game
template_name: str = "games.html"
context_object_name: str = "games"
paginate_by = 100
class RewardCampaignView(ListView):
model = RewardCampaign
template_name: str = "reward_campaigns.html"
context_object_name: str = "reward_campaigns"
paginate_by = 100

View File

View File

@ -1,16 +0,0 @@
from __future__ import annotations
import logging
from django.views.generic import ListView
from twitch_app.models import Game
logger: logging.Logger = logging.getLogger(__name__)
class GameView(ListView):
model = Game
template_name: str = "games.html"
context_object_name: str = "games"
paginate_by = 100

View File

@ -1,118 +0,0 @@
from __future__ import annotations
import logging
from typing import TYPE_CHECKING, Any
import hishel
from django.conf import settings
from django.contrib import messages
from django.http.response import HttpResponse
from django.views.generic import FormView
from core.data import WebhookData
from core.forms import DiscordSettingForm
from twitch_app.models import Game
if TYPE_CHECKING:
from pathlib import Path
from django.http import HttpRequest
cache_dir: Path = settings.DATA_DIR / "cache"
cache_dir.mkdir(exist_ok=True, parents=True)
storage = hishel.FileStorage(base_path=cache_dir)
controller = hishel.Controller(
cacheable_status_codes=[200, 203, 204, 206, 300, 301, 308, 404, 405, 410, 414, 501],
allow_stale=True,
always_revalidate=True,
)
if TYPE_CHECKING:
from django.http import (
HttpResponse,
)
from httpx import Response
logger: logging.Logger = logging.getLogger(__name__)
def get_webhooks(request: HttpRequest) -> list[str]:
"""Get the webhooks from the cookie."""
cookie: str = request.COOKIES.get("webhooks", "")
return list(filter(None, cookie.split(",")))
def get_avatar(webhook_response: Response) -> str:
"""Get the avatar URL from the webhook response."""
avatar: str = "https://cdn.discordapp.com/embed/avatars/0.png"
if webhook_response.is_success and webhook_response.json().get("id") and webhook_response.json().get("avatar"):
avatar = f'https://cdn.discordapp.com/avatars/{webhook_response.json().get("id")}/{webhook_response.json().get("avatar")}.png'
return avatar
def get_webhook_data(webhook: str) -> WebhookData:
"""Get the webhook data."""
with hishel.CacheClient(storage=storage, controller=controller) as client:
webhook_response: Response = client.get(url=webhook, extensions={"cache_metadata": True})
return WebhookData(
name=webhook_response.json().get("name") if webhook_response.is_success else "Unknown",
url=webhook,
avatar=get_avatar(webhook_response),
status="Success" if webhook_response.is_success else "Failed",
response=webhook_response.text,
)
class WebhooksView(FormView):
model = Game
template_name = "webhooks.html"
form_class = DiscordSettingForm
context_object_name: str = "webhooks"
paginate_by = 100
def get_context_data(self: WebhooksView, **kwargs: dict[str, WebhooksView] | DiscordSettingForm) -> dict[str, Any]:
"""Get the context data for the view."""
context: dict[str, DiscordSettingForm | list[WebhookData]] = super().get_context_data(**kwargs)
webhooks: list[str] = get_webhooks(self.request)
context.update({
"webhooks": [get_webhook_data(webhook) for webhook in webhooks],
"form": DiscordSettingForm(),
})
return context
def form_valid(self: WebhooksView, form: DiscordSettingForm) -> HttpResponse:
"""Handle valid form submission."""
webhook = str(form.cleaned_data["webhook_url"])
with hishel.CacheClient(storage=storage, controller=controller) as client:
webhook_response: Response = client.get(url=webhook, extensions={"cache_metadata": True})
if not webhook_response.is_success:
messages.error(self.request, "Failed to get webhook information. Is the URL correct?")
return self.render_to_response(self.get_context_data(form=form))
webhook_name: str | None = str(webhook_response.json().get("name")) if webhook_response.is_success else None
cookie: str = self.request.COOKIES.get("webhooks", "")
webhooks: list[str] = cookie.split(",")
webhooks = list(filter(None, webhooks))
if webhook in webhooks:
if webhook_name:
messages.error(self.request, f"Webhook {webhook_name} already exists.")
else:
messages.error(self.request, "Webhook already exists.")
return self.render_to_response(self.get_context_data(form=form))
webhooks.append(webhook)
response: HttpResponse = self.render_to_response(self.get_context_data(form=form))
response.set_cookie(key="webhooks", value=",".join(webhooks), max_age=315360000) # 10 years
messages.success(self.request, "Webhook successfully added.")
return response
def form_invalid(self: WebhooksView, form: DiscordSettingForm) -> HttpResponse:
messages.error(self.request, "Failed to add webhook.")
return self.render_to_response(self.get_context_data(form=form))

View File

@ -16,7 +16,7 @@ def main() -> None:
"available on your PYTHONPATH environment variable? Did you " "available on your PYTHONPATH environment variable? Did you "
"forget to activate a virtual environment?" "forget to activate a virtual environment?"
) )
raise ImportError(msg) from exc raise ImportError(msg) from exc # noqa: DOC501, RUF100
execute_from_command_line(sys.argv) execute_from_command_line(sys.argv)

74
poetry.lock generated
View File

@ -317,17 +317,17 @@ files = [
[[package]] [[package]]
name = "django" name = "django"
version = "5.1b1" version = "5.1rc1"
description = "A high-level Python web framework that encourages rapid development and clean, pragmatic design." description = "A high-level Python web framework that encourages rapid development and clean, pragmatic design."
optional = false optional = false
python-versions = ">=3.10" python-versions = ">=3.10"
files = [ files = [
{file = "Django-5.1b1-py3-none-any.whl", hash = "sha256:da04000c01f7c216ec2b1d6d85698f4cf540a8d304c83b7e29d512b19c7da039"}, {file = "Django-5.1rc1-py3-none-any.whl", hash = "sha256:dc162667eb0e66352cd1b6fc2c7a107649ff293cbc8f2a9fdee1a1c0ea3d9e13"},
{file = "Django-5.1b1.tar.gz", hash = "sha256:2c52fb8e630f49f1d5b9555d9be1a0e1b27279cd9aba46161ea8d6d103f6262c"}, {file = "Django-5.1rc1.tar.gz", hash = "sha256:4b3d5c509ccb1528f158afe831d9d98c40efc852eee0d530c5fbe92e6b54cfdf"},
] ]
[package.dependencies] [package.dependencies]
asgiref = ">=3.7.0" asgiref = ">=3.8.1,<4"
sqlparse = ">=0.3.1" sqlparse = ">=0.3.1"
tzdata = {version = "*", markers = "sys_platform == \"win32\""} tzdata = {version = "*", markers = "sys_platform == \"win32\""}
@ -821,13 +821,13 @@ testing = ["pytest", "pytest-benchmark"]
[[package]] [[package]]
name = "pre-commit" name = "pre-commit"
version = "3.7.1" version = "3.8.0"
description = "A framework for managing and maintaining multi-language pre-commit hooks." description = "A framework for managing and maintaining multi-language pre-commit hooks."
optional = false optional = false
python-versions = ">=3.9" python-versions = ">=3.9"
files = [ files = [
{file = "pre_commit-3.7.1-py2.py3-none-any.whl", hash = "sha256:fae36fd1d7ad7d6a5a1c0b0d5adb2ed1a3bda5a21bf6c3e5372073d7a11cd4c5"}, {file = "pre_commit-3.8.0-py2.py3-none-any.whl", hash = "sha256:9a90a53bf82fdd8778d58085faf8d83df56e40dfe18f45b19446e26bf1b3a63f"},
{file = "pre_commit-3.7.1.tar.gz", hash = "sha256:8ca3ad567bc78a4972a3f1a477e94a79d4597e8140a6e0b651c5e33899c3654a"}, {file = "pre_commit-3.8.0.tar.gz", hash = "sha256:8bb6494d4a20423842e198980c9ecf9f96607a07ea29549e180eef9ae80fe7af"},
] ]
[package.dependencies] [package.dependencies]
@ -856,20 +856,20 @@ dev = ["black", "flake8", "flake8-black", "isort", "jupyter-console", "mkdocs",
[[package]] [[package]]
name = "pytest" name = "pytest"
version = "8.2.2" version = "8.3.2"
description = "pytest: simple powerful testing with Python" description = "pytest: simple powerful testing with Python"
optional = false optional = false
python-versions = ">=3.8" python-versions = ">=3.8"
files = [ files = [
{file = "pytest-8.2.2-py3-none-any.whl", hash = "sha256:c434598117762e2bd304e526244f67bf66bbd7b5d6cf22138be51ff661980343"}, {file = "pytest-8.3.2-py3-none-any.whl", hash = "sha256:4ba08f9ae7dcf84ded419494d229b48d0903ea6407b030eaec46df5e6a73bba5"},
{file = "pytest-8.2.2.tar.gz", hash = "sha256:de4bb8104e201939ccdc688b27a89a7be2079b22e2bd2b07f806b6ba71117977"}, {file = "pytest-8.3.2.tar.gz", hash = "sha256:c132345d12ce551242c87269de812483f5bcc87cdbb4722e48487ba194f9fdce"},
] ]
[package.dependencies] [package.dependencies]
colorama = {version = "*", markers = "sys_platform == \"win32\""} colorama = {version = "*", markers = "sys_platform == \"win32\""}
iniconfig = "*" iniconfig = "*"
packaging = "*" packaging = "*"
pluggy = ">=1.5,<2.0" pluggy = ">=1.5,<2"
[package.extras] [package.extras]
dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"]
@ -1091,40 +1091,40 @@ use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"]
[[package]] [[package]]
name = "ruff" name = "ruff"
version = "0.5.1" version = "0.5.5"
description = "An extremely fast Python linter and code formatter, written in Rust." description = "An extremely fast Python linter and code formatter, written in Rust."
optional = false optional = false
python-versions = ">=3.7" python-versions = ">=3.7"
files = [ files = [
{file = "ruff-0.5.1-py3-none-linux_armv6l.whl", hash = "sha256:6ecf968fcf94d942d42b700af18ede94b07521bd188aaf2cd7bc898dd8cb63b6"}, {file = "ruff-0.5.5-py3-none-linux_armv6l.whl", hash = "sha256:605d589ec35d1da9213a9d4d7e7a9c761d90bba78fc8790d1c5e65026c1b9eaf"},
{file = "ruff-0.5.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:204fb0a472f00f2e6280a7c8c7c066e11e20e23a37557d63045bf27a616ba61c"}, {file = "ruff-0.5.5-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:00817603822a3e42b80f7c3298c8269e09f889ee94640cd1fc7f9329788d7bf8"},
{file = "ruff-0.5.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d235968460e8758d1e1297e1de59a38d94102f60cafb4d5382033c324404ee9d"}, {file = "ruff-0.5.5-py3-none-macosx_11_0_arm64.whl", hash = "sha256:187a60f555e9f865a2ff2c6984b9afeffa7158ba6e1eab56cb830404c942b0f3"},
{file = "ruff-0.5.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:38beace10b8d5f9b6bdc91619310af6d63dd2019f3fb2d17a2da26360d7962fa"}, {file = "ruff-0.5.5-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fe26fc46fa8c6e0ae3f47ddccfbb136253c831c3289bba044befe68f467bfb16"},
{file = "ruff-0.5.1-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5e478d2f09cf06add143cf8c4540ef77b6599191e0c50ed976582f06e588c994"}, {file = "ruff-0.5.5-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4ad25dd9c5faac95c8e9efb13e15803cd8bbf7f4600645a60ffe17c73f60779b"},
{file = "ruff-0.5.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f0368d765eec8247b8550251c49ebb20554cc4e812f383ff9f5bf0d5d94190b0"}, {file = "ruff-0.5.5-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f70737c157d7edf749bcb952d13854e8f745cec695a01bdc6e29c29c288fc36e"},
{file = "ruff-0.5.1-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:3a9a9a1b582e37669b0138b7c1d9d60b9edac880b80eb2baba6d0e566bdeca4d"}, {file = "ruff-0.5.5-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:cfd7de17cef6ab559e9f5ab859f0d3296393bc78f69030967ca4d87a541b97a0"},
{file = "ruff-0.5.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bdd9f723e16003623423affabcc0a807a66552ee6a29f90eddad87a40c750b78"}, {file = "ruff-0.5.5-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a09b43e02f76ac0145f86a08e045e2ea452066f7ba064fd6b0cdccb486f7c3e7"},
{file = "ruff-0.5.1-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:be9fd62c1e99539da05fcdc1e90d20f74aec1b7a1613463ed77870057cd6bd96"}, {file = "ruff-0.5.5-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d0b856cb19c60cd40198be5d8d4b556228e3dcd545b4f423d1ad812bfdca5884"},
{file = "ruff-0.5.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e216fc75a80ea1fbd96af94a6233d90190d5b65cc3d5dfacf2bd48c3e067d3e1"}, {file = "ruff-0.5.5-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3687d002f911e8a5faf977e619a034d159a8373514a587249cc00f211c67a091"},
{file = "ruff-0.5.1-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:c4c2112e9883a40967827d5c24803525145e7dab315497fae149764979ac7929"}, {file = "ruff-0.5.5-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:ac9dc814e510436e30d0ba535f435a7f3dc97f895f844f5b3f347ec8c228a523"},
{file = "ruff-0.5.1-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:dfaf11c8a116394da3b65cd4b36de30d8552fa45b8119b9ef5ca6638ab964fa3"}, {file = "ruff-0.5.5-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:af9bdf6c389b5add40d89b201425b531e0a5cceb3cfdcc69f04d3d531c6be74f"},
{file = "ruff-0.5.1-py3-none-musllinux_1_2_i686.whl", hash = "sha256:d7ceb9b2fe700ee09a0c6b192c5ef03c56eb82a0514218d8ff700f6ade004108"}, {file = "ruff-0.5.5-py3-none-musllinux_1_2_i686.whl", hash = "sha256:d40a8533ed545390ef8315b8e25c4bb85739b90bd0f3fe1280a29ae364cc55d8"},
{file = "ruff-0.5.1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:bac6288e82f6296f82ed5285f597713acb2a6ae26618ffc6b429c597b392535c"}, {file = "ruff-0.5.5-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:cab904683bf9e2ecbbe9ff235bfe056f0eba754d0168ad5407832928d579e7ab"},
{file = "ruff-0.5.1-py3-none-win32.whl", hash = "sha256:5c441d9c24ec09e1cb190a04535c5379b36b73c4bc20aa180c54812c27d1cca4"}, {file = "ruff-0.5.5-py3-none-win32.whl", hash = "sha256:696f18463b47a94575db635ebb4c178188645636f05e934fdf361b74edf1bb2d"},
{file = "ruff-0.5.1-py3-none-win_amd64.whl", hash = "sha256:b1789bf2cd3d1b5a7d38397cac1398ddf3ad7f73f4de01b1e913e2abc7dfc51d"}, {file = "ruff-0.5.5-py3-none-win_amd64.whl", hash = "sha256:50f36d77f52d4c9c2f1361ccbfbd09099a1b2ea5d2b2222c586ab08885cf3445"},
{file = "ruff-0.5.1-py3-none-win_arm64.whl", hash = "sha256:2875b7596a740cbbd492f32d24be73e545a4ce0a3daf51e4f4e609962bfd3cd2"}, {file = "ruff-0.5.5-py3-none-win_arm64.whl", hash = "sha256:3191317d967af701f1b73a31ed5788795936e423b7acce82a2b63e26eb3e89d6"},
{file = "ruff-0.5.1.tar.gz", hash = "sha256:3164488aebd89b1745b47fd00604fb4358d774465f20d1fcd907f9c0fc1b0655"}, {file = "ruff-0.5.5.tar.gz", hash = "sha256:cc5516bdb4858d972fbc31d246bdb390eab8df1a26e2353be2dbc0c2d7f5421a"},
] ]
[[package]] [[package]]
name = "sentry-sdk" name = "sentry-sdk"
version = "2.9.0" version = "2.11.0"
description = "Python client for Sentry (https://sentry.io)" description = "Python client for Sentry (https://sentry.io)"
optional = false optional = false
python-versions = ">=3.6" python-versions = ">=3.6"
files = [ files = [
{file = "sentry_sdk-2.9.0-py2.py3-none-any.whl", hash = "sha256:0bea5fa8b564cc0d09f2e6f55893e8f70286048b0ffb3a341d5b695d1af0e6ee"}, {file = "sentry_sdk-2.11.0-py2.py3-none-any.whl", hash = "sha256:d964710e2dbe015d9dc4ff0ad16225d68c3b36936b742a6fe0504565b760a3b7"},
{file = "sentry_sdk-2.9.0.tar.gz", hash = "sha256:4c85bad74df9767976afb3eeddc33e0e153300e887d637775a753a35ef99bee6"}, {file = "sentry_sdk-2.11.0.tar.gz", hash = "sha256:4ca16e9f5c7c6bc2fb2d5c956219f4926b148e511fffdbbde711dc94f1e0468f"},
] ]
[package.dependencies] [package.dependencies]
@ -1191,13 +1191,13 @@ files = [
[[package]] [[package]]
name = "sqlparse" name = "sqlparse"
version = "0.5.0" version = "0.5.1"
description = "A non-validating SQL parser." description = "A non-validating SQL parser."
optional = false optional = false
python-versions = ">=3.8" python-versions = ">=3.8"
files = [ files = [
{file = "sqlparse-0.5.0-py3-none-any.whl", hash = "sha256:c204494cd97479d0e39f28c93d46c0b2d5959c7b9ab904762ea6c7af211c8663"}, {file = "sqlparse-0.5.1-py3-none-any.whl", hash = "sha256:773dcbf9a5ab44a090f3441e2180efe2560220203dc2f8c0b0fa141e18b505e4"},
{file = "sqlparse-0.5.0.tar.gz", hash = "sha256:714d0a4932c059d16189f58ef5411ec2287a4360f17cdd0edd2d09d4c5087c93"}, {file = "sqlparse-0.5.1.tar.gz", hash = "sha256:bb6b4df465655ef332548e24f08e205afc81b9ab86cb1c45657a7ff173a3a00e"},
] ]
[package.extras] [package.extras]
@ -1323,4 +1323,4 @@ brotli = ["brotli"]
[metadata] [metadata]
lock-version = "2.0" lock-version = "2.0"
python-versions = "^3.12" python-versions = "^3.12"
content-hash = "53676c3c85411ebdd5f25d2a016b07d5f3cc48c74a36c6130f4a967f8fd9ff38" content-hash = "247df8360be634f1bf0c3f3db54a728aec6f7ac7f5644e92a33e70d0758d7d19"

View File

@ -9,7 +9,7 @@ package-mode = false
[tool.poetry.dependencies] [tool.poetry.dependencies]
python = "^3.12" python = "^3.12"
discord-webhook = "^1.3.1" discord-webhook = "^1.3.1"
django = { version = "^5.1b1", allow-prereleases = true } django = { version = "^5.1rc1", allow-prereleases = true }
django-auto-prefetch = "^1.9.0" django-auto-prefetch = "^1.9.0"
django-debug-toolbar = "^4.4.6" django-debug-toolbar = "^4.4.6"
django-simple-history = "^3.7.0" django-simple-history = "^3.7.0"
@ -18,14 +18,14 @@ httpx = "^0.27.0"
pillow = "^10.4.0" pillow = "^10.4.0"
platformdirs = "^4.2.2" platformdirs = "^4.2.2"
python-dotenv = "^1.0.1" python-dotenv = "^1.0.1"
sentry-sdk = { extras = ["django"], version = "^2.9.0" } sentry-sdk = { extras = ["django"], version = "^2.11.0" }
whitenoise = { extras = ["brotli"], version = "^6.7.0" } whitenoise = { extras = ["brotli"], version = "^6.7.0" }
undetected-playwright-patch = "^1.40.0.post1700587210000" undetected-playwright-patch = "^1.40.0.post1700587210000"
[tool.poetry.group.dev.dependencies] [tool.poetry.group.dev.dependencies]
djlint = "^1.34.1" djlint = "^1.34.1"
pre-commit = "^3.7.1" pre-commit = "^3.8.0"
pytest = "^8.2.2" pytest = "^8.3.2"
pytest-django = "^4.8.0" pytest-django = "^4.8.0"
ruff = "^0.5.1" ruff = "^0.5.1"

View File

@ -1,6 +1,7 @@
import asyncio import asyncio
import logging import logging
import typing import typing
from datetime import datetime
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
@ -10,17 +11,12 @@ from platformdirs import user_data_dir
from playwright.async_api import Playwright, async_playwright from playwright.async_api import Playwright, async_playwright
from playwright.async_api._generated import Response from playwright.async_api._generated import Response
from twitch_app.models import ( from twitch_app.models import Game, Image, Reward, RewardCampaign, UnlockRequirements
Drop,
DropCampaign,
Game,
Organization,
)
if TYPE_CHECKING: if TYPE_CHECKING:
from playwright.async_api._generated import BrowserContext, Page from playwright.async_api._generated import BrowserContext, Page
# Where to store the Firefox profile # Where to store the Chrome profile
data_dir = Path( data_dir = Path(
user_data_dir( user_data_dir(
appname="TTVDrops", appname="TTVDrops",
@ -37,120 +33,92 @@ if not data_dir:
logger: logging.Logger = logging.getLogger(__name__) logger: logging.Logger = logging.getLogger(__name__)
async def insert_data(data: dict) -> None: # noqa: C901, PLR0914 async def add_reward_campaign(json_data: dict) -> None:
"""Insert data into the database. """Add data from JSON to the database."""
for campaign_data in json_data["data"]["rewardCampaignsAvailableToUser"]:
Args: # Add or get Game
data: The data from Twitch. game_data = campaign_data["game"]
""" if game_data:
user_data: dict = data.get("data", {}).get("user") game, _ = await sync_to_async(Game.objects.get_or_create)(
if not user_data: id=game_data["id"],
logger.debug("No user data found") slug=game_data["slug"],
return
user_data["id"]
drop_campaign_data = user_data["dropCampaign"]
if not drop_campaign_data:
logger.debug("No drop campaign data found")
return
# Create or get the organization
owner_data = drop_campaign_data["owner"]
owner, created = await sync_to_async(Organization.objects.get_or_create)(
id=owner_data["id"],
defaults={"name": owner_data["name"]},
)
if created:
logger.debug("Organization created: %s", owner)
else:
logger.debug("Organization found: %s", owner)
# Create or get the game
game_data = drop_campaign_data["game"]
game, created = await sync_to_async(Game.objects.get_or_create)(
id=game_data["id"],
defaults={
"slug": game_data["slug"],
"display_name": game_data["displayName"],
"organization": owner,
},
)
if created:
logger.debug("Game created: %s", game)
# Create the drop campaign
drop_campaign, created = await sync_to_async(DropCampaign.objects.get_or_create)(
id=drop_campaign_data["id"],
defaults={
"account_link_url": drop_campaign_data.get("accountLinkURL"),
"description": drop_campaign_data.get("description"),
"details_url": drop_campaign_data.get("detailsURL"),
"end_at": drop_campaign_data.get("endAt"),
"image_url": drop_campaign_data.get("imageURL"),
"name": drop_campaign_data.get("name"),
"start_at": drop_campaign_data.get("startAt"),
"status": drop_campaign_data.get("status"),
"game": game,
},
)
if created:
logger.debug("Drop campaign created: %s", drop_campaign)
# Create time-based drops
for drop_data in drop_campaign_data["timeBasedDrops"]:
drop_benefit_edges = drop_data["benefitEdges"]
time_based_drop, created = await sync_to_async(Drop.objects.get_or_create)(
id=drop_data["id"],
defaults={
"required_subs": drop_data.get("requiredSubs"),
"end_at": drop_data.get("endAt"),
"name": drop_data.get("name"),
"required_minutes_watched": drop_data.get("requiredMinutesWatched"),
"start_at": drop_data.get("startAt"),
"drop_campaign": drop_campaign,
},
)
if created:
logger.debug("Time-based drop created: %s", time_based_drop)
for edge in drop_benefit_edges:
benefit_data = edge["benefit"]
benefit_owner_data = benefit_data["ownerOrganization"]
org, created = await sync_to_async(
Organization.objects.get_or_create,
)(
id=benefit_owner_data["id"],
defaults={"name": benefit_owner_data["name"]},
)
if created:
logger.debug("Organization created: %s", org)
benefit_game_data = benefit_data["game"]
benefit_game, created = await sync_to_async(Game.objects.get_or_create)(
id=benefit_game_data["id"],
defaults={"display_name": benefit_game_data["name"]},
)
if created:
logger.debug("Benefit game created: %s", benefit_game)
# Get the drop to add the data to
drop, created = await sync_to_async(Drop.objects.get_or_create)(
id=drop_data["id"],
defaults={ defaults={
"created_at": benefit_data.get("createdAt"), "display_name": game_data["displayName"],
"entitlement_limit": benefit_data.get("entitlementLimit"), "typename": game_data["__typename"],
"image_asset_url": benefit_data.get("imageAssetURL"),
"is_ios_available": benefit_data.get("isIosAvailable"),
"name": benefit_data.get("name"),
}, },
) )
else:
logger.warning("%s is not for a game?", campaign_data["name"])
game = None
if created: # Add or get Image
logger.debug("Drop created: %s", drop) image_data = campaign_data["image"]
image, _ = await sync_to_async(Image.objects.get_or_create)(
image1_x_url=image_data["image1xURL"],
defaults={"typename": image_data["__typename"]},
)
await sync_to_async(drop.save)() # Create Reward instances
rewards = []
for reward_data in campaign_data["rewards"]:
banner_image_data = reward_data["bannerImage"]
banner_image, _ = await sync_to_async(Image.objects.get_or_create)(
image1_x_url=banner_image_data["image1xURL"],
defaults={"typename": banner_image_data["__typename"]},
)
thumbnail_image_data = reward_data["thumbnailImage"]
thumbnail_image, _ = await sync_to_async(Image.objects.get_or_create)(
image1_x_url=thumbnail_image_data["image1xURL"],
defaults={"typename": thumbnail_image_data["__typename"]},
)
reward, _ = await sync_to_async(Reward.objects.get_or_create)(
id=reward_data["id"],
name=reward_data["name"],
banner_image=banner_image,
thumbnail_image=thumbnail_image,
earnable_until=datetime.fromisoformat(reward_data["earnableUntil"].replace("Z", "+00:00")),
redemption_instructions=reward_data["redemptionInstructions"],
redemption_url=reward_data["redemptionURL"],
typename=reward_data["__typename"],
)
rewards.append(reward)
# Add or get Unlock Requirements
unlock_requirements_data = campaign_data["unlockRequirements"]
_, _ = await sync_to_async(UnlockRequirements.objects.get_or_create)(
subs_goal=unlock_requirements_data["subsGoal"],
defaults={
"minute_watched_goal": unlock_requirements_data["minuteWatchedGoal"],
"typename": unlock_requirements_data["__typename"],
},
)
# Create Reward Campaign
reward_campaign, _ = await sync_to_async(RewardCampaign.objects.get_or_create)(
id=campaign_data["id"],
name=campaign_data["name"],
brand=campaign_data["brand"],
starts_at=datetime.fromisoformat(campaign_data["startsAt"].replace("Z", "+00:00")),
ends_at=datetime.fromisoformat(campaign_data["endsAt"].replace("Z", "+00:00")),
status=campaign_data["status"],
summary=campaign_data["summary"],
instructions=campaign_data["instructions"],
external_url=campaign_data["externalURL"],
reward_value_url_param=campaign_data["rewardValueURLParam"],
about_url=campaign_data["aboutURL"],
is_sitewide=campaign_data["isSitewide"],
game=game,
image=image,
typename=campaign_data["__typename"],
)
# Add Rewards to the Campaign
for reward in rewards:
await sync_to_async(reward_campaign.rewards.add)(reward)
await sync_to_async(reward_campaign.save)()
class Command(BaseCommand): class Command(BaseCommand):
@ -217,7 +185,7 @@ class Command(BaseCommand):
logger.debug("Page loaded. Scraping data...") logger.debug("Page loaded. Scraping data...")
# Wait 5 seconds for the page to load # Wait 5 seconds for the page to load
await asyncio.sleep(5) # await asyncio.sleep(5)
await browser.close() await browser.close()
@ -226,14 +194,16 @@ class Command(BaseCommand):
if not isinstance(campaign, dict): if not isinstance(campaign, dict):
continue continue
if "dropCampaign" in campaign.get("data", {}).get("user", {}): if "rewardCampaignsAvailableToUser" in campaign["data"]:
await add_reward_campaign(campaign)
if "dropCampaign" in campaign.get("data", {}).get("user", {}): # noqa: SIM102
if not campaign["data"]["user"]["dropCampaign"]: if not campaign["data"]["user"]["dropCampaign"]:
continue continue
await insert_data(campaign)
if "dropCampaigns" in campaign.get("data", {}).get("user", {}): if "dropCampaigns" in campaign.get("data", {}).get("user", {}):
await insert_data(campaign) msg = "Multiple dropCampaigns not supported"
raise NotImplementedError(msg)
return json_data return json_data

View File

@ -1,160 +1,90 @@
# Generated by Django 5.1b1 on 2024-07-09 22:26 # Generated by Django 5.1rc1 on 2024-07-30 21:49
import uuid
import auto_prefetch
import django.db.models.deletion import django.db.models.deletion
import django.db.models.functions.text
import django.db.models.manager
from django.db import migrations, models from django.db import migrations, models
from django.db.migrations.operations.base import Operation
class Migration(migrations.Migration): class Migration(migrations.Migration):
initial = True initial = True
dependencies = [] dependencies: list[tuple[str, str]] = []
operations = [ operations: list[Operation] = [
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)),
("added_at", models.DateTimeField(auto_now_add=True, null=True)),
("modified_at", models.DateTimeField(auto_now=True, null=True)),
],
options={
"verbose_name": "Drop Campaign",
"verbose_name_plural": "Drop Campaigns",
"ordering": ("name",),
"abstract": False,
"base_manager_name": "prefetch_manager",
},
managers=[
("objects", django.db.models.manager.Manager()),
("prefetch_manager", django.db.models.manager.Manager()),
],
),
migrations.CreateModel( migrations.CreateModel(
name="Game", name="Game",
fields=[ fields=[
("id", models.TextField(primary_key=True, serialize=False)), ("id", models.AutoField(primary_key=True, serialize=False)),
("slug", models.TextField(blank=True, null=True)), ("slug", models.TextField()),
( ("display_name", models.TextField()),
"twitch_url", ("typename", models.TextField()),
models.GeneratedField( # type: ignore # noqa: PGH003
db_persist=True,
expression=django.db.models.functions.text.Concat(
models.Value("https://www.twitch.tv/directory/category/"),
"slug",
),
output_field=models.TextField(),
),
),
(
"image_url",
models.GeneratedField( # type: ignore # noqa: PGH003
db_persist=True,
expression=django.db.models.functions.text.Concat(
models.Value("https://static-cdn.jtvnw.net/ttv-boxart/"),
"id",
models.Value("_IGDB.jpg"),
),
output_field=models.URLField(),
),
),
("display_name", models.TextField(blank=True, null=True)),
("added_at", models.DateTimeField(auto_now_add=True, null=True)),
("modified_at", models.DateTimeField(auto_now=True, null=True)),
],
options={
"verbose_name": "Game",
"verbose_name_plural": "Games",
"ordering": ("display_name",),
"abstract": False,
"base_manager_name": "prefetch_manager",
},
managers=[
("objects", django.db.models.manager.Manager()),
("prefetch_manager", django.db.models.manager.Manager()),
], ],
), ),
migrations.CreateModel( migrations.CreateModel(
name="Organization", name="Image",
fields=[ fields=[
("id", models.TextField(primary_key=True, serialize=False)), ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("name", models.TextField(blank=True, null=True)), ("image1_x_url", models.URLField()),
("added_at", models.DateTimeField(auto_now_add=True, null=True)), ("typename", models.TextField()),
("modified_at", models.DateTimeField(auto_now=True, null=True)),
],
options={
"verbose_name": "Organization",
"verbose_name_plural": "Organizations",
"ordering": ("name",),
"abstract": False,
"base_manager_name": "prefetch_manager",
},
managers=[
("objects", django.db.models.manager.Manager()),
("prefetch_manager", django.db.models.manager.Manager()),
], ],
), ),
migrations.CreateModel( migrations.CreateModel(
name="Drop", name="UnlockRequirements",
fields=[ fields=[
("id", models.TextField(primary_key=True, serialize=False)), ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("created_at", models.DateTimeField(blank=True, null=True)), ("subs_goal", models.IntegerField()),
("entitlement_limit", models.IntegerField(blank=True, null=True)), ("minute_watched_goal", models.IntegerField()),
("image_asset_url", models.URLField(blank=True, null=True)), ("typename", models.TextField()),
("name", models.TextField(blank=True, null=True)), ],
("added_at", models.DateTimeField(auto_now_add=True, null=True)), ),
("modified_at", models.DateTimeField(auto_now=True, null=True)), migrations.CreateModel(
("required_subs", models.IntegerField(blank=True, null=True)), name="Reward",
("end_at", models.DateTimeField(blank=True, null=True)), fields=[
("required_minutes_watched", models.IntegerField(blank=True, null=True)), ("id", models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
("start_at", models.DateTimeField(blank=True, null=True)), ("name", models.TextField()),
("earnable_until", models.DateTimeField()),
("redemption_instructions", models.TextField()),
("redemption_url", models.URLField()),
("typename", models.TextField()),
( (
"drop_campaign", "banner_image",
auto_prefetch.ForeignKey( models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, on_delete=django.db.models.deletion.CASCADE,
related_name="drops", related_name="banner_rewards",
to="twitch_app.dropcampaign", to="twitch_app.image",
),
),
(
"thumbnail_image",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="thumbnail_rewards",
to="twitch_app.image",
), ),
), ),
], ],
options={ ),
"verbose_name": "Drop", migrations.CreateModel(
"verbose_name_plural": "Drops", name="RewardCampaign",
"ordering": ("name",), fields=[
"abstract": False, ("id", models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
"base_manager_name": "prefetch_manager", ("name", models.TextField()),
}, ("brand", models.TextField()),
managers=[ ("starts_at", models.DateTimeField()),
("objects", django.db.models.manager.Manager()), ("ends_at", models.DateTimeField()),
("prefetch_manager", django.db.models.manager.Manager()), ("status", models.TextField()),
("summary", models.TextField()),
("instructions", models.TextField()),
("external_url", models.URLField()),
("reward_value_url_param", models.TextField()),
("about_url", models.URLField()),
("is_sitewide", models.BooleanField()),
("typename", models.TextField()),
("game", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="twitch_app.game")),
("image", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="twitch_app.image")),
("rewards", models.ManyToManyField(to="twitch_app.reward")),
], ],
), ),
migrations.AddField(
model_name="dropcampaign",
name="game",
field=auto_prefetch.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="drop_campaigns",
to="twitch_app.game",
),
),
migrations.AddField(
model_name="game",
name="organization",
field=auto_prefetch.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="games",
to="twitch_app.organization",
),
),
] ]

View File

@ -0,0 +1,84 @@
# Generated by Django 5.1rc1 on 2024-07-30 23:39
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('twitch_app', '0001_initial'),
]
operations = [
migrations.AlterField(
model_name='rewardcampaign',
name='about_url',
field=models.URLField(blank=True, null=True),
),
migrations.AlterField(
model_name='rewardcampaign',
name='brand',
field=models.TextField(blank=True, null=True),
),
migrations.AlterField(
model_name='rewardcampaign',
name='ends_at',
field=models.DateTimeField(blank=True, null=True),
),
migrations.AlterField(
model_name='rewardcampaign',
name='external_url',
field=models.URLField(blank=True, null=True),
),
migrations.AlterField(
model_name='rewardcampaign',
name='game',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='reward_campaigns', to='twitch_app.game'),
),
migrations.AlterField(
model_name='rewardcampaign',
name='image',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='reward_campaigns', to='twitch_app.image'),
),
migrations.AlterField(
model_name='rewardcampaign',
name='instructions',
field=models.TextField(blank=True, null=True),
),
migrations.AlterField(
model_name='rewardcampaign',
name='is_sitewide',
field=models.BooleanField(blank=True, null=True),
),
migrations.AlterField(
model_name='rewardcampaign',
name='name',
field=models.TextField(blank=True, null=True),
),
migrations.AlterField(
model_name='rewardcampaign',
name='reward_value_url_param',
field=models.TextField(blank=True, null=True),
),
migrations.AlterField(
model_name='rewardcampaign',
name='starts_at',
field=models.DateTimeField(blank=True, null=True),
),
migrations.AlterField(
model_name='rewardcampaign',
name='status',
field=models.TextField(blank=True, null=True),
),
migrations.AlterField(
model_name='rewardcampaign',
name='summary',
field=models.TextField(blank=True, null=True),
),
migrations.AlterField(
model_name='rewardcampaign',
name='typename',
field=models.TextField(blank=True, null=True),
),
]

View File

@ -0,0 +1,109 @@
# Generated by Django 5.1rc1 on 2024-07-30 23:43
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('twitch_app', '0002_alter_rewardcampaign_about_url_and_more'),
]
operations = [
migrations.AlterField(
model_name='game',
name='display_name',
field=models.TextField(blank=True, null=True),
),
migrations.AlterField(
model_name='game',
name='slug',
field=models.TextField(blank=True, null=True),
),
migrations.AlterField(
model_name='game',
name='typename',
field=models.TextField(blank=True, null=True),
),
migrations.AlterField(
model_name='image',
name='image1_x_url',
field=models.URLField(blank=True, null=True),
),
migrations.AlterField(
model_name='image',
name='typename',
field=models.TextField(blank=True, null=True),
),
migrations.AlterField(
model_name='reward',
name='banner_image',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='banner_rewards', to='twitch_app.image'),
),
migrations.AlterField(
model_name='reward',
name='earnable_until',
field=models.DateTimeField(null=True),
),
migrations.AlterField(
model_name='reward',
name='name',
field=models.TextField(blank=True, null=True),
),
migrations.AlterField(
model_name='reward',
name='redemption_instructions',
field=models.TextField(blank=True, null=True),
),
migrations.AlterField(
model_name='reward',
name='redemption_url',
field=models.URLField(blank=True, null=True),
),
migrations.AlterField(
model_name='reward',
name='thumbnail_image',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='thumbnail_rewards', to='twitch_app.image'),
),
migrations.AlterField(
model_name='reward',
name='typename',
field=models.TextField(blank=True, null=True),
),
migrations.AlterField(
model_name='rewardcampaign',
name='ends_at',
field=models.DateTimeField(null=True),
),
migrations.AlterField(
model_name='rewardcampaign',
name='is_sitewide',
field=models.BooleanField(null=True),
),
migrations.AlterField(
model_name='rewardcampaign',
name='rewards',
field=models.ManyToManyField(related_name='reward_campaigns', to='twitch_app.reward'),
),
migrations.AlterField(
model_name='rewardcampaign',
name='starts_at',
field=models.DateTimeField(null=True),
),
migrations.AlterField(
model_name='unlockrequirements',
name='minute_watched_goal',
field=models.IntegerField(null=True),
),
migrations.AlterField(
model_name='unlockrequirements',
name='subs_goal',
field=models.IntegerField(null=True),
),
migrations.AlterField(
model_name='unlockrequirements',
name='typename',
field=models.TextField(blank=True, null=True),
),
]

View File

@ -0,0 +1,23 @@
# Generated by Django 5.1rc1 on 2024-07-30 23:44
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('twitch_app', '0003_alter_game_display_name_alter_game_slug_and_more'),
]
operations = [
migrations.AlterField(
model_name='reward',
name='id',
field=models.UUIDField(primary_key=True, serialize=False),
),
migrations.AlterField(
model_name='rewardcampaign',
name='id',
field=models.UUIDField(primary_key=True, serialize=False),
),
]

View File

@ -1,123 +1,209 @@
from typing import Literal
import auto_prefetch
from django.db import models from django.db import models
from django.db.models import Value
from django.db.models.functions import Concat
class Organization(auto_prefetch.Model): class Game(models.Model):
"""The company that owns the game. """The game that the reward is for.
For example, 2K games. Attributes:
id (int): The primary key of the game.
slug (str): The slug identifier of the game.
display_name (str): The display name of the game.
typename (str): The type name of the object, typically "Game".
JSON example:
{
"id": "780302568",
"slug": "xdefiant",
"displayName": "XDefiant",
"__typename": "Game"
}
""" """
id = models.TextField(primary_key=True) id = models.AutoField(primary_key=True)
name = models.TextField(blank=True, null=True) slug = models.TextField(null=True, blank=True)
added_at = models.DateTimeField(blank=True, null=True, auto_now_add=True) display_name = models.TextField(null=True, blank=True)
modified_at = models.DateTimeField(blank=True, null=True, auto_now=True) typename = models.TextField(null=True, blank=True)
class Meta(auto_prefetch.Model.Meta):
verbose_name: str = "Organization"
verbose_name_plural: str = "Organizations"
ordering: tuple[Literal["name"]] = ("name",)
def __str__(self) -> str: def __str__(self) -> str:
return self.name or self.id return self.display_name or "Unknown"
def get_twitch_url(self) -> str:
return f"https://www.twitch.tv/directory/game/{self.slug}"
class Game(auto_prefetch.Model): class Image(models.Model):
"""The game that the drop campaign is for. """An image model representing URLs and type.
For example, MultiVersus. Attributes:
image1_x_url (str): URL to the image.
typename (str): The type name of the object, typically "RewardCampaignImageSet".
JSON example:
{
"image1xURL": "https://static-cdn.jtvnw.net/twitch-quests-assets/CAMPAIGN/quests_xdefiant_q3_2024/campaign.png",
"__typename": "RewardCampaignImageSet"
}
""" """
organization = auto_prefetch.ForeignKey( image1_x_url = models.URLField(null=True, blank=True)
Organization, typename = models.TextField(null=True, blank=True)
on_delete=models.CASCADE,
related_name="games",
)
id = models.TextField(primary_key=True)
slug = models.TextField(blank=True, null=True)
twitch_url = models.GeneratedField( # type: ignore # noqa: PGH003
expression=Concat(Value("https://www.twitch.tv/directory/category/"), "slug"),
output_field=models.TextField(),
db_persist=True,
)
image_url = models.GeneratedField( # type: ignore # noqa: PGH003
expression=Concat(
Value("https://static-cdn.jtvnw.net/ttv-boxart/"),
"id",
Value("_IGDB.jpg"),
),
output_field=models.URLField(),
db_persist=True,
)
display_name = models.TextField(blank=True, null=True)
added_at = models.DateTimeField(blank=True, null=True, auto_now_add=True)
modified_at = models.DateTimeField(blank=True, null=True, auto_now=True)
class Meta(auto_prefetch.Model.Meta):
verbose_name: str = "Game"
verbose_name_plural: str = "Games"
ordering: tuple[Literal["display_name"]] = ("display_name",)
def __str__(self) -> str: def __str__(self) -> str:
return self.display_name or self.slug or self.id return self.image1_x_url or "Unknown"
class Drop(auto_prefetch.Model): class Reward(models.Model):
"""The actual drop that is being given out.""" """The actual reward you get when you complete the requirements.
id = models.TextField(primary_key=True) Attributes:
created_at = models.DateTimeField(blank=True, null=True) id (UUID): The primary key of the reward.
entitlement_limit = models.IntegerField(blank=True, null=True) name (str): The name of the reward.
image_asset_url = models.URLField(blank=True, null=True) banner_image (Image): The banner image associated with the reward.
name = models.TextField(blank=True, null=True) thumbnail_image (Image): The thumbnail image associated with the reward.
added_at = models.DateTimeField(blank=True, null=True, auto_now_add=True) earnable_until (datetime): The date and time until the reward can be earned.
modified_at = models.DateTimeField(blank=True, null=True, auto_now=True) redemption_instructions (str): Instructions on how to redeem the reward.
required_subs = models.IntegerField(blank=True, null=True) redemption_url (str): URL for redeeming the reward.
end_at = models.DateTimeField(blank=True, null=True) typename (str): The type name of the object, typically "Reward".
required_minutes_watched = models.IntegerField(blank=True, null=True)
start_at = models.DateTimeField(blank=True, null=True)
drop_campaign = auto_prefetch.ForeignKey("DropCampaign", on_delete=models.CASCADE, related_name="drops")
class Meta(auto_prefetch.Model.Meta): JSON example:
verbose_name: str = "Drop" {
verbose_name_plural: str = "Drops" "id": "374628c6-34b4-11ef-a468-62ece0f03426",
ordering: tuple[Literal["name"]] = ("name",) "name": "Twitchy Character Skin",
"bannerImage": {
def __str__(self) -> str: "image1xURL": "https://static-cdn.jtvnw.net/twitch-quests-assets/CAMPAIGN/quests_xdefiant_q3_2024/reward.png",
return f"{self.name}" "__typename": "RewardCampaignImageSet"
},
"thumbnailImage": {
class DropCampaign(auto_prefetch.Model): "image1xURL": "https://static-cdn.jtvnw.net/twitch-quests-assets/CAMPAIGN/quests_xdefiant_q3_2024/reward.png",
"""Drops are grouped into campaigns. "__typename": "RewardCampaignImageSet"
},
For example, MultiVersus S1 Drops "earnableUntil": "2024-07-30T06:59:59Z",
"redemptionInstructions": "",
"redemptionURL": "https://redeem.ubisoft.com/xdefiant/",
"__typename": "Reward"
}
""" """
id = models.TextField(primary_key=True) id = models.UUIDField(primary_key=True)
account_link_url = models.URLField(blank=True, null=True) name = models.TextField(null=True, blank=True)
description = models.TextField(blank=True, null=True) banner_image = models.ForeignKey(Image, related_name="banner_rewards", on_delete=models.CASCADE, null=True)
details_url = models.URLField(blank=True, null=True) thumbnail_image = models.ForeignKey(Image, related_name="thumbnail_rewards", on_delete=models.CASCADE, null=True)
end_at = models.DateTimeField(blank=True, null=True) earnable_until = models.DateTimeField(null=True)
image_url = models.URLField(blank=True, null=True) redemption_instructions = models.TextField(null=True, blank=True)
name = models.TextField(blank=True, null=True) redemption_url = models.URLField(null=True, blank=True)
start_at = models.DateTimeField(blank=True, null=True) typename = models.TextField(null=True, blank=True)
status = models.TextField(blank=True, null=True)
game = auto_prefetch.ForeignKey(
Game,
on_delete=models.CASCADE,
related_name="drop_campaigns",
)
added_at = models.DateTimeField(blank=True, null=True, auto_now_add=True)
modified_at = models.DateTimeField(blank=True, null=True, auto_now=True)
class Meta(auto_prefetch.Model.Meta):
verbose_name: str = "Drop Campaign"
verbose_name_plural: str = "Drop Campaigns"
ordering: tuple[Literal["name"]] = ("name",)
def __str__(self) -> str: def __str__(self) -> str:
return f"{self.game.display_name} - {self.name}" return self.name or "Unknown"
class UnlockRequirements(models.Model):
"""Requirements to unlock a reward.
Attributes:
subs_goal (int): The number of subscriptions needed to unlock the reward.
minute_watched_goal (int): The number of minutes watched needed to unlock the reward.
typename (str): The type name of the object, typically "QuestRewardUnlockRequirements".
JSON example:
{
"subsGoal": 2,
"minuteWatchedGoal": 0,
"__typename": "QuestRewardUnlockRequirements"
}
"""
subs_goal = models.IntegerField(null=True)
minute_watched_goal = models.IntegerField(null=True)
typename = models.TextField(null=True, blank=True)
def __str__(self) -> str:
return f"{self.subs_goal} subs and {self.minute_watched_goal} minutes watched"
class RewardCampaign(models.Model):
"""Represents a reward campaign.
Attributes:
id (UUID): The primary key of the reward campaign.
name (str): The name of the reward campaign.
brand (str): The brand associated with the campaign.
starts_at (datetime): The start date and time of the campaign.
ends_at (datetime): The end date and time of the campaign.
status (str): The status of the campaign.
summary (str): A brief summary of the campaign.
instructions (str): Instructions for the campaign.
external_url (str): The external URL related to the campaign.
reward_value_url_param (str): URL parameter for the reward value.
about_url (str): URL with more information about the campaign.
is_sitewide (bool): Indicates if the campaign is sitewide.
game (Game): The game associated with the campaign.
image (Image): The image associated with the campaign.
rewards (ManyToManyField): The rewards available in the campaign.
typename (str): The type name of the object, typically "RewardCampaign".
JSON example:
{
"id": "3757a2ae-34b4-11ef-a468-62ece0f03426",
"name": "XDefiant Season 1 Launch",
"brand": "Ubisoft",
"startsAt": "2024-07-02T17:00:00Z",
"endsAt": "2024-07-30T06:59:59Z",
"status": "UNKNOWN",
"summary": "Get a redeemable code for the Twitchy Character Skin in XDefiant for gifting or purchasing 2 subscriptions of any tier to participating channels.",
"instructions": "",
"externalURL": "https://redeem.ubisoft.com/xdefiant/",
"rewardValueURLParam": "",
"aboutURL": "https://xdefiant.com/S1-twitch-rewards",
"isSitewide": false,
"game": {
"id": "780302568",
"slug": "xdefiant",
"displayName": "XDefiant",
"__typename": "Game"
},
"image": {
"image1xURL": "https://static-cdn.jtvnw.net/twitch-quests-assets/CAMPAIGN/quests_xdefiant_q3_2024/campaign.png",
"__typename": "RewardCampaignImageSet"
},
"rewards": [
{
"id": "374628c6-34b4-11ef-a468-62ece0f03426",
"name": "Twitchy Character Skin",
"bannerImage": {
"image1xURL": "https://static-cdn.jtvnw.net/twitch-quests-assets/CAMPAIGN/quests_xdefiant_q3_2024/reward.png",
"__typename": "RewardCampaignImageSet"
},
"thumbnailImage": {
"image1xURL": "https://static-cdn.jtvnw.net/twitch-quests-assets/CAMPAIGN/quests_xdefiant_q3_2024/reward.png",
"__typename": "RewardCampaignImageSet"
},
"earnableUntil": "2024-07-30T06:59:59Z",
"redemptionInstructions": "",
"redemptionURL": "https://redeem.ubisoft.com/xdefiant/",
"__typename": "Reward"
}
],
"__typename": "RewardCampaign"
}
""" # noqa: E501
id = models.UUIDField(primary_key=True)
name = models.TextField(null=True, blank=True)
brand = models.TextField(null=True, blank=True)
starts_at = models.DateTimeField(null=True)
ends_at = models.DateTimeField(null=True)
status = models.TextField(null=True, blank=True)
summary = models.TextField(null=True, blank=True)
instructions = models.TextField(null=True, blank=True)
external_url = models.URLField(null=True, blank=True)
reward_value_url_param = models.TextField(null=True, blank=True)
about_url = models.URLField(null=True, blank=True)
is_sitewide = models.BooleanField(null=True)
game = models.ForeignKey(Game, on_delete=models.CASCADE, related_name="reward_campaigns", null=True)
image = models.ForeignKey(Image, on_delete=models.CASCADE, related_name="reward_campaigns", null=True)
rewards = models.ManyToManyField(Reward, related_name="reward_campaigns")
typename = models.TextField(null=True, blank=True)
def __str__(self) -> str:
return self.name or "Unknown"

57
twitch_app/testboi.py Normal file
View File

@ -0,0 +1,57 @@
from dataclasses import dataclass
from datetime import datetime
from uuid import UUID
@dataclass
class Game:
id: int
slug: str
display_name: str
typename: str
@dataclass
class Image:
image1_x_url: str
typename: str
@dataclass
class Reward:
id: UUID
name: str
banner_image: Image
thumbnail_image: Image
earnable_until: datetime
redemption_instructions: str
redemption_url: str
typename: str
@dataclass
class UnlockRequirements:
subs_goal: int
minute_watched_goal: int
typename: str
@dataclass
class RewardCampaign:
id: UUID
name: str
brand: str
starts_at: datetime
ends_at: datetime
status: str
summary: str
instructions: str
external_url: str
reward_value_url_param: str
about_url: str
is_sitewide: bool
game: Game
unlock_requirements: UnlockRequirements
image: Image
rewards: list[Reward]
typename: str