diff --git a/apiserver/plane/db/management/commands/update_deleted_workspace_slug.py b/apiserver/plane/db/management/commands/update_deleted_workspace_slug.py new file mode 100644 index 00000000000..3d07e8e3427 --- /dev/null +++ b/apiserver/plane/db/management/commands/update_deleted_workspace_slug.py @@ -0,0 +1,78 @@ +import time +from django.core.management.base import BaseCommand +from django.db import transaction +from plane.db.models import Workspace + + +class Command(BaseCommand): + help = "Updates the slug of a soft-deleted workspace by appending the epoch timestamp" + + def add_arguments(self, parser): + parser.add_argument( + "slug", + type=str, + help="The slug of the workspace to update", + ) + parser.add_argument( + "--dry-run", + action="store_true", + help="Run the command without making any changes", + ) + + def handle(self, *args, **options): + slug = options["slug"] + dry_run = options["dry_run"] + + # Get the workspace with the specified slug + try: + workspace = Workspace.all_objects.get(slug=slug) + except Workspace.DoesNotExist: + self.stdout.write( + self.style.ERROR(f"Workspace with slug '{slug}' not found.") + ) + return + + # Check if the workspace is soft-deleted + if workspace.deleted_at is None: + self.stdout.write( + self.style.WARNING( + f"Workspace '{workspace.name}' (slug: {workspace.slug}) is not deleted." + ) + ) + return + + # Check if the slug already has a timestamp appended + if "__" in workspace.slug and workspace.slug.split("__")[-1].isdigit(): + self.stdout.write( + self.style.WARNING( + f"Workspace '{workspace.name}' (slug: {workspace.slug}) already has a timestamp appended." + ) + ) + return + + # Get the deletion timestamp + deletion_timestamp = int(workspace.deleted_at.timestamp()) + + # Create the new slug with the deletion timestamp + new_slug = f"{workspace.slug}__{deletion_timestamp}" + + if dry_run: + self.stdout.write( + f"Would update workspace '{workspace.name}' slug from '{workspace.slug}' to '{new_slug}'" + ) + else: + try: + with transaction.atomic(): + workspace.slug = new_slug + workspace.save(update_fields=["slug"]) + self.stdout.write( + self.style.SUCCESS( + f"Updated workspace '{workspace.name}' slug from '{workspace.slug}' to '{new_slug}'" + ) + ) + except Exception as e: + self.stdout.write( + self.style.ERROR( + f"Error updating workspace '{workspace.name}': {str(e)}" + ) + ) \ No newline at end of file diff --git a/apiserver/plane/db/models/workspace.py b/apiserver/plane/db/models/workspace.py index 2c0370a61ad..e1af103f398 100644 --- a/apiserver/plane/db/models/workspace.py +++ b/apiserver/plane/db/models/workspace.py @@ -1,6 +1,9 @@ # Python imports from django.db.models.functions import Ln import pytz +import time +from django.utils import timezone +from typing import Optional, Any, Tuple, Dict # Django imports from django.conf import settings @@ -149,6 +152,34 @@ def logo_url(self): return self.logo return None + def delete( + self, + using: Optional[str] = None, + soft: bool = True, + *args: Any, + **kwargs: Any + ): + """ + Override the delete method to append epoch timestamp to the slug when soft deleting. + + Args: + using: The database alias to use for the deletion. + soft: Whether to perform a soft delete (True) or hard delete (False). + *args: Additional positional arguments. + **kwargs: Additional keyword arguments. + """ + # Call the parent class's delete method first + result = super().delete(using=using, soft=soft, *args, **kwargs) + + # If it's a soft delete and the model still exists (not hard deleted) + if soft and hasattr(self, 'deleted_at') and self.deleted_at: + # Use the deleted_at timestamp to update the slug + deletion_timestamp: int = int(self.deleted_at.timestamp()) + self.slug = f"{self.slug}__{deletion_timestamp}" + self.save(update_fields=["slug"]) + + return result + class Meta: verbose_name = "Workspace" verbose_name_plural = "Workspaces" @@ -391,7 +422,7 @@ def __str__(self): class WorkspaceUserPreference(BaseModel): """Preference for the workspace for a user""" - class UserPreferenceKeys(models.TextChoices): + class UserPreferenceKeys(models.TextChoices): VIEWS = "views", "Views" ACTIVE_CYCLES = "active_cycles", "Active Cycles" ANALYTICS = "analytics", "Analytics"