Compare commits
2 commits
42dbea7401
...
5d95038faf
| Author | SHA1 | Date | |
|---|---|---|---|
|
5d95038faf |
|||
|
0d72d99b8f |
7 changed files with 265 additions and 26 deletions
|
|
@ -497,16 +497,27 @@ class GameFeed(Feed):
|
||||||
return box_art
|
return box_art
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
def item_enclosure_length(self, item: Game) -> int: # noqa: ARG002
|
def item_enclosure_length(self, item: Game) -> int:
|
||||||
"""Returns the length of the enclosure."""
|
"""Returns the length of the enclosure.
|
||||||
# TODO(TheLovinator): Track image size for proper length # noqa: TD003
|
|
||||||
|
|
||||||
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
|
def item_enclosure_mime_type(self, item: Game) -> str:
|
||||||
"""Returns the MIME type of the enclosure."""
|
"""Returns the MIME type of the enclosure.
|
||||||
# TODO(TheLovinator): Determine actual MIME type if needed # noqa: TD003
|
|
||||||
return "image/jpeg"
|
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/
|
# MARK: /rss/campaigns/
|
||||||
|
|
@ -633,15 +644,26 @@ class DropCampaignFeed(Feed):
|
||||||
"""Returns the URL of the campaign image for enclosure."""
|
"""Returns the URL of the campaign image for enclosure."""
|
||||||
return item.get_feed_enclosure_url()
|
return item.get_feed_enclosure_url()
|
||||||
|
|
||||||
def item_enclosure_length(self, item: DropCampaign) -> int: # noqa: ARG002
|
def item_enclosure_length(self, item: DropCampaign) -> int:
|
||||||
"""Returns the length of the enclosure."""
|
"""Returns the length of the enclosure.
|
||||||
# TODO(TheLovinator): Track image size for proper length # noqa: TD003
|
|
||||||
return 0
|
|
||||||
|
|
||||||
def item_enclosure_mime_type(self, item: DropCampaign) -> str: # noqa: ARG002
|
Reads the `image_size_bytes` field added to the model. If the field is
|
||||||
"""Returns the MIME type of the enclosure."""
|
unset it returns `0` to match previous behavior.
|
||||||
# TODO(TheLovinator): Determine actual MIME type if needed # noqa: TD003
|
"""
|
||||||
return "image/jpeg"
|
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/
|
# MARK: /rss/games/<twitch_id>/campaigns/
|
||||||
|
|
@ -801,16 +823,26 @@ class GameCampaignFeed(Feed):
|
||||||
"""Returns the URL of the campaign image for enclosure."""
|
"""Returns the URL of the campaign image for enclosure."""
|
||||||
return item.get_feed_enclosure_url()
|
return item.get_feed_enclosure_url()
|
||||||
|
|
||||||
def item_enclosure_length(self, item: DropCampaign) -> int: # noqa: ARG002
|
def item_enclosure_length(self, item: DropCampaign) -> int:
|
||||||
"""Returns the length of the enclosure."""
|
"""Returns the length of the enclosure.
|
||||||
# TODO(TheLovinator): Track image size for proper length # noqa: TD003
|
|
||||||
|
|
||||||
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
|
def item_enclosure_mime_type(self, item: DropCampaign) -> str:
|
||||||
"""Returns the MIME type of the enclosure."""
|
"""Returns the MIME type of the enclosure.
|
||||||
# TODO(TheLovinator): Determine actual MIME type if needed # noqa: TD003
|
|
||||||
return "image/jpeg"
|
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/
|
# 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
|
from django.core.management.base import BaseCommand
|
||||||
|
|
||||||
|
|
@ -24,6 +25,14 @@ class Command(BaseCommand):
|
||||||
try:
|
try:
|
||||||
# Opening the file and saving triggers dimension calculation
|
# Opening the file and saving triggers dimension calculation
|
||||||
game.box_art_file.open()
|
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()
|
game.save()
|
||||||
total_updated += 1
|
total_updated += 1
|
||||||
self.stdout.write(self.style.SUCCESS(f" Updated {game}"))
|
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:
|
if campaign.image_file and not campaign.image_width:
|
||||||
try:
|
try:
|
||||||
campaign.image_file.open()
|
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()
|
campaign.save()
|
||||||
total_updated += 1
|
total_updated += 1
|
||||||
self.stdout.write(self.style.SUCCESS(f" Updated {campaign}"))
|
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,
|
editable=False,
|
||||||
help_text="Height of cached box art image in pixels.",
|
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(
|
owners = models.ManyToManyField(
|
||||||
Organization,
|
Organization,
|
||||||
|
|
@ -357,6 +370,19 @@ class DropCampaign(auto_prefetch.Model):
|
||||||
editable=False,
|
editable=False,
|
||||||
help_text="Height of cached image in pixels.",
|
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(
|
start_at = models.DateTimeField(
|
||||||
null=True,
|
null=True,
|
||||||
blank=True,
|
blank=True,
|
||||||
|
|
|
||||||
|
|
@ -6,10 +6,15 @@ from datetime import timedelta
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
from django.core.files.base import ContentFile
|
||||||
|
from django.core.management import call_command
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from hypothesis.extra.django import TestCase
|
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 Channel
|
||||||
from twitch.models import ChatBadge
|
from twitch.models import ChatBadge
|
||||||
from twitch.models import ChatBadgeSet
|
from twitch.models import ChatBadgeSet
|
||||||
|
|
@ -51,6 +56,19 @@ class RSSFeedTestCase(TestCase):
|
||||||
operation_names=["DropCampaignDetails"],
|
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:
|
def test_organization_feed(self) -> None:
|
||||||
"""Test organization feed returns 200."""
|
"""Test organization feed returns 200."""
|
||||||
url: str = reverse("twitch:organization_feed")
|
url: str = reverse("twitch:organization_feed")
|
||||||
|
|
@ -73,6 +91,16 @@ class RSSFeedTestCase(TestCase):
|
||||||
)
|
)
|
||||||
assert expected_rss_link in content
|
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:
|
def test_campaign_feed(self) -> None:
|
||||||
"""Test campaign feed returns 200."""
|
"""Test campaign feed returns 200."""
|
||||||
url: str = reverse("twitch:campaign_feed")
|
url: str = reverse("twitch:campaign_feed")
|
||||||
|
|
@ -80,6 +108,17 @@ class RSSFeedTestCase(TestCase):
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert response["Content-Type"] == "application/rss+xml; charset=utf-8"
|
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:
|
def test_campaign_feed_includes_badge_description(self) -> None:
|
||||||
"""Badge benefit descriptions should be visible in the RSS drop summary."""
|
"""Badge benefit descriptions should be visible in the RSS drop summary."""
|
||||||
drop: TimeBasedDrop = TimeBasedDrop.objects.create(
|
drop: TimeBasedDrop = TimeBasedDrop.objects.create(
|
||||||
|
|
@ -152,6 +191,60 @@ class RSSFeedTestCase(TestCase):
|
||||||
# Should NOT contain other campaign
|
# Should NOT contain other campaign
|
||||||
assert "Other Campaign" not in content
|
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]]
|
QueryAsserter = Callable[..., AbstractContextManager[object]]
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -614,7 +614,7 @@ def dataset_backups_view(request: HttpRequest) -> HttpResponse:
|
||||||
"""
|
"""
|
||||||
# TODO(TheLovinator): Instead of only using sql we should also support other formats like parquet, csv, or json. # noqa: TD003
|
# TODO(TheLovinator): Instead of only using sql we should also support other formats like parquet, csv, or json. # noqa: TD003
|
||||||
# TODO(TheLovinator): Upload to s3 instead. # noqa: TD003
|
# TODO(TheLovinator): Upload to s3 instead. # noqa: TD003
|
||||||
|
# TODO(TheLovinator): https://developers.google.com/search/docs/appearance/structured-data/dataset#json-ld
|
||||||
datasets_root: Path = settings.DATA_DIR / "datasets"
|
datasets_root: Path = settings.DATA_DIR / "datasets"
|
||||||
search_dirs: list[Path] = [datasets_root]
|
search_dirs: list[Path] = [datasets_root]
|
||||||
seen_paths: set[str] = set()
|
seen_paths: set[str] = set()
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue