Compare commits
No commits in common. "5d95038faf66b5bd798fb8a384fe1fd10cd2cd9f" and "42dbea74018ece88ed3e84bd4c06676e1cb17cb3" have entirely different histories.
5d95038faf
...
42dbea7401
7 changed files with 26 additions and 265 deletions
|
|
@ -497,27 +497,16 @@ class GameFeed(Feed):
|
||||||
return box_art
|
return box_art
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
def item_enclosure_length(self, item: Game) -> int:
|
def item_enclosure_length(self, item: Game) -> int: # noqa: ARG002
|
||||||
"""Returns the length of the enclosure.
|
"""Returns the length of the enclosure."""
|
||||||
|
# TODO(TheLovinator): Track image size for proper length # noqa: TD003
|
||||||
|
|
||||||
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
|
return 0
|
||||||
|
|
||||||
def item_enclosure_mime_type(self, item: Game) -> str:
|
def item_enclosure_mime_type(self, item: Game) -> str: # noqa: ARG002
|
||||||
"""Returns the MIME type of the enclosure.
|
"""Returns the MIME type of the enclosure."""
|
||||||
|
# TODO(TheLovinator): Determine actual MIME type if needed # noqa: TD003
|
||||||
Use the ``box_art_mime_type`` field when available, otherwise fall back
|
return "image/jpeg"
|
||||||
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/
|
||||||
|
|
@ -644,26 +633,15 @@ 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:
|
def item_enclosure_length(self, item: DropCampaign) -> int: # noqa: ARG002
|
||||||
"""Returns the length of the enclosure.
|
"""Returns the length of the enclosure."""
|
||||||
|
# TODO(TheLovinator): Track image size for proper length # noqa: TD003
|
||||||
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
|
return 0
|
||||||
|
|
||||||
def item_enclosure_mime_type(self, item: DropCampaign) -> str:
|
def item_enclosure_mime_type(self, item: DropCampaign) -> str: # noqa: ARG002
|
||||||
"""Returns the MIME type of the enclosure.
|
"""Returns the MIME type of the enclosure."""
|
||||||
|
# TODO(TheLovinator): Determine actual MIME type if needed # noqa: TD003
|
||||||
Uses `image_mime_type` on the campaign if set, falling back to the
|
return "image/jpeg"
|
||||||
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/
|
||||||
|
|
@ -823,26 +801,16 @@ 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:
|
def item_enclosure_length(self, item: DropCampaign) -> int: # noqa: ARG002
|
||||||
"""Returns the length of the enclosure.
|
"""Returns the length of the enclosure."""
|
||||||
|
# TODO(TheLovinator): Track image size for proper length # noqa: TD003
|
||||||
|
|
||||||
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
|
return 0
|
||||||
|
|
||||||
def item_enclosure_mime_type(self, item: DropCampaign) -> str:
|
def item_enclosure_mime_type(self, item: DropCampaign) -> str: # noqa: ARG002
|
||||||
"""Returns the MIME type of the enclosure.
|
"""Returns the MIME type of the enclosure."""
|
||||||
|
# TODO(TheLovinator): Determine actual MIME type if needed # noqa: TD003
|
||||||
Prefers `image_mime_type` on the campaign object; falls back to
|
return "image/jpeg"
|
||||||
`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,5 +1,4 @@
|
||||||
import contextlib
|
"""Management command to backfill image dimensions for existing cached images."""
|
||||||
import mimetypes
|
|
||||||
|
|
||||||
from django.core.management.base import BaseCommand
|
from django.core.management.base import BaseCommand
|
||||||
|
|
||||||
|
|
@ -25,14 +24,6 @@ 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}"))
|
||||||
|
|
@ -45,13 +36,6 @@ 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}"))
|
||||||
|
|
|
||||||
|
|
@ -1,36 +0,0 @@
|
||||||
# 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,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
|
@ -1,36 +0,0 @@
|
||||||
# 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,19 +115,6 @@ 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,
|
||||||
|
|
@ -370,19 +357,6 @@ 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,15 +6,10 @@ 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
|
||||||
|
|
@ -56,19 +51,6 @@ 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")
|
||||||
|
|
@ -91,16 +73,6 @@ 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")
|
||||||
|
|
@ -108,17 +80,6 @@ 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(
|
||||||
|
|
@ -191,60 +152,6 @@ 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