Add rewards to more pages as we have support for them now
All checks were successful
Deploy to Server / deploy (push) Successful in 26s
All checks were successful
Deploy to Server / deploy (push) Successful in 26s
This commit is contained in:
parent
3535d7d2dd
commit
37c1390100
16 changed files with 966 additions and 216 deletions
|
|
@ -164,6 +164,7 @@ class V1GameDetailSchema(V1GameSchema):
|
|||
"""Twitch game detail response."""
|
||||
|
||||
campaigns: list[V1DropCampaignSummarySchema]
|
||||
reward_campaigns: list[V1RewardCampaignSchema]
|
||||
|
||||
|
||||
class V1ChannelDetailSchema(V1ChannelSchema):
|
||||
|
|
@ -642,6 +643,13 @@ def get_game(request: HttpRequest, twitch_id: str) -> V1GameDetailSchema:
|
|||
DropCampaign.objects.filter(game=game),
|
||||
).order_by("-end_at")
|
||||
now = timezone.now()
|
||||
reward_campaigns = list(
|
||||
RewardCampaign.objects
|
||||
.filter(game=game)
|
||||
.select_related("game")
|
||||
.prefetch_related("game__owners")
|
||||
.order_by("-starts_at"),
|
||||
)
|
||||
return V1GameDetailSchema(
|
||||
twitch_id=game.twitch_id,
|
||||
slug=game.slug,
|
||||
|
|
@ -659,6 +667,9 @@ def get_game(request: HttpRequest, twitch_id: str) -> V1GameDetailSchema:
|
|||
campaigns=[
|
||||
_serialize_campaign_summary(campaign, now) for campaign in campaigns
|
||||
],
|
||||
reward_campaigns=[
|
||||
_serialize_reward_campaign(rc, now) for rc in reward_campaigns
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
292
twitch/feeds.py
292
twitch/feeds.py
|
|
@ -1695,3 +1695,295 @@ class RewardCampaignDiscordFeed(TTVDropsAtomBaseFeed, RewardCampaignFeed):
|
|||
def feed_url(self) -> str:
|
||||
"""Return the URL to the Discord feed itself."""
|
||||
return reverse("core:reward_campaign_feed_discord")
|
||||
|
||||
|
||||
# MARK: /rss/games/<twitch_id>/rewards/
|
||||
class GameRewardCampaignFeed(TTVDropsBaseFeed):
|
||||
"""RSS feed for the latest reward campaigns of a specific game."""
|
||||
|
||||
item_guid_is_permalink = True
|
||||
_limit: int | None = None
|
||||
|
||||
def __call__(
|
||||
self,
|
||||
request: HttpRequest,
|
||||
*args: str | int,
|
||||
**kwargs: str | int,
|
||||
) -> HttpResponse:
|
||||
"""Override to capture limit parameter from request.
|
||||
|
||||
Args:
|
||||
request (HttpRequest): The incoming HTTP request, potentially containing a 'limit' query parameter.
|
||||
*args: Additional positional arguments.
|
||||
**kwargs: Additional keyword arguments.
|
||||
|
||||
Returns:
|
||||
HttpResponse: The HTTP response generated by the parent Feed class after processing the request.
|
||||
"""
|
||||
if request.GET.get("limit"):
|
||||
try:
|
||||
self._limit = int(request.GET.get("limit", 200))
|
||||
except ValueError, TypeError:
|
||||
self._limit = None
|
||||
return super().__call__(request, *args, **kwargs)
|
||||
|
||||
def get_object(self, request: HttpRequest, twitch_id: str) -> Game: # noqa: ARG002
|
||||
"""Retrieve the Game instance for the given Twitch ID.
|
||||
|
||||
Returns:
|
||||
Game: The corresponding Game object.
|
||||
"""
|
||||
return Game.objects.get(twitch_id=twitch_id)
|
||||
|
||||
def item_link(self, item: RewardCampaign) -> str:
|
||||
"""Return the link to the reward campaign detail."""
|
||||
return reverse("twitch:reward_campaign_detail", args=[item.twitch_id])
|
||||
|
||||
def title(self, obj: Game) -> str:
|
||||
"""Return the feed title for the game reward campaigns."""
|
||||
return f"TTVDrops: {obj.display_name} Rewards"
|
||||
|
||||
def link(self, obj: Game) -> str:
|
||||
"""Return the absolute URL to the game detail page."""
|
||||
return reverse("twitch:game_detail", args=[obj.twitch_id])
|
||||
|
||||
def description(self, obj: Game) -> str:
|
||||
"""Return a description for the feed."""
|
||||
return f"Latest reward campaigns for {obj.display_name}"
|
||||
|
||||
def items(self, obj: Game) -> list[RewardCampaign]:
|
||||
"""Return latest active reward campaigns for this game."""
|
||||
limit: int = self._limit if self._limit is not None else 200
|
||||
queryset: QuerySet[RewardCampaign] = _active_reward_campaigns(
|
||||
RewardCampaign.objects
|
||||
.filter(game=obj)
|
||||
.select_related("game")
|
||||
.order_by("-starts_at"),
|
||||
)
|
||||
return list(queryset[:limit])
|
||||
|
||||
def item_title(self, item: RewardCampaign) -> SafeText:
|
||||
"""Return the reward campaign name as the item title."""
|
||||
if item.brand:
|
||||
return SafeText(f"{item.brand}: {item.name}")
|
||||
return SafeText(item.name)
|
||||
|
||||
def item_description(self, item: RewardCampaign) -> SafeText:
|
||||
"""Return a description of the reward campaign."""
|
||||
parts: list = []
|
||||
|
||||
if item.summary:
|
||||
parts.append(format_html("<p>{}</p>", item.summary))
|
||||
|
||||
if item.starts_at or item.ends_at:
|
||||
start_part = (
|
||||
format_html(
|
||||
"Starts: {} ({})",
|
||||
item.starts_at.strftime("%Y-%m-%d %H:%M %Z"),
|
||||
naturaltime(item.starts_at),
|
||||
)
|
||||
if item.starts_at
|
||||
else ""
|
||||
)
|
||||
end_part = (
|
||||
format_html(
|
||||
"Ends: {} ({})",
|
||||
item.ends_at.strftime("%Y-%m-%d %H:%M %Z"),
|
||||
naturaltime(item.ends_at),
|
||||
)
|
||||
if item.ends_at
|
||||
else ""
|
||||
)
|
||||
if start_part and end_part:
|
||||
parts.append(format_html("<p>{}<br />{}</p>", start_part, end_part))
|
||||
elif start_part:
|
||||
parts.append(format_html("<p>{}</p>", start_part))
|
||||
elif end_part:
|
||||
parts.append(format_html("<p>{}</p>", end_part))
|
||||
|
||||
if item.is_sitewide:
|
||||
parts.append(
|
||||
SafeText("<p><strong>This is a sitewide reward campaign</strong></p>"),
|
||||
)
|
||||
elif item.game:
|
||||
parts.append(
|
||||
format_html(
|
||||
"<p>Game: {}</p>",
|
||||
item.game.display_name or item.game.name,
|
||||
),
|
||||
)
|
||||
|
||||
if item.about_url:
|
||||
parts.append(
|
||||
format_html('<p><a href="{}">Learn more</a></p>', item.about_url),
|
||||
)
|
||||
|
||||
if item.external_url:
|
||||
parts.append(
|
||||
format_html(
|
||||
'<p><a href="{}">Redeem reward</a></p>',
|
||||
item.external_url,
|
||||
),
|
||||
)
|
||||
|
||||
return SafeText("".join(str(p) for p in parts))
|
||||
|
||||
def item_pubdate(self, item: RewardCampaign) -> datetime.datetime:
|
||||
"""Returns the publication date to the feed item.
|
||||
|
||||
Uses starts_at (when the reward starts). Fallback to added_at or now if missing.
|
||||
"""
|
||||
if item.starts_at:
|
||||
return item.starts_at
|
||||
if item.added_at:
|
||||
return item.added_at
|
||||
return timezone.now()
|
||||
|
||||
def item_updateddate(self, item: RewardCampaign) -> datetime.datetime:
|
||||
"""Returns the reward campaign's last update time."""
|
||||
return item.updated_at
|
||||
|
||||
def item_categories(self, item: RewardCampaign) -> tuple[str, ...]:
|
||||
"""Returns the associated game's name and brand as categories."""
|
||||
categories: list[str] = ["twitch", "rewards", "quests"]
|
||||
|
||||
if item.brand:
|
||||
categories.append(item.brand)
|
||||
|
||||
if item.game:
|
||||
categories.append(item.game.get_game_name)
|
||||
|
||||
return tuple(categories)
|
||||
|
||||
def item_guid(self, item: RewardCampaign) -> str:
|
||||
"""Return a unique identifier for each reward campaign."""
|
||||
return self._absolute_url(
|
||||
reverse("twitch:reward_campaign_detail", args=[item.twitch_id]),
|
||||
)
|
||||
|
||||
def item_author_name(self, item: RewardCampaign) -> str:
|
||||
"""Return the author name for the reward campaign."""
|
||||
if item.brand:
|
||||
return item.brand
|
||||
|
||||
if item.game and item.game.display_name:
|
||||
return item.game.display_name
|
||||
|
||||
return "Twitch"
|
||||
|
||||
def author_name(self, obj: Game) -> str:
|
||||
"""Return the author name for the game, typically the owner organization name."""
|
||||
return obj.display_name or "Twitch"
|
||||
|
||||
def item_enclosures(self, item: RewardCampaign) -> list[feedgenerator.Enclosure]:
|
||||
"""Return a list of enclosures for the reward campaign, if available.
|
||||
|
||||
Args:
|
||||
item (RewardCampaign): The reward campaign item.
|
||||
|
||||
Returns:
|
||||
list[feedgenerator.Enclosure]: A list of Enclosure objects if an image URL is
|
||||
available, otherwise an empty list.
|
||||
"""
|
||||
image_url: str = getattr(item, "image_best_url", "")
|
||||
if image_url:
|
||||
try:
|
||||
size: int | None = getattr(item, "image_size_bytes", None)
|
||||
length: int = int(size) if size is not None else 0
|
||||
except TypeError, ValueError:
|
||||
length = 0
|
||||
|
||||
if not length:
|
||||
return []
|
||||
|
||||
mime: str = getattr(item, "image_mime_type", "")
|
||||
mime_type: str = mime or "image/jpeg"
|
||||
|
||||
return [
|
||||
feedgenerator.Enclosure(
|
||||
self._absolute_url(image_url),
|
||||
str(length),
|
||||
mime_type,
|
||||
),
|
||||
]
|
||||
return []
|
||||
|
||||
def feed_url(self, obj: Game) -> str:
|
||||
"""Return the URL to the RSS feed itself."""
|
||||
return reverse("core:game_reward_feed", args=[obj.twitch_id])
|
||||
|
||||
|
||||
class GameRewardCampaignAtomFeed(TTVDropsAtomBaseFeed, GameRewardCampaignFeed):
|
||||
"""Atom feed for latest reward campaigns for a specific game (reuses GameRewardCampaignFeed)."""
|
||||
|
||||
def feed_url(self, obj: Game) -> str:
|
||||
"""Return the URL to the Atom feed itself."""
|
||||
return reverse("core:game_reward_feed_atom", args=[obj.twitch_id])
|
||||
|
||||
|
||||
class GameRewardCampaignDiscordFeed(TTVDropsAtomBaseFeed, GameRewardCampaignFeed):
|
||||
"""Discord feed for latest reward campaigns for a specific game with Discord relative timestamps."""
|
||||
|
||||
def item_description(self, item: RewardCampaign) -> SafeText:
|
||||
"""Return a description of the reward campaign with Discord timestamps."""
|
||||
parts: list = []
|
||||
|
||||
if item.summary:
|
||||
parts.append(format_html("<p>{}</p>", item.summary))
|
||||
|
||||
if item.starts_at or item.ends_at:
|
||||
start_part = (
|
||||
format_html(
|
||||
"Starts: {} ({})",
|
||||
item.starts_at.strftime("%Y-%m-%d %H:%M %Z"),
|
||||
discord_timestamp(item.starts_at),
|
||||
)
|
||||
if item.starts_at
|
||||
else ""
|
||||
)
|
||||
end_part = (
|
||||
format_html(
|
||||
"Ends: {} ({})",
|
||||
item.ends_at.strftime("%Y-%m-%d %H:%M %Z"),
|
||||
discord_timestamp(item.ends_at),
|
||||
)
|
||||
if item.ends_at
|
||||
else ""
|
||||
)
|
||||
if start_part and end_part:
|
||||
parts.append(format_html("<p>{}<br />{}</p>", start_part, end_part))
|
||||
elif start_part:
|
||||
parts.append(format_html("<p>{}</p>", start_part))
|
||||
elif end_part:
|
||||
parts.append(format_html("<p>{}</p>", end_part))
|
||||
|
||||
if item.is_sitewide:
|
||||
parts.append(
|
||||
SafeText("<p><strong>This is a sitewide reward campaign</strong></p>"),
|
||||
)
|
||||
elif item.game:
|
||||
parts.append(
|
||||
format_html(
|
||||
"<p>Game: {}</p>",
|
||||
item.game.display_name or item.game.name,
|
||||
),
|
||||
)
|
||||
|
||||
if item.about_url:
|
||||
parts.append(
|
||||
format_html('<p><a href="{}">Learn more</a></p>', item.about_url),
|
||||
)
|
||||
|
||||
if item.external_url:
|
||||
parts.append(
|
||||
format_html(
|
||||
'<p><a href="{}">Redeem reward</a></p>',
|
||||
item.external_url,
|
||||
),
|
||||
)
|
||||
|
||||
return SafeText("".join(str(p) for p in parts))
|
||||
|
||||
def feed_url(self, obj: Game) -> str:
|
||||
"""Return the URL to the Discord feed itself."""
|
||||
return reverse("core:game_reward_feed_discord", args=[obj.twitch_id])
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ from twitch.feeds import RSS_STYLESHEETS
|
|||
from twitch.feeds import DropCampaignFeed
|
||||
from twitch.feeds import GameCampaignFeed
|
||||
from twitch.feeds import GameFeed
|
||||
from twitch.feeds import GameRewardCampaignFeed
|
||||
from twitch.feeds import OrganizationRSSFeed
|
||||
from twitch.feeds import RewardCampaignFeed
|
||||
from twitch.feeds import TTVDropsBaseFeed
|
||||
|
|
@ -297,6 +298,11 @@ class RSSFeedTestCase(TestCase):
|
|||
{},
|
||||
f"https://ttvdrops.lovinator.space{reverse('twitch:reward_campaign_detail', args=[self.reward_campaign.twitch_id])}",
|
||||
),
|
||||
(
|
||||
"core:game_reward_feed_atom",
|
||||
{"twitch_id": self.game.twitch_id},
|
||||
f"https://ttvdrops.lovinator.space{reverse('twitch:reward_campaign_detail', args=[self.reward_campaign.twitch_id])}",
|
||||
),
|
||||
]
|
||||
|
||||
for url_name, kwargs, expected_entry_id in atom_feed_cases:
|
||||
|
|
@ -347,6 +353,7 @@ class RSSFeedTestCase(TestCase):
|
|||
reverse("core:campaign_feed_atom"),
|
||||
reverse("core:game_feed_atom"),
|
||||
reverse("core:game_campaign_feed_atom", args=[self.game.twitch_id]),
|
||||
reverse("core:game_reward_feed_atom", args=[self.game.twitch_id]),
|
||||
reverse("core:organization_feed_atom"),
|
||||
reverse("core:reward_campaign_feed_atom"),
|
||||
]
|
||||
|
|
@ -425,10 +432,12 @@ class RSSFeedTestCase(TestCase):
|
|||
reverse("core:game_feed"),
|
||||
reverse("core:campaign_feed"),
|
||||
reverse("core:game_campaign_feed", args=[self.game.twitch_id]),
|
||||
reverse("core:game_reward_feed", args=[self.game.twitch_id]),
|
||||
reverse("core:reward_campaign_feed"),
|
||||
reverse("core:game_feed_atom"),
|
||||
reverse("core:campaign_feed_atom"),
|
||||
reverse("core:game_campaign_feed_atom", args=[self.game.twitch_id]),
|
||||
reverse("core:game_reward_feed_atom", args=[self.game.twitch_id]),
|
||||
reverse("core:reward_campaign_feed_atom"),
|
||||
]
|
||||
|
||||
|
|
@ -484,6 +493,7 @@ class RSSFeedTestCase(TestCase):
|
|||
reverse("core:campaign_feed"),
|
||||
reverse("core:game_feed"),
|
||||
reverse("core:game_campaign_feed", args=[self.game.twitch_id]),
|
||||
reverse("core:game_reward_feed", args=[self.game.twitch_id]),
|
||||
reverse("core:organization_feed"),
|
||||
reverse("core:reward_campaign_feed"),
|
||||
]
|
||||
|
|
@ -516,12 +526,14 @@ class RSSFeedTestCase(TestCase):
|
|||
GameFeed,
|
||||
DropCampaignFeed,
|
||||
GameCampaignFeed,
|
||||
GameRewardCampaignFeed,
|
||||
RewardCampaignFeed,
|
||||
):
|
||||
feed: (
|
||||
DropCampaignFeed
|
||||
| GameCampaignFeed
|
||||
| GameFeed
|
||||
| GameRewardCampaignFeed
|
||||
| OrganizationRSSFeed
|
||||
| RewardCampaignFeed
|
||||
) = feed_class()
|
||||
|
|
@ -535,6 +547,7 @@ class RSSFeedTestCase(TestCase):
|
|||
reverse("core:campaign_feed"),
|
||||
reverse("core:game_feed"),
|
||||
reverse("core:game_campaign_feed", args=[self.game.twitch_id]),
|
||||
reverse("core:game_reward_feed", args=[self.game.twitch_id]),
|
||||
reverse("core:organization_feed"),
|
||||
reverse("core:reward_campaign_feed"),
|
||||
]
|
||||
|
|
@ -799,6 +812,89 @@ class RSSFeedTestCase(TestCase):
|
|||
assert "Past Reward Campaign" not in content
|
||||
assert "Upcoming Reward Campaign" not in content
|
||||
|
||||
def test_game_reward_campaign_feed(self) -> None:
|
||||
"""Test game-specific reward campaign feed returns 200."""
|
||||
url: str = reverse("core:game_reward_feed", args=[self.game.twitch_id])
|
||||
response: _MonkeyPatchedWSGIResponse = self.client.get(url)
|
||||
assert response.status_code == 200
|
||||
assert response["Content-Type"] == "application/xml; charset=utf-8"
|
||||
assert response["Content-Disposition"] == "inline"
|
||||
content: str = response.content.decode("utf-8")
|
||||
assert "Test Game" in content
|
||||
assert "Test Brand: Test Reward Campaign" in content
|
||||
|
||||
def test_game_reward_campaign_feed_filters_correctly(self) -> None:
|
||||
"""Test game reward feed only shows rewards for that game."""
|
||||
other_game: Game = Game.objects.create(
|
||||
twitch_id="other-reward-game-123",
|
||||
slug="other-reward-game",
|
||||
name="Other Game Rewards",
|
||||
display_name="Other Game Rewards",
|
||||
)
|
||||
other_game.owners.add(self.org)
|
||||
RewardCampaign.objects.create(
|
||||
twitch_id="other-reward-123",
|
||||
name="Other Reward Campaign",
|
||||
brand="Other Brand",
|
||||
starts_at=timezone.now() - timedelta(days=1),
|
||||
ends_at=timezone.now() + timedelta(days=7),
|
||||
status="ACTIVE",
|
||||
summary="Other reward summary",
|
||||
instructions="Do other things",
|
||||
external_url="https://example.com/other-reward",
|
||||
about_url="https://example.com/about-other-reward",
|
||||
is_sitewide=False,
|
||||
game=other_game,
|
||||
)
|
||||
|
||||
url: str = reverse("core:game_reward_feed", args=[self.game.twitch_id])
|
||||
response: _MonkeyPatchedWSGIResponse = self.client.get(url)
|
||||
content: str = response.content.decode("utf-8")
|
||||
|
||||
assert "Test Brand: Test Reward Campaign" in content
|
||||
assert "Other Brand: Other Reward Campaign" not in content
|
||||
|
||||
def test_game_reward_campaign_feed_only_includes_active_campaigns(self) -> None:
|
||||
"""Game reward campaign feed should exclude old and upcoming campaigns."""
|
||||
now: datetime.datetime = timezone.now()
|
||||
RewardCampaign.objects.create(
|
||||
twitch_id="game-reward-past-123",
|
||||
name="Game Past Reward",
|
||||
brand="Test Brand",
|
||||
starts_at=now - timedelta(days=10),
|
||||
ends_at=now - timedelta(days=1),
|
||||
status="EXPIRED",
|
||||
summary="Past reward",
|
||||
instructions="Was active",
|
||||
external_url="https://example.com/past-reward",
|
||||
about_url="https://example.com/about-past-reward",
|
||||
is_sitewide=False,
|
||||
game=self.game,
|
||||
)
|
||||
RewardCampaign.objects.create(
|
||||
twitch_id="game-reward-upcoming-123",
|
||||
name="Game Upcoming Reward",
|
||||
brand="Test Brand",
|
||||
starts_at=now + timedelta(days=1),
|
||||
ends_at=now + timedelta(days=10),
|
||||
status="UPCOMING",
|
||||
summary="Upcoming reward",
|
||||
instructions="Wait",
|
||||
external_url="https://example.com/upcoming-reward",
|
||||
about_url="https://example.com/about-upcoming-reward",
|
||||
is_sitewide=False,
|
||||
game=self.game,
|
||||
)
|
||||
|
||||
url: str = reverse("core:game_reward_feed", args=[self.game.twitch_id])
|
||||
response: _MonkeyPatchedWSGIResponse = self.client.get(url)
|
||||
assert response.status_code == 200
|
||||
content: str = response.content.decode("utf-8")
|
||||
|
||||
assert "Test Reward Campaign" in content
|
||||
assert "Game Past Reward" not in content
|
||||
assert "Game Upcoming Reward" not in content
|
||||
|
||||
def test_game_campaign_feed_enclosure_helpers(self) -> None:
|
||||
"""GameCampaignFeed helper methods should pull from the model fields."""
|
||||
feed = GameCampaignFeed()
|
||||
|
|
@ -1106,6 +1202,35 @@ def test_game_campaign_feed_queries_do_not_scale_with_items(
|
|||
assert response.status_code == 200
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_game_reward_campaign_feed_queries_bounded(
|
||||
client: Client,
|
||||
django_assert_num_queries: QueryAsserter,
|
||||
) -> None:
|
||||
"""Game reward campaign feed should stay within a modest query budget."""
|
||||
org: Organization = Organization.objects.create(
|
||||
twitch_id="test-org-game-reward-queries",
|
||||
name="Query Org Game Rewards",
|
||||
)
|
||||
game: Game = Game.objects.create(
|
||||
twitch_id="test-game-reward-queries",
|
||||
slug="query-game-reward",
|
||||
name="Query Game Rewards",
|
||||
display_name="Query Game Rewards",
|
||||
)
|
||||
game.owners.add(org)
|
||||
|
||||
for i in range(3):
|
||||
_build_reward_campaign(game, i)
|
||||
|
||||
url: str = reverse("core:game_reward_feed", args=[game.twitch_id])
|
||||
|
||||
with django_assert_num_queries(2, exact=True):
|
||||
response: _MonkeyPatchedWSGIResponse = client.get(url)
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_organization_feed_queries_bounded(
|
||||
client: Client,
|
||||
|
|
@ -1235,6 +1360,7 @@ URL_NAMES: list[tuple[str, dict[str, str]]] = [
|
|||
("core:campaign_feed", {}),
|
||||
("core:game_feed", {}),
|
||||
("core:game_campaign_feed", {"twitch_id": "test-game-123"}),
|
||||
("core:game_reward_feed", {"twitch_id": "test-game-123"}),
|
||||
("core:organization_feed", {}),
|
||||
("core:reward_campaign_feed", {}),
|
||||
]
|
||||
|
|
@ -1465,6 +1591,11 @@ class DiscordFeedTestCase(TestCase):
|
|||
{},
|
||||
f"https://ttvdrops.lovinator.space{reverse('twitch:reward_campaign_detail', args=[self.reward_campaign.twitch_id])}",
|
||||
),
|
||||
(
|
||||
"core:game_reward_feed_discord",
|
||||
{"twitch_id": self.game.twitch_id},
|
||||
f"https://ttvdrops.lovinator.space{reverse('twitch:reward_campaign_detail', args=[self.reward_campaign.twitch_id])}",
|
||||
),
|
||||
]
|
||||
|
||||
for url_name, kwargs, expected_entry_id in discord_feed_cases:
|
||||
|
|
@ -1490,6 +1621,7 @@ class DiscordFeedTestCase(TestCase):
|
|||
reverse("core:campaign_feed_discord"),
|
||||
reverse("core:game_feed_discord"),
|
||||
reverse("core:game_campaign_feed_discord", args=[self.game.twitch_id]),
|
||||
reverse("core:game_reward_feed_discord", args=[self.game.twitch_id]),
|
||||
reverse("core:organization_feed_discord"),
|
||||
reverse("core:reward_campaign_feed_discord"),
|
||||
]
|
||||
|
|
@ -1518,6 +1650,19 @@ class DiscordFeedTestCase(TestCase):
|
|||
)
|
||||
assert "()" not in content
|
||||
|
||||
def test_game_reward_campaign_discord_feed(self) -> None:
|
||||
"""Test game-specific reward campaign Discord feed returns 200."""
|
||||
url: str = reverse(
|
||||
"core:game_reward_feed_discord",
|
||||
args=[self.game.twitch_id],
|
||||
)
|
||||
response: _MonkeyPatchedWSGIResponse = self.client.get(url)
|
||||
assert response.status_code == 200
|
||||
assert response["Content-Type"] == "application/xml; charset=utf-8"
|
||||
content: str = response.content.decode("utf-8")
|
||||
assert "<feed" in content
|
||||
assert "Test Game Discord" in content
|
||||
|
||||
def test_discord_reward_campaign_feed_contains_discord_timestamps(self) -> None:
|
||||
"""Discord reward campaign feed should contain Discord relative timestamps."""
|
||||
url: str = reverse("core:reward_campaign_feed_discord")
|
||||
|
|
@ -1532,6 +1677,25 @@ class DiscordFeedTestCase(TestCase):
|
|||
)
|
||||
assert "()" not in content
|
||||
|
||||
def test_discord_game_reward_campaign_feed_contains_discord_timestamps(
|
||||
self,
|
||||
) -> None:
|
||||
"""Discord game reward campaign feed should contain Discord relative timestamps."""
|
||||
url: str = reverse(
|
||||
"core:game_reward_feed_discord",
|
||||
args=[self.game.twitch_id],
|
||||
)
|
||||
response: _MonkeyPatchedWSGIResponse = self.client.get(url)
|
||||
assert response.status_code == 200
|
||||
content: str = response.content.decode("utf-8")
|
||||
|
||||
# Should contain Discord timestamp format (double-escaped in XML payload)
|
||||
discord_pattern: re.Pattern[str] = re.compile(r"&lt;t:\d+:R&gt;")
|
||||
assert discord_pattern.search(content), (
|
||||
f"Expected Discord timestamp format &lt;t:UNIX_TIMESTAMP:R&gt; in content, got: {content}"
|
||||
)
|
||||
assert "()" not in content
|
||||
|
||||
def test_feed_links_return_200(self) -> None:
|
||||
"""Test that all links in the feeds return 200 OK."""
|
||||
feed_urls: list[str] = [
|
||||
|
|
|
|||
|
|
@ -721,6 +721,27 @@ class GameDetailView(DetailView):
|
|||
active_campaigns, upcoming_campaigns, expired_campaigns = (
|
||||
DropCampaign.split_for_channel_detail(campaigns_list, now)
|
||||
)
|
||||
|
||||
# Fetch reward campaigns for this game
|
||||
all_reward_campaigns: list[RewardCampaign] = list(
|
||||
RewardCampaign.objects
|
||||
.filter(game=game)
|
||||
.select_related("game")
|
||||
.order_by("-starts_at"),
|
||||
)
|
||||
active_rewards: list[RewardCampaign] = []
|
||||
upcoming_rewards: list[RewardCampaign] = []
|
||||
expired_rewards: list[RewardCampaign] = []
|
||||
for rc in all_reward_campaigns:
|
||||
if rc.starts_at is None or rc.ends_at is None:
|
||||
continue
|
||||
if rc.starts_at <= now <= rc.ends_at:
|
||||
active_rewards.append(rc)
|
||||
elif rc.starts_at > now:
|
||||
upcoming_rewards.append(rc)
|
||||
elif rc.ends_at < now:
|
||||
expired_rewards.append(rc)
|
||||
|
||||
owners: list[Organization] = list(getattr(game, "owners_for_detail", []))
|
||||
|
||||
game_name: str = game.get_game_name
|
||||
|
|
@ -802,6 +823,9 @@ class GameDetailView(DetailView):
|
|||
"active_campaigns": active_campaigns,
|
||||
"upcoming_campaigns": upcoming_campaigns,
|
||||
"expired_campaigns": expired_campaigns,
|
||||
"active_rewards": active_rewards,
|
||||
"upcoming_rewards": upcoming_rewards,
|
||||
"expired_rewards": expired_rewards,
|
||||
"owner": owners[0] if owners else None,
|
||||
"owners": owners,
|
||||
"now": now,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue