Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: better readability for characters in admin view #96

Merged
merged 3 commits into from
Sep 22, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions src/accounts/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ class AccountAdminView(admin.ModelAdmin):
"is_confirmed",
"is_verified",
"is_active",
"is_staff",
"is_superuser",
"legacy_id",
)
fieldsets = (
Expand All @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion src/persistence/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,4 @@

@admin.register(Character)
class CharacterAdminView(admin.ModelAdmin):
pass
readonly_fields = ("character_name", "last_updated")
Empty file.
Empty file.
Empty file.
98 changes: 98 additions & 0 deletions src/persistence/management/commands/nuke_duplicated_characters.py
Original file line number Diff line number Diff line change
@@ -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):
corp-0 marked this conversation as resolved.
Show resolved Hide resolved
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:
corp-0 marked this conversation as resolved.
Show resolved Hide resolved
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)
corp-0 marked this conversation as resolved.
Show resolved Hide resolved
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)
corp-0 marked this conversation as resolved.
Show resolved Hide resolved

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...")
10 changes: 8 additions & 2 deletions src/persistence/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""

Expand All @@ -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")
Empty file.
Empty file.
64 changes: 64 additions & 0 deletions src/tests/persistence/commands/nuke_duplicated_characters.py
Original file line number Diff line number Diff line change
@@ -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)
corp-0 marked this conversation as resolved.
Show resolved Hide resolved

# 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
Loading