Refactor dashboard view to group campaigns by game, preventing duplicates for multi-owner games

This commit is contained in:
Joakim Hellsén 2026-01-12 00:59:22 +01:00
commit 5fe4ed4eb1
No known key found for this signature in database
3 changed files with 160 additions and 122 deletions

View file

@ -16,14 +16,21 @@ Hover over the end time to see the exact date and time.
style="margin-right: 1rem" style="margin-right: 1rem"
title="RSS feed for all campaigns">RSS feed for campaigns</a> title="RSS feed for all campaigns">RSS feed for campaigns</a>
</div> </div>
{% if campaigns_by_org_game %} {% if campaigns_by_game %}
{% for org_id, org_data in campaigns_by_org_game.items %} {% for game_id, game_data in campaigns_by_game.items %}
{% for game_id, game_data in org_data.games.items %}
<article id="game-article-{{ game_id }}" style="margin-bottom: 2rem;"> <article id="game-article-{{ game_id }}" style="margin-bottom: 2rem;">
<header style="margin-bottom: 1rem;"> <header style="margin-bottom: 1rem;">
<h2 style="margin: 0 0 0.5rem 0;"> <h2 style="margin: 0 0 0.5rem 0;">
<a href="{% url 'twitch:game_detail' game_id %}">{{ game_data.name }}</a> <a href="{% url 'twitch:game_detail' game_id %}">{{ game_data.name }}</a>
</h2> </h2>
{% if game_data.owners %}
<div style="font-size: 0.9rem; color: #666;">
Organizations:
{% for org in game_data.owners %}
<a href="{% url 'twitch:organization_detail' org.twitch_id %}">{{ org.name }}</a>{% if not forloop.last %}, {% endif %}
{% endfor %}
</div>
{% endif %}
</header> </header>
<div style="display: flex; gap: 1rem;"> <div style="display: flex; gap: 1rem;">
<div style="flex-shrink: 0;"> <div style="flex-shrink: 0;">
@ -141,7 +148,6 @@ Hover over the end time to see the exact date and time.
</div> </div>
</article> </article>
{% endfor %} {% endfor %}
{% endfor %}
{% else %} {% else %}
<p>No active campaigns at the moment.</p> <p>No active campaigns at the moment.</p>
{% endif %} {% endif %}

View file

@ -1,11 +1,13 @@
from __future__ import annotations from __future__ import annotations
import datetime
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from typing import Any from typing import Any
from typing import Literal from typing import Literal
import pytest import pytest
from django.urls import reverse from django.urls import reverse
from django.utils import timezone
from twitch.models import Channel from twitch.models import Channel
from twitch.models import DropBenefit from twitch.models import DropBenefit
@ -405,6 +407,39 @@ class TestChannelListView:
assert response.status_code == 200 assert response.status_code == 200
assert "active_campaigns" in response.context 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 @pytest.mark.django_db
def test_debug_view(self, client: Client) -> None: def test_debug_view(self, client: Client) -> None:
"""Test debug view returns 200 and has games_without_owner in context.""" """Test debug view returns 200 and has games_without_owner in context."""

View file

@ -675,6 +675,7 @@ def dashboard(request: HttpRequest) -> HttpResponse:
active_campaigns: QuerySet[DropCampaign] = ( active_campaigns: QuerySet[DropCampaign] = (
DropCampaign.objects DropCampaign.objects
.filter(start_at__lte=now, end_at__gte=now) .filter(start_at__lte=now, end_at__gte=now)
.select_related("game")
.prefetch_related("game__owners") .prefetch_related("game__owners")
.prefetch_related( .prefetch_related(
"allow_channels", "allow_channels",
@ -682,34 +683,30 @@ def dashboard(request: HttpRequest) -> HttpResponse:
.order_by("-start_at") .order_by("-start_at")
) )
# Use OrderedDict to preserve insertion order (newest campaigns first) # Preserve insertion order (newest campaigns first). Group by game so games with multiple owners
campaigns_by_org_game: OrderedDict[str, Any] = OrderedDict() # don't render duplicate campaign cards.
campaigns_by_game: OrderedDict[str, dict[str, Any]] = OrderedDict()
for campaign in active_campaigns: for campaign in active_campaigns:
for owner in campaign.game.owners.all(): game: Game = campaign.game
org_id: str = owner.twitch_id if owner else "unknown" game_id: str = game.twitch_id
org_name: str = owner.name if owner else "Unknown"
game_id: str = campaign.game.twitch_id
game_name: str = campaign.game.display_name
if org_id not in campaigns_by_org_game: if game_id not in campaigns_by_game:
campaigns_by_org_game[org_id] = {"name": org_name, "games": OrderedDict()} campaigns_by_game[game_id] = {
"name": game.display_name,
if game_id not in campaigns_by_org_game[org_id]["games"]: "box_art": game.box_art_best_url,
campaigns_by_org_game[org_id]["games"][game_id] = { "owners": list(game.owners.all()),
"name": game_name,
"box_art": campaign.game.box_art,
"campaigns": [], "campaigns": [],
} }
campaigns_by_org_game[org_id]["games"][game_id]["campaigns"].append(campaign) campaigns_by_game[game_id]["campaigns"].append(campaign)
return render( return render(
request, request,
"twitch/dashboard.html", "twitch/dashboard.html",
{ {
"active_campaigns": active_campaigns, "active_campaigns": active_campaigns,
"campaigns_by_org_game": campaigns_by_org_game, "campaigns_by_game": campaigns_by_game,
"now": now, "now": now,
}, },
) )