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 %}
+
+ {% for c in broken_image_campaigns %}
+ -
+ {{ c.name }} - {{ c.image_url|default:'(empty)' }}
+
+ {% endfor %}
+
+ {% else %}
+ None ✅
+ {% endif %}
+
+
+ Active Campaigns Missing Image ({{ active_missing_image|length }})
+ {% if active_missing_image %}
+
+ {% for c in active_missing_image %}
+ -
+ {{ c.name }}
+
+ {% endfor %}
+
+ {% else %}
+ None ✅
+ {% endif %}
+
+
+ Benefits With Broken Image URLs ({{ broken_benefit_images|length }})
+ {% if broken_benefit_images %}
+
+ {% for b in broken_benefit_images %}
+ -
+ {{ b.name }} (Game: {{ b.game.display_name }}) - {{ b.image_asset_url|default:'(empty)' }}
+
+ {% endfor %}
+
+ {% else %}
+ None ✅
+ {% endif %}
+
+
+ Time-Based Drops Without Benefits ({{ drops_without_benefits|length }})
+ {% if drops_without_benefits %}
+
+ {% for d in drops_without_benefits %}
+ -
+ {{ d.name }} (Campaign: {{ d.campaign.name }})
+
+ {% endfor %}
+
+ {% else %}
+ None ✅
+ {% endif %}
+
+
+ Campaigns With Invalid Dates ({{ invalid_date_campaigns|length }})
+ {% if invalid_date_campaigns %}
+
+ {% for c in invalid_date_campaigns %}
+ -
+ {{ c.name }} - Start: {{ c.start_at|default:'(none)' }} / End: {{ c.end_at|default:'(none)' }}
+
+ {% endfor %}
+
+ {% else %}
+ None ✅
+ {% endif %}
+
+
+ Duplicate Campaign Names Per Game ({{ duplicate_name_campaigns|length }})
+ {% if duplicate_name_campaigns %}
+
+
+
+ | Game |
+ Name |
+ Count |
+
+
+
+ {% for row in duplicate_name_campaigns %}
+
+ | {{ row.game_id }} |
+ {{ row.name }} |
+ {{ row.name_count }} |
+
+ {% endfor %}
+
+
+ {% 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.