diff --git a/.vscode/settings.json b/.vscode/settings.json index c96dc5f..49b53c1 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -18,6 +18,7 @@ "dotenv", "dropcampaign", "elif", + "Facepunch", "filterwarnings", "granian", "gunicorn", diff --git a/twitch/feeds.py b/twitch/feeds.py index a022b33..a172e50 100644 --- a/twitch/feeds.py +++ b/twitch/feeds.py @@ -654,7 +654,7 @@ class OrganizationCampaignFeed(Feed): """Return the latest 100 drop campaigns for this organization, ordered by most recently added.""" return list( DropCampaign.objects - .filter(game__owner=obj, operation_name="DropCampaignDetails") + .filter(game__owners=obj, operation_name="DropCampaignDetails") .select_related("game") .order_by("-added_at")[:100], ) diff --git a/twitch/management/commands/better_import_drops.py b/twitch/management/commands/better_import_drops.py index 42864cf..5820de0 100644 --- a/twitch/management/commands/better_import_drops.py +++ b/twitch/management/commands/better_import_drops.py @@ -443,48 +443,43 @@ class Command(BaseCommand): Returns: Game instance. """ - # Determine correct owner organization for the game - owner_org_obj = campaign_org_obj + # Collect all possible owner organizations + owner_orgs = set() 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) + owner_orgs.add(self._get_or_create_organization(owner_org_data)) + # Always add campaign_org_obj as fallback + if campaign_org_obj: + owner_orgs.add(campaign_org_obj) 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 != owner_org_obj.pk: # type: ignore[attr-defined] - game_obj.owner = owner_org_obj - update_fields.append("owner") - + # Update owners (ManyToMany) + current_owners = set(game_obj.owners.all()) + new_owners = owner_orgs - current_owners + if new_owners: + game_obj.owners.add(*new_owners) # Persist normalized display name when provided if game_data.display_name and game_obj.display_name != game_data.display_name: game_obj.display_name = game_data.display_name update_fields.append("display_name") - # Persist canonical name when provided (Inventory format) if game_data.name and game_obj.name != game_data.name: game_obj.name = game_data.name update_fields.append("name") - # Persist slug when provided by API (Inventory and DropCampaignDetails) if game_data.slug is not None and game_obj.slug != (game_data.slug or ""): game_obj.slug = game_data.slug or "" update_fields.append("slug") - # Persist box art URL when provided if game_data.box_art_url is not None and game_obj.box_art != (game_data.box_art_url or ""): game_obj.box_art = game_data.box_art_url or "" update_fields.append("box_art") - if update_fields: game_obj.save(update_fields=update_fields) - return game_obj game_obj, created = Game.objects.update_or_create( @@ -494,12 +489,13 @@ class Command(BaseCommand): "name": game_data.name or "", "slug": game_data.slug or "", "box_art": game_data.box_art_url or "", - "owner": owner_org_obj, }, ) + # Set owners (ManyToMany) + if created or owner_orgs: + game_obj.owners.add(*owner_orgs) if created: tqdm.write(f"{Fore.GREEN}✓{Style.RESET_ALL} Created new game: {game_data.display_name}") - self.game_cache[game_data.twitch_id] = game_obj return game_obj diff --git a/twitch/migrations/0004_remove_game_twitch_game_owner_i_398fa9_idx_and_more.py b/twitch/migrations/0004_remove_game_twitch_game_owner_i_398fa9_idx_and_more.py new file mode 100644 index 0000000..00db410 --- /dev/null +++ b/twitch/migrations/0004_remove_game_twitch_game_owner_i_398fa9_idx_and_more.py @@ -0,0 +1,42 @@ +# Generated by Django 6.0.1 on 2026-01-09 20:52 +from __future__ import annotations + +from django.db import migrations +from django.db import models + + +class Migration(migrations.Migration): + """Allow multiple organizations to own a game. + + For example, Rust is owned by both Facepunch Studios and Twitch Gaming. + """ + + dependencies = [ + ("twitch", "0003_remove_dropcampaign_twitch_drop_is_acco_7e9078_idx_and_more"), + ] + + operations = [ + migrations.RemoveIndex( + model_name="game", + name="twitch_game_owner_i_398fa9_idx", + ), + migrations.RemoveIndex( + model_name="game", + name="twitch_game_owner_i_7f9043_idx", + ), + migrations.AddField( + model_name="game", + name="owners", + field=models.ManyToManyField( + blank=True, + help_text="Organizations that own this game.", + related_name="games", + to="twitch.organization", + verbose_name="Organizations", + ), + ), + migrations.RemoveField( + model_name="game", + name="owner", + ), + ] diff --git a/twitch/models.py b/twitch/models.py index 56dd611..bac55bd 100644 --- a/twitch/models.py +++ b/twitch/models.py @@ -92,14 +92,12 @@ class Game(models.Model): help_text="Locally cached box art image served from this site.", ) - owner = models.ForeignKey( + owners = models.ManyToManyField( Organization, - on_delete=models.SET_NULL, related_name="games", - null=True, blank=True, - verbose_name="Organization", - help_text="The organization that owns this game.", + verbose_name="Organizations", + help_text="Organizations that own this game.", ) added_at = models.DateTimeField( @@ -118,11 +116,10 @@ class Game(models.Model): models.Index(fields=["name"]), models.Index(fields=["slug"]), models.Index(fields=["twitch_id"]), - models.Index(fields=["owner"]), models.Index(fields=["added_at"]), models.Index(fields=["updated_at"]), - # For games_grid_view grouping by owner + display_name - models.Index(fields=["owner", "display_name"]), + # For games_grid_view grouping by owners + display_name + # ManyToManyField does not support direct indexing, so skip these ] def __str__(self) -> str: diff --git a/twitch/tests/test_feeds.py b/twitch/tests/test_feeds.py index e760d47..4cf5949 100644 --- a/twitch/tests/test_feeds.py +++ b/twitch/tests/test_feeds.py @@ -27,8 +27,8 @@ class RSSFeedTestCase(TestCase): slug="test-game", name="Test Game", display_name="Test Game", - owner=self.org, ) + self.game.owners.add(self.org) self.campaign = DropCampaign.objects.create( twitch_id="test-campaign-123", name="Test Campaign", @@ -87,8 +87,8 @@ class RSSFeedTestCase(TestCase): slug="other-game", name="Other Game", display_name="Other Game", - owner=self.org, ) + other_game.owners.add(self.org) DropCampaign.objects.create( twitch_id="other-campaign-123", name="Other Campaign", @@ -120,8 +120,8 @@ class RSSFeedTestCase(TestCase): slug="other-game-2", name="Other Game 2", display_name="Other Game 2", - owner=other_org, ) + other_game.owners.add(other_org) DropCampaign.objects.create( twitch_id="other-campaign-456", name="Other Campaign 2", diff --git a/twitch/tests/test_game_owner_organization.py b/twitch/tests/test_game_owner_organization.py index e949767..af33e5d 100644 --- a/twitch/tests/test_game_owner_organization.py +++ b/twitch/tests/test_game_owner_organization.py @@ -64,12 +64,12 @@ class GameOwnerOrganizationTests(TestCase): assert success is True assert broken_dir is None - # Check game owner is Twitch Gaming, not Other Org + # Check game owners include Twitch Gaming and 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") + org1: Organization = Organization.objects.get(twitch_id="d32de13d-937e-4196-8198-1a7f875f295a") + org2: Organization = Organization.objects.get(twitch_id="other-org-id") + owners = list(game.owners.all()) + assert org1 in owners + assert org2 in owners + assert any(o.name == "Twitch Gaming" for o in owners) + assert any(o.name == "Other Org" for o in owners) diff --git a/twitch/tests/test_views.py b/twitch/tests/test_views.py index 634fe9a..d570527 100644 --- a/twitch/tests/test_views.py +++ b/twitch/tests/test_views.py @@ -35,8 +35,8 @@ class TestSearchView: twitch_id="456", name="test_game", display_name="Test Game", - owner=org, ) + game.owners.add(org) campaign: DropCampaign = DropCampaign.objects.create( twitch_id="789", name="Test Campaign", @@ -246,8 +246,8 @@ class TestChannelListView: twitch_id="game1", name="test_game", display_name="Test Game", - owner=org, ) + game.owners.add(org) # Create a channel channel: Channel = Channel.objects.create( diff --git a/twitch/views.py b/twitch/views.py index 62e9771..9e38108 100644 --- a/twitch/views.py +++ b/twitch/views.py @@ -211,7 +211,7 @@ def drop_campaign_list_view(request: HttpRequest) -> HttpResponse: if game_filter: queryset = queryset.filter(game__twitch_id=game_filter) - queryset = queryset.select_related("game__owner").order_by("-start_at") + queryset = queryset.prefetch_related("game__owners").order_by("-start_at") # Optionally filter by status (active, upcoming, expired) now = timezone.now() @@ -398,7 +398,7 @@ def drop_campaign_detail_view(request: HttpRequest, twitch_id: str) -> HttpRespo "now": now, "drops": enhanced_drops, "campaign_data": format_and_color_json(campaign_data[0]), - "owner": campaign.game.owner, + "owners": list(campaign.game.owners.all()), "allowed_channels": campaign.allow_channels.all().order_by("display_name"), } @@ -454,7 +454,7 @@ class GamesGridView(ListView): games_with_campaigns: QuerySet[Game] = ( Game.objects .filter(drop_campaigns__isnull=False) - .select_related("owner") + .prefetch_related("owners") .annotate( campaign_count=Count("drop_campaigns", distinct=True), active_count=Count( @@ -466,13 +466,13 @@ class GamesGridView(ListView): distinct=True, ), ) - .order_by("owner__name", "display_name") + .order_by("display_name") ) games_by_org: defaultdict[Organization, list[dict[str, Game]]] = defaultdict(list) for game in games_with_campaigns: - if game.owner: - games_by_org[game.owner].append({"game": game}) + for org in game.owners.all(): + games_by_org[org].append({"game": game}) context["games_by_org"] = OrderedDict( sorted(games_by_org.items(), key=lambda item: item[0].name), @@ -619,7 +619,7 @@ class GameDetailView(DetailView): "active_campaigns": active_campaigns, "upcoming_campaigns": upcoming_campaigns, "expired_campaigns": expired_campaigns, - "owner": game.owner, + "owners": list(game.owners.all()), "now": now, "game_data": format_and_color_json(game_data[0]), }, @@ -653,24 +653,23 @@ def dashboard(request: HttpRequest) -> HttpResponse: campaigns_by_org_game: OrderedDict[str, Any] = OrderedDict() for campaign in active_campaigns: - owner: Organization | None = campaign.game.owner + for owner in campaign.game.owners.all(): + org_id: str = owner.twitch_id if owner else "unknown" + org_name: str = owner.name if owner else "Unknown" + game_id: str = campaign.game.twitch_id + game_name: str = campaign.game.display_name - org_id: str = owner.twitch_id if owner else "unknown" - org_name: str = owner.name if owner else "Unknown" - game_id: str = campaign.game.twitch_id - game_name: str = campaign.game.display_name + if org_id not in campaigns_by_org_game: + campaigns_by_org_game[org_id] = {"name": org_name, "games": OrderedDict()} - if org_id not in campaigns_by_org_game: - campaigns_by_org_game[org_id] = {"name": org_name, "games": OrderedDict()} + if game_id not in campaigns_by_org_game[org_id]["games"]: + campaigns_by_org_game[org_id]["games"][game_id] = { + "name": game_name, + "box_art": campaign.game.box_art, + "campaigns": [], + } - if game_id not in campaigns_by_org_game[org_id]["games"]: - campaigns_by_org_game[org_id]["games"][game_id] = { - "name": game_name, - "box_art": campaign.game.box_art, - "campaigns": [], - } - - campaigns_by_org_game[org_id]["games"][game_id]["campaigns"].append(campaign) + campaigns_by_org_game[org_id]["games"][game_id]["campaigns"].append(campaign) return render( request, @@ -694,7 +693,7 @@ def debug_view(request: HttpRequest) -> HttpResponse: # Games with no assigned owner organization games_without_owner: QuerySet[Game] = Game.objects.filter( - owner__isnull=True, + owners__isnull=True, ).order_by("display_name") # Campaigns with missing or obviously broken images diff --git a/uv.lock b/uv.lock index 53e5a7b..26c896d 100644 --- a/uv.lock +++ b/uv.lock @@ -567,7 +567,7 @@ requires-dist = [ [package.metadata.requires-dev] dev = [ - { name = "django-stubs", specifier = ">=5.2.8" }, + { name = "django-stubs" }, { name = "djlint" }, { name = "pytest" }, { name = "pytest-django" },