diff --git a/templates/base.html b/templates/base.html index 5382bec..ab4d1f4 100644 --- a/templates/base.html +++ b/templates/base.html @@ -48,6 +48,7 @@ Games | Organizations | {% if user.is_authenticated %} + Debug | {% if user.is_staff %} Admin | {% endif %} diff --git a/templates/twitch/debug.html b/templates/twitch/debug.html new file mode 100644 index 0000000..9c51f98 --- /dev/null +++ b/templates/twitch/debug.html @@ -0,0 +1,117 @@ +{% extends "base.html" %} +{% block title %} + Debug +{% endblock title %} +{% block content %} +

Debug Data Integrity Report

+

Generated at: {{ now }}

+
+

Games Without Campaigns / Orgs ({{ games_without_orgs|length }})

+ {% if games_without_orgs %} + + {% else %} +

None ✅

+ {% endif %} +
+
+

Campaigns With Broken Image URLs ({{ broken_image_campaigns|length }})

+ {% if broken_image_campaigns %} + + {% else %} +

None ✅

+ {% endif %} +
+
+

Active Campaigns Missing Image ({{ active_missing_image|length }})

+ {% if active_missing_image %} + + {% else %} +

None ✅

+ {% endif %} +
+
+

Benefits With Broken Image URLs ({{ broken_benefit_images|length }})

+ {% if broken_benefit_images %} + + {% else %} +

None ✅

+ {% endif %} +
+
+

Time-Based Drops Without Benefits ({{ drops_without_benefits|length }})

+ {% if drops_without_benefits %} + + {% else %} +

None ✅

+ {% endif %} +
+
+

Campaigns With Invalid Dates ({{ invalid_date_campaigns|length }})

+ {% if invalid_date_campaigns %} + + {% else %} +

None ✅

+ {% endif %} +
+
+

Duplicate Campaign Names Per Game ({{ duplicate_name_campaigns|length }})

+ {% if duplicate_name_campaigns %} + + + + + + + + + + {% for row in duplicate_name_campaigns %} + + + + + + {% endfor %} + +
GameNameCount
{{ row.game_id }}{{ row.name }}{{ row.name_count }}
+ {% else %} +

None ✅

+ {% endif %} +
+{% endblock content %} diff --git a/twitch/management/commands/import_drops.py b/twitch/management/commands/import_drops.py index 9bed851..cf7cb79 100644 --- a/twitch/management/commands/import_drops.py +++ b/twitch/management/commands/import_drops.py @@ -1,6 +1,7 @@ from __future__ import annotations import json +import logging import re import shutil import traceback @@ -19,6 +20,9 @@ if TYPE_CHECKING: from datetime import datetime +logger: logging.Logger = logging.getLogger(__name__) + + class Command(BaseCommand): """Import Twitch drop campaign data from a JSON file or directory of JSON files.""" @@ -113,6 +117,7 @@ class Command(BaseCommand): processed_path: Subdirectory to move processed files to. """ data = orjson.loads(file_path.read_bytes()) + broken_dir: Path = processed_path / "broken" broken_dir.mkdir(parents=True, exist_ok=True) @@ -128,14 +133,13 @@ class Command(BaseCommand): "DropCurrentSessionContext", "DropsHighlightService_AvailableDrops", "DropsPage_ClaimDropRewards", - "Inventory", "OnsiteNotifications_DeleteNotification", "PlaybackAccessToken", "streamPlaybackAccessToken", "VideoPlayerStreamInfoOverlayChannel", ] for keyword in probably_shit: - if f'"operationName": "{keyword}"' in data: + if f'"operationName": "{keyword}"' in str(data): target_dir: Path = broken_dir / keyword target_dir.mkdir(parents=True, exist_ok=True) @@ -265,6 +269,13 @@ class Command(BaseCommand): elif isinstance(campaigns, dict): self.import_to_db(campaigns, file_path=file_path) + elif "currentUser" in data["data"] and "inventory" in data["data"]["currentUser"]: + inventory = data["data"]["currentUser"]["inventory"] + if "dropCampaignsInProgress" in inventory: + campaigns = inventory["dropCampaignsInProgress"] + for drop_campaign_data in campaigns: + self.import_to_db(drop_campaign_data, file_path=file_path) + else: msg = "Invalid JSON structure: Missing either data.user.dropCampaign or data.currentUser.dropCampaigns" raise CommandError(msg) diff --git a/twitch/models.py b/twitch/models.py index 8141235..2059b89 100644 --- a/twitch/models.py +++ b/twitch/models.py @@ -1,13 +1,16 @@ from __future__ import annotations import logging -from typing import ClassVar +from typing import TYPE_CHECKING, ClassVar from django.db import models from django.utils import timezone from accounts.models import User +if TYPE_CHECKING: + import datetime + logger: logging.Logger = logging.getLogger("ttvdrops") @@ -97,7 +100,9 @@ class DropCampaign(models.Model): @property def is_active(self) -> bool: """Check if the campaign is currently active.""" - now = timezone.now() + now: datetime.datetime = timezone.now() + if self.start_at is None or self.end_at is None: + return False return self.start_at <= now <= self.end_at @property diff --git a/twitch/urls.py b/twitch/urls.py index 35fa48b..42855e2 100644 --- a/twitch/urls.py +++ b/twitch/urls.py @@ -8,6 +8,7 @@ app_name = "twitch" urlpatterns = [ path("", views.dashboard, name="dashboard"), + path("debug/", views.debug_view, name="debug"), path("campaigns/", views.DropCampaignListView.as_view(), name="campaign_list"), path("campaigns//", views.DropCampaignDetailView.as_view(), name="campaign_detail"), path("games/", views.GameListView.as_view(), name="game_list"), diff --git a/twitch/views.py b/twitch/views.py index 8715dd0..20a85bb 100644 --- a/twitch/views.py +++ b/twitch/views.py @@ -7,6 +7,7 @@ from typing import TYPE_CHECKING, Any, cast from django.contrib import messages from django.contrib.auth.decorators import login_required +from django.db import models from django.db.models import Count, Prefetch, Q from django.db.models.query import QuerySet from django.http.response import HttpResponseRedirect @@ -14,7 +15,14 @@ from django.shortcuts import get_object_or_404, redirect, render from django.utils import timezone from django.views.generic import DetailView, ListView -from twitch.models import DropCampaign, Game, NotificationSubscription, Organization, TimeBasedDrop +from twitch.models import ( + DropBenefit, + DropCampaign, + Game, + NotificationSubscription, + Organization, + TimeBasedDrop, +) if TYPE_CHECKING: from django.db.models import QuerySet @@ -286,7 +294,9 @@ class GameDetailView(DetailView): ) active_campaigns: list[DropCampaign] = [ - campaign for campaign in all_campaigns if campaign.start_at <= now and campaign.end_at is not None and campaign.end_at >= now + campaign + for campaign in all_campaigns + if campaign.start_at is not None and campaign.start_at <= now and campaign.end_at is not None and campaign.end_at >= now ] active_campaigns.sort(key=lambda c: c.end_at if c.end_at is not None else datetime.datetime.max.replace(tzinfo=datetime.UTC)) @@ -365,6 +375,66 @@ def dashboard(request: HttpRequest) -> HttpResponse: ) +@login_required +def debug_view(request: HttpRequest) -> HttpResponse: + """Debug view showing potentially broken or inconsistent data. + + Only staff users may access this endpoint. + + Returns: + HttpResponse: Rendered debug template or redirect if unauthorized. + """ + # Was previously staff-only; now any authenticated user can view. + + now = timezone.now() + + # Games with no organizations (no campaigns linking to an org) + games_without_orgs: QuerySet[Game, Game] = Game.objects.filter(drop_campaigns__isnull=True).order_by("display_name") + + # Campaigns with missing or obviously broken images (empty or very short or not http) + broken_image_campaigns: QuerySet[DropCampaign, DropCampaign] = DropCampaign.objects.filter( + Q(image_url__isnull=True) | Q(image_url__exact="") | ~Q(image_url__startswith="http") + ).select_related("game", "owner") + + # Benefits with missing images + broken_benefit_images: QuerySet[DropBenefit, DropBenefit] = DropBenefit.objects.filter( + Q(image_asset_url__isnull=True) | Q(image_asset_url__exact="") | ~Q(image_asset_url__startswith="http") + ).select_related("game", "owner_organization") + + # Time-based drops without any benefits + drops_without_benefits: QuerySet[TimeBasedDrop, TimeBasedDrop] = TimeBasedDrop.objects.filter(benefits__isnull=True).select_related( + "campaign" + ) + + # Campaigns with invalid dates (start after end or missing either) + invalid_date_campaigns: QuerySet[DropCampaign, DropCampaign] = DropCampaign.objects.filter( + Q(start_at__gt=models.F("end_at")) | Q(start_at__isnull=True) | Q(end_at__isnull=True) + ).select_related("game", "owner") + + # Duplicate campaign names per game + duplicate_name_campaigns = ( + DropCampaign.objects.values("game_id", "name").annotate(name_count=Count("id")).filter(name_count__gt=1).order_by("-name_count") + ) + + # Campaigns currently active but image missing + active_missing_image = DropCampaign.objects.filter(start_at__lte=now, end_at__gte=now).filter( + Q(image_url__isnull=True) | Q(image_url__exact="") + ) + + context: dict[str, Any] = { + "now": now, + "games_without_orgs": games_without_orgs, + "broken_image_campaigns": broken_image_campaigns, + "broken_benefit_images": broken_benefit_images, + "drops_without_benefits": drops_without_benefits, + "invalid_date_campaigns": invalid_date_campaigns, + "duplicate_name_campaigns": duplicate_name_campaigns, + "active_missing_image": active_missing_image, + } + + return render(request, "twitch/debug.html", context) + + @login_required def subscribe_game_notifications(request: HttpRequest, game_id: str) -> HttpResponseRedirect: """Update Game notification for a user.