1101 lines
49 KiB
Python
1101 lines
49 KiB
Python
# Copyright (c) 2025 Patrick Motsch
|
|
# All rights reserved.
|
|
"""
|
|
JIRA operations method module.
|
|
Handles JIRA ticket operations including connection, export, import, and data processing.
|
|
"""
|
|
|
|
import logging
|
|
import json
|
|
import io
|
|
import pandas as pd
|
|
import csv as csv_module
|
|
from io import StringIO, BytesIO
|
|
from typing import Dict, Any, List, Optional
|
|
from datetime import datetime, UTC
|
|
|
|
from modules.workflows.methods.methodBase import MethodBase, action
|
|
from modules.datamodels.datamodelChat import ActionResult, ActionDocument
|
|
from modules.datamodels.datamodelDocref import DocumentReferenceList
|
|
from modules.shared.configuration import APP_CONFIG
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
class MethodJira(MethodBase):
|
|
"""JIRA operations methods."""
|
|
|
|
def __init__(self, services):
|
|
super().__init__(services)
|
|
self.name = "jira"
|
|
self.description = "JIRA operations methods"
|
|
# Store connections in memory (keyed by connectionId)
|
|
self._connections: Dict[str, Any] = {}
|
|
|
|
def _convertAdfToText(self, adfData):
|
|
"""Convert Atlassian Document Format (ADF) to plain text.
|
|
|
|
Based on Atlassian Document Format specification for JIRA fields.
|
|
Handles paragraphs, lists, text formatting, and other ADF node types.
|
|
|
|
Args:
|
|
adfData: ADF object or None
|
|
|
|
Returns:
|
|
str: Plain text content, or empty string if None/invalid
|
|
"""
|
|
if not adfData or not isinstance(adfData, dict):
|
|
return ""
|
|
|
|
if adfData.get("type") != "doc":
|
|
return str(adfData) if adfData else ""
|
|
|
|
content = adfData.get("content", [])
|
|
if not isinstance(content, list):
|
|
return ""
|
|
|
|
def extractTextFromContent(contentList, listLevel=0):
|
|
"""Recursively extract text from ADF content with proper formatting."""
|
|
textParts = []
|
|
listCounter = 1
|
|
|
|
for item in contentList:
|
|
if not isinstance(item, dict):
|
|
continue
|
|
|
|
itemType = item.get("type", "")
|
|
|
|
if itemType == "text":
|
|
# Extract text content, preserving formatting
|
|
text = item.get("text", "")
|
|
marks = item.get("marks", [])
|
|
|
|
# Handle text formatting (bold, italic, etc.)
|
|
if marks:
|
|
for mark in marks:
|
|
if mark.get("type") == "strong":
|
|
text = f"**{text}**"
|
|
elif mark.get("type") == "em":
|
|
text = f"*{text}*"
|
|
elif mark.get("type") == "code":
|
|
text = f"`{text}`"
|
|
elif mark.get("type") == "link":
|
|
attrs = mark.get("attrs", {})
|
|
href = attrs.get("href", "")
|
|
if href:
|
|
text = f"[{text}]({href})"
|
|
|
|
textParts.append(text)
|
|
|
|
elif itemType == "hardBreak":
|
|
textParts.append("\n")
|
|
|
|
elif itemType == "paragraph":
|
|
paragraphContent = item.get("content", [])
|
|
if paragraphContent:
|
|
paragraphText = extractTextFromContent(paragraphContent, listLevel)
|
|
if paragraphText.strip():
|
|
textParts.append(paragraphText)
|
|
|
|
elif itemType == "bulletList":
|
|
listContent = item.get("content", [])
|
|
for listItem in listContent:
|
|
if listItem.get("type") == "listItem":
|
|
listItemContent = listItem.get("content", [])
|
|
for listParagraph in listItemContent:
|
|
if listParagraph.get("type") == "paragraph":
|
|
listParagraphContent = listParagraph.get("content", [])
|
|
if listParagraphContent:
|
|
indent = " " * listLevel
|
|
bulletText = extractTextFromContent(listParagraphContent, listLevel + 1)
|
|
if bulletText.strip():
|
|
textParts.append(f"{indent}• {bulletText}")
|
|
|
|
elif itemType == "orderedList":
|
|
listContent = item.get("content", [])
|
|
for listItem in listContent:
|
|
if listItem.get("type") == "listItem":
|
|
listItemContent = listItem.get("content", [])
|
|
for listParagraph in listItemContent:
|
|
if listParagraph.get("type") == "paragraph":
|
|
listParagraphContent = listParagraph.get("content", [])
|
|
if listParagraphContent:
|
|
indent = " " * listLevel
|
|
orderedText = extractTextFromContent(listParagraphContent, listLevel + 1)
|
|
if orderedText.strip():
|
|
textParts.append(f"{indent}{listCounter}. {orderedText}")
|
|
listCounter += 1
|
|
|
|
elif itemType == "listItem":
|
|
# Handle nested list items
|
|
listItemContent = item.get("content", [])
|
|
if listItemContent:
|
|
textParts.append(extractTextFromContent(listItemContent, listLevel))
|
|
|
|
elif itemType == "embedCard":
|
|
# Handle embedded content (videos, etc.)
|
|
attrs = item.get("attrs", {})
|
|
url = attrs.get("url", "")
|
|
if url:
|
|
textParts.append(f"[Embedded Content: {url}]")
|
|
|
|
elif itemType == "codeBlock":
|
|
# Handle code blocks
|
|
codeContent = item.get("content", [])
|
|
if codeContent:
|
|
codeText = extractTextFromContent(codeContent, listLevel)
|
|
if codeText.strip():
|
|
textParts.append(f"```\n{codeText}\n```")
|
|
|
|
elif itemType == "blockquote":
|
|
# Handle blockquotes
|
|
quoteContent = item.get("content", [])
|
|
if quoteContent:
|
|
quoteText = extractTextFromContent(quoteContent, listLevel)
|
|
if quoteText.strip():
|
|
textParts.append(f"> {quoteText}")
|
|
|
|
elif itemType == "heading":
|
|
# Handle headings
|
|
headingContent = item.get("content", [])
|
|
if headingContent:
|
|
headingText = extractTextFromContent(headingContent, listLevel)
|
|
if headingText.strip():
|
|
level = item.get("attrs", {}).get("level", 1)
|
|
textParts.append(f"{'#' * level} {headingText}")
|
|
|
|
elif itemType == "rule":
|
|
# Handle horizontal rules
|
|
textParts.append("---")
|
|
|
|
else:
|
|
# Handle unknown types by trying to extract content
|
|
if "content" in item:
|
|
contentText = extractTextFromContent(item.get("content", []), listLevel)
|
|
if contentText.strip():
|
|
textParts.append(contentText)
|
|
|
|
return "\n".join(textParts)
|
|
|
|
result = extractTextFromContent(content)
|
|
return result.strip()
|
|
|
|
def _getDocumentData(self, documentReference: str) -> Any:
|
|
"""Get document data from a document reference (string or document object)."""
|
|
try:
|
|
if isinstance(documentReference, str):
|
|
# Get document from workflow
|
|
documentList = DocumentReferenceList.from_string_list([documentReference])
|
|
chatDocuments = self.services.chat.getChatDocumentsFromDocumentList(documentList)
|
|
if not chatDocuments or len(chatDocuments) == 0:
|
|
return None
|
|
document = chatDocuments[0]
|
|
return document.documentData
|
|
else:
|
|
# Assume it's already a document object
|
|
return documentReference.documentData if hasattr(documentReference, 'documentData') else documentReference
|
|
except Exception as e:
|
|
logger.error(f"Error getting document data: {str(e)}")
|
|
return None
|
|
|
|
def _parseJsonFromDocument(self, documentReference: str) -> Optional[Dict[str, Any]]:
|
|
"""Parse JSON from a document reference."""
|
|
data = self._getDocumentData(documentReference)
|
|
if data is None:
|
|
return None
|
|
|
|
if isinstance(data, str):
|
|
try:
|
|
return json.loads(data)
|
|
except json.JSONDecodeError:
|
|
return None
|
|
elif isinstance(data, dict):
|
|
return data
|
|
else:
|
|
return None
|
|
|
|
@action
|
|
async def connectJira(self, parameters: Dict[str, Any]) -> ActionResult:
|
|
"""
|
|
Connect to JIRA instance and create ticket interface.
|
|
|
|
Parameters:
|
|
- apiUsername (str, required): JIRA API username/email
|
|
- apiTokenConfigKey (str, required): APP_CONFIG key name for JIRA API token
|
|
- apiUrl (str, required): JIRA instance URL (e.g., https://example.atlassian.net)
|
|
- projectCode (str, required): JIRA project code (e.g., "DCS")
|
|
- issueType (str, required): JIRA issue type (e.g., "Task")
|
|
- taskSyncDefinition (str or dict, required): Field mapping definition as JSON string or dict
|
|
|
|
Returns:
|
|
- ActionResult with ActionDocument containing connection ID
|
|
"""
|
|
try:
|
|
apiUsername = parameters.get("apiUsername")
|
|
if not apiUsername:
|
|
return ActionResult.isFailure(error="apiUsername parameter is required")
|
|
|
|
apiTokenConfigKey = parameters.get("apiTokenConfigKey")
|
|
if not apiTokenConfigKey:
|
|
return ActionResult.isFailure(error="apiTokenConfigKey parameter is required")
|
|
|
|
apiUrl = parameters.get("apiUrl")
|
|
if not apiUrl:
|
|
return ActionResult.isFailure(error="apiUrl parameter is required")
|
|
|
|
projectCode = parameters.get("projectCode")
|
|
if not projectCode:
|
|
return ActionResult.isFailure(error="projectCode parameter is required")
|
|
|
|
issueType = parameters.get("issueType")
|
|
if not issueType:
|
|
return ActionResult.isFailure(error="issueType parameter is required")
|
|
|
|
taskSyncDefinitionParam = parameters.get("taskSyncDefinition")
|
|
if not taskSyncDefinitionParam:
|
|
return ActionResult.isFailure(error="taskSyncDefinition parameter is required")
|
|
|
|
# Parse taskSyncDefinition
|
|
if isinstance(taskSyncDefinitionParam, str):
|
|
try:
|
|
taskSyncDefinition = json.loads(taskSyncDefinitionParam)
|
|
except json.JSONDecodeError as e:
|
|
return ActionResult.isFailure(error=f"taskSyncDefinition is not valid JSON: {str(e)}")
|
|
elif isinstance(taskSyncDefinitionParam, dict):
|
|
taskSyncDefinition = taskSyncDefinitionParam
|
|
else:
|
|
return ActionResult.isFailure(error=f"taskSyncDefinition must be a dict or JSON string, got {type(taskSyncDefinitionParam)}")
|
|
|
|
# Get API token from APP_CONFIG
|
|
apiToken = APP_CONFIG.get(apiTokenConfigKey)
|
|
if not apiToken:
|
|
errorMsg = f"{apiTokenConfigKey} not found in APP_CONFIG"
|
|
logger.error(errorMsg)
|
|
return ActionResult.isFailure(error=errorMsg)
|
|
|
|
# Create ticket interface
|
|
syncInterface = await self.services.ticket.connectTicket(
|
|
taskSyncDefinition=taskSyncDefinition,
|
|
connectorType="Jira",
|
|
connectorParams={
|
|
"apiUsername": apiUsername,
|
|
"apiToken": apiToken,
|
|
"apiUrl": apiUrl,
|
|
"projectCode": projectCode,
|
|
"ticketType": issueType,
|
|
},
|
|
)
|
|
|
|
# Store connection with unique ID
|
|
import uuid
|
|
connectionId = str(uuid.uuid4())
|
|
self._connections[connectionId] = {
|
|
"interface": syncInterface,
|
|
"taskSyncDefinition": taskSyncDefinition,
|
|
"apiUrl": apiUrl,
|
|
"projectCode": projectCode,
|
|
}
|
|
|
|
logger.info(f"JIRA connection established: {connectionId} (Project: {projectCode})")
|
|
|
|
# Generate filename
|
|
workflowContext = self.services.chat.getWorkflowContext() if hasattr(self.services, 'chat') else None
|
|
filename = self._generateMeaningfulFileName(
|
|
"jira_connection",
|
|
"json",
|
|
workflowContext,
|
|
"connectJira"
|
|
)
|
|
|
|
# Create connection info document
|
|
connectionInfo = {
|
|
"connectionId": connectionId,
|
|
"apiUrl": apiUrl,
|
|
"projectCode": projectCode,
|
|
"issueType": issueType,
|
|
}
|
|
|
|
validationMetadata = self._createValidationMetadata(
|
|
"connectJira",
|
|
connectionId=connectionId,
|
|
apiUrl=apiUrl,
|
|
projectCode=projectCode
|
|
)
|
|
|
|
document = ActionDocument(
|
|
documentName=filename,
|
|
documentData=json.dumps(connectionInfo, indent=2),
|
|
mimeType="application/json",
|
|
validationMetadata=validationMetadata
|
|
)
|
|
|
|
return ActionResult.isSuccess(documents=[document])
|
|
|
|
except Exception as e:
|
|
errorMsg = f"Error connecting to JIRA: {str(e)}"
|
|
logger.error(errorMsg)
|
|
return ActionResult.isFailure(error=errorMsg)
|
|
|
|
@action
|
|
async def exportTicketsAsJson(self, parameters: Dict[str, Any]) -> ActionResult:
|
|
"""
|
|
Export tickets from JIRA as JSON list.
|
|
|
|
Parameters:
|
|
- connectionId (str, required): Connection ID from connectJira action result
|
|
- taskSyncDefinition (str or dict, optional): Field mapping definition (if not provided, uses stored definition)
|
|
|
|
Returns:
|
|
- ActionResult with ActionDocument containing list of tickets as JSON
|
|
"""
|
|
try:
|
|
connectionIdParam = parameters.get("connectionId")
|
|
if not connectionIdParam:
|
|
return ActionResult.isFailure(error="connectionId parameter is required")
|
|
|
|
# Get connection ID from document if it's a reference
|
|
connectionId = None
|
|
if isinstance(connectionIdParam, str):
|
|
# Try to parse from document reference
|
|
connectionInfo = self._parseJsonFromDocument(connectionIdParam)
|
|
if connectionInfo and "connectionId" in connectionInfo:
|
|
connectionId = connectionInfo["connectionId"]
|
|
else:
|
|
# Assume it's the connection ID directly
|
|
connectionId = connectionIdParam
|
|
|
|
if not connectionId or connectionId not in self._connections:
|
|
return ActionResult.isFailure(error=f"Connection ID {connectionIdParam} not found. Ensure connectJira was called first.")
|
|
|
|
connection = self._connections[connectionId]
|
|
syncInterface = connection["interface"]
|
|
|
|
# Export tickets
|
|
dataList = await syncInterface.exportTicketsAsList()
|
|
|
|
logger.info(f"Exported {len(dataList)} tickets from JIRA")
|
|
|
|
# Generate filename
|
|
workflowContext = self.services.chat.getWorkflowContext() if hasattr(self.services, 'chat') else None
|
|
filename = self._generateMeaningfulFileName(
|
|
"jira_tickets_export",
|
|
"json",
|
|
workflowContext,
|
|
"exportTicketsAsJson"
|
|
)
|
|
|
|
validationMetadata = self._createValidationMetadata(
|
|
"exportTicketsAsJson",
|
|
connectionId=connectionId,
|
|
ticketCount=len(dataList)
|
|
)
|
|
|
|
document = ActionDocument(
|
|
documentName=filename,
|
|
documentData=json.dumps(dataList, indent=2, ensure_ascii=False),
|
|
mimeType="application/json",
|
|
validationMetadata=validationMetadata
|
|
)
|
|
|
|
return ActionResult.isSuccess(documents=[document])
|
|
|
|
except Exception as e:
|
|
errorMsg = f"Error exporting tickets from JIRA: {str(e)}"
|
|
logger.error(errorMsg)
|
|
return ActionResult.isFailure(error=errorMsg)
|
|
|
|
@action
|
|
async def importTicketsFromJson(self, parameters: Dict[str, Any]) -> ActionResult:
|
|
"""
|
|
Import ticket data from JSON back to JIRA.
|
|
|
|
Parameters:
|
|
- connectionId (str, required): Connection ID from connectJira action result
|
|
- ticketData (str, required): Document reference containing ticket data as JSON
|
|
- taskSyncDefinition (str or dict, optional): Field mapping definition (if not provided, uses stored definition)
|
|
|
|
Returns:
|
|
- ActionResult with ActionDocument containing import result with counts
|
|
"""
|
|
try:
|
|
connectionIdParam = parameters.get("connectionId")
|
|
if not connectionIdParam:
|
|
return ActionResult.isFailure(error="connectionId parameter is required")
|
|
|
|
ticketDataParam = parameters.get("ticketData")
|
|
if not ticketDataParam:
|
|
return ActionResult.isFailure(error="ticketData parameter is required")
|
|
|
|
# Get connection ID from document if it's a reference
|
|
connectionId = None
|
|
if isinstance(connectionIdParam, str):
|
|
connectionInfo = self._parseJsonFromDocument(connectionIdParam)
|
|
if connectionInfo and "connectionId" in connectionInfo:
|
|
connectionId = connectionInfo["connectionId"]
|
|
else:
|
|
connectionId = connectionIdParam
|
|
|
|
if not connectionId or connectionId not in self._connections:
|
|
return ActionResult.isFailure(error=f"Connection ID {connectionIdParam} not found. Ensure connectJira was called first.")
|
|
|
|
connection = self._connections[connectionId]
|
|
syncInterface = connection["interface"]
|
|
|
|
# Get ticket data from document
|
|
ticketDataJson = self._parseJsonFromDocument(ticketDataParam)
|
|
if ticketDataJson is None:
|
|
return ActionResult.isFailure(error="Could not parse ticket data from document reference")
|
|
|
|
# Ensure it's a list
|
|
if not isinstance(ticketDataJson, list):
|
|
return ActionResult.isFailure(error="ticketData must be a JSON array")
|
|
|
|
# Import tickets
|
|
await syncInterface.importListToTickets(ticketDataJson)
|
|
|
|
logger.info(f"Imported {len(ticketDataJson)} tickets to JIRA")
|
|
|
|
# Generate filename
|
|
workflowContext = self.services.chat.getWorkflowContext() if hasattr(self.services, 'chat') else None
|
|
filename = self._generateMeaningfulFileName(
|
|
"jira_import_result",
|
|
"json",
|
|
workflowContext,
|
|
"importTicketsFromJson"
|
|
)
|
|
|
|
importResult = {
|
|
"imported": len(ticketDataJson),
|
|
"connectionId": connectionId,
|
|
}
|
|
|
|
validationMetadata = self._createValidationMetadata(
|
|
"importTicketsFromJson",
|
|
connectionId=connectionId,
|
|
importedCount=len(ticketDataJson)
|
|
)
|
|
|
|
document = ActionDocument(
|
|
documentName=filename,
|
|
documentData=json.dumps(importResult, indent=2),
|
|
mimeType="application/json",
|
|
validationMetadata=validationMetadata
|
|
)
|
|
|
|
return ActionResult.isSuccess(documents=[document])
|
|
|
|
except Exception as e:
|
|
errorMsg = f"Error importing tickets to JIRA: {str(e)}"
|
|
logger.error(errorMsg)
|
|
return ActionResult.isFailure(error=errorMsg)
|
|
|
|
@action
|
|
async def mergeTicketData(self, parameters: Dict[str, Any]) -> ActionResult:
|
|
"""
|
|
Merge JIRA export data with existing SharePoint data.
|
|
|
|
Parameters:
|
|
- jiraData (str, required): Document reference containing JIRA ticket data as JSON array
|
|
- existingData (str, required): Document reference containing existing SharePoint data as JSON array
|
|
- taskSyncDefinition (str or dict, required): Field mapping definition
|
|
- idField (str, optional): Field name to use as ID for merging (default: "ID")
|
|
|
|
Returns:
|
|
- ActionResult with ActionDocument containing merged data and merge details
|
|
"""
|
|
try:
|
|
jiraDataParam = parameters.get("jiraData")
|
|
if not jiraDataParam:
|
|
return ActionResult.isFailure(error="jiraData parameter is required")
|
|
|
|
existingDataParam = parameters.get("existingData")
|
|
if not existingDataParam:
|
|
return ActionResult.isFailure(error="existingData parameter is required")
|
|
|
|
taskSyncDefinitionParam = parameters.get("taskSyncDefinition")
|
|
if not taskSyncDefinitionParam:
|
|
return ActionResult.isFailure(error="taskSyncDefinition parameter is required")
|
|
|
|
idField = parameters.get("idField", "ID")
|
|
|
|
# Parse taskSyncDefinition
|
|
if isinstance(taskSyncDefinitionParam, str):
|
|
try:
|
|
taskSyncDefinition = json.loads(taskSyncDefinitionParam)
|
|
except json.JSONDecodeError as e:
|
|
return ActionResult.isFailure(error=f"taskSyncDefinition is not valid JSON: {str(e)}")
|
|
elif isinstance(taskSyncDefinitionParam, dict):
|
|
taskSyncDefinition = taskSyncDefinitionParam
|
|
else:
|
|
return ActionResult.isFailure(error=f"taskSyncDefinition must be a dict or JSON string, got {type(taskSyncDefinitionParam)}")
|
|
|
|
# Get data from documents
|
|
jiraDataJson = self._parseJsonFromDocument(jiraDataParam)
|
|
if jiraDataJson is None or not isinstance(jiraDataJson, list):
|
|
return ActionResult.isFailure(error="Could not parse jiraData as JSON array")
|
|
|
|
existingDataJson = self._parseJsonFromDocument(existingDataParam)
|
|
if existingDataJson is None or not isinstance(existingDataJson, list):
|
|
# Empty existing data is OK
|
|
existingDataJson = []
|
|
|
|
# Perform merge
|
|
existingLookup = {row.get(idField): row for row in existingDataJson if row.get(idField)}
|
|
mergedData: List[dict] = []
|
|
changes: List[str] = []
|
|
updatedCount = addedCount = unchangedCount = 0
|
|
|
|
for jiraRow in jiraDataJson:
|
|
jiraId = jiraRow.get(idField)
|
|
if jiraId and jiraId in existingLookup:
|
|
existingRow = existingLookup[jiraId].copy()
|
|
rowChanges: List[str] = []
|
|
|
|
for fieldName, fieldConfig in taskSyncDefinition.items():
|
|
if fieldConfig[0] == 'get':
|
|
oldValue = "" if existingRow.get(fieldName) is None else str(existingRow.get(fieldName))
|
|
newValue = "" if jiraRow.get(fieldName) is None else str(jiraRow.get(fieldName))
|
|
|
|
# Convert ADF data to readable text for logging
|
|
if isinstance(newValue, dict) and newValue.get("type") == "doc":
|
|
newValueReadable = self._convertAdfToText(newValue)
|
|
if oldValue != newValueReadable:
|
|
rowChanges.append(f"{fieldName}: '{oldValue[:100]}...' -> '{newValueReadable[:100]}...'")
|
|
elif oldValue != newValue:
|
|
# Truncate long values for logging
|
|
oldTruncated = oldValue[:100] + "..." if len(oldValue) > 100 else oldValue
|
|
newTruncated = newValue[:100] + "..." if len(newValue) > 100 else newValue
|
|
rowChanges.append(f"{fieldName}: '{oldTruncated}' -> '{newTruncated}'")
|
|
|
|
existingRow[fieldName] = jiraRow.get(fieldName)
|
|
|
|
mergedData.append(existingRow)
|
|
if rowChanges:
|
|
updatedCount += 1
|
|
changes.append(f"Row ID {jiraId} updated: {', '.join(rowChanges)}")
|
|
else:
|
|
unchangedCount += 1
|
|
del existingLookup[jiraId]
|
|
else:
|
|
mergedData.append(jiraRow)
|
|
addedCount += 1
|
|
changes.append(f"Row ID {jiraId} added as new record")
|
|
|
|
# Add remaining existing rows
|
|
for remaining in existingLookup.values():
|
|
mergedData.append(remaining)
|
|
unchangedCount += 1
|
|
|
|
mergeDetails = {
|
|
"updated": updatedCount,
|
|
"added": addedCount,
|
|
"unchanged": unchangedCount,
|
|
"changes": changes
|
|
}
|
|
|
|
logger.info(f"Merged ticket data: {updatedCount} updated, {addedCount} added, {unchangedCount} unchanged")
|
|
|
|
# Generate filename
|
|
workflowContext = self.services.chat.getWorkflowContext() if hasattr(self.services, 'chat') else None
|
|
filename = self._generateMeaningfulFileName(
|
|
"merged_ticket_data",
|
|
"json",
|
|
workflowContext,
|
|
"mergeTicketData"
|
|
)
|
|
|
|
result = {
|
|
"data": mergedData,
|
|
"mergeDetails": mergeDetails
|
|
}
|
|
|
|
validationMetadata = self._createValidationMetadata(
|
|
"mergeTicketData",
|
|
updated=updatedCount,
|
|
added=addedCount,
|
|
unchanged=unchangedCount
|
|
)
|
|
|
|
document = ActionDocument(
|
|
documentName=filename,
|
|
documentData=json.dumps(result, indent=2, ensure_ascii=False),
|
|
mimeType="application/json",
|
|
validationMetadata=validationMetadata
|
|
)
|
|
|
|
return ActionResult.isSuccess(documents=[document])
|
|
|
|
except Exception as e:
|
|
errorMsg = f"Error merging ticket data: {str(e)}"
|
|
logger.error(errorMsg)
|
|
return ActionResult.isFailure(error=errorMsg)
|
|
|
|
@action
|
|
async def parseCsvContent(self, parameters: Dict[str, Any]) -> ActionResult:
|
|
"""
|
|
Parse CSV content with custom headers.
|
|
|
|
Parameters:
|
|
- csvContent (str, required): Document reference containing CSV file content as bytes
|
|
- skipRows (int, optional): Number of header rows to skip (default: 2)
|
|
- hasCustomHeaders (bool, optional): Whether CSV has custom header rows (default: true)
|
|
|
|
Returns:
|
|
- ActionResult with ActionDocument containing parsed data and headers as JSON
|
|
"""
|
|
try:
|
|
csvContentParam = parameters.get("csvContent")
|
|
if not csvContentParam:
|
|
return ActionResult.isFailure(error="csvContent parameter is required")
|
|
|
|
skipRows = parameters.get("skipRows", 2)
|
|
hasCustomHeaders = parameters.get("hasCustomHeaders", True)
|
|
|
|
# Get CSV content from document
|
|
csvBytes = self._getDocumentData(csvContentParam)
|
|
if csvBytes is None:
|
|
return ActionResult.isFailure(error="Could not get CSV content from document reference")
|
|
|
|
# Convert to bytes if needed
|
|
if isinstance(csvBytes, str):
|
|
csvBytes = csvBytes.encode('utf-8')
|
|
elif not isinstance(csvBytes, bytes):
|
|
return ActionResult.isFailure(error="CSV content must be bytes or string")
|
|
|
|
# Parse headers if hasCustomHeaders
|
|
headers = {"header1": "Header 1", "header2": "Header 2"}
|
|
if hasCustomHeaders:
|
|
csvLines = csvBytes.decode('utf-8').split('\n')
|
|
if len(csvLines) >= 2:
|
|
headers["header1"] = csvLines[0].rstrip('\r\n')
|
|
headers["header2"] = csvLines[1].rstrip('\r\n')
|
|
|
|
# Parse CSV data
|
|
df = pd.read_csv(
|
|
io.BytesIO(csvBytes),
|
|
skiprows=skipRows,
|
|
quoting=1,
|
|
escapechar='\\',
|
|
on_bad_lines='skip',
|
|
engine='python'
|
|
)
|
|
|
|
# Convert to dict records
|
|
for column in df.columns:
|
|
df[column] = df[column].astype('object').fillna('')
|
|
data = df.to_dict(orient='records')
|
|
|
|
logger.info(f"Parsed CSV: {len(data)} rows, {len(df.columns)} columns")
|
|
|
|
# Generate filename
|
|
workflowContext = self.services.chat.getWorkflowContext() if hasattr(self.services, 'chat') else None
|
|
filename = self._generateMeaningfulFileName(
|
|
"parsed_csv_data",
|
|
"json",
|
|
workflowContext,
|
|
"parseCsvContent"
|
|
)
|
|
|
|
result = {
|
|
"data": data,
|
|
"headers": headers,
|
|
"rowCount": len(data),
|
|
"columnCount": len(df.columns)
|
|
}
|
|
|
|
validationMetadata = self._createValidationMetadata(
|
|
"parseCsvContent",
|
|
rowCount=len(data),
|
|
columnCount=len(df.columns),
|
|
skipRows=skipRows
|
|
)
|
|
|
|
document = ActionDocument(
|
|
documentName=filename,
|
|
documentData=json.dumps(result, indent=2, ensure_ascii=False),
|
|
mimeType="application/json",
|
|
validationMetadata=validationMetadata
|
|
)
|
|
|
|
return ActionResult.isSuccess(documents=[document])
|
|
|
|
except Exception as e:
|
|
errorMsg = f"Error parsing CSV content: {str(e)}"
|
|
logger.error(errorMsg)
|
|
return ActionResult.isFailure(error=errorMsg)
|
|
|
|
@action
|
|
async def parseExcelContent(self, parameters: Dict[str, Any]) -> ActionResult:
|
|
"""
|
|
Parse Excel content with custom headers.
|
|
|
|
Parameters:
|
|
- excelContent (str, required): Document reference containing Excel file content as bytes
|
|
- skipRows (int, optional): Number of header rows to skip (default: 3)
|
|
- hasCustomHeaders (bool, optional): Whether Excel has custom header rows (default: true)
|
|
|
|
Returns:
|
|
- ActionResult with ActionDocument containing parsed data and headers as JSON
|
|
"""
|
|
try:
|
|
excelContentParam = parameters.get("excelContent")
|
|
if not excelContentParam:
|
|
return ActionResult.isFailure(error="excelContent parameter is required")
|
|
|
|
skipRows = parameters.get("skipRows", 3)
|
|
hasCustomHeaders = parameters.get("hasCustomHeaders", True)
|
|
|
|
# Get Excel content from document
|
|
excelBytes = self._getDocumentData(excelContentParam)
|
|
if excelBytes is None:
|
|
return ActionResult.isFailure(error="Could not get Excel content from document reference")
|
|
|
|
# Convert to bytes if needed
|
|
if isinstance(excelBytes, str):
|
|
excelBytes = excelBytes.encode('latin-1') # Excel might have binary data
|
|
elif not isinstance(excelBytes, bytes):
|
|
return ActionResult.isFailure(error="Excel content must be bytes or string")
|
|
|
|
# Parse Excel
|
|
df = pd.read_excel(BytesIO(excelBytes), engine='openpyxl', header=None)
|
|
|
|
# Extract headers if hasCustomHeaders
|
|
headers = {"header1": "Header 1", "header2": "Header 2"}
|
|
if hasCustomHeaders and len(df) >= 3:
|
|
headerRow1 = df.iloc[0:1].copy()
|
|
headerRow2 = df.iloc[1:2].copy()
|
|
tableHeaders = df.iloc[2:3].copy()
|
|
dfData = df.iloc[skipRows:].copy()
|
|
dfData.columns = tableHeaders.iloc[0]
|
|
|
|
headers = {
|
|
"header1": ",".join([str(x) if pd.notna(x) else "" for x in headerRow1.iloc[0].tolist()]),
|
|
"header2": ",".join([str(x) if pd.notna(x) else "" for x in headerRow2.iloc[0].tolist()]),
|
|
}
|
|
else:
|
|
# No custom headers, use standard parsing
|
|
if skipRows > 0:
|
|
dfData = df.iloc[skipRows:].copy()
|
|
if len(df) > skipRows:
|
|
dfData.columns = df.iloc[skipRows-1]
|
|
else:
|
|
dfData = df.copy()
|
|
|
|
# Reset index and clean data
|
|
dfData = dfData.reset_index(drop=True)
|
|
for column in dfData.columns:
|
|
dfData[column] = dfData[column].astype('object').fillna('')
|
|
|
|
data = dfData.to_dict(orient='records')
|
|
|
|
logger.info(f"Parsed Excel: {len(data)} rows, {len(dfData.columns)} columns")
|
|
|
|
# Generate filename
|
|
workflowContext = self.services.chat.getWorkflowContext() if hasattr(self.services, 'chat') else None
|
|
filename = self._generateMeaningfulFileName(
|
|
"parsed_excel_data",
|
|
"json",
|
|
workflowContext,
|
|
"parseExcelContent"
|
|
)
|
|
|
|
result = {
|
|
"data": data,
|
|
"headers": headers,
|
|
"rowCount": len(data),
|
|
"columnCount": len(dfData.columns)
|
|
}
|
|
|
|
validationMetadata = self._createValidationMetadata(
|
|
"parseExcelContent",
|
|
rowCount=len(data),
|
|
columnCount=len(dfData.columns),
|
|
skipRows=skipRows
|
|
)
|
|
|
|
document = ActionDocument(
|
|
documentName=filename,
|
|
documentData=json.dumps(result, indent=2, ensure_ascii=False),
|
|
mimeType="application/json",
|
|
validationMetadata=validationMetadata
|
|
)
|
|
|
|
return ActionResult.isSuccess(documents=[document])
|
|
|
|
except Exception as e:
|
|
errorMsg = f"Error parsing Excel content: {str(e)}"
|
|
logger.error(errorMsg)
|
|
return ActionResult.isFailure(error=errorMsg)
|
|
|
|
@action
|
|
async def createCsvContent(self, parameters: Dict[str, Any]) -> ActionResult:
|
|
"""
|
|
Create CSV content with custom headers.
|
|
|
|
Parameters:
|
|
- data (str, required): Document reference containing data as JSON (with "data" field from mergeTicketData)
|
|
- headers (str, optional): Document reference containing headers JSON (from parseCsvContent/parseExcelContent)
|
|
- columns (str or list, optional): List of column names (if not provided, extracted from taskSyncDefinition or data)
|
|
- taskSyncDefinition (str or dict, optional): Field mapping definition (used to extract column names if columns not provided)
|
|
|
|
Returns:
|
|
- ActionResult with ActionDocument containing CSV content as bytes
|
|
"""
|
|
try:
|
|
dataParam = parameters.get("data")
|
|
if not dataParam:
|
|
return ActionResult.isFailure(error="data parameter is required")
|
|
|
|
headersParam = parameters.get("headers")
|
|
columnsParam = parameters.get("columns")
|
|
taskSyncDefinitionParam = parameters.get("taskSyncDefinition")
|
|
|
|
# Get data from document
|
|
dataJson = self._parseJsonFromDocument(dataParam)
|
|
if dataJson is None:
|
|
return ActionResult.isFailure(error="Could not parse data from document reference")
|
|
|
|
# Extract data array if wrapped in object
|
|
if isinstance(dataJson, dict) and "data" in dataJson:
|
|
dataList = dataJson["data"]
|
|
elif isinstance(dataJson, list):
|
|
dataList = dataJson
|
|
else:
|
|
return ActionResult.isFailure(error="Data must be a JSON array or object with 'data' field")
|
|
|
|
# Get headers
|
|
headers = {"header1": "Header 1", "header2": "Header 2"}
|
|
if headersParam:
|
|
headersJson = self._parseJsonFromDocument(headersParam)
|
|
if headersJson and isinstance(headersJson, dict) and "headers" in headersJson:
|
|
headers = headersJson["headers"]
|
|
elif headersJson and isinstance(headersJson, dict):
|
|
headers = headersJson
|
|
|
|
# Get columns
|
|
if columnsParam:
|
|
if isinstance(columnsParam, str):
|
|
try:
|
|
columns = json.loads(columnsParam) if columnsParam.startswith('[') or columnsParam.startswith('{') else columnsParam.split(',')
|
|
except:
|
|
columns = columnsParam.split(',')
|
|
elif isinstance(columnsParam, list):
|
|
columns = columnsParam
|
|
else:
|
|
columns = None
|
|
elif taskSyncDefinitionParam:
|
|
# Extract columns from taskSyncDefinition
|
|
if isinstance(taskSyncDefinitionParam, str):
|
|
taskSyncDefinition = json.loads(taskSyncDefinitionParam)
|
|
else:
|
|
taskSyncDefinition = taskSyncDefinitionParam
|
|
columns = list(taskSyncDefinition.keys())
|
|
elif dataList and len(dataList) > 0:
|
|
columns = list(dataList[0].keys())
|
|
else:
|
|
columns = []
|
|
|
|
# Create DataFrame
|
|
if not dataList:
|
|
df = pd.DataFrame(columns=columns)
|
|
else:
|
|
df = pd.DataFrame(dataList)
|
|
# Ensure all columns exist
|
|
for col in columns:
|
|
if col not in df.columns:
|
|
df[col] = ""
|
|
# Reorder columns
|
|
df = df[columns]
|
|
|
|
# Clean data
|
|
for column in df.columns:
|
|
df[column] = df[column].astype("object").fillna("")
|
|
df[column] = df[column].astype(str).str.replace('\n', '\\n', regex=False).str.replace('"', '""', regex=False)
|
|
|
|
# Create headers with timestamp
|
|
timestamp = datetime.fromtimestamp(self.services.utils.timestampGetUtc(), UTC).strftime("%Y-%m-%d %H:%M:%S UTC")
|
|
header1Row = next(csv_module.reader([headers.get("header1", "Header 1")]), [])
|
|
header2Row = next(csv_module.reader([headers.get("header2", "Header 2")]), [])
|
|
if len(header2Row) > 1:
|
|
header2Row[1] = timestamp
|
|
|
|
headerRow1 = pd.DataFrame([header1Row + [""] * (len(df.columns) - len(header1Row))], columns=df.columns)
|
|
headerRow2 = pd.DataFrame([header2Row + [""] * (len(df.columns) - len(header2Row))], columns=df.columns)
|
|
tableHeaders = pd.DataFrame([df.columns.tolist()], columns=df.columns)
|
|
finalDf = pd.concat([headerRow1, headerRow2, tableHeaders, df], ignore_index=True)
|
|
|
|
# Convert to CSV bytes
|
|
out = StringIO()
|
|
finalDf.to_csv(out, index=False, header=False, quoting=1, escapechar='\\')
|
|
csvBytes = out.getvalue().encode('utf-8')
|
|
|
|
logger.info(f"Created CSV content: {len(dataList)} rows, {len(columns)} columns")
|
|
|
|
# Generate filename
|
|
workflowContext = self.services.chat.getWorkflowContext() if hasattr(self.services, 'chat') else None
|
|
filename = self._generateMeaningfulFileName(
|
|
"ticket_sync",
|
|
"csv",
|
|
workflowContext,
|
|
"createCsvContent"
|
|
)
|
|
|
|
validationMetadata = self._createValidationMetadata(
|
|
"createCsvContent",
|
|
rowCount=len(dataList),
|
|
columnCount=len(columns)
|
|
)
|
|
|
|
# Store as base64 for document
|
|
import base64
|
|
csvBase64 = base64.b64encode(csvBytes).decode('utf-8')
|
|
|
|
document = ActionDocument(
|
|
documentName=filename,
|
|
documentData=csvBase64,
|
|
mimeType="application/octet-stream",
|
|
validationMetadata=validationMetadata
|
|
)
|
|
|
|
return ActionResult.isSuccess(documents=[document])
|
|
|
|
except Exception as e:
|
|
errorMsg = f"Error creating CSV content: {str(e)}"
|
|
logger.error(errorMsg)
|
|
return ActionResult.isFailure(error=errorMsg)
|
|
|
|
@action
|
|
async def createExcelContent(self, parameters: Dict[str, Any]) -> ActionResult:
|
|
"""
|
|
Create Excel content with custom headers.
|
|
|
|
Parameters:
|
|
- data (str, required): Document reference containing data as JSON (with "data" field from mergeTicketData)
|
|
- headers (str, optional): Document reference containing headers JSON (from parseExcelContent)
|
|
- columns (str or list, optional): List of column names (if not provided, extracted from taskSyncDefinition or data)
|
|
- taskSyncDefinition (str or dict, optional): Field mapping definition (used to extract column names if columns not provided)
|
|
|
|
Returns:
|
|
- ActionResult with ActionDocument containing Excel content as bytes
|
|
"""
|
|
try:
|
|
dataParam = parameters.get("data")
|
|
if not dataParam:
|
|
return ActionResult.isFailure(error="data parameter is required")
|
|
|
|
headersParam = parameters.get("headers")
|
|
columnsParam = parameters.get("columns")
|
|
taskSyncDefinitionParam = parameters.get("taskSyncDefinition")
|
|
|
|
# Get data from document
|
|
dataJson = self._parseJsonFromDocument(dataParam)
|
|
if dataJson is None:
|
|
return ActionResult.isFailure(error="Could not parse data from document reference")
|
|
|
|
# Extract data array if wrapped in object
|
|
if isinstance(dataJson, dict) and "data" in dataJson:
|
|
dataList = dataJson["data"]
|
|
elif isinstance(dataJson, list):
|
|
dataList = dataJson
|
|
else:
|
|
return ActionResult.isFailure(error="Data must be a JSON array or object with 'data' field")
|
|
|
|
# Get headers
|
|
headers = {"header1": "Header 1", "header2": "Header 2"}
|
|
if headersParam:
|
|
headersJson = self._parseJsonFromDocument(headersParam)
|
|
if headersJson and isinstance(headersJson, dict) and "headers" in headersJson:
|
|
headers = headersJson["headers"]
|
|
elif headersJson and isinstance(headersJson, dict):
|
|
headers = headersJson
|
|
|
|
# Get columns
|
|
if columnsParam:
|
|
if isinstance(columnsParam, str):
|
|
try:
|
|
columns = json.loads(columnsParam) if columnsParam.startswith('[') or columnsParam.startswith('{') else columnsParam.split(',')
|
|
except:
|
|
columns = columnsParam.split(',')
|
|
elif isinstance(columnsParam, list):
|
|
columns = columnsParam
|
|
else:
|
|
columns = None
|
|
elif taskSyncDefinitionParam:
|
|
# Extract columns from taskSyncDefinition
|
|
if isinstance(taskSyncDefinitionParam, str):
|
|
taskSyncDefinition = json.loads(taskSyncDefinitionParam)
|
|
else:
|
|
taskSyncDefinition = taskSyncDefinitionParam
|
|
columns = list(taskSyncDefinition.keys())
|
|
elif dataList and len(dataList) > 0:
|
|
columns = list(dataList[0].keys())
|
|
else:
|
|
columns = []
|
|
|
|
# Create DataFrame
|
|
if not dataList:
|
|
df = pd.DataFrame(columns=columns)
|
|
else:
|
|
df = pd.DataFrame(dataList)
|
|
# Ensure all columns exist
|
|
for col in columns:
|
|
if col not in df.columns:
|
|
df[col] = ""
|
|
# Reorder columns
|
|
df = df[columns]
|
|
|
|
# Clean data
|
|
for column in df.columns:
|
|
df[column] = df[column].astype("object").fillna("")
|
|
df[column] = df[column].astype(str).str.replace('\n', '\\n', regex=False).str.replace('"', '""', regex=False)
|
|
|
|
# Create headers with timestamp
|
|
timestamp = datetime.fromtimestamp(self.services.utils.timestampGetUtc(), UTC).strftime("%Y-%m-%d %H:%M:%S UTC")
|
|
header1Row = next(csv_module.reader([headers.get("header1", "Header 1")]), [])
|
|
header2Row = next(csv_module.reader([headers.get("header2", "Header 2")]), [])
|
|
if len(header2Row) > 1:
|
|
header2Row[1] = timestamp
|
|
|
|
headerRow1 = pd.DataFrame([header1Row + [""] * (len(df.columns) - len(header1Row))], columns=df.columns)
|
|
headerRow2 = pd.DataFrame([header2Row + [""] * (len(df.columns) - len(header2Row))], columns=df.columns)
|
|
tableHeaders = pd.DataFrame([df.columns.tolist()], columns=df.columns)
|
|
finalDf = pd.concat([headerRow1, headerRow2, tableHeaders, df], ignore_index=True)
|
|
|
|
# Convert to Excel bytes
|
|
buf = BytesIO()
|
|
finalDf.to_excel(buf, index=False, header=False, engine='openpyxl')
|
|
excelBytes = buf.getvalue()
|
|
|
|
logger.info(f"Created Excel content: {len(dataList)} rows, {len(columns)} columns")
|
|
|
|
# Generate filename
|
|
workflowContext = self.services.chat.getWorkflowContext() if hasattr(self.services, 'chat') else None
|
|
filename = self._generateMeaningfulFileName(
|
|
"ticket_sync",
|
|
"xlsx",
|
|
workflowContext,
|
|
"createExcelContent"
|
|
)
|
|
|
|
validationMetadata = self._createValidationMetadata(
|
|
"createExcelContent",
|
|
rowCount=len(dataList),
|
|
columnCount=len(columns)
|
|
)
|
|
|
|
# Store as base64 for document
|
|
import base64
|
|
excelBase64 = base64.b64encode(excelBytes).decode('utf-8')
|
|
|
|
document = ActionDocument(
|
|
documentName=filename,
|
|
documentData=excelBase64,
|
|
mimeType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
validationMetadata=validationMetadata
|
|
)
|
|
|
|
return ActionResult.isSuccess(documents=[document])
|
|
|
|
except Exception as e:
|
|
errorMsg = f"Error creating Excel content: {str(e)}"
|
|
logger.error(errorMsg)
|
|
return ActionResult.isFailure(error=errorMsg)
|