- {{ game_data.name }} -
-+ {{ game_data.name }} +
+ {% if game_data.owners %} +{{ campaign.clean_name }}
- - - - - --
- {% if campaign.allow_is_enabled %}
- {% if campaign.allow_channels.all %}
- {% for channel in campaign.allow_channels.all %}
- {% if forloop.counter <= 5 %}
-
- - - {{ channel.display_name }} - - - {% endif %} - {% endfor %} - {% if campaign.allow_channels.all|length > 5 %} -
- - ... and {{ campaign.allow_channels.all|length|add:"-5" }} more - - {% endif %} - {% else %} - {% if campaign.game.twitch_directory_url %} -
-
-
+ +++
+
++ + {% endfor %}+ {% for campaign in game_data.campaigns %} +++ - {% endfor %} -+ +-+
{{ campaign.clean_name }}
+ + + + + ++ Channels: ++ {% else %} + {% if campaign.game.twitch_directory_url %} +-
+ {% if campaign.allow_is_enabled %}
+ {% if campaign.allow_channels.all %}
+ {% for channel in campaign.allow_channels.all %}
+ {% if forloop.counter <= 5 %}
+
- + - Browse {{ campaign.game.display_name }} category + title="Watch {{ channel.display_name }} on Twitch"> + {{ channel.display_name }} - {% else %} -
- Failed to get Twitch category URL :( {% endif %} + {% endfor %} + {% if campaign.allow_channels.all|length > 5 %} +
- + ... and {{ campaign.allow_channels.all|length|add:"-5" }} more + {% endif %} {% else %} {% if campaign.game.twitch_directory_url %} @@ -123,24 +116,37 @@ Hover over the end time to see the exact date and time. - Go to a participating live channel + title="Open Twitch category page for {{ campaign.game.display_name }} with Drops filter"> + Browse {{ campaign.game.display_name }} category {% else %} -
- Failed to get Twitch directory URL :( +
- Failed to get Twitch category URL :( {% endif %} {% endif %} -
- + + Go to a participating live channel + +
+ {% else %} +- Failed to get Twitch directory URL :(
+ {% endif %} + {% endif %} +
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, }, )