From 5931cd3b75dabd3432b41d97638b6bd2f7da60f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20Hells=C3=A9n?= Date: Thu, 13 Aug 2026 02:43:31 +0200 Subject: [PATCH] Add Twitch stats page --- .pre-commit-config.yaml | 2 +- config/tests/test_site_endpoint_smoke.py | 1 + templates/base.html | 1 + templates/twitch/stats.html | 132 +++++++ twitch/models.py | 470 +++++++++++++++++++++++ twitch/tests/test_views.py | 236 ++++++++++++ twitch/urls.py | 2 + twitch/views.py | 72 ++++ 8 files changed, 915 insertions(+), 1 deletion(-) create mode 100644 templates/twitch/stats.html diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 00baaa9..e620a22 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -27,7 +27,7 @@ repos: args: [--target-version, "6.0"] - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.15.22 + rev: v0.16.2 hooks: - id: ruff-check args: ["--fix", "--exit-non-zero-on-fix"] diff --git a/config/tests/test_site_endpoint_smoke.py b/config/tests/test_site_endpoint_smoke.py index 2cc7b54..f0e6dc0 100644 --- a/config/tests/test_site_endpoint_smoke.py +++ b/config/tests/test_site_endpoint_smoke.py @@ -269,6 +269,7 @@ class SiteEndpointSmokeTest(TestCase): {"twitch_id": self.twitch_reward_campaign.twitch_id}, 200, ), + ("twitch:stats", {}, 200), ("twitch:export_campaigns_csv", {}, 200), ("twitch:export_campaigns_json", {}, 200), ("twitch:export_games_csv", {}, 200), diff --git a/templates/base.html b/templates/base.html index 1a47bd9..eca6b46 100644 --- a/templates/base.html +++ b/templates/base.html @@ -233,6 +233,7 @@ Channels | Badges | Emotes | + Stats | Inventory
Kick diff --git a/templates/twitch/stats.html b/templates/twitch/stats.html new file mode 100644 index 0000000..d542d64 --- /dev/null +++ b/templates/twitch/stats.html @@ -0,0 +1,132 @@ +{% extends "base.html" %} +{% block title %} + Twitch Stats +{% endblock title %} +{% block content %} +
+
+

Twitch Drops statistics

+
+ Dashboard > + Twitch > Stats +
+
+ {% if not stats.counters.campaigns %} +
+

No drops yet!

+

+ The archive is empty, so there is nothing to brag about. + Check back after the next drop import. +

+
+ {% else %} + +
+

Archive totals

+
+ {% for label, count in totals %} +
+
{{ count }}
+
{{ label }}
+
+ {% endfor %} +
+
+ +
+

Fun records

+
+ {% for record in stats.records %} +
+
+ {{ record.title }} +
+
{{ record.value }}
+
+ {% if record.url %} + {{ record.detail }} + {% else %} + {{ record.detail }} + {% endif %} +
+
+ {% endfor %} +
+
+ +
+

Leaderboards

+
+ {% for board in leaderboards %} +
+

{{ board.title }}

+ + + + + + + + + + {% for row in board.rows %} + + + + + + {% empty %} + + + + {% endfor %} + +
+ # + + Name + + Count +
+ {{ forloop.counter }} + + {{ row.name }} + {{ row.count }}
+ Nothing here yet. +
+
+ {% endfor %} +
+
+ {% endif %} +
+{% endblock content %} diff --git a/twitch/models.py b/twitch/models.py index 1d9a0a3..3723cbb 100644 --- a/twitch/models.py +++ b/twitch/models.py @@ -17,9 +17,11 @@ from django.db.models import Prefetch from django.db.models import Q from django.db.models import Subquery from django.db.models.functions import Coalesce +from django.db.models.functions import Length from django.urls import reverse from django.utils import timezone from django.utils.html import format_html +from django.utils.timesince import timesince from twitch.utils import normalize_twitch_box_art_url @@ -29,6 +31,21 @@ if TYPE_CHECKING: logger: logging.Logger = logging.getLogger("ttvdrops") +def _format_minutes(minutes: int) -> str: + """Format a raw minute count as a compact human string (e.g., '8h 30m'). + + Django's builtin ``timesince`` only accepts datetimes, so raw minute counts + need this tiny helper; nothing else in the project formats minute integers. + + Returns: + Compact duration string like '8h 30m' or '45m'. + """ + hours, remainder = divmod(int(minutes), 60) + if hours: + return f"{hours}h {remainder}m" if remainder else f"{hours}h" + return f"{minutes}m" + + # MARK: Organization class Organization(auto_prefetch.Model): """Represents an organization on Twitch that can own drop campaigns.""" @@ -1298,6 +1315,459 @@ class DropCampaign(auto_prefetch.Model): # ruff:ignore[too-many-public-methods] "active_reward_campaigns": RewardCampaign.active_for_dashboard(now), } + @classmethod + def stats( # ruff:ignore[too-many-locals, too-many-statements] + cls, + now: datetime.datetime, + ) -> dict[str, Any]: + """Return fun statistics about the Twitch drop archive. + + Args: + now: Current timestamp used for active/upcoming/expired buckets. + + Returns: + Dict with "counters", "records", and "leaderboards" keys ready for + template rendering. + """ + # --- Total counters -------------------------------------------------- + status_counts: dict[str, int] = cls.objects.aggregate( + active_campaigns=Count("pk", filter=Q(start_at__lte=now, end_at__gte=now)), + upcoming_campaigns=Count("pk", filter=Q(start_at__gt=now)), + expired_campaigns=Count("pk", filter=Q(end_at__lt=now)), + ) + counters: dict[str, int] = { + "campaigns": cls.objects.count(), + "drops": TimeBasedDrop.objects.count(), + "benefits": DropBenefit.objects.count(), + "games": Game.objects.count(), + "organizations": Organization.objects.count(), + "channels": Channel.objects.count(), + "reward_campaigns": RewardCampaign.objects.count(), + **status_counts, + } + + campaign_qs: models.QuerySet[DropCampaign] = cls.objects.select_related( + "game", + ).only( + "twitch_id", + "name", + "description", + "start_at", + "end_at", + "game", + "game__twitch_id", + "game__display_name", + "game__name", + "game__slug", + ) + drop_qs: models.QuerySet[TimeBasedDrop] = TimeBasedDrop.objects.select_related( + "campaign__game", + ).only( + "twitch_id", + "name", + "required_minutes_watched", + "required_subs", + "start_at", + "end_at", + "campaign", + "campaign__twitch_id", + "campaign__name", + "campaign__game", + "campaign__game__twitch_id", + "campaign__game__display_name", + "campaign__game__name", + "campaign__game__slug", + ) + + def _campaign_url(campaign: DropCampaign) -> str: + return reverse("twitch:campaign_detail", args=[campaign.twitch_id]) + + def _game_url(game: Game) -> str: + return reverse("twitch:game_detail", args=[game.twitch_id]) + + def _org_url(org: Organization) -> str: + return reverse("twitch:organization_detail", args=[org.twitch_id]) + + def _channel_url(channel: Channel) -> str: + return reverse("twitch:channel_detail", args=[channel.twitch_id]) + + # --- Fun record cards ------------------------------------------------- + records: list[dict[str, Any]] = [] + + longest_campaign = ( + campaign_qs + .filter(start_at__isnull=False, end_at__isnull=False) + .annotate(duration=F("end_at") - F("start_at")) + .order_by("-duration") + .first() + ) + if longest_campaign and longest_campaign.start_at and longest_campaign.end_at: + records.append({ + "title": "Longest campaign", + "value": timesince( + longest_campaign.start_at, + longest_campaign.end_at, + ), + "detail": longest_campaign.name, + "url": _campaign_url(longest_campaign), + }) + + longest_drop = ( + drop_qs + .filter(start_at__isnull=False, end_at__isnull=False) + .annotate(duration=F("end_at") - F("start_at")) + .order_by("-duration") + .first() + ) + if longest_drop and longest_drop.start_at and longest_drop.end_at: + records.append({ + "title": "Longest drop window", + "value": timesince( + longest_drop.start_at, + longest_drop.end_at, + ), + "detail": f"{longest_drop.name} ({longest_drop.campaign.name})", + "url": _campaign_url(longest_drop.campaign), + }) + + first_campaign = ( + campaign_qs + .filter(start_at__isnull=False) + .order_by("start_at", "added_at") + .first() + ) + if first_campaign and first_campaign.start_at: + records.append({ + "title": "First drop ever in db", + "value": first_campaign.start_at.strftime("%b %d, %Y"), + "detail": first_campaign.name, + "url": _campaign_url(first_campaign), + }) + + newest_campaign = ( + campaign_qs + .filter(start_at__isnull=False) + .order_by("-start_at", "-added_at") + .first() + ) + if newest_campaign and newest_campaign.start_at: + records.append({ + "title": "Newest campaign", + "value": newest_campaign.start_at.strftime("%b %d, %Y"), + "detail": newest_campaign.name, + "url": _campaign_url(newest_campaign), + }) + + game_most_drops = ( + Game.objects + .annotate( + drop_count=Count( + "drop_campaigns__time_based_drops", + distinct=True, + ), + ) + .order_by("-drop_count", "display_name") + .only("twitch_id", "display_name", "name", "slug") + .first() + ) + if game_most_drops and game_most_drops.drop_count: + records.append({ + "title": "Game with most drops", + "value": game_most_drops.drop_count, + "detail": game_most_drops.get_game_name, + "url": _game_url(game_most_drops), + }) + + game_most_campaigns = ( + Game.objects + .annotate( + campaign_count=Count("drop_campaigns", distinct=True), + ) + .order_by("-campaign_count", "display_name") + .only("twitch_id", "display_name", "name", "slug") + .first() + ) + if game_most_campaigns and game_most_campaigns.campaign_count: + records.append({ + "title": "Game with most campaigns", + "value": game_most_campaigns.campaign_count, + "detail": game_most_campaigns.get_game_name, + "url": _game_url(game_most_campaigns), + }) + + game_most_rewards = ( + Game.objects + .annotate( + benefit_count=Count( + "drop_campaigns__time_based_drops__benefits", + distinct=True, + ), + ) + .order_by("-benefit_count", "display_name") + .only("twitch_id", "display_name", "name", "slug") + .first() + ) + if game_most_rewards and game_most_rewards.benefit_count: + records.append({ + "title": "Game with most rewards", + "value": game_most_rewards.benefit_count, + "detail": game_most_rewards.get_game_name, + "url": _game_url(game_most_rewards), + }) + + most_rewards_campaign = ( + campaign_qs + .annotate( + benefit_count=Count( + "time_based_drops__benefits", + distinct=True, + ), + ) + .order_by("-benefit_count", "name") + .first() + ) + if most_rewards_campaign and most_rewards_campaign.benefit_count: + records.append({ + "title": "Reward hoarder campaign", + "value": most_rewards_campaign.benefit_count, + "detail": most_rewards_campaign.name, + "url": _campaign_url(most_rewards_campaign), + }) + + most_drops_campaign = ( + campaign_qs + .annotate(drop_count=Count("time_based_drops")) + .order_by("-drop_count", "name") + .first() + ) + if most_drops_campaign and most_drops_campaign.drop_count: + records.append({ + "title": "Campaign with most drops", + "value": most_drops_campaign.drop_count, + "detail": most_drops_campaign.name, + "url": _campaign_url(most_drops_campaign), + }) + + most_channels_campaign = ( + campaign_qs + .annotate(channel_count=Count("allow_channels")) + .order_by("-channel_count", "name") + .first() + ) + if most_channels_campaign and most_channels_campaign.channel_count: + records.append({ + "title": "Campaign with most channels", + "value": most_channels_campaign.channel_count, + "detail": most_channels_campaign.name, + "url": _campaign_url(most_channels_campaign), + }) + + longest_watch_drop = ( + drop_qs + .filter(required_minutes_watched__isnull=False) + .order_by("-required_minutes_watched") + .first() + ) + if longest_watch_drop and longest_watch_drop.required_minutes_watched: + records.append({ + "title": "Marathon watch requirement", + "value": _format_minutes(longest_watch_drop.required_minutes_watched), + "detail": ( + f"{longest_watch_drop.name} ({longest_watch_drop.campaign.name})" + ), + "url": _campaign_url(longest_watch_drop.campaign), + }) + + most_subs_drop = drop_qs.order_by("-required_subs", "name").first() + if most_subs_drop and most_subs_drop.required_subs: + records.append({ + "title": "Most subs required", + "value": most_subs_drop.required_subs, + "detail": (f"{most_subs_drop.name} ({most_subs_drop.campaign.name})"), + "url": _campaign_url(most_subs_drop.campaign), + }) + + longest_name_campaign = ( + campaign_qs + .filter(name__gt="") + .annotate(name_length=Length("name")) + .order_by("-name_length", "name") + .first() + ) + if longest_name_campaign: + records.append({ + "title": "Longest campaign name", + "value": f"{longest_name_campaign.name_length} characters", + "detail": longest_name_campaign.name, + "url": _campaign_url(longest_name_campaign), + }) + + shortest_name_campaign = ( + campaign_qs + .filter(name__gt="") + .annotate(name_length=Length("name")) + .order_by("name_length", "name") + .first() + ) + if shortest_name_campaign: + records.append({ + "title": "Shortest campaign name", + "value": f"{shortest_name_campaign.name_length} characters", + "detail": shortest_name_campaign.name, + "url": _campaign_url(shortest_name_campaign), + }) + + longest_description_campaign = ( + campaign_qs + .filter(description__gt="") + .annotate(desc_length=Length("description")) + .order_by("-desc_length", "name") + .first() + ) + if longest_description_campaign: + records.append({ + "title": "Longest description", + "value": f"{longest_description_campaign.desc_length} characters", + "detail": longest_description_campaign.name, + "url": _campaign_url(longest_description_campaign), + }) + + org_most_campaigns = ( + Organization.objects + .annotate( + campaign_count=Count("games__drop_campaigns", distinct=True), + ) + .order_by("-campaign_count", "name") + .only("twitch_id", "name") + .first() + ) + if org_most_campaigns and org_most_campaigns.campaign_count: + records.append({ + "title": "Busiest organization", + "value": org_most_campaigns.campaign_count, + "detail": org_most_campaigns.name, + "url": _org_url(org_most_campaigns), + }) + + channel_most_campaigns = ( + Channel.objects + .annotate(campaign_count=Count("allowed_campaigns")) + .order_by("-campaign_count", "display_name") + .only("twitch_id", "name", "display_name") + .first() + ) + if channel_most_campaigns and channel_most_campaigns.campaign_count: + records.append({ + "title": "Busiest channel", + "value": channel_most_campaigns.campaign_count, + "detail": ( + channel_most_campaigns.display_name or channel_most_campaigns.name + ), + "url": _channel_url(channel_most_campaigns), + }) + + # --- Leaderboards ----------------------------------------------------- + leaderboards: dict[str, list[dict[str, Any]]] = { + "games_by_drops": [ + { + "name": game.get_game_name, + "count": game.drop_count, + "url": _game_url(game), + } + for game in ( + Game.objects + .annotate( + drop_count=Count( + "drop_campaigns__time_based_drops", + distinct=True, + ), + ) + .order_by("-drop_count", "display_name") + .only("twitch_id", "display_name", "name", "slug") + )[:5] + ], + "games_by_campaigns": [ + { + "name": game.get_game_name, + "count": game.campaign_count, + "url": _game_url(game), + } + for game in ( + Game.objects + .annotate( + campaign_count=Count("drop_campaigns", distinct=True), + ) + .order_by("-campaign_count", "display_name") + .only("twitch_id", "display_name", "name", "slug") + )[:5] + ], + "campaigns_by_rewards": [ + { + "name": campaign.name, + "count": campaign.benefit_count, + "url": _campaign_url(campaign), + } + for campaign in ( + campaign_qs.annotate( + benefit_count=Count( + "time_based_drops__benefits", + distinct=True, + ), + ).order_by("-benefit_count", "name") + )[:5] + ], + "campaigns_by_drops": [ + { + "name": campaign.name, + "count": campaign.drop_count, + "url": _campaign_url(campaign), + } + for campaign in ( + campaign_qs.annotate(drop_count=Count("time_based_drops")).order_by( + "-drop_count", + "name", + ) + )[:5] + ], + "organizations_by_campaigns": [ + { + "name": org.name, + "count": org.campaign_count, + "url": _org_url(org), + } + for org in ( + Organization.objects + .annotate( + campaign_count=Count( + "games__drop_campaigns", + distinct=True, + ), + ) + .order_by("-campaign_count", "name") + .only("twitch_id", "name") + )[:5] + ], + "channels_by_campaigns": [ + { + "name": channel.display_name or channel.name, + "count": channel.campaign_count, + "url": _channel_url(channel), + } + for channel in ( + Channel.objects + .annotate(campaign_count=Count("allowed_campaigns")) + .order_by("-campaign_count", "display_name") + .only("twitch_id", "name", "display_name") + )[:5] + ], + } + + return { + "counters": counters, + "records": records, + "leaderboards": leaderboards, + } + @property def is_active(self) -> bool: """Check if the campaign is currently active.""" diff --git a/twitch/tests/test_views.py b/twitch/tests/test_views.py index 28e64eb..6d6580f 100644 --- a/twitch/tests/test_views.py +++ b/twitch/tests/test_views.py @@ -2947,6 +2947,242 @@ class TestRewardCampaignViews: assert len(response.context["expired_campaigns"]) == 1 +@pytest.mark.django_db +class TestStatsView: + """Tests for the Twitch stats view.""" + + @pytest.fixture + def stats_fixture(self) -> dict[str, Any]: + """Create a small archive with known record holders for stats tests. + + Returns: + Dict with the created objects for assertions. + """ + now: datetime.datetime = timezone.now() + + org: Organization = Organization.objects.create( + twitch_id="stats-org-1", + name="Stats Org", + ) + game_a: Game = Game.objects.create( + twitch_id="stats-game-a", + name="game_a", + display_name="Game A", + ) + game_b: Game = Game.objects.create( + twitch_id="stats-game-b", + name="game_b", + display_name="Game B", + ) + game_a.owners.add(org) + game_b.owners.add(org) + + channel: Channel = Channel.objects.create( + twitch_id="stats-channel-1", + name="statschannel", + display_name="Stats Channel", + ) + + # Longest campaign: exactly 5 full days. + longest_campaign: DropCampaign = DropCampaign.objects.create( + twitch_id="stats-campaign-long", + name="Longest Campaign Ever", + game=game_a, + start_at=now - timedelta(days=10), + end_at=now - timedelta(days=5), + operation_names=["DropCampaignDetails"], + ) + # Earliest drop. + first_campaign: DropCampaign = DropCampaign.objects.create( + twitch_id="stats-campaign-first", + name="First Campaign", + game=game_b, + start_at=now - timedelta(days=100), + end_at=now - timedelta(days=99), + operation_names=["DropCampaignDetails"], + ) + # Most rewards: 3 unique benefits across two drops. + reward_campaign: DropCampaign = DropCampaign.objects.create( + twitch_id="stats-campaign-rewards", + name="Reward Hoarder", + game=game_a, + start_at=now - timedelta(days=1), + end_at=now + timedelta(days=1), + operation_names=["DropCampaignDetails"], + ) + + drop_long: TimeBasedDrop = TimeBasedDrop.objects.create( + twitch_id="stats-drop-long", + name="Long Drop", + campaign=longest_campaign, + required_minutes_watched=240, + start_at=now - timedelta(days=10), + end_at=now - timedelta(days=5), + ) + TimeBasedDrop.objects.create( + twitch_id="stats-drop-first", + name="First Drop", + campaign=first_campaign, + required_minutes_watched=60, + start_at=now - timedelta(days=100), + end_at=now - timedelta(days=99), + ) + drop_reward_1: TimeBasedDrop = TimeBasedDrop.objects.create( + twitch_id="stats-drop-reward-1", + name="Reward Drop 1", + campaign=reward_campaign, + required_minutes_watched=30, + ) + drop_reward_2: TimeBasedDrop = TimeBasedDrop.objects.create( + twitch_id="stats-drop-reward-2", + name="Reward Drop 2", + campaign=reward_campaign, + required_minutes_watched=45, + ) + + benefit_1: DropBenefit = DropBenefit.objects.create( + twitch_id="stats-benefit-1", + name="Benefit One", + ) + benefit_2: DropBenefit = DropBenefit.objects.create( + twitch_id="stats-benefit-2", + name="Benefit Two", + ) + benefit_3: DropBenefit = DropBenefit.objects.create( + twitch_id="stats-benefit-3", + name="Benefit Three", + ) + + drop_reward_1.benefits.add(benefit_1, benefit_2) + drop_reward_2.benefits.add(benefit_2, benefit_3) + drop_long.benefits.add(benefit_1) + + reward_campaign.allow_channels.add(channel) + longest_campaign.allow_channels.add(channel) + + return { + "org": org, + "game_a": game_a, + "game_b": game_b, + "channel": channel, + "longest_campaign": longest_campaign, + "first_campaign": first_campaign, + "reward_campaign": reward_campaign, + } + + @staticmethod + def _context_stats( + response: _MonkeyPatchedWSGIResponse, + ) -> dict[str, Any]: + """Return the stats context dict from a response.""" + context = response.context + if isinstance(context, list): + context = context[-1] + return context["stats"] + + def test_stats_view_returns_200(self, client: Client) -> None: + """Stats view returns 200 with the expected template.""" + response: _MonkeyPatchedWSGIResponse = client.get(reverse("twitch:stats")) + assert response.status_code == 200 + assert response.templates[0].name == "twitch/stats.html" + assert "stats" in response.context + assert "totals" in response.context + assert "leaderboards" in response.context + + def test_stats_empty_database(self, client: Client) -> None: + """Stats view still renders cleanly when the archive is empty.""" + response: _MonkeyPatchedWSGIResponse = client.get(reverse("twitch:stats")) + assert response.status_code == 200 + counters = self._context_stats(response)["counters"] + assert counters["campaigns"] == 0 + assert counters["drops"] == 0 + assert counters["games"] == 0 + assert self._context_stats(response)["records"] == [] + + def test_stats_counters( + self, + client: Client, + stats_fixture: dict[str, Any], + ) -> None: + """Totals match the seeded archive.""" + response: _MonkeyPatchedWSGIResponse = client.get(reverse("twitch:stats")) + counters = self._context_stats(response)["counters"] + assert counters["campaigns"] == 3 + assert counters["drops"] == 4 + assert counters["benefits"] == 3 + assert counters["games"] == 2 + assert counters["organizations"] == 1 + assert counters["channels"] == 1 + assert counters["reward_campaigns"] == 0 + assert counters["active_campaigns"] == 1 + assert counters["upcoming_campaigns"] == 0 + assert counters["expired_campaigns"] == 2 + + def test_stats_longest_campaign_record( + self, + client: Client, + stats_fixture: dict[str, Any], + ) -> None: + """Longest campaign record points at the 5-day campaign.""" + response: _MonkeyPatchedWSGIResponse = client.get(reverse("twitch:stats")) + records = self._context_stats(response)["records"] + longest = next(r for r in records if r["title"] == "Longest campaign") + # Django's timesince joins number and unit with a non-breaking space. + assert longest["value"].replace("\u00a0", " ") == "5 days" + assert longest["detail"] == "Longest Campaign Ever" + assert longest["url"] == reverse( + "twitch:campaign_detail", + args=["stats-campaign-long"], + ) + + def test_stats_first_drop_record( + self, + client: Client, + stats_fixture: dict[str, Any], + ) -> None: + """First drop record points at the earliest campaign.""" + response: _MonkeyPatchedWSGIResponse = client.get(reverse("twitch:stats")) + records = self._context_stats(response)["records"] + first = next(r for r in records if r["title"] == "First drop ever") + assert first["detail"] == "First Campaign" + + def test_stats_game_most_drops( + self, + client: Client, + stats_fixture: dict[str, Any], + ) -> None: + """Game A owns the most drops (3 drops across two campaigns).""" + response: _MonkeyPatchedWSGIResponse = client.get(reverse("twitch:stats")) + records = self._context_stats(response)["records"] + game_record = next(r for r in records if r["title"] == "Game with most drops") + assert game_record["value"] == 3 + assert game_record["detail"] == "Game A" + + def test_stats_reward_hoarder_campaign( + self, + client: Client, + stats_fixture: dict[str, Any], + ) -> None: + """Reward hoarder campaign has 3 unique benefits.""" + response: _MonkeyPatchedWSGIResponse = client.get(reverse("twitch:stats")) + records = self._context_stats(response)["records"] + hoarder = next(r for r in records if r["title"] == "Reward hoarder campaign") + assert hoarder["value"] == 3 + assert hoarder["detail"] == "Reward Hoarder" + + def test_stats_leaderboards( + self, + client: Client, + stats_fixture: dict[str, Any], + ) -> None: + """Leaderboards render top entries and games-by-drops is correct.""" + response: _MonkeyPatchedWSGIResponse = client.get(reverse("twitch:stats")) + leaderboards = response.context["leaderboards"] + by_drops = next(b for b in leaderboards if b["title"] == "Top games by drops") + assert by_drops["rows"][0]["name"] == "Game A" + assert by_drops["rows"][0]["count"] == 3 + + @pytest.mark.django_db class TestSEOHelperFunctions: """Tests for SEO helper functions.""" diff --git a/twitch/urls.py b/twitch/urls.py index 854ac66..2949f3b 100644 --- a/twitch/urls.py +++ b/twitch/urls.py @@ -93,6 +93,8 @@ urlpatterns: list[URLPattern | URLResolver] = [ views.sitewide_rewards_view, name="sitewide_rewards", ), + # /twitch/stats/ + path("stats/", views.stats_view, name="stats"), # /twitch/rewards/no-game/ path( "rewards/no-game/", diff --git a/twitch/views.py b/twitch/views.py index 4f9f481..102f8f6 100644 --- a/twitch/views.py +++ b/twitch/views.py @@ -921,6 +921,78 @@ def dashboard(request: HttpRequest) -> HttpResponse: ) +# MARK: /stats/ +def stats_view(request: HttpRequest) -> HttpResponse: + """View showing fun statistics about the Twitch drop archive. + + Args: + request: The HTTP request. + + Returns: + HttpResponse: The rendered stats page. + """ + now: datetime.datetime = timezone.now() + stats_data: dict[str, Any] = DropCampaign.stats(now) + + counters: dict[str, int] = stats_data["counters"] + totals: list[tuple[str, int]] = [ + ("Campaigns", counters["campaigns"]), + ("Drops", counters["drops"]), + ("Rewards", counters["benefits"]), + ("Games", counters["games"]), + ("Organizations", counters["organizations"]), + ("Channels", counters["channels"]), + ("Reward campaigns", counters["reward_campaigns"]), + ("Active", counters["active_campaigns"]), + ("Upcoming", counters["upcoming_campaigns"]), + ("Expired", counters["expired_campaigns"]), + ] + + leaderboards_data: dict[str, list[dict[str, Any]]] = stats_data["leaderboards"] + leaderboards: list[dict[str, Any]] = [ + { + "title": "Top games by drops", + "rows": leaderboards_data["games_by_drops"], + }, + { + "title": "Top games by campaigns", + "rows": leaderboards_data["games_by_campaigns"], + }, + { + "title": "Top campaigns by rewards", + "rows": leaderboards_data["campaigns_by_rewards"], + }, + { + "title": "Top campaigns by drops", + "rows": leaderboards_data["campaigns_by_drops"], + }, + { + "title": "Top organizations by campaigns", + "rows": leaderboards_data["organizations_by_campaigns"], + }, + { + "title": "Top channels by campaigns", + "rows": leaderboards_data["channels_by_campaigns"], + }, + ] + + seo_context: dict[str, Any] = _build_seo_context( + page_title="Twitch Stats", + page_description=( + "Fun statistics about Twitch drops: longest drops, " + "busiest games, first drops, and more." + ), + ) + context: dict[str, Any] = { + "now": now, + "stats": stats_data, + "totals": totals, + "leaderboards": leaderboards, + **seo_context, + } + return render(request, "twitch/stats.html", context) + + # MARK: /reward-campaigns/ def reward_campaign_list_view(request: HttpRequest) -> HttpResponse: """Function-based view for reward campaigns list. -- 2.51.2