Refactor dashboard view to group campaigns by game, preventing duplicates for multi-owner games
This commit is contained in:
parent
92ce21938e
commit
5fe4ed4eb1
3 changed files with 160 additions and 122 deletions
|
|
@ -16,106 +16,99 @@ 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 %}
|
||||||
</header>
|
<div style="font-size: 0.9rem; color: #666;">
|
||||||
<div style="display: flex; gap: 1rem;">
|
Organizations:
|
||||||
<div style="flex-shrink: 0;">
|
{% for org in game_data.owners %}
|
||||||
<img src="{{ game_data.box_art }}"
|
<a href="{% url 'twitch:organization_detail' org.twitch_id %}">{{ org.name }}</a>{% if not forloop.last %}, {% endif %}
|
||||||
alt="Box art for {{ game_data.name }}"
|
{% endfor %}
|
||||||
width="200"
|
|
||||||
height="267"
|
|
||||||
style="border-radius: 8px" />
|
|
||||||
</div>
|
</div>
|
||||||
<div style="flex: 1; overflow-x: auto;">
|
{% endif %}
|
||||||
<div style="display: flex; gap: 1rem; min-width: max-content;">
|
</header>
|
||||||
{% for campaign in game_data.campaigns %}
|
<div style="display: flex; gap: 1rem;">
|
||||||
<article id="campaign-article-{{ campaign.twitch_id }}"
|
<div style="flex-shrink: 0;">
|
||||||
style="display: flex;
|
<img src="{{ game_data.box_art }}"
|
||||||
flex-direction: column;
|
alt="Box art for {{ game_data.name }}"
|
||||||
align-items: center;
|
width="200"
|
||||||
padding: 0.5rem;
|
height="267"
|
||||||
flex-shrink: 0">
|
style="border-radius: 8px" />
|
||||||
<div>
|
</div>
|
||||||
<a href="{% url 'twitch:campaign_detail' campaign.twitch_id %}">
|
<div style="flex: 1; overflow-x: auto;">
|
||||||
<img src="{{ campaign.image_url }}"
|
<div style="display: flex; gap: 1rem; min-width: max-content;">
|
||||||
alt="Image for {{ campaign.name }}"
|
{% for campaign in game_data.campaigns %}
|
||||||
width="120"
|
<article id="campaign-article-{{ campaign.twitch_id }}"
|
||||||
height="120"
|
style="display: flex;
|
||||||
style="border-radius: 4px" />
|
flex-direction: column;
|
||||||
<h4 style="margin: 0.5rem 0; text-align: left;">{{ campaign.clean_name }}</h4>
|
align-items: center;
|
||||||
</a>
|
padding: 0.5rem;
|
||||||
<time datetime="{{ campaign.end_at|date:'c' }}"
|
flex-shrink: 0">
|
||||||
title="{{ campaign.end_at|date:'DATETIME_FORMAT' }}"
|
<div>
|
||||||
style="font-size: 0.9rem;
|
<a href="{% url 'twitch:campaign_detail' campaign.twitch_id %}">
|
||||||
display: block;
|
<img src="{{ campaign.image_url }}"
|
||||||
text-align: left">
|
alt="Image for {{ campaign.name }}"
|
||||||
Ends in {{ campaign.end_at|timeuntil }}
|
width="120"
|
||||||
</time>
|
height="120"
|
||||||
<time datetime="{{ campaign.start_at|date:'c' }}"
|
style="border-radius: 4px" />
|
||||||
title="{{ campaign.start_at|date:'DATETIME_FORMAT' }}"
|
<h4 style="margin: 0.5rem 0; text-align: left;">{{ campaign.clean_name }}</h4>
|
||||||
style="font-size: 0.9rem;
|
</a>
|
||||||
display: block;
|
<time datetime="{{ campaign.end_at|date:'c' }}"
|
||||||
text-align: left">
|
title="{{ campaign.end_at|date:'DATETIME_FORMAT' }}"
|
||||||
Started {{ campaign.start_at|timesince }} ago
|
style="font-size: 0.9rem;
|
||||||
</time>
|
display: block;
|
||||||
<time datetime="{{ campaign.added_at|date:'c' }}"
|
text-align: left">
|
||||||
title="{{ campaign.added_at|date:'DATETIME_FORMAT' }}"
|
Ends in {{ campaign.end_at|timeuntil }}
|
||||||
style="font-size: 0.9rem;
|
</time>
|
||||||
display: block;
|
<time datetime="{{ campaign.start_at|date:'c' }}"
|
||||||
text-align: left">
|
title="{{ campaign.start_at|date:'DATETIME_FORMAT' }}"
|
||||||
Scraped {{ campaign.added_at|timesince }} ago
|
style="font-size: 0.9rem;
|
||||||
</time>
|
display: block;
|
||||||
<time datetime="{{ campaign.start_at|date:'c' }} to {{ campaign.end_at|date:'c' }}"
|
text-align: left">
|
||||||
title="{{ campaign.start_at|date:'DATETIME_FORMAT' }} to {{ campaign.end_at|date:'DATETIME_FORMAT' }}"
|
Started {{ campaign.start_at|timesince }} ago
|
||||||
style="font-size: 0.9rem;
|
</time>
|
||||||
display: block;
|
<time datetime="{{ campaign.added_at|date:'c' }}"
|
||||||
text-align: left">
|
title="{{ campaign.added_at|date:'DATETIME_FORMAT' }}"
|
||||||
Duration: {{ campaign.start_at|timesince:campaign.end_at }}
|
style="font-size: 0.9rem;
|
||||||
</time>
|
display: block;
|
||||||
<div style="margin-top: 0.5rem; font-size: 0.8rem; ">
|
text-align: left">
|
||||||
<strong>Channels:</strong>
|
Scraped {{ campaign.added_at|timesince }} ago
|
||||||
<ul style="margin: 0.25rem 0 0 0;
|
</time>
|
||||||
padding-left: 1rem;
|
<time datetime="{{ campaign.start_at|date:'c' }} to {{ campaign.end_at|date:'c' }}"
|
||||||
list-style-type: none">
|
title="{{ campaign.start_at|date:'DATETIME_FORMAT' }} to {{ campaign.end_at|date:'DATETIME_FORMAT' }}"
|
||||||
{% if campaign.allow_is_enabled %}
|
style="font-size: 0.9rem;
|
||||||
{% if campaign.allow_channels.all %}
|
display: block;
|
||||||
{% for channel in campaign.allow_channels.all %}
|
text-align: left">
|
||||||
{% if forloop.counter <= 5 %}
|
Duration: {{ campaign.start_at|timesince:campaign.end_at }}
|
||||||
<li style="margin-bottom: 0.1rem;">
|
</time>
|
||||||
<a href="https://twitch.tv/{{ channel.name }}"
|
<div style="margin-top: 0.5rem; font-size: 0.8rem; ">
|
||||||
target="_blank"
|
<strong>Channels:</strong>
|
||||||
rel="noopener noreferrer"
|
<ul style="margin: 0.25rem 0 0 0;
|
||||||
title="Watch {{ channel.display_name }} on Twitch">
|
padding-left: 1rem;
|
||||||
{{ channel.display_name }}
|
list-style-type: none">
|
||||||
</a>
|
{% if campaign.allow_is_enabled %}
|
||||||
</li>
|
{% if campaign.allow_channels.all %}
|
||||||
{% endif %}
|
{% for channel in campaign.allow_channels.all %}
|
||||||
{% endfor %}
|
{% if forloop.counter <= 5 %}
|
||||||
{% if campaign.allow_channels.all|length > 5 %}
|
<li style="margin-bottom: 0.1rem;">
|
||||||
<li style="margin-bottom: 0.1rem; color: #666; font-style: italic;">
|
<a href="https://twitch.tv/{{ channel.name }}"
|
||||||
... and {{ campaign.allow_channels.all|length|add:"-5" }} more
|
|
||||||
</li>
|
|
||||||
{% endif %}
|
|
||||||
{% else %}
|
|
||||||
{% if campaign.game.twitch_directory_url %}
|
|
||||||
<li>
|
|
||||||
<a href="{{ campaign.game.twitch_directory_url }}"
|
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
title="Open Twitch category page for {{ campaign.game.display_name }} with Drops filter">
|
title="Watch {{ channel.display_name }} on Twitch">
|
||||||
Browse {{ campaign.game.display_name }} category
|
{{ channel.display_name }}
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
{% else %}
|
|
||||||
<li>Failed to get Twitch category URL :(</li>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
{% if campaign.allow_channels.all|length > 5 %}
|
||||||
|
<li style="margin-bottom: 0.1rem; color: #666; font-style: italic;">
|
||||||
|
... and {{ campaign.allow_channels.all|length|add:"-5" }} more
|
||||||
|
</li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% else %}
|
{% else %}
|
||||||
{% if campaign.game.twitch_directory_url %}
|
{% if campaign.game.twitch_directory_url %}
|
||||||
|
|
@ -123,24 +116,37 @@ Hover over the end time to see the exact date and time.
|
||||||
<a href="{{ campaign.game.twitch_directory_url }}"
|
<a href="{{ campaign.game.twitch_directory_url }}"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
title="Find streamers playing {{ campaign.game.display_name }} with drops enabled">
|
title="Open Twitch category page for {{ campaign.game.display_name }} with Drops filter">
|
||||||
Go to a participating live channel
|
Browse {{ campaign.game.display_name }} category
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
{% else %}
|
{% else %}
|
||||||
<li>Failed to get Twitch directory URL :(</li>
|
<li>Failed to get Twitch category URL :(</li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</ul>
|
{% else %}
|
||||||
</div>
|
{% if campaign.game.twitch_directory_url %}
|
||||||
|
<li>
|
||||||
|
<a href="{{ campaign.game.twitch_directory_url }}"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
title="Find streamers playing {{ campaign.game.display_name }} with drops enabled">
|
||||||
|
Go to a participating live channel
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{% else %}
|
||||||
|
<li>Failed to get Twitch directory URL :(</li>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</div>
|
||||||
{% endfor %}
|
</article>
|
||||||
</div>
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</div>
|
||||||
{% endfor %}
|
</article>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% else %}
|
{% else %}
|
||||||
<p>No active campaigns at the moment.</p>
|
<p>No active campaigns at the moment.</p>
|
||||||
|
|
|
||||||
|
|
@ -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."""
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
"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_game[game_id]["campaigns"].append(campaign)
|
||||||
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)
|
|
||||||
|
|
||||||
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,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue