From 5fe4ed4eb198796343ffca7bdaf22bf7a7e3f2ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20Hells=C3=A9n?= Date: Mon, 12 Jan 2026 00:59:22 +0100 Subject: [PATCH] Refactor dashboard view to group campaigns by game, preventing duplicates for multi-owner games --- templates/twitch/dashboard.html | 214 ++++++++++++++++---------------- twitch/tests/test_views.py | 35 ++++++ twitch/views.py | 33 +++-- 3 files changed, 160 insertions(+), 122 deletions(-) diff --git a/templates/twitch/dashboard.html b/templates/twitch/dashboard.html index 817f3f2..19790c9 100644 --- a/templates/twitch/dashboard.html +++ b/templates/twitch/dashboard.html @@ -16,106 +16,99 @@ Hover over the end time to see the exact date and time. style="margin-right: 1rem" title="RSS feed for all campaigns">RSS feed for campaigns - {% if campaigns_by_org_game %} - {% for org_id, org_data in campaigns_by_org_game.items %} - {% for game_id, game_data in org_data.games.items %} -
-
-

- {{ game_data.name }} -

-
-
-
- Box art for {{ game_data.name }} + {% if campaigns_by_game %} + {% for game_id, game_data in campaigns_by_game.items %} +
+
+

+ {{ game_data.name }} +

+ {% if game_data.owners %} +
+ Organizations: + {% for org in game_data.owners %} + {{ org.name }}{% if not forloop.last %}, {% endif %} + {% endfor %}
-
-
- {% for campaign in game_data.campaigns %} -
-
- - Image for {{ campaign.name }} -

{{ campaign.clean_name }}

-
- - - - -
- Channels: -
-
- {% endfor %} -
+
+
+ {% endfor %}
-
- {% endfor %} + + {% endfor %} {% else %}

No active campaigns at the moment.

diff --git a/twitch/tests/test_views.py b/twitch/tests/test_views.py index 2830010..5ad8110 100644 --- a/twitch/tests/test_views.py +++ b/twitch/tests/test_views.py @@ -1,11 +1,13 @@ from __future__ import annotations +import datetime from typing import TYPE_CHECKING from typing import Any from typing import Literal import pytest from django.urls import reverse +from django.utils import timezone from twitch.models import Channel from twitch.models import DropBenefit @@ -405,6 +407,39 @@ class TestChannelListView: assert response.status_code == 200 assert "active_campaigns" in response.context + @pytest.mark.django_db + def test_dashboard_dedupes_campaigns_for_multi_owner_game(self, client: Client) -> None: + """Dashboard should not render duplicate campaign cards when a game has multiple owners.""" + now = timezone.now() + org1: Organization = Organization.objects.create(twitch_id="org_a", name="Org A") + org2: Organization = Organization.objects.create(twitch_id="org_b", name="Org B") + game: Game = Game.objects.create(twitch_id="game_multi_owner", name="game", display_name="Multi Owner") + game.owners.add(org1, org2) + + campaign: DropCampaign = DropCampaign.objects.create( + twitch_id="camp1", + name="Campaign", + game=game, + operation_name="DropCampaignDetails", + start_at=now - datetime.timedelta(hours=1), + end_at=now + datetime.timedelta(hours=1), + ) + + response: _MonkeyPatchedWSGIResponse = client.get(reverse("twitch:dashboard")) + assert response.status_code == 200 + + context: ContextList | dict[str, Any] = response.context + if isinstance(context, list): + context = context[-1] + + assert "campaigns_by_game" in context + assert game.twitch_id in context["campaigns_by_game"] + assert len(context["campaigns_by_game"][game.twitch_id]["campaigns"]) == 1 + + # Template renders each campaign with a stable id, so we can assert it appears once. + html = response.content.decode("utf-8") + assert html.count(f"campaign-article-{campaign.twitch_id}") == 1 + @pytest.mark.django_db def test_debug_view(self, client: Client) -> None: """Test debug view returns 200 and has games_without_owner in context.""" diff --git a/twitch/views.py b/twitch/views.py index 2bc41c8..dfa0484 100644 --- a/twitch/views.py +++ b/twitch/views.py @@ -675,6 +675,7 @@ def dashboard(request: HttpRequest) -> HttpResponse: active_campaigns: QuerySet[DropCampaign] = ( DropCampaign.objects .filter(start_at__lte=now, end_at__gte=now) + .select_related("game") .prefetch_related("game__owners") .prefetch_related( "allow_channels", @@ -682,34 +683,30 @@ def dashboard(request: HttpRequest) -> HttpResponse: .order_by("-start_at") ) - # Use OrderedDict to preserve insertion order (newest campaigns first) - campaigns_by_org_game: OrderedDict[str, Any] = OrderedDict() + # Preserve insertion order (newest campaigns first). Group by game so games with multiple owners + # don't render duplicate campaign cards. + campaigns_by_game: OrderedDict[str, dict[str, Any]] = OrderedDict() for campaign in active_campaigns: - for owner in campaign.game.owners.all(): - org_id: str = owner.twitch_id if owner else "unknown" - org_name: str = owner.name if owner else "Unknown" - game_id: str = campaign.game.twitch_id - game_name: str = campaign.game.display_name + game: Game = campaign.game + game_id: str = game.twitch_id - if org_id not in campaigns_by_org_game: - campaigns_by_org_game[org_id] = {"name": org_name, "games": OrderedDict()} + if game_id not in campaigns_by_game: + campaigns_by_game[game_id] = { + "name": game.display_name, + "box_art": game.box_art_best_url, + "owners": list(game.owners.all()), + "campaigns": [], + } - if game_id not in campaigns_by_org_game[org_id]["games"]: - campaigns_by_org_game[org_id]["games"][game_id] = { - "name": game_name, - "box_art": campaign.game.box_art, - "campaigns": [], - } - - campaigns_by_org_game[org_id]["games"][game_id]["campaigns"].append(campaign) + campaigns_by_game[game_id]["campaigns"].append(campaign) return render( request, "twitch/dashboard.html", { "active_campaigns": active_campaigns, - "campaigns_by_org_game": campaigns_by_org_game, + "campaigns_by_game": campaigns_by_game, "now": now, }, )