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

@ -158,3 +158,7 @@ cython_debug/
# 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.
#.idea/
*.json
staticfiles/

@ -29,21 +29,21 @@ repos:
# Automatically upgrade your Django project code
- repo: https://github.com/adamchainz/django-upgrade
rev: "1.19.0"
rev: "1.20.0"
hooks:
- id: django-upgrade
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
rev: v3.16.0
rev: v3.17.0
hooks:
- id: pyupgrade
args: [ "--py312-plus" ]
# An extremely fast Python linter and formatter.
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.5.1
rev: v0.5.5
hooks:
- id: ruff-format
- id: ruff

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

@ -7,6 +7,11 @@
<div class="col-lg-9">
{% include "partials/info_box.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 %}
<h2 id="org-{{ org|slugify }}">
<a href="#org-{{ org|slugify }}">{{ org }}</a>

@ -14,7 +14,7 @@
<a class="nav-link" href="https://github.com/sponsors/TheLovinator1">Donate</a>
</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>
</ul>
</nav>

@ -7,9 +7,6 @@
<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.
</p>
<p>
You can add a Discord Webhook <a href="{% url 'core:webhooks' %}">here</a>.
</p>
</div>
</div>
</div>

@ -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>

@ -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 %}

@ -24,35 +24,5 @@
</form>
</div>
<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>
{% endblock content %}

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

@ -5,15 +5,16 @@ from typing import TYPE_CHECKING
import hishel
from django.conf import settings
from django.db.models.manager import BaseManager
from django.template.response import TemplateResponse
from django.views.generic import ListView
from core.data import WebhookData
from twitch_app.models import Organization
from twitch_app.models import Game, RewardCampaign
if TYPE_CHECKING:
from pathlib import Path
from django.db.models.manager import BaseManager
from django.http import HttpRequest, HttpResponse
from httpx import Response
@ -59,11 +60,24 @@ def get_webhook_data(webhook: str) -> WebhookData:
def index(request: HttpRequest) -> HttpResponse:
"""Render the index page."""
orgs: BaseManager[Organization] = Organization.objects.all()
webhooks: list[WebhookData] = [get_webhook_data(webhook) for webhook in get_webhooks(request)]
reward_campaigns: BaseManager[RewardCampaign] = RewardCampaign.objects.all()
return TemplateResponse(
request=request,
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

@ -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

@ -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))

@ -16,7 +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 # noqa: DOC501, RUF100
execute_from_command_line(sys.argv)

74
poetry.lock generated

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

@ -9,7 +9,7 @@ package-mode = false
[tool.poetry.dependencies]
python = "^3.12"
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-debug-toolbar = "^4.4.6"
django-simple-history = "^3.7.0"
@ -18,14 +18,14 @@ httpx = "^0.27.0"
pillow = "^10.4.0"
platformdirs = "^4.2.2"
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" }
undetected-playwright-patch = "^1.40.0.post1700587210000"
[tool.poetry.group.dev.dependencies]
djlint = "^1.34.1"
pre-commit = "^3.7.1"
pytest = "^8.2.2"
pre-commit = "^3.8.0"
pytest = "^8.3.2"
pytest-django = "^4.8.0"
ruff = "^0.5.1"

@ -1,6 +1,7 @@
import asyncio
import logging
import typing
from datetime import datetime
from pathlib import Path
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._generated import Response
from twitch_app.models import (
Drop,
DropCampaign,
Game,
Organization,
)
from twitch_app.models import Game, Image, Reward, RewardCampaign, UnlockRequirements
if TYPE_CHECKING:
from playwright.async_api._generated import BrowserContext, Page
# Where to store the Firefox profile
# Where to store the Chrome profile
data_dir = Path(
user_data_dir(
appname="TTVDrops",
@ -37,120 +33,92 @@ if not data_dir:
logger: logging.Logger = logging.getLogger(__name__)
async def insert_data(data: dict) -> None: # noqa: C901, PLR0914
"""Insert data into the database.
Args:
data: The data from Twitch.
"""
user_data: dict = data.get("data", {}).get("user")
if not user_data:
logger.debug("No user data found")
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"],
async def add_reward_campaign(json_data: dict) -> None:
"""Add data from JSON to the database."""
for campaign_data in json_data["data"]["rewardCampaignsAvailableToUser"]:
# Add or get Game
game_data = campaign_data["game"]
if game_data:
game, _ = await sync_to_async(Game.objects.get_or_create)(
id=game_data["id"],
slug=game_data["slug"],
defaults={
"created_at": benefit_data.get("createdAt"),
"entitlement_limit": benefit_data.get("entitlementLimit"),
"image_asset_url": benefit_data.get("imageAssetURL"),
"is_ios_available": benefit_data.get("isIosAvailable"),
"name": benefit_data.get("name"),
"display_name": game_data["displayName"],
"typename": game_data["__typename"],
},
)
else:
logger.warning("%s is not for a game?", campaign_data["name"])
game = None
if created:
logger.debug("Drop created: %s", drop)
# Add or get Image
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):
@ -217,7 +185,7 @@ class Command(BaseCommand):
logger.debug("Page loaded. Scraping data...")
# Wait 5 seconds for the page to load
await asyncio.sleep(5)
# await asyncio.sleep(5)
await browser.close()
@ -226,14 +194,16 @@ class Command(BaseCommand):
if not isinstance(campaign, dict):
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"]:
continue
await insert_data(campaign)
if "dropCampaigns" in campaign.get("data", {}).get("user", {}):
await insert_data(campaign)
msg = "Multiple dropCampaigns not supported"
raise NotImplementedError(msg)
return json_data

@ -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.functions.text
import django.db.models.manager
from django.db import migrations, models
from django.db.migrations.operations.base import Operation
class Migration(migrations.Migration):
initial = True
dependencies = []
dependencies: list[tuple[str, str]] = []
operations = [
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()),
],
),
operations: list[Operation] = [
migrations.CreateModel(
name="Game",
fields=[
("id", models.TextField(primary_key=True, serialize=False)),
("slug", models.TextField(blank=True, null=True)),
(
"twitch_url",
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()),
("id", models.AutoField(primary_key=True, serialize=False)),
("slug", models.TextField()),
("display_name", models.TextField()),
("typename", models.TextField()),
],
),
migrations.CreateModel(
name="Organization",
name="Image",
fields=[
("id", models.TextField(primary_key=True, serialize=False)),
("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": "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()),
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("image1_x_url", models.URLField()),
("typename", models.TextField()),
],
),
migrations.CreateModel(
name="Drop",
name="UnlockRequirements",
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)),
("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)),
("required_subs", models.IntegerField(blank=True, null=True)),
("end_at", models.DateTimeField(blank=True, null=True)),
("required_minutes_watched", models.IntegerField(blank=True, null=True)),
("start_at", models.DateTimeField(blank=True, null=True)),
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("subs_goal", models.IntegerField()),
("minute_watched_goal", models.IntegerField()),
("typename", models.TextField()),
],
),
migrations.CreateModel(
name="Reward",
fields=[
("id", models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
("name", models.TextField()),
("earnable_until", models.DateTimeField()),
("redemption_instructions", models.TextField()),
("redemption_url", models.URLField()),
("typename", models.TextField()),
(
"drop_campaign",
auto_prefetch.ForeignKey(
"banner_image",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="drops",
to="twitch_app.dropcampaign",
related_name="banner_rewards",
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",
"verbose_name_plural": "Drops",
"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(
name="RewardCampaign",
fields=[
("id", models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
("name", models.TextField()),
("brand", models.TextField()),
("starts_at", models.DateTimeField()),
("ends_at", models.DateTimeField()),
("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",
),
),
]

@ -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),
),
]

@ -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),
),
]

@ -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),
),
]

@ -1,123 +1,209 @@
from typing import Literal
import auto_prefetch
from django.db import models
from django.db.models import Value
from django.db.models.functions import Concat
class Organization(auto_prefetch.Model):
"""The company that owns the game.
class Game(models.Model):
"""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)
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 = "Organization"
verbose_name_plural: str = "Organizations"
ordering: tuple[Literal["name"]] = ("name",)
id = models.AutoField(primary_key=True)
slug = models.TextField(null=True, blank=True)
display_name = models.TextField(null=True, blank=True)
typename = models.TextField(null=True, blank=True)
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):
"""The game that the drop campaign is for.
class Image(models.Model):
"""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(
Organization,
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",)
image1_x_url = models.URLField(null=True, blank=True)
typename = models.TextField(null=True, blank=True)
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):
"""The actual drop that is being given out."""
class Reward(models.Model):
"""The actual reward you get when you complete the requirements.
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)
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)
required_subs = models.IntegerField(blank=True, null=True)
end_at = models.DateTimeField(blank=True, null=True)
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")
Attributes:
id (UUID): The primary key of the reward.
name (str): The name of the reward.
banner_image (Image): The banner image associated with the reward.
thumbnail_image (Image): The thumbnail image associated with the reward.
earnable_until (datetime): The date and time until the reward can be earned.
redemption_instructions (str): Instructions on how to redeem the reward.
redemption_url (str): URL for redeeming the reward.
typename (str): The type name of the object, typically "Reward".
class Meta(auto_prefetch.Model.Meta):
verbose_name: str = "Drop"
verbose_name_plural: str = "Drops"
ordering: tuple[Literal["name"]] = ("name",)
def __str__(self) -> str:
return f"{self.name}"
class DropCampaign(auto_prefetch.Model):
"""Drops are grouped into campaigns.
For example, MultiVersus S1 Drops
JSON example:
{
"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"
}
"""
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 = 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",)
id = models.UUIDField(primary_key=True)
name = models.TextField(null=True, blank=True)
banner_image = models.ForeignKey(Image, related_name="banner_rewards", on_delete=models.CASCADE, null=True)
thumbnail_image = models.ForeignKey(Image, related_name="thumbnail_rewards", on_delete=models.CASCADE, null=True)
earnable_until = models.DateTimeField(null=True)
redemption_instructions = models.TextField(null=True, blank=True)
redemption_url = models.URLField(null=True, blank=True)
typename = models.TextField(null=True, blank=True)
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

@ -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