chore: issue and issue description versioning commands

This commit is contained in:
gurusainath
2024-12-10 16:10:13 +05:30
parent 3654f24f8e
commit 7cfb1f8ace
7 changed files with 558 additions and 26 deletions

View File

@@ -0,0 +1,419 @@
# Python imports
import json
from typing import Optional, List, Dict
# Django imports
from django.utils import timezone
from django.db import transaction
# Third party imports
from celery import shared_task
# Module imports
from plane.db.models import (
Issue,
IssueVersion,
IssueDescriptionVersion,
ProjectMember,
CycleIssue,
ModuleIssue,
IssueActivity,
IssueAssignee,
IssueLabel,
)
from plane.utils.exception_logger import log_exception
@shared_task
def issue_task(updated_issue, issue_id, user_id):
try:
print("====== Issue detail update ======")
current_issue = json.loads(updated_issue) if updated_issue else {}
issue = Issue.objects.get(id=issue_id)
updated_current_issue = {}
for key, value in current_issue.items():
if getattr(issue, key) != value:
updated_current_issue[key] = value
if updated_current_issue:
issue_version = (
IssueVersion.objects.filter(issue_id=issue_id)
.order_by("-last_saved_at")
.first()
)
if (
issue_version
and str(issue_version.owned_by) == str(user_id)
and (timezone.now() - issue_version.last_saved_at).total_seconds()
<= 600
):
for key, value in updated_current_issue.items():
setattr(issue_version, key, value)
issue_version.last_saved_at = timezone.now()
issue_version.save(
update_fields=list(updated_current_issue.keys()) + ["last_saved_at"]
)
else:
IssueVersion.log_issue_version(issue, user_id)
return
except Issue.DoesNotExist:
return
except Exception as e:
log_exception(e)
return
@shared_task
def issue_description_task(updated_issue, issue_id, user_id):
try:
print("====== Issue description update ======")
current_issue = json.loads(updated_issue) if updated_issue else {}
issue = Issue.objects.get(id=issue_id)
current_issue_description_html = current_issue.get("description_html")
issue_description_html = issue.description_html
if current_issue_description_html != issue_description_html:
issue_description_version = (
IssueVersion.objects.filter(issue_id=issue_id)
.order_by("-last_saved_at")
.first()
)
current_issue_description_json = current_issue.get("description")
current_issue_description_html = current_issue.get("description_html")
current_issue_description_binary = current_issue.get("description_binary")
current_issue_description_stripped = current_issue.get(
"description_stripped"
)
if (
issue_description_version
and str(issue_description_version.owned_by) == str(user_id)
and (
timezone.now() - issue_description_version.last_saved_at
).total_seconds()
<= 600
):
issue_description_version.description_json = (
current_issue_description_json
)
issue_description_version.description_html = (
current_issue_description_html
)
issue_description_version.description_binary = (
current_issue_description_binary
)
issue_description_version.description_stripped = (
current_issue_description_stripped
)
issue_description_version.last_saved_at = timezone.now()
issue_description_version.save(
update_fields=[
"description_json",
"description_html",
"description_binary",
"description_stripped",
"last_saved_at",
]
)
else:
IssueDescriptionVersion.log_issue_description_version(
current_issue, user_id
)
return
except Issue.DoesNotExist:
return
except Exception as e:
log_exception(e)
return
@shared_task
def issue_versioning_task(updated_issue, issue_id, user_id):
try:
issue_task.delay(updated_issue, issue_id, user_id)
issue_description_task.delay(updated_issue, issue_id, user_id)
except Exception as e:
log_exception(e)
return
def get_owner_id(issue: Issue) -> Optional[int]:
"""Get the owner ID of the issue"""
if issue.updated_by_id:
return issue.updated_by_id
if issue.created_by_id:
return issue.created_by_id
# Find project admin as fallback
project_member = ProjectMember.objects.filter(
project_id=issue.project_id,
role=20, # Admin role
).first()
return project_member.member_id if project_member else None
# ============ Issue versioning management task starts ============
def get_related_data(issue_ids: List[int]) -> Dict:
"""Get related data for the given issue IDs"""
cycle_issues = {
ci.issue_id: ci.cycle_id
for ci in CycleIssue.objects.filter(issue_id__in=issue_ids)
}
assignees = {
issue_id: list(assignee_ids)
for issue_id, assignee_ids in (
IssueAssignee.objects.filter(issue_id__in=issue_ids)
.values_list("issue_id", "assignee_id")
.order_by("issue_id")
).groupby("issue_id")
}
labels = {
issue_id: list(label_ids)
for issue_id, label_ids in (
IssueLabel.objects.filter(issue_id__in=issue_ids)
.values_list("issue_id", "label_id")
.order_by("issue_id")
).groupby("issue_id")
}
modules = {
issue_id: list(module_ids)
for issue_id, module_ids in (
ModuleIssue.objects.filter(issue_id__in=issue_ids)
.values_list("issue_id", "module_id")
.order_by("issue_id")
).groupby("issue_id")
}
latest_activities = {
ia.issue_id: ia.id
for ia in IssueActivity.objects.filter(issue_id__in=issue_ids)
.order_by("issue_id", "-created_at")
.distinct("issue_id")
}
return {
"cycle_issues": cycle_issues,
"assignees": assignees,
"labels": labels,
"modules": modules,
"activities": latest_activities,
}
def create_issue_version(issue: Issue, related_data: Dict) -> Optional[IssueVersion]:
"""Create IssueVersion object from the given issue and related data"""
if not issue.workspace_id or not issue.project_id:
print(f"Skipping issue {issue.id} - missing workspace_id or project_id")
return None
owned_by_id = get_owner_id(issue)
if owned_by_id is None:
print(f"Skipping issue {issue.id} - missing owned_by")
return None
return IssueVersion(
workspace_id=issue.workspace_id,
project_id=issue.project_id,
created_by_id=issue.created_by_id,
updated_by_id=issue.updated_by_id,
owned_by_id=owned_by_id,
last_saved_at=timezone.now(),
activity_id=related_data["activities"].get(issue.id),
properties=getattr(issue, "properties", {}),
meta=getattr(issue, "meta", {}),
issue_id=issue.id,
parent=issue.parent_id,
state=issue.state_id,
point=issue.point,
estimate_point=issue.estimate_point_id,
name=issue.name,
priority=issue.priority,
start_date=issue.start_date,
target_date=issue.target_date,
assignees=related_data["assignees"].get(issue.id, []),
sequence_id=issue.sequence_id,
labels=related_data["labels"].get(issue.id, []),
sort_order=issue.sort_order,
completed_at=issue.completed_at,
archived_at=issue.archived_at,
is_draft=issue.is_draft,
external_source=issue.external_source,
external_id=issue.external_id,
type=issue.type_id,
cycle=related_data["cycle_issues"].get(issue.id),
modules=related_data["modules"].get(issue.id, []),
)
@shared_task
def issue_management_command_task(batch_size=5000, offset=0, countdown=300):
"""Task to create IssueVersion records for existing Issues in batches"""
try:
with transaction.atomic():
# Get processed issues and total count
processed_issue_ids = set(
IssueVersion.objects.values_list("issue_id", flat=True).distinct()
)
base_query = Issue.objects.exclude(id__in=processed_issue_ids)
total_issues_count = base_query.count()
if total_issues_count == 0:
return
end_offset = min(offset + batch_size, total_issues_count)
# Get issues batch with optimized queries
issues_batch = list(
base_query.order_by("created_at")
.select_related("workspace", "project")
.all()[offset:end_offset]
)
if not issues_batch:
return
# Get all related data in bulk
issue_ids = [issue.id for issue in issues_batch]
related_data = get_related_data(issue_ids)
issue_versions = []
for issue in issues_batch:
version = create_issue_version(issue, related_data)
if version:
issue_versions.append(version)
# Bulk create versions
if issue_versions:
IssueVersion.objects.bulk_create(issue_versions, batch_size=1000)
# Schedule the next batch if there are more workspaces to process
if end_offset < total_issues_count:
issue_management_command_task.apply_async(
args=[batch_size, end_offset], countdown=countdown
)
return
except Exception as e:
log_exception(e)
return
@shared_task
def schedule_issue_management_command_task(batch_size=5000, countdown=300):
issue_management_command_task.delay(batch_size=batch_size, countdown=countdown)
# ============ Issue versioning management task ends ============
# ============ Issue description versioning management task starts ============
@shared_task
def issue_description_management_command_task(batch_size=5000, offset=0, countdown=300):
"""Task to create IssueDescriptionVersion records for existing Issues in batches"""
try:
with transaction.atomic():
# Get processed issue IDs and total count
processed_ids = set(
IssueDescriptionVersion.objects.values_list(
"issue_id", flat=True
).distinct()
)
base_query = Issue.objects.exclude(id__in=processed_ids)
total_issues_count = base_query.count()
if total_issues_count == 0:
return
# Calculate batch range
end_offset = min(offset + batch_size, total_issues_count)
# Fetch issues with related data
issues_batch = (
base_query.order_by("created_at")
.select_related("workspace", "project")
.only(
"id",
"workspace_id",
"project_id",
"created_by_id",
"updated_by_id",
"description_binary",
"description_html",
"description_stripped",
"description",
)[offset:end_offset]
)
if not issues_batch:
return
version_objects = []
for issue in issues_batch:
# Validate required fields
if not issue.workspace_id or not issue.project_id:
print(f"Skipping {issue.id} - missing workspace_id or project_id")
continue
# Determine owned_by_id
owned_by_id = get_owner_id(issue)
if owned_by_id is None:
print(f"Skipping issue {issue.id} - missing owned_by")
continue
# Create version object
version_objects.append(
IssueDescriptionVersion(
workspace_id=issue.workspace_id,
project_id=issue.project_id,
created_by_id=issue.created_by_id,
updated_by_id=issue.updated_by_id,
owned_by_id=owned_by_id,
last_saved_at=timezone.now(),
issue_id=issue.id,
description_binary=issue.description_binary,
description_html=issue.description_html,
description_stripped=issue.description_stripped,
description_json=issue.description,
)
)
# Bulk create version objects
if version_objects:
IssueDescriptionVersion.objects.bulk_create(version_objects)
# Schedule next batch if needed
if end_offset < total_issues_count:
issue_description_management_command_task.apply_async(
args=[batch_size, end_offset], countdown=countdown
)
return
except Exception as e:
log_exception(e)
return
@shared_task
def schedule_issue_description_management_command_task(batch_size=5000, countdown=300):
issue_description_management_command_task.delay(
batch_size=batch_size, countdown=countdown
)
# ============ Issue description versioning management task nds ============

View File

@@ -0,0 +1,23 @@
# Django imports
from django.core.management.base import BaseCommand
# Module imports
from plane.bgtasks.issue_version_task import (
schedule_issue_description_management_command_task,
)
class Command(BaseCommand):
help = "Creates IssueDescriptionVersion records for existing Issues in batches"
def handle(self, *args, **options):
batch_size = input("Enter the batch size: ")
batch_countdown = input("Enter the batch countdown: ")
schedule_issue_description_management_command_task.delay(
batch_size=int(batch_size), countdown=int(batch_countdown)
)
self.stdout.write(
self.style.SUCCESS("Successfully created issue description version task")
)

View File

@@ -0,0 +1,19 @@
# Django imports
from django.core.management.base import BaseCommand
# Module imports
from plane.bgtasks.issue_version_task import schedule_issue_management_command_task
class Command(BaseCommand):
help = "Creates IssueVersion records for existing Issues in batches"
def handle(self, *args, **options):
batch_size = input("Enter the batch size: ")
batch_countdown = input("Enter the batch countdown: ")
schedule_issue_management_command_task.delay(
batch_size=int(batch_size), countdown=int(batch_countdown)
)
self.stdout.write(self.style.SUCCESS("Successfully created issue version task"))

View File

@@ -1,9 +1,11 @@
# Generated by Django 4.2.15 on 2024-12-02 06:34
# Generated by Django 4.2.16 on 2024-12-09 10:03
from django.conf import settings
import django.core.validators
from django.db import migrations, models
import django.db.models.deletion
import django.utils.timezone
import plane.db.models.user
import uuid
@@ -18,6 +20,10 @@ class Migration(migrations.Migration):
model_name='issueversion',
name='description',
),
migrations.RemoveField(
model_name='issueversion',
name='description_binary',
),
migrations.RemoveField(
model_name='issueversion',
name='description_html',
@@ -31,6 +37,31 @@ class Migration(migrations.Migration):
name='activity',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='versions', to='db.issueactivity'),
),
migrations.AddField(
model_name='issueversion',
name='point',
field=models.IntegerField(blank=True, null=True, validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(12)]),
),
migrations.AddField(
model_name='profile',
name='is_mobile_onboarded',
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name='profile',
name='mobile_onboarding_step',
field=models.JSONField(default=plane.db.models.user.get_mobile_default_onboarding),
),
migrations.AddField(
model_name='profile',
name='mobile_timezone_auto_set',
field=models.BooleanField(default=False),
),
migrations.AlterField(
model_name='issueversion',
name='owned_by',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='issue_versions', to=settings.AUTH_USER_MODEL),
),
migrations.CreateModel(
name='IssueDescriptionVersion',
fields=[
@@ -45,7 +76,7 @@ class Migration(migrations.Migration):
('last_saved_at', models.DateTimeField(default=django.utils.timezone.now)),
('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')),
('issue', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='description_versions', to='db.issue')),
('owned_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='description_versions', to=settings.AUTH_USER_MODEL)),
('owned_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='issue_description_versions', to=settings.AUTH_USER_MODEL)),
('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_%(class)s', to='db.project')),
('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')),
('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_%(class)s', to='db.workspace')),

