This commit is contained in:
2024-07-01 05:56:36 +02:00
parent 3f7bacca2f
commit 219aee31af
30 changed files with 679 additions and 263 deletions

View File

@ -1,5 +1,6 @@
{ {
"cSpell.words": [ "cSpell.words": [
"allauth",
"appendonly", "appendonly",
"asgiref", "asgiref",
"logdir", "logdir",
@ -8,6 +9,7 @@
"PGID", "PGID",
"PUID", "PUID",
"requirepass", "requirepass",
"socialaccount",
"ttvdrops", "ttvdrops",
"ulimits" "ulimits"
] ]

View File

@ -63,7 +63,7 @@ DISCORD_WEBHOOK_URL: str = os.getenv(key="DISCORD_WEBHOOK_URL", default="")
INSTALLED_APPS: list[str] = [ INSTALLED_APPS: list[str] = [
"core.apps.CoreConfig", "core.apps.CoreConfig",
"twitch.apps.TwitchConfig", "twitch_app.apps.TwitchConfig",
"whitenoise.runserver_nostatic", "whitenoise.runserver_nostatic",
"django.contrib.admin", "django.contrib.admin",
"django.contrib.auth", "django.contrib.auth",
@ -72,6 +72,10 @@ INSTALLED_APPS: list[str] = [
"django.contrib.messages", "django.contrib.messages",
"django.contrib.staticfiles", "django.contrib.staticfiles",
"ninja", "ninja",
"allauth",
"allauth.account",
"allauth.socialaccount",
"allauth.socialaccount.providers.twitch",
] ]
MIDDLEWARE: list[str] = [ MIDDLEWARE: list[str] = [
@ -83,6 +87,7 @@ MIDDLEWARE: list[str] = [
"django.contrib.auth.middleware.AuthenticationMiddleware", "django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware", "django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware", "django.middleware.clickjacking.XFrameOptionsMiddleware",
"allauth.account.middleware.AccountMiddleware",
] ]
@ -155,3 +160,15 @@ LOGGING = {
}, },
}, },
} }
LOGIN_URL = "/login/"
LOGIN_REDIRECT_URL = "/"
LOGOUT_REDIRECT_URL = "/"
SOCIALACCOUNT_ONLY = True
ACCOUNT_EMAIL_VERIFICATION = "none"
AUTHENTICATION_BACKENDS: list[str] = [
"django.contrib.auth.backends.ModelBackend",
"allauth.account.auth_backends.AuthenticationBackend",
]

View File

@ -1,21 +1,27 @@
import logging
from django.contrib import admin from django.contrib import admin
from django.urls import URLPattern, include, path from django.urls import URLPattern, include, path
from django.urls.resolvers import URLResolver from django.urls.resolvers import URLResolver
from ninja import NinjaAPI from ninja import NinjaAPI
from twitch.api import router as twitch_router from twitch_app.api import router as twitch_router
logger: logging.Logger = logging.getLogger(__name__)
api = NinjaAPI( api = NinjaAPI(
title="TTVDrops API", title="TTVDrops API",
version="1.0.0", version="1.0.0",
description="No rate limits, but don't abuse it.", description="No rate limits, but don't abuse it.",
) )
api.add_router(prefix="/twitch", router=twitch_router) api.add_router(prefix="/twitch", router=twitch_router)
app_name: str = "config" app_name: str = "config"
urlpatterns: list[URLPattern | URLResolver] = [ urlpatterns: list[URLPattern | URLResolver] = [
path(route="admin/", view=admin.site.urls), path(route="admin/", view=admin.site.urls),
path(route="accounts/", view=include(arg="allauth.urls")),
path(route="", view=include(arg="core.urls")), path(route="", view=include(arg="core.urls")),
path(route="api/", view=api.urls), path(route="api/", view=api.urls),
] ]

6
core/admin.py Normal file
View File

@ -0,0 +1,6 @@
from django.contrib import admin
from .models import DiscordSetting, Subscription
admin.site.register(DiscordSetting)
admin.site.register(Subscription)

View File

@ -10,15 +10,18 @@ if TYPE_CHECKING:
logger: logging.Logger = logging.getLogger(__name__) logger: logging.Logger = logging.getLogger(__name__)
def send(message: str) -> None: def send(message: str, webhook_url: str | None = None) -> None:
"""Send a message to Discord. """Send a message to Discord.
Args: Args:
message: The message to send. message: The message to send.
webhook_url: The webhook URL to send the message to.
""" """
webhook_url = str(settings.DISCORD_WEBHOOK_URL) logger.info("Discord message: %s", message)
webhook_url = webhook_url or str(settings.DISCORD_WEBHOOK_URL)
if not webhook_url: if not webhook_url:
logger.error("No Discord webhook URL found.") logger.error("No webhook URL provided.")
return return
webhook = DiscordWebhook( webhook = DiscordWebhook(

15
core/forms.py Normal file
View File

@ -0,0 +1,15 @@
from django import forms
class DiscordSettingForm(forms.Form):
name = forms.CharField(
max_length=255,
label="Name",
required=True,
help_text="Friendly name for knowing where the notification goes to.",
)
webhook_url = forms.URLField(
label="Webhook URL",
required=True,
help_text="The URL to the Discord webhook. The URL can be found by right-clicking on the channel and selecting 'Edit Channel', then 'Integrations', and 'Webhooks'.", # noqa: E501
)

View File

@ -0,0 +1,62 @@
# Generated by Django 5.0.6 on 2024-06-30 23:42
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
from django.db.migrations.operations.base import Operation
class Migration(migrations.Migration):
initial = True
dependencies: list[tuple[str, str]] = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations: list[Operation] = [
migrations.CreateModel(
name="NotificationType",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("name", models.CharField(max_length=255)),
],
),
migrations.CreateModel(
name="DiscordSetting",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("webhook_url", models.URLField()),
("disabled", models.BooleanField(default=False)),
(
"user",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to=settings.AUTH_USER_MODEL,
),
),
(
"notification_type",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to="core.notificationtype",
),
),
],
),
]

View File

@ -0,0 +1,26 @@
# Generated by Django 5.0.6 on 2024-07-01 01:10
from django.db import migrations, models
from django.db.migrations.operations.base import Operation
class Migration(migrations.Migration):
dependencies: list[tuple[str, str]] = [
("core", "0001_initial"),
]
operations: list[Operation] = [
migrations.RemoveField(
model_name="discordsetting",
name="notification_type",
),
migrations.AddField(
model_name="discordsetting",
name="name",
field=models.CharField(default="No name", max_length=255),
preserve_default=False,
),
migrations.DeleteModel(
name="NotificationType",
),
]

View File

@ -0,0 +1,53 @@
# Generated by Django 5.0.6 on 2024-07-01 03:28
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
from django.db.migrations.operations.base import Operation
class Migration(migrations.Migration):
dependencies: list[tuple[str, str]] = [
("core", "0002_remove_discordsetting_notification_type_and_more"),
("twitch_app", "0001_initial"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations: list[Operation] = [
migrations.CreateModel(
name="Subscription",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("created_at", models.DateTimeField(auto_now_add=True)),
(
"discord_webhook",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to="core.discordsetting",
),
),
(
"game",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to="twitch_app.game",
),
),
(
"user",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to=settings.AUTH_USER_MODEL,
),
),
],
),
]

24
core/models.py Normal file
View File

@ -0,0 +1,24 @@
from django.contrib.auth.models import User
from django.db import models
from twitch_app.models import Game
class DiscordSetting(models.Model):
user = models.ForeignKey(User, on_delete=models.CASCADE)
name = models.CharField(max_length=255)
webhook_url = models.URLField()
disabled = models.BooleanField(default=False)
def __str__(self) -> str:
return f"Discord: {self.user.username} - {self.name}"
class Subscription(models.Model):
user = models.ForeignKey(User, on_delete=models.CASCADE)
game = models.ForeignKey(Game, on_delete=models.CASCADE)
created_at = models.DateTimeField(auto_now_add=True)
discord_webhook = models.ForeignKey(DiscordSetting, on_delete=models.CASCADE)
def __str__(self) -> str:
return f"Subscription: {self.user.username} - {self.game.display_name} - {self.discord_webhook.name}"

View File

