diff --git a/src/accounts/admin.py b/src/accounts/admin.py index 2710bfb..37859c7 100644 --- a/src/accounts/admin.py +++ b/src/accounts/admin.py @@ -34,7 +34,7 @@ class AccountAdminView(admin.ModelAdmin): "is_confirmed", "is_verified", "is_active", - "is_staff", + "is_superuser", "legacy_id", ) fieldsets = ( @@ -58,14 +58,14 @@ class AccountAdminView(admin.ModelAdmin): "is_active", "is_confirmed", "is_verified", - "is_staff", + "is_superuser", ), }, ), ("Legacy", {"classes": ("wide",), "fields": ("legacy_id",)}), ) inlines = [AccountConfirmationInline, PasswordResetRequestInline] - list_filter = ("is_staff", "is_verified", "is_confirmed", "is_active") + list_filter = ("is_superuser", "is_verified", "is_confirmed", "is_active") search_fields = ( "email__icontains", "username__icontains", diff --git a/src/persistence/admin.py b/src/persistence/admin.py index 10ae692..596bbba 100644 --- a/src/persistence/admin.py +++ b/src/persistence/admin.py @@ -5,4 +5,4 @@ @admin.register(Character) class CharacterAdminView(admin.ModelAdmin): - pass + readonly_fields = ("character_name", "last_updated") diff --git a/src/persistence/management/__init__.py b/src/persistence/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/persistence/management/commands/__init__.py b/src/persistence/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/persistence/management/commands/create_test_duplicated_characters.py b/src/persistence/management/commands/create_test_duplicated_characters.py new file mode 100644 index 0000000..e69de29 diff --git a/src/persistence/management/commands/nuke_duplicated_characters.py b/src/persistence/management/commands/nuke_duplicated_characters.py new file mode 100644 index 0000000..a5b82b6 --- /dev/null +++ b/src/persistence/management/commands/nuke_duplicated_characters.py @@ -0,0 +1,98 @@ +import json +import logging + +from django.core.management.base import BaseCommand +from django.db import transaction + +from accounts.models import Account +from persistence.models import Character + +logger = logging.getLogger(__name__) + + +class Command(BaseCommand): + help = "Finds all duplicated characters in the database and deletes them by comparing their JSON data." + + def add_arguments(self, parser): + parser.add_argument( + "--dry-run", + action="store_true", + help="Dry run mode: shows what would be deleted without actually deleting anything.", + ) + + def handle(self, *args, **options): + dry_run = options["dry_run"] + + if dry_run: + logger.info("Running nuke duplicated characters command in dry run mode") + else: + logger.warning( + "we are about to run the nuke duplicated characters command! This operation can not be undone." + ) + + total_deleted = 0 + + accounts = Account.objects.all() + for account in accounts: + character_map = self.get_character_map(account) + duplicates = self.get_duplicates(character_map) + + if duplicates: + total_deleted += self.process_duplicates(account, duplicates, dry_run) + + if dry_run: + logger.info("Dry run completed. No characters were deleted.") + else: + logger.info("Total duplicated characters deleted: %d", total_deleted) + + @staticmethod + def get_character_map(account) -> dict: + """Returns a dictionary mapping character data (as serialized JSON) to a list of characters.""" + characters = Character.objects.filter(account=account) + character_map: dict[str, list[Character]] = {} + + for character in characters: + data_str = json.dumps(character.data, sort_keys=True) + if data_str in character_map: + character_map[data_str].append(character) + else: + character_map[data_str] = [character] + + return character_map + + @staticmethod + def get_duplicates(character_map: dict) -> list: + """Returns a list of duplicate characters for a given character map.""" + return [chars[1:] for chars in character_map.values() if len(chars) > 1] + + def process_duplicates(self, account: Account, duplicates: list, dry_run: bool) -> int: + """Processes the duplicates, logging and optionally deleting them.""" + total_deleted = 0 + + for chars_to_delete in duplicates: + char_ids = [char.id for char in chars_to_delete] + + if dry_run: + logger.info( + "[Dry run] would delete these duplicated characters for account %s: %s", + account.unique_identifier, + char_ids, + ) + else: + self.delete_characters(account, char_ids) + + total_deleted += len(chars_to_delete) + + return total_deleted + + @staticmethod + def delete_characters(account, char_ids: list): + """Deletes the characters with the specified IDs.""" + with transaction.atomic(): + Character.objects.filter(id__in=char_ids).delete() + logger.warning( + "Deleted the following characters for account %s: %s", + account.unique_identifier, + char_ids, + ) + logger.warning("This operation cannot be undone; they are gone forever...") diff --git a/src/persistence/models.py b/src/persistence/models.py index ef70ab3..175613c 100644 --- a/src/persistence/models.py +++ b/src/persistence/models.py @@ -22,7 +22,9 @@ class Character(models.Model): ) data = models.JSONField( - name="data", verbose_name="Character data", help_text="Unstructured character data in JSON format." + name="data", + verbose_name="Character data", + help_text="Unstructured character data in JSON format.", ) """The character data.""" @@ -32,4 +34,8 @@ class Character(models.Model): ) def __str__(self): - return f"{self.account.unique_identifier}'s character" + return f"{self.character_name} by {self.account.unique_identifier}" + + @property + def character_name(self) -> str: + return self.data.get("Name", "Unknown") diff --git a/src/tests/persistence/__init__.py b/src/tests/persistence/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/tests/persistence/commands/__init__.py b/src/tests/persistence/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/tests/persistence/commands/nuke_duplicated_characters.py b/src/tests/persistence/commands/nuke_duplicated_characters.py new file mode 100644 index 0000000..8c0ece2 --- /dev/null +++ b/src/tests/persistence/commands/nuke_duplicated_characters.py @@ -0,0 +1,64 @@ +import json + +from django.core.management import call_command +from django.test import TestCase + +from accounts.models import Account +from persistence.models import Character + + +class DeleteDuplicateCharactersCommandTest(TestCase): + def setUp(self): + # Create two test accounts + self.account1 = Account.objects.create( + unique_identifier="testuser1", username="testuser1", email="user1@test.com" + ) + self.account2 = Account.objects.create( + unique_identifier="testuser2", username="testuser2", email="user2@test.com" + ) + + # Define character data + data_unique1 = {"Name": "Unique Character", "Age": 30} + data_unique2 = {"Name": "Unique Character", "Age": 25} + data_duplicate = {"Name": "Duplicate Character", "Age": 40} + + # Add characters to account1 + Character.objects.create(account=self.account1, data=data_unique1) + Character.objects.create(account=self.account1, data=data_unique2) + Character.objects.create(account=self.account1, data=data_duplicate) + Character.objects.create(account=self.account1, data=data_duplicate) + + # Add characters to account2 + Character.objects.create(account=self.account2, data=data_unique1) + Character.objects.create(account=self.account2, data=data_unique2) + Character.objects.create(account=self.account2, data=data_duplicate) + Character.objects.create(account=self.account2, data=data_duplicate) + + def test_delete_duplicate_characters_command(self): + # Verify initial character counts + self.assertEqual(Character.objects.filter(account=self.account1).count(), 4) + self.assertEqual(Character.objects.filter(account=self.account2).count(), 4) + + # Run the command in dry-run mode + call_command("nuke_duplicated_characters", "--dry-run") + + # Ensure no characters were deleted in dry-run mode + self.assertEqual(Character.objects.filter(account=self.account1).count(), 4) + self.assertEqual(Character.objects.filter(account=self.account2).count(), 4) + + # Run the command without dry-run to delete duplicates + call_command("nuke_duplicated_characters") + + # Verify duplicates are deleted + self.assertEqual(Character.objects.filter(account=self.account1).count(), 3) + self.assertEqual(Character.objects.filter(account=self.account2).count(), 3) + + # Collect remaining character data for account1 + remaining_data_account1 = Character.objects.filter(account=self.account1).values_list("data", flat=True) + data_strings_account1 = [json.dumps(data, sort_keys=True) for data in remaining_data_account1] + self.assertEqual(len(set(data_strings_account1)), 3) # Should be 3 unique characters + + # Collect remaining character data for account2 + remaining_data_account2 = Character.objects.filter(account=self.account2).values_list("data", flat=True) + data_strings_account2 = [json.dumps(data, sort_keys=True) for data in remaining_data_account2] + self.assertEqual(len(set(data_strings_account2)), 3) # Should be 3 unique characters