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",
"dropcampaign",
"elif",
"Facepunch",
"filterwarnings",
"granian",
"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 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],
)

View file

@ -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

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.",
)
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:

View file

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

View file

@ -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)

View file

@ -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(

View file

@ -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

2
uv.lock generated
View file

@ -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" },