@ -0,0 +1,206 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<meta name="description" content="Twitch Drops">
<meta name="author" content="TheLovinator">
<meta name="keywords" content="Twitch, Drops, Twitch Drops">
<meta name="robots" content="index, follow">
<title>Add Discord Webhook</title>
<style>
:root {
--background-color: #121212;
--text-color: #e0e0e0;
--header-background: #1e1e1e;
--border-color: #333;
--button-background: #6441a5;
--button-hover-background: #503682;
--button-shadow: rgba(0, 0, 0, 0.2);
--button-padding: 0.5rem 1rem;
--button-margin: 0.5rem 0;
--button-radius: 0.5rem;
--button-font-size: 0.875rem;
--input-background: #2a2a2a;
--input-border: #444;
--input-focus-border: #6441a5;
--input-padding: 0.5rem;
--input-radius: 0.5rem;
--form-gap: 1rem;
}
html {
max-width: 88ch;
padding: calc(1vmin + 0.5rem);
margin-inline: auto;
font-size: clamp(1em, 0.909em + 0.45vmin, 1.25em);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
background-color: var(--background-color);
color: var(--text-color);
}
a {
text-decoration: none;
color: inherit;
}
header {
padding: 10px 30px;
background: var(--header-background);
display: flex;
flex-direction: column;
align-items: flex-start;
}
ul {
list-style-type: none;
padding: 0;
}
.game {
margin-bottom: 1rem;
border: 1px solid var(--border-color);
}
img {
margin: 10px;
}
button {
background-color: var(--button-background);
color: white;
border: none;
padding: var(--button-padding);
margin: var(--button-margin);
border-radius: var(--button-radius);
cursor: pointer;
transition: background-color 0.3s ease, box-shadow 0.3s ease;
font-size: var(--button-font-size);
box-shadow: 0 2px 4px var(--button-shadow);
}
button:hover {
background-color: var(--button-hover-background);
box-shadow: 0 3px 6px var(--button-shadow);
}
.navbar {
margin-bottom: 1rem;
text-align: center;
}
.logo {
text-align: center;
font-size: 2.5rem;
font-weight: 600;
margin: 0;
}
.messages {
list-style-type: none;
padding: 0;
margin-bottom: var(--form-gap);
}
.error {
color: red;
}
.success {
color: green;
}
form {
display: flex;
flex-direction: column;
gap: var(--form-gap);
background-color: var(--header-background);
padding: 1rem;
border: 1px solid var(--border-color);
}
form div {
display: flex;
flex-direction: column;
}
label {
margin-bottom: 0.5rem;
font-weight: bold;
}
input, select, textarea {
background-color: var(--input-background);
border: 1px solid var(--input-border);
padding: var(--input-padding);
color: var(--text-color);
transition: border-color 0.3s ease;
}
input:focus, select:focus, textarea:focus {
border-color: var(--input-focus-border);
outline: none;
}
small {
color: #aaa;
margin-top: 0.5rem;
}
</style>
</head>
<body>
{% if messages %}
<ul class="messages">
{% for message in messages %}
<li {% if message.tags %}class="{{ message.tags }}"{% endif %}>{{ message }}</li>
{% endfor %}
</ul>
{% endif %}
<h1 class="logo">
<a href="{% url 'core:index' %}">Twitch Drops</a>
</h1>
<div class="navbar">
<a href='{% url "api-1.0.0:openapi-view" %}'>API</a> |
<a href="https://github.com/sponsors/TheLovinator1">Donate</a> |
TheLovinator#9276
</div>
<h1>Add Discord Webhook</h1>
<form method="post">
{% csrf_token %}
{{ form.non_field_errors }}
<div>
{{ form.name.errors }}
<label for="{{ form.name.id_for_label }}">{{ form.name.label_tag }}</label>
{{ form.name }}
<small>{{ form.name.help_text }}</small>
</div>
<div>
{{ form.webhook_url.errors }}
<label for="{{ form.webhook_url.id_for_label }}">{{ form.webhook_url.label_tag }}</label>
{{ form.webhook_url }}
<small>{{ form.webhook_url.help_text }}</small>
</div>
<button type="submit">Add Webhook</button>
</form>
<h2>Webhooks</h2>
<ul>
{% for webhook in webhooks %}
<li>
<form method="post" action="{% url 'core:delete_discord_webhook' %}">
<strong>{{ webhook.name }}</strong>
<small>
<a href="{{ webhook.webhook_url }}">{{ webhook.webhook_url }}</a>
</small>
{% 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">Delete</button>
</form>
</li>
{% endfor %}
</ul>
</body>
</html>

View File

@ -1,3 +1,4 @@
{% load socialaccount %}
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
@ -17,9 +18,13 @@
--border-color: #333; --border-color: #333;
--button-background: #6441a5; --button-background: #6441a5;
--button-hover-background: #503682; --button-hover-background: #503682;
--button-shadow: rgba(0, 0, 0, 0.2);
--button-padding: 0.5rem 1rem;
--button-margin: 0.5rem 0;
--button-radius: 0.5rem;
--button-font-size: 0.875rem;
} }
/* Set some good defaults for the page */
html { html {
max-width: 88ch; max-width: 88ch;
padding: calc(1vmin + 0.5rem); padding: calc(1vmin + 0.5rem);
@ -30,63 +35,56 @@
color: var(--text-color); color: var(--text-color);
} }
/* Don't underline links and remove the blue/purple color */
a { a {
text-decoration: none; text-decoration: none;
color: inherit; color: inherit;
} }
/* Add a gray background for the game name header */
/* This header also contains the button to subscribe to the game */
header { header {
display: flex;
align-items: center;
justify-content: space-between; /* So the button is on the right, and the game name is on the left */
padding: 10px 30px; padding: 10px 30px;
background: var(--header-background); background: var(--header-background);
display: flex;
flex-direction: column;
align-items: flex-start;
} }
/* Remove dot in front of list items */
ul { ul {
list-style-type: none; list-style-type: none;
padding: 0; padding: 0;
} }
/* Add a border around each game to separate them */
.game { .game {
margin-bottom: 1rem; margin-bottom: 1rem;
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
} }
/* Move images away from the border */
img { img {
margin: 10px; margin: 10px;
} }
/* Button to subscribe to a game */
/* For example: Subscribe to Rocket League */
button { button {
background-color: var(--button-background); background-color: var(--button-background);
color: white; color: white;
border: none; border: none;
padding: 0.5rem 1rem; padding: var(--button-padding);
border-radius: 5px; margin: var(--button-margin);
border-radius: var(--button-radius);
cursor: pointer; cursor: pointer;
transition: background-color 0.3s ease; transition: background-color 0.3s ease, box-shadow 0.3s ease;
font-size: var(--button-font-size);
box-shadow: 0 2px 4px var(--button-shadow);
} }
/* Make button darker when hovered */
button:hover { button:hover {
background-color: var(--button-hover-background); background-color: var(--button-hover-background);
box-shadow: 0 3px 6px var(--button-shadow);
} }
/* Navbar at the top of the page */
.navbar { .navbar {
margin-bottom: 1rem; margin-bottom: 1rem;
text-align: center; text-align: center;
} }
/* Make the logo bigger and bolder and center it */
.logo { .logo {
text-align: center; text-align: center;
font-size: 2.5rem; font-size: 2.5rem;
@ -94,12 +92,10 @@
margin: 0; margin: 0;
} }
/* Django messages framework */
.messages { .messages {
list-style-type: none; list-style-type: none;
} }
/* Make error messages red and success messages green */
.error { .error {
color: red; color: red;
} }
@ -121,19 +117,34 @@
<div class="navbar"> <div class="navbar">
<a href='{% url "api-1.0.0:openapi-view" %}'>API</a> | <a href='{% url "api-1.0.0:openapi-view" %}'>API</a> |
<a href="https://github.com/sponsors/TheLovinator1">Donate</a> | <a href="https://github.com/sponsors/TheLovinator1">Donate</a> |
TheLovinator#9276 TheLovinator#9276 |
{% if user.is_authenticated %}
<a href='{% url "account_logout" %}'>Logout</a>
{% else %}
<a href='{% provider_login_url "twitch" %}'>Login</a>
{% endif %}
</div> </div>
{% for organization, org_data in orgs_data.items %} {% for organization, org_data in orgs_data.items %}
<ul> <ul>
{% for game, game_data in org_data.games.items %} {% for game, game_data in org_data.games.items %}
<li class="game"> <li class="game">
<header> <header>
<img src="{{ game.image_url }}"
alt="{{ game.display_name }}"
height="100"
width="100"
loading="lazy">
<h2> <h2>
<a href="https://www.twitch.tv/directory/category/{{ game.slug }}">{{ game.display_name }}</a> <a href="https://www.twitch.tv/directory/category/{{ game.slug }}">{{ game.display_name }}</a>
</h2> </h2>
<form action="" method="post"> <form action='{% url "core:subscription_create" %}' method="post">
{% csrf_token %} {% csrf_token %}
<input type="hidden" name="game_id" value="{{ game.id }}"> <input type="hidden" name="game_id" value="{{ game.id }}">
<select name="discord_webhook">
{% for discord_setting in discord_settings %}
<option value="{{ discord_setting.id }}">{{ discord_setting.name }}</option>
{% endfor %}
</select>
<button type="submit">Subscribe to {{ game.display_name }}</button> <button type="submit">Subscribe to {{ game.display_name }}</button>
</form> </form>
<form action='{% url "core:test" %}' method="post"> <form action='{% url "core:test" %}' method="post">
@ -150,7 +161,8 @@
<img src="{{ drop_benefit.image_asset_url }}" <img src="{{ drop_benefit.image_asset_url }}"
alt="{{ drop_benefit.name }}" alt="{{ drop_benefit.name }}"
height="100" height="100"
width="100"> width="100"
loading="lazy">
<a href="{{ drop_benefit.details_url }}">{{ drop_benefit.name }}</a> <a href="{{ drop_benefit.details_url }}">{{ drop_benefit.name }}</a>
</li> </li>
{% endfor %} {% endfor %}

