Add box art and campaign image metadata fields; update feeds and backfill command
All checks were successful
Deploy to Server / deploy (push) Successful in 10s
All checks were successful
Deploy to Server / deploy (push) Successful in 10s
This commit is contained in:
parent
0d72d99b8f
commit
5d95038faf
6 changed files with 264 additions and 25 deletions
|
|
@ -497,16 +497,27 @@ class GameFeed(Feed):
|
|||
return box_art
|
||||
return ""
|
||||
|
||||
def item_enclosure_length(self, item: Game) -> int: # noqa: ARG002
|
||||
"""Returns the length of the enclosure."""
|
||||
# TODO(TheLovinator): Track image size for proper length # noqa: TD003
|
||||
def item_enclosure_length(self, item: Game) -> int:
|
||||
"""Returns the length of the enclosure.
|
||||
|
||||
return 0
|
||||
Prefer the newly-added ``box_art_size_bytes`` field so that the RSS
|
||||
feed can include an accurate ``length`` attribute. Fall back to 0 if
|
||||
the value is missing or ``None``.
|
||||
"""
|
||||
try:
|
||||
size = getattr(item, "box_art_size_bytes", None)
|
||||
return int(size) if size is not None else 0
|
||||
except TypeError, ValueError:
|
||||
return 0
|
||||
|
||||
def item_enclosure_mime_type(self, item: Game) -> str: # noqa: ARG002
|
||||
"""Returns the MIME type of the enclosure."""
|
||||
# TODO(TheLovinator): Determine actual MIME type if needed # noqa: TD003
|
||||
return "image/jpeg"
|
||||
def item_enclosure_mime_type(self, item: Game) -> str:
|
||||
"""Returns the MIME type of the enclosure.
|
||||
|
||||
Use the ``box_art_mime_type`` field when available, otherwise fall back
|
||||
to a generic JPEG string (as was previously hard-coded).
|
||||
"""
|
||||
mime: str = getattr(item, "box_art_mime_type", "")
|
||||
return mime or "image/jpeg"
|
||||
|
||||
|
||||
# MARK: /rss/campaigns/
|
||||
|
|
@ -633,15 +644,26 @@ class DropCampaignFeed(Feed):
|
|||
"""Returns the URL of the campaign image for enclosure."""
|
||||
return item.get_feed_enclosure_url()
|
||||
|
||||
def item_enclosure_length(self, item: DropCampaign) -> int: # noqa: ARG002
|
||||
"""Returns the length of the enclosure."""
|
||||
# TODO(TheLovinator): Track image size for proper length # noqa: TD003
|
||||
return 0
|
||||
def item_enclosure_length(self, item: DropCampaign) -> int:
|
||||
"""Returns the length of the enclosure.
|
||||
|
||||
def item_enclosure_mime_type(self, item: DropCampaign) -> str: # noqa: ARG002
|
||||
"""Returns the MIME type of the enclosure."""
|
||||
# TODO(TheLovinator): Determine actual MIME type if needed # noqa: TD003
|
||||
return "image/jpeg"
|
||||
Reads the `image_size_bytes` field added to the model. If the field is
|
||||
unset it returns `0` to match previous behavior.
|
||||
"""
|
||||
try:
|
||||
size = getattr(item, "image_size_bytes", None)
|
||||
return int(size) if size is not None else 0
|
||||
except TypeError, ValueError:
|
||||
return 0
|
||||
|
||||
def item_enclosure_mime_type(self, item: DropCampaign) -> str:
|
||||
"""Returns the MIME type of the enclosure.
|
||||
|
||||
Uses `image_mime_type` on the campaign if set, falling back to the
|
||||
previous hard-coded `image/jpeg`.
|
||||
"""
|
||||
mime: str = getattr(item, "image_mime_type", "")
|
||||
return mime or "image/jpeg"
|
||||
|
||||
|
||||
# MARK: /rss/games/<twitch_id>/campaigns/
|
||||
|
|
@ -801,16 +823,26 @@ class GameCampaignFeed(Feed):
|
|||
"""Returns the URL of the campaign image for enclosure."""
|
||||
return item.get_feed_enclosure_url()
|
||||
|
||||
def item_enclosure_length(self, item: DropCampaign) -> int: # noqa: ARG002
|
||||
"""Returns the length of the enclosure."""
|
||||
# TODO(TheLovinator): Track image size for proper length # noqa: TD003
|
||||
def item_enclosure_length(self, item: DropCampaign) -> int:
|
||||
"""Returns the length of the enclosure.
|
||||
|
||||
return 0
|
||||
Reads the ``image_size_bytes`` field added to the model when rendering a
|
||||
game-specific campaign feed.
|
||||
"""
|
||||
try:
|
||||
size = getattr(item, "image_size_bytes", None)
|
||||
return int(size) if size is not None else 0
|
||||
except TypeError, ValueError:
|
||||
return 0
|
||||
|
||||
def item_enclosure_mime_type(self, item: DropCampaign) -> str: # noqa: ARG002
|
||||
"""Returns the MIME type of the enclosure."""
|
||||
# TODO(TheLovinator): Determine actual MIME type if needed # noqa: TD003
|
||||
return "image/jpeg"
|
||||
def item_enclosure_mime_type(self, item: DropCampaign) -> str:
|
||||
"""Returns the MIME type of the enclosure.
|
||||
|
||||
Prefers `image_mime_type` on the campaign object; falls back to
|
||||
`image/jpeg` when not available.
|
||||
"""
|
||||
mime: str = getattr(item, "image_mime_type", "")
|
||||
return mime or "image/jpeg"
|
||||
|
||||
|
||||
# MARK: /rss/reward-campaigns/
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
"""Management command to backfill image dimensions for existing cached images."""
|
||||
import contextlib
|
||||
import mimetypes
|
||||
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
|
|
@ -24,6 +25,14 @@ class Command(BaseCommand):
|
|||
try:
|
||||
# Opening the file and saving triggers dimension calculation
|
||||
game.box_art_file.open()
|
||||
|
||||
# populate size and mime if available
|
||||
with contextlib.suppress(Exception):
|
||||
game.box_art_size_bytes = game.box_art_file.size
|
||||
mime, _ = mimetypes.guess_type(game.box_art_file.name or "")
|
||||
if mime:
|
||||
game.box_art_mime_type = mime
|
||||
|
||||
game.save()
|
||||
total_updated += 1
|
||||
self.stdout.write(self.style.SUCCESS(f" Updated {game}"))
|
||||
|
|
@ -36,6 +45,13 @@ class Command(BaseCommand):
|
|||
if campaign.image_file and not campaign.image_width:
|
||||
try:
|
||||
campaign.image_file.open()
|
||||
|
||||
with contextlib.suppress(Exception):
|
||||
campaign.image_size_bytes = campaign.image_file.size
|
||||
mime, _ = mimetypes.guess_type(campaign.image_file.name or "")
|
||||
if mime:
|
||||
campaign.image_mime_type = mime
|
||||
|
||||
campaign.save()
|
||||
total_updated += 1
|
||||
self.stdout.write(self.style.SUCCESS(f" Updated {campaign}"))
|
||||
|
|
|
|||
|
|
@ -0,0 +1,36 @@
|
|||
# Generated by Django 6.0.3 on 2026-03-09 18:33
|
||||
|
||||
from django.db import migrations
|
||||
from django.db import models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
"""Add box art MIME type and size fields to Game, and backfill existing data."""
|
||||
|
||||
dependencies = [
|
||||
("twitch", "0012_dropcampaign_operation_names_gin_index"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="game",
|
||||
name="box_art_mime_type",
|
||||
field=models.CharField(
|
||||
blank=True,
|
||||
default="",
|
||||
editable=False,
|
||||
help_text="MIME type of the cached box art image (e.g., 'image/png').",
|
||||
max_length=50,
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="game",
|
||||
name="box_art_size_bytes",
|
||||
field=models.PositiveIntegerField(
|
||||
blank=True,
|
||||
editable=False,
|
||||
help_text="File size of the cached box art image in bytes.",
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
]
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
# Generated by Django 6.0.3 on 2026-03-09 18:35
|
||||
|
||||
from django.db import migrations
|
||||
from django.db import models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
"""Add image MIME type and size fields to DropCampaign, and backfill existing data."""
|
||||
|
||||
dependencies = [
|
||||
("twitch", "0013_game_box_art_mime_type_game_box_art_size_bytes"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="dropcampaign",
|
||||
name="image_mime_type",
|
||||
field=models.CharField(
|
||||
blank=True,
|
||||
default="",
|
||||
editable=False,
|
||||
help_text="MIME type of the cached campaign image (e.g., 'image/png').",
|
||||
max_length=50,
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="dropcampaign",
|
||||
name="image_size_bytes",
|
||||
field=models.PositiveIntegerField(
|
||||
blank=True,
|
||||
editable=False,
|
||||
help_text="File size of the cached campaign image in bytes.",
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
]
|
||||
|
|
@ -115,6 +115,19 @@ class Game(auto_prefetch.Model):
|
|||
editable=False,
|
||||
help_text="Height of cached box art image in pixels.",
|
||||
)
|
||||
box_art_size_bytes = models.PositiveIntegerField(
|
||||
null=True,
|
||||
blank=True,
|
||||
editable=False,
|
||||
help_text="File size of the cached box art image in bytes.",
|
||||
)
|
||||
box_art_mime_type = models.CharField(
|
||||
max_length=50,
|
||||
blank=True,
|
||||
default="",
|
||||
editable=False,
|
||||
help_text="MIME type of the cached box art image (e.g., 'image/png').",
|
||||
)
|
||||
|
||||
owners = models.ManyToManyField(
|
||||
Organization,
|
||||
|
|
@ -357,6 +370,19 @@ class DropCampaign(auto_prefetch.Model):
|
|||
editable=False,
|
||||
help_text="Height of cached image in pixels.",
|
||||
)
|
||||
image_size_bytes = models.PositiveIntegerField(
|
||||
null=True,
|
||||
blank=True,
|
||||
editable=False,
|
||||
help_text="File size of the cached campaign image in bytes.",
|
||||
)
|
||||
image_mime_type = models.CharField(
|
||||
max_length=50,
|
||||
blank=True,
|
||||
default="",
|
||||
editable=False,
|
||||
help_text="MIME type of the cached campaign image (e.g., 'image/png').",
|
||||
)
|
||||
start_at = models.DateTimeField(
|
||||
null=True,
|
||||
blank=True,
|
||||
|
|
|
|||
|
|
@ -6,10 +6,15 @@ from datetime import timedelta
|
|||
from typing import TYPE_CHECKING
|
||||
|
||||
import pytest
|
||||
from django.core.files.base import ContentFile
|
||||
from django.core.management import call_command
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from hypothesis.extra.django import TestCase
|
||||
|
||||
from twitch.feeds import DropCampaignFeed
|
||||
from twitch.feeds import GameCampaignFeed
|
||||
from twitch.feeds import GameFeed
|
||||
from twitch.models import Channel
|
||||
from twitch.models import ChatBadge
|
||||
from twitch.models import ChatBadgeSet
|
||||
|
|
@ -51,6 +56,19 @@ class RSSFeedTestCase(TestCase):
|
|||
operation_names=["DropCampaignDetails"],
|
||||
)
|
||||
|
||||
# populate the new enclosure metadata fields so feeds can return them
|
||||
self.game.box_art_size_bytes = 42
|
||||
self.game.box_art_mime_type = "image/png"
|
||||
# provide a URL so that the RSS enclosure element is emitted
|
||||
self.game.box_art = "https://example.com/box.png"
|
||||
self.game.save()
|
||||
|
||||
self.campaign.image_size_bytes = 314
|
||||
self.campaign.image_mime_type = "image/gif"
|
||||
# feed will only include an enclosure if there is some image URL/field
|
||||
self.campaign.image_url = "https://example.com/campaign.png"
|
||||
self.campaign.save()
|
||||
|
||||
def test_organization_feed(self) -> None:
|
||||
"""Test organization feed returns 200."""
|
||||
url: str = reverse("twitch:organization_feed")
|
||||
|
|
@ -73,6 +91,16 @@ class RSSFeedTestCase(TestCase):
|
|||
)
|
||||
assert expected_rss_link in content
|
||||
|
||||
# enclosure metadata from our new fields should be present
|
||||
assert 'length="42"' in content
|
||||
assert 'type="image/png"' in content
|
||||
|
||||
def test_game_feed_enclosure_helpers(self) -> None:
|
||||
"""Helper methods should return values from model fields."""
|
||||
feed = GameFeed()
|
||||
assert feed.item_enclosure_length(self.game) == 42
|
||||
assert feed.item_enclosure_mime_type(self.game) == "image/png"
|
||||
|
||||
def test_campaign_feed(self) -> None:
|
||||
"""Test campaign feed returns 200."""
|
||||
url: str = reverse("twitch:campaign_feed")
|
||||
|
|
@ -80,6 +108,17 @@ class RSSFeedTestCase(TestCase):
|
|||
assert response.status_code == 200
|
||||
assert response["Content-Type"] == "application/rss+xml; charset=utf-8"
|
||||
|
||||
content: str = response.content.decode("utf-8")
|
||||
# verify enclosure meta
|
||||
assert 'length="314"' in content
|
||||
assert 'type="image/gif"' in content
|
||||
|
||||
def test_campaign_feed_enclosure_helpers(self) -> None:
|
||||
"""Helper methods for campaigns should respect new fields."""
|
||||
feed = DropCampaignFeed()
|
||||
assert feed.item_enclosure_length(self.campaign) == 314
|
||||
assert feed.item_enclosure_mime_type(self.campaign) == "image/gif"
|
||||
|
||||
def test_campaign_feed_includes_badge_description(self) -> None:
|
||||
"""Badge benefit descriptions should be visible in the RSS drop summary."""
|
||||
drop: TimeBasedDrop = TimeBasedDrop.objects.create(
|
||||
|
|
@ -152,6 +191,60 @@ class RSSFeedTestCase(TestCase):
|
|||
# Should NOT contain other campaign
|
||||
assert "Other Campaign" not in content
|
||||
|
||||
# enclosure metadata also should appear here
|
||||
assert 'length="314"' in content
|
||||
assert 'type="image/gif"' in content
|
||||
|
||||
def test_game_campaign_feed_enclosure_helpers(self) -> None:
|
||||
"""GameCampaignFeed helper methods should pull from the model fields."""
|
||||
feed = GameCampaignFeed()
|
||||
assert feed.item_enclosure_length(self.campaign) == 314
|
||||
assert feed.item_enclosure_mime_type(self.campaign) == "image/gif"
|
||||
|
||||
def test_backfill_command_sets_metadata(self) -> None:
|
||||
"""Running the backfill command should populate size and mime fields.
|
||||
|
||||
We create a fake file for both a game and a campaign and then execute the
|
||||
management command. Afterward, the corresponding metadata fields should
|
||||
be filled in.
|
||||
"""
|
||||
# create game with local file
|
||||
game2: Game = Game.objects.create(
|
||||
twitch_id="file-game",
|
||||
slug="file-game",
|
||||
name="File Game",
|
||||
display_name="File Game",
|
||||
)
|
||||
game2.box_art_file.save("sample.png", ContentFile(b"hello"))
|
||||
game2.save()
|
||||
|
||||
campaign2: DropCampaign = DropCampaign.objects.create(
|
||||
twitch_id="file-camp",
|
||||
name="File Campaign",
|
||||
game=self.game,
|
||||
start_at=timezone.now(),
|
||||
end_at=timezone.now() + timedelta(days=1),
|
||||
operation_names=["DropCampaignDetails"],
|
||||
)
|
||||
campaign2.image_file.save("camp.jpg", ContentFile(b"world"))
|
||||
campaign2.save()
|
||||
|
||||
# ensure fields are not populated initially
|
||||
assert campaign2.image_size_bytes is None
|
||||
assert not campaign2.image_mime_type
|
||||
assert game2.box_art_size_bytes is None
|
||||
assert not game2.box_art_mime_type
|
||||
|
||||
call_command("backfill_image_dimensions")
|
||||
|
||||
campaign2.refresh_from_db()
|
||||
game2.refresh_from_db()
|
||||
|
||||
assert campaign2.image_size_bytes == len(b"world")
|
||||
assert campaign2.image_mime_type == "image/jpeg"
|
||||
assert game2.box_art_size_bytes == len(b"hello")
|
||||
assert game2.box_art_mime_type == "image/png"
|
||||
|
||||
|
||||
QueryAsserter = Callable[..., AbstractContextManager[object]]
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue