Refactor game ownership to support multiple organizations and update related logic
This commit is contained in:
parent
df2941cdbc
commit
99e7b40535
10 changed files with 99 additions and 64 deletions
1
.vscode/settings.json
vendored
1
.vscode/settings.json
vendored
|
|
@ -18,6 +18,7 @@
|
|||
"dotenv",
|
||||
"dropcampaign",
|
||||
"elif",
|
||||
"Facepunch",
|
||||
"filterwarnings",
|
||||
"granian",
|
||||
"gunicorn",
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
),
|
||||
]
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
2
uv.lock
generated
|
|
@ -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" },
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue