mirror of
https://github.com/makeplane/plane.git
synced 2026-09-03 12:50:29 +02:00
* Pgtrigger and Outbox prototype * feat: add event stream app with models and migrations - Introduced the event_stream app with initial models: Outbox and IssueProxy. - Implemented a PostgreSQL trigger for logging updates to issues. - Added necessary migrations and updated settings to include the new app. * refactor: migrate event publisher to event stream - Updated imports in test files to reflect the new event_stream app structure. - Removed the event_publisher app, including its models, migrations, and tests. - Introduced MemorySafeOutboxEventListener and related service for handling outbox events. - Added management command for starting the outbox event listener with memory safety. - Implemented PostgreSQL triggers for outbox notifications in the new event_stream app. * refactor: enhance issue serializer and event stream models - Updated IssueSerializer and IssueCreateSerializer to manage assignees and labels more efficiently by calculating additions and removals instead of deleting all and re-adding. - Introduced new proxy models for CycleIssue and ModuleIssue with corresponding PostgreSQL triggers for event handling. - Refactored Outbox model to include entity type and ID for better event tracking. - Removed outdated IssueProxy model and its associated triggers, streamlining the event stream architecture. * chore: update base requirements for OpenSearch and add pika for RabbitMQ - Retained django-opensearch-dsl version 0.7.0 in requirements. - Added pika version 1.3.2 for RabbitMQ integration. * refactor: update import paths for event stream listener - Changed import statements in test_competitive_listeners.py, service.py, and listen_outbox_events.py to reflect the new module structure by removing the 'apiserver' prefix. * feat: implement PostgreSQL LISTEN/NOTIFY listener for outbox events - Added DatabaseConnection class to manage psycopg3 connections for LISTEN/NOTIFY. - Introduced NotificationListener class to handle PostgreSQL NOTIFY messages and dispatch them to registered handlers. - Updated Command class to utilize NotificationListener for listening to outbox events, replacing the previous restartable listener implementation. - Improved error handling and logging throughout the listener process. * feat: enhance event processing with advisory locks - Implemented advisory lock mechanism to prevent concurrent processing of events. - Added methods to acquire and release advisory locks in the DatabaseConnection class. - Updated root_handler to fetch complete outbox data and mark events as processed only if all handlers succeed. - Improved error handling and logging for event processing. * fix: improve event logging and error handling in NotificationListener - Updated event logging to include event ID for better traceability. - Enhanced error handling in the command's handle method to log exceptions and maintain robustness during event listening. * feat: add memory monitoring and auto-restart functionality to event listener - Introduced MemoryMonitor class to track memory usage and event processing counts. - Implemented automatic restarts for the NotificationListener based on memory and event limits. - Enhanced command line options to configure memory limits, event limits, and memory check intervals. - Improved logging for memory stats and event processing to aid in monitoring and debugging. * feat: update local and production settings for event logging - Introduced logging configuration for 'plane.event_stream' in both local and production settings to enhance event logging capabilities. * chore: Implement async outbox polling mechanism with memory monitoring and event handling. Add management command for outbox poller and create necessary database migrations for outbox triggers and indexes. * docs: Add comprehensive README for Event Stream system detailing architecture, components, configuration, usage, and performance tuning. * feat: Enhance MongoDB integration with a singleton connection manager and implement outbox cleanup task for migrating records from PostgreSQL to MongoDB * feat: Add asyncio support for tests and new test files for outbox polling * Merge branch 'event-stream' of github.com:makeplane/plane-ee into event-stream * refactor: Update event types in outbox triggers for cycle, issue, and module models; add IssueComment, IssueLink, and IssueRelation proxies with corresponding triggers * refactor: Update import paths for JsonFormatter and consolidate OpenFeature imports; add unit tests for OutboxPoller and related classes * refactor: Replace all_objects with objects in Outbox queries and update test fixtures for outbox records * feat: Add daily outbox cleaner task to Celery and update type hint for MongoDB collection * refactor: Enhance memory monitoring and outbox polling logic with improved logging and delay handling * feat: Implement outbox triggers for issue, issue attachment, issue link, and issue relation models with dynamic event type determination * chore: Remove obsolete files related to multiple listeners and competitive processing tests * Delete service.py * feat: Add outbox poller script to manage database migrations and process events with configurable parameters * refactor: Optimize EpicCreateSerializer to handle initiative, assignee, and label updates with conflict management and improved logic for adding/removing related entities * feat: Update issue proxy triggers for outbox events - Removed the existing 'issue_outbox_update' trigger from the IssueProxy model. - Added a new 'issue_outbox_update' trigger with enhanced logic to handle conversion events when the issue type changes. - The new trigger captures updates and soft deletes, inserting appropriate events into the outbox. * feat: Refactor issue proxy triggers and add new outbox event handling - Removed and replaced existing triggers for IssueProxy, IssueAssigneeProxy, IssueLabelProxy, CycleIssueProxy, and ModuleIssueProxy. - Introduced new triggers to handle outbox events for various issue-related actions, including creation, updates, and deletions. - Enhanced logic for event type determination based on issue type, ensuring accurate event handling in the outbox. * refactor: Update serializers to use ID fields for assignees and labels - Changed the handling of assignees and labels in IssueCreateSerializer and EpicCreateSerializer to use ID fields instead of object references. - Improved logic for determining assignees and labels to add or remove by using lists of IDs. - Enhanced code readability with comments explaining the changes. * feat: Enhance issue event handling in outbox triggers - Updated triggers for IssueProxy, IssueAssigneeProxy, and IssueLabelProxy to determine event types based on issue type, including handling for epics. - Introduced filtered data for outbox events to exclude description fields, improving data integrity and reducing unnecessary payload size. - Enhanced logic for detecting changes during updates, ensuring only relevant changes are captured and processed. * feat: implement connection pooling for outbox poller - Introduced DatabaseConnectionPool class to manage async connection pooling using psycopg_pool. - Enhanced outbox polling logic to utilize connection pooling for improved performance and resource management. - Added health check and statistics retrieval methods for the connection pool. - Updated outbox model and migration to include claimed_at field for better event tracking. - Refactored tests to validate new connection pooling functionality. * refactor: move MongoConnection to a new settings module and update imports - Created a new mongo.py file to define the MongoConnection class for managing MongoDB connections. - Updated the import path in outbox_cleaner.py to reference the new MongoConnection location. * refactor: update import path for MongoConnection in outbox_cleaner.py * chore: remove scout_apm from production settings and requirements - Deleted scout_apm from the installed apps in production.py. - Removed scout-apm dependency from base.txt requirements. * feat: add claimed_at field to outbox events and update requirements - Added claimed_at field to the outbox event model for enhanced event tracking. - Updated outbox poller to handle claimed_at in event processing. - Included django-pgtrigger in base.txt requirements for database trigger management. * feat: enhance memory monitoring in outbox poller - Updated MemoryMonitor to signal for restarts instead of exiting on memory limit exceedance. - Added methods to check for restart requests and wait for restart signals. - Improved outbox poller logic to handle memory monitoring more gracefully during processing. - Ensured all claimed rows are processed before initiating a restart. * feat: implement graceful shutdown handling in outbox poller - Added GracefulShutdownHandler class to manage shutdown signals (SIGTERM, SIGINT, SIGQUIT). - Integrated shutdown handling into the outbox poller to allow for graceful exits during processing. - Updated polling logic to check for shutdown requests and clean up resources accordingly. - Enhanced command help text to reflect new signal handling capabilities. * Enhance outbox event handling by adding workspace and project IDs - Updated the outbox cleaner to include `workspace_id` and `project_id` in the deletion process. - Modified the outbox poller to handle new fields in event processing. - Adjusted models and migrations to support the new fields in the outbox table. - Updated tests to ensure proper handling of workspace and project IDs in outbox records. * Merge branch 'preview' of github.com:makeplane/plane-ee into event-stream * feat: add initiator_id field to Outbox model and update migration dependencies * feat: add initiator_id to Outbox insertions across event stream models - Updated Outbox insert statements in various models to include initiator_id. - Adjusted related logic in CycleIssueProxy, IssueProxy, ModuleIssueProxy, and others to ensure proper tracking of the user initiating changes. - Enhanced event handling for soft deletes and regular updates to maintain consistency in data tracking. * feat: include initiator_id in OutboxEvent model and database interactions - Added initiator_id field to the OutboxEvent model for enhanced tracking. - Updated database queries in outbox_poller.py to include initiator_id in insertions. - Ensured consistency in data handling across event stream components. * refactor: update handle_row function to use OutboxEvent model - Changed the parameter type of handle_row from Dict to OutboxEvent for better type safety. - Updated logging to utilize the to_dict method of OutboxEvent for consistent event data representation. * feat: enhance Outbox model with initiator type and update related logic - Added initiator_type field to the Outbox model to track the type of event initiator. - Introduced InitiatorTypes enum for better clarity and management of initiator types. - Updated OutboxEvent model and related methods to include initiator_type for consistent event data representation. - Adjusted database interactions across event stream models to accommodate the new initiator_type field. * feat: add initiator_type to Outbox insertions across event stream models - Updated Outbox insert statements in various models to include initiator_type for better tracking of event initiators. - Adjusted related logic in CycleIssueProxy, IssueProxy, ModuleIssueProxy, and others to ensure consistent handling of initiator_type during event processing. - Enhanced event handling for both soft deletes and regular updates to maintain data integrity and tracking. * feat: update event stream triggers to use After timing for improved consistency - Changed trigger timing from Before to After for various event stream models including CycleIssueProxy, IssueProxy, and IssueAssigneeProxy to ensure that outbox updates reflect the final state of the entities. - Enhanced logic in triggers to include previous attributes for better tracking of changes during updates. - Adjusted related logic in IssueLabelProxy and IssueCommentProxy to maintain consistency across event handling. * feat: enhance bulk update task with transaction management and initiator type - Wrapped bulk creation of issue relations and parent ID updates in a transaction to ensure atomicity. - Set the initiator type to 'SYSTEM.IMPORT' for both bulk creation and updates to improve tracking of operations. - Improved database interaction by using a connection cursor for executing SQL commands. --------- Co-authored-by: Dheeraj Kumar Ketireddy <dheeru0198@gmail.com>
765 lines
26 KiB
Python
765 lines
26 KiB
Python
# Python imports
|
|
import logging
|
|
|
|
# Third party imports
|
|
from celery import shared_task
|
|
import requests
|
|
from django.db import transaction, connection
|
|
from django.conf import settings
|
|
|
|
from plane.utils.exception_logger import log_exception
|
|
from plane.utils.helpers import get_boolean_value
|
|
from plane.api.serializers.issue import IssueSerializer
|
|
from plane.db.models.issue import Issue, IssueLink, IssueComment
|
|
from plane.db.models.project import Project
|
|
from plane.db.models.cycle import CycleIssue
|
|
from plane.db.models.module import ModuleIssue
|
|
from plane.db.models.workspace import Workspace
|
|
from plane.db.models.asset import FileAsset
|
|
from plane.db.models.page import Page, ProjectPage
|
|
from plane.ee.models import IssueProperty, IssuePropertyValue
|
|
from plane.ee.utils.external_issue_property_validator import (
|
|
externalIssuePropertyValueSaver,
|
|
externalIssuePropertyValueValidator,
|
|
)
|
|
from plane.silo.bgtasks.bulk_update_issue_relations_task import (
|
|
bulk_update_issue_relations_task,
|
|
)
|
|
|
|
logger = logging.getLogger("plane.worker")
|
|
|
|
|
|
def dispatch_job_completion(job_id, phase="issues", is_last_batch=False):
|
|
"""Dispatch the job completion to silo service"""
|
|
try:
|
|
silo_url = settings.SILO_URL.rstrip("/")
|
|
endpoint = f"{silo_url}/api/jobs/{job_id}/finished/"
|
|
|
|
response = requests.post(
|
|
endpoint,
|
|
json={"jobId": job_id, "phase": phase, "isLastBatch": is_last_batch},
|
|
headers={"Content-Type": "application/json"},
|
|
)
|
|
|
|
if response.status_code != 200:
|
|
logger.error(
|
|
"Error updating job batch completion",
|
|
extra={
|
|
"jobId": job_id,
|
|
"phase": phase,
|
|
"isLastBatch": is_last_batch,
|
|
},
|
|
)
|
|
|
|
except Exception as e:
|
|
logger.error(
|
|
"Failed to update job batch completion",
|
|
extra={
|
|
"jobId": job_id,
|
|
"phase": phase,
|
|
"isLastBatch": is_last_batch,
|
|
},
|
|
)
|
|
log_exception(e)
|
|
|
|
|
|
def update_job_batch_completion(
|
|
job_id,
|
|
imported_batch_count=0,
|
|
total_issues=0,
|
|
imported_issues=0,
|
|
phase="issues",
|
|
is_last_batch=False,
|
|
):
|
|
"""Update the job report with batch and issue counts"""
|
|
try:
|
|
from plane.ee.models import ImportJob
|
|
from django.utils import timezone
|
|
|
|
# Use F() expressions to handle concurrency safely
|
|
from django.db.models import F
|
|
from django.db.models.functions import Coalesce
|
|
|
|
# Get the job and its report
|
|
job = ImportJob.objects.select_related("report").get(pk=job_id)
|
|
|
|
# Get the model class from the instance
|
|
ReportModel = job.report.__class__
|
|
|
|
# Update counters atomically at database level
|
|
ReportModel.objects.filter(id=job.report.id).update(
|
|
# Coalesce handles NULL values by replacing them with 0 before adding
|
|
imported_batch_count=Coalesce(F("imported_batch_count"), 0)
|
|
+ imported_batch_count,
|
|
total_issue_count=Coalesce(F("total_issue_count"), 0) + total_issues,
|
|
imported_issue_count=Coalesce(F("imported_issue_count"), 0)
|
|
+ imported_issues,
|
|
errored_issue_count=Coalesce(F("errored_issue_count"), 0)
|
|
+ (total_issues - imported_issues),
|
|
completed_batch_count=Coalesce(F("completed_batch_count"), 0)
|
|
+ (1 if imported_batch_count > 0 else 0),
|
|
updated_at=timezone.now(),
|
|
)
|
|
|
|
# Refresh the object to get updated values
|
|
job.report.refresh_from_db()
|
|
|
|
logger.info(
|
|
"Updated job batch completion",
|
|
extra={
|
|
"jobId": job_id,
|
|
"isLastBatch": is_last_batch,
|
|
"phase": phase,
|
|
"imported_batch_count": imported_batch_count,
|
|
"total_issues": total_issues,
|
|
"imported_issues": imported_issues,
|
|
"completed_batch_count": job.report.completed_batch_count,
|
|
"total_batch_count": job.report.total_batch_count,
|
|
},
|
|
)
|
|
|
|
# Check if all batches are processed and update job status
|
|
if (
|
|
job.report.completed_batch_count >= job.report.total_batch_count
|
|
or is_last_batch
|
|
):
|
|
"""
|
|
We'll make an api call to silo, such that silo can perform
|
|
any additional processing along with marking the job as finished
|
|
"""
|
|
bulk_update_issue_relations_task(job_id, user_id=job.initiator_id)
|
|
dispatch_job_completion(job_id, phase, is_last_batch)
|
|
|
|
except ImportJob.DoesNotExist:
|
|
logger.error(
|
|
"Job not found with id",
|
|
extra={"jobId": job_id},
|
|
)
|
|
except Exception as e:
|
|
logger.error(
|
|
"Failed to update job batch completion",
|
|
extra={"jobId": job_id},
|
|
)
|
|
log_exception(e)
|
|
|
|
|
|
def sanitize_issue_data(issue_data):
|
|
"""
|
|
Sanitize the issue data
|
|
"""
|
|
# limit the name to 255 characters
|
|
issue_data["name"] = issue_data["name"][:255]
|
|
|
|
return issue_data
|
|
|
|
|
|
def process_single_issue(slug, project, user_id, issue_data):
|
|
try:
|
|
with transaction.atomic():
|
|
with connection.cursor() as cur:
|
|
cur.execute("SET LOCAL plane.initiator_type = 'SYSTEM.IMPORT'")
|
|
# Process the main issue
|
|
issue_data = sanitize_issue_data(issue_data)
|
|
serializer = IssueSerializer(
|
|
data=issue_data,
|
|
context={
|
|
"project_id": project.id,
|
|
"workspace_id": project.workspace_id,
|
|
"default_assignee_id": project.default_assignee_id,
|
|
},
|
|
)
|
|
|
|
if not serializer.is_valid():
|
|
logger.error(
|
|
"Error processing issue",
|
|
extra={"issueId": issue_data.get("id")},
|
|
)
|
|
# Create an exception for serializer.errors
|
|
exception = Exception(serializer.errors)
|
|
log_exception(exception)
|
|
return None
|
|
|
|
external_id = issue_data.get("external_id")
|
|
external_source = issue_data.get("external_source")
|
|
|
|
# Check if issue exists
|
|
issue = None
|
|
if external_id and external_source:
|
|
issue = Issue.objects.filter(
|
|
project_id=project.id,
|
|
workspace__slug=slug,
|
|
external_source=external_source,
|
|
external_id=external_id,
|
|
).first()
|
|
|
|
if issue:
|
|
serializer.instance = issue
|
|
|
|
issue = serializer.save()
|
|
|
|
# Update the issue with the created_by_id
|
|
issue.created_by_id = issue_data.get("created_by")
|
|
issue.save(disable_auto_set_user=True)
|
|
|
|
# Process links
|
|
process_issue_links(issue, issue_data.get("links", []))
|
|
|
|
# Process comments
|
|
process_issue_comments(
|
|
user_id=user_id, issue=issue, comments=issue_data.get("comments", [])
|
|
)
|
|
|
|
# Process cycles
|
|
process_issue_cycles(issue, issue_data.get("cycles", []))
|
|
|
|
# Process modules
|
|
process_issue_modules(issue, issue_data.get("modules", []))
|
|
|
|
# Process file assets
|
|
process_issue_file_assets(issue, issue_data.get("file_assets", []))
|
|
|
|
# Process issue property values
|
|
process_issue_property_values(
|
|
issue, issue_data.get("issue_property_values", [])
|
|
)
|
|
|
|
return issue
|
|
except Exception as e:
|
|
logger.error(
|
|
"Error processing issue",
|
|
extra={"issueId": issue_data.get("id")},
|
|
)
|
|
log_exception(e)
|
|
return None
|
|
|
|
|
|
def process_issue_links(issue, links):
|
|
bulk_create_links = []
|
|
|
|
# Get existing links
|
|
existing_links = list(
|
|
IssueLink.objects.filter(
|
|
issue=issue, project_id=issue.project_id, workspace_id=issue.workspace_id
|
|
).values_list("url", flat=True)
|
|
)
|
|
|
|
for link_data in links:
|
|
if link_data["url"] not in existing_links:
|
|
bulk_create_links.append(
|
|
IssueLink(
|
|
issue=issue,
|
|
project_id=issue.project_id,
|
|
workspace_id=issue.workspace_id,
|
|
title=link_data["name"],
|
|
url=link_data["url"],
|
|
)
|
|
)
|
|
|
|
IssueLink.objects.bulk_create(
|
|
bulk_create_links, batch_size=100, ignore_conflicts=True
|
|
)
|
|
return
|
|
|
|
|
|
def process_issue_comments(user_id, issue, comments):
|
|
if not comments:
|
|
return
|
|
|
|
bulk_create_comments = []
|
|
bulk_update_comments = []
|
|
|
|
# Get existing comments for this issue only
|
|
existing_comments_map = {
|
|
str(comment.external_id): comment
|
|
for comment in IssueComment.objects.filter(
|
|
issue=issue,
|
|
project_id=issue.project_id,
|
|
workspace_id=issue.workspace_id,
|
|
external_id__in=[
|
|
str(c.get("external_id")) for c in comments if c.get("external_id")
|
|
],
|
|
)
|
|
}
|
|
|
|
for comment_data in comments:
|
|
external_id = (
|
|
str(comment_data.get("external_id"))
|
|
if comment_data.get("external_id")
|
|
else None
|
|
)
|
|
|
|
# Skip if no external_id
|
|
if not external_id:
|
|
continue
|
|
|
|
comment_actor = (
|
|
comment_data.get("actor") if comment_data.get("actor") else user_id
|
|
)
|
|
if external_id in existing_comments_map:
|
|
# Update case
|
|
existing_comment = existing_comments_map[external_id]
|
|
existing_comment.actor_id = comment_actor
|
|
existing_comment.created_by_id = comment_actor
|
|
existing_comment.comment_html = comment_data["comment_html"]
|
|
bulk_update_comments.append(existing_comment)
|
|
else:
|
|
# Create case
|
|
|
|
comment = IssueComment(
|
|
issue=issue,
|
|
project_id=issue.project_id,
|
|
workspace_id=issue.workspace_id,
|
|
comment_html=comment_data["comment_html"],
|
|
actor_id=comment_actor,
|
|
created_by_id=comment_actor,
|
|
external_id=external_id,
|
|
external_source=comment_data.get("external_source"),
|
|
)
|
|
bulk_create_comments.append(comment)
|
|
|
|
# Bulk create new comments
|
|
created_comments = IssueComment.objects.bulk_create(
|
|
bulk_create_comments, batch_size=100, ignore_conflicts=True
|
|
)
|
|
|
|
# Bulk update existing comments
|
|
if bulk_update_comments:
|
|
IssueComment.objects.bulk_update(
|
|
bulk_update_comments,
|
|
["comment_html", "actor_id", "created_by_id"],
|
|
batch_size=100,
|
|
)
|
|
|
|
# Process file assets for each comment
|
|
for comment in created_comments:
|
|
comment_data = next(
|
|
(
|
|
c
|
|
for c in comments
|
|
if str(c.get("external_id")) == str(comment.external_id)
|
|
),
|
|
None,
|
|
)
|
|
if comment_data and comment_data.get("file_assets"):
|
|
process_comment_file_assets(comment, comment_data["file_assets"])
|
|
|
|
for comment in bulk_update_comments:
|
|
comment_data = next(
|
|
(
|
|
c
|
|
for c in comments
|
|
if str(c.get("external_id")) == str(comment.external_id)
|
|
),
|
|
None,
|
|
)
|
|
if comment_data and comment_data.get("file_assets"):
|
|
process_comment_file_assets(comment, comment_data["file_assets"])
|
|
|
|
return
|
|
|
|
|
|
def process_issue_cycles(issue, cycle_ids):
|
|
# Create new cycle associations without deleting existing ones
|
|
for cycle_id in cycle_ids:
|
|
CycleIssue.objects.get_or_create(
|
|
issue=issue,
|
|
project_id=issue.project_id,
|
|
workspace_id=issue.workspace_id,
|
|
cycle_id=cycle_id,
|
|
)
|
|
return
|
|
|
|
|
|
def process_issue_modules(issue, module_ids):
|
|
# Create new module associations without deleting existing ones
|
|
for module_id in module_ids:
|
|
ModuleIssue.objects.get_or_create(
|
|
issue=issue,
|
|
project_id=issue.project_id,
|
|
workspace_id=issue.workspace_id,
|
|
module_id=module_id,
|
|
)
|
|
return
|
|
|
|
|
|
def process_comment_file_assets(comment, file_assets):
|
|
if not file_assets:
|
|
return
|
|
|
|
# Get all assets by their IDs
|
|
asset_ids = [asset_id for asset_id in file_assets if asset_id]
|
|
if not asset_ids:
|
|
return
|
|
|
|
# Bulk update all assets
|
|
FileAsset.objects.filter(id__in=asset_ids).update(
|
|
entity_type=FileAsset.EntityTypeContext.COMMENT_DESCRIPTION,
|
|
comment_id=comment.id,
|
|
project_id=comment.project_id,
|
|
workspace_id=comment.workspace_id,
|
|
)
|
|
return
|
|
|
|
|
|
def process_issue_file_assets(issue, file_assets):
|
|
if not file_assets:
|
|
return
|
|
|
|
# Get all assets by their IDs
|
|
asset_ids = [asset_id for asset_id in file_assets if asset_id]
|
|
if not asset_ids:
|
|
return
|
|
|
|
# Bulk update all assets
|
|
FileAsset.objects.filter(id__in=asset_ids).update(
|
|
entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT,
|
|
issue_id=issue.id,
|
|
project_id=issue.project_id,
|
|
workspace_id=issue.workspace_id,
|
|
)
|
|
return
|
|
|
|
|
|
def process_issue_property_values(issue, issue_property_values):
|
|
workspace = Workspace.objects.get(pk=issue.workspace_id)
|
|
|
|
for property_data in issue_property_values:
|
|
property_id = property_data.get("id")
|
|
if not property_id:
|
|
continue
|
|
|
|
issue_property = IssueProperty.objects.get(pk=property_id)
|
|
|
|
# existing issue property values
|
|
existing_issue_property_values = IssuePropertyValue.objects.filter(
|
|
workspace__slug=workspace.slug,
|
|
project_id=issue.project_id,
|
|
issue_id=issue.id,
|
|
property_id=property_id,
|
|
property__issue_type__is_epic=False,
|
|
)
|
|
|
|
issue_property_values = property_data.get("values", [])
|
|
|
|
if not issue_property_values:
|
|
continue
|
|
|
|
# validate the property value
|
|
bulk_external_issue_property_values = []
|
|
for value in issue_property_values:
|
|
# check if ant external id and external source is provided
|
|
property_value = value.get("value", None)
|
|
|
|
if property_value:
|
|
externalIssuePropertyValueValidator(
|
|
issue_property=issue_property, value=property_value
|
|
)
|
|
|
|
# check if issue property with the same external id and external source already exists
|
|
property_external_id = value.get("external_id", None)
|
|
property_external_source = value.get("external_source", None)
|
|
|
|
if property_value in ["true", "false"]:
|
|
property_value = get_boolean_value(property_value)
|
|
|
|
# Save the values
|
|
bulk_external_issue_property_values.append(
|
|
externalIssuePropertyValueSaver(
|
|
workspace_id=issue.workspace.id,
|
|
project_id=issue.project_id,
|
|
issue_id=issue.id,
|
|
issue_property=issue_property,
|
|
value=property_value,
|
|
external_id=property_external_id,
|
|
external_source=property_external_source,
|
|
)
|
|
)
|
|
|
|
# remove the existing issue property values
|
|
existing_issue_property_values.delete()
|
|
|
|
# Bulk create the issue property values
|
|
IssuePropertyValue.objects.bulk_create(
|
|
bulk_external_issue_property_values, batch_size=10
|
|
)
|
|
|
|
|
|
def process_issues(slug, project, user_id, issue_list):
|
|
"""
|
|
Process issues for import
|
|
Args:
|
|
slug (str): Workspace slug
|
|
project (Project): Project object
|
|
user_id (str): User ID
|
|
issue_list (list): List of issue data dictionaries
|
|
Returns:
|
|
tuple: (imported_issues_count, total_issues_count, external_id_map)
|
|
"""
|
|
external_id_map = {}
|
|
total_issues = len(issue_list)
|
|
imported_issues = 0
|
|
|
|
if not issue_list:
|
|
return imported_issues, total_issues, external_id_map
|
|
|
|
# First pass: Create/Update parent issues
|
|
with transaction.atomic():
|
|
for issue_data in issue_list:
|
|
if issue_data.get("parent") is None:
|
|
issue = process_single_issue(slug, project, user_id, issue_data)
|
|
if issue:
|
|
imported_issues += 1
|
|
if issue_data.get("external_id"):
|
|
external_id_map[issue_data["external_id"]] = str(issue.id)
|
|
|
|
# Second pass: Create/Update child issues
|
|
with transaction.atomic():
|
|
for issue_data in issue_list:
|
|
if issue_data.get("parent") is not None:
|
|
parent_external_id = issue_data["parent"]
|
|
if parent_external_id in external_id_map:
|
|
issue_data["parent"] = external_id_map[parent_external_id]
|
|
else:
|
|
parent_issue = Issue.objects.filter(
|
|
project_id=project.id,
|
|
workspace__slug=slug,
|
|
external_id=parent_external_id,
|
|
external_source=issue_data.get("external_source"),
|
|
).first()
|
|
issue_data["parent"] = (
|
|
str(parent_issue.id) if parent_issue else None
|
|
)
|
|
|
|
issue = process_single_issue(slug, project, user_id, issue_data)
|
|
if issue:
|
|
imported_issues += 1
|
|
if issue_data.get("external_id"):
|
|
external_id_map[issue_data["external_id"]] = str(issue.id)
|
|
|
|
return imported_issues, total_issues, external_id_map
|
|
|
|
|
|
def process_pages(slug, project_id, user_id, page_list):
|
|
"""
|
|
Process pages for import with bulk operations
|
|
Args:
|
|
slug (str): Workspace slug
|
|
project_id (str): Project ID
|
|
user_id (str): User ID
|
|
page_list (list): List of page data dictionaries
|
|
Returns:
|
|
tuple: (imported_pages_count, total_pages_count)
|
|
"""
|
|
total_pages = len(page_list)
|
|
imported_pages = 0
|
|
|
|
if not page_list:
|
|
return imported_pages, total_pages
|
|
|
|
# Get workspace id from slug
|
|
workspace = Workspace.objects.get(slug=slug)
|
|
|
|
# Pages to create vs update
|
|
pages_to_create = []
|
|
pages_to_update = []
|
|
|
|
# First, identify existing pages by external id
|
|
external_ids_map = {
|
|
(p.get("external_id"), p.get("external_source")): p
|
|
for p in page_list
|
|
if p.get("external_id") and p.get("external_source")
|
|
}
|
|
|
|
# Get all existing pages in one query
|
|
if external_ids_map:
|
|
existing_pages = Page.objects.filter(
|
|
workspace__id=workspace.id,
|
|
projects__id=project_id,
|
|
external_id__in=[key[0] for key in external_ids_map.keys()],
|
|
external_source__in=list(set(key[1] for key in external_ids_map.keys())),
|
|
)
|
|
|
|
# Create lookup map for existing pages
|
|
existing_pages_map = {
|
|
(page.external_id, page.external_source): page for page in existing_pages
|
|
}
|
|
else:
|
|
existing_pages_map = {}
|
|
|
|
# Process each page for bulk operations
|
|
with transaction.atomic():
|
|
# Handle updates first
|
|
for key, page_data in external_ids_map.items():
|
|
if key in existing_pages_map:
|
|
# Update case
|
|
page = existing_pages_map[key]
|
|
for field, value in page_data.items():
|
|
# Skip fields that shouldn't be directly updated
|
|
if field in [
|
|
"id",
|
|
"created_at",
|
|
"updated_at",
|
|
"created_by",
|
|
"projects",
|
|
]:
|
|
continue
|
|
setattr(page, field, value)
|
|
pages_to_update.append(page)
|
|
else:
|
|
# New pages to create
|
|
new_page = Page(
|
|
workspace_id=workspace.id,
|
|
name=page_data.get("name", "Untitled Page"),
|
|
external_id=page_data.get("external_id"),
|
|
external_source=page_data.get("external_source"),
|
|
description=page_data.get("description", {}),
|
|
description_html=page_data.get("description_html", "<p></p>"),
|
|
description_binary=page_data.get("description_binary"),
|
|
owned_by_id=user_id,
|
|
created_by_id=user_id,
|
|
updated_by_id=user_id,
|
|
)
|
|
pages_to_create.append(new_page)
|
|
|
|
# For pages without external ids
|
|
for page_data in [
|
|
p
|
|
for p in page_list
|
|
if not (p.get("external_id") and p.get("external_source"))
|
|
]:
|
|
new_page = Page(
|
|
workspace_id=workspace.id,
|
|
name=page_data.get("name", "Untitled Page"),
|
|
external_id=page_data.get("external_id"),
|
|
external_source=page_data.get("external_source"),
|
|
description=page_data.get("description", {}),
|
|
description_html=page_data.get("description_html", "<p></p>"),
|
|
description_binary=page_data.get("description_binary"),
|
|
owned_by_id=user_id,
|
|
created_by_id=user_id,
|
|
updated_by_id=user_id,
|
|
)
|
|
pages_to_create.append(new_page)
|
|
|
|
# Bulk update existing pages
|
|
if pages_to_update:
|
|
fields_to_update = [
|
|
"name",
|
|
"description",
|
|
"description_html",
|
|
"description_binary",
|
|
"owned_by_id",
|
|
"updated_by_id",
|
|
]
|
|
Page.objects.bulk_update(pages_to_update, fields_to_update, batch_size=100)
|
|
imported_pages += len(pages_to_update)
|
|
|
|
# Bulk create new pages
|
|
if pages_to_create:
|
|
created_pages = Page.objects.bulk_create(pages_to_create, batch_size=100)
|
|
imported_pages += len(created_pages)
|
|
|
|
ProjectPage.objects.bulk_create(
|
|
[
|
|
ProjectPage(
|
|
page=page, project_id=project_id, workspace_id=workspace.id
|
|
)
|
|
for page in created_pages
|
|
],
|
|
batch_size=1000,
|
|
)
|
|
|
|
return imported_pages, total_pages
|
|
|
|
|
|
@shared_task
|
|
def import_data(slug, project_id, user_id, job_id, payload):
|
|
"""
|
|
Import issues and pages into a project
|
|
Args:
|
|
slug (str): Workspace slug
|
|
project_id (str): Project ID
|
|
user_id (str): User ID
|
|
job_id (str): Job ID for tracking batch completion
|
|
payload (dict): Dictionary containing lists of 'issues' and 'pages'.
|
|
"""
|
|
try:
|
|
project = Project.objects.get(pk=project_id)
|
|
imported_issues = 0
|
|
total_issues = 0
|
|
imported_pages = 0
|
|
total_pages = 0
|
|
|
|
# Process issues
|
|
issue_list = payload.get("issues")
|
|
job_phase = payload.get("phase", "issues")
|
|
is_last_batch = payload.get("isLastBatch", False)
|
|
|
|
logger.info(
|
|
"inside import_data task",
|
|
extra={
|
|
"jobId": job_id,
|
|
"phase": job_phase,
|
|
"issueCount": len(issue_list or []),
|
|
"lastBatchData": is_last_batch,
|
|
},
|
|
)
|
|
|
|
if issue_list:
|
|
imported_issues, total_issues, external_id_map = process_issues(
|
|
slug, project, user_id, issue_list
|
|
)
|
|
update_job_batch_completion(
|
|
job_id, 1, total_issues, imported_issues, job_phase, is_last_batch
|
|
)
|
|
|
|
# Handle edge-case where a batch contains no issues.
|
|
# Without this, completed_batch_count never increments for such batches,
|
|
# leaving the job in an endless "TRANSFORMING" state.
|
|
if not issue_list:
|
|
update_job_batch_completion(job_id, 0, 0, 0, job_phase, is_last_batch)
|
|
|
|
# Process pages
|
|
page_list = payload.get("pages")
|
|
if page_list:
|
|
imported_pages, total_pages = process_pages(
|
|
slug, project_id, user_id, page_list
|
|
)
|
|
update_job_batch_completion(
|
|
job_id, 1, total_pages, imported_pages, "pages", is_last_batch
|
|
)
|
|
|
|
if not page_list:
|
|
update_job_batch_completion(job_id, 0, 0, 0, "pages", is_last_batch)
|
|
|
|
logger.info(
|
|
f"Processed {imported_issues}/{total_issues} issues and {imported_pages}/{total_pages} pages."
|
|
)
|
|
return True
|
|
except Exception as e:
|
|
# Assuming there's error handling in the calling code
|
|
logger.error(
|
|
"Error in import_data",
|
|
extra={"jobId": job_id, "phase": job_phase, "isLastBatch": is_last_batch},
|
|
)
|
|
log_exception(e)
|
|
# increase the error batch count and completed batch count
|
|
from plane.ee.models import ImportJob
|
|
from django.db.models import F
|
|
|
|
job = ImportJob.objects.get(pk=job_id)
|
|
if job.report:
|
|
job.report.__class__.objects.filter(id=job.report.id).update(
|
|
errored_batch_count=F("errored_batch_count") + 1,
|
|
completed_batch_count=F("completed_batch_count") + 1,
|
|
)
|
|
|
|
# Safely fetch phase and last-batch flag from the original payload;
|
|
# they may not be in scope if the error occurred before variables were set.
|
|
safe_job_phase = payload.get("phase", "issues")
|
|
safe_is_last_batch = payload.get("isLastBatch", False)
|
|
|
|
dispatch_job_completion(job_id, safe_job_phase, safe_is_last_batch)
|
|
|
|
return False
|