Add allowed_campaign_count field to Channel model and implement counter cache logic

This commit is contained in:
Joakim Hellsén 2026-04-12 04:20:47 +02:00
commit 293dd57263
Signed by: Joakim Hellsén
SSH key fingerprint: SHA256:/9h/CsExpFp+PRhsfA0xznFx2CGfTT5R/kpuFfUgEQk
7 changed files with 397 additions and 16 deletions

View file

@ -0,0 +1,86 @@
from __future__ import annotations
from typing import TYPE_CHECKING
import pytest
from django.db import connection
from django.db.migrations.executor import MigrationExecutor
from django.db.migrations.state import StateApps
if TYPE_CHECKING:
from django.db.migrations.state import StateApps
from twitch.models import Channel
from twitch.models import DropCampaign
from twitch.models import Game
@pytest.mark.django_db(transaction=True)
def test_0021_backfills_allowed_campaign_count() -> None: # noqa: PLR0914
"""Migration 0021 should backfill cached allowed campaign counts."""
migrate_from: list[tuple[str, str]] = [
("twitch", "0020_rewardcampaign_tw_reward_ends_starts_idx"),
]
migrate_to: list[tuple[str, str]] = [
("twitch", "0021_channel_allowed_campaign_count_cache"),
]
executor = MigrationExecutor(connection)
executor.migrate(migrate_from)
old_apps: StateApps = executor.loader.project_state(migrate_from).apps
Game: type[Game] = old_apps.get_model("twitch", "Game")
Channel: type[Channel] = old_apps.get_model("twitch", "Channel")
DropCampaign: type[DropCampaign] = old_apps.get_model("twitch", "DropCampaign")
game = Game.objects.create(
twitch_id="migration_backfill_game",
name="Migration Backfill Game",
display_name="Migration Backfill Game",
)
channel1 = Channel.objects.create(
twitch_id="migration_backfill_channel_1",
name="migrationbackfillchannel1",
display_name="Migration Backfill Channel 1",
)
channel2 = Channel.objects.create(
twitch_id="migration_backfill_channel_2",
name="migrationbackfillchannel2",
display_name="Migration Backfill Channel 2",
)
_channel3 = Channel.objects.create(
twitch_id="migration_backfill_channel_3",
name="migrationbackfillchannel3",
display_name="Migration Backfill Channel 3",
)
campaign1 = DropCampaign.objects.create(
twitch_id="migration_backfill_campaign_1",
name="Migration Backfill Campaign 1",
game=game,
operation_names=["DropCampaignDetails"],
)
campaign2 = DropCampaign.objects.create(
twitch_id="migration_backfill_campaign_2",
name="Migration Backfill Campaign 2",
game=game,
operation_names=["DropCampaignDetails"],
)
campaign1.allow_channels.add(channel1, channel2)
campaign2.allow_channels.add(channel1)
executor = MigrationExecutor(connection)
executor.migrate(migrate_to)
new_apps: StateApps = executor.loader.project_state(migrate_to).apps
new_channel: type[Channel] = new_apps.get_model("twitch", "Channel")
counts_by_twitch_id: dict[str, int] = {
channel.twitch_id: channel.allowed_campaign_count
for channel in new_channel.objects.order_by("twitch_id")
}
assert counts_by_twitch_id == {
"migration_backfill_channel_1": 2,
"migration_backfill_channel_2": 1,
"migration_backfill_channel_3": 0,
}

View file

@ -494,6 +494,115 @@ class TestChannelListView:
assert len(channels) == 1
assert channels[0].twitch_id == channel.twitch_id
def test_channel_list_queryset_only_selects_rendered_fields(self) -> None:
"""Channel list queryset should defer non-rendered fields."""
channel: Channel = Channel.objects.create(
twitch_id="channel_minimal_fields",
name="channelminimalfields",
display_name="Channel Minimal Fields",
)
queryset: QuerySet[Channel] = Channel.for_list_view()
fetched_channel: Channel | None = queryset.filter(
twitch_id=channel.twitch_id,
).first()
assert fetched_channel is not None
assert hasattr(fetched_channel, "campaign_count")
deferred_fields: set[str] = fetched_channel.get_deferred_fields()
assert "added_at" in deferred_fields
assert "updated_at" in deferred_fields
assert "name" not in deferred_fields
assert "display_name" not in deferred_fields
assert "twitch_id" not in deferred_fields
def test_channel_list_queryset_uses_counter_cache_without_join(self) -> None:
"""Channel list SQL should use cached count and avoid campaign join/grouping."""
sql: str = str(Channel.for_list_view().query).upper()
assert "TWITCH_DROPCAMPAIGN_ALLOW_CHANNELS" not in sql
assert "GROUP BY" not in sql
assert "ALLOWED_CAMPAIGN_COUNT" in sql
def test_channel_allowed_campaign_count_updates_on_add_remove_clear(self) -> None:
"""Counter cache should stay in sync when campaign-channel links change."""
game: Game = Game.objects.create(
twitch_id="counter_cache_game",
name="Counter Cache Game",
display_name="Counter Cache Game",
)
channel: Channel = Channel.objects.create(
twitch_id="counter_cache_channel",
name="countercachechannel",
display_name="Counter Cache Channel",
)
campaign1: DropCampaign = DropCampaign.objects.create(
twitch_id="counter_cache_campaign_1",
name="Counter Cache Campaign 1",
game=game,
operation_names=["DropCampaignDetails"],
)
campaign2: DropCampaign = DropCampaign.objects.create(
twitch_id="counter_cache_campaign_2",
name="Counter Cache Campaign 2",
game=game,
operation_names=["DropCampaignDetails"],
)
campaign1.allow_channels.add(channel)
channel.refresh_from_db()
assert channel.allowed_campaign_count == 1
campaign2.allow_channels.add(channel)
channel.refresh_from_db()
assert channel.allowed_campaign_count == 2
campaign1.allow_channels.remove(channel)
channel.refresh_from_db()
assert channel.allowed_campaign_count == 1
campaign2.allow_channels.clear()
channel.refresh_from_db()
assert channel.allowed_campaign_count == 0
def test_channel_allowed_campaign_count_updates_on_set(self) -> None:
"""Counter cache should stay in sync when allow_channels.set(...) is used."""
game: Game = Game.objects.create(
twitch_id="counter_cache_set_game",
name="Counter Cache Set Game",
display_name="Counter Cache Set Game",
)
channel1: Channel = Channel.objects.create(
twitch_id="counter_cache_set_channel_1",
name="countercachesetchannel1",
display_name="Counter Cache Set Channel 1",
)
channel2: Channel = Channel.objects.create(
twitch_id="counter_cache_set_channel_2",
name="countercachesetchannel2",
display_name="Counter Cache Set Channel 2",
)
campaign: DropCampaign = DropCampaign.objects.create(
twitch_id="counter_cache_set_campaign",
name="Counter Cache Set Campaign",
game=game,
operation_names=["DropCampaignDetails"],
)
campaign.allow_channels.set([channel1, channel2])
channel1.refresh_from_db()
channel2.refresh_from_db()
assert channel1.allowed_campaign_count == 1
assert channel2.allowed_campaign_count == 1
campaign.allow_channels.set([channel2])
channel1.refresh_from_db()
channel2.refresh_from_db()
assert channel1.allowed_campaign_count == 0
assert channel2.allowed_campaign_count == 1
@pytest.mark.django_db
def test_dashboard_view(self, client: Client) -> None:
"""Test dashboard view returns 200 and has grouped campaign data in context."""