View File

@@ -41,6 +41,8 @@ from .issue import (
IssueSequence,
IssueSubscriber,
IssueVote,
IssueVersion,
IssueDescriptionVersion,
)
from .module import Module, ModuleIssue, ModuleLink, ModuleMember, ModuleUserProperties
from .notification import EmailNotificationLog, Notification, UserNotificationPreference

View File

@@ -660,15 +660,6 @@ class IssueVote(ProjectBaseModel):
class IssueVersion(ProjectBaseModel):
activity = models.ForeignKey(
"db.IssueActivity",
on_delete=models.SET_NULL,
null=True,
related_name="versions",
)
issue = models.ForeignKey(
"db.Issue", on_delete=models.CASCADE, related_name="versions"
)
PRIORITY_CHOICES = (
("urgent", "Urgent"),
("high", "High"),
@@ -676,11 +667,17 @@ class IssueVersion(ProjectBaseModel):
("low", "Low"),
("none", "None"),
)
issue = models.ForeignKey(
"db.Issue", on_delete=models.CASCADE, related_name="versions"
)
parent = models.UUIDField(blank=True, null=True)
state = models.UUIDField(blank=True, null=True)
point = models.IntegerField(
validators=[MinValueValidator(0), MaxValueValidator(12)], null=True, blank=True
)
estimate_point = models.UUIDField(blank=True, null=True)
name = models.CharField(max_length=255, verbose_name="Issue Name")
description_binary = models.BinaryField(null=True)
priority = models.CharField(
max_length=30,
choices=PRIORITY_CHOICES,
@@ -689,7 +686,9 @@ class IssueVersion(ProjectBaseModel):
)
start_date = models.DateField(null=True, blank=True)
target_date = models.DateField(null=True, blank=True)
assignees = ArrayField(models.UUIDField(), blank=True, default=list)
sequence_id = models.IntegerField(default=1, verbose_name="Issue Sequence ID")
labels = ArrayField(models.UUIDField(), blank=True, default=list)
sort_order = models.FloatField(default=65535)
completed_at = models.DateTimeField(null=True)
archived_at = models.DateField(null=True)
@@ -697,14 +696,22 @@ class IssueVersion(ProjectBaseModel):
external_source = models.CharField(max_length=255, null=True, blank=True)
external_id = models.CharField(max_length=255, blank=True, null=True)
type = models.UUIDField(blank=True, null=True)
last_saved_at = models.DateTimeField(default=timezone.now)
owned_by = models.UUIDField()
assignees = ArrayField(models.UUIDField(), blank=True, default=list)
labels = ArrayField(models.UUIDField(), blank=True, default=list)
cycle = models.UUIDField(null=True, blank=True)
modules = ArrayField(models.UUIDField(), blank=True, default=list)
properties = models.JSONField(default=dict)
meta = models.JSONField(default=dict)
activity = models.ForeignKey(
"db.IssueActivity",
on_delete=models.SET_NULL,
null=True,
related_name="versions",
)
properties = models.JSONField(default=dict) # issue properties
meta = models.JSONField(default=dict) # issue meta
last_saved_at = models.DateTimeField(default=timezone.now)
owned_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="issue_versions",
)
class Meta:
verbose_name = "Issue Version"
@@ -734,14 +741,12 @@ class IssueVersion(ProjectBaseModel):
point=issue.point,
estimate_point=issue.estimate_point_id,
name=issue.name,
description=issue.description,
description_html=issue.description_html,
description_stripped=issue.description_stripped,
description_binary=issue.description_binary,
priority=issue.priority,
start_date=issue.start_date,
target_date=issue.target_date,
assignees=issue.assignees,
sequence_id=issue.sequence_id,
labels=issue.labels,
sort_order=issue.sort_order,
completed_at=issue.completed_at,
archived_at=issue.archived_at,
@@ -749,11 +754,12 @@ class IssueVersion(ProjectBaseModel):
external_source=issue.external_source,
external_id=issue.external_id,
type=issue.type_id,
last_saved_at=issue.last_saved_at,
assignees=issue.assignees,
labels=issue.labels,
cycle=cycle_issue.cycle if cycle_issue else None,
modules=Module.objects.filter(issue=issue).values_list("id", flat=True),
# activity=issue.activity_id,
properties={},
meta={},
last_saved_at=timezone.now(),
owned_by=user,
)
return True
@@ -774,10 +780,30 @@ class IssueDescriptionVersion(ProjectBaseModel):
owned_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="description_versions",
related_name="issue_description_versions",
)
class Meta:
verbose_name = "Issue Description Version"
verbose_name_plural = "Issue Description Versions"
db_table = "issue_description_versions"
@classmethod
def log_issue_description_version(cls, issue, user):
try:
"""
Log the issue description version
"""
cls.objects.create(
issue=issue,
description_binary=issue.description_binary,
description_html=issue.description_html,
description_stripped=issue.description_stripped,
description_json=issue.description,
last_saved_at=timezone.now(),
owned_by=user,
)
return True
except Exception as e:
log_exception(e)
return False

View File

@@ -26,6 +26,14 @@ def get_default_onboarding():
}
def get_mobile_default_onboarding():
return {
"profile_complete": False,
"workspace_create": False,
"workspace_join": False,
}
class User(AbstractBaseUser, PermissionsMixin):
id = models.UUIDField(
default=uuid.uuid4, unique=True, editable=False, db_index=True, primary_key=True
@@ -178,6 +186,10 @@ class Profile(TimeAuditModel):
billing_address = models.JSONField(null=True)
has_billing_address = models.BooleanField(default=False)
company_name = models.CharField(max_length=255, blank=True)
# mobile
is_mobile_onboarded = models.BooleanField(default=False)
mobile_onboarding_step = models.JSONField(default=get_mobile_default_onboarding)
mobile_timezone_auto_set = models.BooleanField(default=False)
class Meta:
verbose_name = "Profile"