From df2941cdbc9228c26fd938fbf91dc77d5dbe468d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20Hells=C3=A9n?= Date: Fri, 9 Jan 2026 21:47:52 +0100 Subject: [PATCH] Fix owners not getting imported correctly --- pyproject.toml | 2 +- .../commands/better_import_drops.py | 23 ++++-- twitch/schemas.py | 2 +- twitch/tests/test_feeds.py | 3 + twitch/tests/test_game_owner_organization.py | 75 +++++++++++++++++++ twitch/tests/test_views.py | 3 + uv.lock | 39 ++++++++++ 7 files changed, 138 insertions(+), 9 deletions(-) create mode 100644 twitch/tests/test_game_owner_organization.py diff --git a/pyproject.toml b/pyproject.toml index d19a25a..2fb61d6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,7 +20,7 @@ dependencies = [ ] [dependency-groups] -dev = ["pytest", "pytest-django", "djlint"] +dev = ["pytest", "pytest-django", "djlint", "django-stubs"] [tool.pytest.ini_options] DJANGO_SETTINGS_MODULE = "config.settings" diff --git a/twitch/management/commands/better_import_drops.py b/twitch/management/commands/better_import_drops.py index f6ebede..42864cf 100644 --- a/twitch/management/commands/better_import_drops.py +++ b/twitch/management/commands/better_import_drops.py @@ -432,25 +432,34 @@ class Command(BaseCommand): def _get_or_create_game( self, game_data: GameSchema, - org_obj: Organization, + campaign_org_obj: Organization, ) -> Game: - """Get or create a game from cache or database. + """Get or create a game from cache or database, using correct owner organization. Args: game_data: Game data from Pydantic model. - org_obj: Organization that owns this game. + campaign_org_obj: Organization that owns the campaign (fallback). Returns: Game instance. """ + # Determine correct owner organization for the game + owner_org_obj = campaign_org_obj + if hasattr(game_data, "owner_organization") and game_data.owner_organization: + owner_org_data = game_data.owner_organization + if isinstance(owner_org_data, dict): + # Convert dict to OrganizationSchema + owner_org_data = OrganizationSchema.model_validate(owner_org_data) + owner_org_obj = self._get_or_create_organization(owner_org_data) + if game_data.twitch_id in self.game_cache: game_obj: Game = self.game_cache[game_data.twitch_id] update_fields: list[str] = [] # Ensure owner is correct without triggering a read - if game_obj.owner_id != org_obj.pk: # type: ignore[attr-defined] - game_obj.owner = org_obj + if game_obj.owner_id != owner_org_obj.pk: # type: ignore[attr-defined] + game_obj.owner = owner_org_obj update_fields.append("owner") # Persist normalized display name when provided @@ -485,7 +494,7 @@ class Command(BaseCommand): "name": game_data.name or "", "slug": game_data.slug or "", "box_art": game_data.box_art_url or "", - "owner": org_obj, + "owner": owner_org_obj, }, ) if created: @@ -631,7 +640,7 @@ class Command(BaseCommand): game_obj: Game = self._get_or_create_game( game_data=drop_campaign.game, - org_obj=org_obj, + campaign_org_obj=org_obj, ) start_at_dt: datetime | None = parse_date(drop_campaign.start_at) diff --git a/twitch/schemas.py b/twitch/schemas.py index 4e3884c..2734af5 100644 --- a/twitch/schemas.py +++ b/twitch/schemas.py @@ -35,6 +35,7 @@ class GameSchema(BaseModel): slug: str | None = None # Present in Inventory format name: str | None = None # Present in Inventory format (alternative to displayName) type_name: Literal["Game"] = Field(alias="__typename") # Present in both formats + owner_organization: dict | None = Field(default=None, alias="ownerOrganization") model_config = { "extra": "forbid", @@ -58,7 +59,6 @@ class GameSchema(BaseModel): """ if isinstance(data, dict) and "displayName" not in data and "name" in data: data["displayName"] = data["name"] - return data diff --git a/twitch/tests/test_feeds.py b/twitch/tests/test_feeds.py index dc175f1..e760d47 100644 --- a/twitch/tests/test_feeds.py +++ b/twitch/tests/test_feeds.py @@ -35,6 +35,7 @@ class RSSFeedTestCase(TestCase): game=self.game, start_at=timezone.now(), end_at=timezone.now() + timedelta(days=7), + operation_name="DropCampaignDetails", ) def test_organization_feed(self) -> None: @@ -94,6 +95,7 @@ class RSSFeedTestCase(TestCase): game=other_game, start_at=timezone.now(), end_at=timezone.now() + timedelta(days=7), + operation_name="DropCampaignDetails", ) # Get feed for first game @@ -126,6 +128,7 @@ class RSSFeedTestCase(TestCase): game=other_game, start_at=timezone.now(), end_at=timezone.now() + timedelta(days=7), + operation_name="DropCampaignDetails", ) # Get feed for first organization diff --git a/twitch/tests/test_game_owner_organization.py b/twitch/tests/test_game_owner_organization.py new file mode 100644 index 0000000..e949767 --- /dev/null +++ b/twitch/tests/test_game_owner_organization.py @@ -0,0 +1,75 @@ +from __future__ import annotations + +from pathlib import Path + +from django.test import TestCase + +from twitch.management.commands.better_import_drops import Command +from twitch.models import Game +from twitch.models import Organization + + +class GameOwnerOrganizationTests(TestCase): + """Tests for correct precedence of game owner organization during import.""" + + def test_game_owner_organization_precedence(self) -> None: + """If both owner and ownerOrganization are present, game owner should be ownerOrganization.""" + command = Command() + command.pre_fill_cache() + + payload = { + "data": { + "user": { + "id": "17658559", + "dropCampaign": { + "id": "test-campaign-1", + "name": "Rustmas 2025", + "description": "Test campaign desc", + "startAt": "2025-12-08T18:00:00Z", + "endAt": "2026-01-01T07:59:59.999Z", + "accountLinkURL": "https://www.twitch.tv/", + "detailsURL": "https://help.twitch.tv/s/article/twitch-chat-badges-guide", + "imageURL": "https://static-cdn.jtvnw.net/twitch-quests-assets/CAMPAIGN/495ebb6b-8134-4e51-b9d0-1f4a221b4f8d.png", + "status": "ACTIVE", + "self": {"isAccountConnected": True, "__typename": "DropCampaignSelfEdge"}, + "game": { + "id": "263490", + "slug": "rust", + "displayName": "Rust", + "__typename": "Game", + "ownerOrganization": { + "id": "d32de13d-937e-4196-8198-1a7f875f295a", + "name": "Twitch Gaming", + "__typename": "Organization", + }, + }, + "owner": {"id": "other-org-id", "name": "Other Org", "__typename": "Organization"}, + "timeBasedDrops": [], + "eventBasedDrops": [], + "allow": {"channels": None, "isEnabled": False, "__typename": "DropCampaignACL"}, + "__typename": "DropCampaign", + }, + "__typename": "User", + }, + }, + "extensions": {"operationName": "DropCampaignDetails"}, + } + + # Run import logic + success, broken_dir = command.process_responses( + responses=[payload], + file_path=Path("test_owner_org.json"), + options={}, + ) + assert success is True + assert broken_dir is None + + # Check game owner is Twitch Gaming, not Other Org + game: Game = Game.objects.get(twitch_id="263490") + org: Organization = Organization.objects.get(twitch_id="d32de13d-937e-4196-8198-1a7f875f295a") + assert game.owner == org + assert game.owner + assert game.owner.name == "Twitch Gaming" + + # Check both organizations exist + Organization.objects.get(twitch_id="other-org-id") diff --git a/twitch/tests/test_views.py b/twitch/tests/test_views.py index f24f802..634fe9a 100644 --- a/twitch/tests/test_views.py +++ b/twitch/tests/test_views.py @@ -42,6 +42,7 @@ class TestSearchView: name="Test Campaign", description="A test campaign", game=game, + operation_name="DropCampaignDetails", ) drop: TimeBasedDrop = TimeBasedDrop.objects.create( twitch_id="1011", @@ -262,6 +263,7 @@ class TestChannelListView: twitch_id=f"campaign{i}", name=f"Campaign {i}", game=game, + operation_name="DropCampaignDetails", ) campaign.allow_channels.add(channel) campaigns.append(campaign) @@ -350,6 +352,7 @@ class TestChannelListView: twitch_id=f"campaign_ch2_{i}", name=f"Campaign Ch2 {i}", game=game, + operation_name="DropCampaignDetails", ) campaign.allow_channels.add(channel2) diff --git a/uv.lock b/uv.lock index 93df15c..53e5a7b 100644 --- a/uv.lock +++ b/uv.lock @@ -118,6 +118,34 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6d/72/685c978af45ad08257e2c69687a873eda6b6531c79b6e6091794c41c5ff6/django_debug_toolbar-6.1.0-py3-none-any.whl", hash = "sha256:e214dea4494087e7cebdcea84223819c5eb97f9de3110a3665ad673f0ba98413", size = 269069, upload-time = "2025-10-30T19:50:37.71Z" }, ] +[[package]] +name = "django-stubs" +version = "5.2.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "django" }, + { name = "django-stubs-ext" }, + { name = "types-pyyaml" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6c/75/97626224fd8f1787bb6f7f06944efcfddd5da7764bf741cf7f59d102f4a0/django_stubs-5.2.8.tar.gz", hash = "sha256:9bba597c9a8ed8c025cae4696803d5c8be1cf55bfc7648a084cbf864187e2f8b", size = 257709, upload-time = "2025-12-01T08:13:09.569Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7d/3f/7c9543ad5ade5ce1d33d187a3abd82164570314ebee72c6206ab5c044ebf/django_stubs-5.2.8-py3-none-any.whl", hash = "sha256:a3c63119fd7062ac63d58869698d07c9e5ec0561295c4e700317c54e8d26716c", size = 508136, upload-time = "2025-12-01T08:13:07.963Z" }, +] + +[[package]] +name = "django-stubs-ext" +version = "5.2.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "django" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/14/a2/d67f4a5200ff7626b104eddceaf529761cba4ed318a73ffdb0677551be73/django_stubs_ext-5.2.8.tar.gz", hash = "sha256:b39938c46d7a547cd84e4a6378dbe51a3dd64d70300459087229e5fee27e5c6b", size = 6487, upload-time = "2025-12-01T08:12:37.486Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/2d/cb0151b780c3730cf0f2c0fcb1b065a5e88f877cf7a9217483c375353af1/django_stubs_ext-5.2.8-py3-none-any.whl", hash = "sha256:1dd5470c9675591362c78a157a3cf8aec45d0e7a7f0cf32f227a1363e54e0652", size = 9949, upload-time = "2025-12-01T08:12:36.397Z" }, +] + [[package]] name = "djlint" version = "1.36.4" @@ -515,6 +543,7 @@ dependencies = [ [package.dev-dependencies] dev = [ + { name = "django-stubs" }, { name = "djlint" }, { name = "pytest" }, { name = "pytest-django" }, @@ -538,11 +567,21 @@ requires-dist = [ [package.metadata.requires-dev] dev = [ + { name = "django-stubs", specifier = ">=5.2.8" }, { name = "djlint" }, { name = "pytest" }, { name = "pytest-django" }, ] +[[package]] +name = "types-pyyaml" +version = "6.0.12.20250915" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/69/3c51b36d04da19b92f9e815be12753125bd8bc247ba0470a982e6979e71c/types_pyyaml-6.0.12.20250915.tar.gz", hash = "sha256:0f8b54a528c303f0e6f7165687dd33fafa81c807fcac23f632b63aa624ced1d3", size = 17522, upload-time = "2025-09-15T03:01:00.728Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bd/e0/1eed384f02555dde685fff1a1ac805c1c7dcb6dd019c916fe659b1c1f9ec/types_pyyaml-6.0.12.20250915-py3-none-any.whl", hash = "sha256:e7d4d9e064e89a3b3cae120b4990cd370874d2bf12fa5f46c97018dd5d3c9ab6", size = 20338, upload-time = "2025-09-15T03:00:59.218Z" }, +] + [[package]] name = "typing-extensions" version = "4.15.0"