View File

@ -10,4 +10,19 @@ app_name: str = "core"
urlpatterns: list[URLPattern | URLResolver] = [ urlpatterns: list[URLPattern | URLResolver] = [
path(route="", view=views.index, name="index"), path(route="", view=views.index, name="index"),
path(route="test/", view=views.test_webhook, name="test"), path(route="test/", view=views.test_webhook, name="test"),
path(
route="add-discord-webhook/",
view=views.add_discord_webhook,
name="add_discord_webhook",
),
path(
route="delete_discord_webhook/",
view=views.delete_discord_webhook,
name="delete_discord_webhook",
),
path(
route="subscribe/",
view=views.subscription_create,
name="subscription_create",
),
] ]

View File

@ -1,12 +1,19 @@
import logging import logging
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.db.models.manager import BaseManager from django.db.models.manager import BaseManager
from django.http import HttpRequest, HttpResponse from django.http import (
HttpRequest,
HttpResponse,
)
from django.shortcuts import redirect, render
from django.template.response import TemplateResponse from django.template.response import TemplateResponse
from core.discord import send from core.discord import send
from twitch.models import ( from core.models import DiscordSetting
from twitch_app.models import (
DropBenefit, DropBenefit,
DropCampaign, DropCampaign,
Game, Game,
@ -14,11 +21,15 @@ from twitch.models import (
TimeBasedDrop, TimeBasedDrop,
) )
from .forms import DiscordSettingForm
if TYPE_CHECKING: if TYPE_CHECKING:
from django.contrib.auth.base_user import AbstractBaseUser
from django.contrib.auth.models import AnonymousUser
from django.db.models.manager import BaseManager from django.db.models.manager import BaseManager
from django.views.decorators.http import require_POST from django.views.decorators.http import require_POST
logger = logging.getLogger(__name__) logger: logging.Logger = logging.getLogger(__name__)
def index(request: HttpRequest) -> HttpResponse: def index(request: HttpRequest) -> HttpResponse:
@ -32,12 +43,7 @@ def index(request: HttpRequest) -> HttpResponse:
""" """
organizations: BaseManager[Organization] = Organization.objects.all() organizations: BaseManager[Organization] = Organization.objects.all()
# Organize the data orgs_data = {org: {"games": {}, "drop_campaigns": []} for org in organizations}
orgs_data = {}
for org in organizations:
orgs_data[org] = {"games": {}, "drop_campaigns": []}
# Populate the games under each organization through DropBenefit and DropCampaign
for org in organizations: for org in organizations:
drop_benefits: BaseManager[DropBenefit] = DropBenefit.objects.filter( drop_benefits: BaseManager[DropBenefit] = DropBenefit.objects.filter(
owner_organization=org, owner_organization=org,
@ -70,25 +76,29 @@ def index(request: HttpRequest) -> HttpResponse:
for campaign in drop_campaigns: for campaign in drop_campaigns:
orgs_data[org]["drop_campaigns"].append(campaign) orgs_data[org]["drop_campaigns"].append(campaign)
if request.user.is_authenticated:
discord_settings: BaseManager[DiscordSetting] = DiscordSetting.objects.filter(
user=request.user,
)
context = {
"orgs_data": orgs_data,
"discord_settings": discord_settings if request.user.is_authenticated else None,
}
return TemplateResponse( return TemplateResponse(
request=request, request=request,
template="index.html", template="index.html",
context={"orgs_data": orgs_data}, context=context,
) )
@require_POST @require_POST
def test_webhook( def test_webhook(request: HttpRequest) -> HttpResponse:
request: HttpRequest,
*args, # noqa: ANN002, ARG001
**kwargs, # noqa: ARG001, ANN003
) -> HttpResponse:
"""Test webhook. """Test webhook.
Args: Args:
request: The request. request: The request.
args: Additional arguments.
kwargs: Additional keyword arguments.
Returns: Returns:
HttpResponse: Returns a response. HttpResponse: Returns a response.
@ -99,12 +109,98 @@ def test_webhook(
campaign: DropCampaign = DropCampaign.objects.get(id=org_id) campaign: DropCampaign = DropCampaign.objects.get(id=org_id)
msg: str = f""" msg: str = f"Found new drop for {campaign.game.display_name}:\n{campaign.name}\n{campaign.description}"
Found new drop for {campaign.game.display_name}:\n
{campaign.name}\n
{campaign.description}\n
<{campaign.details_url}>
"""
send(msg.strip()) send(msg.strip())
return HttpResponse(status=200) return HttpResponse(status=200)
@login_required
def add_discord_webhook(request: HttpRequest) -> HttpResponse:
"""Add Discord webhook."""
if request.method == "POST":
form = DiscordSettingForm(request.POST)
if form.is_valid():
DiscordSetting.objects.create(
user=request.user,
name=form.cleaned_data["name"],
webhook_url=form.cleaned_data["webhook_url"],
disabled=False,
)
messages.success(
request=request,
message=f"Webhook '{form.cleaned_data["name"]}' added ({form.cleaned_data["webhook_url"]})",
)
return redirect("core:add_discord_webhook")
else:
form = DiscordSettingForm()
webhooks: BaseManager[DiscordSetting] = DiscordSetting.objects.filter(
user=request.user,
)
return render(
request,
"add_discord_webhook.html",
{"form": form, "webhooks": webhooks},
)
@login_required
def delete_discord_webhook(request: HttpRequest) -> HttpResponse:
"""Delete Discord webhook."""
if request.method == "POST":
DiscordSetting.objects.filter(
id=request.POST.get("webhook_id"),
name=request.POST.get("webhook_name"),
webhook_url=request.POST.get("webhook_url"),
user=request.user,
).delete()
messages.success(
request=request,
message=f"Webhook '{request.POST.get("webhook_name")}' deleted ({request.POST.get("webhook_url")})",
)
return redirect("core:add_discord_webhook")
@login_required
def subscription_create(request: HttpRequest) -> HttpResponse:
"""Create subscription."""
if request.method == "POST":
game: Game = Game.objects.get(id=request.POST.get("game_id"))
user: AbstractBaseUser | AnonymousUser = request.user
webhook_id: str | None = request.POST.get("webhook_id")
if not webhook_id:
messages.error(request, "No webhook ID provided.")
return redirect("core:index")
if not user.is_authenticated:
messages.error(
request,
"You need to be logged in to create a subscription.",
)
return redirect("core:index")
logger.info(
"Current webhooks: %s",
DiscordSetting.objects.filter(user=user).values_list("id", flat=True),
)
discord_webhook: DiscordSetting = DiscordSetting.objects.get(
id=int(webhook_id),
user=user,
)
messages.success(request, "Subscription created")
send(
message=f"This channel will now receive a notification when a new Twitch drop for **{game}** is available.", # noqa: E501
webhook_url=discord_webhook.webhook_url,
)
return redirect("core:index")
messages.error(request, "Failed to create subscription")
return redirect("core:index")

View File

@ -1,14 +1,16 @@
discord-webhook
django-allauth[socialaccount]
django-ninja
django-simple-history django-simple-history
django>=0.0.0.dev0 django>=0.0.0.dev0
pillow pillow
platformdirs
playwright
playwright playwright
psycopg[binary] psycopg[binary]
python-dotenv python-dotenv
redis[hiredis] redis[hiredis]
selectolax selectolax
sentry-sdk[django] sentry-sdk[django]
twitchAPI
whitenoise[brotli] whitenoise[brotli]
platformdirs
playwright
django-ninja
discord-webhook

View File

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

View File

@ -1,83 +0,0 @@
# Generated by Django 5.1a1 on 2024-06-22 19:21
from django.db import migrations, models
from django.db.migrations.operations.base import Operation
class Migration(migrations.Migration):
dependencies: list[tuple[str, str]] = [
("twitch", "0002_alter_dropcampaign_options_alter_dropcampaign_game_and_more"),
]
operations: list[Operation] = [
migrations.AddField(
model_name="channel",
name="added_at",
field=models.DateTimeField(auto_now_add=True, null=True),
),
migrations.AddField(
model_name="channel",
name="modified_at",
field=models.DateTimeField(auto_now=True, null=True),
),
migrations.AddField(
model_name="dropbenefit",
name="added_at",
field=models.DateTimeField(auto_now_add=True, null=True),
),
migrations.AddField(
model_name="dropbenefit",
name="modified_at",
field=models.DateTimeField(auto_now=True, null=True),
),
migrations.AddField(
model_name="dropcampaign",
name="added_at",
field=models.DateTimeField(auto_now_add=True, null=True),
),
migrations.AddField(
model_name="dropcampaign",
name="modified_at",
field=models.DateTimeField(auto_now=True, null=True),
),
migrations.AddField(
model_name="game",
name="added_at",
field=models.DateTimeField(auto_now_add=True, null=True),
),
migrations.AddField(
model_name="game",
name="modified_at",
field=models.DateTimeField(auto_now=True, null=True),
),
migrations.AddField(
model_name="organization",
name="added_at",
field=models.DateTimeField(auto_now_add=True, null=True),
),
migrations.AddField(
model_name="organization",
name="modified_at",
field=models.DateTimeField(auto_now=True, null=True),
),
migrations.AddField(
model_name="timebaseddrop",
name="added_at",
field=models.DateTimeField(auto_now_add=True, null=True),
),
migrations.AddField(
model_name="timebaseddrop",
name="modified_at",
field=models.DateTimeField(auto_now=True, null=True),
),
migrations.AddField(
model_name="user",
name="added_at",
field=models.DateTimeField(auto_now_add=True, null=True),
),
migrations.AddField(
model_name="user",
name="modified_at",
field=models.DateTimeField(auto_now=True, null=True),
),
]

View File

@ -1,17 +0,0 @@
# Generated by Django 5.1a1 on 2024-06-22 19:27
from django.db import migrations
from django.db.migrations.operations.base import Operation
class Migration(migrations.Migration):
dependencies: list[tuple[str, str]] = [
("twitch", "0003_channel_added_at_channel_modified_at_and_more"),
]
operations: list[Operation] = [
migrations.AlterModelOptions(
name="dropcampaign",
options={},
),
]

9
twitch_app/admin.py Normal file
View File

@ -0,0 +1,9 @@
from django.contrib import admin
from .models import DropBenefit, DropCampaign, Game, Organization, TimeBasedDrop
admin.site.register(DropBenefit)
admin.site.register(DropCampaign)
admin.site.register(Game)
admin.site.register(Organization)
admin.site.register(TimeBasedDrop)

View File

@ -5,7 +5,6 @@ from django.http import HttpRequest
from ninja import Router, Schema from ninja import Router, Schema
from .models import ( from .models import (
Channel,
DropBenefit, DropBenefit,
DropCampaign, DropCampaign,
Game, Game,
@ -94,13 +93,6 @@ def get_organizations(
return Organization.objects.all() return Organization.objects.all()
# http://localhost:8000/api/twitch/channels
@router.get("/channels", response=list[ChannelSchema])
def get_channels(request: HttpRequest) -> BaseManager[Channel]: # noqa: ARG001
"""Get all channels."""
return Channel.objects.all()
# http://localhost:8000/api/twitch/games # http://localhost:8000/api/twitch/games
@router.get("/games", response=list[GameSchema]) @router.get("/games", response=list[GameSchema])
def get_games(request: HttpRequest) -> BaseManager[Game]: # noqa: ARG001 def get_games(request: HttpRequest) -> BaseManager[Game]: # noqa: ARG001

View File

@ -3,4 +3,4 @@ from django.apps import AppConfig
class TwitchConfig(AppConfig): class TwitchConfig(AppConfig):
default_auto_field: str = "django.db.models.BigAutoField" default_auto_field: str = "django.db.models.BigAutoField"
name: str = "twitch" name: str = "twitch_app"

View File

@ -10,8 +10,7 @@ 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.models import ( from twitch_app.models import (
Channel,
DropBenefit, DropBenefit,
DropCampaign, DropCampaign,
Game, Game,
@ -40,7 +39,7 @@ if not data_dir:
logger: logging.Logger = logging.getLogger("twitch.management.commands.scrape_twitch") logger: logging.Logger = logging.getLogger("twitch.management.commands.scrape_twitch")
async def insert_data(data: dict) -> None: # noqa: PLR0914, C901, PLR0912 async def insert_data(data: dict) -> None: # noqa: PLR0914, C901
"""Insert data into the database. """Insert data into the database.
Args: Args:
@ -96,20 +95,6 @@ async def insert_data(data: dict) -> None: # noqa: PLR0914, C901, PLR0912
if created: if created:
logger.debug("Drop campaign created: %s", drop_campaign) logger.debug("Drop campaign created: %s", drop_campaign)
# Create channels
if drop_campaign_data["allow"] and drop_campaign_data["allow"]["channels"]:
for channel_data in drop_campaign_data["allow"]["channels"]:
channel, created = await sync_to_async(Channel.objects.get_or_create)(
id=channel_data["id"],
defaults={
"display_name": channel_data["displayName"],
"name": channel_data["name"],
},
)
await sync_to_async(drop_campaign.channels.add)(channel)
if created:
logger.debug("Channel created: %s", channel)
# Create time-based drops # Create time-based drops
for drop_data in drop_campaign_data["timeBasedDrops"]: for drop_data in drop_campaign_data["timeBasedDrops"]:
drop_benefit_edges = drop_data["benefitEdges"] drop_benefit_edges = drop_data["benefitEdges"]

View File

@ -1,6 +1,7 @@
# Generated by Django 5.1a1 on 2024-06-20 21:44 # Generated by Django 5.0.6 on 2024-07-01 00:08
import django.db.models.deletion import django.db.models.deletion
import django.db.models.functions.text
from django.db import migrations, models from django.db import migrations, models
from django.db.migrations.operations.base import Operation from django.db.migrations.operations.base import Operation
@ -11,20 +12,25 @@ class Migration(migrations.Migration):
dependencies: list[tuple[str, str]] = [] dependencies: list[tuple[str, str]] = []
operations: list[Operation] = [ operations: list[Operation] = [
migrations.CreateModel(
name="Channel",
fields=[
("id", models.TextField(primary_key=True, serialize=False)),
("display_name", models.TextField(blank=True, null=True)),
("name", models.TextField(blank=True, null=True)),
],
),
migrations.CreateModel( migrations.CreateModel(
name="Game", name="Game",
fields=[ fields=[
("id", models.TextField(primary_key=True, serialize=False)), ("id", models.TextField(primary_key=True, serialize=False)),
("slug", models.TextField(blank=True, null=True)), ("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(),
),
),
("display_name", models.TextField(blank=True, null=True)), ("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)),
], ],
), ),
migrations.CreateModel( migrations.CreateModel(
@ -32,6 +38,8 @@ class Migration(migrations.Migration):
fields=[ fields=[
("id", models.TextField(primary_key=True, serialize=False)), ("id", models.TextField(primary_key=True, serialize=False)),
("name", models.TextField(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)),
], ],
), ),
migrations.CreateModel( migrations.CreateModel(
@ -43,18 +51,20 @@ class Migration(migrations.Migration):
("image_asset_url", models.URLField(blank=True, null=True)), ("image_asset_url", models.URLField(blank=True, null=True)),
("is_ios_available", models.BooleanField(blank=True, null=True)), ("is_ios_available", models.BooleanField(blank=True, null=True)),
("name", models.TextField(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)),
( (
"game", "game",
models.ForeignKey( models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, on_delete=django.db.models.deletion.CASCADE,
to="twitch.game", to="twitch_app.game",
), ),
), ),
( (
"owner_organization", "owner_organization",
models.ForeignKey( models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, on_delete=django.db.models.deletion.CASCADE,
to="twitch.organization", to="twitch_app.organization",
), ),
), ),
], ],
@ -71,7 +81,9 @@ class Migration(migrations.Migration):
models.IntegerField(blank=True, null=True), models.IntegerField(blank=True, null=True),
), ),
("start_at", models.DateTimeField(blank=True, null=True)), ("start_at", models.DateTimeField(blank=True, null=True)),
("benefits", models.ManyToManyField(to="twitch.dropbenefit")), ("added_at", models.DateTimeField(auto_now_add=True, null=True)),
("modified_at", models.DateTimeField(auto_now=True, null=True)),
("benefits", models.ManyToManyField(to="twitch_app.dropbenefit")),
], ],
), ),
migrations.CreateModel( migrations.CreateModel(
@ -86,29 +98,40 @@ class Migration(migrations.Migration):
("name", models.TextField(blank=True, null=True)), ("name", models.TextField(blank=True, null=True)),
("start_at", models.DateTimeField(blank=True, null=True)), ("start_at", models.DateTimeField(blank=True, null=True)),
("status", models.TextField(blank=True, null=True)), ("status", models.TextField(blank=True, null=True)),
("channels", models.ManyToManyField(to="twitch.channel")), ("added_at", models.DateTimeField(auto_now_add=True, null=True)),
("modified_at", models.DateTimeField(auto_now=True, null=True)),
( (
"game", "game",
models.ForeignKey( models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, on_delete=django.db.models.deletion.CASCADE,
to="twitch.game", related_name="drop_campaigns",
to="twitch_app.game",
), ),
), ),
( (
"owner", "owner",
models.ForeignKey( models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, on_delete=django.db.models.deletion.CASCADE,
to="twitch.organization", related_name="drop_campaigns",
to="twitch_app.organization",
), ),
), ),
("time_based_drops", models.ManyToManyField(to="twitch.timebaseddrop")), (
"time_based_drops",
models.ManyToManyField(to="twitch_app.timebaseddrop"),
),
], ],
), ),
migrations.CreateModel( migrations.CreateModel(
name="User", name="User",
fields=[ fields=[
("id", models.TextField(primary_key=True, serialize=False)), ("id", models.TextField(primary_key=True, serialize=False)),
("drop_campaigns", models.ManyToManyField(to="twitch.dropcampaign")), ("added_at", models.DateTimeField(auto_now_add=True, null=True)),
("modified_at", models.DateTimeField(auto_now=True, null=True)),
(
"drop_campaigns",
models.ManyToManyField(to="twitch_app.dropcampaign"),
),
], ],
), ),
] ]

View File

@ -1,4 +1,4 @@
# Generated by Django 5.1a1 on 2024-06-22 20:05 # Generated by Django 5.0.6 on 2024-07-01 03:49
import django.db.models.functions.text import django.db.models.functions.text
from django.db import migrations, models from django.db import migrations, models
@ -7,20 +7,21 @@ from django.db.migrations.operations.base import Operation
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies: list[tuple[str, str]] = [ dependencies: list[tuple[str, str]] = [
("twitch", "0004_alter_dropcampaign_options"), ("twitch_app", "0001_initial"),
] ]
operations: list[Operation] = [ operations: list[Operation] = [
migrations.AddField( migrations.AddField(
model_name="game", model_name="game",
name="twitch_url", name="image_url",
field=models.GeneratedField( # type: ignore # noqa: PGH003 field=models.GeneratedField( # type: ignore # noqa: PGH003
db_persist=True, db_persist=True,
expression=django.db.models.functions.text.Concat( expression=django.db.models.functions.text.Concat(
models.Value("https://www.twitch.tv/directory/category/"), models.Value("https://static-cdn.jtvnw.net/ttv-boxart/"),
"slug", "id",
models.Value("_IGDB.jpg"),
), ),
output_field=models.TextField(), output_field=models.URLField(),
), ),
), ),
] ]

View File

@ -23,6 +23,15 @@ class Game(models.Model):
output_field=models.TextField(), output_field=models.TextField(),
db_persist=True, 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) display_name = models.TextField(blank=True, null=True)
added_at = models.DateTimeField(blank=True, null=True, auto_now_add=True) added_at = models.DateTimeField(blank=True, null=True, auto_now_add=True)
modified_at = models.DateTimeField(blank=True, null=True, auto_now=True) modified_at = models.DateTimeField(blank=True, null=True, auto_now=True)
@ -31,17 +40,6 @@ class Game(models.Model):
return self.display_name or self.slug or self.id return self.display_name or self.slug or self.id
class Channel(models.Model):
id = models.TextField(primary_key=True)
display_name = models.TextField(blank=True, null=True)
name = models.TextField(blank=True, null=True)
added_at = models.DateTimeField(blank=True, null=True, auto_now_add=True)
modified_at = models.DateTimeField(blank=True, null=True, auto_now=True)
def __str__(self) -> str:
return self.display_name or self.name or self.id
class DropBenefit(models.Model): class DropBenefit(models.Model):
id = models.TextField(primary_key=True) id = models.TextField(primary_key=True)
created_at = models.DateTimeField(blank=True, null=True) created_at = models.DateTimeField(blank=True, null=True)
@ -93,7 +91,6 @@ class DropCampaign(models.Model):
on_delete=models.CASCADE, on_delete=models.CASCADE,
related_name="drop_campaigns", related_name="drop_campaigns",
) )
channels = models.ManyToManyField(Channel)
time_based_drops = models.ManyToManyField(TimeBasedDrop) time_based_drops = models.ManyToManyField(TimeBasedDrop)
added_at = models.DateTimeField(blank=True, null=True, auto_now_add=True) added_at = models.DateTimeField(blank=True, null=True, auto_now_add=True)
modified_at = models.DateTimeField(blank=True, null=True, auto_now=True) modified_at = models.DateTimeField(blank=True, null=True, auto_now=True)