From ad35de0f5f7589018c07e0cc5ba5197783676b84 Mon Sep 17 00:00:00 2001
From: Gilles <43683714+corp-0@users.noreply.github.com>
Date: Sat, 21 Sep 2024 18:26:59 -0300
Subject: [PATCH 1/3] fix: corrected admin field in Account that will allow
 users into the admin view

---
 src/accounts/admin.py | 6 +++---
 1 file changed, 3 insertions(+), 3 deletions(-)

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

From 223231aed9445157efa304aec298e9664cae8773 Mon Sep 17 00:00:00 2001
From: Gilles <43683714+corp-0@users.noreply.github.com>
Date: Sat, 21 Sep 2024 18:27:47 -0300
Subject: [PATCH 2/3] feat: improved readability of characters in admin view

---
 src/persistence/admin.py  |  2 +-
 src/persistence/models.py | 10 ++++++++--
 2 files changed, 9 insertions(+), 3 deletions(-)

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/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")

From 6875b32124afe7ed5b1c84a795bd36f012f4e505 Mon Sep 17 00:00:00 2001
From: Gilles <43683714+corp-0@users.noreply.github.com>
Date: Sat, 21 Sep 2024 18:28:35 -0300
Subject: [PATCH 3/3] feat: created command tu nuke all duplicated characters

---
 src/persistence/management/__init__.py        |  0
 .../management/commands/__init__.py           |  0
 .../create_test_duplicated_characters.py      |  0
 .../commands/nuke_duplicated_characters.py    | 98 +++++++++++++++++++
 src/tests/persistence/__init__.py             |  0
 src/tests/persistence/commands/__init__.py    |  0
 .../commands/nuke_duplicated_characters.py    | 64 ++++++++++++
 7 files changed, 162 insertions(+)
 create mode 100644 src/persistence/management/__init__.py
 create mode 100644 src/persistence/management/commands/__init__.py
 create mode 100644 src/persistence/management/commands/create_test_duplicated_characters.py
 create mode 100644 src/persistence/management/commands/nuke_duplicated_characters.py
 create mode 100644 src/tests/persistence/__init__.py
 create mode 100644 src/tests/persistence/commands/__init__.py
 create mode 100644 src/tests/persistence/commands/nuke_duplicated_characters.py

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