Refactor game ownership to support multiple organizations and update related logic

This commit is contained in:
Joakim Hellsén 2026-01-09 21:57:37 +01:00
commit 99e7b40535
No known key found for this signature in database
10 changed files with 99 additions and 64 deletions

View file

@ -18,6 +18,7 @@
"dotenv", "dotenv",
"dropcampaign", "dropcampaign",
"elif", "elif",
"Facepunch",
"filterwarnings", "filterwarnings",
"granian", "granian",
"gunicorn", "gunicorn",

View file

@ -654,7 +654,7 @@ class OrganizationCampaignFeed(Feed):
"""Return the latest 100 drop campaigns for this organization, ordered by most recently added.""" """Return the latest 100 drop campaigns for this organization, ordered by most recently added."""
return list( return list(
DropCampaign.objects DropCampaign.objects
.filter(game__owner=obj, operation_name="DropCampaignDetails") .filter(game__owners=obj, operation_name="DropCampaignDetails")
.select_related("game") .select_related("game")
.order_by("-added_at")[:100], .order_by("-added_at")[:100],
) )

View file

@ -443,48 +443,43 @@ class Command(BaseCommand):
Returns: Returns:
Game instance. Game instance.
""" """
# Determine correct owner organization for the game # Collect all possible owner organizations
owner_org_obj = campaign_org_obj owner_orgs = set()
if hasattr(game_data, "owner_organization") and game_data.owner_organization: if hasattr(game_data, "owner_organization") and game_data.owner_organization:
owner_org_data = game_data.owner_organization owner_org_data = game_data.owner_organization
if isinstance(owner_org_data, dict): if isinstance(owner_org_data, dict):
# Convert dict to OrganizationSchema
owner_org_data = OrganizationSchema.model_validate(owner_org_data) 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: if game_data.twitch_id in self.game_cache:
game_obj: Game = self.game_cache[game_data.twitch_id] game_obj: Game = self.game_cache[game_data.twitch_id]
update_fields: list[str] = [] update_fields: list[str] = []
# Update owners (ManyToMany)
# Ensure owner is correct without triggering a read current_owners = set(game_obj.owners.all())
if game_obj.owner_id != owner_org_obj.pk: # type: ignore[attr-defined] new_owners = owner_orgs - current_owners
game_obj.owner = owner_org_obj if new_owners:
update_fields.append("owner") game_obj.owners.add(*new_owners)
# Persist normalized display name when provided # Persist normalized display name when provided
if game_data.display_name and game_obj.display_name != game_data.display_name: if game_data.display_name and game_obj.display_name != game_data.display_name:
game_obj.display_name = game_data.display_name game_obj.display_name = game_data.display_name
update_fields.append("display_name") update_fields.append("display_name")
# Persist canonical name when provided (Inventory format) # Persist canonical name when provided (Inventory format)
if game_data.name and game_obj.name != game_data.name: if game_data.name and game_obj.name != game_data.name:
game_obj.name = game_data.name game_obj.name = game_data.name
update_fields.append("name") update_fields.append("name")
# Persist slug when provided by API (Inventory and DropCampaignDetails) # Persist slug when provided by API (Inventory and DropCampaignDetails)
if game_data.slug is not None and game_obj.slug != (game_data.slug or ""): if game_data.slug is not None and game_obj.slug != (game_data.slug or ""):
game_obj.slug = game_data.slug or "" game_obj.slug = game_data.slug or ""
update_fields.append("slug") update_fields.append("slug")
# Persist box art URL when provided # 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 ""): 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 "" game_obj.box_art = game_data.box_art_url or ""
update_fields.append("box_art") update_fields.append("box_art")
if update_fields: if update_fields:
game_obj.save(update_fields=update_fields) game_obj.save(update_fields=update_fields)
return game_obj return game_obj
game_obj, created = Game.objects.update_or_create( game_obj, created = Game.objects.update_or_create(
@ -494,12 +489,13 @@ class Command(BaseCommand):
"name": game_data.name or "", "name": game_data.name or "",
"slug": game_data.slug or "", "slug": game_data.slug or "",
"box_art": game_data.box_art_url 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: if created:
tqdm.write(f"{Fore.GREEN}{Style.RESET_ALL} Created new game: {game_data.display_name}") tqdm.write(f"{Fore.GREEN}{Style.RESET_ALL} Created new game: {game_data.display_name}")
self.game_cache[game_data.twitch_id] = game_obj self.game_cache[game_data.twitch_id] = game_obj
return game_obj return game_obj

View file

@ -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",
),
]

View file

@ -92,14 +92,12 @@ class Game(models.Model):
help_text="Locally cached box art image served from this site.", help_text="Locally cached box art image served from this site.",
) )
owner = models.ForeignKey( owners = models.ManyToManyField(
Organization, Organization,
on_delete=models.SET_NULL,
related_name="games", related_name="games",
null=True,
blank=True, blank=True,
verbose_name="Organization", verbose_name="Organizations",
help_text="The organization that owns this game.", help_text="Organizations that own this game.",
) )
added_at = models.DateTimeField( added_at = models.DateTimeField(
@ -118,11 +116,10 @@ class Game(models.Model):
models.Index(fields=["name"]), models.Index(fields=["name"]),
models.Index(fields=["slug"]), models.Index(fields=["slug"]),
models.Index(fields=["twitch_id"]), models.Index(fields=["twitch_id"]),
models.Index(fields=["owner"]),
models.Index(fields=["added_at"]), models.Index(fields=["added_at"]),
models.Index(fields=["updated_at"]), models.Index(fields=["updated_at"]),
# For games_grid_view grouping by owner + display_name # For games_grid_view grouping by owners + display_name
models.Index(fields=["owner", "display_name"]), # ManyToManyField does not support direct indexing, so skip these
] ]
def __str__(self) -> str: def __str__(self) -> str:

View file

@ -27,8 +27,8 @@ class RSSFeedTestCase(TestCase):
slug="test-game", slug="test-game",
name="Test Game", name="Test Game",
display_name="Test Game", display_name="Test Game",
owner=self.org,
) )
self.game.owners.add(self.org)
self.campaign = DropCampaign.objects.create( self.campaign = DropCampaign.objects.create(
twitch_id="test-campaign-123", twitch_id="test-campaign-123",
name="Test Campaign", name="Test Campaign",
@ -87,8 +87,8 @@ class RSSFeedTestCase(TestCase):
slug="other-game", slug="other-game",
name="Other Game", name="Other Game",
display_name="Other Game", display_name="Other Game",
owner=self.org,
) )
other_game.owners.add(self.org)
DropCampaign.objects.create( DropCampaign.objects.create(
twitch_id="other-campaign-123", twitch_id="other-campaign-123",
name="Other Campaign", name="Other Campaign",
@ -120,8 +120,8 @@ class RSSFeedTestCase(TestCase):
slug="other-game-2", slug="other-game-2",
name="Other Game 2", name="Other Game 2",
display_name="Other Game 2", display_name="Other Game 2",
owner=other_org,
) )
other_game.owners.add(other_org)
DropCampaign.objects.create( DropCampaign.objects.create(
twitch_id="other-campaign-456", twitch_id="other-campaign-456",
name="Other Campaign 2", name="Other Campaign 2",

View file

@ -64,12 +64,12 @@ class GameOwnerOrganizationTests(TestCase):
assert success is True assert success is True
assert broken_dir is None 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") game: Game = Game.objects.get(twitch_id="263490")
org: Organization = Organization.objects.get(twitch_id="d32de13d-937e-4196-8198-1a7f875f295a") org1: Organization = Organization.objects.get(twitch_id="d32de13d-937e-4196-8198-1a7f875f295a")
assert game.owner == org org2: Organization = Organization.objects.get(twitch_id="other-org-id")
assert game.owner owners = list(game.owners.all())
assert game.owner.name == "Twitch Gaming" assert org1 in owners
assert org2 in owners
# Check both organizations exist assert any(o.name == "Twitch Gaming" for o in owners)
Organization.objects.get(twitch_id="other-org-id") assert any(o.name == "Other Org" for o in owners)

View file

@ -35,8 +35,8 @@ class TestSearchView:
twitch_id="456", twitch_id="456",
name="test_game", name="test_game",
display_name="Test Game", display_name="Test Game",
owner=org,
) )
game.owners.add(org)
campaign: DropCampaign = DropCampaign.objects.create( campaign: DropCampaign = DropCampaign.objects.create(
twitch_id="789", twitch_id="789",
name="Test Campaign", name="Test Campaign",
@ -246,8 +246,8 @@ class TestChannelListView:
twitch_id="game1", twitch_id="game1",
name="test_game", name="test_game",
display_name="Test Game", display_name="Test Game",
owner=org,
) )
game.owners.add(org)
# Create a channel # Create a channel
channel: Channel = Channel.objects.create( channel: Channel = Channel.objects.create(

View file

@ -211,7 +211,7 @@ def drop_campaign_list_view(request: HttpRequest) -> HttpResponse:
if game_filter: if game_filter:
queryset = queryset.filter(game__twitch_id=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) # Optionally filter by status (active, upcoming, expired)
now = timezone.now() now = timezone.now()
@ -398,7 +398,7 @@ def drop_campaign_detail_view(request: HttpRequest, twitch_id: str) -> HttpRespo
"now": now, "now": now,
"drops": enhanced_drops, "drops": enhanced_drops,
"campaign_data": format_and_color_json(campaign_data[0]), "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"), "allowed_channels": campaign.allow_channels.all().order_by("display_name"),
} }
@ -454,7 +454,7 @@ class GamesGridView(ListView):
games_with_campaigns: QuerySet[Game] = ( games_with_campaigns: QuerySet[Game] = (
Game.objects Game.objects
.filter(drop_campaigns__isnull=False) .filter(drop_campaigns__isnull=False)
.select_related("owner") .prefetch_related("owners")
.annotate( .annotate(
campaign_count=Count("drop_campaigns", distinct=True), campaign_count=Count("drop_campaigns", distinct=True),
active_count=Count( active_count=Count(
@ -466,13 +466,13 @@ class GamesGridView(ListView):
distinct=True, distinct=True,
), ),
) )
.order_by("owner__name", "display_name") .order_by("display_name")
) )
games_by_org: defaultdict[Organization, list[dict[str, Game]]] = defaultdict(list) games_by_org: defaultdict[Organization, list[dict[str, Game]]] = defaultdict(list)
for game in games_with_campaigns: for game in games_with_campaigns:
if game.owner: for org in game.owners.all():
games_by_org[game.owner].append({"game": game}) games_by_org[org].append({"game": game})
context["games_by_org"] = OrderedDict( context["games_by_org"] = OrderedDict(
sorted(games_by_org.items(), key=lambda item: item[0].name), sorted(games_by_org.items(), key=lambda item: item[0].name),
@ -619,7 +619,7 @@ class GameDetailView(DetailView):
"active_campaigns": active_campaigns, "active_campaigns": active_campaigns,
"upcoming_campaigns": upcoming_campaigns, "upcoming_campaigns": upcoming_campaigns,
"expired_campaigns": expired_campaigns, "expired_campaigns": expired_campaigns,
"owner": game.owner, "owners": list(game.owners.all()),
"now": now, "now": now,
"game_data": format_and_color_json(game_data[0]), "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() campaigns_by_org_game: OrderedDict[str, Any] = OrderedDict()
for campaign in active_campaigns: 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" if org_id not in campaigns_by_org_game:
org_name: str = owner.name if owner else "Unknown" campaigns_by_org_game[org_id] = {"name": org_name, "games": OrderedDict()}
game_id: str = campaign.game.twitch_id
game_name: str = campaign.game.display_name
if org_id not in campaigns_by_org_game: if game_id not in campaigns_by_org_game[org_id]["games"]:
campaigns_by_org_game[org_id] = {"name": org_name, "games": OrderedDict()} 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]["campaigns"].append(campaign)
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)
return render( return render(
request, request,
@ -694,7 +693,7 @@ def debug_view(request: HttpRequest) -> HttpResponse:
# Games with no assigned owner organization # Games with no assigned owner organization
games_without_owner: QuerySet[Game] = Game.objects.filter( games_without_owner: QuerySet[Game] = Game.objects.filter(
owner__isnull=True, owners__isnull=True,
).order_by("display_name") ).order_by("display_name")
# Campaigns with missing or obviously broken images # Campaigns with missing or obviously broken images

2
uv.lock generated
View file

@ -567,7 +567,7 @@ requires-dist = [
[package.metadata.requires-dev] [package.metadata.requires-dev]
dev = [ dev = [
{ name = "django-stubs", specifier = ">=5.2.8" }, { name = "django-stubs" },
{ name = "djlint" }, { name = "djlint" },
{ name = "pytest" }, { name = "pytest" },
{ name = "pytest-django" }, { name = "pytest-django" },