From 22df0ecdc64800690650dbe0dcdc8487fca73564 Mon Sep 17 00:00:00 2001 From: Nikhil <118773738+pablohashescobar@users.noreply.github.com> Date: Mon, 11 Aug 2025 18:44:49 +0530 Subject: [PATCH] [WEB-4638] feat: command to fix duplicate issue sequences in workspace (#3882) --- .../fix_workspace_duplicate_sequences.py | 267 ++++++++++++++++++ .../commands/update_issue_identifiers.py | 202 ------------- 2 files changed, 267 insertions(+), 202 deletions(-) create mode 100644 apps/api/plane/db/management/commands/fix_workspace_duplicate_sequences.py delete mode 100644 apps/api/plane/db/management/commands/update_issue_identifiers.py diff --git a/apps/api/plane/db/management/commands/fix_workspace_duplicate_sequences.py b/apps/api/plane/db/management/commands/fix_workspace_duplicate_sequences.py new file mode 100644 index 0000000000..ff0016219b --- /dev/null +++ b/apps/api/plane/db/management/commands/fix_workspace_duplicate_sequences.py @@ -0,0 +1,267 @@ +# Django imports +from django.core.management.base import BaseCommand, CommandError +from django.db.models import Max, Count +from django.db import connection, transaction +from collections import defaultdict + +# Module imports +from plane.db.models import Project, Issue, IssueSequence, Workspace +from plane.utils.uuid import convert_uuid_to_integer + + +class Command(BaseCommand): + help = "Fix all duplicate issue sequences for all projects in a workspace" + + def add_arguments(self, parser): + # Required workspace slug argument + parser.add_argument("workspace_slug", type=str, help="Workspace slug") + + # Optional project ID argument + parser.add_argument( + "--project-id", + type=str, + help="Optional project ID to process only that specific project", + ) + + # Optional dry-run flag + parser.add_argument( + "--dry-run", + action="store_true", + help="Show what would be changed without making actual changes", + ) + + # Optional flag to process all projects without prompting + parser.add_argument( + "--auto-confirm", + action="store_true", + help="Automatically confirm updates for all projects (use with caution)", + ) + + def handle(self, *args, **options): + try: + workspace_slug = options.get("workspace_slug") + if not workspace_slug: + raise CommandError("Workspace slug is required") + + project_id = options.get("project_id") + dry_run = options.get("dry_run", False) + auto_confirm = options.get("auto_confirm", False) + + # Fetch the workspace + try: + workspace = Workspace.objects.get(slug=workspace_slug) + except Workspace.DoesNotExist: + raise CommandError(f"Workspace '{workspace_slug}' not found") + + self.stdout.write( + self.style.SUCCESS( + f"Found workspace: {workspace.name} ({workspace.slug})" + ) + ) + + # Get projects based on whether project_id is provided + if project_id: + # Process only the specified project + try: + projects = Project.objects.filter( + workspace=workspace, id=project_id + ) + if not projects.exists(): + raise CommandError( + f"Project with ID '{project_id}' not found in workspace '{workspace_slug}'" + ) + self.stdout.write( + f"Processing specific project: {projects.first().name} ({projects.first().identifier})" + ) + except Project.DoesNotExist: + raise CommandError( + f"Project with ID '{project_id}' not found in workspace '{workspace_slug}'" + ) + else: + # Get all projects in the workspace + projects = Project.objects.filter(workspace=workspace).order_by("name") + + if not projects.exists(): + self.stdout.write( + self.style.WARNING("No projects found in this workspace!") + ) + return + + self.stdout.write(f"Found {projects.count()} projects in workspace") + + total_projects_processed = 0 + total_issues_updated = 0 + + # Loop through each project + for project in projects: + self.stdout.write("\n" + "=" * 60) + self.stdout.write( + self.style.SUCCESS( + f"Processing project: {project.name} ({project.identifier})" + ) + ) + + # Process this project + project_updated_count = self.process_project( + project, dry_run, auto_confirm + ) + + if project_updated_count > 0: + total_projects_processed += 1 + total_issues_updated += project_updated_count + + # Final summary + self.stdout.write("\n" + "=" * 60) + self.stdout.write(self.style.SUCCESS("Workspace processing complete!")) + self.stdout.write(f"Projects processed: {total_projects_processed}") + self.stdout.write(f"Total issues updated: {total_issues_updated}") + + except Exception as e: + raise CommandError(f"Error: {str(e)}") + + def process_project(self, project, dry_run, auto_confirm): + """Process a single project for duplicate sequences""" + + # Find all issues with duplicate sequence_ids + duplicate_sequences = ( + Issue.objects.filter(project=project) + .values("sequence_id") + .annotate(count=Count("id")) + .filter(count__gt=1) + .values_list("sequence_id", flat=True) + ) + + if not duplicate_sequences: + self.stdout.write( + self.style.SUCCESS(" ✓ No duplicate sequences found in this project!") + ) + return 0 + + self.stdout.write( + self.style.WARNING( + f" ⚠ Found {len(duplicate_sequences)} sequences with duplicates: {list(duplicate_sequences)}" + ) + ) + + # Group issues by sequence_id to handle duplicates + duplicates_map = defaultdict(list) + total_duplicates = 0 + + for sequence_id in duplicate_sequences: + issues = Issue.objects.filter( + project=project, sequence_id=sequence_id + ).order_by( + "created_at" + ) # Order by creation time, keep the oldest + + duplicates_map[sequence_id] = list(issues) + total_duplicates += len(issues) - 1 # -1 because we keep one + + self.stdout.write(f" Sequence {sequence_id}: {len(issues)} issues") + for issue in issues: + self.stdout.write( + f" - {issue.name} (ID: {issue.id}, Created: {issue.created_at})" + ) + + self.stdout.write( + self.style.WARNING( + f" Total issues to be updated in this project: {total_duplicates}" + ) + ) + + if dry_run: + self.stdout.write( + self.style.SUCCESS(" DRY RUN: No changes were made for this project.") + ) + return 0 + + # Ask for confirmation unless auto-confirm is enabled + if not auto_confirm: + confirm = input( + f"\nProceed with updating {total_duplicates} issues in project '{project.name}'? (y/N/q to quit): " + ) + if confirm.lower() == "q": + self.stdout.write("Operation cancelled by user.") + raise CommandError("Operation cancelled") + elif confirm.lower() != "y": + self.stdout.write(" Skipping this project.") + return 0 + + # Process the duplicates + return self.update_project_duplicates(project, duplicates_map) + + def update_project_duplicates(self, project, duplicates_map): + """Update duplicate sequences for a single project""" + + with transaction.atomic(): + # Lock the project to prevent concurrent modifications + lock_key = convert_uuid_to_integer(project.id) + + with connection.cursor() as cursor: + cursor.execute("SELECT pg_advisory_xact_lock(%s)", [lock_key]) + + # Get the current maximum sequence for the project + last_sequence = ( + IssueSequence.objects.filter(project=project).aggregate( + largest=Max("sequence") + )["largest"] + or 0 + ) + + # Prepare bulk updates + bulk_issues = [] + bulk_issue_sequences = [] + new_sequence = last_sequence + + # Get all issue sequences for this project for efficient lookup + issue_sequence_map = { + isq.issue_id: isq + for isq in IssueSequence.objects.filter(project=project) + } + + updated_count = 0 + + # Process each group of duplicates + for sequence_id, issues in duplicates_map.items(): + # Keep the first issue (oldest), update the rest + issues_to_update = issues[1:] # Skip the first one + + self.stdout.write( + f" Keeping issue '{issues[0].name}' with sequence {sequence_id}" + ) + + for issue in issues_to_update: + new_sequence += 1 + old_sequence = issue.sequence_id + issue.sequence_id = new_sequence + bulk_issues.append(issue) + updated_count += 1 + + # Update corresponding IssueSequence + sequence_obj = issue_sequence_map.get(issue.id) + if sequence_obj: + sequence_obj.sequence = new_sequence + bulk_issue_sequences.append(sequence_obj) + + self.stdout.write( + f" Updating '{issue.name}': {old_sequence} -> {new_sequence}" + ) + + # Perform bulk updates + if bulk_issues: + Issue.objects.bulk_update(bulk_issues, ["sequence_id"]) + self.stdout.write(f" Updated {len(bulk_issues)} issues") + + if bulk_issue_sequences: + IssueSequence.objects.bulk_update(bulk_issue_sequences, ["sequence"]) + self.stdout.write( + f" Updated {len(bulk_issue_sequences)} issue sequences" + ) + + self.stdout.write( + self.style.SUCCESS( + f" ✓ Successfully fixed duplicate sequences! Updated {updated_count} issues." + ) + ) + + return updated_count diff --git a/apps/api/plane/db/management/commands/update_issue_identifiers.py b/apps/api/plane/db/management/commands/update_issue_identifiers.py deleted file mode 100644 index da08224f35..0000000000 --- a/apps/api/plane/db/management/commands/update_issue_identifiers.py +++ /dev/null @@ -1,202 +0,0 @@ -# Django imports -from django.core.management.base import BaseCommand, CommandError -from django.db.models import Max, Count -from django.db import connection, transaction -from collections import defaultdict - -# Module imports -from plane.db.models import Project, Issue, IssueSequence -from plane.utils.uuid import convert_uuid_to_integer - - -class Command(BaseCommand): - help = "Fix all duplicate issue sequences for a given project" - - def add_arguments(self, parser): - # Positional argument for project identifier - parser.add_argument("project_identifier", type=str, help="Project Identifier") - - # Optional workspace slug argument - parser.add_argument( - "--workspace_slug", - type=str, - help="Workspace slug (if not provided, will be prompted)", - ) - - # Optional dry-run flag - parser.add_argument( - "--dry-run", - action="store_true", - help="Show what would be changed without making actual changes", - ) - - def handle(self, *args, **options): - try: - # Get workspace slug - workspace_slug = options.get("workspace") - if not workspace_slug: - workspace_slug = input("Workspace slug: ") - - if not workspace_slug: - raise CommandError("Workspace slug is required") - - project_identifier = options.get("project_identifier") - if not project_identifier: - raise CommandError("Project identifier is required") - - dry_run = options.get("dry_run", False) - - # Fetch the project - try: - project = Project.objects.get( - identifier__iexact=project_identifier, - workspace__slug=workspace_slug, - ) - except Project.DoesNotExist: - raise CommandError( - f"Project '{project_identifier}' not found in workspace '{workspace_slug}'" - ) - - self.stdout.write( - self.style.SUCCESS( - f"Found project: {project.name} ({project.identifier})" - ) - ) - - # Find all issues with duplicate sequence_ids - duplicate_sequences = ( - Issue.objects.filter(project=project) - .values("sequence_id") - .annotate(count=Count("id")) - .filter(count__gt=1) - .values_list("sequence_id", flat=True) - ) - - if not duplicate_sequences: - self.stdout.write( - self.style.SUCCESS("No duplicate sequences found in this project!") - ) - return - - self.stdout.write( - self.style.WARNING( - f"Found {len(duplicate_sequences)} sequences with duplicates: {list(duplicate_sequences)}" - ) - ) - - # Group issues by sequence_id to handle duplicates - duplicates_map = defaultdict(list) - total_duplicates = 0 - - for sequence_id in duplicate_sequences: - issues = Issue.objects.filter( - project=project, sequence_id=sequence_id - ).order_by( - "created_at" - ) # Order by creation time, keep the oldest - - duplicates_map[sequence_id] = list(issues) - total_duplicates += len(issues) - 1 # -1 because we keep one - - self.stdout.write(f" Sequence {sequence_id}: {len(issues)} issues") - for issue in issues: - self.stdout.write( - f" - {issue.name} (ID: {issue.id}, Created: {issue.created_at})" - ) - - self.stdout.write( - self.style.WARNING(f"Total issues to be updated: {total_duplicates}") - ) - - if dry_run: - self.stdout.write( - self.style.SUCCESS( - "DRY RUN: No changes were made. Use without --dry-run to apply changes." - ) - ) - return - - # Ask for confirmation - confirm = input( - f"\nProceed with updating {total_duplicates} issues? (y/N): " - ) - if confirm.lower() != "y": - self.stdout.write("Operation cancelled.") - return - - # Process the duplicates - with transaction.atomic(): - # Lock the project to prevent concurrent modifications - lock_key = convert_uuid_to_integer(project.id) - - with connection.cursor() as cursor: - cursor.execute("SELECT pg_advisory_xact_lock(%s)", [lock_key]) - - # Get the current maximum sequence for the project - last_sequence = ( - IssueSequence.objects.filter(project=project).aggregate( - largest=Max("sequence") - )["largest"] - or 0 - ) - - # Prepare bulk updates - bulk_issues = [] - bulk_issue_sequences = [] - new_sequence = last_sequence - - # Get all issue sequences for this project for efficient lookup - issue_sequence_map = { - isq.issue_id: isq - for isq in IssueSequence.objects.filter(project=project) - } - - updated_count = 0 - - # Process each group of duplicates - for sequence_id, issues in duplicates_map.items(): - # Keep the first issue (oldest), update the rest - issues_to_update = issues[1:] # Skip the first one - - self.stdout.write( - f"Keeping issue '{issues[0].name}' with sequence {sequence_id}" - ) - - for issue in issues_to_update: - new_sequence += 1 - old_sequence = issue.sequence_id - issue.sequence_id = new_sequence - bulk_issues.append(issue) - updated_count += 1 - - # Update corresponding IssueSequence - sequence_obj = issue_sequence_map.get(issue.id) - if sequence_obj: - sequence_obj.sequence = new_sequence - bulk_issue_sequences.append(sequence_obj) - - self.stdout.write( - f" Updating '{issue.name}': {old_sequence} -> {new_sequence}" - ) - - # Perform bulk updates - if bulk_issues: - Issue.objects.bulk_update(bulk_issues, ["sequence_id"]) - self.stdout.write(f"Updated {len(bulk_issues)} issues") - - if bulk_issue_sequences: - IssueSequence.objects.bulk_update( - bulk_issue_sequences, ["sequence"] - ) - self.stdout.write( - f"Updated {len(bulk_issue_sequences)} issue sequences" - ) - - self.stdout.write( - self.style.SUCCESS( - f"Successfully fixed duplicate sequences! Updated {updated_count} issues." - ) - ) - - except Exception as e: - raise CommandError(f"Error: {str(e)}")