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"
title="RSS feed for all campaigns">RSS feed for campaigns</a>
</div>
{% 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 %}
{% if campaigns_by_game %}
{% for game_id, game_data in campaigns_by_game.items %}
<article id="game-article-{{ game_id }}" style="margin-bottom: 2rem;">
<header style="margin-bottom: 1rem;">
<h2 style="margin: 0 0 0.5rem 0;">
<a href="{% url 'twitch:game_detail' game_id %}">{{ game_data.name }}</a>
</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>
<div style="display: flex; gap: 1rem;">
<div style="flex-shrink: 0;">
@ -141,7 +148,6 @@ Hover over the end time to see the exact date and time.
</div>
</article>
{% endfor %}
{% endfor %}
{% else %}
<p>No active campaigns at the moment.</p>
{% endif %}

View file

@ -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."""

View file

@ -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_org_game[org_id]["games"]:
campaigns_by_org_game[org_id]["games"][game_id] = {
"name": game_name,
"box_art": campaign.game.box_art,
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": [],
}
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,
},
)