cortexdb-connectors 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- cortexdb_connectors/__init__.py +40 -0
- cortexdb_connectors/base.py +364 -0
- cortexdb_connectors/cli.py +476 -0
- cortexdb_connectors/confluence/__init__.py +359 -0
- cortexdb_connectors/discord/__init__.py +646 -0
- cortexdb_connectors/github/__init__.py +309 -0
- cortexdb_connectors/gitlab/__init__.py +749 -0
- cortexdb_connectors/google_workspace/__init__.py +1033 -0
- cortexdb_connectors/hubspot/__init__.py +489 -0
- cortexdb_connectors/insights/__init__.py +40 -0
- cortexdb_connectors/insights/api.py +252 -0
- cortexdb_connectors/insights/detectors.py +771 -0
- cortexdb_connectors/insights/engine.py +308 -0
- cortexdb_connectors/intercom/__init__.py +631 -0
- cortexdb_connectors/jira/__init__.py +298 -0
- cortexdb_connectors/linear/__init__.py +705 -0
- cortexdb_connectors/notion/__init__.py +702 -0
- cortexdb_connectors/pagerduty/__init__.py +298 -0
- cortexdb_connectors/salesforce/__init__.py +399 -0
- cortexdb_connectors/servicenow/__init__.py +460 -0
- cortexdb_connectors/slack/__init__.py +331 -0
- cortexdb_connectors/state.py +145 -0
- cortexdb_connectors/teams/__init__.py +656 -0
- cortexdb_connectors/webhooks.py +672 -0
- cortexdb_connectors/worker.py +192 -0
- cortexdb_connectors/zendesk/__init__.py +496 -0
- cortexdb_connectors-0.2.0.dist-info/METADATA +207 -0
- cortexdb_connectors-0.2.0.dist-info/RECORD +31 -0
- cortexdb_connectors-0.2.0.dist-info/WHEEL +5 -0
- cortexdb_connectors-0.2.0.dist-info/entry_points.txt +2 -0
- cortexdb_connectors-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,460 @@
|
|
|
1
|
+
"""
|
|
2
|
+
ServiceNow connector for CortexDB.
|
|
3
|
+
|
|
4
|
+
Polls ServiceNow Table API for Incidents, Change Requests, Problems,
|
|
5
|
+
Knowledge Articles, and Tasks, converting them into CortexDB Episodes.
|
|
6
|
+
Uses ``httpx`` for direct HTTP access with offset-based pagination
|
|
7
|
+
(``sysparm_offset``).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import logging
|
|
13
|
+
from datetime import datetime, timedelta, timezone
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
import httpx
|
|
17
|
+
|
|
18
|
+
from cortexdb_connectors.base import (
|
|
19
|
+
Actor,
|
|
20
|
+
CortexConnector,
|
|
21
|
+
EntityRef,
|
|
22
|
+
Episode,
|
|
23
|
+
EpisodeType,
|
|
24
|
+
RawEvent,
|
|
25
|
+
Source,
|
|
26
|
+
Visibility,
|
|
27
|
+
VisibilityLevel,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
logger = logging.getLogger(__name__)
|
|
31
|
+
|
|
32
|
+
_SOURCE = Source(system="servicenow")
|
|
33
|
+
|
|
34
|
+
_DEFAULT_TABLES = frozenset(
|
|
35
|
+
["incident", "change_request", "problem", "kb_knowledge", "sc_task"]
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
_TABLE_TYPE_MAP: dict[str, EpisodeType] = {
|
|
39
|
+
"incident": EpisodeType.INCIDENT,
|
|
40
|
+
"change_request": EpisodeType.DEPLOYMENT,
|
|
41
|
+
"problem": EpisodeType.ISSUE,
|
|
42
|
+
"kb_knowledge": EpisodeType.DOCUMENT,
|
|
43
|
+
"sc_task": EpisodeType.ISSUE,
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
_MAX_RETRIES = 3
|
|
47
|
+
_PAGE_SIZE = 100
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class ServiceNowConnector(CortexConnector):
|
|
51
|
+
"""Connector that syncs ServiceNow records into CortexDB.
|
|
52
|
+
|
|
53
|
+
Parameters
|
|
54
|
+
----------
|
|
55
|
+
cortex_url:
|
|
56
|
+
Base URL of the CortexDB API.
|
|
57
|
+
cortex_api_key:
|
|
58
|
+
Bearer token for CortexDB authentication.
|
|
59
|
+
instance:
|
|
60
|
+
ServiceNow instance name (e.g. ``mycompany`` for
|
|
61
|
+
mycompany.service-now.com) or full URL.
|
|
62
|
+
username:
|
|
63
|
+
ServiceNow username for basic auth.
|
|
64
|
+
password:
|
|
65
|
+
ServiceNow password for basic auth.
|
|
66
|
+
tables:
|
|
67
|
+
ServiceNow table names to ingest. Defaults to incident,
|
|
68
|
+
change_request, problem, kb_knowledge, sc_task.
|
|
69
|
+
namespace:
|
|
70
|
+
CortexDB namespace for ingested episodes.
|
|
71
|
+
backfill_days:
|
|
72
|
+
Number of days of historical data to backfill on first sync.
|
|
73
|
+
tenant_id:
|
|
74
|
+
CortexDB tenant identifier.
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
connector_name: str = "servicenow"
|
|
78
|
+
|
|
79
|
+
def __init__(
|
|
80
|
+
self,
|
|
81
|
+
cortex_url: str,
|
|
82
|
+
cortex_api_key: str,
|
|
83
|
+
instance: str,
|
|
84
|
+
username: str,
|
|
85
|
+
password: str,
|
|
86
|
+
tables: list[str] | None = None,
|
|
87
|
+
namespace: str = "servicenow",
|
|
88
|
+
backfill_days: int = 90,
|
|
89
|
+
tenant_id: str = "default",
|
|
90
|
+
) -> None:
|
|
91
|
+
super().__init__(cortex_url, cortex_api_key, tenant_id)
|
|
92
|
+
# Accept either "mycompany" or "https://mycompany.service-now.com".
|
|
93
|
+
if instance.startswith("http"):
|
|
94
|
+
self._base_url = instance.rstrip("/")
|
|
95
|
+
else:
|
|
96
|
+
self._base_url = f"https://{instance}.service-now.com"
|
|
97
|
+
self.username = username
|
|
98
|
+
self.password = password
|
|
99
|
+
self.table_filter = set(tables) if tables else set(_DEFAULT_TABLES)
|
|
100
|
+
self.namespace = namespace
|
|
101
|
+
self.backfill_days = backfill_days
|
|
102
|
+
|
|
103
|
+
# -- HTTP helpers --------------------------------------------------------
|
|
104
|
+
|
|
105
|
+
def _auth(self) -> tuple[str, str]:
|
|
106
|
+
return self.username, self.password
|
|
107
|
+
|
|
108
|
+
async def _request(
|
|
109
|
+
self,
|
|
110
|
+
client: httpx.AsyncClient,
|
|
111
|
+
url: str,
|
|
112
|
+
params: dict[str, Any] | None = None,
|
|
113
|
+
) -> httpx.Response:
|
|
114
|
+
"""GET request with rate-limit retry."""
|
|
115
|
+
for attempt in range(_MAX_RETRIES):
|
|
116
|
+
resp = await client.get(url, params=params)
|
|
117
|
+
if resp.status_code == 429:
|
|
118
|
+
import asyncio
|
|
119
|
+
|
|
120
|
+
retry_after = int(resp.headers.get("Retry-After", "10"))
|
|
121
|
+
logger.warning(
|
|
122
|
+
"ServiceNow rate limited, retrying in %ds (attempt %d/%d)",
|
|
123
|
+
retry_after,
|
|
124
|
+
attempt + 1,
|
|
125
|
+
_MAX_RETRIES,
|
|
126
|
+
)
|
|
127
|
+
await asyncio.sleep(retry_after)
|
|
128
|
+
continue
|
|
129
|
+
resp.raise_for_status()
|
|
130
|
+
return resp
|
|
131
|
+
raise httpx.HTTPStatusError(
|
|
132
|
+
"Rate limit retries exhausted",
|
|
133
|
+
request=resp.request, # type: ignore[possibly-undefined]
|
|
134
|
+
response=resp, # type: ignore[possibly-undefined]
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
# -- CortexConnector interface -------------------------------------------
|
|
138
|
+
|
|
139
|
+
async def fetch_events(
|
|
140
|
+
self, since: datetime | None = None
|
|
141
|
+
) -> list[RawEvent]:
|
|
142
|
+
"""Fetch records from configured ServiceNow tables.
|
|
143
|
+
|
|
144
|
+
Uses the Table API with ``sysparm_query`` for incremental sync
|
|
145
|
+
and ``sysparm_offset`` / ``sysparm_limit`` for pagination.
|
|
146
|
+
"""
|
|
147
|
+
cutoff = since or datetime.now(timezone.utc) - timedelta(
|
|
148
|
+
days=self.backfill_days
|
|
149
|
+
)
|
|
150
|
+
raw_events: list[RawEvent] = []
|
|
151
|
+
|
|
152
|
+
async with httpx.AsyncClient(
|
|
153
|
+
auth=self._auth(),
|
|
154
|
+
headers={"Accept": "application/json"},
|
|
155
|
+
timeout=30.0,
|
|
156
|
+
) as client:
|
|
157
|
+
for table in self.table_filter:
|
|
158
|
+
try:
|
|
159
|
+
raw_events.extend(
|
|
160
|
+
await self._fetch_table(client, table, cutoff)
|
|
161
|
+
)
|
|
162
|
+
except Exception:
|
|
163
|
+
logger.exception(
|
|
164
|
+
"Failed to fetch ServiceNow table %s", table
|
|
165
|
+
)
|
|
166
|
+
continue
|
|
167
|
+
|
|
168
|
+
return raw_events
|
|
169
|
+
|
|
170
|
+
def normalize(self, raw: RawEvent) -> Episode:
|
|
171
|
+
"""Convert a ServiceNow ``RawEvent`` into a CortexDB ``Episode``."""
|
|
172
|
+
data = raw.data
|
|
173
|
+
table = data.get("table", "unknown")
|
|
174
|
+
record = data.get("record", {})
|
|
175
|
+
episode_type = _TABLE_TYPE_MAP.get(table, EpisodeType.CUSTOM)
|
|
176
|
+
|
|
177
|
+
actor = self._extract_actor(record)
|
|
178
|
+
content = self._build_content(table, record)
|
|
179
|
+
entities = self._extract_entities(table, record)
|
|
180
|
+
tags = ["servicenow", table]
|
|
181
|
+
|
|
182
|
+
state = record.get("state", "")
|
|
183
|
+
priority = record.get("priority", "")
|
|
184
|
+
|
|
185
|
+
metadata: dict[str, Any] = {
|
|
186
|
+
"servicenow_table": table,
|
|
187
|
+
"sys_id": record.get("sys_id", ""),
|
|
188
|
+
"number": record.get("number", ""),
|
|
189
|
+
}
|
|
190
|
+
if state:
|
|
191
|
+
metadata["state"] = state
|
|
192
|
+
if priority:
|
|
193
|
+
metadata["priority"] = priority
|
|
194
|
+
if record.get("category"):
|
|
195
|
+
metadata["category"] = record["category"]
|
|
196
|
+
if record.get("impact"):
|
|
197
|
+
metadata["impact"] = record["impact"]
|
|
198
|
+
if record.get("urgency"):
|
|
199
|
+
metadata["urgency"] = record["urgency"]
|
|
200
|
+
|
|
201
|
+
thread_id = self._derive_thread_id(table, record)
|
|
202
|
+
|
|
203
|
+
return Episode(
|
|
204
|
+
actor=actor,
|
|
205
|
+
source=_SOURCE,
|
|
206
|
+
episode_type=episode_type,
|
|
207
|
+
occurred_at=raw.timestamp,
|
|
208
|
+
content=content,
|
|
209
|
+
raw_payload=data,
|
|
210
|
+
tenant_id=self.tenant_id,
|
|
211
|
+
namespace=self.namespace,
|
|
212
|
+
entities=entities,
|
|
213
|
+
tags=tags,
|
|
214
|
+
metadata=metadata,
|
|
215
|
+
thread_id=thread_id,
|
|
216
|
+
idempotency_key=f"snow:{table}:{record.get('sys_id', raw.external_id)}",
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
def map_permissions(self, raw: RawEvent) -> Visibility:
|
|
220
|
+
"""Map ServiceNow record visibility.
|
|
221
|
+
|
|
222
|
+
Knowledge articles marked as public are ``Organization``;
|
|
223
|
+
everything else is ``Restricted`` by default since ServiceNow
|
|
224
|
+
typically contains internal operational data.
|
|
225
|
+
"""
|
|
226
|
+
data = raw.data
|
|
227
|
+
table = data.get("table", "")
|
|
228
|
+
record = data.get("record", {})
|
|
229
|
+
|
|
230
|
+
if table == "kb_knowledge":
|
|
231
|
+
workflow_state = record.get("workflow_state", "")
|
|
232
|
+
if workflow_state == "published":
|
|
233
|
+
return Visibility(level=VisibilityLevel.ORGANIZATION)
|
|
234
|
+
return Visibility(level=VisibilityLevel.RESTRICTED)
|
|
235
|
+
|
|
236
|
+
return Visibility(level=VisibilityLevel.ORGANIZATION)
|
|
237
|
+
|
|
238
|
+
# -- fetchers ------------------------------------------------------------
|
|
239
|
+
|
|
240
|
+
async def _fetch_table(
|
|
241
|
+
self,
|
|
242
|
+
client: httpx.AsyncClient,
|
|
243
|
+
table: str,
|
|
244
|
+
cutoff: datetime,
|
|
245
|
+
) -> list[RawEvent]:
|
|
246
|
+
"""Fetch all records from a ServiceNow table since cutoff."""
|
|
247
|
+
events: list[RawEvent] = []
|
|
248
|
+
cutoff_str = cutoff.strftime("%Y-%m-%d %H:%M:%S")
|
|
249
|
+
fields = self._fields_for(table)
|
|
250
|
+
offset = 0
|
|
251
|
+
|
|
252
|
+
while True:
|
|
253
|
+
params: dict[str, Any] = {
|
|
254
|
+
"sysparm_query": (
|
|
255
|
+
f"sys_updated_on>{cutoff_str}^ORDERBYDESCsys_updated_on"
|
|
256
|
+
),
|
|
257
|
+
"sysparm_fields": fields,
|
|
258
|
+
"sysparm_limit": _PAGE_SIZE,
|
|
259
|
+
"sysparm_offset": offset,
|
|
260
|
+
"sysparm_display_value": "false",
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
try:
|
|
264
|
+
resp = await self._request(
|
|
265
|
+
client,
|
|
266
|
+
f"{self._base_url}/api/now/table/{table}",
|
|
267
|
+
params=params,
|
|
268
|
+
)
|
|
269
|
+
except Exception:
|
|
270
|
+
logger.exception(
|
|
271
|
+
"Failed to fetch page %d for table %s", offset, table
|
|
272
|
+
)
|
|
273
|
+
break
|
|
274
|
+
|
|
275
|
+
body = resp.json()
|
|
276
|
+
records = body.get("result", [])
|
|
277
|
+
if not records:
|
|
278
|
+
break
|
|
279
|
+
|
|
280
|
+
for rec in records:
|
|
281
|
+
ts = self._parse_snow_ts(rec.get("sys_updated_on"))
|
|
282
|
+
events.append(
|
|
283
|
+
RawEvent(
|
|
284
|
+
source="servicenow",
|
|
285
|
+
external_id=rec.get("sys_id", ""),
|
|
286
|
+
timestamp=ts,
|
|
287
|
+
data={
|
|
288
|
+
"table": table,
|
|
289
|
+
"record": rec,
|
|
290
|
+
},
|
|
291
|
+
permissions={
|
|
292
|
+
"assigned_to": rec.get("assigned_to", ""),
|
|
293
|
+
"assignment_group": rec.get("assignment_group", ""),
|
|
294
|
+
},
|
|
295
|
+
)
|
|
296
|
+
)
|
|
297
|
+
|
|
298
|
+
if len(records) < _PAGE_SIZE:
|
|
299
|
+
break
|
|
300
|
+
offset += _PAGE_SIZE
|
|
301
|
+
|
|
302
|
+
return events
|
|
303
|
+
|
|
304
|
+
# -- helpers -------------------------------------------------------------
|
|
305
|
+
|
|
306
|
+
@staticmethod
|
|
307
|
+
def _fields_for(table: str) -> str:
|
|
308
|
+
"""Return the field list for a ServiceNow table query."""
|
|
309
|
+
base = "sys_id,sys_updated_on,sys_created_on,opened_by,assigned_to,assignment_group"
|
|
310
|
+
table_fields: dict[str, str] = {
|
|
311
|
+
"incident": (
|
|
312
|
+
f"{base},number,short_description,description,state,"
|
|
313
|
+
"priority,urgency,impact,category,subcategory,"
|
|
314
|
+
"cmdb_ci,caller_id,resolved_by,close_code"
|
|
315
|
+
),
|
|
316
|
+
"change_request": (
|
|
317
|
+
f"{base},number,short_description,description,state,"
|
|
318
|
+
"priority,risk,impact,type,category,"
|
|
319
|
+
"start_date,end_date,cmdb_ci"
|
|
320
|
+
),
|
|
321
|
+
"problem": (
|
|
322
|
+
f"{base},number,short_description,description,state,"
|
|
323
|
+
"priority,urgency,impact,category,"
|
|
324
|
+
"known_error,cause_notes,fix_notes"
|
|
325
|
+
),
|
|
326
|
+
"kb_knowledge": (
|
|
327
|
+
"sys_id,sys_updated_on,sys_created_on,"
|
|
328
|
+
"number,short_description,text,workflow_state,"
|
|
329
|
+
"kb_category,author,published"
|
|
330
|
+
),
|
|
331
|
+
"sc_task": (
|
|
332
|
+
f"{base},number,short_description,description,state,"
|
|
333
|
+
"priority,request_item,due_date"
|
|
334
|
+
),
|
|
335
|
+
}
|
|
336
|
+
return table_fields.get(table, base + ",number,short_description,description,state")
|
|
337
|
+
|
|
338
|
+
@staticmethod
|
|
339
|
+
def _build_content(table: str, record: dict[str, Any]) -> str:
|
|
340
|
+
"""Build human-readable content from a ServiceNow record."""
|
|
341
|
+
number = record.get("number", "")
|
|
342
|
+
short_desc = record.get("short_description", "Untitled")
|
|
343
|
+
desc = (record.get("description") or record.get("text") or "")[:1000]
|
|
344
|
+
state = record.get("state", "")
|
|
345
|
+
|
|
346
|
+
label_map: dict[str, str] = {
|
|
347
|
+
"incident": "Incident",
|
|
348
|
+
"change_request": "Change Request",
|
|
349
|
+
"problem": "Problem",
|
|
350
|
+
"kb_knowledge": "Knowledge Article",
|
|
351
|
+
"sc_task": "Task",
|
|
352
|
+
}
|
|
353
|
+
label = label_map.get(table, table.title())
|
|
354
|
+
|
|
355
|
+
if table == "kb_knowledge":
|
|
356
|
+
workflow = record.get("workflow_state", "")
|
|
357
|
+
return f"{label} [{workflow}]: {short_desc}\n{desc}".strip()
|
|
358
|
+
|
|
359
|
+
return f"{label} {number} [{state}]: {short_desc}\n{desc}".strip()
|
|
360
|
+
|
|
361
|
+
@staticmethod
|
|
362
|
+
def _extract_actor(record: dict[str, Any]) -> Actor:
|
|
363
|
+
"""Extract actor from a ServiceNow record."""
|
|
364
|
+
opened_by = record.get("opened_by") or record.get("author") or ""
|
|
365
|
+
assigned_to = record.get("assigned_to") or ""
|
|
366
|
+
actor_id = opened_by or assigned_to or "system"
|
|
367
|
+
return Actor(id=str(actor_id), name=str(actor_id))
|
|
368
|
+
|
|
369
|
+
@staticmethod
|
|
370
|
+
def _extract_entities(
|
|
371
|
+
table: str, record: dict[str, Any]
|
|
372
|
+
) -> list[EntityRef]:
|
|
373
|
+
"""Extract entity references from a ServiceNow record."""
|
|
374
|
+
entities: list[EntityRef] = []
|
|
375
|
+
|
|
376
|
+
sys_id = record.get("sys_id", "")
|
|
377
|
+
number = record.get("number", "")
|
|
378
|
+
if sys_id:
|
|
379
|
+
entities.append(
|
|
380
|
+
EntityRef(
|
|
381
|
+
entity_type=table,
|
|
382
|
+
entity_id=sys_id,
|
|
383
|
+
display_name=number or sys_id,
|
|
384
|
+
)
|
|
385
|
+
)
|
|
386
|
+
|
|
387
|
+
cmdb_ci = record.get("cmdb_ci")
|
|
388
|
+
if cmdb_ci:
|
|
389
|
+
entities.append(
|
|
390
|
+
EntityRef(
|
|
391
|
+
entity_type="configuration_item",
|
|
392
|
+
entity_id=str(cmdb_ci),
|
|
393
|
+
display_name=str(cmdb_ci),
|
|
394
|
+
)
|
|
395
|
+
)
|
|
396
|
+
|
|
397
|
+
assigned_to = record.get("assigned_to")
|
|
398
|
+
if assigned_to:
|
|
399
|
+
entities.append(
|
|
400
|
+
EntityRef(
|
|
401
|
+
entity_type="user",
|
|
402
|
+
entity_id=str(assigned_to),
|
|
403
|
+
display_name=str(assigned_to),
|
|
404
|
+
)
|
|
405
|
+
)
|
|
406
|
+
|
|
407
|
+
group = record.get("assignment_group")
|
|
408
|
+
if group:
|
|
409
|
+
entities.append(
|
|
410
|
+
EntityRef(
|
|
411
|
+
entity_type="group",
|
|
412
|
+
entity_id=str(group),
|
|
413
|
+
display_name=str(group),
|
|
414
|
+
)
|
|
415
|
+
)
|
|
416
|
+
|
|
417
|
+
category = record.get("category") or record.get("kb_category")
|
|
418
|
+
if category:
|
|
419
|
+
entities.append(
|
|
420
|
+
EntityRef(
|
|
421
|
+
entity_type="category",
|
|
422
|
+
entity_id=str(category),
|
|
423
|
+
display_name=str(category),
|
|
424
|
+
)
|
|
425
|
+
)
|
|
426
|
+
|
|
427
|
+
return entities
|
|
428
|
+
|
|
429
|
+
@staticmethod
|
|
430
|
+
def _derive_thread_id(
|
|
431
|
+
table: str, record: dict[str, Any]
|
|
432
|
+
) -> str | None:
|
|
433
|
+
"""Derive a thread_id for grouping related records."""
|
|
434
|
+
sys_id = record.get("sys_id", "")
|
|
435
|
+
if not sys_id:
|
|
436
|
+
return None
|
|
437
|
+
|
|
438
|
+
# Tasks thread to their parent request item.
|
|
439
|
+
if table == "sc_task":
|
|
440
|
+
request_item = record.get("request_item")
|
|
441
|
+
if request_item:
|
|
442
|
+
return f"snow:request:{request_item}"
|
|
443
|
+
|
|
444
|
+
return f"snow:{table}:{sys_id}"
|
|
445
|
+
|
|
446
|
+
@staticmethod
|
|
447
|
+
def _parse_snow_ts(value: str | None) -> datetime:
|
|
448
|
+
"""Parse a ServiceNow datetime string (``YYYY-MM-DD HH:MM:SS``)."""
|
|
449
|
+
if not value:
|
|
450
|
+
return datetime.now(timezone.utc)
|
|
451
|
+
try:
|
|
452
|
+
return datetime.strptime(value, "%Y-%m-%d %H:%M:%S").replace(
|
|
453
|
+
tzinfo=timezone.utc
|
|
454
|
+
)
|
|
455
|
+
except (ValueError, TypeError):
|
|
456
|
+
pass
|
|
457
|
+
try:
|
|
458
|
+
return datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
459
|
+
except (ValueError, TypeError):
|
|
460
|
+
return datetime.now(timezone.utc)
|