From 29c31e79bdc77eb701610d6d2266064bd21c3984 Mon Sep 17 00:00:00 2001
From: ValueOn AG
Date: Wed, 24 Sep 2025 23:18:10 +0200
Subject: [PATCH] Tested workflow engine 3.1
---
app.py | 6 +-
env_dev.env | 5 +
env_int.env | 22 +-
env_prod.env | 25 +-
modules/connectors/connectorDbPostgre.py | 21 +-
modules/connectors/connectorTicketsClickup.py | 145 ++
...rTicketJira.py => connectorTicketsJira.py} | 87 +-
modules/connectors/connectorWebTavily.py | 36 +-
modules/features/init.py | 18 +
.../mainNeutralizePlayground.py | 51 +-
modules/features/syncDelta/mainSyncDelta.py | 942 ++++++-----
modules/interfaces/interfaceAppModel.py | 17 -
modules/interfaces/interfaceAppObjects.py | 77 +-
modules/interfaces/interfaceTicketModel.py | 4 +-
modules/interfaces/interfaceTicketObjects.py | 1376 +----------------
modules/interfaces/interfaceWebObjects.py | 9 +-
modules/routes/routeDataNeutralization.py | 20 +-
modules/security/csrf.py | 10 +-
modules/services/__init__.py | 62 +-
modules/services/serviceAi/mainServiceAi.py | 12 +-
.../mainServiceDocumentExtraction.py | 80 +-
...zation.py => mainServiceNeutralization.py} | 67 +-
.../serviceNeutralization/subParseString.py | 8 +-
.../serviceNeutralization/subProcessList.py | 8 +-
.../serviceNeutralization/subProcessText.py | 6 +-
...Sharepoint.py => mainServiceSharepoint.py} | 53 +-
.../serviceTicket/mainServiceTicket.py | 41 +
.../mainServiceWorkflow.py} | 68 +-
modules/workflows/methods/methodSharepoint.py | 63 +-
modules/workflows/processing/promptFactory.py | 16 +-
modules/workflows/workflowManager.py | 68 +-
...y => tool_security_generate_master_keys.py | 0
32 files changed, 1360 insertions(+), 2063 deletions(-)
create mode 100644 modules/connectors/connectorTicketsClickup.py
rename modules/connectors/{connectorTicketJira.py => connectorTicketsJira.py} (87%)
create mode 100644 modules/features/init.py
rename modules/services/serviceNeutralization/{mainNeutralization.py => mainServiceNeutralization.py} (79%)
rename modules/services/serviceSharepoint/{mainSharepoint.py => mainServiceSharepoint.py} (89%)
create mode 100644 modules/services/serviceTicket/mainServiceTicket.py
rename modules/services/{serviceWorkflows/mainServiceWorkflows.py => serviceWorkflow/mainServiceWorkflow.py} (90%)
rename tools_security_generate_master_keys.py => tool_security_generate_master_keys.py (100%)
diff --git a/app.py b/app.py
index ccf0c7d1..16a60595 100644
--- a/app.py
+++ b/app.py
@@ -208,9 +208,6 @@ async def lifespan(app: FastAPI):
logger.info("Application has been shut down")
-
-
-
# START APP
app = FastAPI(
title="PowerOn | Data Platform API",
@@ -248,6 +245,9 @@ app.add_middleware(TokenRefreshMiddleware, enabled=True)
# Proactive token refresh middleware (refresh tokens before they expire)
app.add_middleware(ProactiveTokenRefreshMiddleware, enabled=True, check_interval_minutes=5)
+# Run triggered features
+import modules.features.init
+
# Include all routers
from modules.routes.routeAdmin import router as generalRouter
app.include_router(generalRouter)
diff --git a/env_dev.env b/env_dev.env
index 4348fe37..266c6023 100644
--- a/env_dev.env
+++ b/env_dev.env
@@ -5,6 +5,8 @@ APP_ENV_TYPE = dev
APP_ENV_LABEL = Development Instance Patrick
APP_API_URL = http://localhost:8000
APP_KEY_SYSVAR = D:/Athi/Local/Web/poweron/local/key.txt
+APP_INIT_PASS_ADMIN_SECRET = DEV_ENC:Z0FBQUFBQm8xRjFXZXRwU0NnLTNhdFVUU3ZlcFU4emRMa2xLRno5c0xwSVhOcjgzNlBUWnZ2V2RmQ0RmRmE4a3BTQ3FRMHN2aWdsSTAxSDJrUGJ2UmQwME5Pa3RNTTgyVFh6NUl4YTJoZTVxdkExUVkyWnpac1k9
+APP_INIT_PASS_EVENT_SECRET = DEV_ENC:Z0FBQUFBQm8xRjFXQWY5WWpQYXUzX2dTQllVNk1Vb1J1S2t2NG9PMEYzSWNLeVR6WlhvYjU4TDFmYjZva01oZll5QWI0MHU5cXJvT0lvZHdoNW01WWxqRG9pdEQyWTcxYWlJVE5SRXVIMkh2VTRlYk1kSGRVNnM9
# PostgreSQL Storage (new)
DB_APP_HOST=localhost
@@ -76,3 +78,6 @@ Connector_WebTavily_API_KEY_SECRET = DEV_ENC:Z0FBQUFBQm8wSFJNSEJ2YmVieFRaWk5yR1k
# Google Cloud Speech Services configuration
Connector_GoogleSpeech_API_KEY_SECRET = DEV_ENC:Z0FBQUFBQm8wSFJNSFFITGlUVzF3NE5Ldk10d3o5MS10Q2o4aEJGM250WF9CeWxFQVNaNHBhMk1hS3E5YXRrakh5dmx0VDJuZ3BsWGVMTC0tbU9wWFRWZWM1N25ibWpkeF84enJ1Y2ViMVd1V0plUWdxN3VId1VRUzBhN3MzLVBkSXEwM1BHT2Z2c3JBalh6eXVKMUNFX2pfbGdGYUg1ZUFfcXhSRnJyT0tzdWVVdG1HSHBZOUgwLUVPMVQ4YkZUc3dMcFlLWjRxQUM1X05OWm5ndmJGcjFETV9UM1FoLWt2RVVEem92UGhvZlRFXzNxOVRzQkhyV0hqeTRWQXdMdDVDbEMwOWFkTnV3UXpsYWZwRENaRzd4QjlwTjJUWHhHLVZPTzd1eXNhSWh5ajNwelgxSDRlNUx0N05yTlI1N1RjSzdIZGhFLXBOMjEwMkxsT0daSVhiWVpQZUtfNVdwdGVrazVMM2NkUGZPOHBuNjM3YXdFcGFPdlVtY01ReGhsVENwNnRvNGhJejNHd3hFOFA0bWgxalFFNDVoQ2xYTG5VN1dDZGhndEdWRlFjYzBRMUgwbzRfS2N3VVgyaXJpYmJfZzNadmx5cTFxS2Vja1I5Qm1UT0hDM1FuNk5JRmYtT2p3RWp2SWxTWGZuU1psOUN4NEJTOHkweWIzY2NjbTJRZG5oRjVxNGh4LTUwZE1zZi1zLU43Ulk4UGtmR0N6dU5RcVVvRF9DQlE5Sk1FR1YtOE84WnVuTDlOUHhQR1JLT2g0VkNIT2ctWTBuMXIwNHhSSjcxNnNWRFhQc18zSm1UR1M0Mm54TGxsRG5uX2tDSWhBNDRGaHFObkhuVmtnVVlQU1FhVWhTdnpGUDRfcDQ1OWpERklHMmN5Y0RVWC1JYlItTUozaWY1dmxZUW12NXAtUEtsQWpqUFk4NzFwWVNfSUNqeDNkc25wMnJHN3c5NTB1dmxmUFZfU0NWS1hQMTc1NmdOTmEyREZRVXB0cmlyaldkT3B0Q3FQMFdpdWQ3WU1RZDZKYlFneDdnQ2NWWHFHSXl1c2xRN21LbDdyUGFUcWFxeVVTOWoxSkVJaFZiUHI2VFBHWEdvM2Q1cXdIVGYyc3Y2cVdRd00ydHdrME8tcDVqSmNLV193R291VElTNWFNa2pMQi1zX21VdnZ1R0tTbEJndndvbWRrVE52eW1aTFFzRURtdGItc3FJeXJDenVTWTlIZ0E1eG1yX2N1SHJSUWIxdm8wakdzaDIyaDQ0cE9UdDlhclp2MzVVamQ2em0zbmdLUzBJa1ZaRFpQaTBnZGpTWnRhRGZxUVNZWDg5VDFndWFmZlZnVG5SUEhlWkpfQnREWS0xbEZfNXd5OUpEUkZHa1NZNWtPbnBadFFialgzazlyM0dTb3ctR2x5LUozT3VDc3F1Tk5TbGN2MnRRS1hTb1gzWUNVSlJuUl85azhxaGxCMzVNQUQzVGg1cDZHalRaOUFrM1JPSGJKaGlKRTAwbnV4TmxIZnhkMF9FODVKUk1GZGlWZk1ScnhmQnJXWmRxMTk3SWhIdnBjSVJJOElkalRUWXFRTFNvQXZpdFpFOUdDWkhHOTRLVmN2cEh0X2JpYjNvRjhvUHFVQVNQdXY4OWxQSWNvcUNfZW5HYy10dEFicldhRHZLS1ktY2RGczQta2lGWXkxb2RhNUZMNExabWx0dXdhR3BSWGpSYVUxRXJZVTNBYmdNVFd5NW1vY2s0T0RlV3hqZjNSMHhJakY1TDBackV5bmM2V1o2SEJlT3RSbnpPR0VXbmhQTUtPMzYyU1RjbFRmQUlWTUZjVGRheXBuekZJN3NNZVFFZ3JHenNnOFdQVWxsbFBoYTVvQUd1NGx2SDdYcGhrdUpSWlRIRWVVUkpxdjJSZV9zb0J3N3o4QnRpYXpTRHdkZ1pqSWswSjdJMjVEZDZUNzZuWDVXWkNxUDRtQ1p1dnk2ZEx0S0NKT2ZUc3B5eEdRdEpnTlZQMkt5OHFjQ3FfcHpzUFZEY3Z5WDdEQkt4cEN2MFg2eXF4bDZFeHZFWk5tMFpUR0xDZi1JVjN4eUtRaXlNXzBJUFV2N19MVTRhMWtxWnd6d0Y2bVNFQUJSdEU5Z01FTjEtZDJmWkpEYUlsTVJnTEJYdU1iVFoySEttd3libURrSUNJelVic2Mzb0t5ZzNDX0hjZUtfOFQ1QkxRWmx2dmhnbDhNZllla1dNa0Y5akVpNDRKdHRSUU9fTE9sYVUzdzZtTkJEYTBWdkxkRURSa01TOGxWcVZkUmxkWTA1QjJjS1pOUjJEQTZxeDdSVXhNWldXbnE1V1J2STVCNkt2VHRuNEdtaHUweWdEbUZyMlhWd09FWWI0UUFyQVpUeDE3QXdfQkMtcjdpUU5GUTQzUEczNWg1Wm5rVEgwRW11RFowVnFxYnpGNUYwYks1Y3JPbTdUc2ZXS1ZfYzdhcno3U1ZXZUVkblRoOVl5XzZpTUgwRXFZeFd6NXdqTGlvNm1QeXgxS2ZFTVJSV1JVejliWFBVRGU1MWVudEZzRDFwSW94YlU1Y3JmallsVldXcHdvTmFQdnU5UE0tNHNHMXhPWE1JQUxCNC1WVVRJNmNJcTM3a1dUWWwzSVptTFg3OXlWLWxITkdiR0MyTmRzRWFOeHBMZEVzbms3RC1MTFo1TVhKeURhUW9peHk1bHhJbHphVzR4RmxiUkJwcmkzcWZ3S3dWV0Jkb2VaZ3pMTXdUNUJmZjZfVEVXeDFNMnBvemM0TUJNeUQ2SE1aeWczc0V6M0NUMHFGdURMbTRka3AzZ1d1TUh2V1c5RzBKQVVlTEstWEthOTdaWUZHTlRHaVNmbEFJRFU3M0l2TWlBNF9kaFpJUXlxMHJYa2lxOGFRbDNqMTA1RDFFclFTcGxmb0g2WVI3Z0NrLWN4cUNzNWVuR2VMaE41dWRqMnR5eWNuM0gwUmIwcTFEQ09qbmJCUFIwbjM4MGF6TlhxQWpKOFZXWGNKdnl2Wi1zU1BsZU5NYWpsbzVKMGxTLUJKckd6enJnZWhXemstenN3NGNqUk9HeGlGaFNhSl83TlUzLTVZWW9zYVZZTTZzSjNfd3JkVDNaZVp4dk1GQVMxblJBRW1BWUZLU1VKUFkyQ1dPbndUNjYwdll2U0JxN1FQNk5OaGVYR3U5TXdGNGFVZGVXcS1tS2dwbVc1V3hEeXhVNkJ2cjdGX2FpY1NvOTJhcWFyOUVGOFpOdmd0R29Rb2RIaU01R05LeWRxUE00WlhOQVlMbkZxZDNyUFRXdUFGZ0lOUmp2RzIyaDlzMGxNQk40VzFzYjAwMEhjRVlrNWJ5cFhpVWYxQkxYQ25rUDJ3RTY1VlVFLThiNG1nY1hkdnZTMGoyVlN6dkJleFhndDNCODhlOVl1ZHBkci1hd3l0NGNXeWZ6aUp4S3pHS1c4aDM3WElBTjBwYlNSbmJoMk5SNF81VVNqd0dXY1JUejVsZnpGS1Z5dHFPNUVVM1I5eGhjblZjMV9idFJkc3NZaUdHRlIzQWJQdHhzT01qVW8xUUwxNHZmY3Q1aHBnNHhXTGRjb1BmTmM2X0NmdkpxNS1JMHNQNVg1N0xsd0pmdE8wNktkUGpuX0F3LURyaGhyajg3eWNDdkozUFZIYmpJTTZ3WWVCVFZUd1AtRklFUUxTNXkzalpfdlc4VE1tOHU1Q0MtUWdLbEdYRzdVU1RkM3gyeEY3eXBWLUhXVVo4VkZoUHVkakJPNk0tNTJKTU1JZjVISlR3SmJBQkVhRW51UHg3UjBOMVRPRnF2dzIwRkgxczBBUWZpemFFMzFTeDJfWHZhSkhsTzBhcFIzVmZRODEzRUl1b1ZDUGFqYUxjN2JsbkhYdHVPT00yYlUwbmpVbkU0RkJXbWx5UVFJdHNvNUdxQzMyQnQycDJpMjlnd2xwb3huRUJiZUg5dkhaMjhMV2R5T0NsU0N4WjdBX2ZfODhOdTZOZ0x6WlRIUGI3MzR1ZkJicHN6NzUzRzlsUmVkNlR6MjZjTTA3c290Qzh4ejRiWERHbmFtV1BQV2ZKb2pGU0F1OGsySG9hNHdtSkkxTWpwV2gyaVpWcFpsRWs5a0hSY3UzMk4wQ0dkZWtMbG4xOFZ6TXdEOXBob3I0NjNkT28tZk5IcW5FUkg4YnBtUVFLY1Q5M1lzYzhrRGZOaDF6SnpnejRuM1Y3SW1xMUJmLXpJdEM0UjNHU0t5OEhoamxxLXRmWmtyOS1ud09XeGFzc3VFXzNPWWNGcXFwdHN2cVFEZ0dWdUNKbF9Lc3d6dVhPb3NLMlNEaW1xd3JPLUViYV9GTnNRPT0=
+
+# Feature SyncDelta JIRA configuration
+Feature_SyncDelta_JIRA_DELTA_TOKEN_SECRET = DEV_ENC:Z0FBQUFBQm8xRnZIVUdfQTg5dXRfenlxanFvdXBCSmltQmNhRFNkUnNYai0zWlZqV1NmS212SlhsRUdwYXRRcjVvRVdqaWN0RWNrMjNYWG1VazJOaU5xWnZDWFJoWGxvTXpTbHNzbk9nTGlxa0ZZQk5WZHNHaHVDdmNQYTBRbHBQaFlvY0FYM3NzU05MUHNZU1AxaWNCM1ZTQllLdFpZX2pWektnUTF1WGRiRGtsOGE0bVdPYXp4aEhlWDVTWmYzWlRuVl84Zk5pREZJay1zMWRpTWxKYnFtYzBXM01vMzdiN0JlQl9kYXN2Sy1ZZUdnT2dYNzFuajA1QUJVbTFKS1dfUjNUMS1MSC0wOGc1ZDd2NTJESTR6M2Y2SDhsUG40OWM0b2djeDdZTS1qOVl2YlhhbGx2dG1KWkd3andQZXJVVC1zWDdBMzdHOVdRMHRJN3RabzZNWWlEY0xqcUg4Nk9RPT0=
diff --git a/env_int.env b/env_int.env
index 5677525e..e853401a 100644
--- a/env_int.env
+++ b/env_int.env
@@ -5,30 +5,32 @@ APP_ENV_TYPE = int
APP_ENV_LABEL = Integration Instance
APP_API_URL = https://gateway-int.poweron-center.net
APP_KEY_SYSVAR = CONFIG_KEY
+APP_INIT_PASS_ADMIN_SECRET = INT_ENC:Z0FBQUFBQm8xRjFaenVxWnprRDJjMUhjWk1CTnpreEd0eDh4bXBaQVdHQVlQWEQ5bERuN2tEV3pDZmlldFhPazhpRll4bjhLd0d5RXhtaldBVUdFU2ttQ3cxdmoxcFNDSkJMT2RRV2JDT2xKeGhHUXNFU2JfMEU9
+APP_INIT_PASS_EVENT_SECRET = INT_ENC:Z0FBQUFBQm8xRjFaLWp4RG5ubVR2M2lXRHRZSFpSVE91WFRSX2hwME1rTV9LdF9MX01rdHMtNkhjdWRlcGd0RDhYdWlxMUZ6VFV3akxfUTZYa3FoMGFmZ3Y5clp1X2gwV0RjNnVvdE5zY25GMGYyS0V2a0ppU2c9
# PostgreSQL Storage (new)
DB_APP_HOST=gateway-int-server.postgres.database.azure.com
DB_APP_DATABASE=poweron_app
DB_APP_USER=heeshkdlby
-DB_APP_PASSWORD_SECRET = INT_ENC:Z0FBQUFBQm8wTnBISUNVLWVobHJzX0xtS0pMcV9neXY3S05qc1F6RU9SRTdHM2F2VW1ldVlMYU9zRTU2OE9QTDBmcGRjN3ZUb1dobGZrUHZrR2EyWURtUXRYWk5MTExMVUJxY01yaFBTWFE4OTlHNHBsWHFSUnc9
+DB_APP_PASSWORD_SECRET = INT_ENC:Z0FBQUFBQm8xRjFhVHBfQnZMRi15aHBVR0o0RkV3dEptRGFEdnJqUEY2c1BONnhXb0pyUDlTYUQ4VmlOOU5POHgycnBLejI2NjAtRDNkMkNtUGxqamVTRkgyWFYwNDVkeG1FazBPSmFXUjU5RGdLSlVsZ1FFVlRnb1ZiSG1yZU0za2IzOXhSZVY3UkpfbzdjSEtWLWpqdEl1b3pONXowRGhKRU8xUXNnUmN2a1VPMXJndWdfYkZSSklGNG5DNFpOdXYxZGJkOU1zRXpsV0lMVzlLa3JHZzdWSVpSbkwwQjQ3TnVUdnJGM2FqTy1IaFY4MWtuaEdKRkVXVjJ4UWprWmR4SWN6ZkQ4TTUyRFJma3J0TkswVUNld3R5Nk9nNWxJbHVWVUE4QnV5LUpmUU9JV1FuVE5wVDA9
DB_APP_PORT=5432
# PostgreSQL Storage (new)
DB_CHAT_HOST=gateway-int-server.postgres.database.azure.com
DB_CHAT_DATABASE=poweron_chat
DB_CHAT_USER=heeshkdlby
-DB_CHAT_PASSWORD_SECRET = INT_ENC:Z0FBQUFBQm8wTnBIVmhCSEtCcDF6dXBCSkVzOTdaNUZVOUgtZ2JQQ3lMUjVKdUgxbnBkZHdPSFE5amNWTzhKNW4zcV9QSFdNakFVNXRVcDVlTnd4Tm51QjA2MTVVMVY1b3dBZHhQZXZLdUlsc3lBektKRjhIUXc9
+DB_CHAT_PASSWORD_SECRET = INT_ENC:Z0FBQUFBQm8xRjFhRzFNT3RtV1RFNDlrZnNpSTlqWXdXbnhmeDZMb2VVUG9pd1k2NmQtWlRoeDY2OURDWXVSdHJCeEo4MDBWbTN1SFN2M2dGalBKQzhtN05aS1RVSnRVZjF0T2NRa2o5c0dhRFJvRHlOektwZnZOZm5ZLXNXSEp6Y1RFNUVnSFpWQ3BTbXFGY1dNeEFKb0VEWlgxY1RWVzdLbjRraXdqSldwejY4aVpZWVFuMnNka0xhNXhDeFZvUE5idlRkQ1ZpSUJPWFN4bUZxQ20xNFNyQkRrSlBhMW1SZ2dNbnRHRGtXV0x4NHBvT3dhaWVzUFRsS0U3RVZwajA0OEZLMEVFZmNaTkFNMWdtSkdYT1d2bkZtX2JZWVNzdE9sRVV5VHR4NUZnX21haVJlMmVPTEk9
DB_CHAT_PORT=5432
# PostgreSQL Storage (new)
DB_MANAGEMENT_HOST=gateway-int-server.postgres.database.azure.com
DB_MANAGEMENT_DATABASE=poweron_management
DB_MANAGEMENT_USER=heeshkdlby
-DB_MANAGEMENT_PASSWORD_SECRET = INT_ENC:Z0FBQUFBQm8wTnBISHA2OXVrWjhaQURZM3g4WGxiTmt3WW05WXBIRGVwNFNfdmphOGdUQ0ZCMUdFTlAzZlJTM2ZFaEhVWGRqNXBtREpTalItcDNxS1BJeEZKdWc0dWxHUm41QTBMZ3VqT3pHeFVmVUtJWE1YbTA9
+DB_MANAGEMENT_PASSWORD_SECRET = INT_ENC:Z0FBQUFBQm8xRjFhN0xPc05UQ25ma1c4eUN3eU1TaHFrZGxpQk1pYzA2aUNQYW44Y3pBVlF5YUJramNCenRQRUJreERBRTB6dnNwLTN5R2dwdzlfRWI5VHFhSUswOUdqY2RmelIyVDk0RlIteGlwNGhlWHY4UmthSF95SXhJVFBjT0JwY0JrVUxPUmt1YWN1RHZmd19TTGlrWmEtdEdHSVExSDFjcFZxM1IxYkhZcVoxc21ndE9HZXVNQU9URDVGektkcktkTlFsUDlrRGNDN0U2S1djVktWMk16dzducllweHlQVW01MU9LbTZORnhkVGw3UllUUnBwNm1YQ1hvZVFjcHcyTjRldkRPM1BCUldReEZ6dm93VEItVG1sRFlrNk00czZQd3NrU09fN2NtYW45Y0stLUE9
DB_MANAGEMENT_PORT=5432
# Security Configuration
-APP_JWT_KEY_SECRET = INT_ENC:Z0FBQUFBQm8wTnBIVXVUQnhWcjhvVFhtTDl5T1M1SXZZdjZDY0tIa0hmbnRuanUweUdoQ04xNzhod3VscG44V0xlNldzY2t1MVE5UjVjUTdSRUU1N3VBUGNVN0ozU0o1akNBX0x0X1FNOGE0TE9paTh0ZEVnZmNTbGFnSjBpNTBXMTZxemJwWmRTdkJOWms4VVRieGpSM3VtaFY3Zmw0NlJTbVVfbDdwYldVYUlfbGVFUGhsajVZPQ==
+APP_JWT_KEY_SECRET = INT_ENC:Z0FBQUFBQm8xRjFhWHFFUTU2RlJKUDlrbk5xWDRqYklCUFUzVy0zTGFKdTc5X0RGcE04a0M3VkFqZm9GLXEtUmNhcHBzdDR0WHRNTERPZEYyaGtqb2FlX2RMSU5QYmpuaU5pWEFZMVJwMmFHT2NGUVZMQmdKM1FKTzRDNG9jb0ZiOXhldjMzOVZneTZZUHF3dmdyeFlxVGF3OXpvaElqY3JWekhtT1FISkFOclJsNFR4MVJTRTJNUEZjZWtQVUpVaGE3R3NtUzVMYVFBTEVBb0tCMUlSclVoVzBxQU5OVml0cWNDYzNvUWZfbHcwVEsyVkoyOWxneW1qWGxKZFZlTkdhSFRscDA3YWhjZlpJWi1LWldEVFk2ZUxxUWwwb1l4cE9SdmwzcmNuNWRDeUZBcEowTkRta2lZMnNzd0x3eFJ2VXc5dGVwLW5ZbVc4VjFmTGRXRENRZVZkRHVvNHNFeV9mQmsyTGU4U0lKdUtraV9kSEpCZnJxTWcxYXNxMzYwaWg2bm1vbFNJMnkwSWxfazMyUldpMGZfV0NfQWY3bGJFOWJ0Zzc2RnpRNnY5clF3MnJVLTgzQT0=
APP_TOKEN_EXPIRY=300
# CORS Configuration
@@ -51,29 +53,29 @@ Service_GOOGLE_REDIRECT_URI = https://gateway-int.poweron-center.net/api/google/
# OpenAI configuration
Connector_AiOpenai_API_URL = https://api.openai.com/v1/chat/completions
-Connector_AiOpenai_API_SECRET = INT_ENC:Z0FBQUFBQm8wTnBIS0RqLW13RThlbTNNYUdLa3pXeFVYVm5Mc0czREtRczRBSlVjcVJJcVpKU19kRUZTU2pqMGZFR2pHZnZ4TGdMeFJqbHl5aTYwa2pzcTlNZklnMUNIZHZwdGFuWFhGZDlkemI2cnJuRURBZVBmM3Fxbm91c0ZQai1UMGJSM29kanIzMFB4Z2x6QWcycVk2SzRHQXc2YmZRPT0=
+Connector_AiOpenai_API_SECRET = INT_ENC:Z0FBQUFBQm8xRjFheDVaRE1LYUhScWpEUFhSNVNSQzNZRGU2VmZIYUh0QXkzUFJ6QlY0Mjc2S2dTWk1rMDNLeGE0bFNNNXZST0JKZ0JKT01BVnM2eU96YUtmTlZqOTRpWDB5blJ2Y0xuTE91eUhJZFlfZE5BWVpRZk9SX3RaMldZTk9TMmdoUXZQRWFpUEh6dGcycDRqYkg0cWt3bHpnc0swT1pGbEU5U1hfelU0Ri1HUjVEWW1tNXR3eXZEOVBfWXRMMzkyeDBIVXFZOXNWQ1R2bF9aU3Q5eG1Gb3M2dlBVRTRVRURxOWJxSWRHOEQtMWVhWTZXUDRnUUhPT0d1Wl9UNmlBelhlOEFuVUFKMUplZmNNS0xVUVlWakNoQUotSk5oNnRRV0phbVJNdDNCdklPZV9jYXBkRzd6YVNCejEtVEpTdi11LUlhdzB6RElHVUxORzBPY3NScnpYM1g0VmlFYTI4QlUtc19NcjRwaTZOUDZfM0s0NWxSZ29Mc1gxRVRtUTRDOE11cTgt
Connector_AiOpenai_MODEL_NAME = gpt-4o
Connector_AiOpenai_TEMPERATURE = 0.2
Connector_AiOpenai_MAX_TOKENS = 2000
# Anthropic configuration
Connector_AiAnthropic_API_URL = https://api.anthropic.com/v1/messages
-Connector_AiAnthropic_API_SECRET = INT_ENC:Z0FBQUFBQm8wTnBIN0pPeHE3SzFWbTNySU1NRThmcURKWWNiZ3pQLTlwSXZmd0JkTUxXb2VGTVIyeUhZb2JKRzJsQ1AwTlZBWl9RYkRaQkVoR3dxQkdGYUFmd0xRdW1jUGxXdjJPbDlDVTVtT1c3aldRVVNoWmRLd09TZW5xU1JOVHp1ZE5Za0xBODR1TlhMQ1ZiaEZ4Nm00QnpPSks4RGVxYUhqaGdvMWVwMzBKSTdIUEVXSE1XM1ZNUjNBWDAzLWxwLXlib29OV0pOV21MTkFpb0ZDLU5seHMyTldxSFdIZz09
+Connector_AiAnthropic_API_SECRET = INT_ENC:Z0FBQUFBQm8xRjFhejRGeGtkQkt0Wml4NHZkY0JlV3BGVHBPRmZiQ0RrcmVVMUwxSWczZUExblA5eGFwaFRhTmdEUXVTS1Fad0daZXBWOElpS1FQUEhnT2tGU0hBSnRRdUlOTXVSWndBTlRwUFdsYVlQNWpZbHFBZURUZ19RdGNhZzVDZmF3QWhQaWV3eHFFc3BtS2dnTnI1U2oxRUtSc2V0SVdjSkNqUEVBdUJqeDhJTklJMWh2Wmk4RFBGV2d5Zk9QMGw4b1hQNm1pRnE5MFEwV0lWWFJsMEVOWHpNdWpBQWlMTXFHbmY3dVN1dUV4SVBJcnlRYU1EeV9DeFRucjlXREo4UG9DX3E0dzF2YzZKS2JGeVRtYUhFUEduR0JNTUljcHgzNHhVeVdJWC03X1NUREdJcUNwU043QkFTME45eXBkUUNzanRRNVVZT2xLWG1pcHJKNTQ3a1pjQUxXdlBjcmFRNEIySzVuNHBfSm4zRWRiM21GazdNZGNQWnUtSGN4d1JnVmZoNllabXd6dG0zQ2NKV2NxWlFBeUt3Y1puTEJ5ZkRvYXlQV3Aza3hXYzlxSFBscU85LXNyZC1NdFNNSWFmTmZSQTdiemNPQnlhYWNtaWYwQUpLU2lrLS1iZE44VGdCNzJoNGlZRHpSXzZSZWx5Y0E9
Connector_AiAnthropic_MODEL_NAME = claude-3-5-sonnet-20241022
Connector_AiAnthropic_TEMPERATURE = 0.2
Connector_AiAnthropic_MAX_TOKENS = 2000
# Agent Mail configuration
Service_MSFT_CLIENT_ID = c7e7112d-61dc-4f3a-8cd3-08cc4cd7504c
-Service_MSFT_CLIENT_SECRET = INT_ENC:Z0FBQUFBQm8wTnBIVXktVWJLTEdLSDd1MENKejQ2bzdCTUlTQ1ZELVJfSGhaeExkMjQ4N1dNVnhjZjRTMl83dlBqeEJCMHVabVpZVlQxRjhjQkRiOHdpMUNaODJqN0UtYW9GallJekY0U2RVZHpORXg4dThuc01uMy11ZGtDb01BQXc3TlE1ZXBjaU4=
+Service_MSFT_CLIENT_SECRET = INT_ENC:Z0FBQUFBQm8xRjFhdFBRZ2NkYlUwZW5STkFWdUdCYzM3b2dLTC1CWGo0eS16Rjd6QUpTNTItZjkxbUdTYXNMejlmTGt1QWsweHlCWl9ETldNcUNwYWtmUzJIUmJSUTkwczktaXlXRjY5ZHBXRElTZXpkdlp4ZnVYc2tNRWE4S25iWlNfYkJYNU03QzdVXzY1N1ZoZ1R2ek1fYmVsQUVQVkZCb0E2N2ZPVmtSWU5Ya0NhclB3Wkt1QlRKQXI0aUVpZngtaGZiN1FGYjYxVHFjcjNzbGphVjdQM0tTQ0FNNUN1bFdYVFJfbzdWajFvVDFPQzFoRzFmYVBTbmFtT3FkT2t1WW1LVkJiVnZCd3Vxd3l6d3ZMZkdLUWtMeEtVWDhmbE5CaXFpNHBnTXJVdTBZWUNnQjJscFV0Q2g5RmtkQ3hTQ3drRjcwWFVsbUk0cGREOGRRR1FwMW5oN0V1dGRVdlRRPT0=
Service_MSFT_TENANT_ID = common
# Google Service configuration
Service_GOOGLE_CLIENT_ID = 354925410565-aqs2b2qaiqmm73qpjnel6al8eid78uvg.apps.googleusercontent.com
-Service_GOOGLE_CLIENT_SECRET = INT_ENC:Z0FBQUFBQm8wTnBJeVpuNWVraERfUFBaT3BDRVk0T21KcGdrYU9zNGNyRkljNDR2TnB6R291VGJJM3d4RnBHTVVXYTRCT1F1RGFRYnNTX0xTLXFqVHVHTnN0bG9LeHdEbFpZcUNIMXFWY0dJYko4U3FNSk5vUnY2ZWRWWFJLUjR5WkJrZmpMU0pxNGI=
+Service_GOOGLE_CLIENT_SECRET = INT_ENC:Z0FBQUFBQm8xRjFhVXpEblBSZHh0X2V6bUtTMHp2NVRfV3FOeDNTci1ycG9FeF9WVVNObnJ6VFNiSEkydjNpeExYWkVyOER5TWNSVXFXNlpBdUVNNlZ4bU9yaGdOaFo3UGxSVnRNMWM4SlRVcGZUV2hFTDFuMkxzbVF0Nk4xQVFKMWlCbDdWNG9tT3prUDAyRmkzR1h3alBPSGFMTDNqVFZWNElDeTBhT2VoamRQVnB6bmtvM3BKNWN5dDl5dnZkMVdoek1pZjk3OXFmcDl5bkU4U1BwNEV0MXRjLV9iUHh0NGFGRHFiTTlHclJJdUJoWDUyclhOdEFfY3ZKRktrTkVOZ1Z5ZzZlNVp2ZXFpcXZfQjZTbGhHN3RnSDVqZGZIUEJSclNUd3FOOTNBSXQwclU3Uy1vb0JNUzVaRHlURU4wSlhibHpWRzJqRVo5OXBrYUJaTzdVdkNhcW1VWVRkUW1nPT0=
# Tavily Web Search configuration
-Connector_WebTavily_API_KEY_SECRET = INT_ENC:Z0FBQUFBQm8wTnBJV1BlRS1UaTZmZkVYZ2hQU0lBRXVEbDl3N3BFNVI4MlBsN2JRSHdrYV95SC1vdk1pMnQzNGRaQThrRy1HcEJyT1Y2OXdQcmw2Yk9KQ3RDRzRpamx2cFpkYkN5SjkzNVVmZnVaOWJnN0MwTGZMcVdRdU1jY2kwVGhNMXZQUG9kajA=
+Connector_WebTavily_API_KEY_SECRET = INT_ENC:Z0FBQUFBQm8xRjFhY2ZWeWRfbWpSSUl6MElHNk5tVzZqeGVMXzdFZy1Ma211aHlCUm5QSTJzMW92YWg5WWZVREQxX1NJcXVlNE52QjdxekhMVkdOc0xacEZJNmg1VEVlbXJJaWpJaEpRQ0tnZHU0R0hqaGdIRUJLYmRuR28zcWR0SHR6NG9NbXR1NENLTHZpS3FlcGRQc2tPaDRvVU5USUU1QTRjeDd3Yl8zLXVZSGxvRXNqalV1SjJNX1BIRm0zVy1hQ0tNdlBENV9yZ0dmLUpCYnJRc2dzcHpsRUZCWV9Zal9mSzJUSDQyVEFJN0twaExyWDVKSnlNUGlLbTk2N1NmUC14Z1JGX05aTnV2c043Z1JYZ0x5MjZYVEdITDNTZnFuZW9KYzVRUnpDcUVTMk9HTW02TEJaUEVZZm1wYU5CdE4yWGc4UDY1bHozOVdhQUdWd0lDeWlQbS15aWFhY01RPT0=
# Google Cloud Speech Services configuration
-Connector_GoogleSpeech_API_KEY_SECRET = INT_ENC:Z0FBQUFBQm8wTnBJSDh5aW9CNE04dDVsYnBUNWdGUWhDMmFlNmY2bnl5X1llVnNZV3VGakI5RFFNYVprYm5mU0F6TVZ5NDZkYlVhMGpzM0RPNGFmNDdvV1c0Y2hUQnowYzRmREhwRk5fMVVnejlGR0Y3V1pVemtFbEZEeTFEOUptbThaSHJJeGtwWGZZQ0VLYkpaTGRXMVFxX0hRX2treG1ES2VheTdsR1U3eUxYV0xPbzExSDZzOHBQR0FSdGh2V0hXRFpRLW52ZlJyMTBDR1VkVkh4VU52MWVwMDdxYlBfbjlMeTd5M0FIVGtaT1c3WmpwZjh6Skp2djB2cXM5NkdOS29ONzdaNGk0WlpzTlJxU2cwQWxTd25XYmllbjJXemQxY0ljZkZqZFV3MEhucXNfYUR3T3diWEFyLS1WQmRiZEJXbERuQXhXanZQUDBJZFphZGk5aHFTQVRkM3B5QllYZ0Q0V19VRlRtVThEb09TWGFHVHRKc0R3eGRoYWpkT0xRbXhGb0pFYUk3MXBGekV6WDdzekMtaU1JNlNaaXdQa19keUotSDJkZDNQTVpZQjlxLWhwRWIta05YR0sxTXRVS1ZLaHRJM1IwUTMtQlUtbHU4dmVfQjdsY1Y3ODFSZXBiQUJIdFNrR3dGelkzWjhQaXR1NlFIYm1KMFVNMmlMcGQtRE1zNmx0ZTRuVUhVRUFuNUEwTnNNSTBnaTRtaVNOT0lLQTR3U01SOHNjZkdOQ2VXQXBuZ3k3Q3NjbDh1dU5fWXVkN0pvNmxZWlQyaVVLNEFEN3dxRkV5NUU1dG5kdGxieXo3WGhIalAzWjQ0TFNLRFFVZkJFRUNjQW1xNWdUUHRTOG0xVklmd09NdGd4SURxdmI2UXU3U01PVDZSM0lhbDFQUjZobkl3VC10eThuV3BSV0l3Nkw5X1dVN1RhYkdqb0ROempfQ2xjc0lQemtaSGNkMjJjR1hjN0V3NFhta2l1MVRGeG9PekdhM1V6NGpCMG5yYmZJb1BmdndyMXdpOGdSSldmRFg0UlZSX3EyTVN3ckotaGJLbU5EMG1jYnY0VmtFNk14dzdzWVloVFhWMkQxNDlmc3QwSWJZV2ZaU3J2NlNkdHlyQUVXUTNXczJZMzBua0Zmbl9MYWxSSi1QdUowQkdINWJIZFNoUlY5V2NYYjFva3A3OHZ6MEd6MGRvNkJrQjNKa21FcHI2Y3pfTWQ2TUFzUEp5M1FZazVUSUVUTnlkQ2U1RVU3OFdYYXE0S3QwT210a1c5aVlPNDhET3JBeUFHeG41MDM3aUdXVWN6TUtUMi01aGJOWGN4WTZDN29WNl9SdGRtR0gzRzJFZmhZa3p6UUpoVEMyb196aFdUVE1nTDNDSkJuN1lsSWlMWlBVS3VhclRxd1ZhWXhNUDZRa0Jlb3N5UkhiZ2pYc29ZZm55bWFZa09DZ2lvZmE4YTRoYmJJREh0ZXMtSkN3MkJBNDlJQTR1MTlEVTFQTUFJMENCQmFCeGtXYlJJVmtSUjBuNXBDa0wtVTJuVk8tVGk2dWxmeEgyV3pkOFdDU1JhRnowLU9EbWZhYWkyZVRfSDVJWDdtd0l0TF9OQ19NRi1tUnAxdHg5a0dFSDY5RzFsR1NiV0p3VG5DckNyREVjcWQ0elV1aktKNlJkNXdIVnpXY3U1bTBUbVJ5a3VucDdualg2cU1rZkEtOWpWa2tGU0puNUpNUzVaV3Y4UmhiZWhLTkdzS0h5NkxkcmNLblB2dG9lb2xYYXlqZkZiM3ZRTFVtM1VwOFFGQ1QxWFh2cUlhMFFyME5rSEJwLW5IUS1pRmNpVXVWYUR3emg0N2lDXzlFN1NnRk9ab0lLaEVvaV9FcEVfR0VBUzRkWG9KUm1sRk9DcDEtSGQzMFFXLUt3QnBpaV9fV2lQVExXSDcwc0E1ejE5SWd5c0NnQTlyWlBuVFNCeFpxN0M4M3kxaHN2RmJXekxiNy0zTEN0N2daLWpERFJ3SFFMUk11N25mRVk0MHlyMHE1d1NDbFpFaTBGSDFFcksyYVJZUEdKemtNWE9qbDBfMEpyaVVaSFdOZWpodGt4N0g5TGN1NVRQUm41cTNxRWdyTDFjd0xTSEZibUt3R0pISkpmRzlSVEYzeWNUdm5qUEZPVlVJX1d5MGpxUWNjTzFMaGlEVG9GY3RhUWZiMFpsbVR2OHNLaUs4ZkFENmNoRFRyd290a1FFZng1ckczaWxMMEVsZ3dBb0ZXdlh2YzJxTlhwZkJTV2VBandjWFB3MjJrRTR4LUUxV2lCRTdGYjNoeVhZTUx3RjFRNlFoY1VYMHRyTmlxdm9jUjAtWndLQ1RNcENDclh4TkR3ZE9tSjFOaGxIcmtWQ2ZIaHRabGNJQVI3RnZHUGtBRWt3YmpuUUhrT0VRTUxfVFZOSWZ1Z25IWUsxVDIwZEY0blQwbEdXY3hETW41UldqcW0zMUNCVHNDZkRyTGlrVlU3c0lWdFpvUzlfTGtLMGxJZUg4dUdjTU01VUtYaVQtSFBqT2F4NXhEUUlBRU1lSU54dzFhd3d2UjUxb3JXSUdQbVRyUTFlc2Z5WkNGWlNzVTc5aWllcGsxbzRmYVlFTWw4VVVtTDdkczdzQ3NFSGMzdVltSjlfY3dyNzlPaEk1cE5jdk4weDFKc3BTUXpPbDI5Y1ptblp0TGJ5UGwzVmU4VEtWUTZEQlRtemp1YnppdDdkSHpyY0c4NlZqSmI1UVBwSTJSZ1NNcEQwRTdySGRySi1XUkhWTHlaajFZSkQwc1k5NGZDZUhzRFdBZXNqSVdwU0ZsMGlNLUZPSU1OT243N0RFQ091RjVyaVRBdEUtOWlfSTdpX25laHNwVFlFU0RjWE42amhxSHlvcDlDdE13Y3JtWFNsQkZoclVFS2hFblQ3MXpxOHJsUGMwbkVmT3ZMMFZiNmpHZVRCa2k0YTJnNHM0dWpyQXJaYjU1Z1hNWGU2aU1hY3RxYWVzU1hVWWI4bjR2Y0R1MmF0NDVlc08xTmVwQ01ENThUMC1kWk9SWG9IWWFDZ1V1RGhDY0pjVEZBUGJreXh3RTRuQUlTWVR4NlVuaGozbmJVTXNzRDdrMGZaclpsb290WmFaTzF0NThITENXN1JSN25JS18yWnpJY19maVlXN21QTWE5M1FhWGJqTFQ0RTZoY3dlcEM2Q0gzQ1RtYk9jTnVkU1drVnhZc1NkMm82c25CWkpaTW5KbTd5dGJHSHhhQzR2TFk1NW9pX2pQNDdOeVhGZ0swTklYUkRZbmdsVzNabTZjWDRrX3BCMC1OT0M4R0dMTWlhQXhIMXJVT0Z0bmp2aTBFWnRDSFYySVFULXRneFpBQ0ZwdEZaTHAyeWx3bFF0Q2o5YzlfbUQ5Xy1uWjFKQXVVTE9Qa2VPNDZmQ0kzaUxBNG9EdGpyZm9VOEZhTC05V2JBMU1KTmt6RlY5aUpILVE5bExnVkdCcDlLZExBeHRSWWxQS1pkcE9BWjdXeV8tb1ltcTVoalQwLTl5Mk5GanphVGxKUzlJUjk0Y1g2QXBSRzBSNkkzTjg3bTFlYU5XbE0wYkNacC05bzVFN3F4NXdkUFBqQU9QYWVtdUM1MWtNR1RzbzBQVHJGR1NzTEV5TzdCUXRmcXJuTUVCVV9QbXFXcG5QdmJfQWlVMXRvU1hOakpHandKbnFhZTlqUWtWbXg3MzFpS2I5SlJzN0dQN0JuaGNKSWtVX0Vxc1FPUk4zMzVTMVIzSWJYcUVYU3Jldm01bHpleU90MlU3XzhhVWI3a3pqajRjd0g3YVppY3MxNUdKU1lUZFhyUnZDYXJWcnltQ2tadEFxdG92aUI3MU96WnFBdXdLRWZkMFVtc0N0SDVJc0RYdjhJZVhVWXZfb2VDY3NxVEdZc05tRWNVZTRUVHphT1RyWlRoX25iNXlLX0pDeXZrbXR1VkxnUTlZOVpUak5GYzVUODZvVmtKNHo2QUg3b2pSZmNhY1lUUVA3WXktMjYxWjBiTE5XeWJlQ0VhY2VyVnRxd3hvUWxtSjhCSGNkOHFLRTF4QmpLc0FoYzg0b2xRZXZpVTZ5TENXNXdiTEZNV01nSXpPR2U4ZlZzXzZZeGdmSG5kN2hLeDR6WU0zWExxUXJBcXR6NmdZb0ZiZHRPSFVuRUVyZV9zOUJsN09GWE5YM25FRlNoLTlNNWVMcTctb2l1Mzdyb0Y5TDU1YjdnQVUwSFoxXzF0VFh3TGktbFl2bVQ3NzdkRHV3SlNfaEZjY1ItYUk0OHhLRk9rUXdqNUVTdGNBc1ZCY1pFQ0N0WE45MUEwUlpwejdrcDNBPT0=
+Connector_GoogleSpeech_API_KEY_SECRET = INT_ENC:Z0FBQUFBQm8xRjFhYUN6eXZFUVE1dUJRdGNVWktodUF2dUpZUmVGa1o2WlQzdVJlR1dTd0pfRFY4SVNYRWM4aWk3UGJSaEl0MGRiT19vbUdhVTNvbGFzS0NyY2pBMXdVSXFkOHVWRC0tTkpVUFdTVlhzeFd5OTVOdTVyVVFIWUJ5VkpMb2ZyaUtyWFBUeFctcGZDYXVaUEJWQUxmNlh6TDUwQV9RdEZYV09ENnNIM2JqU0xpbHcxcm56QlI2azNIMjdvRUlsZHVEckpwQnA2cnBuRC1IdTJtcUUyZE41NVBoRS01Y0hoYTZxREZZQ1ZJRndObGFmT09QN3Q4MF9sUWhEaWV1ZlJ6dVpXQW1aTk5ERW5jdDY5U2MtUUFBZGE3a0FjcUNiZ3RQY25OZGJOZEJjVG1Id0FOVE41N0ZKY0IwNVRxV2ozd3E5WExpWWFwbnAtZzZoM1M2bnU2MkN5WkFPTVJhd2E1M0lQRXpwaHFjc3cxWEt0dWVHb2NuZ2RmdHdpOW1DMDJwbXJMa25EMTVFbTBHVUhMY1Z3RG4zUjVEZzNPNHRnTjEzVnJoQzlGNDh4NDY4d1oxT0pVSERrV0NScFFfTjVPbTFwM1NHX01lcTUwS2pKMVVycXRDRTQ2aExDYXhla243MkJNWkY5elV0QUJlSVpzRmxtajc4U2RoU0ZWTmZkOVhxYWd5dUlEbWRYeGtVN3YzLWY5cG02T2kzNm1waXcxOFNaV0tNYWRWZkhSYlc1QVlaVVdCVEI5VmVpNXUyMVUyckoxSUh3N1NKczROZnZyM0owbjNlb2h5X1AxQTJ3YzU4aVZFLU81ZHRpcXJSdkhVcEwwWl9ydGxFVkt5c2h1NVM4UDdkOE9icXFoMWNJVGNYNEhWd1NlTFJua2ZZWGJXY2xWY1VlZFM5V0UxdjdBc0JWTDdkdzNuUkdUOUlZMndxeUFoR1V4SkhBT2FwYTREODNiV2dZNlg0Q3dHcm1OTU9aLXlvSElvNzktM2M3TWhoUkhYZzNULTZPeWZUVkRpQTJySERkOXRrdEVLa0plb1N0NHp6Q2Q5OVo2dHAwTXluZGlDbDJSbWNBWEN4b2g4MVlHZlpyaWVyX1hBYzQ1NV9Ba0ZCVXV2YkJPdUoxczNYQ2pBa0tPTTNZX3A1TDExczRRdjdmN0xCNlZzTGlYZzJlcUhXbkpxUTg3VHFsNlpuQ0pHOVhHSEs4eDBZaWdmVlFYcko2cHZWS0FGd19rbXBHeEtqcHVLalo3VzF1blY0bUZKdGhmbFJOdlYwQlFuVUVVUVU1Vm5FX1hqMWxJTzFrVW5FUTF6cWJpNVVCUDcwUFR4ZkZGamVmVkZncVB3OUZRODVjd0lvSU4wWXBtYzVaT1ZWN2ZyM1NQam5BRUFYVzdmMDBLaFRPZ0E0MFJGWnBJWUpwazhSX2hudVdiaGh0dnVMQTZzY3Zvems0b29FMWR2WmhWd0Frbm84R2ZTZzQxaVFfWEVUUW8tRzdFOVhjT29TNWNyMWcwRnVDem1EWHVQMTFzMUViWHpkZUVUSm9Ndkd3cVJaT1BLQlV6VlpBLUZkMWc5X0VzWkU2YzlXSElIVWVlamJqNlYtdGNIdDliYVA5cnRVcmRKczd3OHMwMDhubmdMc3RNSzB2dHJadnRPYWRuZ0pYODNpRWJnNWdyX2JCb2ZoZExQX3RzdXBtb3hLZ1VxVm5feklKMHY3VTdic3NrZzRsVGZ1bVNvVTJUSzBfV3c1RkVYX1VVLU9OWnFBYks0eFVmR2VqdWtFNDg3aU85SWFqTXFzLUNmMWo2bWhqeklBa21peFlQWW9oblBBbU9JWVEwLUdhYkhkSjF3OTVHVi0yR3pqM3RtWDRfdHd0NWhmLWtHaUJzUGx4YkZNalB2OEg4Z0lZN01VZFlndXJ1TW5USTJNY05KXzRIVkh0dXV4U0JudkRseV9wTVNjXzVfRUU2TkhVb2VuUnYtUGpLVFJLbjVoSW8zTGhFTFN1eHdENTBWbEtOcW9KdUh6eUthMmZHQmpyVXBMV05MUDNfZDdiMFpRTWx5YzZPSUVjMFJ1NlNwVWl4ZHFOTEl0VnBHQW1lcmZ0aFVaRm9sRllZcnNwbG10bXJDTnZqOHdDanFTWTJOOV9xb1I4M0lsOC1LaWMxR002d3RoZ0V1dHhOdi10QlBWakRLN0dFeEJ6Vm9yNEdNbXdrV1ZsMVZyX2VfekpSSUdmbWZYV01rWVFRa21fYnJBRmZiaVBneV9tTGdMLW80OEpDUFE0Nk9UQmNsNUlKQ1lrbzl1QzE1MS1rei1zdDZ6MXpYVEhScDV3azkwRnZIdGgyZ015WHpJQkxmeUdlMjdIcHlmOXFpUjlsMmV6V21rdm5sczUxU2F4cTh5OHNoYkRNa24wSGFJZDVOczdsTm9fVjgwY1dJNnRiMlE4ZjZRem41aUtja0J5Q0w3N29vM3p0bWRZN3ptU1VuSnVWYkE3QmQ2VDVWSkRrcEtXV29FUVU5ZVhDazRKb1dLX2pqNzBENThlVks2cUpMRlFyQ3dFa1M3ZXUyeTFrcVFmQ3E2UzB0amMxalp1Y1ZKalZoMEZwUThpaFV0VGJlS1E0eTExMGdZNlJKcnJ6WU5mT2ZDTTh2aUdDaUJ1amxlTlRKQ05STl9saERydmxCb0RsTkdqQUpDRGthaDNRT3d6Z0lmbDJybVJlc3pBSmpqYklEZUVzY25xcXRNaHhSTVNPVHQ2R0o0RU53d3NwSmNJeTIzRGV5NjFtV0V0TVVpczUtcS1wTDgyVXE1bUkzNS1EUGc1Q1NReFEwRFRjdkN0eVFYWEdFcnJGRWZQVDZCX2ZUbWJaUTYzSHRJM2JtVDVQNWZVVlZwZm5QQzJjdmk4QnBYR0pmX3BrcUVQTDkycmoxOVF3bE8yQVFDMFdYejNCWkE1SHJoT1ZuT1FFWmkwby1mUU43MDlYNUxtNXJ4S3JBWGtlMmE0T1BlZ3RDaHllYmhhWTlweFJNdjRiX0IzZWxZY0JqUnNtRjJzNWRXZGFtRUZpX1J4QW9POGEzb0RxaXFia29sZjFhTXZWWF80bEFtaEJsdWRaU05xVnhrcWIwOGViRDZjVGdJTVVYX0l6QV9GSlV1NkdmdlZQOGFOTXJwOHJQX3FXamdRQTdCSDJfMGsxbjZOYXV3NDJRUFFwd3NEaG1qREtLcmtpQWtMLTJncUZncXpaRHpvVV9IVk9Od2NybEU4elNCOGdBTVVJUW5PUjl0SE56UGV5ZG93eUJnUVFueWJsMF82Z3VsQXJxWFlVM2QxeENIYzZPc1BKM2tIRFNIZmR2eDZlMFNjbmF6V216cmZwdkR4YTRuU01pRmpZQzVLYmtySFFPSVdhZXFQdXg2a1RpWWItUXFBRFU3MGJVemJzMl9NdVAxaTZVUVN3TmhTRG56RHJ3NFJzV0FoNmMzMmw0YkdyX3d6bUZzV2xlQ0FTVC1IbWxPdUJnRDY3bjJaanptOW1NemRuaDRqUVNUMzMzN0pNS0dOd3BJNHdDNUZjaVpyOFpkVWxiQ2RCM2h4S2dpNDV0UGV3WlYtWUFnQmVfUUw1QmpJOEZjOW1tclM3WjlLNmUwcWQ4UVJuLTlXdmYwQWNoVUJZWFVTTWh0Z2xuZlJ4OXVveVB2Wm9SWlB5SEhBaTMwaFFISGdaM3NTbHRWaFJXYlBnXzRfNWsxM0VxX1pkNVIzelRIS1Q3djQwc212Nng3dGNmWHFrTkg4ZVBydWk0VEdheHgyRG5tQVVmNmFtd0VJTHZvU3d5R2dldEFQRmU1eHY5S1NmQTE2Ry1lRXFwWjJqYUZabkJWMHFZcThwRU1XWUNPWWJ2RFRybFo0OGFTUVU1OGRtZmJ0Szd1cDV3NXd4QjN5cEozaFk1aFVVLUxxMzg2Sy04R3RiZktjRERJVTVnaWxJSG5CLVdrYjI4Y2RzMnlOcU94S0Y4RFBoSHE2bVlNdWZkN3NQMHZlTVpFNnZISW1VX2Z1NnJPM1ZjNm5zNDcxR2tVc1REWTZYcEJsbjZFOVlOVXZSWkNzOWwyZk93cDAtYklkX19uWEp3d2Z5aGg4S2dVTF9pU2YtWFo4OGtHX2FCZUpieDJ6a0pCSk1CS0JDVTh3cEhGR3VQQWNQUUdHaDdFc3FYUTJqR01UZ0lWb3JRSlpjRkNPcnVWQWxYUUlUNXdBcDdNVkNxMWtrWVplM0JZVFk2OUNXMXFlbDhGTVNTN2VCOWJLQm5aa3RqYlM2UDBrOGMyQU4zaFU3SFg3eUg0WDFqOGJTMFNGT2xIOW4td2hQaUxDSHhKNzNpWEYtTlA1SXo2WlNNdFZLN3ppRUdvYm9oM3NGTjRBd2ZEQ0ZvaW42WVFaSjNkNWhnaTF0SVp1NnFfdDd5RmVsZFpiSzhXWmxyU3l0VEswWmtmN2tuSlV5NDk0ZjJUS2ZhZ3UyWlc5NWx4YlBaalhBRmtLak5DQUNRTnk5VUYtZmx2cVFQTl8yUXFLUzJJYk5Ga0V5dmswVDc4b1BfUXd4NERrdFBaM1otSTZldkQ3SWpqR0xidGNZemNOMXhVa3RlWkkybjQ5YTJaRHFJU092TGRjSGFfTzJ1X0RWMzZkVk9MNFB3Mml2blpWQ3Q5T09Rbk9NWEtNVWs1aFpJc1pxSnF1LUlReXNiZ05JUUUzOW5CTEVseXMya3NJSFp6YVF2V1NHZlluU1M1d3p5YkpJNEFIQlN0ZkxWTGZXbjhpU3F6X3Y4UVJxZ2RxTkNLTURTZ0RUby12TEhiWG9fTkpvQnFEeXc0OURTUTFFRjQ2V0NsTm1QRkhKdGdabFllTFV4MmNpd3FBclFyMEE5czdULU5XNEg2VE9td29tZkFKZk9pdlNJTmptcENpdDU4WHhhU3JEWXlyQm4tNjE5WnlPQnV1ME84VzhjMDYyLWRfeXdidGZiNlpZQW5wazktTjNNMmc0QklDeDgzdUREdHRPWTlKWGdud1dkZFExdXJlY3lrOTNVVXhudktrNl9NclN6SzJjVFRpQXRfWHlaR3FSVzdJbWxVYTNuTU1VRVNuX0hIbTUxXzZGbnV1SXNBU1lYZjFpWlg3bmJHejdiTlZRN2RIZGQ3NGFMekxNNWpHQVpnTF9na2ZtemJKUTdxZ1QtVVJhczQ1ZTV2WGNNc2hQVHNkNnVCSEpuOE1iZ0ZCMGpLOVZzQVo0aThvS05ydC03WnJMVDJzNllIVHBSSEprbF9iN1NINnFXbzZINTg3RUdPeVhSemZHSXZINnJxUFpWYUJOMk1WT3l5SVhtUmdXZVRJLTVlZHFsSTEySXlFdVhHdnJfaXJJWFpNaGgxUVhNcUJNMk9rSTA0Qnc0b21ZUnFyNXJweGw0QzFzczJmd2x6dXI3aFhULVpyb3p6ZFNxb2hIalNCeG0tdDZDU0oyRmx2b0pWemlyTlV3RzJSNWdvWjBZekJvdjVlOEhSRGROVEN2MmV3WktfelNtQTc4M01yeTZIWGJ1bnlscFhmSlo1a1V6T3ZCaGlUcEtnY2xHRzk5LVRvdUxmOW1PejlELUJVNU90Yk4weXRlTnlTVkFqcUFoLUhMYmY1NDJGdjIwTXdWMGlSUXhCM3FmbVQ0UmV4TmdSRnlKcG5PYWxDU1RMbFhiSlNLWW1RSlJSalRDUXZ5ZjRIcVp0SzhsSG42RnhZdElsZ3RNMkU3SkJvQmdmR05mRXBWTllDSk9CSE83NE5ZdXNtcnNRMllyWm5HZ3hTV1Vxcm1veU9HVlJrUklzenVOb3ExN0FXWTJnMGJFamt6d3A1ak0xMjVqUzNpbFZpU2NLbHNmMjdPemo0dXhjSTRUdlhOSDlEUm4tal9pNkFRTEZwSWREaVdXQ0oyQWlETUxVMTVNbDFwUFhCSEZGb1hudXlMUjhOeVRCdVByR1Vfa2s2YTFpMG1id3hwRHJfNlFEb0NJV2hlZTZFYTE5R01CbXJXRzJTcGRQdzdscERyV04tcDlmb19GUUVBNVp5VGpVS2s3M1ZTUFl6TFUxcWlQNHZwaU45RVVSQzJQNEd4LXVYQ3h4M3JITFVMM3hDcTFfZUMzYmM2aVJLUzQzbVZtQWxwMzVzcXdnUXk2MTBSRmo3c2E3cU42OUo0eTdWc1NjdlBwejRGRzJCN0VrYy1WTUE0YTV4S05lVFdmTDVhRzhYWGY0RUt3NzlNZ05qMFFKTlFmekpCZ05QWktZRXdRVFJJb29CYXRqMGVUbzNSajFvRDhoQW4wY1dSY1F1bTllQmdzWFhZcFlFVzVUMFBSMFY2VVQ2bzlvc002bEZvWXJ0cDdpR3gzZklqNFNpR3hpM1cyclFqUnQtTFpqcldvb01pdUlzelo1bEVPWmpQcC1ZclRXVHdUdnV3eGRBeVFWTHI5cUNYX1doWWtqWTFsUDVLMnZKTXFyR0ptOVRqN2U0WkxZRlVCbU9OeFhuRm04WlRrbmNUVDVwcHpSUi1zODVRd3dGMF9RdkUzeldGM2Q2LTl1eFhLSVRKb0kzTTRQOXBScGE5bzhRNXhDVTFzYXRpTC0tWktfbDlWbGR4S2dXZjRyWGM0VFp1TnFPWXRRN0ZobXlLUEdOUVNXNmsxYUtrSG9oZ3I2MTdXSENORjNuc0RmQS02bEtsa1draG5idThaOFhrZkh2U3M5eE9GQ2t1eXBmZHFKM1I5bUI2MW5Hcm4xaU4yYVBST1BqYnlmeUxKR3NORmZlQVp6a1dYWXlTWUp1RHJWNVhLeU5YS01uRVJrN0NiU1ZxQnNwWThMNXFNUWppZno4OUxNenZkWnlmblF3UG9LMjZCYUtJQXg5ZTZVd0tTQUN6aDNWM3VtN244R0FCNDVmZTJ1cWVxTWdpNWlVZ01HUFJhTDEwMGFyMlNuNkd3VkJyRmRwRUV0WG1YRkNPZDhUb1VnNVZDY1hPRGhQc0s1bkNNRjBXR3hzMUV4R3QxWkhhRUJWbmJ3NktnYVltRmloS0VuY0dudVRUZGZsWWNoVGt1Uk8zSkNUaGZaOWU5Ti1CVzI2N1dhZG5mVDM4aDRtSEF3ank5cjQyOGNWUkNyZlRydGxMeVZsU0doSThKbElXR0s3Y1ZrUF9CdkZmQVlWMGl1V1ZyekNYbDRuXzh0MzJVV0E0aFNqNDMxbksweEN5MGJMLWdScWtRNXVncXYyM3ZTTk1qN0FfbjV0cmRDNVZPMnR0TkJqclR4M20xVzBsTkRrM3B6aUZiRXB1MjE2NS05ZXdEcUNCajVfbDcxNDcyOXctVE9KYllNV2V5ZVRoVEFYbF9qdlBCZEdrNkJ5S2djUXFWT05fN1psYlg1ZGgtMFZmQm82R210OWNEMlQ3WW1YdlItcEZma3pSZER4dWtLejZOS0dFSGNCTkwxdWpoNXZxZEhzYnlsTm44cU5WMTN2V053YklrZXlaSXluVFVuWkhaLWpQZUMtOUxMNWNoeDhEUUIxdkppS3QxWlNFbE1lT2F3QVE1TnA3c2UzcjVfekJZMWp3M256SG9iWVdlRHZaajRLOHM4M216cGdHYUxjTWJSWEdkQU1UeGJzTW90VUw1U0U0RjZXcm9GMDlsVnZQUkdsYXBaOUtmR2J6VEhCOS1JWHVIVmMzX1hvbmNoMVZfeVllM1RtajhxMXYyOWlZQjFadFprc0JPN1A4QzZBOGNyVGdZTlJWVmQxYms4UzhNRnV0LWZkWXVWS1VWZDRhQTR4V2p5QVFaYmI1LURfVlBvVDRZeGJ2RGJBMFZPeWR0NTh0ekxMd1pOWlhxXzgyOGxoUlVOZFVpRmc5LVFVZzNZX3pkc3pZQ2c9PQ==
diff --git a/env_prod.env b/env_prod.env
index 62ce7253..a323ee70 100644
--- a/env_prod.env
+++ b/env_prod.env
@@ -5,30 +5,32 @@ APP_ENV_TYPE = prod
APP_ENV_LABEL = Production Instance
APP_API_URL = https://gateway.poweron-center.net
APP_KEY_SYSVAR = CONFIG_KEY
+APP_INIT_PASS_ADMIN_SECRET = PROD_ENC:Z0FBQUFBQm8xRjFlMENTTFpGT0R2SkRjX3hJWi01Tk9mOWhqeUdERVpYa2luUEtlNU9RZGZSVHN4SkpOempuRmhfeHIzV2t5dzNGeGVSaDJaWVRjUUExYU1RNFlBZjNZa3k2Yi16b2JFX2VZbXVRN1VqMXZNRDg9
+APP_INIT_PASS_EVENT_SECRET = PROD_ENC:Z0FBQUFBQm8xRjFlbW40U3Q3bGNVQWdaNDFRZ3FMN3p2UmtYbm9vVG9HcDhrSE1sZGM3elVFUXNRSmpuUTI4czBlSnhSWGJ3dm10X21VRVI5R3N5OVFGNXdGZVZtMkFsT2twcFYwQmtscGNsOFpZVnpYSWdzX3M9
# PostgreSQL Storage (new)
DB_APP_HOST=gateway-prod-server.postgres.database.azure.com
DB_APP_DATABASE=poweron_app
DB_APP_USER=gzxxmcrdhn
-DB_APP_PASSWORD_SECRET = PROD_ENC:Z0FBQUFBQm8wTnBMd0FKLUpzaTdYT0Zia2V3VExPSktfTUx6RmRDc1hobjhYamxyMTkxakhDeGVHRTA3TmVoNC1Mamh0elFiV0h5MnA3YmpheXRzLVdhN2Ytb2R4a1NiSWY0RlFQMXlJU2hUMFY1RGJ1dEdRTFE9
+DB_APP_PASSWORD_SECRET = PROD_ENC:Z0FBQUFBQm8xRjFlQnhET1BoeDJ2aVV3OVNseXdPdzZLQUpRTFdQdmdybE5SYjhEcjdIQS1HWG1ua2EtSjlxUWwyNTRNQU1Gbk94dnVqQ25WOE5KTkRsc0RMcTFzOGRUdjNBY1h6cEdIQ1ZXclF4eGVKNHQzUXoyajBFbjFkMGE4MFVkWm9kTVRQaDMxVGczOHFVaFplblVJV1FRd3BSbGpvTTYyV2hDWXFQM3ZfUFpSM1B1U1BZZTg2eDJSZzNGRzZZMm9KRW1SZkFMUl9TX0FWTGlkdGVBMGZEMHJjTkVyQzlGMDItQXhJN1ZDdlBQRC02cEhWeXYtN0RzNUx1UHhGYnAxcmx2RFFRVFBZbGJzcnZDeFNOaWxlZGZrMVg3TW9WcFBQcEpLZllNMGVFd0dOQmxua01IWmJCS1hzM0ZMazZaODBRRk9sdVlRUTd4eDBpLUx0YUY3SE9lQzdzZ2tuTm5QNHFlTm5uR2F4RXpGaTZwOWlieHl3U0puU3RxODJWcFBFdlVWTV9pZXRXTEZPa19mNmhtRmtwMG5KdmtBZzUybjBMWnRhOW8zd2JNeEFKT1B3b014VlY2eUdKSWxFa3VzM3pnUkdobVF6RjJycng5WEFBVE5KTnVqQlRyaWRMYUpFTzhiYzlaQXhEZXFPeVhjdGNIcVZHT1pDTU5QR3JUZVFGcm5HXzR0dVJvX3N0YXJQcWN4cGlxMjNmNlZVb19Oa0NScHJLcm9JMkNBY2VEM0swblV2czhXZldfajFwRU14SGhZUjdDd1BmZzBNMmxhSEtQTkhfU1kwUXdrRGc5cVdGTC1kbldFdGFBN2dyTVlTeE1Vd3AyTjZZVGk2SlcxalppcjdaZA==
DB_APP_PORT=5432
# PostgreSQL Storage (new)
DB_CHAT_HOST=gateway-prod-server.postgres.database.azure.com
DB_CHAT_DATABASE=poweron_chat
DB_CHAT_USER=gzxxmcrdhn
-DB_CHAT_PASSWORD_SECRET = PROD_ENC:Z0FBQUFBQm8wTnBMM3p1TEY3VTQxT0xrbW9fbHFJLXNDZHJUOVBSUHhhdURpMi1EZTZIaXQ0M1V5ZUZFQVhjSGF5SUVzTDNrWW11UlNQQVhwNEU0al9yZXQxSnRIU1U0akRDbFVIUHVvUV9SMkFkaEFGR1ZVUjA9
+DB_CHAT_PASSWORD_SECRET = PROD_ENC:Z0FBQUFBQm8xRjFla0NndGhTM1FyRkJKcDkxZmdib0M5VGxxXzkxMGZ1VG5hWHBDQXlFSWhUQUV5ZjNzeE5rSEdoUDBFam82OEdzVzJrbllQdm5QU252Q2JELW1BWThvMUJpTVRSLXdLX24tQ0FvbEI3X2FQT0hlQkowZklWRG1YRXJMbEFRZnVyM2c1M0k5SEZlakpLenlIX2laXzl1LU5FYndrUHFxekRyUjlpb05CWjM2Z1RrbTJYOGloZHlQYjdkejZ6NjBxSmdST1FaYndNNzIwWG9VUXVhbnAwemtJWGotT2RJVnhLUDJaWVU2dUZ0cmNkOERGYzN6Nzc3V2VENnk5ZUgwdzk4anpzTUg3SzNSZi1Xak5MOVNCSFNnRnZSTThzWkZPTVJYcEZkaE9WMkNHWW8wMTRyX0NNRWpZa0kyNnNBY1pqYlN6UWpmWW80SmpubWtTbG44ZElXSmljcmVUTy1Id2pGUDYyNV8yTjBEZ1FnaVNVcVBYcmFGc2hFdFctWDhtbmF5MDdhVFBRTy1WSHhyUkNYNjVHVmJ6Njk2aEs5MnQwYU5EX0FIc0VtM1AwZE1KdGRlSThIOWVqS2lwaUgxVHZ0SDlacW94RVNxa0taQ0tKQ1RKMVFUTlliYUxCR0ZmQTI0WHk3YV8yZG5mbmZKT2FEOHdiLU5hVUVrRHM4QzRpS0lZQXRSYl9NZDBWbC1IcTFRN2htUjBpV1FEdXRmYm9PRVI4RTRpUGRGRGNseGs0M0dtUUpKUkdKMXBGaF9oSXlmOEdYaUtma2NqS3FtdWkyQWtRUURTbHdTOThrcGV1Q1JOc2lhV0l3dl84YVhDRHlzZWhDU0s4T1FZdFpVQ19xbA==
DB_CHAT_PORT=5432
# PostgreSQL Storage (new)
DB_MANAGEMENT_HOST=gateway-prod-server.postgres.database.azure.com
DB_MANAGEMENT_DATABASE=poweron_management
DB_MANAGEMENT_USER=gzxxmcrdhn
-DB_MANAGEMENT_PASSWORD_SECRET = PROD_ENC:Z0FBQUFBQm8wTnBMRDJRY19uM1hTNC1mMzhVaGNtamtScGpVYTY3RUdBTlpTTDdrUF9PdF84WkFSakRoX0VEcGhwanBPSU9OUGJNWXJDblVUS0o0Y0FBd0hMejUyTXFJTFVCaUJmTkpVYVQzWXFRSDV2d1lENHM9
+DB_MANAGEMENT_PASSWORD_SECRET = PROD_ENC:Z0FBQUFBQm8xRjFlMlFwYzQ4YUNOVFhyZktRaVg5UzNmbkhVN2RZaEtHTU1Xb1hnYnA5T0pZdFVrdHZiaXVCdVpEcnhPczJTQVYxeF85SWloSS1wdkNTWFNSZXZqM3ptNU45QVBXUmlPR0daMDZ3UzRFMHNKWnU0VUtuUEJmeVpUWXhxSnN5dDB3X19oTVJKQzFaMUpoWTJHQnZERU1NazE0WHBmRUJULV9CVnhfR1NUWVpGMEh6NTQxRXpKMnlaNGREazJ5X3ZhX3k2b1ZES2JIcXotOEdDYnltVENka0sxT3ZvUTQxdDR2eDJ2NXNIcHVpa2d5NEtOZHJBRnV1Wmt5cXdRTy1NU1BwdDFnelFBeDR0b0Z0UmlTdm4zU2s0a2ZRV3E4VGljS0prZ3RINWtnMkxlX2o3M1QxLU1od29iWmNfQU96eTRhUldGbnoyN3VKRExVdHZsR2oxdlRzRkdqLU1uUXFHb3RJX3pTa0d5WjNmMUtXc3A1YzNJS0NOR2JpeVVUOTdQNjlCbkhNRTZzTnZyU3dHRi1LemlqNlNaZTZvbmIyRVA5cUFXcUUyZ1lSTFZram5RVnRxX0dqTFBLWXcyUzNWaVlqajVuaEl0elpiaWx0YzVtNGVBRFJaeXZMRzRTTEloQzhuV1JMRkNKVmxjQUZSdGM4RjNfN0dsRFhyN21qbFhCMGFoOEIwOU50RzRUX3d2WFdXQVpodEVmck9WNDdUSGoybjJVWFJJcmF2Z215SzMtNk5IREduNG9IYWV6T1JZa3NMNjZVX3gwR085LWdLTGk3cUgyMkZrYnlLZVNqUXpMMUw0WFF4b3JtOXl0OHdjazdmeGcwZENzdkhEUjYxaXZjaA==
DB_MANAGEMENT_PORT=5432
# Security Configuration
-APP_JWT_KEY_SECRET = PROD_ENC:Z0FBQUFBQm8wTnBMX2lyNHVQVVkzamE1eURGMkRoVmhJTTVSTEQ1c3E4XzlucExfdUNxTHNwazB2X1h4YzdUeDhsYWNCbUZ5VjJNVTZDYlY2dGhreTg5UGV2Z3A4X1FTc094XzhxdWRNSzBXd20yY3pFNkpUYzhaeml5ME9OMjFkNjZMQkdvczZnWTVYX09fR0RYQXhpVHFPQnA2cWh1T3pqTFVieXpHV1hlUjVQdWRCSEc1bk1ZPQ==
+APP_JWT_KEY_SECRET = PROD_ENC:Z0FBQUFBQm8xRjFmQzdxRmgzam9WZE9QX2RoUGlYcGU4V3A4ajExV2tNVjFGNHZaVTB6b1hCTXlmcUphbUhaT2hrb1A3UjBsVGtfUERCSVppc2JxYVJOT2s1ZGpnR3JSOUJETzBqRExYVjVSQlNTQkZGYXc5aVZnWEJWMzFHcEpMSjgxY1RUcm1Wd3JVSDVaaktpSXBZc3hBckE0SFhrOFB5d3B6X2VFdURtVDdrT2p3UUVvdU9qU0hZQ1RNVGJZZkFaTjJHc1E1SGpSTGdFdFU1bF84STRQbG9VRTIxU1BoUnBMa0FRc3VvaE4xc0NOQUI3blZLQkxkM3BldTc3RzJfbEJjQVZ2UlhTb1dMRFpjM1k4YWthUG9kemZRekFTWThoTjRVbm05V1VDeDN1dDJzckhvb2F4d1QzY1BIT0dENEgxOXpNbkNVWHluajA2LUp1MndHYW5uSHpWRjFDYlVoUWFKQnJCRTVtMUNNdjhtV1VrV2J4Q1BUektDaFFkZXpUcTJOakpKc1JoNVFEUGdLb1hqRE4zMnRMaXZJX2tCQTZnZTkyVmMtNll1TU9VOTZZX25NelhqSTV4Tm04VHhoczRFY1QwRWpsZFdEczY0dXM1Qm95dFkyNkpjeXA3VTlReGhUeGF6NXMtV0NoYloxQzFWZndlcG9oZGtSczVyUlM1bkVSelY4aXhLcmZPSmZ5WEZ5VzluTEZOWXpBbVJQc1NvYnZ2UVNzSldvZ1VEN0lyQVhybm1vRHVVVDdBczBLWXMyb1hFSVNheFc1bV9oSVdrdGJRcjYtVHBOczIyM01SZVd3VGh2aDdtZEZubVU3al9GcHNacmRDSGVXZE9VVEJ5NmNzLTFGUnJxZ01qVmU4TlBDNWhmS2ZWNlpkdXZYeXVOWHFfLTdHYVNlTTlZR0o1NWh3V2JCMjhyNWt1eUM2S1pMTHhITHhmcE9hbHpILXZEYlJ4ZmpyMFE3aWQ3cUhnV3Ruc0NZQ1dzUWNEV3lmZFJQU3l5bWxnVkpJc0ZZT0tFN1lZYUlrX1R3Rkx3ckh0cUxfbFlwc29YQXR0dnJvNWprUzRmYnNrQVdOUldxbVZuZVNlYUNwN1VKWDN6V1Vnd241NDNxRGU3a3RXSDZTb1Q2YTQzaDN3RWJ4V1dZS0JKUHY5R2xiS3VZUG1XWVYyRTA9
APP_TOKEN_EXPIRY=300
# CORS Configuration
@@ -50,29 +52,32 @@ Service_GOOGLE_REDIRECT_URI = https://gateway-prod.poweron-center.net/api/google
# OpenAI configuration
Connector_AiOpenai_API_URL = https://api.openai.com/v1/chat/completions
-Connector_AiOpenai_API_SECRET = PROD_ENC:Z0FBQUFBQm8wTnBMSlhwejcyRl9EUWpPX3M5bnI3QTNiRDd1QXVaVkFCczBzeUczcHhyenJvRDN0SDZGaHp6dGJqNjNiLW9oTjJPZGV1b0VxWElfT29jQ19vNWF4aG11bkRlS1JMa1VoeG82VWVmWkV0VDZUWTFmcXZXYUh6ZWs0bEswNXhhZ1ZEU1JNYk1jU0p3YVZkZmZVWmF4dURDcGR3PT0=
+Connector_AiOpenai_API_SECRET = PROD_ENC:Z0FBQUFBQm8xRjFma2p6ZWhwWDdLNzQya2E5MlFiTUt5d2lOaHlnZTNNWTdQcXh5amNTaTlWalpaMXFWQVQ0NFNkSlN3OFVOVVBGYkJkUWdqenh3WVJ6SkVLYW91SGN5VHhUeDFsSlFkUGdlWk03NmE1MGtTbHBQdDFXNng5YXY5MGluN0ZKVjFTaVIxTVNhQUZaNzA3MWJwODVFWVdwelVBX0RRN1FPOF81TEpCTnpaYjVlSzJxTXNnbVZVWlZBQklqSzkzZzB0bWJhRlJ6cnZIRVl1SDdlZ1lSWEU0eFQzdnZhbVRGSkd0UDNHR2YzVjB3d19VUlVaV05LOGk0dVVMN3V4MjVaU2xPcXB0dDdNUE54U2tfRDFMM0t5R2M0UlRVdU95YzIyU3VqSFFVNVVmNll3YnY2M3F5d3NzbnVPenlkeXB0X05IUHF5MU1hWlBZZV9ubFpZcDFwYzRNdTZVaExqeFk5SGhsTTZLRmdwQTFINTFteVdQSDhRUDZMaV83bkllb2IzYXYzWGhhQ1p0TnpQUkdvLVRwOVUxN3V4MlZfSGdGS3B5WWlDSDVzRXhxN2ljWDB4QWpkWXVXdlAtdE9nMnNNX05lVThaRkNIZHV3blEzMW9obUw3Q1FMM2piYzdTdm1MajhLVGFIbVJ3aGhaNFJteFlPNVkzemRadGhGaUhhTVc3eUltVWlXMkE0UGEtZW5MaW9NSjhlMS1CT2l3S1RXLVE4dEZjbXgtdVhJOElXZjl4LVhDMmRTanhvOEJZQ3dfYU5GVEVPNGxBTmhjNlcza2dNenEwdEQ2MHBGemRtbDV5cG11dF9qLW11VU8yekxrMnRLeVRZOEx2TVBKU3EwS0VLQUFtVWluaWQxSE5qeFphZVlDTnpSbjg3UW9VWHBDQVMtUVJxOW13anI4ZHo0YmhRZlF3SGw4STlkeTJ1ZUJVVXNWUTdpaExDOElLbXQ2YVhYZlV6NlpaTnNROGJUaWYtMzlDcnhwMmZqTVNiV2tLMHFnWjJFaUdhQkxqcW9adTJLYnF5SUw5ay1yYVJJVEZxMXNJSk1HQT09
Connector_AiOpenai_MODEL_NAME = gpt-4o
Connector_AiOpenai_TEMPERATURE = 0.2
Connector_AiOpenai_MAX_TOKENS = 2000
# Anthropic configuration
Connector_AiAnthropic_API_URL = https://api.anthropic.com/v1/messages
-Connector_AiAnthropic_API_SECRET = PROD_ENC:Z0FBQUFBQm8wTnBMaEFnaHBDYndpTkZJSFp5OGdmY2xtNDZEZmFmbk1rUUQ2STZCQlprMjRhY3BLdkhTWWdDRlIzcm94NE5LZ2dCdlNkdWpkVVk2QnIzTzQ5TGEtX2p6a2kzeF9PR3QtNWs4aWFKX1ozUTNYT09sMkJNb1JMRk1vbTE0U0Y2eU1SUjhwY3Z2TWIyU2d4Nk1iS2d0YkRKUm0wNjNEbWNxYTg3SGNnU3FMSzVtYjhLVnhxbXd1SmZyam9QSGtna1dkSGlpeENEREZQck1tZk4tTkJvTERTcjZSdz09
+Connector_AiAnthropic_API_SECRET = PROD_ENC:Z0FBQUFBQm8xRjFmNkx4WERQNTZmUl9CS29RblR4Nm1jbnZ3LXV4ZEd5MUhvMWMzZUZqUmo2M2pCOWFkcUFjbUZleUVnUEZoZDdlMmNTcjdZRUQ3RnU3VjV6Tm0wd3RmUWIxZDZJTHlDNmtfSVNYSEVUbEFVSVVLbDFsVFlsSUNZVU56TU5tbk5ZbG4xVTZZNGVNSHR5ZWozb1k4NjZTMkRucmo3cTAzdFpVbEFlaGNtM2h1dEpYWDhJT3V1aHFsZF9xbmw1VHNpeXNWZUVyQVUwT1J5NzM2VVpuLWstNHV0UENlSmlZc3QyVGJzdXRldUhJNTFnSnJGSUUyZnh1SHJhTzcxbVpaVllOVXN2cTh5YnVxalJOZVF4V0xKTUs4Nk1yUTFSUWJJYk1zV1hTanpjZHdHdXk3a2lEcDdDOGtZNkZYMkxCRlFQY0hLcXFWVUhUZjI5dEQ3S29QenRYN1pFbG5uN0N6cDlXUFNSSEFILVZOWVpmTmdZSlFrRDVmc0Y0dEEwTHpmRmlVaE5VaDJvNVJldVE2NV9zNVJFN0VLMW9CcmZwR0VQVkk1cnF2czNxQTN4dnc3RnpvZHVGejJYUkljSGdPS2RQM1JFYy04RVVtYkhOTnNJSW5XRW9aQ0l1TzdnSU1fNUVkcm1wc2wwbGV0WEpFNlJIZmJGSmNLWVRKcVlaNzBfWVdJX2FPM3NuTzFyVHZVUTljc0N6bjJoVVYyQ1pJcXFzbDZySmNVekNGdUtqdk1lM0tJY1pfOFcwUDhzVjdtQ1h4TjFxMlJ3OGhILUUyU0g3ajU3akhiTDF5S3lzSXdUbUVDMGVLY1NrY0VKVE1pd1R1cmtrVnZEX1hYUm8wcUtPVWtxR0twVFdMYmYwX0R6UXRiX0FkNlpoX0xlUDk5VXRweHBUb2JXQ21YTlB1b2kyck1KcHRIMDBZdjVtNW9sb2g2a2cwNW5sQURhTnlhNThKUGZhQmY5blN2LVNIRHdhN1ZDRGY0U29iM1hQaW9HSTlQUXRCN0ZKMUlsU1JtRkowTktCbnFweE9GcG1NOGZ6eDg5OGNaTEdKdDZoTGlwb180Sm43OVcyMGI4WHp5RDAxMF9Xa3hOZGZYMm9fSzNwTEtfcUZzSWVJUVhmWHJxTnRJUFNOZFgydDY2VmQ3VE9vRU10dVFNYm5LVmNHZElDRGhRRk1fRHBzVDdERk9ab09sRTBKMG8ydUxGZFoyMWFLNFJ3MnhkSm9oUjNIQ3ItbllLM1pqczdlQUE1RmdaWTMwbFRneVpSZVkzNFJGODJlOC0xMi16SC1KWW1rdWdicUdPa0VVQT09
Connector_AiAnthropic_MODEL_NAME = claude-3-5-sonnet-20241022
Connector_AiAnthropic_TEMPERATURE = 0.2
Connector_AiAnthropic_MAX_TOKENS = 2000
# Agent Mail configuration
Service_MSFT_CLIENT_ID = c7e7112d-61dc-4f3a-8cd3-08cc4cd7504c
-Service_MSFT_CLIENT_SECRET = PROD_ENC:Z0FBQUFBQm8wTnBMMXV1OE5qODFrcGJqVEt2Zlk1TkFyQ3VoMzVad21UcTgwSXJqRjdiWmdsS0J3VWRBWWg4WWllNzE5X21ubGItMl96b0hZYTlXbVBkTmVhQVRadGlnWUlWQWdOZUV2U0pDSDdiWEhMNHJQUVllYzFpWFNJUnY0M0FpZ1ZWcExyWmk=
+Service_MSFT_CLIENT_SECRET = PROD_ENC:Z0FBQUFBQm8xRjFmWV9mVkRraEczbjBqbHJSVFdYLVViTzNpd014RWlJRGRxVjNKdVhyVXEyS1dna3JBRlNMdTBkTVVXR09hWkNabktiZVhJdUxSUHhUQjNJN3RFeHlqdlFSRlNFWGU3NTdTcmVEYXQzZ3FzX09Fak1iUzhDVUN2NjUzNFhtaGpxX3hvZ180U21FSllxTnE5c3FKVkEtTVJFTFdraFBlLS03ZUlsY0FaRkVBRHVmNEdmdnlxcTQtSEUxLWlqakFueS1TcVBFMHhYV0tfM2tNSDFFMzMzc0NHSVNsdHNHTU9BVjhJSVFzblpzR19RMFp1VFJFZXBnLUI1VWZWMDJLVFNBSWNSVElRcVRNNk1KOGJRZVBsNjBnZGlrZFFfdzlqTmZFbmVPMWs2ZFBpaVVPQm5YbExRaE9KOUlZS3piTi0yTFhGM0kySTlYQldnQW9zVnFyeXFhYTFjSUZfN2VrY20wWUlpb0lCWnMtMTBRQV92TTE3aEM5RXZaYlNxWEZoVXJxLVNJT2RNOGg1YlFqaXI1dUh2dk1VY21hNW1KRzloZl95aTBMUVJZZ3E2VTE1S0JhenpkNVRvdHRfcHM5b29UVjd5MVdRdTV6clh5NEFudkNlelczYUN6ZkVIMWZxcy10TE9kYV9LNWRTX0dyRHBkN01EeDIzV0R3MDhmMnFtNjBDUzJYalFuRTlSMkJyMGRvVXZ1MFpqSGQxd2tTVUpQOVc1T3FTU19YSEhmUkFXalB2cG1aVEFZc1NNZ3FDQXNoS25ic1RmWjJXblQ5TnZDSkFVZXpYeVpCemFJNXBDVGRmckJ0UFl3Wnp0Q1FXT19XVW9tWnNOMGx3cVRFR3dqSmJZd1BFMERBdjEtbTlGQnhVX0dOOUhXekM2b29uZy1Qd0lrcE9uS1M4RFNfbmMwYl94ZkN4U3V5WGpiWmVIUExNM1hUalN6ck1CUXpVeENvSXFuUE53PT0=
Service_MSFT_TENANT_ID = common
# Google Service configuration
Service_GOOGLE_CLIENT_ID = 354925410565-aqs2b2qaiqmm73qpjnel6al8eid78uvg.apps.googleusercontent.com
-Service_GOOGLE_CLIENT_SECRET = PROD_ENC:Z0FBQUFBQm8wTnBMYmNCSXM5cnRBVUxlYm83VG11MlBGZHhiV2hWOWxWYk5XRk1hSmhsTGdsX2dHSGhxYk5FWEpEbXdQM3hCNE1nRjZHNjlDb0RMWTIwb2pqczdocjFkSWxfYWlLOU9KbmtUcTl1SmZJZUh2V1RwM2kzVkZhRFIyTERsaThXYS1OVFk=
+Service_GOOGLE_CLIENT_SECRET = PROD_ENC:Z0FBQUFBQm8xRjFmUU4yNTRwTDFvU205NF96WENmRVUyRlBER1YwbmgxVVpucXRDUzkyd3o2SVNiVmprTzFiZ3lrbHVaQjVYbVVpV09jMFVSR0lCUEUtYmpBajloaUVtcUw3QjBoQ0lUT0NkOWdOMC1zckxZX1ljZFNNRENqb3VodlZhZ3RmQkNjUTJidUFsMlNrWi15RG5xbTdWdXlmejJGT0VLUmRSWnUtUDZpSWFzTVRPbjF2cFFSZHNRdzV6cjlMaU8tTnRtSEFkRjBERTV6RS0taHhKOXQ2YzBEamN3eEVtTnVvMEY5UzB6MWd4Qk1JdUIxQ2t5UG9SbWR5NlMxV3RtbzFpRDl2bk5IMjR3XzE4bm1OdDZlVmpHTnRNdElMSUN5TUlvdW5FRjNOZzRmR2s1YzBiT1h5U2dIeTVyMVo5UzlJQktXTWNWRHpnbzM3Z1RqSFZkSXBIRlg1WS1veWczeG4yZ2xyak1yVEpQWURXblhuZ29UMUNUZXJsMTJRWE5NUVFDV252Qm5nU2NOWElPV3J5OXRYRnNqX1J5eTZUYm1OSjhrVEVpTnBtcXVWTnJ6ZDV0Y1dhR19jaTBSQjNvR3J3eTZKWVNRMnFFNEcyVFR1cTNYSzNJZzJmMk1hR3MwVGtMQTY5NUgzb2x6R053RFVnd1pOLXMyTnE4Ti1KUDk5M09pOWt4aHhRVFNpZWxBWGc1VzFDSEl3d2E0VFQ3a3M4clBPYzNFM19NRGpSa3FUblN3R1gwTE5hNEZxX0JHNFNXLTVrQnMwR0pmbVFtcnZTNWNwQWhhUG5qMEZaUG02YWpJNTY2NU5EQUNwX0xpdUx6R0w2Rl9UQWxjeE9xYUdBMmpkVGxZNTIxRlRuVGlybmJIaFZTeGMwZG9lMkpyTTA3V3c2QWV4ZTBhSE8xSG9Nb2hfTnhIRV8yU09lWUNweHRGNkwzcHBPTkg4S2NheWVmR0ExUmdKVTRBPT0=
# Tavily Web Search configuration
-Connector_WebTavily_API_KEY_SECRET = PROD_ENC:Z0FBQUFBQm8wTnBNVU9JZEcwUWFuQ0lfRElGdDRhSFJDNVVBNUhBVzVKQlhBZXNsUDluRXYyV1NuaWw3eEJMdnhscGNZNW5KVmgtMzNfSGRfX1RMZlB5SmtHSzNTMC1RUlp1c2dqOWhSVnhuVUVGVUlaak16ZjlpWW00OFVIRFU1aEZXYzNaN3VNS1I=
+Connector_WebTavily_API_KEY_SECRET = PROD_ENC:Z0FBQUFBQm8xRjFmSmxORGhlMzd4dkpmS0tVaUVLdG5UZ3RUckp1cTg5Y1dnXzBoYWZtc0FhR21xdzRvVnhiOVY1eU5PQTM5S1R2cUhhODljLTQzUUJHMUpJRFpucjVIbC0xTDRUVDV3UzQ0V3gxQjI0SkNLWEdHYUtSM1g0UmFueWo3SUlROXRBMjFGQWR3a2p5SnAxVzFrSWxFWHVrdnNsYjFNb0RvMmdmY3ZCUmxfd0FRS3BQUnpNUEZEMlZCSFZfY1VXa3F1Y2V2QW1aNG5uN2ctTnZvMGw1Vlp0eV9VejlZNjRtcEJPYWxtVUdVb0xpbHN6VzZnR0hZTk8wMC1YQS1DMVA3UkY4dV91OTN6dXYtczhKeGxLa3F5UTlROW5GOW9qZS05akRvZ2lfWVY1ZFk4Y21fS3AwMDU5NjY0ekdwd2pFRE9vYXFSMF9PcXRrcWwzTEZrN29VSXVIenJ5MGJ0TGVydTVUR3RVbWItd3dkX00yQTIyeUdFZktGNHJBZkNpbjQ0VjJrd09nQkx4Q3d0eTdIYVZQbU44VGE2d0g2bFd6czRWcG1pSmhhSkxSYnJ4bnRENGdqWHBKVkJHa0h3LW4yU0VCQWYxZ21kdTlqc3g5bktJN3pycFhGNDZoU2R0eU1sSVVCc19LdFIyd3MwYVEwb2tJdXBpc1Y3VXRWVUd6SnozMWhrNVFZLTk3R1FzUDlTMWgwNDFaWHctM3NjOWJiY1g5cTE5SUlIZmUzcnFQaXdJdUozMjZGYmdKTTN3UEQ2ZXZITWM4MU5tYzAxaFRrOTdWWW9iTnJBREJSYXFVZnpCRjVFc2lyam1Bd3g0cVpidV9ISU5qOEdqQWcyaTN4Z08zWUdhX1EzNTc0UXZBWVUzQTdCRWxLWW1tYzJxeXU2NkdOSVhwVVpGV0x6NTNIcERtQWdXbHNhQW1LcVE4eDJGd0E2U2FjTjVuekRiaWJ4aGpEdlNCb1Z3PT0=
# Google Cloud Speech Services configuration
-Connector_GoogleSpeech_API_KEY_SECRET = PROD_ENC:Z0FBQUFBQm8wTnBNR2pBeUR6NmFTcFZkQmZnSmlweEpfQ1RSNnFobHZ2N0Z5Q1luRVJWSmx1WFJTMlloX2hROVQ2TjlvRTJQWjVuT1F4WjJmUVdCbTEyYTQxY2tjSXA2S2Q2NTN6RnRiQmxTTWdBeC1GdnNZUV9KWU1IQVFaQnc2VmJFSHVPVDFhaF9VYUJjWS1MRTlNSEkzUl9lTDZjX016MHZKSVE0bUNjZ3Q3Ni0zOXNfMmQxYUhTcnRzSDdQSUlyUExjUHpYQTF2cG9CS2dZajJLUzZUS09JeGRDRkdPd3Y4VFhWaS1DY3FOc0hQWXhNNVc1LTI4RjdKYjktWXc1X3hVUmg5VnhQX3BuQzJzZ0ZnbUVLNHZHWTRqNjI3VDlrNDlMLWNFVl9sVFd5cm0xbjlXa3VMOEthdXdWTkFPR2ZjQm5ReGNqbFN4NTg5NTVqcmlETGoteWhqLVEwSTNBQTN0Y2ptc0JBd1BabjUwaml0NVllOF90ejhObkR0VHdIU28yRmY1QWk1VktURW1DNWZPR2FnQ2dqS3lWSU8yT3ZDN3J5Y2FzRmJOOFNTWGZhUXNzbmFKOXdxakRfekFyZEFSQlF1QnJIMF9idktTQlp5MWI3eklrOHJPUmVxOFRyWmJNSGVNXzhDSTkwd29tSTJhTVM0T3lMYWhQZzEyd0RYX21NVVFTMm5JMHVpOFFfSHdkc3RZX1A2czVtRGwzU2RZUTVYaEp1TGpfejNwLXdZY0pZQmotOXVGbGRxcnFNdU9XUEZKODJvWnA5TE1mYktjUGUtRkJJbHZuTF81em0wVTdVWi1QRGk1dG45cWR6QkZmRHd0WWRqZE9xR0FCRXktenhLYmNpY2pfYTU0bUtPYk5KWFVRc1E2M0dsSWVFWWJucXc4QnBBanRjZk5GbzVKXzVMX0ZzTFZNYUZXTm8zNjMtb1dEU0VkTmtaR2xPZmZXam5qTVIzMXJpeG5raXVJOHdBZXhXeTA1SjBJU0xHZjZubDk1Z2k3ZFltdzRQbzZZNlJfcHVHWjRzWXJIVlAyRVU1eVVYVzd0R0JoaU5DWEF6ZkdDeGZXdDFiMXpiTkEtTUtuMEt6ZFJyMG0yN2NFbDFna2V6dEl5cmJkS2liOHV5OUhBVGJRX29vVmk5VnptUEhoT01oOTlHeHZaUTVCbTQ3Z1ZkY3J0NnpPcl9JLXVYOXBCZVh6blNSVFJFVUJBT0lOckNyQjI4SlpBV283TUtBYWpZMWVpV1p2czQxZHlBYnJ6d2JUQjZ1WXRGdEdkYy1keHFjRG51N0NSN1c1ZEttTXRfSGZEbzhsV0p3aW1rbHJpS0pEaV9IYTRiVi1WLWF5TUJGMnhVZ3c4dUxiY2ljTXE0S0JJUWM2M21NemwtdlhxcTJta1ROYUpUTmVaLThyQWRxS2NpM0QtaVJyQVU3WWQ5V1kwdUp6dzlpbUxzekxwVGgtQl83MTdWeFoweUZLcFZwNnQ4cVdfMkw2MHBZUU1CSXJNMjk3YThpczRqWTBuZjlRSUJfMXQtdk1uZUVTMzVvNFNYekFmRFVJSG1ib3RzMHZiQXVqa0VWNzNid3RfTzNiTDJrU1VBY0xoWDV3Y21OMWpzUW5IZ25RRWZRT3ZwLVVESG8wUGNBcXFfWGZKUVZPLWVnNlRjaFRibmlrLTgwUmhRRWVNNFRUWjBjbjNHSFUydGY3NWNQSTc3NWlXY2s5U0lOQ2hUeWZiVXdVOV8wRjdFTXNzN25nSE9JMTJqNmxEMTN2U0N1TnRDWWJpMm9WM3FRbWY3bWZGY2huUURlQmdIdkRBNGhLWEl4M1hqNkotY1FBWm9xX2FJZVRBekZoaWx0R2k4eEF2T042dkV3cmVhMG42Q3NTM3dILTI3NEVadUpiUjU2cmxVeExMTlFpVzBfWmxmZVRXSU4xWmVhdTZqaVpmeWhwUjB1VE15SVFtMFhqUTVLOHd4dEVkQ2hiSk5nczJ5aUV1Vk85OW05YWJMYkdMSXRCZV9WLWxudEtUX0FKR0hDNjdMcEREMUlWMHJaV3RUcUpMLXlEU0Q2ZUhETVJycENlTjV2VktibXhTelJDcUFYWWRwV1VHbldtOFdHRlZJcDc2dzBXZGlJTnlSeU9mUzZHRzJNd25WS1FfdUpCRUd0NDdGaFoxVU41Qi1pM1ZQdHZ1THJBdkRMeVYwNGpkRWl2WWtrbnpoRWVFMzc2ME9WVUNNYXEtRnJKWnprekhxcHJVRVRTdHNlSE1ZdWFtLVRjWDJvVVFBTS01NXBjbGlNNC1Dcm1NaUdiUk1uUDFDdlJ0UFRlSkR0eDJwOWladzFxOFpoY1lUc3o1MmUzWm1MYUs3MjYzdk9KbWtrOWxDVWhlOTY5TlVEM210YnlmZXhnUzR6bTVHbi1IS3ZyX0lSc0FtOVBJV1BBeldJNDk1UUxaSW90UG5IY1hJZVhhWHQ3ZzQyd29YOU9DNEthWXdnRkx0aDB1LTBUN0VOUlBGZ2ZPLWY1UXdTQVRCRmhJQ090a2xUbzN6YXhuTmNqaWJpajR2Mm1Rd2lxelc3UjRSRm11dFBlVlRsTlRoRUtPYkJVbzh2Q2Y2MUhqZnNsb3E5cUhLV0hFRTlQVzcwVm5DZ283ekJPNDRxSi1neTZYR3E3UEdXT2kwLWVaZGxFUnlaazRHa01TSEtyRy15S3QydHBHcnlMZWx5Z2xqY1ZkTEVIMERIajl1dW5JZzY2NWVjV0JSb1NzSG5OeXNheFR6QmoybjlBeThHVkJtNHduaXlJYkVySlNYTDMzNWpneHlXeklvYWRvQzAzV1lDa2lzaU5CUEFNNVdLMEpWZ1BVbUhhNm1NX0k0ZVM1dFNFOXFaSkVuTXhFUHYtdF82bGxfcmFMMG9kTUtMOG9adWF3VFlYVnBTSjREbWRMX0pLR2ZHRHpIdGMzYW5OVUtzVGp3OXc5WDJfd0l2T0lVN2xvMjBzSHZSaWY1cDhTUmhVdDR0dWgtMGp4V2V2bDRQSC05cFdyNHBYaHF2dldwTVVNWExYZEZFQTlmaHFMald2LTdJX3lfRi1WbXhfXzRtUzNXVXBHUTBocFRST1h0akVZc1BleDc0aGxidS0zTjl1cTA1aEdWRXUzaXlNQUR4N0ctdkhISE9DSGl2amdyTFZ5bTFnakFrVndhQ0ZTYi1GcHM2TG5aU0xXUGhjMVRBaTRNYzBlcXdtYkdsQ0xwTmxmRzJpV3NnUkdMMDA5czFaUnR0R01TSmZqeHB3MGM0WkxpNzE2SXpSaFhENzh0OWU2M01JZ1pCWHhZdGN0aUJmS3lSWHNMWXhZNnVZSHlrYkFkNHlaVm1FcEwyTzRYN3dhdGItVEh6TS1NU2R6YkNhNjEwSEpwdzF1WUdtWldJQ2ZkUEhqM3VhTFpvWjhxQ2dpNlhpY0NTcTEycm9GQk9NM3Bla1F2REJRLV9ZYW1Dc19JZ1NFOW5yLUxCX0tvakE4ODhhOEh0SXA5anJjRGJ6d3dObzVfU3FYbW1kLWVCSThIWnl5TEh1OFlVTml5QTRDVlJPaU9mZTB0NjhSZjQtT0lpa1piWmwybUFQOURlOHBtQW9KdE5SZTJzR1A3MkJVS0xuZTRKSmYtcW5OT2M4Y3BWaUVYQUJTblpsR0xYckx1eklwNzh4ckpBNUxaMWlKd1dINjQ0UWUzejNPcnpMWHZHZTg4NlhyTnlhdXZSQ093QWktTnNLcU40aTBGUFFNUWFKcU1EZWRQalVxbEtIcENTWXl4NXRRLUkxYXhGeDkzY3pfcVgyUlFWa0dVVXNHU3dvOXRDZC1TYnZVS1d4SjdKT3NKTjRMbFZqUlJXSkRQQnNUM2VkbWRhaG1qMFBVQ1VxX0FNYnJmbFI1RjgxSjE4c25VNm1EOXpaanhQLXVJcXEyN2VaTExZMURzYThwdzh5NEVUY3Rab0plT0tWRU5rQmJvOTlWRlp3elRQSWd0LW5laGpQaFFtUzdZVzExSjQ3ZVowck82TjZjektjZzZ4NElGa2pIYjJHa29FR1BsSG5HZGlmd0xJU0RSUW96emZVSUNzSzNCTlNUcGNLQzVSaTRacWtOUWxwOTBLdkt5Q19PWDRONGp4bVNXZHd5ZmtHSVFfX0lCNFhrY2NVU0N1MllJOGtmcVFXQXpJRkdiX2dSVWRuakdldHZ5bEo2U21jQmVtcHpTaHlVZ0k4ZF9xemxMOU92bHllM2VVd1FPUDVRNC1DTFZ1U25yQnlrNm4yUi1qNTRtOU9OaVVYVzZ6Z28wT3lubDk1SF95Zm9rSWZsMVg5NnNDQTl1YzhRPT0=
+Connector_GoogleSpeech_API_KEY_SECRET = PROD_ENC:Z0FBQUFBQm8xRjFmZFBTcUNWVTdLSkVNWk1MSkVfMFN4eW9nbEhNZVd0OGZVZ2F0bnU3bXF0eHJmUTU1Z21ZVWNTMGF5SkR4dEJ2MTZKT2lJR3BFZjA4UFlBbGc0MF9EUEdKcnBRb3RRb3QzbTBvRndRY1V1UVZOZkRzbmhqXzBURWFfR091aDlBQms3OE53THBidXdEWjlpeGlGTENjVW02a2pZSmZYWjZLUTZEMjJuYk54MDN4LUJmaTU1bEpLY3V1bmNvSU5mZWdwVWFHbEV2YzFnaE1WQTNkT0RWbXc5N2lQbm1TZVF5RWxybkotYXFBQk9DSWJHTDJUVE04OEtHUUVZb29QNklRMFUxM3ZrM25TTUtIc2lWOVNxQzRkZDFFcTRTT251SVBIRUptS1ZpNVh5cjF6WWVpM19feXNmNGR0aERzLWxaQWdMQ0o5aHVaakJVaDloUFRRY2Vld1hIaENQZjhJVUhFYU1qWHZHQkw2R0xHTVh6MkJFRDhoQUg0X21VNlYwWjBBNk5BNVJHcUlaRXM1bHg5QXVKVzRwZUl6WjhXYlpIZUx0Q3BlYU5EZzMyamVLZzc3eGZLamUxYTRuR2NmUXpSNXlRd0NTUWdBUV9ub1lPZW9VQjA0bmlrNXRRZkdGVUw1OHF0MHdwbFpqMmx5c0V0QnBmOWhYV1drRXJ2d0ZRX2ZkZkROaW92T2lSVHphT1BGNmNZZG9Bejh1Tm1OSUowZ3h0V0doVkhNWlZJdWJoWi1COHYxclpnUnp4R3A1NTNrMC1NUjJPeHlCeEJyLTloUGdmXy00akVMemlOZ3VoVW9qZkFBRXo1bi1tYzFUaVdUV2lCNmo3OFRjZXVvS0UxdndGTDhBNXZsbmlfSThldkF4aGZLbXBiMGcwbTdQanhQc18zam90ekNhbExfNmNJdWV3ZG1GYWlZb2FOVTFLS29UWGZOZGNxdW5XQzFPZy0xUVJ3QzZOdHdTRmNwLXVHNTQ2cUpZeXNHZnowMG8zLTFST0JWTTBXUjY1OHMwME5VYXNoNXpYTTYwWlRIaVZVcmtEbWxpSGNHc0FHYVdkNzc1eTh0NmUydU1jSTZhd3FOOURIU252WmFKUnVPb2lrZlZfRmZ1UmhtenBDbzRabHh0OXBiLTY5ekIwa1RLcTFBOE1nM0h1TGVETk9tVU5MZ3hHRnNhQVBLcFhYMEg1dWg0VzdNYWc0RFRLNFNMVWJvTWhvUlFTYTdvSlBBbFQyVGtqYnQ3MmhCTGhZVlR2VW1ac1E4Y0N1aUNnTC1OM25mam9TZXY1NGJwNFJrYmRyTlFlVXUyMTRTeWRBbTRPVkh2SFpheGZkbHg5dDV0WVdkb0FVVmwwMHd4X2NGZFVISHlqZEczbThrb2xoTU9sWjVub3NRaTdscTItd0ZWMlc5dngyTG1ReGc5Rk52SE81S2NlNDZJLVphaHdKeGlUb0JhMkhuaTVGRU5jM2o3OTZRaDlNQUd4SUhMTGFyRF9WaFZhME8ybUpNWGlBQWJqdmVGcU02NDI2UWxyYTU1UzduMWVhQnlRNHhkOFpIZmN0SnVEUWFoYlNvRTlmUTVDMEZ2M3pqbzlPclZrbGl2MEhEZUk0NUVNcndnTm5haEp6RFpwRy1IcXlKYlZ0MHJpd0pUMk1nd2YzWUVJOVlBeTA5dXl4aWkxU25IUzF1WEktWHliT003SWJKcEtmMmNleVlHN25yZUJpSndYQVI4bGd3dnBvR195WmxUSVhkWVZDNDRDT0pUM0hibk9EMGQtMEd1em9naXBpTk9XYUg5OGVyT2lzQ2JxUVJmZ2RDOW1lS0c4UTBuaENGZllUeFdkdUJycGVUVWFZQVJVQTJFOTVxdGhMcXdCSmg0d29ZMDdTNzlpY25vNHVPX1JPS3RWcXJLY2ttZ0N0NUJKemljd3dYMEJZc19IR055emVVVHlVdGR5eFRSaTd3c29Ram9vNXNleGRTUkFZTElRZF9rS2x2WlJqS1RZWC03N1paOG1Od1lKWEdDeXR6dFI2R2hIZXpnYzFVWnJFcFJSeWZmVENWc0p6LWZSa09ERmhhOThQU2tkX0JGOVkxWjJsanREVHZmM2lTYVlzc3ZYcWw0bGlZSTRPdDc5d0R0Y2o4OVVMc2hxMWtDSS01RXFGZW5tRmZJNXZvbnJXUTZ0dTJmNDVlQ3oyN2w5dEo2U24xSUxLWVRmeVZwUWlwWFpEWi1XemlhejhmYlg0TF9hNG5YWjg5Q1FGSnlnVmNpVXdRYWYtWkQ2Q0hxakxNR2dTWkI1VTlfdTdlb0cxM3JkeXZqUmUxZ1BHd2FRemtHcU5ZNWIxOU9vYmZET0wzdEI3YU0ydUpwOU1mWVI5RWdKZ0w1b2ZDNHVOMndPVS03TVQ5V0pxa0IyZzR5RU8wNFdadmxsNndkeUt4OUZqeGJ5TXA3VVNVTlVkb2t4S011WFA3Y1JXUHlhRzdCYkdQUzZzMWdFazRodTlaUWVIQ2pfYTZBSm5WZkp0Vm4yckZKNndqbncyTldZNWQzbUZhXzlwTlVCUFlpeHVodDhfTUxoQmpDMXM2TEN2alpNLTVRWTJvWjJNT2VLUVpJU1l3VzRjQ0xUS29uTjRhX0ZIa2dyUG9wc280aXN3a3IyWmlXdWw3ZUJ0TUtuWjRrOVUxN0c5OWEwNGNVdTdZTTJqV3hsZmJRVUZ3cUg3bjFMOW4wWnJzS2FYOHlRY3pDVk15LV9nc3dzYUg4UWdkaGMyRDZWMUIyNl82T1E2MjNiNV9oOWNvc29nTnhuelpkZUJwLTdCcDJ2T2FYcS1FT1RQUU92VkJ1SlIyRUNfWjc2S0hvbVFyLUZKa1lsQ2RqQkVnRkxLa015UnJGNXNuNzAwRnpSRkJDbEhXZ1ZyOVZqM2F5TnpJQWZDcHZtLTJscE1XSmc4NDlJOXpkT1ZCOHItczJCbEZQR012UlJBZG1RLUlwM3QyZVNURnZHVWdZVVZwRUEyLXhMSE80YXBzQ3lrTkNCR2VoNTBKN09QckxkZXp2Q0ZDT1lwLUxDVnpMRmxHemJYeTFhbGptdFFEY1ZvemFnc0k2anlnaVRuYWd2WVQtVHoxMk1NcTgxb0ZkX0h6NldyUmdFeUc2V3B2Q0QwdU1aREVtWHRyYm5GY0JjVWZlU3M0VHJ2clR2ejlCdTZubGdWQkI4eGpESXBteEpiQzRjX19yU2UtVWpNM3JNeVhpQ2h5NjlicXVNV3Z0c3ZlcmhHUzVyX2t3aEJSZHg1cEdyVnVEQTBpSGtTSkFQa3VUUTE5Y2xGdGtNRTFyMXlOVDJWSjF4ZG9ZY0l3ZVpwRWZDc3dob1ZSRDg4Z0hCYkZXd29MTWhWSGlWS0R0UXV4bmdRZ3ZDYi1vd2JHN21ULU1WcXEzTWJram45Q3ppaFZRTWg3N0xXNmx3cUxSa2ZFUDV5dHJFUGYwMXF0YmFUVDMyT2xqZkt1aElhV1dUcUxmaGV0Z1JGWklVRF9tRzBJZGx1RnpkdTlEb0tweDVab1NuNW9QT002TlJUdjIxWnNOeWdHZ3hVWHBMZFVISy1LLWdDLUNGZFdDR3dHVHI2LVVMaE5CcDhqRExHcnR5U3p6aTZFU09NVzdIbzJGOGp0OUJSWjdBWm0wRGExaV9Ybm5PRDZSMV9vVU50YXV1cVBUNXcyTUItYzJTdGM2N3pUVXV3SnJRbEVWTS1vMWxsM05YNFg3X1N0Vy16RDlMa2p2Y2R0NGIwYWZOMXNvNFpWR19od2VDQmNaYWVpNjFOYmluTklPSmZvRi1fRFdsX050Yl9QS3hnbWdhOFF0b3FCMjNFZmN5eWgwakcyRWVTN1d0RUZGV2Q3ekxYVXlkeVdWQXh3bkxMWEJDaTZZcmVIcV95bHVVVjRTdGFYX0MtMzZra2ZHU2gySDNWTGhvejNLVUZWRFhWb1N0dFpiQmFpdWZaeTlOb0hDWm45LWZUOUJyQjZ3ek1CNFNJNVB0cG83YWthVXB5UHp2cTc4ZHo3ME03d1MzdXJ6QV9paW5VS0lkckNqWGxEclNCR3VPdTFLSDluNFMyTVpwMExhQjJRZTUtSXlaUmZjdVZFaGRCS3dWRnFQMGN5SlRvT0o3Ylk4cVcyeTBjV0tuR1N6eV9JU1I0eTEySDRYM3JucWxXYTdFSm5fdlJpMXF2aERrZWJiZm1ROHJEQkRxU1FxSkxFNzNfeW51V0xGRG41OE1pcERrM2hCbVI2aWNBMmZCaHdSUzJIUDNvOXN0M2loSnlFTDVNYmZ2bE1jcXRMamJ5NzJlTzc4bXZIWVIwZkhKSWlwSC1YYVZZT0RGZ01MbmZTd3RBeDNEdmhydDVWS29QdkRTZ1RSSWloY0V4dUVOU3VwVkZXWjNuUmJKeElQbUI1WUUwNzFqaHFWb3FkOUhRaHVQYXJYLVJkbk1WWF9idlM2LUF5OVhSUGJMMTU3UnVyTTJSOHBTcXFoS0lBclFEUmNzb25PbExqWjVydmU3VS10cmVRZnk0ekdoTXpsT1N2Xy1mUVdKVkdVc29Ga21lSVFMcWc5ZklDb3NNYng4YVZoYW5velNOcHNnM1pDbHJqd3dpN0tuZ3lCOXNNSmU4bWNaNnUyTWREU1ExT21HYy1oV1loOGxmdGU4cDBrcUtxODNSRjdvVzNKVFpSWDhrMTA0ekE5M2MxN1RNRkVZRGNUaWtLdGNJYUd0ZzhNanNPYmRpUm05Nnl2OVhtOUMybEMwVXRRY3dsNlppb1ZuN1VIZndSaFhnTFdOOGEtODZLbTh2elJvc0lSeXVVNHQ5b3E4UVlnd2wyYVFIMWRRZFU3aWZucTFFd2kxc3I2eV9RejVUeExnSEZOelRjLTdfSzRNaDkyRWZEMDhEVVA0R2ZqT2ZELW1CMzRCRnZIYzRSTU4wVU1rdWo5SGk4c2VCd042bG5VRkg4cktVQUoydDd5cWRlYkdmUE0tN2VaR2kwVmhnRUVHQUFQdnVGeGtjUFhlY0Y2X2N3aGFCTFB4ZVJHa3VuclhHUy1qVzNWWWNhMENkOFRSRmx2M2paZjhtY3VmdUpWenpDSW00YmdISEFuTDhuRl9GY3pGemlJckhwRjhGV2xOdmdMSG5qdkZFNWVIYzhNRnJzbnUzTUhmYndicEdfSVZDZm1LUlc5V1hHcllCcW9udEI4U2FWX0JITXV5MnlwbnhsV1Q3QnpIeXpScTduUTd4UmZzMzBtLTg1Wm5tN2hlLWYwNXZwdHlJQllvNHI3V0Qzb2tWZGo2VVRYYjE1RWFORHJBYW5NN3JoQ2M3UWcyazFYX1dNcWIzMG5mUWZNZi1UR3Y5RlB1Q1JTMGZ5NHBwVURNZDFNV1htVDRtUzBickcxVTZLenR0X24waHhmWFhuOGFvVUhWZkNram8tQ2lzc1NyVEdhZ2VwWlJfLVV2Vm44OXZwckIxdnMxRGpLbURnWjhmd2JqcHgwcks1RVQzZTMyc0tBNFlDN3dOaFcyczF0YmhqcXA2NzVoSG4wUnRTUHF6Slg5Tk1mOFdwX29uM2p1WGlyU3ZTdk5HRWRWU2FzZDhfLXRoZTg5UGRGZ2EyODVDODhib2UtMXdZVWxHOEptQWJhVDhydUdhTTloUmVvZEdoMDFrcmdFVE5yRTg4Tk1EN1UwcnRMQXNVMi01ZzJ2LWRuZmFsdlRSZmtHblB5VnRJalFyTVotY0dqTUpxT0lfUzdsekZmc3g3ZGJlaFpiREY0aHVSc21ZQ2NuaGZVZkV4VkNKb2dHdDltS2FFaEsxM0VKTXJWNmpyRXFmM2syZ2g1MTVnNGFZdEF3TEVMWnBKWEdXZFhMYXZrNUJ0LXlpOUpwaTFPX1NJMk5RNy13bklFMG9GSmVpMV9qVHRfWWVOUUdlajAyb1MxSU81aGlLaXBBVUc1OHhqNFh2bko3N0doeEdaNFJjcG5EemZMbGhyY2xTSkVEeGRtTlpzYUFIQXBPcDBYekVhbWw2SE4zaVVzQzlrancxMzRyN0k5WmtSRFdTc2RsMXVHQzhnOC1QNU9VamZNd0ZoVW02VEVUMEJpNnl4d01nZVFfUVJaOEVCUzBoTW44ZUZNcXoxdFpIamdPLW5KTHJwZE43am9DNjZaV1VDYW1hM2hxVHRFbHZ5cXNvV2VZbmJCanptMDAtNDBHQ3hVczB4Vlp3VmNXNDFLTjlNM1FORHJqb3BTdVRNWVhlMzNFcVVxOUhvemdfQ3VaaDdPcjVYa2V0SVRrOXprNTVlS0Zvb2dvZzcta2w1NGhyR09zU21CTThKQ2hBbkgxcEVBSThZLVdBVWhmZFFhRWxfNnhMalFuVVRldUM1TTA4cUVWejVLTWdwU3VuajNXUUYxWDZBVzB0cVRObUloalV3ZmIwQ1VPSFZOYUlEX0lZLU9XU0NsbndVeGY5cjVxTGIzcUdscU1fdXh4bXJfLTJrSUo0R0llOE1xLUdNYzJzbEpMSzR1QzM2emJQbWFqbnhVeGVHQmNmbUFWb3lfWXVDNEh2bWxrTGxSbFM1Mk9Eb1E2VlFfMkRUeDNKT2dXckpyU3VDTDk5M0tYSWVYYUtZM2ZtOHpDNDI5X1NNdXRKQ0d0OXVPaWhSV2thOVczcWgzTk9aVVRjQWxQRGRDTFkzckZQb3NZTXUwMnFoLU12MERmZXZOZm5nSkhuc0Jvak56dEtqcEVMNzVYcjdYbF9GUUwxcHpoNXBrSzU0OE5saXA4QlVkWXVUWndhRk12RVVjd1BrZUFiTnRvbzVldld6Z1ZiMVhLX3ljVVctVEVyYXFGdm85S2U3dFNHODhTOVB3ZU1iSURJSnc1SjRYZVNSWFBwWng0bmJkN1NJYjh5QWxoaVAxU2NKNlRKR2YxR05xZ1VfcE5ZcEZDSGFzeFMteFF2UE1zbkh1bjVLY3FMYjhkZ3dlMTZtdTFodTJqa1lTZ0g3QVl1b3lIVGRZd3Q4NTJCT2sxXzlmNmkwdlpydFA0aG1lWlV5a0VaTmZwdTFWVlc3WFRUVHUtTWxZdG51NHBsdUIwNlFNd05MQWVaM1pEaWp6VzlQc3J6TzJfTWtQdHc3VXJ1TTR1cFdwV0lqRGdod2RPZGtjZExCT19rR2ZVSnNmTXN2RjFzVkV1TW9yd3ZacHhuTEhDdlNDb1lvY1REUWRHSTg4a0hnd00tY3pscGNJOGlzT0U2dXFPOWlLdlRGUUZYSVVnbjJyTUMxUHRLTUJiQTFLUkMtNTQ4b2ZtVEhYUnI2cnY4Z21fWERVMFM2VVo0d3h1YlN1OUlidmdJcHRQTDlVMjZHZFFGVjMwaHliVERzejRlV3dka1hCekE2eGJxcTFwX1lCU0xZT05tOUwwQllKSmJGa0VuRG9VUWJDdnViSEhpdTBmMEdpMzhlM2Fvb1EyTGtXVGJweUUxWmlwZHJXLTBGWm1FY3RJRHE3Sk5ab2xfVE9XWFAzXzEyWXlZLV9FN1pNdG9iYWJ1b3dackJFX0RZUHRNLWtjdTN2MkpyMVpjNTlXTTliamN3SkhjYTNDY3ZiUWw2STM5ZDNVWk1yREg4bG1fYU1ZODM3anRGdjBjZm5KWjZ3c0xQb0VKYkhnRTFoR21kVmV1RWVvTldZS2lfQmFNVUktNEdpQ09vc0VrWGJoOGxCZlRZSkF0MU9iM0dfQkxOQ2cweE1tSG1sYUdsNDBQeG50TnlwZkJaOG9xLUd6bFFhOVg3N09HTW5zQ2dEQXY0UjI2MkVTVmJ4aTE0WWMyMWxzQzBKX1ZRSDUwbDdkOW82Ny1OYV81Vml4Y2Z0NlBiWDNheDVKdDduNm5qWllxcmREOEN0b3dXSmtOcHQ0ZE1PaHN4TXlOWWs1RDBhRU9wYThaQV82a29SV0hpckRJZ1g2QnlGdElTN2J4V3FJaFRUMnBBbXFBV2pQcWZfU1BqMk40c3ktLXJCcWtaS3d3bWJZM2dPWkdCekZxZmhmUDk4UHhGV24zOE91UnpBZ3E4STJmdHNyaEZUTnBUUk5tSzViek5GWC1FSXdaQkxnZ0pjdXZpY1dpSVUtNnVEUU5Jd3BGRVZocUxEU21lc216dmJ6bkd3WHBTam5rVkpaX1p3V2pOajk4QUJkVGRvWkpRVEJXR2FlbkludWVjN3JUYklVMXd2TzI3NVV1RkktaGxmMlNncWtYRm9Vci1TZ01uTzJaNzk3bTN6aXVsTHdKcHhPU1NBWWZ5a0FtUEFnaEJIWmNMWVgtdDEwdnM0dldnUmlablE2QW85ZlVkNjdiOVJHUmlBakRmSUJMQ0NIOUdqTEFlUExpeURDZ016STVISU9wc1ZPODlaaGFLdjl3aWV1Vms0UktrRkhtUGZBOEp0ZVd6RXYtYjZTVTNaaXBXbGlRMTl1NEZrUGR1UWlYa2xYOE5qcmk3SFhWLWJzLWF1ZDRyX01NeWc2a0dEdXZubTZLSVJ2azctcTBpb0RfWnNMeXRONWx6MFREMGFfRWYtbGlhZ1d1MnNNQ2JuVlVkeGlPN1kzd0tPcnZ3UkNGbGtKX3Z1Zm5lb2hEVjRIMlBNeEd0NTY0eGRacThsUU5OVXdrNkhfbE9lblp6ZjVlVHhJa2lHRERodlVmcUlxNmNNcU5KMEFBbTZIS3hzdWgyMV9BcE1GenoxWHI4ano4X3ZDTS1qcWZLTlFFWmUtTTZTc1U0NWFfbkpUTDIybUc4eWs5aFBNLXdWWm1iVi1hNnJvSWxLLXd3bUxmc2pqQy03aFZfQXJPZ0k3Q04tb2ktYVRjSHFXQUdaLUw3bzREU05BNXFfZ2I0Tm5OWmEwUEVMSDVjbWVtTFNZa1kxb0VSXzhvZTUyanh6YnNtbklndC1md1o0MzY2QmljUFJycHpUeUNrUHpnVXNzMll4d2NyVkRJby1ySEp0RGRuOHdhYkZoUEI0XzYxbkZNZ21lekJsLUZPOUtkbzFJTGQzMkVfZkF6akRUakt2YmlMSVhfd3hESThXRnpGWmJhYlh0bWhPbm9MNGViQllYQkdiazVFLVJOb1QyLThKVnEtejNuRy10dDFxTkduVG92bU9lZjVIX1BPRGJPMUNPcDdmT2ZNWlFVSzBndzVJb0QxUFd1NkJJZjQtVWF2aEhNWlgySklUbzEwR3ZtZ0c4SzVtMlR4WHFTWGtpVlRKR01EeHRIa0FFdVNwZDJDRnVRZU1fNXRqZHdrckUxOXlQMDZhMDNvSDh4UGpqYzdUWEczZXQyODBrWGU0S1B5WGlsNC1pM0JEQjA2YldGaU5LNkM4RWhtdExnT3JRQWNybVFSZWlyem82eUlBUFpoZURJd3U1cmFHS0xyblUwaTVZMUVyV2ZGXzBBTkZudnhpY0lBNFAxUmhzS1BDMkh2UXBMQjRIdHRuTlR1MGd0NUw5dmN3SjMzbGhfelVzZnNPZGpxTWM2YXZPbU0zQ1VGMlVJR2cyT1Rqc1lDV053d1ZHWjl6WW0yOEpVWUhpUUdCOURFcmFXX0NLVlJDSHlmZV95akxxYWpCaExYNzM3Vmd4Y2JZRWRJVmJjeTVTNVF5OFJhZjFKTHJpRklZdWt0OHFzaDQ1NWFyME1YZ25yNVRsSG84QnRLVWVaT3cxQnhkZ3ZIRTQ5UVN1ZHFkNHMyMnVSTFBVbXY3SWJteEs2ZUZVcGNUMVl5Q1M2SWVvSGU4bEJuclhuc0MzUnNXbWVTZEh4SEFQMlRZeTNraG5QQi1mMlUxanFtUUQyRXdKZ1R3bWNoOWNGZ29ZUHQzTHJTbjRLYVBESnVPMER3UmVNSkphU2ZvbVFFN3pUTWNsZWZaQnlKVy1yZ0xCSkFNeW52dGtEZzhXdmxqdU53azByTmpEd0VlVFFjN3V6djBNYkhiM2JBWDV0QWhGOFpNU0QxOGFuaU96X0M5MVRTR2t2NnZ0Vmk0UlJkMVh6cEZtT3BrUkhRdzV5cE43MEhOTUNXWlU3OGppVFdWWkRUeDJmR3hHeDlpVEs5SHA3MDVTaXRBYzktSWVUbzdfTjNPZmw4NU9RZVpwLVZQbGlGcVpNb1E1RDEzQjU0WGNEMVhubVlIbGRPa19HQjdHRXBnRmttcUpZc0M5Z2thZG4tX3B5cEFnZi1fc3pPMzRkdGk2MWd5Yl82N0I5d2xpSjh4MDRWYXJ0ZktOYThwMWNpdFFVdF9LZUR0VWFjZ1R1S0FCSnZhLUxDdWtLRUhPRWg4emljRmRVUmhQdVRCMTR4c0xKUTdNd2s1OWhWci11QUZIaGJicmVqVVQzVEZwdTdQay1sckhnV0VvSThKcmZaSjhZQ29BTUE4U1FkZkVvWUFGbktqRGhvQmxxRXhTcndPVlFiZ2Z2WUhwck5BVS1ySXFpaU1DS21jMXl1VnFfaTJxYS1VbFdnSUtxZTA1elliTG92S1BwVTh1emM0NnVnUWFHNUtzeWlZWjkwcEk0WU1jcHdjY0xDWTJveXNwLXVHRnVJUTZMVHppelk0MXdnTTV5OWRCa0VFVFJEd05sOU9LdUFyS1lYVi10ek8tTlcyQndBbXlDR05BTGt3dUdieDA5Qkx4Wm5PQlQ3NEFETy1vcjRTc21WOVZkdzJ6cnBJWG1PZkN4WDAyNjM3UjZSanN2UFFaa2VlTUpmSThfTDNnWmVDZjBfcWxNMUt1TkxmZFVqZldON01UMm9xQmgxZGo1RERTMFhjRmh0SHlYcm1xLVZMNGo5TUFrOHNhaXdXc3NPbmxtc3RTNTl6Q1hhaTYwTzJfNFFVRmNTbW55RmtoTWxUYWxJa3BkMVlCZXBiTUt1Tmx1UF8zWVpPcElETnQ5Y1RZS1JZOFJKYVVUMHBtX3AtQzN3UlR4NlltcHpFcHp0X2kxY3BubnZEWW5zM0gxc1k0WW5HNUZMOWdVemxSR2dPWl90MFRJQTVxUXI3RVFMQlUwSy1yM2RtZF9vSWxaaEVzdVZSNGhLQTRUai1PWHVNZ21LS1lxZFhYRFMzMzMxbU96RWFZRUVudVNqdk91TVVfbnpLZHRKMldmajZwTE85WkRWOGRSNlYxYVhHRm42VURYeVBHYnhndm03QUttVjY4anhmajVHVENMbUtwaTh0RkxCa2xnd3ZUM1ZYTE95UUpiZGkzXzNGcmV4SWRFanVIRzB2UGhEQVVwY0wzeVZPUk5iUEV6cnZfSGM0b0dKcDVlOFF5SFZHZG9MRFg0ZS1KU3JtWFplMEgwU1FyNTFuV290cXB1NzAwcFFjeVFyXy1oczBKODk0Ym9xc3VITmdiQ3FHbFc1TkVVbU1ERHlJZTJYcVhoZjB2UTNCVzhBblMxLUEyNWxpZXhPOEpvZXh0dmp0Tzh6WjJYZjg1U0NkbW1YNEo5TlUxeGxxTVlhNnJqUVJqOGFaa0s1R1QtUUV5NlM2N0w2ZFJBZDBqUklmcWRzM1JteGdqRzNteUVUNFRBcWNaaU1PMnA4akZhYXVvYlE5a1dRYTE1a2RCTjk1aUNFT2t3UnNuSFRyRWxreXNVNXZpV2h2WjE2UmFOejJ5SzhHTmN5eG9GLW1ndE9hbFJEMTdZalhUUjdqeGxmZ09fWjhmbXZRamhKZ04zd0h3MGRYTmEteHVQdnJLZDR4aUlFeHZYWko2RjF6dV9yN1FKc1FDQVhHcEpNNXB3NmNLeE91ZTZvQzhBZmE2WTRyNlZLTzM5RWpyV3pHRjlrU2pqWlZXdWhLekdxM1JrU2dyckV2NGZlNHRHQmtsWHhqc2RPc3Q0cDBYYUIxb1RFYkVxTW5KcnlBMlRTUkN6cklPaTRGMlNXTmlrQUVoNm8wVjFYQmprNXdVaXJBZ2dIbDA1emdhdkNmN2stdDN4ODYwQnkyQUdQazBxLWhhUnFNOXptUUVELVR6VG9PRExYeWdaZTIyWF93R21HbXpCc2ppSnBZc2hGNC11OEJNblZoV0VuYjBRMENUeGdzNEJNMDhRX05TTmpHQUViV21ELUVRSlR0bFhmakYzMWdJemJmZno3QlZfM2d4MnQzSTY2MVdOWW51VDItYlVjZ3FWY1BGd0R2OUViNzFOejZGZ2ZidXo2QWpfdDEtSU5FakRjYVgyNHlET2NQRUNWY29FTWlzTUJiek1ZNHdiN2pLS2Nyd2NzM1dCUHlSTDNpUjhlLUFMejdVQm9mQjhaX0lrR0JzWEdvZUZHMmdEb2VjVnZLUWRadGdyZ3ZWNXpHS240djRfZHU1TzJiSDEwNkZDQkZORjAyUTEteGJoMDFDWjdadWVkdVRvTldrNklpNUVFOUp2YlR6d283T0pDOVdUZU1vbXR1OWtxU3NLdUY1RTVSWUlSNXV0Qks2bjB5dXM2WnZCQ1VwQWZYbzNjTThzdkE3dWxVdkhYb01YX2pfeXBIdF9aMWRNalBlWkN6R3NoQWFVTktjR2VQZlRRWEEyaG42WXN6VjJyWWc5NFl3bEYyRHgySzVIdWkwQTU2bEVjamRfTE9ZLUt2cUVwcFBfeGRuOFlQaEZ0UURadzZQeUdlSmlmZ1RFbDJnalFrZnF2V3k2MmR0MGdpekktR0oyT3Z3a2g3NmlQZG5WMmdUUlc0OERMcUVlMC16LTU1V1NIMUZLMjExZldRU1BXYzBPbldzS0R3VVR6RTF3VWZkRHg4RmVXNTBLOVQ5UU9hREFGVGEwYUJrZnR2QUNJdVlkdmZLTTR0djJmZjB5bVRoR1loWU5HOWVITklveTE4YmZhVjE4WmduZmFjTkVYdFNVNkJXeFhxbGF5MFo2bEppcU1aaTJWdXRVTVZSOGY1YXNvRmc4Y0xwVGFmOW1ySjR0bnBWQm1aTFZOU0lyNG1WTHJWdWRtNnlpY0Z3M0IxaXVuM1hrY0Y0SnJrTlJvV3lyejZ2X2x1ODd3a3BEbDA3UEVVTmE3Qlhpekxuc1FoTWVSLTJCODNyMFFaQzE2SjJFYUc5eVBtZU9tTTFZS0pjYTFQMVAwTmNNd1lDT051ejd4NDVXb082SUVBanhONFc4N3RGdXRRbHhlTTR1dDdCVm9CTTE5aVJnWEkySGxDamJPZXVRM3pDWlI5a2hiR055T2tQbi1IaTdxTE1SYm9sRGpiRVlEZFlYNGVNMUxsSkItTFFaaVVHc2Eya1hOaUoyeTZXd0RtRHNiWlE2Zm1JR2ZQUG5sN3R0bzNxc3pZeEh3b1JNeHpKRk1KcVJnM3pqcGRRUnNUQTRoU0NZa1pMQzRvdzdxMWZBdi1EU2dYbEVJaUNOelEtYWF1ZGpVa21wdlBmSFVlOHpoWDlpNzR6MzhMTlo2dXcwMHlmeHk3bURiVFBDLVJyMVpQcEsyUjJVVVA0U2pGX0g4N3JRUExSZ1ZzVndLZ1A1dEo4akE2bEJidXdLZFBsclVGT1lpY3doUXBQUVEwUUxBemxTMnZlOHQyenlZcHdLNVhyNEFKbEZQM3JYczljWnN0c0xmaS0wU09lbU5FVGZjdXd6bVBNY1FaeWcwUHUxZHdFUjFMdEVkdThVejBvS3ZwVldWcl90LWVNdnBPemNTUlJvNkFMQS1mNDhjUU12ZmtXdVNKSHlab1ROZGtfM3NvazFjYldvTFR6SzNfSlZWRDFNTF9ybDFKTG1WZWt2V2NsSTVYNGEzYkpQSU1tRVlCSl9NOWRERXpQajhwOUxlUlViOW1uMkpORjdPQXRmU0RJbnVvYWw4LUpDUE9lNmM5SHMyQjN1bFRxTU85ZDNjTERrLWg5TFIzTHdTbFNDVENSY1hZMURSMEN3cW1mNFYxUTlRQ0dKZ3pkaDlEMEhpb2g3MldkSGxoa2huMTFKcUNNUkZRUjBndUlYdlRhSXgwYVhCMnl0TldvLTJ6RlFFcU5WTTJOc0IyS0tJTUhwanVSa08xM3BXVDNtNFRoeTJsaUpGSE13S2dHa29DY21UeFZZVDA1MWZwV1pKQ25haVpxTndQaGVOZkNZMm5EVEFPdjVyOEZuZDFuQjJLWE5jRDlDeWNCdjJ0UTVXbjVMUWVYYnlNX2hOYXVFQmpNMmk5cXlZcUdEU1NNSWpqa1hlZUpoZDF1M2swRl9DSXdXQmFQQ1YzODROZ1cxc0NRPT0=
+
+# Feature SyncDelta JIRA configuration
+Feature_SyncDelta_JIRA_DELTA_TOKEN_SECRET = PROD_ENC:Z0FBQUFBQm8xRjFmUHNhOGsxT0lOaHhLTUxNUElMRlZtNWl0TlhKRWxQNWxTT0ljX3N4VEVUNnZGUzFfMy1qZVFlZzl6Z3FIT2NXdXdZYXpyUnNGUXFUMF9oZVIzdXpqRVlBWlp6TUhkMURnb2E2R1R4dGtRZ0J3VHFaM2w5STBEaW5TN0JtRS01UXdUd0xZbjd2LXc2U0Zpc3duem1SMmJCUjg0cGxPaHA0YXhITFJrZmIyUjYxWm9PZ2ZVUGNHYXl4ZkVkZ21KakdJNzhMNkRMWGFzeUNWcGhTaUM2UWdjTHZVemJ5WnJ0M3hPbndrLURYYV9SSVJwM190NEd0M3V3SzNyaW1vLWZJdHc5QlZwLXNUV2hJdHNKbTZiREY1YTVUclVpYTVkSFAxYVR1ek9JZ3VyZzVFRzQyYUpiZ3psdFlZbHdpblZVNF85Y1lwWEdTT1hOajNBUGpMMi1zOWdhNElBa0cwMFFGeTZpV25rSGJRRjZWenZPRXdWdGczSGNfM2lPUFdsYUcxWHRrTlFzRGViY1hWWU1LMG1EWUl0cnJvVHRCZnNNZGZ0MGFQUDF6TGpqTlRKcjV2YjF2RDBEVktaWmF1SkE4ZVJWeDJ3TWlVSmpCeEtXdlNsOXY5RDY3d2JPWUtINHRZVmJoaEUwYnpyZFExYWlKV3JGeDZSd20yTlE4LU14YzlYNUlIWG9NbXhqbWJUQ0hJTzhRbTlnenRxQ2V0dEhDcWpEN2lETjR1TkhRMFdpYjBuSEN3MzlVZ1dQbld1YXlhSDdfV3FCdXVkRHZMZDVUZkpjdHdoazl2OFNQc0VHM2dWdEZLaVh4Y2FsWFRwci1GQ3pxTkhoNk9uR0dDVTdndG9UMmR5SlFKOUxqXzR1YVpKNjBfd2FCY0xsMDk1bHpFd05LV3NjT3FFOFZPUEdnbElUdDZpZ3JhNFMtalVWT09PbWxvMWlMUTVHdU5JZnZTbjNLek1nPT0=
diff --git a/modules/connectors/connectorDbPostgre.py b/modules/connectors/connectorDbPostgre.py
index ce1cfe9b..c17fa2c3 100644
--- a/modules/connectors/connectorDbPostgre.py
+++ b/modules/connectors/connectorDbPostgre.py
@@ -6,19 +6,34 @@ import logging
from typing import List, Dict, Any, Optional, Union, get_origin, get_args
from datetime import datetime
import uuid
-from pydantic import BaseModel
+from pydantic import BaseModel, Field
import threading
import time
-from modules.shared.attributeUtils import to_dict
+from modules.shared.attributeUtils import to_dict, ModelMixin
from modules.shared.timezoneUtils import get_utc_timestamp
from modules.shared.configuration import APP_CONFIG
-from modules.interfaces.interfaceAppModel import SystemTable
logger = logging.getLogger(__name__)
# No mapping needed - table name = Pydantic model name exactly
+class SystemTable(BaseModel, ModelMixin):
+ """Data model for system table entries"""
+ table_name: str = Field(
+ description="Name of the table",
+ frontend_type="text",
+ frontend_readonly=True,
+ frontend_required=True
+ )
+ initial_id: Optional[str] = Field(
+ default=None,
+ description="Initial ID for the table",
+ frontend_type="text",
+ frontend_readonly=True,
+ frontend_required=False
+ )
+
def _get_model_fields(model_class) -> Dict[str, str]:
"""Get all fields from Pydantic model and map to SQL types."""
if not hasattr(model_class, '__fields__'):
diff --git a/modules/connectors/connectorTicketsClickup.py b/modules/connectors/connectorTicketsClickup.py
new file mode 100644
index 00000000..7e07a830
--- /dev/null
+++ b/modules/connectors/connectorTicketsClickup.py
@@ -0,0 +1,145 @@
+"""ClickUp connector for CRUD operations (compatible with TicketInterface)."""
+
+from dataclasses import dataclass
+from typing import Optional
+import logging
+import aiohttp
+
+from modules.interfaces.interfaceTicketModel import (
+ TicketBase,
+ TicketFieldAttribute,
+ Task,
+)
+
+
+logger = logging.getLogger(__name__)
+
+
+class ConnectorTicketClickup(TicketBase):
+ def __init__(
+ self,
+ *,
+ apiToken: str,
+ teamId: str,
+ listId: Optional[str] = None,
+ apiUrl: str = "https://api.clickup.com/api/v2",
+ ) -> None:
+ self.apiToken = apiToken
+ self.teamId = teamId
+ self.listId = listId
+ self.apiUrl = apiUrl
+
+ def _headers(self) -> dict:
+ return {
+ "Authorization": self.apiToken,
+ "Content-Type": "application/json",
+ }
+
+ async def read_attributes(self) -> list[TicketFieldAttribute]:
+ """Fetch field attributes. Uses list custom fields if listId provided; else basic fields."""
+ attributes: list[TicketFieldAttribute] = []
+ try:
+ async with aiohttp.ClientSession() as session:
+ if self.listId:
+ url = f"{self.apiUrl}/list/{self.listId}/field"
+ async with session.get(url, headers=self._headers()) as response:
+ if response.status != 200:
+ logger.warning(f"ClickUp fields fetch status: {response.status}")
+ else:
+ data = await response.json()
+ for field in data.get("fields", []):
+ fieldId = field.get("id")
+ fieldName = field.get("name", fieldId)
+ if fieldId:
+ attributes.append(TicketFieldAttribute(fieldName=fieldName, field=fieldId))
+
+ # Add common top-level fields
+ core_fields = [
+ ("ID", "id"),
+ ("Name", "name"),
+ ("Status", "status.status"),
+ ("Assignees", "assignees"),
+ ("DateCreated", "date_created"),
+ ("DueDate", "due_date"),
+ ]
+ for name, fid in core_fields:
+ attributes.append(TicketFieldAttribute(fieldName=name, field=fid))
+ except Exception as e:
+ logger.error(f"ClickUp read_attributes error: {e}")
+ return attributes
+
+ async def read_tasks(self, *, limit: int = 0) -> list[Task]:
+ """Read tasks from ClickUp, always returning full task records.
+ If list_id is set, read from that list; otherwise read from team.
+ """
+ tasks: list[Task] = []
+ try:
+ async with aiohttp.ClientSession() as session:
+ page = 0
+ pageSize = 100
+ while True:
+ if self.listId:
+ url = f"{self.apiUrl}/list/{self.listId}/task?subtasks=true&page={page}&order_by=created&reverse=true"
+ else:
+ # Team-level search for open tasks
+ url = f"{self.apiUrl}/team/{self.teamId}/task?subtasks=true&page={page}&order_by=created&reverse=true"
+
+ # Request with parameters to include all fields where possible
+ async with session.get(url, headers=self._headers()) as response:
+ if response.status != 200:
+ errorText = await response.text()
+ logger.error(f"ClickUp read_tasks failed: {response.status} {errorText}")
+ break
+
+ data = await response.json()
+ items = data.get("tasks", [])
+ for item in items:
+ tasks.append(Task(data=item))
+ if limit and len(tasks) >= limit:
+ return tasks
+
+ if len(items) < pageSize:
+ break
+ page += 1
+ except Exception as e:
+ logger.error(f"ClickUp read_tasks error: {e}")
+ return tasks
+
+ async def write_tasks(self, tasklist: list[Task]) -> None:
+ """Update tasks in ClickUp. Expects Task.data to contain {'ID' or 'id' or 'task_id', 'fields': {...}}"""
+ try:
+ async with aiohttp.ClientSession() as session:
+ for task in tasklist:
+ data = task.data
+ taskId = data.get("ID") or data.get("id") or data.get("task_id")
+ fields = data.get("fields", {})
+ if not taskId or not isinstance(fields, dict) or not fields:
+ continue
+
+ # Map generic fields to ClickUp payload
+ payload: dict = {}
+ for fieldId, value in fields.items():
+ # Heuristics: map common field ids
+ if fieldId in ("name", "summary"):
+ payload["name"] = value
+ elif fieldId in ("status",):
+ payload["status"] = value
+ elif fieldId.startswith("customfield_") or fieldId.startswith("cf_"):
+ # ClickUp custom fields need separate endpoint; attempt inline update if supported
+ if "custom_fields" not in payload:
+ payload["custom_fields"] = []
+ payload["custom_fields"].append({"id": fieldId, "value": value})
+ else:
+ # Best-effort assign to description for unknown text fields
+ if isinstance(value, str) and value:
+ payload.setdefault("description", value)
+
+ url = f"{self.apiUrl}/task/{taskId}"
+ async with session.put(url, headers=self._headers(), json=payload) as response:
+ if response.status not in (200, 204):
+ err = await response.text()
+ logger.error(f"ClickUp update failed for {taskId}: {response.status} {err}")
+ except Exception as e:
+ logger.error(f"ClickUp write_tasks error: {e}")
+
+
diff --git a/modules/connectors/connectorTicketJira.py b/modules/connectors/connectorTicketsJira.py
similarity index 87%
rename from modules/connectors/connectorTicketJira.py
rename to modules/connectors/connectorTicketsJira.py
index 508a357a..ffeed2ca 100644
--- a/modules/connectors/connectorTicketJira.py
+++ b/modules/connectors/connectorTicketsJira.py
@@ -1,47 +1,32 @@
-"""Jira connector for CRUD operations."""
+"""Jira connector for CRUD operations (neutralized to generic ticket interface)."""
from dataclasses import dataclass
-import os
import logging
import aiohttp
import asyncio
import json
-from modules.interfaces.interfaceTicketModel import (
- TicketBase,
- TicketFieldAttribute,
- Task,
-)
-
+from modules.interfaces.interfaceTicketModel import (TicketBase, TicketFieldAttribute, Task, )
logger = logging.getLogger(__name__)
-@dataclass
class ConnectorTicketJira(TicketBase):
- jira_username: str
- jira_api_token: str
- jira_url: str
- project_code: str
- issue_type: str
-
- @classmethod
- async def create(
- cls,
+ def __init__(
+ self,
*,
- jira_username: str,
- jira_api_token: str,
- jira_url: str,
- project_code: str,
- issue_type: str,
- ):
- return ConnectorTicketJira(
- jira_username=jira_username,
- jira_api_token=jira_api_token,
- jira_url=jira_url,
- project_code=project_code,
- issue_type=issue_type,
- )
+ apiUsername: str,
+ apiToken: str,
+ apiUrl: str,
+ projectCode: str,
+ ticketType: str,
+ ) -> None:
+ self.apiUsername = apiUsername
+ self.apiToken = apiToken
+ self.apiUrl = apiUrl
+ self.projectCode = projectCode
+ self.ticketType = ticketType
+
async def read_attributes(self) -> list[TicketFieldAttribute]:
"""
@@ -52,22 +37,22 @@ class ConnectorTicketJira(TicketBase):
list[TicketFieldAttribute]: List of field attributes with names and IDs
"""
# Build JQL dynamically; allow empty or '*' issue_type to mean "all types"
- if self.issue_type and self.issue_type != "*":
- jql_query = f"project={self.project_code} AND issuetype={self.issue_type}"
+ if self.ticketType and self.ticketType != "*":
+ jql_query = f"project={self.projectCode} AND issuetype={self.ticketType}"
else:
- jql_query = f"project={self.project_code}"
+ jql_query = f"project={self.projectCode}"
# Prepare the request URL (use JQL search endpoint)
- url = f"{self.jira_url}/rest/api/3/search/jql"
+ url = f"{self.apiUrl}/rest/api/3/search/jql"
# Prepare authentication
- auth = aiohttp.BasicAuth(self.jira_username, self.jira_api_token)
+ auth = aiohttp.BasicAuth(self.apiUsername, self.apiToken)
try:
async with aiohttp.ClientSession() as session:
headers = {"Content-Type": "application/json"}
payload = {
- "jql": jql_query,
+ "jql": jql_query,
"maxResults": 1
# Don't specify fields to get all available fields
}
@@ -100,9 +85,9 @@ class ConnectorTicketJira(TicketBase):
fields = issue.get("fields", {})
for field_id, value in fields.items():
- field_name = field_names.get(field_id, field_id)
+ fieldName = field_names.get(field_id, field_id)
attributes.append(
- TicketFieldAttribute(field_name=field_name, field=field_id)
+ TicketFieldAttribute(fieldName=fieldName, field=field_id)
)
logger.info(
@@ -122,8 +107,8 @@ class ConnectorTicketJira(TicketBase):
async def _read_all_fields_via_fields_api(self) -> list[TicketFieldAttribute]:
"""Fallback: use Jira fields API to list all fields with id->name mapping."""
- auth = aiohttp.BasicAuth(self.jira_username, self.jira_api_token)
- url = f"{self.jira_url}/rest/api/3/field"
+ auth = aiohttp.BasicAuth(self.apiUsername, self.apiToken)
+ url = f"{self.apiUrl}/rest/api/3/field"
try:
async with aiohttp.ClientSession() as session:
async with session.get(url, auth=auth) as response:
@@ -138,10 +123,10 @@ class ConnectorTicketJira(TicketBase):
attributes: list[TicketFieldAttribute] = []
for field in data:
field_id = field.get("id")
- field_name = field.get("name", field_id)
+ fieldName = field.get("name", field_id)
if field_id:
attributes.append(
- TicketFieldAttribute(field_name=field_name, field=field_id)
+ TicketFieldAttribute(fieldName=fieldName, field=field_id)
)
logger.info(
f"Successfully retrieved {len(attributes)} field attributes via fields API"
@@ -162,10 +147,10 @@ class ConnectorTicketJira(TicketBase):
list[Task]: List of tasks with their data
"""
# Build JQL dynamically; allow empty or '*' issue_type to mean "all types"
- if self.issue_type and self.issue_type != "*":
- jql_query = f"project={self.project_code} AND issuetype={self.issue_type}"
+ if self.ticketType and self.ticketType != "*":
+ jql_query = f"project={self.projectCode} AND issuetype={self.ticketType}"
else:
- jql_query = f"project={self.project_code}"
+ jql_query = f"project={self.projectCode}"
# Initialize variables for pagination (cursor-based /search/jql)
max_results = 100
@@ -176,8 +161,8 @@ class ConnectorTicketJira(TicketBase):
seen_issue_ids: set[str] = set()
# Prepare authentication
- auth = aiohttp.BasicAuth(self.jira_username, self.jira_api_token)
- url = f"{self.jira_url}/rest/api/3/search/jql"
+ auth = aiohttp.BasicAuth(self.apiUsername, self.apiToken)
+ url = f"{self.apiUrl}/rest/api/3/search/jql"
try:
async with aiohttp.ClientSession() as session:
@@ -286,7 +271,7 @@ class ConnectorTicketJira(TicketBase):
tasklist: List of Task objects containing task data to update
"""
headers = {"Accept": "application/json", "Content-Type": "application/json"}
- auth = aiohttp.BasicAuth(self.jira_username, self.jira_api_token)
+ auth = aiohttp.BasicAuth(self.apiUsername, self.apiToken)
try:
async with aiohttp.ClientSession() as session:
@@ -346,7 +331,7 @@ class ConnectorTicketJira(TicketBase):
update_data = {"fields": processed_fields}
# Make the update request
- url = f"{self.jira_url}/rest/api/3/issue/{task_id}"
+ url = f"{self.apiUrl}/rest/api/3/issue/{task_id}"
async with session.put(
url, json=update_data, headers=headers, auth=auth
@@ -365,3 +350,5 @@ class ConnectorTicketJira(TicketBase):
except Exception as e:
logger.error(f"Unexpected error while updating Jira tasks: {str(e)}")
raise
+
+
diff --git a/modules/connectors/connectorWebTavily.py b/modules/connectors/connectorWebTavily.py
index 59eb1396..6270d10f 100644
--- a/modules/connectors/connectorWebTavily.py
+++ b/modules/connectors/connectorWebTavily.py
@@ -35,20 +35,7 @@ from modules.shared.configuration import APP_CONFIG
logger = logging.getLogger(__name__)
-# Configuration loading functions
-def get_web_crawl_timeout() -> int:
- """Get web crawl timeout from configuration"""
- return int(APP_CONFIG.get("Web_Crawl_TIMEOUT", "30"))
-
-
-def get_web_crawl_max_retries() -> int:
- """Get web crawl max retries from configuration"""
- return int(APP_CONFIG.get("Web_Crawl_MAX_RETRIES", "3"))
-
-
-def get_web_crawl_retry_delay() -> int:
- """Get web crawl retry delay from configuration"""
- return int(APP_CONFIG.get("Web_Crawl_RETRY_DELAY", "2"))
+# Cached configuration values are loaded into the connector instance on creation
@dataclass
@@ -66,13 +53,26 @@ class TavilyCrawlResult:
@dataclass
class ConnectorTavily(WebSearchBase, WebCrawlBase, WebScrapeBase):
client: AsyncTavilyClient = None
+ # Cached settings loaded at initialization time
+ crawl_timeout: int = 30
+ crawl_max_retries: int = 3
+ crawl_retry_delay: int = 2
@classmethod
async def create(cls):
api_key = APP_CONFIG.get("Connector_WebTavily_API_KEY_SECRET")
if not api_key:
raise ValueError("Tavily API key not configured. Please set Connector_WebTavily_API_KEY_SECRET in config.ini")
- return cls(client=AsyncTavilyClient(api_key=api_key))
+ # Load and cache web crawl related configuration
+ crawl_timeout = int(APP_CONFIG.get("Web_Crawl_TIMEOUT", "30"))
+ crawl_max_retries = int(APP_CONFIG.get("Web_Crawl_MAX_RETRIES", "3"))
+ crawl_retry_delay = int(APP_CONFIG.get("Web_Crawl_RETRY_DELAY", "2"))
+ return cls(
+ client=AsyncTavilyClient(api_key=api_key),
+ crawl_timeout=crawl_timeout,
+ crawl_max_retries=crawl_max_retries,
+ crawl_retry_delay=crawl_retry_delay,
+ )
async def search_urls(self, request: WebSearchRequest) -> WebSearchActionResult:
"""Handles the web search request.
@@ -240,9 +240,9 @@ class ConnectorTavily(WebSearchBase, WebCrawlBase, WebScrapeBase):
"""Calls the Tavily API to extract text content from URLs with retry logic."""
import asyncio
- max_retries = get_web_crawl_max_retries()
- retry_delay = get_web_crawl_retry_delay()
- timeout = get_web_crawl_timeout()
+ max_retries = self.crawl_max_retries
+ retry_delay = self.crawl_retry_delay
+ timeout = self.crawl_timeout
for attempt in range(max_retries + 1):
try:
diff --git a/modules/features/init.py b/modules/features/init.py
new file mode 100644
index 00000000..e1ddec8d
--- /dev/null
+++ b/modules/features/init.py
@@ -0,0 +1,18 @@
+# Launch features as events
+
+import asyncio
+import logging
+from modules.interfaces.interfaceAppObjects import getRootInterface
+
+# GET EVENT USER
+
+logger = logging.getLogger(__name__)
+eventUser = getRootInterface().getUserByUsername("event")
+if not eventUser:
+ logger.error("Event user not found")
+
+# LAUNCH FEATURES
+
+from modules.features.syncDelta.mainSyncDelta import ManagerSyncDelta, performSync
+managerSyncDelta = ManagerSyncDelta(eventUser)
+asyncio.create_task(performSync(eventUser))
diff --git a/modules/features/neutralizePlayground/mainNeutralizePlayground.py b/modules/features/neutralizePlayground/mainNeutralizePlayground.py
index 4b48a495..256b50c2 100644
--- a/modules/features/neutralizePlayground/mainNeutralizePlayground.py
+++ b/modules/features/neutralizePlayground/mainNeutralizePlayground.py
@@ -1,8 +1,8 @@
import logging
from typing import Any, Dict, List, Optional
-from modules.interfaces.interfaceAppModel import User
-from modules.services.serviceNeutralization.mainNeutralization import NeutralizationService
+from modules.interfaces.interfaceAppModel import User, DataNeutralizerAttributes, DataNeutraliserConfig
+from modules.services.serviceNeutralization.mainServiceNeutralization import NeutralizationService
logger = logging.getLogger(__name__)
@@ -75,6 +75,53 @@ class NeutralizationPlayground:
'error': str(e),
}
+ # Additional methods needed by the route
+ def get_config(self) -> Optional[DataNeutraliserConfig]:
+ """Get neutralization configuration"""
+ return self.service.getConfig()
+
+ def save_config(self, config_data: Dict[str, Any]) -> DataNeutraliserConfig:
+ """Save neutralization configuration"""
+ return self.service.saveConfig(config_data)
+
+ def neutralize_text(self, text: str, file_id: str = None) -> Dict[str, Any]:
+ """Neutralize text content"""
+ return self.service.processText(text)
+
+ def resolve_text(self, text: str) -> str:
+ """Resolve UIDs in neutralized text back to original text"""
+ return self.service.resolveText(text)
+
+ def get_attributes(self, file_id: str = None) -> List[DataNeutralizerAttributes]:
+ """Get neutralization attributes, optionally filtered by file ID"""
+ if not self.service.app_interface:
+ return []
+ try:
+ all_attributes = self.service._getAttributes()
+ if file_id:
+ return [attr for attr in all_attributes if attr.fileId == file_id]
+ return all_attributes
+ except Exception as e:
+ logger.error(f"Error getting attributes: {str(e)}")
+ return []
+
+ async def process_sharepoint_files(self, source_path: str, target_path: str) -> Dict[str, Any]:
+ """Process files from SharePoint source path and store neutralized files in target path"""
+ return await self.processSharepointFiles(source_path, target_path)
+
+ def batch_neutralize_files(self, files_data: List[Dict[str, Any]]) -> Dict[str, Any]:
+ """Process multiple files for neutralization"""
+ file_ids = [file_data.get('fileId') for file_data in files_data if file_data.get('fileId')]
+ return self.processFiles(file_ids)
+
+ def get_processing_stats(self) -> Dict[str, Any]:
+ """Get neutralization processing statistics"""
+ return self.getStats()
+
+ def cleanup_file_attributes(self, file_id: str) -> bool:
+ """Clean up neutralization attributes for a specific file"""
+ return self.cleanAttributes(file_id)
+
# Internal SharePoint helper module separated to keep feature logic tidy
class SharepointProcessor:
diff --git a/modules/features/syncDelta/mainSyncDelta.py b/modules/features/syncDelta/mainSyncDelta.py
index 3fac4f7b..a54c504d 100644
--- a/modules/features/syncDelta/mainSyncDelta.py
+++ b/modules/features/syncDelta/mainSyncDelta.py
@@ -1,22 +1,17 @@
"""
-Delta Group JIRA-SharePoint Sync Manager
+Delta Group Sync Manager
-This module handles the synchronization of JIRA tickets to SharePoint using the new
+This module handles the synchronization of tickets to SharePoint using the new
Graph API-based connector architecture.
"""
import logging
import os
-import csv
import io
from datetime import datetime, UTC
from typing import Dict, Any, List, Optional
-from modules.services.serviceSharepoint.mainSharepoint import SharepointService
-from modules.connectors.connectorTicketJira import ConnectorTicketJira
-from modules.interfaces.interfaceAppObjects import getRootInterface
-from modules.interfaces.interfaceAppModel import UserInDB
-from modules.interfaces.interfaceTicketObjects import TicketSharepointSyncInterface
-from modules.shared.timezoneUtils import get_utc_timestamp
+from modules.services import getInterface as getServices
+# Removed direct import - now using services.ticket
from modules.shared.configuration import APP_CONFIG
logger = logging.getLogger(__name__)
@@ -25,165 +20,16 @@ logger = logging.getLogger(__name__)
APP_ENV_TYPE = APP_CONFIG.get("APP_ENV_TYPE", "dev")
-def convert_adf_to_text(adf_data):
- """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:
- adf_data: ADF object or None
-
- Returns:
- str: Plain text content, or empty string if None/invalid
- """
- if not adf_data or not isinstance(adf_data, dict):
- return ""
-
- if adf_data.get("type") != "doc":
- return str(adf_data) if adf_data else ""
-
- content = adf_data.get("content", [])
- if not isinstance(content, list):
- return ""
-
- def extract_text_from_content(content_list, list_level=0):
- """Recursively extract text from ADF content with proper formatting."""
- text_parts = []
- list_counter = 1
-
- for item in content_list:
- if not isinstance(item, dict):
- continue
-
- item_type = item.get("type", "")
-
- if item_type == "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})"
-
- text_parts.append(text)
-
- elif item_type == "hardBreak":
- text_parts.append("\n")
-
- elif item_type == "paragraph":
- paragraph_content = item.get("content", [])
- if paragraph_content:
- paragraph_text = extract_text_from_content(paragraph_content, list_level)
- if paragraph_text.strip():
- text_parts.append(paragraph_text)
-
- elif item_type == "bulletList":
- list_content = item.get("content", [])
- for list_item in list_content:
- if list_item.get("type") == "listItem":
- list_item_content = list_item.get("content", [])
- for list_paragraph in list_item_content:
- if list_paragraph.get("type") == "paragraph":
- list_paragraph_content = list_paragraph.get("content", [])
- if list_paragraph_content:
- indent = " " * list_level
- bullet_text = extract_text_from_content(list_paragraph_content, list_level + 1)
- if bullet_text.strip():
- text_parts.append(f"{indent}• {bullet_text}")
-
- elif item_type == "orderedList":
- list_content = item.get("content", [])
- for list_item in list_content:
- if list_item.get("type") == "listItem":
- list_item_content = list_item.get("content", [])
- for list_paragraph in list_item_content:
- if list_paragraph.get("type") == "paragraph":
- list_paragraph_content = list_paragraph.get("content", [])
- if list_paragraph_content:
- indent = " " * list_level
- ordered_text = extract_text_from_content(list_paragraph_content, list_level + 1)
- if ordered_text.strip():
- text_parts.append(f"{indent}{list_counter}. {ordered_text}")
- list_counter += 1
-
- elif item_type == "listItem":
- # Handle nested list items
- list_item_content = item.get("content", [])
- if list_item_content:
- text_parts.append(extract_text_from_content(list_item_content, list_level))
-
- elif item_type == "embedCard":
- # Handle embedded content (videos, etc.)
- attrs = item.get("attrs", {})
- url = attrs.get("url", "")
- if url:
- text_parts.append(f"[Embedded Content: {url}]")
-
- elif item_type == "codeBlock":
- # Handle code blocks
- code_content = item.get("content", [])
- if code_content:
- code_text = extract_text_from_content(code_content, list_level)
- if code_text.strip():
- text_parts.append(f"```\n{code_text}\n```")
-
- elif item_type == "blockquote":
- # Handle blockquotes
- quote_content = item.get("content", [])
- if quote_content:
- quote_text = extract_text_from_content(quote_content, list_level)
- if quote_text.strip():
- text_parts.append(f"> {quote_text}")
-
- elif item_type == "heading":
- # Handle headings
- heading_content = item.get("content", [])
- if heading_content:
- heading_text = extract_text_from_content(heading_content, list_level)
- if heading_text.strip():
- level = item.get("attrs", {}).get("level", 1)
- text_parts.append(f"{'#' * level} {heading_text}")
-
- elif item_type == "rule":
- # Handle horizontal rules
- text_parts.append("---")
-
- else:
- # Handle unknown types by trying to extract content
- if "content" in item:
- content_text = extract_text_from_content(item.get("content", []), list_level)
- if content_text.strip():
- text_parts.append(content_text)
-
- return "\n".join(text_parts)
-
- result = extract_text_from_content(content)
- return result.strip()
-
-
class ManagerSyncDelta:
- """Manages JIRA to SharePoint synchronization for Delta Group.
+ """Manages Tickets to SharePoint synchronization for Delta Group.
Supports two sync modes:
- CSV mode: Uses CSV files for synchronization (default)
- Excel mode: Uses Excel (.xlsx) files for synchronization
- To change sync mode, use the set_sync_mode() method or modify SYNC_MODE class variable.
+ To change sync mode, use the setSyncMode() method or modify SYNC_MODE class variable.
"""
- SHAREPOINT_SITE_ID = "02830618-4029-4dc8-8d3d-f5168f282249"
+
SHAREPOINT_SITE_NAME = "SteeringBPM"
SHAREPOINT_SITE_PATH = "SteeringBPM"
SHAREPOINT_HOSTNAME = "deltasecurityag.sharepoint.com"
@@ -192,24 +38,22 @@ class ManagerSyncDelta:
SHAREPOINT_AUDIT_FOLDER = "/General/50 Docs hosted by SELISE/SyncHistory"
SHAREPOINT_USER_ID = "patrick.motsch@delta.ch"
- # Sync mode: "csv" or "xlsx"
SYNC_MODE = "xlsx" # Can be "csv" or "xlsx"
-
# File names for different sync modes
SYNC_FILE_CSV = "DELTAgroup x SELISE Ticket Exchange List.csv"
SYNC_FILE_XLSX = "DELTAgroup x SELISE Ticket Exchange List.xlsx"
- # JIRA connection parameters (hardcoded for Delta Group)
+ # Tickets connection parameters
JIRA_USERNAME = "p.motsch@valueon.ch"
- JIRA_API_TOKEN = "ATATT3xFfGF0d973nNb3R1wTDI4lesmJfJAmooS-4cYMJTyLfwYv4himrE6yyCxyX3aSMfl34NHcm2fAXeFXrLHUzJx0RQVUBonCFnlgexjLQTgS5BoCbSO7dwAVjlcHZZkArHbooCUaRwJ15n6AHkm-nwdjLQ3Z74TFnKKUZC4uhuh3Aj-MuX8=2D7124FA"
+ JIRA_API_TOKEN = APP_CONFIG.get("Feature_SyncDelta_JIRA_DELTA_TOKEN_SECRET", "")
JIRA_URL = "https://deltasecurity.atlassian.net"
JIRA_PROJECT_CODE = "DCS"
JIRA_ISSUE_TYPE = "Task"
- # Task sync definition for field mapping (like original synchronizer)
+ # Task sync definition for field mapping
TASK_SYNC_DEFINITION={
- #key=excel-header, [get:jira>excel | put: excel>jira, jira-xml-field-list]
+ #key=excel-header, [get:ticket>excel | put: excel>ticket, tickets-xml-field-list]
'ID': ['get', ['key']],
'Module Category': ['get', ['fields', 'customfield_10058', 'value']],
'Summary': ['get', ['fields', 'summary']],
@@ -226,25 +70,108 @@ class ManagerSyncDelta:
'SELISE Comments': ['put', ['fields', 'customfield_10168']],
}
- def __init__(self):
- """Initialize the sync manager with hardcoded Delta Group credentials."""
- self.root_interface = getRootInterface()
- self.jira_connector = None
- self.sharepoint_connector = None
- self.target_site = None
- # Initialize centralized services with root user
- from modules.services import getInterface as getServices
- root_user = self.root_interface.getUserByUsername("admin")
- self.services = getServices(root_user, None)
-
- def get_sync_file_name(self) -> str:
+ def __init__(self, eventUser=None):
+ self.targetSite = None
+ self.services = None
+ self.sharepointConnection = None
+ self.eventUser = eventUser
+ self.sync_audit_log = [] # Store audit log entries in memory
+
+ try:
+ if not eventUser:
+ logger.error("Event user not found - SharePoint connection required")
+ self._log_audit_event("SYNC_INIT", "FAILED", "Event user not found")
+ else:
+ self.services = getServices(eventUser, None)
+ # Resolve SharePoint connection for the configured user id
+ self.sharepointConnection = self.services.workflowService.getUserConnectionByExternalUsername(
+ "msft", self.SHAREPOINT_USER_ID
+ )
+ if not self.sharepointConnection:
+ logger.error(
+ f"No SharePoint connection found for user: {self.SHAREPOINT_USER_ID}"
+ )
+ self._log_audit_event("SYNC_INIT", "FAILED", f"No SharePoint connection for user: {self.SHAREPOINT_USER_ID}")
+ else:
+ # Configure SharePoint service token and set connector reference
+ if not self.services.sharepoint.setAccessToken(
+ self.sharepointConnection, self.services.interfaceApp
+ ):
+ logger.error("Failed to set SharePoint token from UserConnection")
+ self._log_audit_event("SYNC_INIT", "FAILED", "Failed to set SharePoint token")
+ else:
+ logger.info(
+ f"SharePoint token configured for connection: {self.sharepointConnection.id}"
+ )
+ self._log_audit_event("SYNC_INIT", "SUCCESS", f"SharePoint token configured for connection: {self.sharepointConnection.id}")
+ except Exception as e:
+ logger.error(f"Initialization error in ManagerSyncDelta.__init__: {e}")
+ self._log_audit_event("SYNC_INIT", "ERROR", f"Initialization error: {str(e)}")
+
+ def _log_audit_event(self, action: str, status: str, details: str):
+ """Log audit events for sync operations to memory."""
+ try:
+ timestamp = datetime.now(UTC).strftime("%Y-%m-%d %H:%M:%S UTC")
+ user_id = str(self.eventUser.id) if self.eventUser else "system"
+ log_entry = f"{timestamp} | {user_id} | {action} | {status} | {details}"
+ self.sync_audit_log.append(log_entry)
+ logger.info(f"Sync Audit: {log_entry}")
+ except Exception as e:
+ logger.warning(f"Failed to log audit event: {str(e)}")
+
+ def _log_sync_changes(self, merge_details: dict, sync_mode: str):
+ """Log detailed field changes for sync operations."""
+ try:
+ # Log summary statistics
+ summary = f"Sync {sync_mode} - Updated: {merge_details['updated']}, Added: {merge_details['added']}, Unchanged: {merge_details['unchanged']}"
+ self._log_audit_event("SYNC_CHANGES_SUMMARY", "INFO", summary)
+
+ # Log individual field changes
+ for change in merge_details['changes']:
+ self._log_audit_event("SYNC_FIELD_CHANGE", "INFO", f"{sync_mode}: {change}")
+
+ except Exception as e:
+ logger.warning(f"Failed to log sync changes: {str(e)}")
+
+ async def _save_audit_log_to_sharepoint(self):
+ """Save the sync audit log to SharePoint."""
+ try:
+ if not self.sync_audit_log or not self.targetSite:
+ return False
+
+ # Generate log filename with current timestamp
+ timestamp = datetime.now(UTC).strftime("%Y%m%d_%H%M%S")
+ log_filename = f"log_{timestamp}.log"
+
+ # Create log content
+ log_content = "\n".join(self.sync_audit_log)
+ log_bytes = log_content.encode('utf-8')
+
+ # Upload to SharePoint audit folder
+ await self.services.sharepoint.upload_file(
+ site_id=self.targetSite['id'],
+ folder_path=self.SHAREPOINT_AUDIT_FOLDER,
+ file_name=log_filename,
+ content=log_bytes
+ )
+
+ logger.info(f"Sync audit log saved to SharePoint: {log_filename}")
+ self._log_audit_event("AUDIT_LOG_SAVE", "SUCCESS", f"Audit log saved to SharePoint: {log_filename}")
+ return True
+
+ except Exception as e:
+ logger.error(f"Failed to save audit log to SharePoint: {str(e)}")
+ self._log_audit_event("AUDIT_LOG_SAVE", "FAILED", f"Failed to save audit log: {str(e)}")
+ return False
+
+ def getSyncFileName(self) -> str:
"""Get the appropriate sync file name based on the sync mode."""
if self.SYNC_MODE == "xlsx":
return self.SYNC_FILE_XLSX
else: # Default to CSV
return self.SYNC_FILE_CSV
- def set_sync_mode(self, mode: str) -> bool:
+ def setSyncMode(self, mode: str) -> bool:
"""Set the sync mode to either 'csv' or 'xlsx'.
Args:
@@ -261,60 +188,19 @@ class ManagerSyncDelta:
logger.error(f"Invalid sync mode: {mode}. Must be 'csv' or 'xlsx'")
return False
- async def initialize_connectors(self) -> bool:
- """Initialize JIRA and SharePoint connectors."""
+ async def initializeInterface(self) -> bool:
+ """Initialize SharePoint connector; tickets connector is created by interface on demand."""
try:
- logger.info("Initializing JIRA connector with hardcoded credentials")
-
- # Initialize JIRA connector using class constants
- self.jira_connector = await ConnectorTicketJira.create(
- jira_username=self.JIRA_USERNAME,
- jira_api_token=self.JIRA_API_TOKEN,
- jira_url=self.JIRA_URL,
- project_code=self.JIRA_PROJECT_CODE,
- issue_type=self.JIRA_ISSUE_TYPE
- )
-
- # Use the admin user for SharePoint connection
- adminUser = self.root_interface.getUserByUsername("admin")
- if not adminUser:
- logger.error("Admin user not found - SharePoint connection required")
+ # Validate init-prepared members
+ if not self.services or not self.sharepointConnection or not self.services.sharepoint:
+ logger.error("Service or SharePoint connection not initialized")
return False
-
- logger.info(f"Using admin user for SharePoint: {adminUser.id}")
-
- # Get SharePoint connection for admin user
- user_connections = self.root_interface.getUserConnections(adminUser.id)
- sharepoint_connection = None
-
- for connection in user_connections:
- if connection.authority == "msft" and connection.externalUsername == self.SHAREPOINT_USER_ID:
- sharepoint_connection = connection
- break
-
- if not sharepoint_connection:
- logger.error(f"No SharePoint connection found for user: {self.SHAREPOINT_USER_ID}")
- return False
-
- logger.info(f"Found SharePoint connection: {sharepoint_connection.id}")
-
- # Get fresh SharePoint token for this connection
- from modules.security.tokenManager import TokenManager
- sharepoint_token = TokenManager().getFreshToken(self.root_interface, sharepoint_connection.id)
- if not sharepoint_token:
- logger.error("No SharePoint token found for Delta Group user connection")
- return False
-
- logger.info(f"Found SharePoint token: {sharepoint_token.id}")
-
- # Initialize SharePoint connector with Graph API
- self.sharepoint_connector = SharepointService(access_token=sharepoint_token.tokenAccess)
# Resolve the site by hostname + site path to get the real site ID
logger.info(
f"Resolving site ID via hostname+path: {self.SHAREPOINT_HOSTNAME}:/sites/{self.SHAREPOINT_SITE_PATH}"
)
- resolved = await self.sharepoint_connector.find_site_by_url(
+ resolved = await self.services.sharepoint.find_site_by_url(
hostname=self.SHAREPOINT_HOSTNAME,
site_path=self.SHAREPOINT_SITE_PATH
)
@@ -325,7 +211,7 @@ class ManagerSyncDelta:
)
return False
- self.target_site = {
+ self.targetSite = {
"id": resolved.get("id"),
"displayName": resolved.get("displayName", self.SHAREPOINT_SITE_NAME),
"name": resolved.get("name", self.SHAREPOINT_SITE_NAME)
@@ -333,14 +219,14 @@ class ManagerSyncDelta:
# Test site access by listing root of the drive
logger.info("Testing site access using resolved site ID...")
- test_result = await self.sharepoint_connector.list_folder_contents(
- site_id=self.target_site["id"],
+ test_result = await self.services.sharepoint.list_folder_contents(
+ site_id=self.targetSite["id"],
folder_path=""
)
if test_result is not None:
logger.info(
- f"Site access confirmed: {self.target_site['displayName']} (ID: {self.target_site['id']})"
+ f"Site access confirmed: {self.targetSite['displayName']} (ID: {self.targetSite['id']})"
)
else:
logger.error("Could not access site drive - check permissions")
@@ -352,14 +238,16 @@ class ManagerSyncDelta:
logger.error(f"Error initializing connectors: {str(e)}")
return False
- async def sync_jira_to_sharepoint(self) -> bool:
- """Perform the main JIRA to SharePoint synchronization using sophisticated sync logic."""
+ async def syncTicketsOverSharepoint(self) -> bool:
+ """Perform Tickets to SharePoint synchronization using list-based interface and local CSV/XLSX handling."""
try:
logger.info(f"Starting JIRA to SharePoint synchronization (Mode: {self.SYNC_MODE})")
+ self._log_audit_event("SYNC_START", "INFO", f"Starting JIRA to SharePoint sync (Mode: {self.SYNC_MODE})")
- # Initialize connectors
- if not await self.initialize_connectors():
+ # Initialize interface
+ if not await self.initializeInterface():
logger.error("Failed to initialize connectors")
+ self._log_audit_event("SYNC_INTERFACE", "FAILED", "Failed to initialize connectors")
return False
# Dump current Jira fields to text file for reference
@@ -375,203 +263,539 @@ class ManagerSyncDelta:
logger.warning(f"Failed to dump JIRA data (non-blocking): {str(e)}")
# Get the appropriate sync file name based on mode
- sync_file_name = self.get_sync_file_name()
+ sync_file_name = self.getSyncFileName()
logger.info(f"Using sync file: {sync_file_name}")
- # Create the sophisticated sync interface
- sync_interface = await TicketSharepointSyncInterface.create(
- connector_ticket=self.jira_connector,
- connector_sharepoint=self.sharepoint_connector,
- task_sync_definition=self.TASK_SYNC_DEFINITION,
- sync_folder=self.SHAREPOINT_MAIN_FOLDER,
- sync_file=sync_file_name,
- backup_folder=self.SHAREPOINT_BACKUP_FOLDER,
- audit_folder=self.SHAREPOINT_AUDIT_FOLDER,
- site_id=self.target_site['id']
+ # Create list-based ticket interface (initialize connector by type)
+ sync_interface = await self.services.ticket.createTicketInterfaceByType(
+ taskSyncDefinition=self.TASK_SYNC_DEFINITION,
+ connectorType="Jira",
+ connectorParams={
+ "apiUsername": self.JIRA_USERNAME,
+ "apiToken": self.JIRA_API_TOKEN,
+ "apiUrl": self.JIRA_URL,
+ "projectCode": self.JIRA_PROJECT_CODE,
+ "ticketType": self.JIRA_ISSUE_TYPE,
+ },
)
# Perform the sophisticated sync based on mode
if self.SYNC_MODE == "xlsx":
- logger.info("Performing JIRA to Excel sync...")
- await sync_interface.sync_from_jira_to_excel()
- logger.info("Performing Excel to JIRA sync...")
- await sync_interface.sync_from_excel_to_jira()
+ # Export tickets to list
+ data_list = await sync_interface.exportTicketsAsList()
+ self._log_audit_event("SYNC_EXPORT", "INFO", f"Exported {len(data_list)} tickets from JIRA")
+ # Read existing Excel headers/content
+ existing_data = []
+ existing_headers = {"header1": "Header 1", "header2": "Header 2"}
+ try:
+ file_path = f"{self.SHAREPOINT_MAIN_FOLDER}/{sync_file_name}"
+ excel_content = await self.services.sharepoint.download_file_by_path(
+ site_id=self.targetSite['id'], file_path=file_path
+ )
+ existing_data, existing_headers = self.parseExcelContent(excel_content)
+ except Exception:
+ pass
+ # Merge and write
+ merged_data, merge_details = self.mergeJiraWithExistingDetailed(data_list, existing_data)
+
+ # Log detailed changes for Excel mode
+ self._log_sync_changes(merge_details, "EXCEL")
+
+ await self.backupSharepointFile(filename=sync_file_name)
+ excel_bytes = self.createExcelContent(merged_data, existing_headers)
+ await self.services.sharepoint.upload_file(
+ site_id=self.targetSite['id'],
+ folder_path=self.SHAREPOINT_MAIN_FOLDER,
+ file_name=sync_file_name,
+ content=excel_bytes,
+ )
+ # Import back to tickets
+ try:
+ excel_content = await self.services.sharepoint.download_file_by_path(
+ site_id=self.targetSite['id'], file_path=file_path
+ )
+ excel_rows, _ = self.parseExcelContent(excel_content)
+ self._log_audit_event("SYNC_IMPORT", "INFO", f"Importing {len(excel_rows)} Excel rows back to tickets")
+ except Exception as e:
+ excel_rows = []
+ self._log_audit_event("SYNC_IMPORT", "WARNING", f"Failed to download Excel for import: {str(e)}")
+ await sync_interface.importListToTickets(excel_rows)
else: # CSV mode (default)
- logger.info("Performing JIRA to CSV sync...")
- await sync_interface.sync_from_jira_to_csv()
- logger.info("Performing CSV to JIRA sync...")
- await sync_interface.sync_from_csv_to_jira()
+ # Export tickets to list
+ data_list = await sync_interface.exportTicketsAsList()
+ self._log_audit_event("SYNC_EXPORT", "INFO", f"Exported {len(data_list)} tickets from JIRA")
+ # Prepare headers by reading existing CSV if present
+ existing_headers = {"header1": "Header 1", "header2": "Header 2"}
+ existing_data: list[dict] = []
+ try:
+ file_path = f"{self.SHAREPOINT_MAIN_FOLDER}/{sync_file_name}"
+ csv_content = await self.services.sharepoint.download_file_by_path(
+ site_id=self.targetSite['id'], file_path=file_path
+ )
+ csv_lines = csv_content.decode('utf-8').split('\n')
+ if len(csv_lines) >= 2:
+ existing_headers["header1"] = csv_lines[0].rstrip('\r\n')
+ existing_headers["header2"] = csv_lines[1].rstrip('\r\n')
+ # Parse existing CSV rows after the two header lines
+ import pandas as pd
+ df_existing = pd.read_csv(io.BytesIO(csv_content), skiprows=2, quoting=1, escapechar='\\', on_bad_lines='skip', engine='python')
+ existing_data = df_existing.to_dict('records')
+ except Exception:
+ pass
+ await self.backupSharepointFile(filename=sync_file_name)
+ merged_data, _ = self.mergeJiraWithExistingDetailed(data_list, existing_data)
+ csv_bytes = self.createCsvContent(merged_data, existing_headers)
+ await self.services.sharepoint.upload_file(
+ site_id=self.targetSite['id'],
+ folder_path=self.SHAREPOINT_MAIN_FOLDER,
+ file_name=sync_file_name,
+ content=csv_bytes,
+ )
+ # Import from CSV
+ try:
+ csv_content = await self.services.sharepoint.download_file_by_path(
+ site_id=self.targetSite['id'], file_path=file_path
+ )
+ import pandas as pd
+ df = pd.read_csv(io.BytesIO(csv_content), skiprows=2, quoting=1, escapechar='\\', on_bad_lines='skip', engine='python')
+ csv_rows = df.to_dict('records')
+ self._log_audit_event("SYNC_IMPORT", "INFO", f"Importing {len(csv_rows)} CSV rows back to tickets")
+ except Exception as e:
+ csv_rows = []
+ self._log_audit_event("SYNC_IMPORT", "WARNING", f"Failed to download CSV for import: {str(e)}")
+ await sync_interface.importListToTickets(csv_rows)
logger.info(f"JIRA to SharePoint synchronization completed successfully (Mode: {self.SYNC_MODE})")
+ self._log_audit_event("SYNC_COMPLETE", "SUCCESS", f"JIRA to SharePoint sync completed successfully (Mode: {self.SYNC_MODE})")
+
+ # Save audit log to SharePoint
+ await self._save_audit_log_to_sharepoint()
+
return True
except Exception as e:
logger.error(f"Error during JIRA to SharePoint synchronization: {str(e)}")
+ self._log_audit_event("SYNC_ERROR", "FAILED", f"Error during sync: {str(e)}")
+
+ # Save audit log to SharePoint even on error
+ await self._save_audit_log_to_sharepoint()
+
return False
+
+ async def backupSharepointFile(self, *, filename: str) -> bool:
+ try:
+ timestamp = datetime.now(UTC).strftime("%Y%m%d_%H%M%S")
+ backup_filename = f"backup_{timestamp}_{filename}"
+ await self.services.sharepoint.copy_file_async(
+ site_id=self.targetSite['id'],
+ source_folder=self.SHAREPOINT_MAIN_FOLDER,
+ source_file=filename,
+ dest_folder=self.SHAREPOINT_BACKUP_FOLDER,
+ dest_file=backup_filename,
+ )
+ self._log_audit_event("SYNC_BACKUP", "SUCCESS", f"Backed up file: {filename} -> {backup_filename}")
+ return True
+ except Exception as e:
+ if "itemNotFound" in str(e) or "404" in str(e):
+ self._log_audit_event("SYNC_BACKUP", "SKIPPED", f"File not found for backup: {filename}")
+ return True
+ logger.warning(f"Backup failed: {e}")
+ self._log_audit_event("SYNC_BACKUP", "FAILED", f"Backup failed for {filename}: {str(e)}")
+ return False
+
+ def mergeJiraWithExistingDetailed(self, jira_data: list[dict], existing_data: list[dict]) -> tuple[list[dict], dict]:
+ existing_lookup = {row.get("ID"): row for row in existing_data if row.get("ID")}
+ merged_data: list[dict] = []
+ changes: list[str] = []
+ updated_count = added_count = unchanged_count = 0
+ for jira_row in jira_data:
+ jira_id = jira_row.get("ID")
+ if jira_id and jira_id in existing_lookup:
+ existing_row = existing_lookup[jira_id].copy()
+ row_changes: list[str] = []
+ for field_name, field_config in self.TASK_SYNC_DEFINITION.items():
+ if field_config[0] == 'get':
+ old_value = "" if existing_row.get(field_name) is None else str(existing_row.get(field_name))
+ new_value = "" if jira_row.get(field_name) is None else str(jira_row.get(field_name))
+ if old_value != new_value:
+ row_changes.append(f"{field_name}: '{old_value}' → '{new_value}'")
+ existing_row[field_name] = jira_row.get(field_name)
+ merged_data.append(existing_row)
+ if row_changes:
+ updated_count += 1
+ changes.append(f"Row ID {jira_id} updated: {', '.join(row_changes)}")
+ else:
+ unchanged_count += 1
+ del existing_lookup[jira_id]
+ else:
+ merged_data.append(jira_row)
+ added_count += 1
+ changes.append(f"Row ID {jira_id} added as new record")
+ for remaining in existing_lookup.values():
+ merged_data.append(remaining)
+ unchanged_count += 1
+ details = {"updated": updated_count, "added": added_count, "unchanged": unchanged_count, "changes": changes}
+ return merged_data, details
+
+ def createCsvContent(self, data: list[dict], existing_headers: dict | None = None) -> bytes:
+ import pandas as pd
+ from io import StringIO
+ timestamp = datetime.now(UTC).strftime("%Y-%m-%d %H:%M:%S UTC")
+ if existing_headers is None:
+ existing_headers = {"header1": "Header 1", "header2": "Header 2"}
+ if not data:
+ cols = list(self.TASK_SYNC_DEFINITION.keys())
+ df = pd.DataFrame(columns=cols)
+ else:
+ df = pd.DataFrame(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)
+ import csv as csv_module
+ header1_row = next(csv_module.reader([existing_headers.get("header1", "Header 1")]), [])
+ header2_row = next(csv_module.reader([existing_headers.get("header2", "Header 2")]), [])
+ if len(header2_row) > 1:
+ header2_row[1] = timestamp
+ header_row1 = pd.DataFrame([header1_row + [""] * (len(df.columns) - len(header1_row))], columns=df.columns)
+ header_row2 = pd.DataFrame([header2_row + [""] * (len(df.columns) - len(header2_row))], columns=df.columns)
+ table_headers = pd.DataFrame([df.columns.tolist()], columns=df.columns)
+ final_df = pd.concat([header_row1, header_row2, table_headers, df], ignore_index=True)
+ out = StringIO()
+ final_df.to_csv(out, index=False, header=False, quoting=1, escapechar='\\')
+ return out.getvalue().encode('utf-8')
+
+ def createExcelContent(self, data: list[dict], existing_headers: dict | None = None) -> bytes:
+ import pandas as pd
+ from io import BytesIO
+ timestamp = datetime.now(UTC).strftime("%Y-%m-%d %H:%M:%S UTC")
+ if existing_headers is None:
+ existing_headers = {"header1": "Header 1", "header2": "Header 2"}
+ if not data:
+ cols = list(self.TASK_SYNC_DEFINITION.keys())
+ df = pd.DataFrame(columns=cols)
+ else:
+ df = pd.DataFrame(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)
+ import csv as csv_module
+ header1_row = next(csv_module.reader([existing_headers.get("header1", "Header 1")]), [])
+ header2_row = next(csv_module.reader([existing_headers.get("header2", "Header 2")]), [])
+ if len(header2_row) > 1:
+ header2_row[1] = timestamp
+ header_row1 = pd.DataFrame([header1_row + [""] * (len(df.columns) - len(header1_row))], columns=df.columns)
+ header_row2 = pd.DataFrame([header2_row + [""] * (len(df.columns) - len(header2_row))], columns=df.columns)
+ table_headers = pd.DataFrame([df.columns.tolist()], columns=df.columns)
+ final_df = pd.concat([header_row1, header_row2, table_headers, df], ignore_index=True)
+ buf = BytesIO()
+ final_df.to_excel(buf, index=False, header=False, engine='openpyxl')
+ return buf.getvalue()
+
+ def parseExcelContent(self, excel_content: bytes) -> tuple[list[dict], dict]:
+ import pandas as pd
+ from io import BytesIO
+ df = pd.read_excel(BytesIO(excel_content), engine='openpyxl', header=None)
+ header_row1 = df.iloc[0:1].copy()
+ header_row2 = df.iloc[1:2].copy()
+ table_headers = df.iloc[2:3].copy()
+ df_data = df.iloc[3:].copy()
+ df_data.columns = table_headers.iloc[0]
+ df_data = df_data.reset_index(drop=True)
+ for column in df_data.columns:
+ df_data[column] = df_data[column].astype('object').fillna('')
+ data = df_data.to_dict(orient='records')
+ headers = {
+ "header1": ",".join([str(x) if pd.notna(x) else "" for x in header_row1.iloc[0].tolist()]),
+ "header2": ",".join([str(x) if pd.notna(x) else "" for x in header_row2.iloc[0].tolist()]),
+ }
+ return data, headers
+ def convertAdfToText(self, adf_data):
+ """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:
+ adf_data: ADF object or None
+
+ Returns:
+ str: Plain text content, or empty string if None/invalid
+ """
+ if not adf_data or not isinstance(adf_data, dict):
+ return ""
+
+ if adf_data.get("type") != "doc":
+ return str(adf_data) if adf_data else ""
+
+ content = adf_data.get("content", [])
+ if not isinstance(content, list):
+ return ""
+
+ def extract_text_from_content(content_list, list_level=0):
+ """Recursively extract text from ADF content with proper formatting."""
+ text_parts = []
+ list_counter = 1
+
+ for item in content_list:
+ if not isinstance(item, dict):
+ continue
+
+ item_type = item.get("type", "")
+
+ if item_type == "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})"
+
+ text_parts.append(text)
+
+ elif item_type == "hardBreak":
+ text_parts.append("\n")
+
+ elif item_type == "paragraph":
+ paragraph_content = item.get("content", [])
+ if paragraph_content:
+ paragraph_text = extract_text_from_content(paragraph_content, list_level)
+ if paragraph_text.strip():
+ text_parts.append(paragraph_text)
+
+ elif item_type == "bulletList":
+ list_content = item.get("content", [])
+ for list_item in list_content:
+ if list_item.get("type") == "listItem":
+ list_item_content = list_item.get("content", [])
+ for list_paragraph in list_item_content:
+ if list_paragraph.get("type") == "paragraph":
+ list_paragraph_content = list_paragraph.get("content", [])
+ if list_paragraph_content:
+ indent = " " * list_level
+ bullet_text = extract_text_from_content(list_paragraph_content, list_level + 1)
+ if bullet_text.strip():
+ text_parts.append(f"{indent}• {bullet_text}")
+
+ elif item_type == "orderedList":
+ list_content = item.get("content", [])
+ for list_item in list_content:
+ if list_item.get("type") == "listItem":
+ list_item_content = list_item.get("content", [])
+ for list_paragraph in list_item_content:
+ if list_paragraph.get("type") == "paragraph":
+ list_paragraph_content = list_paragraph.get("content", [])
+ if list_paragraph_content:
+ indent = " " * list_level
+ ordered_text = extract_text_from_content(list_paragraph_content, list_level + 1)
+ if ordered_text.strip():
+ text_parts.append(f"{indent}{list_counter}. {ordered_text}")
+ list_counter += 1
+
+ elif item_type == "listItem":
+ # Handle nested list items
+ list_item_content = item.get("content", [])
+ if list_item_content:
+ text_parts.append(extract_text_from_content(list_item_content, list_level))
+
+ elif item_type == "embedCard":
+ # Handle embedded content (videos, etc.)
+ attrs = item.get("attrs", {})
+ url = attrs.get("url", "")
+ if url:
+ text_parts.append(f"[Embedded Content: {url}]")
+
+ elif item_type == "codeBlock":
+ # Handle code blocks
+ code_content = item.get("content", [])
+ if code_content:
+ code_text = extract_text_from_content(code_content, list_level)
+ if code_text.strip():
+ text_parts.append(f"```\n{code_text}\n```")
+
+ elif item_type == "blockquote":
+ # Handle blockquotes
+ quote_content = item.get("content", [])
+ if quote_content:
+ quote_text = extract_text_from_content(quote_content, list_level)
+ if quote_text.strip():
+ text_parts.append(f"> {quote_text}")
+
+ elif item_type == "heading":
+ # Handle headings
+ heading_content = item.get("content", [])
+ if heading_content:
+ heading_text = extract_text_from_content(heading_content, list_level)
+ if heading_text.strip():
+ level = item.get("attrs", {}).get("level", 1)
+ text_parts.append(f"{'#' * level} {heading_text}")
+
+ elif item_type == "rule":
+ # Handle horizontal rules
+ text_parts.append("---")
+
+ else:
+ # Handle unknown types by trying to extract content
+ if "content" in item:
+ content_text = extract_text_from_content(item.get("content", []), list_level)
+ if content_text.strip():
+ text_parts.append(content_text)
+
+ return "\n".join(text_parts)
+
+ result = extract_text_from_content(content)
+ return result.strip()
-
-# Utility: dump all Jira fields (name -> field id) to a text file
-async def dump_jira_fields_to_file(filepath: str = "delta_sync_fields.txt") -> bool:
- """Write all available JIRA fields for the configured project/issue type to a text file.
-
- The output format matches the legacy fields.txt, e.g.:
- 'Summary': ['get', ['fields', 'summary']]
-
- Args:
- filepath: Target text file path to write.
-
- Returns:
- True on success, False otherwise.
- """
+# Utility: dump all ticket fields (name -> field id) to a text file (generic)
+async def dumpTicketFieldsToFile(
+ *,
+ filepath: str = "ticket_sync_fields.txt",
+ connectorType: str = "Jira",
+ connectorParams: dict | None = None,
+ taskSyncDefinition: dict | None = None,
+ ) -> bool:
+ """Write available ticket fields (name -> field id) to a text file (generic)."""
try:
- # Initialize Jira connector with the hardcoded credentials/constants
- jira = await ConnectorTicketJira.create(
- jira_username=ManagerSyncDelta.JIRA_USERNAME,
- jira_api_token=ManagerSyncDelta.JIRA_API_TOKEN,
- jira_url=ManagerSyncDelta.JIRA_URL,
- project_code=ManagerSyncDelta.JIRA_PROJECT_CODE,
- issue_type=ManagerSyncDelta.JIRA_ISSUE_TYPE,
+ connectorParams = connectorParams or {}
+ taskSyncDefinition = taskSyncDefinition or ManagerSyncDelta.TASK_SYNC_DEFINITION
+ ticket_interface = await createTicketInterfaceByType(
+ taskSyncDefinition=taskSyncDefinition,
+ connectorType=connectorType,
+ connectorParams=connectorParams,
)
-
- attributes = await jira.read_attributes()
+ attributes = await ticket_interface.connector_ticket.read_attributes()
if not attributes:
- logger.warning("No JIRA attributes returned; nothing to write.")
+ logger.warning("No ticket attributes returned; nothing to write.")
return False
-
- # Ensure directory exists if a directory part is provided
dir_name = os.path.dirname(filepath)
if dir_name:
os.makedirs(dir_name, exist_ok=True)
-
- # Write in the expected mapping format
with open(filepath, "w", encoding="utf-8") as f:
for attr in attributes:
- # attr.field_name (human name), attr.field (Jira field id)
f.write(f"'{attr.field_name}': ['get', ['fields', '{attr.field}']]\n")
-
- logger.info(f"Wrote {len(attributes)} JIRA fields to {filepath}")
+ logger.info(f"Wrote {len(attributes)} ticket fields to {filepath}")
return True
except Exception as e:
- logger.error(f"Failed to dump JIRA fields: {str(e)}")
+ logger.error(f"Failed to dump ticket fields: {str(e)}")
return False
-# Utility: dump actual JIRA data for debugging
-async def dump_jira_data_to_file(filepath: str = "delta_sync_data.txt") -> bool:
- """Write actual JIRA ticket data to a text file for debugging field mapping.
-
- Args:
- filepath: Target text file path to write.
-
- Returns:
- True on success, False otherwise.
- """
+# Utility: dump actual ticket data for debugging (generic)
+async def dumpTicketDataToFile(
+ *,
+ filepath: str = "ticket_sync_data.txt",
+ connectorType: str = "Jira",
+ connectorParams: dict | None = None,
+ taskSyncDefinition: dict | None = None,
+ sampleLimit: int = 5,
+ ) -> bool:
+ """Write actual ticket data to a text file for debugging field mapping (generic)."""
try:
- # Initialize Jira connector with the hardcoded credentials/constants
- jira = await ConnectorTicketJira.create(
- jira_username=ManagerSyncDelta.JIRA_USERNAME,
- jira_api_token=ManagerSyncDelta.JIRA_API_TOKEN,
- jira_url=ManagerSyncDelta.JIRA_URL,
- project_code=ManagerSyncDelta.JIRA_PROJECT_CODE,
- issue_type=ManagerSyncDelta.JIRA_ISSUE_TYPE,
+ connectorParams = connectorParams or {}
+ taskSyncDefinition = taskSyncDefinition or ManagerSyncDelta.TASK_SYNC_DEFINITION
+ ticket_interface = await createTicketInterfaceByType(
+ taskSyncDefinition=taskSyncDefinition,
+ connectorType=connectorType,
+ connectorParams=connectorParams,
)
-
- # Get a few sample tickets to see the actual data structure
- tickets = await jira.read_tasks(limit=5)
+ tickets = await ticket_interface.connector_ticket.read_tasks(limit=sampleLimit)
if not tickets:
- logger.warning("No JIRA tickets returned; nothing to write.")
+ logger.warning("No tickets returned; nothing to write.")
return False
-
- # Ensure directory exists if a directory part is provided
dir_name = os.path.dirname(filepath)
if dir_name:
os.makedirs(dir_name, exist_ok=True)
-
- # Write the actual ticket data
with open(filepath, "w", encoding="utf-8") as f:
- f.write("=== JIRA TICKET DATA DEBUG ===\n\n")
+ f.write("=== TICKET DATA DEBUG ===\n\n")
for i, ticket in enumerate(tickets):
f.write(f"--- TICKET {i+1} ---\n")
- f.write(f"Raw ticket data:\n")
+ f.write("Raw ticket data:\n")
f.write(f"{ticket.data}\n\n")
-
- # Also show the specific fields we're trying to map
f.write("Field mapping analysis:\n")
- for field_name, field_path in ManagerSyncDelta.TASK_SYNC_DEFINITION.items():
- if field_path[0] == 'get': # Only analyze 'get' fields
+ for fieldName, fieldPath in taskSyncDefinition.items():
+ if fieldPath[0] == 'get':
try:
- # Navigate through the field path
value = ticket.data
- for key in field_path[1]:
+ for key in fieldPath[1]:
if isinstance(value, dict) and key in value:
value = value[key]
else:
value = f"KEY_NOT_FOUND: {key}"
break
-
- # Convert ADF fields to text
- if field_name in ['Description', 'References', 'DELTA Comments', 'SELISE Comments']:
- if isinstance(value, dict) and value.get("type") == "doc":
- value = convert_adf_to_text(value)
- elif value is None:
- value = ""
-
- f.write(f" {field_name}: {value}\n")
+ if isinstance(value, dict) and value.get("type") == "doc":
+ pass # value = self.convertAdfToText(value)
+ elif value is None:
+ value = ""
+ f.write(f" {fieldName}: {value}\n")
except Exception as e:
- f.write(f" {field_name}: ERROR - {str(e)}\n")
+ f.write(f" {fieldName}: ERROR - {str(e)}\n")
f.write("\n" + "="*50 + "\n\n")
-
- logger.info(f"Wrote JIRA data for {len(tickets)} tickets to {filepath}")
+ logger.info(f"Wrote ticket data for {len(tickets)} tickets to {filepath}")
return True
except Exception as e:
- logger.error(f"Failed to dump JIRA data: {str(e)}")
+ logger.error(f"Failed to dump ticket data: {str(e)}")
return False
-# Global sync function for use in app.py
-async def perform_sync_jira_delta_group() -> bool:
- """Perform JIRA to SharePoint synchronization for Delta Group.
+# Main part of the module
+async def performSync(eventUser=None) -> bool:
+ """Perform tickets to SharePoint synchronization
This function is called by the scheduler and can be used independently.
+ Args:
+ eventUser: Optional event user to use for synchronization
+
Returns:
bool: True if synchronization was successful, False otherwise
"""
try:
- if APP_ENV_TYPE != "prod" and APP_ENV_TYPE != "tst":
- logger.info("JIRA to SharePoint synchronization: TASK to run only in PROD")
- return True
-
- logger.info("Starting Delta Group JIRA sync...")
-
-
- sync_manager = ManagerSyncDelta()
- success = await sync_manager.sync_jira_to_sharepoint()
+ logger.info("Starting DG tickets sync...")
+
+ # Sync audit logging is handled by ManagerSyncDelta instance
+
+ syncManager = ManagerSyncDelta(eventUser)
+ success = await syncManager.syncTicketsOverSharepoint()
if success:
- logger.info("Delta Group JIRA sync completed successfully")
+ logger.info("DG tickets sync completed successfully")
else:
- logger.error("Delta Group JIRA sync failed")
+ logger.error("DG tickets sync failed")
return success
except Exception as e:
- logger.error(f"Error in perform_sync_jira_delta_group: {str(e)}")
+ logger.error(f"Error in performing DG tickets sync: {str(e)}")
return False
# Register scheduled job on import using the shared event manager
try:
from modules.shared.eventManagement import eventManager
- # Schedule sync every 20 minutes (at minutes 00, 20, 40)
- eventManager.register_cron(
- job_id="jira_delta_group_sync",
- func=perform_sync_jira_delta_group,
- cron_kwargs={"minute": "0,20,40"},
- replace_existing=True,
- coalesce=True,
- max_instances=1,
- misfire_grace_time=1800,
- )
- logger.info("Registered jira_delta_group_sync via EventManagement (every 20 minutes)")
+ # Register scheduler only in production
+ if APP_ENV_TYPE == "prod" or APP_ENV_TYPE == "dev":
+ # Schedule sync every 20 minutes (at minutes 00, 20, 40)
+ eventManager.register_cron(
+ job_id="dgsync",
+ func=performSync,
+ cron_kwargs={"minute": "0,20,40"},
+ replace_existing=True,
+ coalesce=True,
+ max_instances=1,
+ misfire_grace_time=1800,
+ )
+ logger.info("Registered DG ticket sync via EventManagement (every 20 minutes)")
+ else:
+ logger.info(f"Skipping DG scheduler registration for ticket sync in env: {APP_ENV_TYPE}")
except Exception as e:
- logger.error(f"Failed to register jira_delta_group_sync: {str(e)}")
\ No newline at end of file
+ logger.error(f"Failed to register DG ticket sync: {str(e)}")
diff --git a/modules/interfaces/interfaceAppModel.py b/modules/interfaces/interfaceAppModel.py
index e8ca0caa..8d58325a 100644
--- a/modules/interfaces/interfaceAppModel.py
+++ b/modules/interfaces/interfaceAppModel.py
@@ -564,20 +564,3 @@ register_model_labels(
"patternType": {"en": "Pattern Type", "fr": "Type de modèle"}
}
)
-
-class SystemTable(BaseModel, ModelMixin):
- """Data model for system table entries"""
- table_name: str = Field(
- description="Name of the table",
- frontend_type="text",
- frontend_readonly=True,
- frontend_required=True
- )
- initial_id: Optional[str] = Field(
- default=None,
- description="Initial ID for the table",
- frontend_type="text",
- frontend_readonly=True,
- frontend_required=False
- )
-
\ No newline at end of file
diff --git a/modules/interfaces/interfaceAppObjects.py b/modules/interfaces/interfaceAppObjects.py
index 069556cf..9320b102 100644
--- a/modules/interfaces/interfaceAppObjects.py
+++ b/modules/interfaces/interfaceAppObjects.py
@@ -121,6 +121,7 @@ class AppObjects:
"""Initialize standard records if they don't exist."""
self._initRootMandate()
self._initAdminUser()
+ self._initEventUser()
def _initRootMandate(self):
"""Creates the Root mandate if it doesn't exist."""
@@ -154,7 +155,7 @@ class AppObjects:
language="en",
privilege=UserPrivilege.SYSADMIN,
authenticationAuthority="local", # Using lowercase value directly
- hashedPassword=self._getPasswordHash("The 1st Poweron Admin"), # Use a secure password in production!
+ hashedPassword=self._getPasswordHash(APP_CONFIG.get("APP_INIT_PASS_ADMIN_SECRET")),
connections=[]
)
createdUser = self.db.recordCreate(UserInDB, adminUser)
@@ -164,6 +165,27 @@ class AppObjects:
self.currentUser = createdUser
self.userId = createdUser.get("id")
+ def _initEventUser(self):
+ """Creates the Event user if it doesn't exist."""
+ # Check if event user already exists
+ existingUsers = self.db.getRecordset(UserInDB, recordFilter={"username": "event"})
+ if not existingUsers:
+ logger.info("Creating Event user")
+ eventUser = UserInDB(
+ mandateId=self.getInitialId(Mandate),
+ username="event",
+ email="event@example.com",
+ fullName="Event",
+ enabled=True,
+ language="en",
+ privilege=UserPrivilege.SYSADMIN,
+ authenticationAuthority="local", # Using lowercase value directly
+ hashedPassword=self._getPasswordHash(APP_CONFIG.get("APP_INIT_PASS_EVENT_SECRET")),
+ connections=[]
+ )
+ createdUser = self.db.recordCreate(UserInDB, eventUser)
+ logger.info(f"Event user created with ID {createdUser['id']}")
+
def _uam(self, model_class: type, recordset: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
"""
Unified user access management function that filters data based on user privileges
@@ -1075,34 +1097,6 @@ def getInterface(currentUser: User) -> AppObjects:
return _gatewayInterfaces[contextKey]
-def getRootUser() -> User:
- """
- Returns the root user from the database.
- This is the user with the initial ID in the users table.
- """
- try:
- # Create a temporary interface without user context
- tempInterface = AppObjects()
-
- # Get the initial user directly
- initialUserId = tempInterface.getInitialId(UserInDB)
- if not initialUserId:
- raise ValueError("No initial user ID found in database")
-
- users = tempInterface.db.getRecordset(UserInDB, recordFilter={"id": initialUserId})
- if not users:
- raise ValueError("Initial user not found in database")
-
-
- # Convert to User model and return the model instance
- user_data = users[0]
-
- return User.parse_obj(user_data)
-
- except Exception as e:
- logger.error(f"Error getting root user: {str(e)}")
- raise ValueError(f"Failed to get root user: {str(e)}")
-
def getRootInterface() -> AppObjects:
"""
Returns a AppObjects instance with root privileges.
@@ -1111,7 +1105,28 @@ def getRootInterface() -> AppObjects:
global _rootAppObjects
if _rootAppObjects is None:
- rootUser = getRootUser()
- _rootAppObjects = AppObjects(rootUser)
+ try:
+ # Create a temporary interface without user context to get root user
+ tempInterface = AppObjects()
+
+ # Get the initial user directly
+ initialUserId = tempInterface.getInitialId(UserInDB)
+ if not initialUserId:
+ raise ValueError("No initial user ID found in database")
+
+ users = tempInterface.db.getRecordset(UserInDB, recordFilter={"id": initialUserId})
+ if not users:
+ raise ValueError("Initial user not found in database")
+
+ # Convert to User model
+ user_data = users[0]
+ rootUser = User.parse_obj(user_data)
+
+ # Create root interface with the root user
+ _rootAppObjects = AppObjects(rootUser)
+
+ except Exception as e:
+ logger.error(f"Error getting root user: {str(e)}")
+ raise ValueError(f"Failed to get root user: {str(e)}")
return _rootAppObjects
diff --git a/modules/interfaces/interfaceTicketModel.py b/modules/interfaces/interfaceTicketModel.py
index 98329a7b..a7cff7ad 100644
--- a/modules/interfaces/interfaceTicketModel.py
+++ b/modules/interfaces/interfaceTicketModel.py
@@ -6,8 +6,8 @@ from abc import ABC, abstractmethod
class TicketFieldAttribute(BaseModel):
- field_name: str = Field(description="Human-readable field name")
- field: str = Field(description="JIRA field ID/key")
+ fieldName: str = Field(description="Human-readable field name")
+ field: str = Field(description="Ticket field ID/key")
class Task(BaseModel):
diff --git a/modules/interfaces/interfaceTicketObjects.py b/modules/interfaces/interfaceTicketObjects.py
index 28dd3c3d..727aa6bc 100644
--- a/modules/interfaces/interfaceTicketObjects.py
+++ b/modules/interfaces/interfaceTicketObjects.py
@@ -1,705 +1,64 @@
-from dataclasses import dataclass
-from io import BytesIO, StringIO
from typing import Any, Optional
from datetime import datetime, timezone
-import pandas as pd
-import openpyxl
-from modules.shared.timezoneUtils import get_utc_now
-
-from modules.services.serviceSharepoint.mainSharepoint import SharepointService
from modules.interfaces.interfaceTicketModel import TicketBase, Task
-
-@dataclass(slots=True)
-class TicketSharepointSyncInterface:
- connector_ticket: TicketBase
- connector_sharepoint: SharepointService
- task_sync_definition: dict
- sync_folder: str
- sync_file: str
- backup_folder: str
- audit_folder: str
- site_id: str # Keep for compatibility but not used with REST API
-
- @classmethod
- async def create(
- cls,
- connector_ticket: TicketBase,
- connector_sharepoint: SharepointService,
- task_sync_definition: dict,
- sync_folder: str,
- sync_file: str,
- backup_folder: str,
- audit_folder: str,
- site_id: str,
- ) -> "TicketSharepointSyncInterface":
- return cls(
- connector_ticket=connector_ticket,
- connector_sharepoint=connector_sharepoint,
- task_sync_definition=task_sync_definition,
- sync_folder=sync_folder,
- sync_file=sync_file,
- backup_folder=backup_folder,
- audit_folder=audit_folder,
- site_id=site_id,
- )
-
- async def create_backup(self):
- """Creates a backup of the current sync file in the backup folder."""
- timestamp = get_utc_now().strftime("%Y%m%d_%H%M%S")
- backup_filename = f"backup_{timestamp}_{self.sync_file}"
-
- try:
- await self.connector_sharepoint.copy_file_async(
- site_id=self.site_id,
- source_folder=self.sync_folder,
- source_file=self.sync_file,
- dest_folder=self.backup_folder,
- dest_file=backup_filename,
- )
- except Exception as e:
- # If the source file doesn't exist (404 error), that's okay for first-time sync
- if "itemNotFound" in str(e) or "404" in str(e) or "could not be found" in str(e):
- raise Exception(f"Source file does not exist - no backup needed: {self.sync_file}")
- else:
- # Re-raise other errors
- raise
-
- async def sync_from_jira_to_csv(self):
- """Syncs tasks from JIRA to a CSV file in SharePoint."""
- start_time = get_utc_now()
- audit_log = []
-
- audit_log.append("=== JIRA TO CSV SYNC STARTED ===")
- audit_log.append(f"Start Time: {start_time.strftime('%Y-%m-%d %H:%M:%S')}")
- audit_log.append(f"Sync File: {self.sync_file}")
- audit_log.append(f"Sync Folder: {self.sync_folder}")
- audit_log.append("")
-
- try:
- # 1. Read JIRA tickets
- audit_log.append("Step 1: Reading JIRA tickets...")
- tickets = await self.connector_ticket.read_tasks(limit=0)
- audit_log.append(f"JIRA issues read: {len(tickets)}")
- audit_log.append("")
-
- # 2. Transform tasks according to task_sync_definition
- audit_log.append("Step 2: Transforming JIRA data...")
- transformed_tasks = self._transform_tasks(tickets, include_put=True)
- jira_data = [task.data for task in transformed_tasks]
- before_count = len(jira_data)
- # Remove records without an ID to avoid blank rows
- jira_data = self._filter_empty_records(jira_data)
- after_count = len(jira_data)
- audit_log.append(f"JIRA issues transformed: {before_count}")
- audit_log.append(f"JIRA issues after ID filter: {after_count}")
- # Log a sample of IDs to diagnose empty export issues
- try:
- sample_ids = [str(row.get("ID")) for row in jira_data[:5]]
- audit_log.append(f"Sample IDs: {', '.join(sample_ids)}")
- except Exception:
- pass
- audit_log.append("")
-
- # 3. Create JIRA export file in audit folder
- audit_log.append("Step 3: Creating JIRA export file...")
- try:
- timestamp = get_utc_now().strftime("%Y%m%d_%H%M%S")
- jira_export_filename = f"jira_export_{timestamp}.csv"
- # Use default headers for JIRA export
- jira_export_content = self._create_csv_content(jira_data, {"header1": "JIRA Export", "header2": "Raw Data"})
- await self.connector_sharepoint.upload_file(
- site_id=self.site_id,
- folder_path=self.audit_folder,
- file_name=jira_export_filename,
- content=jira_export_content,
- )
- audit_log.append(f"JIRA export file created: {jira_export_filename}")
- except Exception as e:
- audit_log.append(f"Failed to create JIRA export file: {str(e)}")
- audit_log.append("")
-
- # 4. Create backup of existing sync file (if it exists)
- audit_log.append("Step 4: Creating backup...")
- backup_created = False
- try:
- await self.create_backup()
- backup_created = True
- audit_log.append("Backup created successfully")
- except Exception as e:
- audit_log.append(
- f"Backup creation failed (file might not exist): {str(e)}"
- )
- audit_log.append("")
-
- # 5. Try to read existing CSV file from SharePoint
- audit_log.append("Step 5: Reading existing CSV file...")
- existing_data = []
- existing_file_found = False
- existing_headers = {"header1": "", "header2": ""}
- try:
- file_path = f"{self.sync_folder}/{self.sync_file}"
- csv_content = await self.connector_sharepoint.download_file_by_path(
- site_id=self.site_id, file_path=file_path
- )
-
- # Read the first two lines to get headers
- csv_lines = csv_content.decode('utf-8').split('\n')
- if len(csv_lines) >= 2:
- # Store the raw first two lines as headers (preserving original formatting)
- existing_headers["header1"] = csv_lines[0].rstrip('\r\n')
- existing_headers["header2"] = csv_lines[1].rstrip('\r\n')
-
- # Try to read with robust CSV parsing (skip first 2 rows)
- df_existing = pd.read_csv(
- BytesIO(csv_content),
- skiprows=2,
- quoting=1, # QUOTE_ALL
- escapechar='\\',
- on_bad_lines='skip', # Skip malformed lines
- engine='python' # More robust parsing
- )
- existing_data = df_existing.to_dict("records")
- existing_file_found = True
- audit_log.append(
- f"Existing CSV file found with {len(existing_data)} records"
- )
- audit_log.append(f"Preserved headers: Header1='{existing_headers['header1']}', Header2='{existing_headers['header2']}'")
- except Exception as e:
- audit_log.append(f"No existing CSV file found or read error: {str(e)}")
- audit_log.append("")
-
- # 6. Merge JIRA data with existing data and track changes
- audit_log.append("Step 6: Merging JIRA data with existing data...")
- merged_data, change_details = self._merge_jira_with_existing_detailed(
- jira_data, existing_data
- )
-
- # Log detailed changes
- audit_log.append(f"Total records after merge: {len(merged_data)}")
- audit_log.append(f"Records updated: {change_details['updated']}")
- audit_log.append(f"Records added: {change_details['added']}")
- audit_log.append(f"Records unchanged: {change_details['unchanged']}")
- audit_log.append("")
-
- # Log individual changes
- if change_details["changes"]:
- audit_log.append("DETAILED CHANGES:")
- for change in change_details["changes"]:
- audit_log.append(f"- {change}")
- audit_log.append("")
-
- # 7. Create CSV with 4-row structure and write to SharePoint
- audit_log.append("Step 7: Writing updated CSV to SharePoint...")
- csv_content = self._create_csv_content(merged_data, existing_headers)
- await self.connector_sharepoint.upload_file(
- site_id=self.site_id,
- folder_path=self.sync_folder,
- file_name=self.sync_file,
- content=csv_content,
- )
- audit_log.append("CSV file successfully written to SharePoint")
- audit_log.append("")
-
- # Success summary
- end_time = get_utc_now()
- duration = (end_time - start_time).total_seconds()
- audit_log.append("=== SYNC COMPLETED SUCCESSFULLY ===")
- audit_log.append(f"End Time: {end_time.strftime('%Y-%m-%d %H:%M:%S')}")
- audit_log.append(f"Duration: {duration:.2f} seconds")
- audit_log.append(f"Total JIRA issues processed: {len(jira_data)}")
- audit_log.append(f"Total records in final CSV: {len(merged_data)}")
-
- except Exception as e:
- # Error handling
- end_time = get_utc_now()
- duration = (end_time - start_time).total_seconds()
- audit_log.append("")
- audit_log.append("=== SYNC FAILED ===")
- audit_log.append(f"Error Time: {end_time.strftime('%Y-%m-%d %H:%M:%S')}")
- audit_log.append(f"Duration before failure: {duration:.2f} seconds")
- audit_log.append(f"Error: {str(e)}")
- raise
- finally:
- # Write audit log to SharePoint
- await self._write_audit_log(audit_log, "jira_to_csv")
-
- async def sync_from_csv_to_jira(self):
- """Syncs tasks from a CSV file in SharePoint to JIRA."""
- start_time = get_utc_now()
- audit_log = []
-
- audit_log.append("=== CSV TO JIRA SYNC STARTED ===")
- audit_log.append(f"Start Time: {start_time.strftime('%Y-%m-%d %H:%M:%S')}")
- audit_log.append(f"Sync File: {self.sync_file}")
- audit_log.append(f"Sync Folder: {self.sync_folder}")
- audit_log.append("")
-
- try:
- # 1. Read CSV file from SharePoint
- audit_log.append("Step 1: Reading CSV file from SharePoint...")
- try:
- file_path = f"{self.sync_folder}/{self.sync_file}"
- csv_content = await self.connector_sharepoint.download_file_by_path(
- site_id=self.site_id, file_path=file_path
- )
- # Try to read with robust CSV parsing
- df = pd.read_csv(
- BytesIO(csv_content),
- skiprows=2,
- quoting=1, # QUOTE_ALL
- escapechar='\\',
- on_bad_lines='skip', # Skip malformed lines
- engine='python' # More robust parsing
- )
- csv_data = df.to_dict("records")
- audit_log.append(
- f"CSV file read successfully with {len(csv_data)} records"
- )
- except Exception as e:
- audit_log.append(f"Failed to read CSV file: {str(e)}")
- audit_log.append("CSV to JIRA sync aborted - no file to process")
- return
- audit_log.append("")
-
- # 2. Read current JIRA data for comparison
- audit_log.append("Step 2: Reading current JIRA data for comparison...")
- try:
- current_jira_tasks = await self.connector_ticket.read_tasks(limit=0)
- current_jira_data = self._transform_tasks(
- current_jira_tasks, include_put=True
- )
- jira_lookup = {
- task.data.get("ID"): task.data for task in current_jira_data
- }
- audit_log.append(f"Current JIRA data read: {len(jira_lookup)} tasks")
- except Exception as e:
- audit_log.append(f"Failed to read current JIRA data: {str(e)}")
- raise
- audit_log.append("")
-
- # 3. Detect actual changes in "put" fields
- audit_log.append("Step 3: Detecting changes in 'put' fields...")
- actual_changes = {}
- records_with_changes = 0
- total_changes = 0
-
- for row in csv_data:
- task_id = row.get("ID")
- if not task_id or task_id not in jira_lookup:
- continue
-
- current_jira_task = jira_lookup[task_id]
- task_changes = {}
-
- for field_name, field_config in self.task_sync_definition.items():
- if field_config[0] == "put": # Only process "put" fields
- csv_value = row.get(field_name, "")
- jira_value = current_jira_task.get(field_name, "")
-
- # Convert None to empty string for comparison
- csv_value = "" if csv_value is None else str(csv_value).strip()
- jira_value = (
- "" if jira_value is None else str(jira_value).strip()
- )
-
- # Include if values are different (allow empty strings to clear fields like the reference does)
- if csv_value != jira_value:
- task_changes[field_name] = csv_value
-
- if task_changes:
- actual_changes[task_id] = task_changes
- records_with_changes += 1
- total_changes += len(task_changes)
-
- audit_log.append(f"Records with actual changes: {records_with_changes}")
- audit_log.append(f"Total field changes detected: {total_changes}")
- audit_log.append("")
-
- # Log detailed changes
- if actual_changes:
- audit_log.append("DETAILED CHANGES TO APPLY TO JIRA:")
- for task_id, changes in actual_changes.items():
- change_list = [
- f"{field}: '{value}'" for field, value in changes.items()
- ]
- audit_log.append(f"- Task ID {task_id}: {', '.join(change_list)}")
- audit_log.append("")
-
- # 4. Update JIRA tasks with actual changes
- if actual_changes:
- audit_log.append("Step 4: Updating JIRA tasks...")
-
- # Convert to Task objects for the connector
- tasks_to_update = []
- for task_id, changes in actual_changes.items():
- # Create task data structure expected by JIRA connector
- # Build the nested fields structure that JIRA expects
- fields = {}
- for field_name, new_value in changes.items():
- # Map back to JIRA field structure using task_sync_definition
- field_config = self.task_sync_definition[field_name]
- field_path = field_config[1]
-
- # Extract the JIRA field ID from the path
- # For "put" fields, the path is like ['fields', 'customfield_10067']
- if len(field_path) >= 2 and field_path[0] == "fields":
- jira_field_id = field_path[1]
- # Parse date fields back to JIRA format
- if self._is_date_field(field_name) and new_value:
- parsed_date = self._parse_date_from_excel(str(new_value))
- if parsed_date:
- fields[jira_field_id] = parsed_date
- else:
- fields[jira_field_id] = new_value
- else:
- fields[jira_field_id] = new_value
-
- if fields:
- task_data = {"ID": task_id, "fields": fields}
- task = Task(data=task_data)
- tasks_to_update.append(task)
-
- # Write tasks back to JIRA
- try:
- await self.connector_ticket.write_tasks(tasks_to_update)
- audit_log.append(
- f"Successfully updated {len(tasks_to_update)} JIRA tasks"
- )
- except Exception as e:
- audit_log.append(f"Failed to update JIRA tasks: {str(e)}")
- raise
- else:
- audit_log.append("Step 4: No changes to apply to JIRA")
- audit_log.append("")
-
- # Success summary
- end_time = get_utc_now()
- duration = (end_time - start_time).total_seconds()
- audit_log.append("=== SYNC COMPLETED SUCCESSFULLY ===")
- audit_log.append(f"End Time: {end_time.strftime('%Y-%m-%d %H:%M:%S')}")
- audit_log.append(f"Duration: {duration:.2f} seconds")
- audit_log.append(f"Total CSV records processed: {len(csv_data)}")
- audit_log.append(f"Records with actual changes: {records_with_changes}")
- audit_log.append(f"JIRA tasks updated: {len(actual_changes)}")
-
- except Exception as e:
- # Error handling
- end_time = get_utc_now()
- duration = (end_time - start_time).total_seconds()
- audit_log.append("")
- audit_log.append("=== SYNC FAILED ===")
- audit_log.append(f"Error Time: {end_time.strftime('%Y-%m-%d %H:%M:%S')}")
- audit_log.append(f"Duration before failure: {duration:.2f} seconds")
- audit_log.append(f"Error: {str(e)}")
- raise
- finally:
- # Write audit log to SharePoint
- await self._write_audit_log(audit_log, "csv_to_jira")
-
- async def sync_from_jira_to_excel(self):
- """Syncs tasks from JIRA to an Excel file in SharePoint."""
- start_time = get_utc_now()
- audit_log = []
-
- audit_log.append("=== JIRA TO EXCEL SYNC STARTED ===")
- audit_log.append(f"Start Time: {start_time.strftime('%Y-%m-%d %H:%M:%S')}")
- audit_log.append(f"Sync File: {self.sync_file}")
- audit_log.append(f"Sync Folder: {self.sync_folder}")
- audit_log.append("")
-
- try:
- # 1. Read JIRA tickets
- audit_log.append("Step 1: Reading JIRA tickets...")
- tickets = await self.connector_ticket.read_tasks(limit=0)
- audit_log.append(f"JIRA issues read: {len(tickets)}")
- audit_log.append("")
-
- # 2. Transform tasks according to task_sync_definition
- audit_log.append("Step 2: Transforming JIRA data...")
- transformed_tasks = self._transform_tasks(tickets, include_put=True)
- jira_data = [task.data for task in transformed_tasks]
- audit_log.append(f"JIRA issues transformed: {len(jira_data)}")
- audit_log.append("")
-
- # 3. Create JIRA export file in audit folder
- audit_log.append("Step 3: Creating JIRA export file...")
- try:
- timestamp = get_utc_now().strftime("%Y%m%d_%H%M%S")
- jira_export_filename = f"jira_export_{timestamp}.xlsx"
- # Use default headers for JIRA export
- jira_export_content = self._create_excel_content(jira_data, {"header1": "JIRA Export", "header2": "Raw Data"})
- await self.connector_sharepoint.upload_file(
- site_id=self.site_id,
- folder_path=self.audit_folder,
- file_name=jira_export_filename,
- content=jira_export_content,
- )
- audit_log.append(f"JIRA export file created: {jira_export_filename}")
- except Exception as e:
- audit_log.append(f"Failed to create JIRA export file: {str(e)}")
- audit_log.append("")
-
- # 4. Create backup of existing Excel file (if it exists)
- audit_log.append("Step 4: Creating backup...")
- backup_created = False
- try:
- await self.create_backup()
- backup_created = True
- audit_log.append("Backup created successfully")
- except Exception as e:
- audit_log.append(
- f"Backup creation failed (file might not exist): {str(e)}"
- )
- audit_log.append("")
-
- # 5. Try to read existing Excel file from SharePoint
- audit_log.append("Step 5: Reading existing Excel file...")
- existing_data = []
- existing_file_found = False
- existing_headers = {"header1": "Header 1", "header2": "Header 2"}
- try:
- file_path = f"{self.sync_folder}/{self.sync_file}"
- excel_content = await self.connector_sharepoint.download_file_by_path(
- site_id=self.site_id, file_path=file_path
- )
-
- # Parse Excel file with 4-row structure
- existing_data, existing_headers = self._parse_excel_content(excel_content)
- existing_file_found = True
- audit_log.append(
- f"Existing Excel file found with {len(existing_data)} records"
- )
- audit_log.append(f"Preserved headers: Header1='{existing_headers['header1']}', Header2='{existing_headers['header2']}'")
- except Exception as e:
- audit_log.append(f"No existing Excel file found or read error: {str(e)}")
- audit_log.append("")
-
- # 6. Merge JIRA data with existing data and track changes
- audit_log.append("Step 6: Merging JIRA data with existing data...")
- merged_data, change_details = self._merge_jira_with_existing_detailed(
- jira_data, existing_data
- )
-
- # Log detailed changes
- audit_log.append(f"Total records after merge: {len(merged_data)}")
- audit_log.append(f"Records updated: {change_details['updated']}")
- audit_log.append(f"Records added: {change_details['added']}")
- audit_log.append(f"Records unchanged: {change_details['unchanged']}")
- audit_log.append("")
-
- # Log individual changes
- if change_details["changes"]:
- audit_log.append("DETAILED CHANGES:")
- for change in change_details["changes"]:
- audit_log.append(f"- {change}")
- audit_log.append("")
-
- # 7. Create Excel with 4-row structure and write to SharePoint
- audit_log.append("Step 7: Writing updated Excel to SharePoint...")
- # Ensure no records without ID are written
- merged_data = self._filter_empty_records(merged_data)
- excel_content = self._create_excel_content(merged_data, existing_headers)
- await self.connector_sharepoint.upload_file(
- site_id=self.site_id,
- folder_path=self.sync_folder,
- file_name=self.sync_file,
- content=excel_content,
- )
- audit_log.append("Excel file successfully written to SharePoint")
- audit_log.append("")
-
- # Success summary
- end_time = get_utc_now()
- duration = (end_time - start_time).total_seconds()
- audit_log.append("=== SYNC COMPLETED SUCCESSFULLY ===")
- audit_log.append(f"End Time: {end_time.strftime('%Y-%m-%d %H:%M:%S')}")
- audit_log.append(f"Duration: {duration:.2f} seconds")
- audit_log.append(f"Total JIRA issues processed: {len(jira_data)}")
- audit_log.append(f"Total records in final Excel: {len(merged_data)}")
-
- except Exception as e:
- # Error handling
- end_time = get_utc_now()
- duration = (end_time - start_time).total_seconds()
- audit_log.append("")
- audit_log.append("=== SYNC FAILED ===")
- audit_log.append(f"Error Time: {end_time.strftime('%Y-%m-%d %H:%M:%S')}")
- audit_log.append(f"Duration before failure: {duration:.2f} seconds")
- audit_log.append(f"Error: {str(e)}")
- raise
- finally:
- # Write audit log to SharePoint
- await self._write_audit_log(audit_log, "jira_to_excel")
-
- async def sync_from_excel_to_jira(self):
- """Syncs tasks from an Excel file in SharePoint to JIRA."""
- start_time = get_utc_now()
- audit_log = []
-
- audit_log.append("=== EXCEL TO JIRA SYNC STARTED ===")
- audit_log.append(f"Start Time: {start_time.strftime('%Y-%m-%d %H:%M:%S')}")
- audit_log.append(f"Sync File: {self.sync_file}")
- audit_log.append(f"Sync Folder: {self.sync_folder}")
- audit_log.append("")
-
- try:
- # 1. Read Excel file from SharePoint
- audit_log.append("Step 1: Reading Excel file from SharePoint...")
- try:
- file_path = f"{self.sync_folder}/{self.sync_file}"
- excel_content = await self.connector_sharepoint.download_file_by_path(
- site_id=self.site_id, file_path=file_path
- )
- # Parse Excel file with 4-row structure
- excel_data, _ = self._parse_excel_content(excel_content)
- audit_log.append(
- f"Excel file read successfully with {len(excel_data)} records"
- )
- except Exception as e:
- audit_log.append(f"Failed to read Excel file: {str(e)}")
- audit_log.append("Excel to JIRA sync aborted - no file to process")
- return
- audit_log.append("")
-
- # 2. Read current JIRA data for comparison
- audit_log.append("Step 2: Reading current JIRA data for comparison...")
- try:
- current_jira_tasks = await self.connector_ticket.read_tasks(limit=0)
- current_jira_data = self._transform_tasks(
- current_jira_tasks, include_put=True
- )
- jira_lookup = {
- task.data.get("ID"): task.data for task in current_jira_data
- }
- audit_log.append(f"Current JIRA data read: {len(jira_lookup)} tasks")
- except Exception as e:
- audit_log.append(f"Failed to read current JIRA data: {str(e)}")
- raise
- audit_log.append("")
-
- # 3. Detect actual changes in "put" fields
- audit_log.append("Step 3: Detecting changes in 'put' fields...")
- actual_changes = {}
- records_with_changes = 0
- total_changes = 0
-
- for row in excel_data:
- task_id = row.get("ID")
- if not task_id or task_id not in jira_lookup:
- continue
-
- current_jira_task = jira_lookup[task_id]
- task_changes = {}
-
- for field_name, field_config in self.task_sync_definition.items():
- if field_config[0] == "put": # Only process "put" fields
- excel_value = row.get(field_name, "")
- jira_value = current_jira_task.get(field_name, "")
-
- # Convert None to empty string for comparison
- excel_value = "" if excel_value is None else str(excel_value).strip()
- jira_value = (
- "" if jira_value is None else str(jira_value).strip()
- )
-
- # Include if values are different (allow empty strings to clear fields like the reference does)
- if excel_value != jira_value:
- task_changes[field_name] = excel_value
-
- if task_changes:
- actual_changes[task_id] = task_changes
- records_with_changes += 1
- total_changes += len(task_changes)
-
- audit_log.append(f"Records with actual changes: {records_with_changes}")
- audit_log.append(f"Total field changes detected: {total_changes}")
- audit_log.append("")
-
- # Log detailed changes
- if actual_changes:
- audit_log.append("DETAILED CHANGES TO APPLY TO JIRA:")
- for task_id, changes in actual_changes.items():
- change_list = [
- f"{field}: '{value}'" for field, value in changes.items()
- ]
- audit_log.append(f"- Task ID {task_id}: {', '.join(change_list)}")
- audit_log.append("")
-
- # 4. Update JIRA tasks with actual changes
- if actual_changes:
- audit_log.append("Step 4: Updating JIRA tasks...")
-
- # Convert to Task objects for the connector
- tasks_to_update = []
- for task_id, changes in actual_changes.items():
- # Create task data structure expected by JIRA connector
- # Build the nested fields structure that JIRA expects
- fields = {}
- for field_name, new_value in changes.items():
- # Map back to JIRA field structure using task_sync_definition
- field_config = self.task_sync_definition[field_name]
- field_path = field_config[1]
-
- # Extract the JIRA field ID from the path
- # For "put" fields, the path is like ['fields', 'customfield_10067']
- if len(field_path) >= 2 and field_path[0] == "fields":
- jira_field_id = field_path[1]
- # Parse date fields back to JIRA format
- if self._is_date_field(field_name) and new_value:
- parsed_date = self._parse_date_from_excel(str(new_value))
- if parsed_date:
- fields[jira_field_id] = parsed_date
- else:
- fields[jira_field_id] = new_value
- else:
- fields[jira_field_id] = new_value
-
- if fields:
- task_data = {"ID": task_id, "fields": fields}
- task = Task(data=task_data)
- tasks_to_update.append(task)
-
- # Write tasks back to JIRA
- try:
- await self.connector_ticket.write_tasks(tasks_to_update)
- audit_log.append(
- f"Successfully updated {len(tasks_to_update)} JIRA tasks"
- )
- except Exception as e:
- audit_log.append(f"Failed to update JIRA tasks: {str(e)}")
- raise
- else:
- audit_log.append("Step 4: No changes to apply to JIRA")
- audit_log.append("")
-
- # Success summary
- end_time = get_utc_now()
- duration = (end_time - start_time).total_seconds()
- audit_log.append("=== SYNC COMPLETED SUCCESSFULLY ===")
- audit_log.append(f"End Time: {end_time.strftime('%Y-%m-%d %H:%M:%S')}")
- audit_log.append(f"Duration: {duration:.2f} seconds")
- audit_log.append(f"Total Excel records processed: {len(excel_data)}")
- audit_log.append(f"Records with actual changes: {records_with_changes}")
- audit_log.append(f"JIRA tasks updated: {len(actual_changes)}")
-
- except Exception as e:
- # Error handling
- end_time = get_utc_now()
- duration = (end_time - start_time).total_seconds()
- audit_log.append("")
- audit_log.append("=== SYNC FAILED ===")
- audit_log.append(f"Error Time: {end_time.strftime('%Y-%m-%d %H:%M:%S')}")
- audit_log.append(f"Duration before failure: {duration:.2f} seconds")
- audit_log.append(f"Error: {str(e)}")
- raise
- finally:
- # Write audit log to SharePoint
- await self._write_audit_log(audit_log, "excel_to_jira")
-
- def _transform_tasks(
- self, tasks: list[Task], include_put: bool = False
+# Module-level factory to create TicketInterface by connector type
+async def createTicketInterfaceByType(
+ *,
+ taskSyncDefinition: dict,
+ connectorType: str,
+ connectorParams: dict,
+) -> "TicketInterface":
+ connectorTypeLower = (connectorType or "").strip().lower()
+ if connectorTypeLower == "jira":
+ from modules.connectors.connectorTicketsJira import ConnectorTicketJira
+ connector_ticket = ConnectorTicketJira(**connectorParams)
+ elif connectorTypeLower == "clickup":
+ from modules.connectors.connectorTicketsClickup import ConnectorTicketClickup
+ # ClickUp does not require async factory; instantiate directly
+ connector_ticket = ConnectorTicketClickup(**connectorParams)
+ else:
+ raise ValueError(f"Unsupported connector_type: {connectorType}")
+
+ return TicketInterface(
+ connector_ticket=connector_ticket,
+ task_sync_definition=taskSyncDefinition,
+ )
+
+
+class TicketInterface:
+ def __init__(self, *, connector_ticket: TicketBase, task_sync_definition: dict):
+ self.connector_ticket = connector_ticket
+ self.task_sync_definition = task_sync_definition
+
+ async def exportTicketsAsList(self) -> list[dict]:
+ tickets = await self.connector_ticket.read_tasks(limit=0)
+ transformed_tasks = self._transformTasks(tickets, includePut=True)
+ data_list = [task.data for task in transformed_tasks]
+ return self._filterEmptyRecords(data_list)
+
+ async def importListToTickets(self, records: list[dict]) -> None:
+ updates: list[Task] = []
+ for row in records:
+ task_id = row.get("ID")
+ if not task_id:
+ continue
+ fields = {}
+ for field_name, field_config in self.task_sync_definition.items():
+ if field_config[0] == "put":
+ field_path = field_config[1]
+ value = row.get(field_name, "")
+ if len(field_path) >= 2 and field_path[0] == "fields":
+ field_id = field_path[1]
+ fields[field_id] = value
+ if fields:
+ updates.append(Task(data={"ID": task_id, "fields": fields}))
+ if updates:
+ await self.connector_ticket.write_tasks(updates)
+
+ def _transformTasks(
+ self, tasks: list[Task], includePut: bool = False
) -> list[Task]:
"""Transforms tasks according to the task_sync_definition."""
transformed_tasks = []
@@ -709,13 +68,12 @@ class TicketSharepointSyncInterface:
# Process each field in the sync definition
for field_name, field_config in self.task_sync_definition.items():
- direction = field_config[0] # "get" or "put"
- field_path = field_config[1] # List of keys to navigate
+ direction = field_config[0]
+ field_path = field_config[1]
# Get the right fields
- if direction == "get" or include_put:
- # Extract value using the field path
- value = self._extract_field_value(task.data, field_path, field_name)
+ if direction == "get" or includePut:
+ value = self._extractFieldValue(task.data, field_path, field_name)
transformed_data[field_name] = value
# Create new Task with transformed data
@@ -724,8 +82,8 @@ class TicketSharepointSyncInterface:
return transformed_tasks
- def _extract_field_value(self, issue_data: dict, field_path: list[str], field_name: str = None) -> Any:
- """Extract field value from JIRA issue data using field path."""
+ def _extractFieldValue(self, issue_data: dict, field_path: list[str], field_name: str = None) -> Any:
+ """Extract field value from ticket data using field path."""
value = issue_data
try:
for key in field_path:
@@ -747,174 +105,21 @@ class TicketSharepointSyncInterface:
):
value = value[0]["value"]
- # Apply ADF conversion for specific fields that contain ADF content
- if isinstance(value, dict) and value.get("type") == "doc":
- value = self._convert_adf_to_text(value)
-
# Apply date formatting for date fields
- if field_name and self._is_date_field(field_name):
- value = self._format_date_for_excel(value)
+ if field_name and self._isDateField(field_name):
+ value = self._formatDateForExcel(value)
return value
except (KeyError, TypeError):
return None
- def _convert_adf_to_text(self, adf_data):
- """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:
- adf_data: ADF object or None
-
- Returns:
- str: Plain text content, or empty string if None/invalid
- """
- if not adf_data or not isinstance(adf_data, dict):
- return ""
-
- if adf_data.get("type") != "doc":
- return str(adf_data) if adf_data else ""
-
- content = adf_data.get("content", [])
- if not isinstance(content, list):
- return ""
-
- def extract_text_from_content(content_list, list_level=0):
- """Recursively extract text from ADF content with proper formatting."""
- text_parts = []
- list_counter = 1
-
- for item in content_list:
- if not isinstance(item, dict):
- continue
-
- item_type = item.get("type", "")
-
- if item_type == "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})"
-
- text_parts.append(text)
-
- elif item_type == "hardBreak":
- text_parts.append("\n")
-
- elif item_type == "paragraph":
- paragraph_content = item.get("content", [])
- if paragraph_content:
- paragraph_text = extract_text_from_content(paragraph_content, list_level)
- if paragraph_text.strip():
- text_parts.append(paragraph_text)
-
- elif item_type == "bulletList":
- list_content = item.get("content", [])
- for list_item in list_content:
- if list_item.get("type") == "listItem":
- list_item_content = list_item.get("content", [])
- for list_paragraph in list_item_content:
- if list_paragraph.get("type") == "paragraph":
- list_paragraph_content = list_paragraph.get("content", [])
- if list_paragraph_content:
- indent = " " * list_level
- bullet_text = extract_text_from_content(list_paragraph_content, list_level + 1)
- if bullet_text.strip():
- text_parts.append(f"{indent}• {bullet_text}")
-
- elif item_type == "orderedList":
- list_content = item.get("content", [])
- for list_item in list_content:
- if list_item.get("type") == "listItem":
- list_item_content = list_item.get("content", [])
- for list_paragraph in list_item_content:
- if list_paragraph.get("type") == "paragraph":
- list_paragraph_content = list_paragraph.get("content", [])
- if list_paragraph_content:
- indent = " " * list_level
- ordered_text = extract_text_from_content(list_paragraph_content, list_level + 1)
- if ordered_text.strip():
- text_parts.append(f"{indent}{list_counter}. {ordered_text}")
- list_counter += 1
-
- elif item_type == "listItem":
- # Handle nested list items
- list_item_content = item.get("content", [])
- if list_item_content:
- text_parts.append(extract_text_from_content(list_item_content, list_level))
-
- elif item_type == "embedCard":
- # Handle embedded content (videos, etc.)
- attrs = item.get("attrs", {})
- url = attrs.get("url", "")
- if url:
- text_parts.append(f"[Embedded Content: {url}]")
-
- elif item_type == "codeBlock":
- # Handle code blocks
- code_content = item.get("content", [])
- if code_content:
- code_text = extract_text_from_content(code_content, list_level)
- if code_text.strip():
- text_parts.append(f"```\n{code_text}\n```")
-
- elif item_type == "blockquote":
- # Handle blockquotes
- quote_content = item.get("content", [])
- if quote_content:
- quote_text = extract_text_from_content(quote_content, list_level)
- if quote_text.strip():
- text_parts.append(f"> {quote_text}")
-
- elif item_type == "heading":
- # Handle headings
- heading_content = item.get("content", [])
- if heading_content:
- heading_text = extract_text_from_content(heading_content, list_level)
- if heading_text.strip():
- level = item.get("attrs", {}).get("level", 1)
- text_parts.append(f"{'#' * level} {heading_text}")
-
- elif item_type == "rule":
- # Handle horizontal rules
- text_parts.append("---")
-
- else:
- # Handle unknown types by trying to extract content
- if "content" in item:
- content_text = extract_text_from_content(item.get("content", []), list_level)
- if content_text.strip():
- text_parts.append(content_text)
-
- return "\n".join(text_parts)
-
- result = extract_text_from_content(content)
- return result.strip()
-
- def _format_date_for_excel(self, date_value: Any) -> Optional[str]:
+ def _formatDateForExcel(self, date_value: Any) -> Optional[str]:
"""Format date value for Excel export.
- Handles various date formats from JIRA and converts them to a consistent format
- suitable for Excel display.
+ Handles various date formats and converts them to a consistent format suitable for Excel display.
Args:
- date_value: Date value from JIRA (string, datetime, or None)
+ date_value: Date value from Tickets (string, datetime, or None)
Returns:
Formatted date string or None if invalid/empty
@@ -955,61 +160,7 @@ class TicketSharepointSyncInterface:
# Log error but don't fail the sync
return str(date_value) if date_value else None
- def _parse_date_from_excel(self, date_string: str) -> Optional[str]:
- """Parse date string from Excel and convert to JIRA format.
-
- Converts Excel date strings back to JIRA-compatible ISO format.
-
- Args:
- date_string: Date string from Excel
-
- Returns:
- ISO formatted date string for JIRA or None if invalid
- """
- if not date_string or not isinstance(date_string, str):
- return None
-
- try:
- # Handle various Excel date formats
- date_string = date_string.strip()
-
- # Try common Excel date formats
- formats_to_try = [
- '%Y-%m-%d %H:%M:%S UTC', # Our export format
- '%Y-%m-%d %H:%M:%S', # Standard format
- '%Y-%m-%d', # Date only
- '%d.%m.%Y', # German format
- '%m/%d/%Y', # US format
- '%d/%m/%Y', # European format
- ]
-
- for fmt in formats_to_try:
- try:
- dt = datetime.strptime(date_string, fmt)
- # Convert to UTC and format as ISO
- if dt.tzinfo is None:
- dt = dt.replace(tzinfo=timezone.utc)
- return dt.isoformat()
- except ValueError:
- continue
-
- # If no format matches, try pandas parsing
- try:
- dt = pd.to_datetime(date_string)
- if hasattr(dt, 'to_pydatetime'):
- dt = dt.to_pydatetime()
- if dt.tzinfo is None:
- dt = dt.replace(tzinfo=timezone.utc)
- return dt.isoformat()
- except:
- pass
-
- return None
-
- except Exception:
- return None
-
- def _is_date_field(self, field_name: str) -> bool:
+ def _isDateField(self, field_name: str) -> bool:
"""Check if a field is a date field based on its name.
Args:
@@ -1021,7 +172,7 @@ class TicketSharepointSyncInterface:
date_keywords = ['date', 'time', 'created', 'updated', 'due', 'deadline']
return any(keyword in field_name.lower() for keyword in date_keywords)
- def _filter_empty_records(self, records: list[dict]) -> list[dict]:
+ def _filterEmptyRecords(self, records: list[dict]) -> list[dict]:
"""Remove records that are missing an ID.
Purposefully only filter by presence of 'ID' to avoid dropping
@@ -1033,392 +184,3 @@ class TicketSharepointSyncInterface:
filtered.append(row)
return filtered
- def _merge_jira_with_existing(
- self, jira_data: list[dict], existing_data: list[dict]
- ) -> list[dict]:
- """Merge JIRA data with existing CSV data, updating only 'get' fields."""
- # Create a lookup for existing data by ID
- existing_lookup = {row.get("ID"): row for row in existing_data if row.get("ID")}
-
- merged_data = []
- for jira_row in jira_data:
- jira_id = jira_row.get("ID")
- if jira_id and jira_id in existing_lookup:
- # Update existing row with JIRA data (only 'get' fields)
- existing_row = existing_lookup[jira_id].copy()
- for field_name, field_config in self.task_sync_definition.items():
- if field_config[0] == "get": # Only update 'get' fields
- existing_row[field_name] = jira_row.get(field_name)
- merged_data.append(existing_row)
- # Remove from lookup to track processed items
- del existing_lookup[jira_id]
- else:
- # New row from JIRA
- merged_data.append(jira_row)
-
- # Add any remaining existing rows that weren't in JIRA data
- merged_data.extend(existing_lookup.values())
-
- return merged_data
-
- def _merge_jira_with_existing_detailed(
- self, jira_data: list[dict], existing_data: list[dict]
- ) -> tuple[list[dict], dict]:
- """Merge JIRA data with existing CSV data and track detailed changes."""
- # Create a lookup for existing data by ID
- existing_lookup = {row.get("ID"): row for row in existing_data if row.get("ID")}
-
- merged_data = []
- changes = []
- updated_count = 0
- added_count = 0
- unchanged_count = 0
-
- for jira_row in jira_data:
- jira_id = jira_row.get("ID")
- if jira_id and jira_id in existing_lookup:
- # Update existing row with JIRA data (only 'get' fields)
- existing_row = existing_lookup[jira_id].copy()
- row_changes = []
-
- for field_name, field_config in self.task_sync_definition.items():
- if field_config[0] == "get": # Only update 'get' fields
- old_value = existing_row.get(field_name, "")
- new_value = jira_row.get(field_name, "")
-
- # Convert None to empty string for comparison
- old_value = "" if old_value is None else str(old_value)
- new_value = "" if new_value is None else str(new_value)
-
- if old_value != new_value:
- row_changes.append(
- f"{field_name}: '{old_value}' → '{new_value}'"
- )
-
- existing_row[field_name] = jira_row.get(field_name)
-
- merged_data.append(existing_row)
-
- if row_changes:
- updated_count += 1
- changes.append(
- f"Row ID {jira_id} updated: {', '.join(row_changes)}"
- )
- else:
- unchanged_count += 1
-
- # Remove from lookup to track processed items
- del existing_lookup[jira_id]
- else:
- # New row from JIRA
- merged_data.append(jira_row)
- added_count += 1
- changes.append(f"Row ID {jira_id} added as new record")
-
- # Add any remaining existing rows that weren't in JIRA data
- for remaining_row in existing_lookup.values():
- merged_data.append(remaining_row)
- unchanged_count += 1
-
- change_details = {
- "updated": updated_count,
- "added": added_count,
- "unchanged": unchanged_count,
- "changes": changes,
- }
-
- return merged_data, change_details
-
- async def _write_audit_log(self, audit_log: list[str], operation_type: str):
- """Write audit log to SharePoint."""
- try:
- timestamp = get_utc_now().strftime("%Y%m%d_%H%M%S")
- audit_filename = f"audit_{operation_type}_{timestamp}.log"
-
- # Convert audit log to bytes
- audit_content = "\n".join(audit_log).encode("utf-8")
-
- # Debug logging
- import logging
- logger = logging.getLogger(__name__)
- logger.debug(f"Writing audit log to folder: {self.audit_folder}, file: {audit_filename}")
-
- # Write to SharePoint
- await self.connector_sharepoint.upload_file(
- site_id=self.site_id,
- folder_path=self.audit_folder,
- file_name=audit_filename,
- content=audit_content,
- )
- logger.debug("Audit log written successfully")
- except Exception as e:
- # If audit logging fails, we don't want to break the main sync process
- # Just log the error (this could be enhanced with fallback logging)
- import logging
- logger = logging.getLogger(__name__)
- logger.warning(f"Failed to write audit log: {str(e)}")
- logger.warning(f"Audit folder: {self.audit_folder}")
- logger.warning(f"Operation type: {operation_type}")
- import traceback
- logger.warning(f"Traceback: {traceback.format_exc()}")
-
- def _create_csv_content(self, data: list[dict], existing_headers: dict = None) -> bytes:
- """Create CSV content with 4-row structure matching reference code."""
- # Get current timestamp for header
- timestamp = get_utc_now().strftime("%Y-%m-%d %H:%M:%S UTC")
-
- # Use existing headers if provided, otherwise use defaults
- if existing_headers is None:
- existing_headers = {"header1": "Header 1", "header2": "Header 2"}
-
- if not data:
- # Build an empty table with the expected columns from schema
- cols = list(self.task_sync_definition.keys())
-
- df = pd.DataFrame(columns=cols)
-
- # Parse existing headers to extract individual columns
- import csv as csv_module
- header1_text = existing_headers.get("header1", "Header 1")
- header2_text = existing_headers.get("header2", "Header 2")
-
- # Parse the existing header rows
- header1_reader = csv_module.reader([header1_text])
- header2_reader = csv_module.reader([header2_text])
- header1_row = next(header1_reader, [])
- header2_row = next(header2_reader, [])
-
- # Row 1: Use existing header1 or default
- if len(header1_row) >= len(cols):
- header_row1_data = header1_row[:len(cols)]
- else:
- header_row1_data = header1_row + [""] * (len(cols) - len(header1_row))
- header_row1 = pd.DataFrame([header_row1_data], columns=cols)
-
- # Row 2: Use existing header2 and add timestamp to second column
- if len(header2_row) >= len(cols):
- header_row2_data = header2_row[:len(cols)]
- else:
- header_row2_data = header2_row + [""] * (len(cols) - len(header2_row))
- if len(header_row2_data) > 1:
- header_row2_data[1] = timestamp
- header_row2 = pd.DataFrame([header_row2_data], columns=cols)
-
- # Row 3: table headers
- table_headers = pd.DataFrame([cols], columns=cols)
-
- final_df = pd.concat(
- [header_row1, header_row2, table_headers, df], ignore_index=True
- )
- csv_text = StringIO()
- final_df.to_csv(csv_text, index=False, header=False, quoting=1, escapechar='\\')
- return csv_text.getvalue().encode("utf-8")
-
- # Create DataFrame from data
- df = pd.DataFrame(data)
-
- # Force all columns to be object (string) type to preserve empty cells
- for column in df.columns:
- df[column] = df[column].astype("object")
- df[column] = df[column].fillna("")
-
- # Clean data: replace actual line breaks with \n and escape quotes
- for column in df.columns:
- df[column] = df[column].astype(str).str.replace('\n', '\\n', regex=False)
- df[column] = df[column].str.replace('"', '""', regex=False)
-
- # Create the 4-row structure
- # Parse existing headers to extract individual columns
- import csv as csv_module
- header1_text = existing_headers.get("header1", "Header 1")
- header2_text = existing_headers.get("header2", "Header 2")
-
- # Parse the existing header rows
- header1_reader = csv_module.reader([header1_text])
- header2_reader = csv_module.reader([header2_text])
- header1_row = next(header1_reader, [])
- header2_row = next(header2_reader, [])
-
- # Row 1: Use existing header1 or default
- if len(header1_row) >= len(df.columns):
- header_row1_data = header1_row[:len(df.columns)]
- else:
- header_row1_data = header1_row + [""] * (len(df.columns) - len(header1_row))
- header_row1 = pd.DataFrame([header_row1_data], columns=df.columns)
-
- # Row 2: Use existing header2 and add timestamp to second column
- if len(header2_row) >= len(df.columns):
- header_row2_data = header2_row[:len(df.columns)]
- else:
- header_row2_data = header2_row + [""] * (len(df.columns) - len(header2_row))
- if len(header_row2_data) > 1:
- header_row2_data[1] = timestamp
- header_row2 = pd.DataFrame([header_row2_data], columns=df.columns)
-
- # Row 3: Table headers (column names)
- table_headers = pd.DataFrame([df.columns.tolist()], columns=df.columns)
-
- # Concatenate all rows: header1 + header2 + table_headers + data
- final_df = pd.concat(
- [header_row1, header_row2, table_headers, df], ignore_index=True
- )
-
- # Convert to CSV bytes with proper quoting for fields containing special characters
- csv_text = StringIO()
- final_df.to_csv(csv_text, index=False, header=False, quoting=1, escapechar='\\')
- return csv_text.getvalue().encode("utf-8")
-
- def _create_excel_content(self, data: list[dict], existing_headers: dict = None) -> bytes:
- """Create Excel content with 4-row structure matching reference code."""
- # Get current timestamp for header
- timestamp = get_utc_now().strftime("%Y-%m-%d %H:%M:%S UTC")
-
- # Use existing headers if provided, otherwise use defaults
- if existing_headers is None:
- existing_headers = {"header1": "Header 1", "header2": "Header 2"}
-
- if not data:
- # Build an empty table with the expected columns from schema
- cols = list(self.task_sync_definition.keys())
-
- df = pd.DataFrame(columns=cols)
-
- # Parse existing headers to extract individual columns
- import csv as csv_module
- header1_text = existing_headers.get("header1", "Header 1")
- header2_text = existing_headers.get("header2", "Header 2")
-
- # Parse the existing header rows
- header1_reader = csv_module.reader([header1_text])
- header2_reader = csv_module.reader([header2_text])
- header1_row = next(header1_reader, [])
- header2_row = next(header2_reader, [])
-
- # Row 1: Use existing header1 or default
- if len(header1_row) >= len(cols):
- header_row1_data = header1_row[:len(cols)]
- else:
- header_row1_data = header1_row + [""] * (len(cols) - len(header1_row))
- header_row1 = pd.DataFrame([header_row1_data], columns=cols)
-
- # Row 2: Use existing header2 and add timestamp to second column
- if len(header2_row) >= len(cols):
- header_row2_data = header2_row[:len(cols)]
- else:
- header_row2_data = header2_row + [""] * (len(cols) - len(header2_row))
- if len(header_row2_data) > 1:
- header_row2_data[1] = timestamp
- header_row2 = pd.DataFrame([header_row2_data], columns=cols)
-
- # Row 3: table headers
- table_headers = pd.DataFrame([cols], columns=cols)
-
- final_df = pd.concat(
- [header_row1, header_row2, table_headers, df], ignore_index=True
- )
-
- # Create Excel file in memory
- excel_buffer = BytesIO()
- final_df.to_excel(excel_buffer, index=False, header=False, engine='openpyxl')
- return excel_buffer.getvalue()
-
- # Create DataFrame from data
- df = pd.DataFrame(data)
-
- # Force all columns to be object (string) type to preserve empty cells
- for column in df.columns:
- df[column] = df[column].astype("object")
- df[column] = df[column].fillna("")
-
- # Clean data: replace actual line breaks with \n and escape quotes
- for column in df.columns:
- df[column] = df[column].astype(str).str.replace('\n', '\\n', regex=False)
- df[column] = df[column].str.replace('"', '""', regex=False)
-
- # Create the 4-row structure
- # Parse existing headers to extract individual columns
- import csv as csv_module
- header1_text = existing_headers.get("header1", "Header 1")
- header2_text = existing_headers.get("header2", "Header 2")
-
- # Parse the existing header rows
- header1_reader = csv_module.reader([header1_text])
- header2_reader = csv_module.reader([header2_text])
- header1_row = next(header1_reader, [])
- header2_row = next(header2_reader, [])
-
- # Row 1: Use existing header1 or default
- if len(header1_row) >= len(df.columns):
- header_row1_data = header1_row[:len(df.columns)]
- else:
- header_row1_data = header1_row + [""] * (len(df.columns) - len(header1_row))
- header_row1 = pd.DataFrame([header_row1_data], columns=df.columns)
-
- # Row 2: Use existing header2 and add timestamp to second column
- if len(header2_row) >= len(df.columns):
- header_row2_data = header2_row[:len(df.columns)]
- else:
- header_row2_data = header2_row + [""] * (len(df.columns) - len(header2_row))
- if len(header_row2_data) > 1:
- header_row2_data[1] = timestamp
- header_row2 = pd.DataFrame([header_row2_data], columns=df.columns)
-
- # Row 3: Table headers (column names)
- table_headers = pd.DataFrame([df.columns.tolist()], columns=df.columns)
-
- # Concatenate all rows: header1 + header2 + table_headers + data
- final_df = pd.concat(
- [header_row1, header_row2, table_headers, df], ignore_index=True
- )
-
- # Create Excel file in memory
- excel_buffer = BytesIO()
- final_df.to_excel(excel_buffer, index=False, header=False, engine='openpyxl')
- return excel_buffer.getvalue()
-
- def _parse_excel_content(self, excel_content: bytes) -> tuple[list[dict], dict]:
- """Parse Excel content with 4-row structure and return data and headers."""
- try:
- # Load Excel file from bytes
- df = pd.read_excel(
- BytesIO(excel_content),
- engine='openpyxl',
- header=None
- )
-
- # Extract the 4 parts:
- # Row 1: Static header row 1
- header_row1 = df.iloc[0:1].copy()
-
- # Row 2: Static header row 2
- header_row2 = df.iloc[1:2].copy()
-
- # Row 3: Table headers
- table_headers = df.iloc[2:3].copy()
-
- # Row 4+: Data rows
- df_data = df.iloc[3:].copy()
- # Set column names from row 3
- df_data.columns = table_headers.iloc[0]
- # Reset index to start from 0
- df_data = df_data.reset_index(drop=True)
-
- # Force all columns to be object (string) type and handle NaN values
- for column in df_data.columns:
- df_data[column] = df_data[column].astype('object')
- # Fill NaN values with empty string to keep cells empty
- df_data[column] = df_data[column].fillna('')
-
- # Convert DataFrame to list of dictionaries
- data = df_data.to_dict(orient='records')
-
- # Extract headers as strings (like CSV version)
- headers = {
- "header1": ",".join([str(x) if pd.notna(x) else "" for x in header_row1.iloc[0].tolist()]),
- "header2": ",".join([str(x) if pd.notna(x) else "" for x in header_row2.iloc[0].tolist()])
- }
-
- return data, headers
-
- except Exception as e:
- raise Exception(f"Failed to parse Excel content: {str(e)}")
diff --git a/modules/interfaces/interfaceWebObjects.py b/modules/interfaces/interfaceWebObjects.py
index bdd1fd53..023eb539 100644
--- a/modules/interfaces/interfaceWebObjects.py
+++ b/modules/interfaces/interfaceWebObjects.py
@@ -33,12 +33,9 @@ class WebInterface:
@classmethod
async def create(cls) -> "WebInterface":
connectorWebTavily = await ConnectorTavily.create()
-
return WebInterface(connectorWebTavily=connectorWebTavily)
- async def search(
- self, web_search_request: WebSearchRequest
- ) -> WebSearchActionResult:
+ async def search(self, web_search_request: WebSearchRequest) -> WebSearchActionResult:
# NOTE: Add connectors here
return await self.connectorWebTavily.search_urls(web_search_request)
@@ -46,9 +43,7 @@ class WebInterface:
# NOTE: Add connectors here
return await self.connectorWebTavily.crawl_urls(web_crawl_request)
- async def scrape(
- self, web_scrape_request: WebScrapeRequest
- ) -> WebScrapeActionResult:
+ async def scrape(self, web_scrape_request: WebScrapeRequest) -> WebScrapeActionResult:
# NOTE: Add connectors here
return await self.connectorWebTavily.scrape(web_scrape_request)
diff --git a/modules/routes/routeDataNeutralization.py b/modules/routes/routeDataNeutralization.py
index 322d5398..71b1db25 100644
--- a/modules/routes/routeDataNeutralization.py
+++ b/modules/routes/routeDataNeutralization.py
@@ -7,7 +7,7 @@ from modules.security.auth import limiter, getCurrentUser
# Import interfaces
from modules.interfaces.interfaceAppModel import User, DataNeutraliserConfig, DataNeutralizerAttributes
-from modules.features.neutralization.mainNeutralizationPlayground import NeutralizationService
+from modules.features.neutralizePlayground.mainNeutralizePlayground import NeutralizationPlayground
# Configure logger
logger = logging.getLogger(__name__)
@@ -33,7 +33,7 @@ async def get_neutralization_config(
) -> DataNeutraliserConfig:
"""Get data neutralization configuration"""
try:
- service = NeutralizationService(currentUser)
+ service = NeutralizationPlayground(currentUser)
config = service.get_config()
if not config:
@@ -67,7 +67,7 @@ async def save_neutralization_config(
) -> DataNeutraliserConfig:
"""Save or update data neutralization configuration"""
try:
- service = NeutralizationService(currentUser)
+ service = NeutralizationPlayground(currentUser)
config = service.save_config(config_data)
return config
@@ -97,7 +97,7 @@ async def neutralize_text(
detail="Text content is required"
)
- service = NeutralizationService(currentUser)
+ service = NeutralizationPlayground(currentUser)
result = service.neutralize_text(text, file_id)
return result
@@ -128,7 +128,7 @@ async def resolve_text(
detail="Text content is required"
)
- service = NeutralizationService(currentUser)
+ service = NeutralizationPlayground(currentUser)
resolved_text = service.resolve_text(text)
return {"resolved_text": resolved_text}
@@ -151,7 +151,7 @@ async def get_neutralization_attributes(
) -> List[DataNeutralizerAttributes]:
"""Get neutralization attributes, optionally filtered by file ID"""
try:
- service = NeutralizationService(currentUser)
+ service = NeutralizationPlayground(currentUser)
attributes = service.get_attributes(fileId)
return attributes
@@ -181,7 +181,7 @@ async def process_sharepoint_files(
detail="Both source and target paths are required"
)
- service = NeutralizationService(currentUser)
+ service = NeutralizationPlayground(currentUser)
result = await service.process_sharepoint_files(source_path, target_path)
return result
@@ -210,7 +210,7 @@ async def batch_process_files(
detail="Files data is required"
)
- service = NeutralizationService(currentUser)
+ service = NeutralizationPlayground(currentUser)
result = service.batch_neutralize_files(files_data)
return result
@@ -232,7 +232,7 @@ async def get_neutralization_stats(
) -> Dict[str, Any]:
"""Get neutralization processing statistics"""
try:
- service = NeutralizationService(currentUser)
+ service = NeutralizationPlayground(currentUser)
stats = service.get_processing_stats()
return stats
@@ -253,7 +253,7 @@ async def cleanup_file_attributes(
) -> Dict[str, str]:
"""Clean up neutralization attributes for a specific file"""
try:
- service = NeutralizationService(currentUser)
+ service = NeutralizationPlayground(currentUser)
success = service.cleanup_file_attributes(fileId)
if success:
diff --git a/modules/security/csrf.py b/modules/security/csrf.py
index 030ab665..f1c4aa5b 100644
--- a/modules/security/csrf.py
+++ b/modules/security/csrf.py
@@ -51,17 +51,19 @@ class CSRFMiddleware(BaseHTTPMiddleware):
csrf_token = request.headers.get("X-CSRF-Token")
if not csrf_token:
logger.warning(f"CSRF token missing for {request.method} {request.url.path}")
- raise HTTPException(
+ from fastapi.responses import JSONResponse
+ return JSONResponse(
status_code=status.HTTP_403_FORBIDDEN,
- detail="CSRF token missing"
+ content={"detail": "CSRF token missing"}
)
# Validate CSRF token format (basic validation)
if not self._is_valid_csrf_token(csrf_token):
logger.warning(f"Invalid CSRF token format for {request.method} {request.url.path}")
- raise HTTPException(
+ from fastapi.responses import JSONResponse
+ return JSONResponse(
status_code=status.HTTP_403_FORBIDDEN,
- detail="Invalid CSRF token format"
+ content={"detail": "Invalid CSRF token format"}
)
# Additional CSRF validation could be added here:
diff --git a/modules/services/__init__.py b/modules/services/__init__.py
index 842a2176..5725742f 100644
--- a/modules/services/__init__.py
+++ b/modules/services/__init__.py
@@ -39,60 +39,44 @@ class PublicService:
class Services:
- def __init__(self, user: User, workflow: ChatWorkflow):
+ def __init__(self, user: User, workflow: ChatWorkflow = None):
self.user: User = user
self.workflow: ChatWorkflow = workflow
- # Directly expose existing service modules
+ # Initialize interfaces
+
+ from modules.interfaces.interfaceChatObjects import getInterface as getChatInterface
+ self.interfaceChat = getChatInterface(user)
+
+ from modules.interfaces.interfaceAppObjects import getInterface as getAppInterface
+ self.interfaceApp = getAppInterface(user)
+
+ from modules.interfaces.interfaceComponentObjects import getInterface as getComponentInterface
+ self.interfaceComponent = getComponentInterface(user)
+
+ # Initialize service packages
from .serviceDocument.mainServiceDocumentExtraction import DocumentExtractionService
- self.document = PublicService(DocumentExtractionService(self))
+ self.documentExtraction = PublicService(DocumentExtractionService(self))
from .serviceDocument.mainServiceDocumentGeneration import DocumentGenerationService
- self.document = PublicService(DocumentGenerationService(self))
+ self.documentGeneration = PublicService(DocumentGenerationService(self))
- from .serviceNeutralization.mainNeutralization import NeutralizationService
- self.neutralization = PublicService(NeutralizationService())
+ from .serviceNeutralization.mainServiceNeutralization import NeutralizationService
+ self.neutralization = PublicService(NeutralizationService(self))
- from .serviceSharepoint.mainSharepoint import SharePointService
- self.sharepoint = PublicService(SharePointService(self))
+ from .serviceSharepoint.mainServiceSharepoint import SharepointService
+ self.sharepoint = PublicService(SharepointService(self))
from .serviceAi.mainServiceAi import AiService
self.ai = PublicService(AiService(self))
- from .serviceWorkflows.mainServiceWorkflows import WorkflowService
+ from .serviceTicket.mainServiceTicket import TicketService
+ self.ticket = PublicService(TicketService(self))
+
+ from .serviceWorkflow.mainServiceWorkflow import WorkflowService
self.workflow = PublicService(WorkflowService(self))
-
- # Initialize chat interface for workflow operations
- from modules.interfaces.interfaceChatObjects import getInterface as getChatInterface
- self.chatInterface = getChatInterface(user)
- # Chat interface wrapper methods
- def getWorkflow(self, workflowId: str):
- return self.chatInterface.getWorkflow(workflowId)
-
- def createWorkflow(self, workflowData: dict):
- return self.chatInterface.createWorkflow(workflowData)
-
- def updateWorkflow(self, workflowId: str, workflowData: dict):
- return self.chatInterface.updateWorkflow(workflowId, workflowData)
-
- def createMessage(self, messageData: dict):
- return self.chatInterface.createMessage(messageData)
-
- def updateMessage(self, messageId: str, messageData: dict):
- return self.chatInterface.updateMessage(messageId, messageData)
-
- def createLog(self, logData: dict):
- return self.chatInterface.createLog(logData)
-
- def updateWorkflowStats(self, workflowId: str, bytesSent: int = 0, bytesReceived: int = 0, tokenCount: int = 0):
- return self.chatInterface.updateWorkflowStats(workflowId, bytesSent, bytesReceived, tokenCount)
-
- @property
- def mandateId(self):
- return self.chatInterface.mandateId
-
def getInterface(user: User, workflow: ChatWorkflow) -> Services:
return Services(user, workflow)
diff --git a/modules/services/serviceAi/mainServiceAi.py b/modules/services/serviceAi/mainServiceAi.py
index c7458756..834e39a0 100644
--- a/modules/services/serviceAi/mainServiceAi.py
+++ b/modules/services/serviceAi/mainServiceAi.py
@@ -2,7 +2,7 @@ import logging
from typing import Dict, Any, List, Optional, Tuple
from modules.interfaces.interfaceChatModel import ChatDocument
-from modules.services.serviceDocument.documentExtraction import DocumentExtractionService
+from modules.services.serviceDocument.mainServiceDocumentExtraction import DocumentExtractionService
from modules.interfaces.interfaceAiModel import AiCallRequest, AiCallOptions
from modules.interfaces.interfaceAiObjects import AiObjects
@@ -19,9 +19,15 @@ class AiService:
The concrete connector instances (OpenAI/Anthropic) are injected by the interface layer.
"""
- def __init__(self, aiObjects: AiObjects | None = None) -> None:
+ def __init__(self, serviceCenter=None) -> None:
+ """Initialize AI service with service center access.
+
+ Args:
+ serviceCenter: Service center instance for accessing other services
+ """
+ self.serviceCenter = serviceCenter
# Only depend on interfaces
- self.aiObjects = aiObjects or AiObjects()
+ self.aiObjects = AiObjects()
self.documentExtractor = DocumentExtractionService()
async def callAi(
diff --git a/modules/services/serviceDocument/mainServiceDocumentExtraction.py b/modules/services/serviceDocument/mainServiceDocumentExtraction.py
index 41340f02..3f1c9ef9 100644
--- a/modules/services/serviceDocument/mainServiceDocumentExtraction.py
+++ b/modules/services/serviceDocument/mainServiceDocumentExtraction.py
@@ -22,7 +22,7 @@ from modules.interfaces.interfaceChatModel import (
ContentItem,
ContentMetadata
)
-from modules.services.serviceNeutralization.mainNeutralization import NeutralizationService
+from modules.services.serviceNeutralization.mainServiceNeutralization import NeutralizationService
from modules.shared.configuration import APP_CONFIG
logger = logging.getLogger(__name__)
@@ -43,9 +43,8 @@ class DocumentExtractionService:
"""Initialize the document processor."""
self._neutralizer = NeutralizationService() if APP_CONFIG.get("ENABLE_CONTENT_NEUTRALIZATION", False) else None
self._serviceCenter = serviceCenter
- # Centralized services interface (for AI)
- from modules.services import getInterface as getServices
- self.services = getServices(serviceCenter.user, serviceCenter.workflow)
+ # Store service center for access to user/workflow context when needed
+ self.services = None # Will be set to None to avoid circular dependency
self.supportedTypes: Dict[str, Callable[[bytes, str, str], Awaitable[List[ContentItem]]]] = {
# Text and data files
@@ -1427,17 +1426,21 @@ class DocumentExtractionService:
"""
from modules.interfaces.interfaceChatModel import ChatDocument
image_doc = ChatDocument(fileData=chunk, fileName="image", mimeType=mimeType)
- processedContent = await self.services.ai.callAi(
- prompt=imagePrompt,
- documents=[image_doc],
- options={
- "process_type": "image",
- "operation_type": "analyse_content",
- "priority": "balanced",
- "compress_documents": True,
- "max_cost": 0.03
- }
- )
+ # Use direct import to avoid circular dependency
+ from modules.services.serviceAi.mainServiceAi import AiService
+ from modules.interfaces.interfaceAiObjects import AiObjects
+ aiService = AiService(AiObjects())
+ processedContent = await aiService.callAi(
+ prompt=imagePrompt,
+ documents=[image_doc],
+ options={
+ "process_type": "image",
+ "operation_type": "analyse_content",
+ "priority": "balanced",
+ "compress_documents": True,
+ "max_cost": 0.03
+ }
+ )
else:
# For text content (including SVG), use text AI service
# Neutralize content if neutralizer is enabled (only for text)
@@ -1462,36 +1465,23 @@ class DocumentExtractionService:
# For code files, preserve the complete content without AI processing
processedContent = contentToProcess
else:
- if self.services and hasattr(self.services, 'ai'):
- processedContent = await self.services.ai.callAi(
- prompt=aiPrompt,
- documents=None,
- options={
- "process_type": "text",
- "operation_type": "analyse_content",
- "priority": "balanced",
- "compress_prompt": True,
- "compress_documents": False,
- "processing_mode": "advanced",
- "max_cost": 0.05,
- "max_processing_time": 30
- }
- )
- else:
- # Fallback to basic AI processing with centralized service
- processedContent = await self.services.ai.callAi(
- prompt=aiPrompt,
- documents=None,
- options={
- "process_type": "text",
- "operation_type": "analyse_content",
- "priority": "speed",
- "compress_prompt": True,
- "compress_documents": False,
- "max_cost": 0.01,
- "max_processing_time": 15
- }
- )
+ # Use direct import to avoid circular dependency
+ from modules.services.serviceAi.mainServiceAi import AiService
+ from modules.interfaces.interfaceAiObjects import AiObjects
+ aiService = AiService(AiObjects())
+ processedContent = await aiService.callAi(
+ prompt=aiPrompt,
+ documents=None,
+ options={
+ "process_type": "text",
+ "operation_type": "analyse_content",
+ "priority": "speed",
+ "compress_prompt": True,
+ "compress_documents": False,
+ "max_cost": 0.01,
+ "max_processing_time": 15
+ }
+ )
chunkResults.append(processedContent)
except Exception as aiError:
diff --git a/modules/services/serviceNeutralization/mainNeutralization.py b/modules/services/serviceNeutralization/mainServiceNeutralization.py
similarity index 79%
rename from modules/services/serviceNeutralization/mainNeutralization.py
rename to modules/services/serviceNeutralization/mainServiceNeutralization.py
index fbd8d122..d3f7ea20 100644
--- a/modules/services/serviceNeutralization/mainNeutralization.py
+++ b/modules/services/serviceNeutralization/mainServiceNeutralization.py
@@ -8,59 +8,51 @@ Mehrsprachig: DE, EN, FR, IT
import logging
import re
-import os
-import uuid
import json
-from typing import Dict, List, Any, Optional, Tuple
-from datetime import datetime
-from pathlib import Path
-import mimetypes
+from typing import Dict, List, Any, Optional
-from modules.interfaces.interfaceAppObjects import getInterface
-from modules.interfaces.interfaceAppModel import User, DataNeutraliserConfig, DataNeutralizerAttributes
-from modules.shared.timezoneUtils import get_utc_timestamp
+from modules.interfaces.interfaceAppModel import DataNeutraliserConfig, DataNeutralizerAttributes
# Import all necessary classes and functions for neutralization
-from modules.services.serviceNeutralization.subProcessCommon import ProcessResult, CommonUtils, NeutralizationResult, NeutralizationAttribute
+from modules.services.serviceNeutralization.subProcessCommon import CommonUtils, NeutralizationResult, NeutralizationAttribute
from modules.services.serviceNeutralization.subProcessText import TextProcessor, PlainText
from modules.services.serviceNeutralization.subProcessList import ListProcessor, TableData
-from modules.services.serviceNeutralization.subProcessBinary import BinaryProcessor, BinaryData
-from modules.services.serviceNeutralization.subParseString import StringParser
-from modules.services.serviceNeutralization.subPatterns import Pattern, HeaderPatterns, DataPatterns, TextTablePatterns
+from modules.services.serviceNeutralization.subProcessBinary import BinaryProcessor
+from modules.services.serviceNeutralization.subPatterns import HeaderPatterns, DataPatterns, TextTablePatterns
logger = logging.getLogger(__name__)
class NeutralizationService:
"""Service for handling data neutralization operations"""
- def __init__(self, current_user: User = None, names_to_parse: List[str] = None):
+ def __init__(self, serviceCenter=None, NamesToParse: List[str] = None):
"""Initialize the service with user context and anonymization processors
Args:
- current_user: User object for context (optional for basic neutralization)
- names_to_parse: List of names to parse and replace (case-insensitive)
+ serviceCenter: Service center instance for accessing other services
+ NamesToParse: List of names to parse and replace (case-insensitive)
"""
- self.current_user = current_user
- self.app_interface = getInterface(current_user) if current_user else None
+ self.serviceCenter = serviceCenter
+ self.interfaceApp = serviceCenter.interfaceApp
# Initialize anonymization processors
- self.names_to_parse = names_to_parse or []
- self.textProcessor = TextProcessor(names_to_parse)
- self.listProcessor = ListProcessor(names_to_parse)
+ self.NamesToParse = NamesToParse or []
+ self.textProcessor = TextProcessor(NamesToParse)
+ self.listProcessor = ListProcessor(NamesToParse)
self.binaryProcessor = BinaryProcessor()
self.commonUtils = CommonUtils()
def getConfig(self) -> Optional[DataNeutraliserConfig]:
"""Get the neutralization configuration for the current user's mandate"""
- if not self.app_interface:
+ if not self.interfaceApp:
return None
- return self.app_interface.getNeutralizationConfig()
+ return self.interfaceApp.getNeutralizationConfig()
def saveConfig(self, config_data: Dict[str, Any]) -> DataNeutraliserConfig:
"""Save or update the neutralization configuration"""
- if not self.app_interface:
+ if not self.interfaceApp:
raise ValueError("User context required for saving configuration")
- return self.app_interface.createOrUpdateNeutralizationConfig(config_data)
+ return self.interfaceApp.createOrUpdateNeutralizationConfig(config_data)
# Public API: process text or file
@@ -70,18 +62,18 @@ class NeutralizationService:
def processFile(self, fileId: str) -> Dict[str, Any]:
"""Neutralize a file referenced by its fileId using app interface."""
- if not self.app_interface:
+ if not self.interfaceApp:
raise ValueError("User context is required to process a file by fileId")
# Fetch file data and metadata
fileInfo = None
try:
# getFile returns an object; fallback to dict-like
- fileInfo = self.app_interface.getFile(fileId)
+ fileInfo = self.interfaceApp.getFile(fileId)
except Exception:
fileInfo = None
fileName = getattr(fileInfo, 'fileName', None) if fileInfo else None
mimeType = getattr(fileInfo, 'mimeType', None) if fileInfo else None
- fileData = self.app_interface.getFileData(fileId)
+ fileData = self.interfaceApp.getFileData(fileId)
if not fileData:
raise ValueError(f"No file data found for fileId: {fileId}")
@@ -111,17 +103,17 @@ class NeutralizationService:
return result
def resolveText(self, text: str) -> str:
- if not self.app_interface:
+ if not self.interfaceApp:
return text
try:
placeholder_pattern = r'\[([a-z]+)\.([a-f0-9-]{36})\]'
matches = re.findall(placeholder_pattern, text)
resolved_text = text
for placeholder_type, uid in matches:
- attributes = self.app_interface.db.getRecordset(
+ attributes = self.interfaceApp.db.getRecordset(
DataNeutralizerAttributes,
recordFilter={
- "mandateId": self.app_interface.mandateId,
+ "mandateId": self.interfaceApp.mandateId,
"id": uid
}
)
@@ -194,6 +186,19 @@ class NeutralizationService:
processed_info={'type': 'error', 'error': str(e)}
).model_dump()
+ def _getAttributes(self) -> List[DataNeutralizerAttributes]:
+ """Get all neutralization attributes for the current user's mandate"""
+ if not self.interfaceApp:
+ return []
+ try:
+ return self.interfaceApp.db.getRecordset(
+ DataNeutralizerAttributes,
+ recordFilter={"mandateId": self.interfaceApp.mandateId}
+ )
+ except Exception as e:
+ logger.error(f"Error getting neutralization attributes: {str(e)}")
+ return []
+
def _getContentTypeFromMime(self, mime_type: str) -> str:
"""Determine content type from MIME type for neutralization processing"""
if mime_type.startswith('text/'):
diff --git a/modules/services/serviceNeutralization/subParseString.py b/modules/services/serviceNeutralization/subParseString.py
index fd9f54cc..5c92e110 100644
--- a/modules/services/serviceNeutralization/subParseString.py
+++ b/modules/services/serviceNeutralization/subParseString.py
@@ -11,15 +11,15 @@ from modules.services.serviceNeutralization.subPatterns import DataPatterns, fin
class StringParser:
"""Handles string parsing and replacement operations"""
- def __init__(self, names_to_parse: List[str] = None):
+ def __init__(self, NamesToParse: List[str] = None):
"""
Initialize the string parser
Args:
- names_to_parse: List of names to parse and replace (case-insensitive)
+ NamesToParse: List of names to parse and replace (case-insensitive)
"""
self.data_patterns = DataPatterns.patterns
- self.names_to_parse = names_to_parse or []
+ self.NamesToParse = NamesToParse or []
self.mapping = {}
def is_placeholder(self, text: str) -> bool:
@@ -84,7 +84,7 @@ class StringParser:
Returns:
str: Text with custom names replaced
"""
- for name in self.names_to_parse:
+ for name in self.NamesToParse:
if not name.strip():
continue
diff --git a/modules/services/serviceNeutralization/subProcessList.py b/modules/services/serviceNeutralization/subProcessList.py
index e4ac91f7..3d5e7900 100644
--- a/modules/services/serviceNeutralization/subProcessList.py
+++ b/modules/services/serviceNeutralization/subProcessList.py
@@ -22,14 +22,14 @@ class TableData:
class ListProcessor:
"""Handles structured data processing with headers for anonymization"""
- def __init__(self, names_to_parse: List[str] = None):
+ def __init__(self, NamesToParse: List[str] = None):
"""
Initialize the list processor
Args:
- names_to_parse: List of names to parse and replace
+ NamesToParse: List of names to parse and replace
"""
- self.string_parser = StringParser(names_to_parse)
+ self.string_parser = StringParser(NamesToParse)
self.header_patterns = HeaderPatterns.patterns
def anonymize_table(self, table: TableData) -> TableData:
@@ -215,7 +215,7 @@ class ListProcessor:
text = self.string_parser.mapping[text]
else:
# Check if text matches any custom names from the user list
- for name in self.string_parser.names_to_parse:
+ for name in self.string_parser.NamesToParse:
if not name.strip():
continue
if text.lower().strip() == name.lower().strip():
diff --git a/modules/services/serviceNeutralization/subProcessText.py b/modules/services/serviceNeutralization/subProcessText.py
index 7b1a7372..98d8cbde 100644
--- a/modules/services/serviceNeutralization/subProcessText.py
+++ b/modules/services/serviceNeutralization/subProcessText.py
@@ -16,14 +16,14 @@ class PlainText:
class TextProcessor:
"""Handles plain text processing for anonymization"""
- def __init__(self, names_to_parse: List[str] = None):
+ def __init__(self, NamesToParse: List[str] = None):
"""
Initialize the text processor
Args:
- names_to_parse: List of names to parse and replace
+ NamesToParse: List of names to parse and replace
"""
- self.string_parser = StringParser(names_to_parse)
+ self.string_parser = StringParser(NamesToParse)
def extract_tables_from_text(self, content: str) -> tuple:
"""
diff --git a/modules/services/serviceSharepoint/mainSharepoint.py b/modules/services/serviceSharepoint/mainServiceSharepoint.py
similarity index 89%
rename from modules/services/serviceSharepoint/mainSharepoint.py
rename to modules/services/serviceSharepoint/mainServiceSharepoint.py
index 26a68e88..7a8bb084 100644
--- a/modules/services/serviceSharepoint/mainSharepoint.py
+++ b/modules/services/serviceSharepoint/mainServiceSharepoint.py
@@ -13,18 +13,55 @@ logger = logging.getLogger(__name__)
class SharepointService:
"""SharePoint connector using Microsoft Graph API for reliable authentication."""
- def __init__(self, access_token: str):
- """Initialize with access token.
+ def __init__(self, serviceCenter=None):
+ """Initialize SharePoint service without access token.
Args:
- access_token: Microsoft Graph access token
+ serviceCenter: Service center instance for accessing other services
+
+ Use setAccessToken() method to configure the access token before making API calls.
"""
- self.access_token = access_token
+ self.serviceCenter = serviceCenter
+ self.access_token = None
self.base_url = "https://graph.microsoft.com/v1.0"
+ def setAccessToken(self, userConnection, interfaceApp) -> bool:
+ """Set access token from UserConnection.
+
+ Args:
+ userConnection: UserConnection object containing token information
+ interfaceApp: InterfaceApp instance used by TokenManager to resolve the token
+
+ Returns:
+ bool: True if token was set successfully, False otherwise
+ """
+ try:
+ if not userConnection:
+ logger.error("UserConnection is required to set access token")
+ return False
+
+ # Get a fresh token for this specific connection
+ from modules.security.tokenManager import TokenManager
+ token = TokenManager().getFreshToken(interfaceApp, userConnection.id)
+ if not token:
+ logger.error(f"No token found for connection {userConnection.id}")
+ return False
+
+ self.access_token = token.tokenAccess
+ logger.info(f"Access token set for connection {userConnection.id}")
+ return True
+
+ except Exception as e:
+ logger.error(f"Error setting access token: {str(e)}")
+ return False
+
async def _make_graph_api_call(self, endpoint: str, method: str = "GET", data: bytes = None) -> Dict[str, Any]:
"""Make a Microsoft Graph API call with proper error handling."""
try:
+ if self.access_token is None:
+ logger.error("Access token is not set. Please call setAccessToken() before using the SharePoint service.")
+ return {"error": "Access token is not set. Please call setAccessToken() before using the SharePoint service."}
+
headers = {
"Authorization": f"Bearer {self.access_token}",
"Content-Type": "application/json" if data and method != "PUT" else "application/octet-stream" if data else "application/json"
@@ -280,6 +317,10 @@ class SharepointService:
async def download_file(self, site_id: str, file_id: str) -> Optional[bytes]:
"""Download a file from SharePoint."""
try:
+ if self.access_token is None:
+ logger.error("Access token is not set. Please call setAccessToken() before using the SharePoint service.")
+ return None
+
endpoint = f"sites/{site_id}/drive/items/{file_id}/content"
headers = {"Authorization": f"Bearer {self.access_token}"}
@@ -416,6 +457,10 @@ class SharepointService:
async def download_file_by_path(self, site_id: str, file_path: str) -> Optional[bytes]:
"""Download a file by its path within a site."""
try:
+ if self.access_token is None:
+ logger.error("Access token is not set. Please call setAccessToken() before using the SharePoint service.")
+ return None
+
# Clean the path
clean_path = file_path.strip('/')
endpoint = f"sites/{site_id}/drive/root:/{clean_path}:/content"
diff --git a/modules/services/serviceTicket/mainServiceTicket.py b/modules/services/serviceTicket/mainServiceTicket.py
new file mode 100644
index 00000000..5018196d
--- /dev/null
+++ b/modules/services/serviceTicket/mainServiceTicket.py
@@ -0,0 +1,41 @@
+"""Ticket service for creating ticket interfaces."""
+
+import logging
+from typing import Dict, Any, Optional
+from modules.interfaces.interfaceTicketObjects import createTicketInterfaceByType
+
+logger = logging.getLogger(__name__)
+
+
+class TicketService:
+ """Service class for ticket interface operations."""
+
+ def __init__(self, serviceCenter=None):
+ """Initialize ticket service with service center access.
+
+ Args:
+ serviceCenter: Service center instance for accessing other services
+ """
+ self.serviceCenter = serviceCenter
+
+ async def createTicketInterfaceByType(
+ self,
+ taskSyncDefinition: Dict[str, Any],
+ connectorType: str,
+ connectorParams: Optional[Dict[str, Any]] = None
+ ):
+ """Create a ticket interface by type with the given parameters.
+
+ Args:
+ taskSyncDefinition: Field mapping definition for ticket synchronization
+ connectorType: Type of connector (e.g., "Jira", "ServiceNow")
+ connectorParams: Optional parameters for the connector
+
+ Returns:
+ Ticket interface instance
+ """
+ return await createTicketInterfaceByType(
+ taskSyncDefinition=taskSyncDefinition,
+ connectorType=connectorType,
+ connectorParams=connectorParams
+ )
diff --git a/modules/services/serviceWorkflows/mainServiceWorkflows.py b/modules/services/serviceWorkflow/mainServiceWorkflow.py
similarity index 90%
rename from modules/services/serviceWorkflows/mainServiceWorkflows.py
rename to modules/services/serviceWorkflow/mainServiceWorkflow.py
index 08ff2e8f..a9440993 100644
--- a/modules/services/serviceWorkflows/mainServiceWorkflows.py
+++ b/modules/services/serviceWorkflow/mainServiceWorkflow.py
@@ -3,7 +3,7 @@ import uuid
from typing import Dict, Any, List, Optional
from modules.interfaces.interfaceAppModel import User, UserConnection
from modules.interfaces.interfaceChatModel import ChatDocument, ChatMessage, ExtractedContent
-from modules.services.serviceDocument.documentExtraction import DocumentExtractionService
+from modules.services.serviceDocument.mainServiceDocumentExtraction import DocumentExtractionService
from modules.services.serviceDocument.documentUtility import getFileExtension, getMimeTypeFromExtension, detectContentTypeFromData
from modules.shared.timezoneUtils import get_utc_timestamp
@@ -12,17 +12,13 @@ logger = logging.getLogger(__name__)
class WorkflowService:
"""Service class containing methods for document processing, chat operations, and workflow management"""
- def __init__(self, service_center):
- self.service_center = service_center
- self.user = service_center.user
- self.workflow = service_center.workflow
- self.interfaceChat = service_center.interfaceChat
- self.interfaceComponent = service_center.interfaceComponent
- self.interfaceApp = service_center.interfaceApp
- self.documentProcessor = service_center.documentProcessor
- # Centralized services interface (for AI)
- from modules.services import getInterface as getServices
- self.services = getServices(self.user, self.workflow)
+ def __init__(self, serviceCenter):
+ self.serviceCenter = serviceCenter
+ self.user = serviceCenter.user
+ self.workflow = serviceCenter.workflow
+ self.interfaceChat = serviceCenter.interfaceChat
+ self.interfaceComponent = serviceCenter.interfaceComponent
+ self.interfaceApp = serviceCenter.interfaceApp
async def summarizeChat(self, messages: List[ChatMessage]) -> str:
"""
@@ -57,8 +53,10 @@ class WorkflowService:
Please provide a comprehensive summary of this conversation."""
- # Get summary using centralized AI (speed priority)
- return await self.services.ai.callAi(
+ # Get summary using AI service directly (avoiding circular dependency)
+ from modules.services.serviceAi.mainServiceAi import AiService
+ ai_service = AiService(self)
+ return await ai_service.callAi(
prompt=prompt,
documents=None,
options={
@@ -251,6 +249,22 @@ class WorkflowService:
logger.debug(f"getConnectionReferenceFromUserConnection: Built reference: {base_ref + state_info}")
return base_ref + state_info
+
+ def getUserConnectionByExternalUsername(self, authority: str, externalUsername: str) -> Optional[UserConnection]:
+ """Fetch the user's connection by authority and external username."""
+ try:
+ if not authority or not externalUsername:
+ return None
+ user_connections = self.interfaceApp.getUserConnections(self.user.id)
+ for connection in user_connections:
+ # Normalize authority for comparison (enum vs string)
+ connection_authority = connection.authority.value if hasattr(connection.authority, 'value') else str(connection.authority)
+ if connection_authority.lower() == authority.lower() and connection.externalUsername == externalUsername:
+ return connection
+ return None
+ except Exception as e:
+ logger.error(f"Error getting connection by external username: {str(e)}")
+ return None
def getUserConnectionFromConnectionReference(self, connectionReference: str) -> Optional[UserConnection]:
"""Get UserConnection from reference string (handles both old and enhanced formats)"""
@@ -334,14 +348,34 @@ class WorkflowService:
# Recovery failed - don't continue with invalid data
raise RuntimeError(f"Document {document.id} properties are inaccessible and recovery failed. Diagnosis: {diagnosis}")
- # Process with document processor directly
- extractedContent = await self.documentProcessor.processFileData(
+ # Process with DocumentExtractionService directly (no circular dependency)
+ from modules.services.serviceDocument.mainServiceDocumentExtraction import DocumentExtractionService
+ docService = DocumentExtractionService(None) # Pass None to avoid circular dependency
+ content_items = await docService.processFileData(
fileData=fileData,
fileName=fileName,
mimeType=mimeType,
base64Encoded=False,
prompt=prompt,
- documentId=document.id
+ enableAI=True
+ )
+
+ # Convert ContentItem list to ExtractedContent
+ contents = []
+ for item in content_items:
+ contents.append({
+ 'label': item.label,
+ 'data': item.data,
+ 'metadata': {
+ 'mimeType': item.metadata.mimeType if hasattr(item.metadata, 'mimeType') else mimeType,
+ 'size': item.metadata.size if hasattr(item.metadata, 'size') else len(fileData),
+ 'base64Encoded': item.metadata.base64Encoded if hasattr(item.metadata, 'base64Encoded') else False
+ }
+ })
+
+ extractedContent = ExtractedContent(
+ id=document.id,
+ contents=contents
)
# Note: ExtractedContent model only has 'id' and 'contents' fields
diff --git a/modules/workflows/methods/methodSharepoint.py b/modules/workflows/methods/methodSharepoint.py
index accb268d..35999e17 100644
--- a/modules/workflows/methods/methodSharepoint.py
+++ b/modules/workflows/methods/methodSharepoint.py
@@ -32,7 +32,7 @@ class MethodSharepoint(MethodBase):
return datetime.now(UTC).strftime("%Y%m%d-%H%M%S")
def _getMicrosoftConnection(self, connectionReference: str) -> Optional[Dict[str, Any]]:
- """Get Microsoft connection from connection reference"""
+ """Get Microsoft connection from connection reference and configure SharePoint service"""
try:
userConnection = self.service.getUserConnectionFromConnectionReference(connectionReference)
if not userConnection:
@@ -48,47 +48,33 @@ class MethodSharepoint(MethodBase):
logger.warning(f"Connection {userConnection.id} status is not active/pending: {userConnection.status.value}")
return None
- # Get a fresh token for this specific connection
- from modules.security.tokenManager import TokenManager
- token = TokenManager().getFreshToken(self.service.interfaceApp, userConnection.id)
- if not token:
- logger.warning(f"No token found for connection {userConnection.id}")
+ # Configure SharePoint service with the UserConnection
+ if not self.service.sharepoint.setAccessToken(userConnection, self.service.interfaceApp):
+ logger.warning(f"Failed to configure SharePoint service with connection {userConnection.id}")
return None
- # Check if token is expired
- if hasattr(token, 'expiresAt') and token.expiresAt:
- current_time = get_utc_timestamp()
- if current_time > token.expiresAt:
- logger.warning(f"Token for connection {userConnection.id} is expired (expiresAt: {token.expiresAt}, current: {current_time})")
- return None
-
- logger.info(f"Successfully retrieved Microsoft connection: {userConnection.id}, status: {userConnection.status.value}, externalId: {userConnection.externalId}")
+ logger.info(f"Successfully configured SharePoint service with Microsoft connection: {userConnection.id}, status: {userConnection.status.value}, externalId: {userConnection.externalId}")
return {
"id": userConnection.id,
"userConnection": userConnection,
- "accessToken": token.tokenAccess,
- "refreshToken": token.tokenRefresh,
"scopes": ["Sites.ReadWrite.All", "Files.ReadWrite.All", "User.Read"] # SharePoint scopes
}
except Exception as e:
logger.error(f"Error getting Microsoft connection: {str(e)}")
return None
- async def _discoverSharePointSites(self, access_token: str) -> List[Dict[str, Any]]:
+ async def _discoverSharePointSites(self) -> List[Dict[str, Any]]:
"""
Discover all SharePoint sites accessible to the user via Microsoft Graph API
- Parameters:
- access_token (str): Microsoft Graph access token
-
Returns:
List[Dict[str, Any]]: List of SharePoint site information
"""
try:
# Query Microsoft Graph to get all sites the user has access to
endpoint = "sites?search=*"
- result = await self._makeGraphApiCall(access_token, endpoint)
+ result = await self._makeGraphApiCall(endpoint)
if "error" in result:
logger.error(f"Error discovering SharePoint sites: {result['error']}")
@@ -375,11 +361,14 @@ class MethodSharepoint(MethodBase):
logger.error(f"Error parsing site URL {siteUrl}: {str(e)}")
return {"hostname": "", "sitePath": ""}
- async def _makeGraphApiCall(self, access_token: str, endpoint: str, method: str = "GET", data: bytes = None) -> Dict[str, Any]:
+ async def _makeGraphApiCall(self, endpoint: str, method: str = "GET", data: bytes = None) -> Dict[str, Any]:
"""Make a Microsoft Graph API call with timeout and detailed logging"""
try:
+ if not hasattr(self.service, 'sharepoint') or not self.service.sharepoint._target.access_token:
+ return {"error": "SharePoint service not configured with access token"}
+
headers = {
- "Authorization": f"Bearer {access_token}",
+ "Authorization": f"Bearer {self.service.sharepoint._target.access_token}",
"Content-Type": "application/json" if data and method != "PUT" else "application/octet-stream" if data else "application/json"
}
@@ -436,11 +425,11 @@ class MethodSharepoint(MethodBase):
logger.error(f"Error making Graph API call: {str(e)}")
return {"error": f"Error making Graph API call: {str(e)}"}
- async def _getSiteId(self, access_token: str, hostname: str, site_path: str) -> str:
+ async def _getSiteId(self, hostname: str, site_path: str) -> str:
"""Get SharePoint site ID from hostname and site path"""
try:
endpoint = f"sites/{hostname}:/{site_path}"
- result = await self._makeGraphApiCall(access_token, endpoint)
+ result = await self._makeGraphApiCall(endpoint)
if "error" in result:
logger.error(f"Error getting site ID: {result['error']}")
@@ -482,7 +471,7 @@ class MethodSharepoint(MethodBase):
# Discover SharePoint sites - use targeted approach when site parameter is provided
if site:
# When site parameter is provided, discover all sites first, then filter
- all_sites = await self._discoverSharePointSites(connection["accessToken"])
+ all_sites = await self._discoverSharePointSites()
if not all_sites:
return ActionResult.isFailure(error="No SharePoint sites found or accessible")
@@ -492,7 +481,7 @@ class MethodSharepoint(MethodBase):
return ActionResult.isFailure(error=f"No SharePoint sites found matching '{site}'")
else:
# No site parameter - discover all sites
- sites = await self._discoverSharePointSites(connection["accessToken"])
+ sites = await self._discoverSharePointSites()
if not sites:
return ActionResult.isFailure(error="No SharePoint sites found or accessible")
@@ -535,7 +524,6 @@ class MethodSharepoint(MethodBase):
# Use global search endpoint (site-specific search not available)
unified_result = await self._makeGraphApiCall(
- connection["accessToken"],
"search/query",
method="POST",
data=json.dumps(payload).encode("utf-8")
@@ -707,7 +695,7 @@ class MethodSharepoint(MethodBase):
logger.info(f"Using search API for files with query: '{search_query}'")
# Make the search API call (files)
- search_result = await self._makeGraphApiCall(connection["accessToken"], endpoint)
+ search_result = await self._makeGraphApiCall(endpoint)
if "error" in search_result:
logger.warning(f"Search failed for site {site_name}: {search_result['error']}")
continue
@@ -942,7 +930,7 @@ class MethodSharepoint(MethodBase):
return ActionResult.isFailure(error=f"Invalid pathQuery '{pathQuery}'. This appears to be search terms, not a valid SharePoint path. Use findDocumentPath action first to search for folders, then use the returned folder path as pathQuery.")
# For pathQuery, we need to discover sites to find the specific one
- sites = await self._discoverSharePointSites(connection["accessToken"])
+ sites = await self._discoverSharePointSites()
if not sites:
return ActionResult.isFailure(error="No SharePoint sites found or accessible")
else:
@@ -975,7 +963,7 @@ class MethodSharepoint(MethodBase):
search_query = fileName.replace("'", "''") # Escape single quotes for OData
endpoint = f"sites/{site_id}/drive/root/search(q='{search_query}')"
- search_result = await self._makeGraphApiCall(connection["accessToken"], endpoint)
+ search_result = await self._makeGraphApiCall(endpoint)
if "error" in search_result:
continue
@@ -988,7 +976,7 @@ class MethodSharepoint(MethodBase):
file_endpoint = f"sites/{site_id}/drive/items/{file_id}"
# Get file metadata
- file_info_result = await self._makeGraphApiCall(connection["accessToken"], file_endpoint)
+ file_info_result = await self._makeGraphApiCall(file_endpoint)
if "error" in file_info_result:
continue
@@ -1027,7 +1015,7 @@ class MethodSharepoint(MethodBase):
# For content download, we need to handle binary data
try:
async with aiohttp.ClientSession() as session:
- headers = {"Authorization": f"Bearer {connection['accessToken']}"}
+ headers = {"Authorization": f"Bearer {self.service.sharepoint._target.access_token}"}
async with session.get(f"https://graph.microsoft.com/v1.0/{content_endpoint}", headers=headers) as response:
if response.status == 200:
content = await response.text()
@@ -1280,7 +1268,7 @@ class MethodSharepoint(MethodBase):
return ActionResult.isFailure(error=f"Invalid pathQuery '{upload_path}'. This appears to be search terms, not a valid SharePoint path. Use findDocumentPath action first to search for folders, then use the returned folder path as pathQuery.")
# For pathQuery, we need to discover sites to find the specific one
- sites = await self._discoverSharePointSites(connection["accessToken"])
+ sites = await self._discoverSharePointSites()
if not sites:
return ActionResult.isFailure(error="No SharePoint sites found or accessible")
@@ -1368,7 +1356,6 @@ class MethodSharepoint(MethodBase):
# Upload the file
upload_result = await self._makeGraphApiCall(
- connection["accessToken"],
upload_endpoint,
method="PUT",
data=file_data
@@ -1633,7 +1620,7 @@ class MethodSharepoint(MethodBase):
return ActionResult.isFailure(error=f"Invalid pathQuery '{pathQuery}'. This appears to be search terms, not a valid SharePoint path. Use findDocumentPath action first to search for folders, then use the returned folder path as pathQuery.")
# For pathQuery, we need to discover sites to find the specific one
- sites = await self._discoverSharePointSites(connection["accessToken"])
+ sites = await self._discoverSharePointSites()
if not sites:
return ActionResult.isFailure(error="No SharePoint sites found or accessible")
else:
@@ -1680,7 +1667,7 @@ class MethodSharepoint(MethodBase):
endpoint = f"sites/{site_id}/drive/root:/{folder_path_clean}:/children"
# Make the API call to list folder contents
- api_result = await self._makeGraphApiCall(connection["accessToken"], endpoint)
+ api_result = await self._makeGraphApiCall(endpoint)
if "error" in api_result:
logger.warning(f"Failed to list folder {folderPath} in site {site_name}: {api_result['error']}")
@@ -1745,7 +1732,7 @@ class MethodSharepoint(MethodBase):
subfolder_endpoint = f"sites/{site_id}/drive/items/{item['id']}/children"
logger.debug(f"Getting contents of subfolder: {item['name']}")
- subfolder_result = await self._makeGraphApiCall(connection["accessToken"], subfolder_endpoint)
+ subfolder_result = await self._makeGraphApiCall(subfolder_endpoint)
if "error" not in subfolder_result:
subfolder_items = subfolder_result.get("value", [])
logger.debug(f"Found {len(subfolder_items)} items in subfolder {item['name']}")
diff --git a/modules/workflows/processing/promptFactory.py b/modules/workflows/processing/promptFactory.py
index 68deb87e..1df21183 100644
--- a/modules/workflows/processing/promptFactory.py
+++ b/modules/workflows/processing/promptFactory.py
@@ -17,7 +17,7 @@ logger = logging.getLogger(__name__)
# Global methods catalog - moved from serviceCenter
methods = {}
-def _discoverMethods(service_center):
+def _discoverMethods(serviceCenter):
"""Dynamically discover all method classes and their actions in modules methods package"""
try:
# Import the methods package
@@ -36,7 +36,7 @@ def _discoverMethods(service_center):
issubclass(item, MethodBase) and
item != MethodBase):
# Instantiate the method
- methodInstance = item(service_center)
+ methodInstance = item(serviceCenter)
# Discover actions from public methods
actions = {}
@@ -83,11 +83,11 @@ def _discoverMethods(service_center):
except Exception as e:
logger.error(f"Error discovering methods: {str(e)}")
-def getMethodsList(service_center) -> List[str]:
+def getMethodsList(serviceCenter) -> List[str]:
"""Get list of available methods with their signatures in the required format"""
# Initialize methods if not already done
if not methods:
- _discoverMethods(service_center)
+ _discoverMethods(serviceCenter)
methodList = []
for methodName, method in methods.items():
@@ -99,10 +99,10 @@ def getMethodsList(service_center) -> List[str]:
methodList.append(signature)
return methodList
-def getEnhancedDocumentContext(service_center) -> str:
+def getEnhancedDocumentContext(serviceCenter) -> str:
"""Get enhanced document context formatted for action planning prompts with proper docList and docItem references"""
try:
- document_list = service_center.getDocumentReferenceList()
+ document_list = serviceCenter.getDocumentReferenceList()
# Build technical context string for AI action planning
context = "AVAILABLE DOCUMENTS:\n\n"
@@ -114,7 +114,7 @@ def getEnhancedDocumentContext(service_center) -> str:
# Generate docList reference for the exchange (using message ID and label)
# Find the message that corresponds to this exchange
message_id = None
- for message in service_center.workflow.messages:
+ for message in serviceCenter.workflow.messages:
if hasattr(message, 'documentsLabel') and message.documentsLabel == exchange.documentsLabel:
message_id = message.id
break
@@ -143,7 +143,7 @@ def getEnhancedDocumentContext(service_center) -> str:
# Generate docList reference for the exchange (using message ID and label)
# Find the message that corresponds to this exchange
message_id = None
- for message in service_center.workflow.messages:
+ for message in serviceCenter.workflow.messages:
if hasattr(message, 'documentsLabel') and message.documentsLabel == exchange.documentsLabel:
message_id = message.id
break
diff --git a/modules/workflows/workflowManager.py b/modules/workflows/workflowManager.py
index bebaa7f8..597725d7 100644
--- a/modules/workflows/workflowManager.py
+++ b/modules/workflows/workflowManager.py
@@ -32,7 +32,7 @@ class WorkflowManager:
currentTime = get_utc_timestamp()
if workflowId:
- workflow = self.services.getWorkflow(workflowId)
+ workflow = self.services.workflow.getWorkflow(workflowId)
if not workflow:
raise ValueError(f"Workflow {workflowId} not found")
@@ -43,11 +43,11 @@ class WorkflowManager:
logger.info(f"Stopping running workflow {workflowId} before processing new prompt")
workflow.status = "stopped"
workflow.lastActivity = currentTime
- self.services.updateWorkflow(workflowId, {
+ self.services.workflow.updateWorkflow(workflowId, {
"status": "stopped",
"lastActivity": currentTime
})
- self.services.createLog({
+ self.services.workflow.createLog({
"workflowId": workflowId,
"message": "Workflow stopped for new prompt",
"type": "info",
@@ -57,17 +57,17 @@ class WorkflowManager:
await asyncio.sleep(0.1)
newRound = workflow.currentRound + 1
- self.services.updateWorkflow(workflowId, {
+ self.services.workflow.updateWorkflow(workflowId, {
"status": "running",
"lastActivity": currentTime,
"currentRound": newRound
})
- workflow = self.services.getWorkflow(workflowId)
+ workflow = self.services.workflow.getWorkflow(workflowId)
if not workflow:
raise ValueError(f"Failed to reload workflow {workflowId} after update")
- self.services.createLog({
+ self.services.workflow.createLog({
"workflowId": workflowId,
"message": f"Workflow resumed (round {workflow.currentRound})",
"type": "info",
@@ -85,7 +85,7 @@ class WorkflowManager:
"currentAction": 0,
"totalTasks": 0,
"totalActions": 0,
- "mandateId": self.services.mandateId,
+ "mandateId": self.services.user.mandateId,
"messageIds": [],
"workflowMode": workflowMode,
"maxSteps": 5 if workflowMode == "React" else 1, # Set maxSteps for React mode
@@ -99,12 +99,12 @@ class WorkflowManager:
}
}
- workflow = self.services.createWorkflow(workflowData)
+ workflow = self.services.workflow.createWorkflow(workflowData)
logger.info(f"Created workflow with mode: {getattr(workflow, 'workflowMode', 'NOT_SET')}")
logger.info(f"Workflow data passed: {workflowData.get('workflowMode', 'NOT_IN_DATA')}")
workflow.currentRound = 1
- self.services.updateWorkflow(workflow.id, {"currentRound": 1})
- self.services.updateWorkflowStats(workflow.id, bytesSent=0, bytesReceived=0)
+ self.services.workflow.updateWorkflow(workflow.id, {"currentRound": 1})
+ self.services.workflow.updateWorkflowStats(workflow.id, bytesSent=0, bytesReceived=0)
# Add workflow to services
self.services.workflow = workflow
@@ -120,17 +120,17 @@ class WorkflowManager:
async def workflowStop(self, workflowId: str) -> ChatWorkflow:
"""Stops a running workflow."""
try:
- workflow = self.services.getWorkflow(workflowId)
+ workflow = self.services.workflow.getWorkflow(workflowId)
if not workflow:
raise ValueError(f"Workflow {workflowId} not found")
workflow.status = "stopped"
workflow.lastActivity = get_utc_timestamp()
- self.services.updateWorkflow(workflowId, {
+ self.services.workflow.updateWorkflow(workflowId, {
"status": "stopped",
"lastActivity": workflow.lastActivity
})
- self.services.createLog({
+ self.services.workflow.createLog({
"workflowId": workflowId,
"message": "Workflow stopped",
"type": "warning",
@@ -192,7 +192,7 @@ class WorkflowManager:
}
# Create message first to get messageId
- message = self.services.createMessage(messageData)
+ message = self.services.workflow.createMessage(messageData)
if message:
workflow.messages.append(message)
@@ -205,7 +205,7 @@ class WorkflowManager:
documents = await self._processFileIds(userInput.listFileId, message.id)
message.documents = documents
# Update the message with documents in database
- self.services.updateMessage(message.id, {"documents": [doc.to_dict() for doc in documents]})
+ self.services.workflow.updateMessage(message.id, {"documents": [doc.to_dict() for doc in documents]})
return message
else:
@@ -307,14 +307,14 @@ class WorkflowManager:
"taskProgress": "stopped",
"actionProgress": "stopped"
}
- message = self.services.createMessage(stopped_message)
+ message = self.services.workflow.createMessage(stopped_message)
if message:
workflow.messages.append(message)
# Update workflow status to stopped
workflow.status = "stopped"
workflow.lastActivity = get_utc_timestamp()
- self.services.updateWorkflow(workflow.id, {
+ self.services.workflow.updateWorkflow(workflow.id, {
"status": "stopped",
"lastActivity": workflow.lastActivity
})
@@ -339,14 +339,14 @@ class WorkflowManager:
"taskProgress": "stopped",
"actionProgress": "stopped"
}
- message = self.services.createMessage(stopped_message)
+ message = self.services.workflow.createMessage(stopped_message)
if message:
workflow.messages.append(message)
# Update workflow status to stopped
workflow.status = "stopped"
workflow.lastActivity = get_utc_timestamp()
- self.services.updateWorkflow(workflow.id, {
+ self.services.workflow.updateWorkflow(workflow.id, {
"status": "stopped",
"lastActivity": workflow.lastActivity,
"totalTasks": workflow.totalTasks,
@@ -354,7 +354,7 @@ class WorkflowManager:
})
# Add stopped log entry
- self.services.createLog({
+ self.services.workflow.createLog({
"workflowId": workflow.id,
"message": "Workflow stopped by user",
"type": "warning",
@@ -381,14 +381,14 @@ class WorkflowManager:
"taskProgress": "fail",
"actionProgress": "fail"
}
- message = self.services.createMessage(error_message)
+ message = self.services.workflow.createMessage(error_message)
if message:
workflow.messages.append(message)
# Update workflow status to failed
workflow.status = "failed"
workflow.lastActivity = get_utc_timestamp()
- self.services.updateWorkflow(workflow.id, {
+ self.services.workflow.updateWorkflow(workflow.id, {
"status": "failed",
"lastActivity": workflow.lastActivity,
"totalTasks": workflow.totalTasks,
@@ -396,7 +396,7 @@ class WorkflowManager:
})
# Add failed log entry
- self.services.createLog({
+ self.services.workflow.createLog({
"workflowId": workflow.id,
"message": f"Workflow failed: {workflow_result.error or 'Unknown error'}",
"type": "error",
@@ -428,14 +428,14 @@ class WorkflowManager:
"taskProgress": "fail",
"actionProgress": "fail"
}
- message = self.services.createMessage(error_message)
+ message = self.services.workflow.createMessage(error_message)
if message:
workflow.messages.append(message)
# Update workflow status to failed
workflow.status = "failed"
workflow.lastActivity = get_utc_timestamp()
- self.services.updateWorkflow(workflow.id, {
+ self.services.workflow.updateWorkflow(workflow.id, {
"status": "failed",
"lastActivity": workflow.lastActivity,
"totalTasks": workflow.totalTasks,
@@ -473,7 +473,7 @@ class WorkflowManager:
}
# Create message using interface
- message = self.services.createMessage(messageData)
+ message = self.services.workflow.createMessage(messageData)
if message:
workflow.messages.append(message)
@@ -482,13 +482,13 @@ class WorkflowManager:
workflow.lastActivity = get_utc_timestamp()
# Update workflow in database
- self.services.updateWorkflow(workflow.id, {
+ self.services.workflow.updateWorkflow(workflow.id, {
"status": "completed",
"lastActivity": workflow.lastActivity
})
# Add completion log entry
- self.services.createLog({
+ self.services.workflow.createLog({
"workflowId": workflow.id,
"message": "Workflow completed",
"type": "success",
@@ -534,7 +534,7 @@ class WorkflowManager:
# Update workflow status to stopped
workflow.status = "stopped"
workflow.lastActivity = get_utc_timestamp()
- self.services.updateWorkflow(workflow.id, {
+ self.services.workflow.updateWorkflow(workflow.id, {
"status": "stopped",
"lastActivity": workflow.lastActivity,
"totalTasks": workflow.totalTasks,
@@ -559,12 +559,12 @@ class WorkflowManager:
"taskProgress": "pending",
"actionProgress": "pending"
}
- message = self.services.createMessage(stopped_message)
+ message = self.services.workflow.createMessage(stopped_message)
if message:
workflow.messages.append(message)
# Add log entry
- self.services.createLog({
+ self.services.workflow.createLog({
"workflowId": workflow.id,
"message": "Workflow stopped by user",
"type": "warning",
@@ -579,7 +579,7 @@ class WorkflowManager:
# Update workflow status to failed
workflow.status = "failed"
workflow.lastActivity = get_utc_timestamp()
- self.services.updateWorkflow(workflow.id, {
+ self.services.workflow.updateWorkflow(workflow.id, {
"status": "failed",
"lastActivity": workflow.lastActivity,
"totalTasks": workflow.totalTasks,
@@ -604,12 +604,12 @@ class WorkflowManager:
"taskProgress": "fail",
"actionProgress": "fail"
}
- message = self.services.createMessage(error_message)
+ message = self.services.workflow.createMessage(error_message)
if message:
workflow.messages.append(message)
# Add error log entry
- self.services.createLog({
+ self.services.workflow.createLog({
"workflowId": workflow.id,
"message": f"Workflow failed: {str(error)}",
"type": "error",
diff --git a/tools_security_generate_master_keys.py b/tool_security_generate_master_keys.py
similarity index 100%
rename from tools_security_generate_master_keys.py
rename to tool_security_generate_master_keys.py