Start subscription

This commit is contained in:
2024-08-15 03:18:29 +02:00
parent 7d6333183d
commit d4d8567ef8
11 changed files with 518 additions and 245 deletions

View File

@ -1,6 +1,6 @@
from django.contrib import admin
from .models.twitch import Benefit, Channel, DropCampaign, Game, Owner, Reward, RewardCampaign, TimeBasedDrop
from .models import Benefit, Channel, DropCampaign, Game, Owner, Reward, RewardCampaign, TimeBasedDrop
admin.site.register(Game)
admin.site.register(Owner)

View File

@ -10,7 +10,7 @@ from platformdirs import user_data_dir
from playwright.async_api import Playwright, async_playwright
from playwright.async_api._generated import Response
from core.models.twitch import Benefit, Channel, DropCampaign, Game, Owner, Reward, RewardCampaign, TimeBasedDrop
from core.models import Benefit, Channel, DropCampaign, Game, Owner, Reward, RewardCampaign, TimeBasedDrop
if TYPE_CHECKING:
from playwright.async_api._generated import BrowserContext, Page
@ -74,6 +74,7 @@ async def add_reward_campaign(campaign: dict | None) -> None: # noqa: C901
Args:
campaign (dict): The reward campaign to add.
"""
# sourcery skip: low-code-quality
if not campaign:
return

View File

@ -0,0 +1,322 @@
# Generated by Django 5.1 on 2024-08-15 00:28
import django.contrib.auth.models
import django.contrib.auth.validators
import django.db.models.deletion
import django.utils.timezone
from django.conf import settings
from django.db import migrations, models
from django.db.migrations.operations.base import Operation
class Migration(migrations.Migration):
initial = True
dependencies: list[tuple[str, str]] = [
("auth", "0012_alter_user_first_name_max_length"),
]
operations: list[Operation] = [
migrations.CreateModel(
name="DropCampaign",
fields=[
("created_at", models.DateTimeField(auto_created=True, null=True)),
("id", models.TextField(primary_key=True, serialize=False)),
("modified_at", models.DateTimeField(auto_now=True, null=True)),
("account_link_url", models.URLField(null=True)),
("description", models.TextField(null=True)),
("details_url", models.URLField(null=True)),
("ends_at", models.DateTimeField(null=True)),
("starts_at", models.DateTimeField(null=True)),
("image_url", models.URLField(null=True)),
("name", models.TextField(null=True)),
("status", models.TextField(null=True)),
],
),
migrations.CreateModel(
name="Game",
fields=[
("twitch_id", models.TextField(primary_key=True, serialize=False)),
("game_url", models.URLField(default="https://www.twitch.tv/", null=True)),
("name", models.TextField(default="Game name unknown", null=True)),
(
"box_art_url",
models.URLField(default="https://static-cdn.jtvnw.net/ttv-static/404_boxart.jpg", null=True),
),
("slug", models.TextField(null=True)),
],
),
migrations.CreateModel(
name="Owner",
fields=[
("id", models.TextField(primary_key=True, serialize=False)),
("name", models.TextField(null=True)),
],
),
migrations.CreateModel(
name="User",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("password", models.CharField(max_length=128, verbose_name="password")),
("last_login", models.DateTimeField(blank=True, null=True, verbose_name="last login")),
(
"is_superuser",
models.BooleanField(
default=False,
help_text="Designates that this user has all permissions without explicitly assigning them.",
verbose_name="superuser status",
),
),
(
"username",
models.CharField(
error_messages={"unique": "A user with that username already exists."},
help_text="Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.",
max_length=150,
unique=True,
validators=[django.contrib.auth.validators.UnicodeUsernameValidator()],
verbose_name="username",
),
),
("first_name", models.CharField(blank=True, max_length=150, verbose_name="first name")),
("last_name", models.CharField(blank=True, max_length=150, verbose_name="last name")),
("email", models.EmailField(blank=True, max_length=254, verbose_name="email address")),
(
"is_staff",
models.BooleanField(
default=False,
help_text="Designates whether the user can log into this admin site.",
verbose_name="staff status",
),
),
(
"is_active",
models.BooleanField(
default=True,
help_text="Designates whether this user should be treated as active. Unselect this instead of deleting accounts.", # noqa: E501
verbose_name="active",
),
),
("date_joined", models.DateTimeField(default=django.utils.timezone.now, verbose_name="date joined")),
("subscribe_to_news", models.BooleanField(default=False, help_text="Subscribe to news")),
("subscribe_to_new_games", models.BooleanField(default=False, help_text="Subscribe to new games")),
(
"groups",
models.ManyToManyField(
blank=True,
help_text="The groups this user belongs to. A user will get all permissions granted to each of their groups.", # noqa: E501
related_name="user_set",
related_query_name="user",
to="auth.group",
verbose_name="groups",
),
),
(
"user_permissions",
models.ManyToManyField(
blank=True,
help_text="Specific permissions for this user.",
related_name="user_set",
related_query_name="user",
to="auth.permission",
verbose_name="user permissions",
),
),
],
options={
"verbose_name": "user",
"verbose_name_plural": "users",
"abstract": False,
},
managers=[
("objects", django.contrib.auth.models.UserManager()),
],
),
migrations.CreateModel(
name="DiscordWebhook",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("url", models.URLField()),
("name", models.CharField(max_length=255)),
(
"user",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="discord_webhooks",
to=settings.AUTH_USER_MODEL,
),
),
],
),
migrations.CreateModel(
name="Channel",
fields=[
("twitch_id", models.TextField(primary_key=True, serialize=False)),
("display_name", models.TextField(default="Channel name unknown", null=True)),
("name", models.TextField(null=True)),
("twitch_url", models.URLField(default="https://www.twitch.tv/", null=True)),
("live", models.BooleanField(default=False)),
("drop_campaigns", models.ManyToManyField(related_name="channels", to="core.dropcampaign")),
],
),
migrations.AddField(
model_name="dropcampaign",
name="game",
field=models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="drop_campaigns",
to="core.game",
),
),
migrations.CreateModel(
name="GameSubscription",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("game", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="core.game")),
("user", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
("webhook", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="core.discordwebhook")),
],
options={
"unique_together": {("user", "game")},
},
),
migrations.AddField(
model_name="user",
name="subscribed_games",
field=models.ManyToManyField(
blank=True,
related_name="subscribed_users",
through="core.GameSubscription",
to="core.game",
),
),
migrations.AddField(
model_name="game",
name="org",
field=models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="games",
to="core.owner",
),
),
migrations.CreateModel(
name="OwnerSubscription",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("owner", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="core.owner")),
("user", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
("webhook", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="core.discordwebhook")),
],
options={
"unique_together": {("user", "owner")},
},
),
migrations.AddField(
model_name="user",
name="subscribed_owners",
field=models.ManyToManyField(
blank=True,
related_name="subscribed_users",
through="core.OwnerSubscription",
to="core.owner",
),
),
migrations.CreateModel(
name="RewardCampaign",
fields=[
("created_at", models.DateTimeField(auto_created=True, null=True)),
("id", models.TextField(primary_key=True, serialize=False)),
("modified_at", models.DateTimeField(auto_now=True, null=True)),
("name", models.TextField(null=True)),
("brand", models.TextField(null=True)),
("starts_at", models.DateTimeField(null=True)),
("ends_at", models.DateTimeField(null=True)),
("status", models.TextField(null=True)),
("summary", models.TextField(null=True)),
("instructions", models.TextField(null=True)),
("reward_value_url_param", models.TextField(null=True)),
("external_url", models.URLField(null=True)),
("about_url", models.URLField(null=True)),
("is_site_wide", models.BooleanField(null=True)),
("sub_goal", models.PositiveBigIntegerField(null=True)),
("minute_watched_goal", models.PositiveBigIntegerField(null=True)),
("image_url", models.URLField(null=True)),
(
"game",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="reward_campaigns",
to="core.game",
),
),
],
),
migrations.CreateModel(
name="Reward",
fields=[
("id", models.TextField(primary_key=True, serialize=False)),
("name", models.TextField(null=True)),
("banner_image_url", models.URLField(null=True)),
("thumbnail_image_url", models.URLField(null=True)),
("earnable_until", models.DateTimeField(null=True)),
("redemption_instructions", models.TextField(null=True)),
("redemption_url", models.URLField(null=True)),
(
"campaign",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="rewards",
to="core.rewardcampaign",
),
),
],
),
migrations.CreateModel(
name="TimeBasedDrop",
fields=[
("created_at", models.DateTimeField(auto_created=True, null=True)),
("id", models.TextField(primary_key=True, serialize=False)),
("modified_at", models.DateTimeField(auto_now=True, null=True)),
("required_subs", models.PositiveBigIntegerField(null=True)),
("ends_at", models.DateTimeField(null=True)),
("name", models.TextField(null=True)),
("required_minutes_watched", models.PositiveBigIntegerField(null=True)),
("starts_at", models.DateTimeField(null=True)),
(
"drop_campaign",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="drops",
to="core.dropcampaign",
),
),
],
),
migrations.CreateModel(
name="Benefit",
fields=[
("created_at", models.DateTimeField(auto_created=True, null=True)),
("id", models.TextField(primary_key=True, serialize=False)),
("modified_at", models.DateTimeField(auto_now=True, null=True)),
("twitch_created_at", models.DateTimeField(null=True)),
("entitlement_limit", models.PositiveBigIntegerField(null=True)),
("image_url", models.URLField(null=True)),
("is_ios_available", models.BooleanField(null=True)),
("name", models.TextField(null=True)),
(
"time_based_drop",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="benefits",
to="core.timebaseddrop",
),
),
],
),
]

View File

@ -1,202 +0,0 @@
# Generated by Django 5.1 on 2024-08-12 23:16
import django.db.models.deletion
from django.db import migrations, models
from django.db.migrations.operations.base import Operation
class Migration(migrations.Migration):
replaces: list[tuple[str, str]] = [
("core", "0001_initial"),
("core", "0002_alter_benefit_time_based_drop_and_more"),
("core", "0003_alter_benefit_options_alter_channel_options_and_more"),
]
initial = True
dependencies: list[tuple[str, str]] = []
operations: list[Operation] = [
migrations.CreateModel(
name="Owner",
fields=[
("id", models.TextField(primary_key=True, serialize=False)),
("name", models.TextField(null=True)),
],
options={
"abstract": False,
},
),
migrations.CreateModel(
name="Game",
fields=[
("twitch_id", models.TextField(primary_key=True, serialize=False)),
("game_url", models.URLField(null=True)),
("name", models.TextField(null=True)),
("box_art_url", models.URLField(null=True)),
("slug", models.TextField(null=True)),
(
"org",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="games",
to="core.owner",
),
),
],
options={
"abstract": False,
},
),
migrations.CreateModel(
name="RewardCampaign",
fields=[
("created_at", models.DateTimeField(auto_created=True, null=True)),
("id", models.TextField(primary_key=True, serialize=False)),
("modified_at", models.DateTimeField(auto_now=True, null=True)),
("name", models.TextField(null=True)),
("brand", models.TextField(null=True)),
("starts_at", models.DateTimeField(null=True)),
("ends_at", models.DateTimeField(null=True)),
("status", models.TextField(null=True)),
("summary", models.TextField(null=True)),
("instructions", models.TextField(null=True)),
("reward_value_url_param", models.TextField(null=True)),
("external_url", models.URLField(null=True)),
("about_url", models.URLField(null=True)),
("is_site_wide", models.BooleanField(null=True)),
("sub_goal", models.PositiveBigIntegerField(null=True)),
("minute_watched_goal", models.PositiveBigIntegerField(null=True)),
("image_url", models.URLField(null=True)),
(
"game",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="reward_campaigns",
to="core.game",
),
),
],
options={
"abstract": False,
},
),
migrations.CreateModel(
name="DropCampaign",
fields=[
("created_at", models.DateTimeField(auto_created=True, null=True)),
("id", models.TextField(primary_key=True, serialize=False)),
("modified_at", models.DateTimeField(auto_now=True, null=True)),
("account_link_url", models.URLField(null=True)),
("description", models.TextField(null=True)),
("details_url", models.URLField(null=True)),
("ends_at", models.DateTimeField(null=True)),
("starts_at", models.DateTimeField(null=True)),
("image_url", models.URLField(null=True)),
("name", models.TextField(null=True)),
("status", models.TextField(null=True)),
(
"game",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="drop_campaigns",
to="core.game",
),
),
],
options={
"abstract": False,
},
),
migrations.CreateModel(
name="TimeBasedDrop",
fields=[
("created_at", models.DateTimeField(auto_created=True, null=True)),
("id", models.TextField(primary_key=True, serialize=False)),
("modified_at", models.DateTimeField(auto_now=True, null=True)),
("required_subs", models.PositiveBigIntegerField(null=True)),
("ends_at", models.DateTimeField(null=True)),
("name", models.TextField(null=True)),
("required_minutes_watched", models.PositiveBigIntegerField(null=True)),
("starts_at", models.DateTimeField(null=True)),
(
"drop_campaign",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="drops",
to="core.dropcampaign",
),
),
],
options={
"abstract": False,
},
),
migrations.CreateModel(
name="Channel",
fields=[
("twitch_id", models.TextField(primary_key=True, serialize=False)),
("display_name", models.TextField(null=True)),
("name", models.TextField(null=True)),
("twitch_url", models.URLField(null=True)),
("live", models.BooleanField(default=False)),
("drop_campaigns", models.ManyToManyField(related_name="channels", to="core.dropcampaign")),
],
options={
"abstract": False,
},
),
migrations.CreateModel(
name="Benefit",
fields=[
("created_at", models.DateTimeField(auto_created=True, null=True)),
("id", models.TextField(primary_key=True, serialize=False)),
("modified_at", models.DateTimeField(auto_now=True, null=True)),
("twitch_created_at", models.DateTimeField(null=True)),
("entitlement_limit", models.PositiveBigIntegerField(null=True)),
("image_url", models.URLField(null=True)),
("is_ios_available", models.BooleanField(null=True)),
("name", models.TextField(null=True)),
(
"time_based_drop",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="benefits",
to="core.timebaseddrop",
),
),
],
options={
"abstract": False,
},
),
migrations.CreateModel(
name="Reward",
fields=[
("id", models.TextField(primary_key=True, serialize=False)),
("name", models.TextField(null=True)),
("banner_image_url", models.URLField(null=True)),
("thumbnail_image_url", models.URLField(null=True)),
("earnable_until", models.DateTimeField(null=True)),
("redemption_instructions", models.TextField(null=True)),
("redemption_url", models.URLField(null=True)),
(
"campaign",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="rewards",
to="core.rewardcampaign",
),
),
],
options={
"abstract": False,
},
),
]

View File

@ -1,37 +0,0 @@
# Generated by Django 5.1 on 2024-08-13 18:17
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("core", "0001_squashed_0003_alter_benefit_options_alter_channel_options_and_more"),
]
operations = [
migrations.AlterField(
model_name="channel",
name="display_name",
field=models.TextField(default="Channel name unknown", null=True),
),
migrations.AlterField(
model_name="channel",
name="twitch_url",
field=models.URLField(default="https://www.twitch.tv/", null=True),
),
migrations.AlterField(
model_name="game",
name="box_art_url",
field=models.URLField(default="https://static-cdn.jtvnw.net/ttv-static/404_boxart.jpg", null=True),
),
migrations.AlterField(
model_name="game",
name="game_url",
field=models.URLField(default="https://www.twitch.tv/", null=True),
),
migrations.AlterField(
model_name="game",
name="name",
field=models.TextField(default="Game name unknown", null=True),
),
]

View File

@ -2,6 +2,7 @@ from __future__ import annotations
import logging
from django.contrib.auth.models import AbstractUser
from django.db import models
logger: logging.Logger = logging.getLogger(__name__)
@ -179,3 +180,64 @@ class Reward(models.Model):
def __str__(self) -> str:
return self.name or "Reward name unknown"
class User(AbstractUser):
"""Extended User model to include subscriptions."""
subscribed_games = models.ManyToManyField(
"Game",
through="GameSubscription",
related_name="subscribed_users",
blank=True,
)
subscribed_owners = models.ManyToManyField(
"Owner",
through="OwnerSubscription",
related_name="subscribed_users",
blank=True,
)
subscribe_to_news = models.BooleanField(default=False, help_text="Subscribe to news")
subscribe_to_new_games = models.BooleanField(default=False, help_text="Subscribe to new games")
def __str__(self) -> str:
return self.username
class DiscordWebhook(models.Model):
"""A Discord webhook for sending notifications."""
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="discord_webhooks")
url = models.URLField()
name = models.CharField(max_length=255)
def __str__(self) -> str:
return f"{self.name} ({self.user.username})"
class GameSubscription(models.Model):
"""A subscription to a specific game with a chosen webhook."""
user = models.ForeignKey(User, on_delete=models.CASCADE)
game = models.ForeignKey(Game, on_delete=models.CASCADE)
webhook = models.ForeignKey(DiscordWebhook, on_delete=models.CASCADE)
class Meta:
unique_together = ("user", "game")
def __str__(self) -> str:
return f"{self.user.username} -> {self.game.name} via {self.webhook.name}"
class OwnerSubscription(models.Model):
"""A subscription to a specific owner with a chosen webhook."""
user = models.ForeignKey(User, on_delete=models.CASCADE)
owner = models.ForeignKey(Owner, on_delete=models.CASCADE)
webhook = models.ForeignKey(DiscordWebhook, on_delete=models.CASCADE)
class Meta:
unique_together = ("user", "owner")
def __str__(self) -> str:
return f"{self.user.username} -> {self.owner.name} via {self.webhook.name}"

View File

@ -43,7 +43,7 @@ DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
STATICFILES_DIRS: list[Path] = [BASE_DIR / "static"]
STATIC_ROOT: Path = BASE_DIR / "staticfiles"
STATIC_ROOT.mkdir(exist_ok=True)
AUTH_USER_MODEL = "core.User"
if DEBUG:
INTERNAL_IPS: list[str] = ["127.0.0.1"]

125
core/signals.py Normal file
View File

@ -0,0 +1,125 @@
import logging
from typing import TYPE_CHECKING
from discord_webhook import DiscordWebhook
from django.db.models.manager import BaseManager
from django.db.models.signals import post_save
from django.dispatch import receiver
from django.utils import timezone
from core.models import DropCampaign, Game, GameSubscription, Owner, OwnerSubscription
if TYPE_CHECKING:
import requests
from django.db.models.manager import BaseManager
logger: logging.Logger = logging.getLogger(__name__)
def convert_time_to_discord_timestamp(time: timezone.datetime | None) -> str:
"""Discord uses <t:UNIX_TIMESTAMP:R> for timestamps.
Args:
time: The time to convert to a Discord timestamp.
Returns:
str: The Discord timestamp string. If time is None, returns "Unknown".
"""
return f"<t:{int(time.timestamp())}:R>" if time else "Unknown"
@receiver(signal=post_save, sender=DropCampaign)
def notify_users_of_new_drop(sender: DropCampaign, instance: DropCampaign, created: bool, **kwargs) -> None: # noqa: ANN003, ARG001, FBT001
"""Notify users of a new drop campaign.
Args:
sender (DropCampaign): The model we are sending the signal from.
instance (DropCampaign): The instance of the model that was created.
created (bool): Whether the instance was created or updated.
**kwargs: Additional keyword arguments.
"""
if not created:
logger.debug("Drop campaign '%s' was updated.", instance.name)
return
game: Game | None = instance.game
if not game:
return
owner: Owner = game.owner # type: ignore # noqa: PGH003
# Notify users subscribed to the game
game_subs: BaseManager[GameSubscription] = GameSubscription.objects.filter(game=game)
for sub in game_subs:
if not sub.webhook.url:
logger.error("No webhook URL provided.")
return
webhook = DiscordWebhook(
url=sub.webhook.url,
content=generate_game_message(instance=instance, game=game, sub=sub),
username=f"{game.name} drop.",
rate_limit_retry=True,
)
response: requests.Response = webhook.execute()
logger.debug(response)
# Notify users subscribed to the owner
owner_subs: BaseManager[OwnerSubscription] = OwnerSubscription.objects.filter(owner=owner)
for sub in owner_subs:
if not sub.webhook.url:
logger.error("No webhook URL provided.")
return
webhook = DiscordWebhook(
url=sub.webhook.url,
content=generate_owner_message(instance=instance, owner=owner, sub=sub),
username=f"{owner.name} drop.",
rate_limit_retry=True,
)
response: requests.Response = webhook.execute()
logger.debug(response)
def generate_game_message(instance: DropCampaign, game: Game, sub: GameSubscription) -> str:
"""Generate a message for a drop campaign.
Args:
instance (DropCampaign): Drop campaign instance.
game (Game): Game instance.
sub (GameSubscription): Game subscription instance.
Returns:
str: The message to send to Discord.
"""
game_name: str = game.name or "Unknown"
description: str = instance.description or "No description provided."
start_at: str = convert_time_to_discord_timestamp(instance.starts_at)
end_at: str = convert_time_to_discord_timestamp(instance.ends_at)
msg: str = f"{game_name}: {instance.name}\n{description}\nStarts: {start_at}\nEnds: {end_at}"
logger.info("Discord message: '%s' to '%s'", msg, sub.webhook.url)
return msg
def generate_owner_message(instance: DropCampaign, owner: Owner, sub: OwnerSubscription) -> str:
"""Generate a message for a drop campaign.
Args:
instance (DropCampaign): Drop campaign instance.
owner (Owner): Owner instance.
sub (OwnerSubscription): Owner subscription instance.
Returns:
str: The message to send to Discord.
"""
owner_name: str = owner.name or "Unknown"
description: str = instance.description or "No description provided."
start_at: str = convert_time_to_discord_timestamp(instance.starts_at)
end_at: str = convert_time_to_discord_timestamp(instance.ends_at)
msg: str = f"{owner_name}: {instance.name}\n{description}\nStarts: {start_at}\nEnds: {end_at}"
logger.info("Discord message: '%s' to '%s'", msg, sub.webhook.url)
return msg

View File

@ -7,7 +7,10 @@
<div class="col-lg-9">
{% include "partials/info_box.html" %}
{% include "partials/news.html" %}
<h2>Reward Campaigns</h2>
<h2>
Reward campaigns -
<div class="d-inline text-muted">{{ reward_campaigns.count }} campaigns</div>
</h2>
{% for campaign in reward_campaigns %}
{% include "partials/reward_campaign_card.html" %}
{% endfor %}
@ -16,7 +19,6 @@
<div class="d-inline text-muted ">{{ games.count }} games</div>
</h2>
{% for game in games %}
{# Only show games with drop campaigns #}
{% if game.drop_campaigns.count > 0 %}
{% include "partials/game_card.html" %}
{% endif %}

View File

@ -9,7 +9,7 @@ from django.db.models.manager import BaseManager
from django.template.response import TemplateResponse
from django.utils import timezone # type: ignore # noqa: PGH003
from core.models.twitch import DropCampaign, Game, RewardCampaign
from core.models import DropCampaign, Game, RewardCampaign
if TYPE_CHECKING:
from django.db.models.manager import BaseManager