diff --git a/.vscode/settings.json b/.vscode/settings.json index 7b7abcd..58c55f6 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -58,6 +58,7 @@ "thelovinator", "tqdm", "ttvdrops", + "twid", "venv", "wrongpassword", "wsgi", diff --git a/twitch/management/commands/better_import_drops.py b/twitch/management/commands/better_import_drops.py index 24b0308..f531d9f 100644 --- a/twitch/management/commands/better_import_drops.py +++ b/twitch/management/commands/better_import_drops.py @@ -438,7 +438,7 @@ class Command(BaseCommand): def _get_or_create_game( self, game_data: GameSchema, - campaign_org_obj: Organization, + campaign_org_obj: Organization | None, ) -> Game: """Get or create a game from cache or database, using correct owner organization. @@ -456,7 +456,7 @@ class Command(BaseCommand): if isinstance(owner_org_data, dict): owner_org_data = OrganizationSchema.model_validate(owner_org_data) owner_orgs.add(self._get_or_create_organization(owner_org_data)) - # Always add campaign_org_obj as fallback + # Add campaign organization as fallback only when provided if campaign_org_obj: owner_orgs.add(campaign_org_obj) @@ -628,17 +628,9 @@ class Command(BaseCommand): for drop_campaign in campaigns_to_process: # Handle campaigns without owner (e.g., from Inventory operation) owner_data: OrganizationSchema | None = getattr(drop_campaign, "owner", None) + org_obj: Organization | None = None if owner_data: - org_obj: Organization = self._get_or_create_organization( - org_data=owner_data, - ) - else: - # Create a default organization for campaigns without owner - org_obj, _ = Organization.objects.get_or_create( - twitch_id="unknown", - defaults={"name": "Unknown Organization"}, - ) - self.organization_cache["unknown"] = org_obj + org_obj = self._get_or_create_organization(org_data=owner_data) game_obj: Game = self._get_or_create_game( game_data=drop_campaign.game, diff --git a/twitch/management/commands/cleanup_unknown_organizations.py b/twitch/management/commands/cleanup_unknown_organizations.py new file mode 100644 index 0000000..f4aade6 --- /dev/null +++ b/twitch/management/commands/cleanup_unknown_organizations.py @@ -0,0 +1,120 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING +from typing import Any + +from colorama import Fore +from colorama import Style +from colorama import init as colorama_init +from django.core.management.base import BaseCommand +from django.core.management.base import CommandError +from django.core.management.base import CommandParser + +from twitch.models import Game +from twitch.models import Organization + +if TYPE_CHECKING: + from debug_toolbar.panels.templates.panel import QuerySet + + +class Command(BaseCommand): + """Detach the placeholder 'Unknown Organization' from games and optionally re-import data. + + This command removes ManyToMany relationships between the special + organization (default twitch_id='unknown') and any games it was attached to + as a fallback. Organizations in Twitch data are optional; this cleanup + ensures we don't over-assign a fake org. + """ + + help = "Detach 'Unknown Organization' from games and optionally re-import" + requires_migrations_checks = True + + def add_arguments(self, parser: CommandParser) -> None: + """Define CLI arguments for the command.""" + parser.add_argument( + "--org-id", + dest="org_id", + default="unknown", + help="Organization twitch_id to detach (default: 'unknown')", + ) + parser.add_argument( + "--dry-run", + action="store_true", + help="Do not modify the database; print what would change.", + ) + parser.add_argument( + "--delete-empty-org", + action="store_true", + help=( + "Delete the organization after detaching if it has no games remaining. " + "Only applies when not in --dry-run." + ), + ) + + def handle(self, *args: Any, **options: Any) -> None: # noqa: ANN401, ARG002 + """Execute the command to detach the organization and optionally re-import data. + + Args: + *args: Positional arguments (unused). + **options: Command-line options parsed by argparse. + + Raises: + CommandError: If the specified organization does not exist. + """ + colorama_init(autoreset=True) + + org_id: str = options["org_id"] + dry_run: bool = bool(options.get("dry_run")) + delete_empty_org: bool = bool(options.get("delete_empty_org")) + + try: + org: Organization = Organization.objects.get(twitch_id=org_id) + except Organization.DoesNotExist as exc: # pragma: no cover - simple guard + msg: str = f"Organization with twitch_id='{org_id}' does not exist. Nothing to do." + raise CommandError(msg) from exc + + # Compute the set of affected games via the through relation for accuracy and performance + affected_games_qs: QuerySet[Game, Game] = Game.objects.filter(owners=org).order_by("display_name") + affected_count: int = affected_games_qs.count() + + if affected_count == 0: + self.stdout.write( + f"{Fore.YELLOW}→{Style.RESET_ALL} No games linked to organization '{org.name}' ({org.twitch_id}).", + ) + else: + self.stdout.write( + f"{Fore.CYAN}•{Style.RESET_ALL} Found {affected_count:,} game(s) linked to '{org.name}' ({org.twitch_id}).", # noqa: E501 + ) + + # Show a short preview list in dry-run mode + preview_limit = 10 + if affected_count > 0 and dry_run: + sample: list[Game] = list(affected_games_qs[:preview_limit]) + for g in sample: + self.stdout.write(f" - {g.display_name} [{g.twitch_id}]") + if affected_count > preview_limit: + self.stdout.write(f" ... and {affected_count - preview_limit:,} more") + + # Perform the detachment + if affected_count > 0 and not dry_run: + # Use the reverse relation clear() to efficiently remove all M2M rows for this org + # This issues a single DELETE on the through table for organization=org + org.games.clear() # pyright: ignore[reportAttributeAccessIssue] + self.stdout.write( + f"{Fore.GREEN}✓{Style.RESET_ALL} Detached organization '{org.name}' from {affected_count:,} game(s).", + ) + + # Optionally delete the organization if it no longer has games + if not dry_run and delete_empty_org: + remaining_games: int = org.games.count() # pyright: ignore[reportAttributeAccessIssue] + if remaining_games == 0: + org_name: str = org.name + org_twid: str = org.twitch_id + org.delete() + self.stdout.write( + f"{Fore.GREEN}✓{Style.RESET_ALL} Deleted organization '{org_name}' ({org_twid}) as it has no games.", # noqa: E501 + ) + else: + self.stdout.write( + f"{Fore.YELLOW}→{Style.RESET_ALL} Organization '{org.name}' still has {remaining_games:,} game(s); not deleted.", # noqa: E501 + )