diff --git a/config/settings.py b/config/settings.py index e464b0d..b0cd681 100644 --- a/config/settings.py +++ b/config/settings.py @@ -74,10 +74,6 @@ INSTALLED_APPS: list[str] = [ "django.contrib.messages", "django.contrib.staticfiles", "ninja", - "allauth", - "allauth.account", - "allauth.socialaccount", - "allauth.socialaccount.providers.twitch", "simple_history", ] @@ -90,7 +86,6 @@ MIDDLEWARE: list[str] = [ "django.contrib.auth.middleware.AuthenticationMiddleware", "django.contrib.messages.middleware.MessageMiddleware", "django.middleware.clickjacking.XFrameOptionsMiddleware", - "allauth.account.middleware.AccountMiddleware", "simple_history.middleware.HistoryRequestMiddleware", ] @@ -165,26 +160,6 @@ LOGGING = { }, } -LOGIN_URL = "/login/" -LOGIN_REDIRECT_URL = "/" -LOGOUT_REDIRECT_URL = "/" -SOCIALACCOUNT_ONLY = True -ACCOUNT_EMAIL_VERIFICATION = "none" -SOCIALACCOUNT_STORE_TOKENS = True - -AUTHENTICATION_BACKENDS: list[str] = [ - "django.contrib.auth.backends.ModelBackend", - "allauth.account.auth_backends.AuthenticationBackend", -] - -SOCIALACCOUNT_PROVIDERS = { - "twitch": { - "SCOPE": ["user:read:email"], - "AUTH_PARAMS": {"force_verify": True}, - }, -} - - MESSAGE_TAGS: dict[int, str] = { messages.DEBUG: "alert-info", messages.INFO: "alert-info", diff --git a/config/urls.py b/config/urls.py index 48d16c2..704a3a3 100644 --- a/config/urls.py +++ b/config/urls.py @@ -21,7 +21,6 @@ app_name: str = "config" urlpatterns: list[URLPattern | URLResolver] = [ path(route="admin/", view=admin.site.urls), - path(route="accounts/", view=include(arg="allauth.urls")), path(route="", view=include(arg="core.urls")), path(route="api/", view=api.urls), ] diff --git a/core/admin.py b/core/admin.py index e317e38..de528d8 100644 --- a/core/admin.py +++ b/core/admin.py @@ -1,8 +1,7 @@ from django.contrib import admin from simple_history.admin import SimpleHistoryAdmin -from .models import DiscordSetting, Subscription +from .models import Webhook # https://django-simple-history.readthedocs.io/en/latest/admin.html -admin.site.register(DiscordSetting, SimpleHistoryAdmin) -admin.site.register(Subscription, SimpleHistoryAdmin) +admin.site.register(Webhook, SimpleHistoryAdmin) diff --git a/core/forms.py b/core/forms.py index 2a8f165..a1b9bba 100644 --- a/core/forms.py +++ b/core/forms.py @@ -1,15 +1,24 @@ from django import forms +from django.core.validators import URLValidator 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 + validators=[ + URLValidator( + schemes=["https"], + message="The URL must be a valid HTTPS URL.", + ), + URLValidator( + regex=r"https://discord.com/api/webhooks/\d{18}/[a-zA-Z0-9_-]{68}", + message="The URL must be a valid Discord webhook URL.", + ), + URLValidator( + regex=r"https://discordapp.com/api/webhooks/\d{18}/[a-zA-Z0-9_-]{68}", + message="The URL must be a valid Discord webhook URL.", + ), + ], + help_text="The URL can be found by right-clicking on the channel and selecting 'Edit Channel', then 'Integrations', and 'Webhooks'.", # noqa: E501 ) diff --git a/core/models.py b/core/models.py index d86a325..baaa292 100644 --- a/core/models.py +++ b/core/models.py @@ -1,28 +1,26 @@ -from django.contrib.auth.models import User +from typing import Literal + +import auto_prefetch from django.db import models from simple_history.models import HistoricalRecords 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() - history = HistoricalRecords() - disabled = models.BooleanField(default=False) - created_at = models.DateTimeField(auto_now_add=True) +class Webhook(auto_prefetch.Model): + """Webhooks to send notifications to.""" - def __str__(self) -> str: - return f"Discord: {self.user.username} - {self.name}" - - -class Subscription(models.Model): - user = models.ForeignKey(User, on_delete=models.CASCADE) + url = models.URLField(unique=True) game = models.ForeignKey(Game, on_delete=models.CASCADE) - created_at = models.DateTimeField(auto_now_add=True) + disabled = models.BooleanField(default=False) + added_at = models.DateTimeField(blank=True, null=True, auto_now_add=True) + modified_at = models.DateTimeField(blank=True, null=True, auto_now=True) history = HistoricalRecords() - discord_webhook = models.ForeignKey(DiscordSetting, on_delete=models.CASCADE) + + class Meta(auto_prefetch.Model.Meta): + verbose_name: str = "Webhook" + verbose_name_plural: str = "Webhooks" + ordering: tuple[Literal["url"]] = ("url",) def __str__(self) -> str: - return f"Subscription: {self.user.username} - {self.game.display_name} - {self.discord_webhook.name}" + return self.url diff --git a/core/templates/add_discord_webhook.html b/core/templates/add_discord_webhook.html deleted file mode 100644 index 5e6ad3b..0000000 --- a/core/templates/add_discord_webhook.html +++ /dev/null @@ -1,54 +0,0 @@ -{% extends "base.html" %} -{% block content %} -
-

Add Discord Webhook

-
- {% csrf_token %} - {{ form.non_field_errors }} -
- {{ form.name.errors }} - - -
{{ form.name.help_text }}
-
-
- {{ form.webhook_url.errors }} - - -
{{ form.webhook_url.help_text }}
-
- -
-

Webhooks

- -
-{% endblock content %} diff --git a/core/templates/index.html b/core/templates/index.html index 6405562..f8f9632 100644 --- a/core/templates/index.html +++ b/core/templates/index.html @@ -1,98 +1,122 @@ {% extends "base.html" %} {% block content %} -
-
-
-
-
-
-
Games
- -
-
-
-
-
- {% for game in games %} -
-
-
- {{ game.display_name }} -
-
-
-

- {{ game.display_name }} -

-
-
- - -
-
- - -
-
- {% for campaign in game.campaigns %} - {% if not forloop.first %}
{% endif %} -
-

{{ campaign.name }}

-

Ends in: {{ campaign.end_at|timeuntil }}

- {% if campaign.description != campaign.name %} - {% if campaign.description|length|get_digit:"-1" > 100 %} -

- -

-
-
{{ campaign.description }}
-
-
- {% else %} -

{{ campaign.description }}

- {% endif %} - {% endif %} -
- {% for drop in campaign.drops %} -
- {{ drop.name }} drop image - {{ drop.name }} -
- {% endfor %} -
-
+ +
+
+
+
+
+
+
Games
+
+ {% for game in games %} + {{ game.display_name }} {% endfor %}
- {% endfor %} +
+
+ {% for game in games %} +
+
+
+ {{ game.display_name }} +
+
+
+

+ {{ game.display_name }} +

+
+
+ + +
+
+ + +
+
+ {% for campaign in game.campaigns %} + {% if not forloop.first %}
{% endif %} +
+

{{ campaign.name }}

+

+ Ends in: {{ campaign.end_at|timeuntil }} +

+ {% if campaign.description != campaign.name %} + {% if campaign.description|length > 100 %} +

+ +

+
+
{{ campaign.description }}
+
+
+ {% else %} +

{{ campaign.description }}

+ {% endif %} + {% endif %} +
+ {% for drop in campaign.drops %} +
+ {{ drop.name }} drop image + {{ drop.name }} +
+ {% endfor %} +
+
+ {% endfor %} +
+
+
+
+ {% endfor %} +
-
+ + {% endblock content %} diff --git a/core/templates/partials/header.html b/core/templates/partials/header.html index a88f857..c384487 100644 --- a/core/templates/partials/header.html +++ b/core/templates/partials/header.html @@ -1,4 +1,3 @@ -{% load socialaccount %}

Twitch drops @@ -15,17 +14,8 @@ Donate
  • - Webhooks + Webhooks
  • - {% if user.is_authenticated %} - - {% else %} - - {% endif %}

    diff --git a/core/templates/webhooks.html b/core/templates/webhooks.html new file mode 100644 index 0000000..e4c143a --- /dev/null +++ b/core/templates/webhooks.html @@ -0,0 +1,47 @@ +{% extends "base.html" %} +{% block content %} +
    +

    Add Discord Webhook

    +
    + {% csrf_token %} + {{ form.non_field_errors }} +
    + {{ form.webhook_url.errors }} + + +
    {{ form.webhook_url.help_text }}
    +
    + +
    +

    Webhooks

    + {% if webhooks %} +
      + {% for webhook in webhooks %} +
    • +
      + {{ webhook.name }} + added on {{ webhook.created_at|date:"F j, Y, g:i a" }} +
      +
      + {% csrf_token %} + + + + +
      +
    • + {% endfor %} +
    + {% else %} +

    No webhooks added yet.

    + {% endif %} +
    +{% endblock content %} diff --git a/core/urls.py b/core/urls.py index 7017280..2281fcb 100644 --- a/core/urls.py +++ b/core/urls.py @@ -9,25 +9,19 @@ app_name: str = "core" urlpatterns: list[URLPattern | URLResolver] = [ path(route="", view=views.index, name="index"), - 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", - ), path( route="games/", view=views.GameView.as_view(), name="games", ), + path( + route="webhooks/", + view=views.Webhooks.as_view(), + name="webhooks", + ), + path( + route="webhooks/add/", + view=views.add_webhook, + name="add_webhook", + ), ] diff --git a/core/views.py b/core/views.py index c1ca027..61e1b66 100644 --- a/core/views.py +++ b/core/views.py @@ -1,21 +1,17 @@ import datetime import logging from dataclasses import dataclass -from typing import TYPE_CHECKING +import httpx from django.contrib import messages -from django.contrib.auth.decorators import login_required -from django.db.models.manager import BaseManager from django.http import ( HttpRequest, HttpResponse, ) -from django.shortcuts import redirect, render from django.template.response import TemplateResponse -from django.views.generic import ListView +from django.views.decorators.http import require_POST +from django.views.generic import ListView, TemplateView -from core.discord import send -from core.models import DiscordSetting from twitch_app.models import ( DropBenefit, DropCampaign, @@ -25,12 +21,6 @@ from twitch_app.models import ( from .forms import DiscordSettingForm -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.views.decorators.http import require_POST - logger: logging.Logger = logging.getLogger(__name__) @@ -75,81 +65,86 @@ class GameContext: slug: str | None = None -def index(request: HttpRequest) -> HttpResponse: - """/ index page. +def fetch_games() -> list[Game]: + """Fetch all games with necessary fields.""" + return list(Game.objects.all().only("id", "image_url", "display_name", "slug")) - Args: - request: The request. - Returns: - HttpResponse: Returns the index page. - """ - list_of_games: list[GameContext] = [] - - for game in Game.objects.all().only("id", "image_url", "display_name", "slug"): - campaigns: list[CampaignContext] = [] - for campaign in DropCampaign.objects.filter( - game=game, - status="ACTIVE", - end_at__gt=datetime.datetime.now(tz=datetime.UTC), - ).only( - "id", - "name", - "image_url", - "status", - "account_link_url", - "description", - "details_url", - "start_at", - "end_at", - ): - drops: list[DropContext] = [] - drop: TimeBasedDrop - for drop in campaign.time_based_drops.all().only( - "id", - "name", - "required_minutes_watched", - "required_subs", - ): - benefit: DropBenefit | None = drop.benefits.first() - image_asset_url: str = ( - benefit.image_asset_url - if benefit - else "https://static-cdn.jtvnw.net/twitch-quests-assets/CAMPAIGN/default.png" - ) - drops.append( - DropContext( - drops_id=drop.id, - image_url=image_asset_url, - name=drop.name, - required_minutes_watched=drop.required_minutes_watched, - required_subs=drop.required_subs, - ), - ) - - if not drops: - logger.info("No drops found for %s", campaign.name) - continue - - campaigns.append( - CampaignContext( - drop_id=campaign.id, - name=campaign.name, - image_url=campaign.image_url, - status=campaign.status, - account_link_url=campaign.account_link_url, - description=campaign.description, - details_url=campaign.details_url, - start_at=campaign.start_at, - end_at=campaign.end_at, - drops=drops, - ), - ) - - if not campaigns: - logger.info("No campaigns found for %s", game.display_name) +def fetch_campaigns(game: Game) -> list[CampaignContext]: + """Fetch active campaigns for a given game.""" + campaigns: list[CampaignContext] = [] + for campaign in DropCampaign.objects.filter( + game=game, + status="ACTIVE", + end_at__gt=datetime.datetime.now(tz=datetime.UTC), + ).only( + "id", + "name", + "image_url", + "status", + "account_link_url", + "description", + "details_url", + "start_at", + "end_at", + ): + drops = fetch_drops(campaign) + if not drops: + logger.info("No drops found for %s", campaign.name) continue + campaigns.append( + CampaignContext( + drop_id=campaign.id, + name=campaign.name, + image_url=campaign.image_url, + status=campaign.status, + account_link_url=campaign.account_link_url, + description=campaign.description, + details_url=campaign.details_url, + start_at=campaign.start_at, + end_at=campaign.end_at, + drops=drops, + ), + ) + return campaigns + + +def fetch_drops(campaign: DropCampaign) -> list[DropContext]: + """Fetch drops for a given campaign.""" + drops: list[DropContext] = [] + drop: TimeBasedDrop + for drop in campaign.time_based_drops.all().only( + "id", + "name", + "required_minutes_watched", + "required_subs", + ): + benefit: DropBenefit | None = drop.benefits.first() + + image_asset_url: str = "https://static-cdn.jtvnw.net/twitch-quests-assets/CAMPAIGN/default.png" + if benefit and benefit.image_asset_url: + image_asset_url = benefit.image_asset_url + + drops.append( + DropContext( + drops_id=drop.id, + image_url=image_asset_url, + name=drop.name, + required_minutes_watched=drop.required_minutes_watched, + required_subs=drop.required_subs, + ), + ) + return drops + + +def prepare_game_contexts() -> list[GameContext]: + """Prepare game contexts with their respective campaigns and drops.""" + list_of_games: list[GameContext] = [] + for game in fetch_games(): + campaigns: list[CampaignContext] = fetch_campaigns(game) + if not campaigns: + continue list_of_games.append( GameContext( game_id=game.id, @@ -159,131 +154,106 @@ def index(request: HttpRequest) -> HttpResponse: twitch_url=game.twitch_url, ), ) + return list_of_games - context: dict[str, list[GameContext]] = {"games": list_of_games} + +def sort_games_by_campaign_start(list_of_games: list[GameContext]) -> list[GameContext]: + """Sort games by the start date of the first campaign and reverse the list so the latest games are first.""" + if list_of_games and list_of_games[0].campaigns: + list_of_games.sort( + key=lambda x: x.campaigns[0].start_at + if x.campaigns and x.campaigns[0].start_at is not None + else datetime.datetime.min, + ) + list_of_games.reverse() + return list_of_games + + +def index(request: HttpRequest) -> HttpResponse: + """Render the index page.""" + list_of_games: list[GameContext] = prepare_game_contexts() + sorted_list_of_games: list[GameContext] = sort_games_by_campaign_start(list_of_games) return TemplateResponse( request=request, template="index.html", - context=context, + context={"games": sorted_list_of_games}, ) -@require_POST -def test_webhook(request: HttpRequest) -> HttpResponse: - """Test webhook. - - Args: - request: The request. - - Returns: - HttpResponse: Returns a response. - """ - org_id: str | None = request.POST.get("org_id") - if not org_id: - return HttpResponse(status=400) - - campaign: DropCampaign = DropCampaign.objects.get(id=org_id) - - msg: str = f"Found new drop for {campaign.game.display_name}:\n{campaign.name}\n{campaign.description}" - send(msg.strip()) - - 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") - - class GameView(ListView): model = Game template_name: str = "games.html" context_object_name: str = "games" paginate_by = 100 + + +@dataclass +class WebhookData: + """The webhook data.""" + + name: str | None = None + url: str | None = None + status: str | None = None + response: str | None = None + + +class Webhooks(TemplateView): + model = Game + template_name: str = "webhooks.html" + context_object_name: str = "webhooks" + paginate_by = 100 + + def get_context_data(self, **kwargs) -> dict[str, list[WebhookData] | DiscordSettingForm]: # noqa: ANN003, ARG002 + """Get the context data for the view.""" + cookie: str = self.request.COOKIES.get("webhooks", "") + webhooks: list[str] = cookie.split(",") + webhooks = list(filter(None, webhooks)) + + webhook_respones: list[WebhookData] = [] + + # Use httpx to connect to webhook url and get the response + # Use the response to get name of the webhook + with httpx.Client() as client: + for webhook in webhooks: + our_webhook = WebhookData(name="Unknown", url=webhook, status="Failed", response="No response") + response: httpx.Response = client.get(url=webhook) + if response.is_success: + our_webhook.name = response.json()["name"] + our_webhook.status = "Success" + our_webhook.response = response.text + else: + our_webhook.status = "Failed" + our_webhook.response = response.text + + # Add to the list of webhooks + webhook_respones.append(our_webhook) + + return {"webhooks": webhook_respones, "form": DiscordSettingForm()} + + +@require_POST +def add_webhook(request: HttpRequest) -> HttpResponse: + """Add a webhook to the list of webhooks.""" + form = DiscordSettingForm(request.POST) + + if form.is_valid(): + webhook = str(form.cleaned_data["webhook"]) + response = HttpResponse() + + if "webhooks" in request.COOKIES: + cookie: str = request.COOKIES["webhooks"] + webhooks: list[str] = cookie.split(",") + webhooks = list(filter(None, webhooks)) + if webhook in webhooks: + messages.error(request, "Webhook already exists.") + return response + webhooks.append(webhook) + webhook: str = ",".join(webhooks) + + response.set_cookie(key="webhooks", value=webhook, max_age=60 * 60 * 24 * 365) + + messages.info(request, "Webhook successfully added.") + return response + + return HttpResponse(status=400, content="Invalid form data.") diff --git a/requirements.txt b/requirements.txt index aa50704..d2caa9b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,13 +1,13 @@ discord-webhook -django-allauth[socialaccount] django-auto-prefetch django-ninja django-simple-history django>=0.0.0.dev0 +hishel +httpx pillow platformdirs playwright -playwright psycopg[binary] python-dotenv sentry-sdk[django] diff --git a/static/css/style.css b/static/css/style.css index c25f865..d3d4cdf 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -59,17 +59,37 @@ a:hover { /* Table of games to the left */ .toc { top: 1rem; + + max-height: 80vh; + /* Adjust this value as needed */ + overflow-y: auto; } /* Checkboxes for subscribing to notifications */ /* Checked */ .form-check-input:checked { background-color: #af1548; - border-color: #111111; + border: 1px solid #af1548; } /* Unchecked */ .form-check-input { - background-color: #0c0c0c; - border-color: #111111; + background-color: #1b1b1b; +} + +.plain-text-item { + color: inherit; + text-decoration: none; + background-color: transparent; + border: none; +} + +.plain-text-item:hover { + color: inherit; + background-color: #af1548; + +} + +.plain-text-item.active { + background-color: #af1548; } diff --git a/twitch_app/models.py b/twitch_app/models.py index 87ae7bf..0639b4d 100644 --- a/twitch_app/models.py +++ b/twitch_app/models.py @@ -1,11 +1,9 @@ import auto_prefetch -from django.contrib.humanize.templatetags.humanize import naturaltime from django.db import models from django.db.models import Value from django.db.models.functions import ( Concat, ) -from django.utils import timezone from simple_history.models import HistoricalRecords @@ -113,10 +111,6 @@ class TimeBasedDrop(auto_prefetch.Model): ordering = ("name",) def __str__(self) -> str: - if self.end_at: - if self.end_at < timezone.now(): - return f"{self.benefits.first()} - {self.name} - Ended {naturaltime(self.end_at)}" - return f"{self.benefits.first()} - {self.name} - Ends in {naturaltime(self.end_at)}" return f"{self.benefits.first()} - {self.name}"