@bangdao-ai/zentao-mcp 2.0.5 → 2.0.7
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.
- package/main.py +14 -5
- package/package.json +1 -1
- package/src/core/zentao_nexus.py +251 -23
- package/src/mcp/server.py +196 -19
- package/src/zenbot/bot_service.py +148 -25
- package/src/zenbot/config.py +16 -1
- package/src/zenbot/db_resolver.py +94 -0
- package/src/zenbot/dingtalk_handler.py +200 -32
- package/src/zenbot/formatters.py +10 -1
- package/src/zenbot/handlers/bug_handlers.py +35 -20
- package/src/zenbot/handlers/case_handlers.py +7 -15
- package/src/zenbot/handlers/product_handlers.py +5 -9
- package/src/zenbot/handlers/story_handlers.py +5 -9
- package/src/zenbot/handlers/task_handlers.py +36 -9
- package/src/zenbot/intent_parser.py +190 -7
- package/src/zenbot/llm_client.py +45 -14
- package/src/zenbot/logging_utils.py +64 -0
- package/src/zenbot/zentao_client.py +36 -0
package/main.py
CHANGED
|
@@ -14,15 +14,24 @@ from dingtalk_stream import DingTalkStreamClient, Credential, ChatbotMessage
|
|
|
14
14
|
|
|
15
15
|
from zenbot.config import JsonFormatter
|
|
16
16
|
from zenbot.dingtalk_handler import ZentaoBotHandler
|
|
17
|
+
from zenbot.logging_utils import struct_log
|
|
17
18
|
|
|
18
19
|
load_dotenv()
|
|
19
20
|
|
|
20
|
-
|
|
21
|
-
|
|
21
|
+
def _setup_structured_logging() -> None:
|
|
22
|
+
fmt = JsonFormatter()
|
|
23
|
+
h = logging.StreamHandler(sys.stderr)
|
|
24
|
+
h.setFormatter(fmt)
|
|
25
|
+
for name in ("zentaoagent", "zentao_audit", "zentao_security"):
|
|
26
|
+
lg = logging.getLogger(name)
|
|
27
|
+
lg.handlers.clear()
|
|
28
|
+
lg.propagate = False
|
|
29
|
+
lg.addHandler(h)
|
|
30
|
+
lg.setLevel(logging.INFO)
|
|
22
31
|
|
|
32
|
+
|
|
33
|
+
_setup_structured_logging()
|
|
23
34
|
logger = logging.getLogger("zentaoagent")
|
|
24
|
-
logger.addHandler(_stderr_handler)
|
|
25
|
-
logger.setLevel(logging.INFO)
|
|
26
35
|
|
|
27
36
|
|
|
28
37
|
def main():
|
|
@@ -36,7 +45,7 @@ def main():
|
|
|
36
45
|
bot_handler = ZentaoBotHandler()
|
|
37
46
|
client = DingTalkStreamClient(credential, logger)
|
|
38
47
|
client.register_callback_handler(ChatbotMessage.TOPIC, bot_handler)
|
|
39
|
-
logger.
|
|
48
|
+
struct_log(logger, logging.INFO, "zenbot_startup", component="main", version="v2")
|
|
40
49
|
client.start_forever()
|
|
41
50
|
|
|
42
51
|
|
package/package.json
CHANGED
package/src/core/zentao_nexus.py
CHANGED
|
@@ -15,6 +15,12 @@ import csv
|
|
|
15
15
|
import shutil
|
|
16
16
|
import requests
|
|
17
17
|
|
|
18
|
+
try:
|
|
19
|
+
from zenbot.logging_utils import struct_log as _struct_log
|
|
20
|
+
except ImportError:
|
|
21
|
+
def _struct_log(logger, level, event, msg=None, component=None, **ctx):
|
|
22
|
+
logger.log(level, f"{event} {ctx}")
|
|
23
|
+
|
|
18
24
|
logger = logging.getLogger("zentaoagent")
|
|
19
25
|
_security_logger = logging.getLogger("zentao_security")
|
|
20
26
|
|
|
@@ -26,7 +32,12 @@ def _init_allowed_dirs():
|
|
|
26
32
|
if not _ALLOWED_WORK_DIRS:
|
|
27
33
|
cwd = os.path.realpath(os.getcwd())
|
|
28
34
|
tmp = os.path.realpath(os.getenv("TMPDIR", "/tmp"))
|
|
29
|
-
|
|
35
|
+
home = os.path.realpath(os.path.expanduser("~"))
|
|
36
|
+
candidates = [cwd, tmp, home, "/tmp"]
|
|
37
|
+
for d in candidates:
|
|
38
|
+
real_d = os.path.realpath(d)
|
|
39
|
+
if real_d and real_d not in _ALLOWED_WORK_DIRS:
|
|
40
|
+
_ALLOWED_WORK_DIRS.append(real_d)
|
|
30
41
|
|
|
31
42
|
|
|
32
43
|
def _safe_path(path: str, must_exist: bool = False) -> str:
|
|
@@ -128,7 +139,12 @@ class ZenTaoNativeClient:
|
|
|
128
139
|
safe_form = {k: (v if k not in ('password',) else '***') for k, v in form_data.items()}
|
|
129
140
|
else:
|
|
130
141
|
safe_form = None
|
|
131
|
-
|
|
142
|
+
_struct_log(
|
|
143
|
+
logger, logging.INFO, "zentao_api_request",
|
|
144
|
+
component="zentao_api",
|
|
145
|
+
m=module, f=action, url_params=safe_params,
|
|
146
|
+
form_preview=safe_form,
|
|
147
|
+
)
|
|
132
148
|
|
|
133
149
|
if not self.silent:
|
|
134
150
|
self._log_request(url_params, form_data, files)
|
|
@@ -149,11 +165,22 @@ class ZenTaoNativeClient:
|
|
|
149
165
|
resp_summary = json.dumps(result, ensure_ascii=False)
|
|
150
166
|
if len(resp_summary) > 1000:
|
|
151
167
|
resp_summary = resp_summary[:1000] + f"...(truncated, total {len(resp_summary)} chars)"
|
|
152
|
-
|
|
168
|
+
st = result.get("status")
|
|
169
|
+
info = result.get("info") or result.get("message") or ""
|
|
170
|
+
_struct_log(
|
|
171
|
+
logger, logging.INFO, "zentao_api_response",
|
|
172
|
+
component="zentao_api",
|
|
173
|
+
m=module, f=action, http_status=resp.status_code,
|
|
174
|
+
api_status=st, info_preview=str(info)[:400],
|
|
175
|
+
body_truncated=len(resp_summary) > 1000,
|
|
176
|
+
)
|
|
153
177
|
return result
|
|
154
178
|
|
|
155
179
|
except requests.exceptions.RequestException as e:
|
|
156
|
-
|
|
180
|
+
_struct_log(
|
|
181
|
+
logger, logging.ERROR, "zentao_api_transport_error",
|
|
182
|
+
component="zentao_api", m=module, f=action, error=str(e),
|
|
183
|
+
)
|
|
157
184
|
return {"status": 0, "message": "fail", "info": f"请求失败: {e}", "data": {}}
|
|
158
185
|
|
|
159
186
|
def _prepare_form_data(self, form_data: Optional[Dict]) -> Optional[list]:
|
|
@@ -831,6 +858,18 @@ class ZenTaoNativeClient:
|
|
|
831
858
|
|
|
832
859
|
# ==================== 业务封装层 ====================
|
|
833
860
|
|
|
861
|
+
|
|
862
|
+
class AmbiguousIdError(Exception):
|
|
863
|
+
"""ID 同时匹配产品和项目,需要用户澄清"""
|
|
864
|
+
def __init__(self, numeric_id: str, product_name: str | None, project_name: str | None,
|
|
865
|
+
product_projects: list | None = None):
|
|
866
|
+
self.numeric_id = numeric_id
|
|
867
|
+
self.product_name = product_name
|
|
868
|
+
self.project_name = project_name
|
|
869
|
+
self.product_projects = product_projects or []
|
|
870
|
+
super().__init__(f"ID {numeric_id} 同时是产品({product_name})和项目({project_name})")
|
|
871
|
+
|
|
872
|
+
|
|
834
873
|
class ZenTaoNexus:
|
|
835
874
|
"""
|
|
836
875
|
ZenTao Nexus v2.0 — 业务封装层
|
|
@@ -879,6 +918,72 @@ class ZenTaoNexus:
|
|
|
879
918
|
return pid
|
|
880
919
|
return self.product_id
|
|
881
920
|
|
|
921
|
+
def smart_resolve_product_id(self, user_input_id: Optional[str] = None) -> Dict:
|
|
922
|
+
"""
|
|
923
|
+
智能解析用户给的数字 ID,判断它到底是 product 还是 project/execution。
|
|
924
|
+
|
|
925
|
+
DB 优先(1~2 条 SQL),API 兜底(2 次 HTTP)。
|
|
926
|
+
|
|
927
|
+
Returns:
|
|
928
|
+
{"product_id": str|None, ← 最终可用的产品 ID
|
|
929
|
+
"input_type": "product"|"project"|"unknown",
|
|
930
|
+
"project_id": str|None, ← 如果输入的是项目ID
|
|
931
|
+
"product_name": str|None,
|
|
932
|
+
"project_name": str|None,
|
|
933
|
+
"corrected": bool} ← True 表示用户给的不是产品,做了纠正
|
|
934
|
+
"""
|
|
935
|
+
uid = str(user_input_id or "").strip()
|
|
936
|
+
if not uid or uid == "0":
|
|
937
|
+
fallback = self.resolve_product_id()
|
|
938
|
+
return {"product_id": fallback if fallback != "0" else None,
|
|
939
|
+
"input_type": "unknown", "project_id": None,
|
|
940
|
+
"product_name": None, "project_name": None, "corrected": False}
|
|
941
|
+
|
|
942
|
+
# ── 1) DB 快速路径 ──
|
|
943
|
+
try:
|
|
944
|
+
from zenbot.db_resolver import identify_id_type_from_db
|
|
945
|
+
db_result = identify_id_type_from_db(uid)
|
|
946
|
+
if db_result:
|
|
947
|
+
if db_result["type"] == "product":
|
|
948
|
+
_save_last_product_id(uid)
|
|
949
|
+
return {"product_id": uid, "input_type": "product",
|
|
950
|
+
"project_id": None, "product_name": db_result.get("product_name"),
|
|
951
|
+
"project_name": None, "corrected": False}
|
|
952
|
+
else:
|
|
953
|
+
real_pid = db_result.get("product_id")
|
|
954
|
+
if real_pid:
|
|
955
|
+
_save_last_product_id(real_pid)
|
|
956
|
+
_struct_log(logger, logging.INFO, "smart_resolve_corrected",
|
|
957
|
+
component="zentao_nexus",
|
|
958
|
+
user_input=uid, actual_type="project",
|
|
959
|
+
resolved_product_id=real_pid,
|
|
960
|
+
project_name=db_result.get("project_name"))
|
|
961
|
+
return {"product_id": real_pid, "input_type": "project",
|
|
962
|
+
"project_id": uid,
|
|
963
|
+
"product_name": db_result.get("product_name"),
|
|
964
|
+
"project_name": db_result.get("project_name"),
|
|
965
|
+
"corrected": True}
|
|
966
|
+
except Exception as e:
|
|
967
|
+
_struct_log(logger, logging.DEBUG, "smart_resolve_db_skip",
|
|
968
|
+
component="zentao_nexus", error=str(e))
|
|
969
|
+
|
|
970
|
+
# ── 2) API 兜底 ──
|
|
971
|
+
id_info = self._identify_id_type(uid)
|
|
972
|
+
if id_info["type"] == "product":
|
|
973
|
+
_save_last_product_id(uid)
|
|
974
|
+
return {"product_id": uid, "input_type": "product",
|
|
975
|
+
"project_id": None, "product_name": id_info.get("product_name"),
|
|
976
|
+
"project_name": None, "corrected": False}
|
|
977
|
+
elif id_info["type"] == "project":
|
|
978
|
+
return {"product_id": None, "input_type": "project",
|
|
979
|
+
"project_id": uid, "product_name": None,
|
|
980
|
+
"project_name": None, "corrected": True}
|
|
981
|
+
else:
|
|
982
|
+
_save_last_product_id(uid)
|
|
983
|
+
return {"product_id": uid, "input_type": "unknown",
|
|
984
|
+
"project_id": None, "product_name": None,
|
|
985
|
+
"project_name": None, "corrected": False}
|
|
986
|
+
|
|
882
987
|
def set_product_id(self, product_id: str) -> Dict:
|
|
883
988
|
pid = str(product_id).strip()
|
|
884
989
|
if not pid or pid == "0":
|
|
@@ -1104,6 +1209,19 @@ class ZenTaoNexus:
|
|
|
1104
1209
|
return self.client.task_view(task_id)
|
|
1105
1210
|
|
|
1106
1211
|
def create_task(self, task_data: Dict) -> Dict:
|
|
1212
|
+
_TASK_ALIASES = {
|
|
1213
|
+
"product_id": "product",
|
|
1214
|
+
"project_id": "projectID",
|
|
1215
|
+
"execution_id": "projectID",
|
|
1216
|
+
"assigned_to": "assignedTo",
|
|
1217
|
+
"est_started": "estStarted",
|
|
1218
|
+
}
|
|
1219
|
+
for alias, canonical in _TASK_ALIASES.items():
|
|
1220
|
+
if alias in task_data and canonical not in task_data:
|
|
1221
|
+
task_data[canonical] = task_data.pop(alias)
|
|
1222
|
+
elif alias in task_data:
|
|
1223
|
+
task_data.pop(alias)
|
|
1224
|
+
|
|
1107
1225
|
task_name = str(task_data.get('name', '') or '').strip()
|
|
1108
1226
|
if task_data.get('assignedTo'):
|
|
1109
1227
|
task_data['assignedTo'] = self.resolve_user_account(task_data['assignedTo']) or task_data['assignedTo']
|
|
@@ -1118,7 +1236,8 @@ class ZenTaoNexus:
|
|
|
1118
1236
|
|
|
1119
1237
|
product_id = str(task_data.get('product') or self.product_id)
|
|
1120
1238
|
requested_project_id = task_data.pop('project', task_data.pop('projectID', None))
|
|
1121
|
-
project_id = self.
|
|
1239
|
+
project_id, product_id = self._resolve_project_and_product(
|
|
1240
|
+
product_id, requested_project_id)
|
|
1122
1241
|
|
|
1123
1242
|
# 1) 优先走稳定网关创建(可直接返回 taskID)
|
|
1124
1243
|
gateway_payload = dict(task_data)
|
|
@@ -1218,27 +1337,135 @@ class ZenTaoNexus:
|
|
|
1218
1337
|
"""查询产品下可见项目列表。"""
|
|
1219
1338
|
return self.client.product_project_pairs(product_id)
|
|
1220
1339
|
|
|
1221
|
-
def
|
|
1340
|
+
def _identify_id_type(self, unknown_id: str) -> Dict:
|
|
1341
|
+
"""
|
|
1342
|
+
探测一个未知数字 ID 到底是 project(执行)、product(产品)、还是两者都是。
|
|
1343
|
+
|
|
1344
|
+
策略:同时检查 product 和 project,若两者都存在则返回 "both"。
|
|
1345
|
+
|
|
1346
|
+
Returns:
|
|
1347
|
+
{"type": "product"|"project"|"both"|"unknown",
|
|
1348
|
+
"product_id": str|None, "project_id": str|None,
|
|
1349
|
+
"product_name": str|None, "project_name": str|None,
|
|
1350
|
+
"projects": list}
|
|
1351
|
+
"""
|
|
1352
|
+
uid = str(unknown_id).strip()
|
|
1353
|
+
if not uid or uid == "0":
|
|
1354
|
+
return {"type": "unknown", "product_id": None, "project_id": None,
|
|
1355
|
+
"product_name": None, "project_name": None, "projects": []}
|
|
1356
|
+
|
|
1357
|
+
is_product = False
|
|
1358
|
+
product_name = None
|
|
1359
|
+
product_projects = []
|
|
1360
|
+
|
|
1361
|
+
is_project = False
|
|
1362
|
+
project_name = None
|
|
1363
|
+
project_product_id = None
|
|
1364
|
+
project_product_name = None
|
|
1365
|
+
|
|
1366
|
+
# ── 检查是否为 product ──
|
|
1367
|
+
product_result = self.client.product_view(uid)
|
|
1368
|
+
if product_result.get("status") in (1, "success"):
|
|
1369
|
+
data = product_result.get("data", {})
|
|
1370
|
+
if isinstance(data, dict) and data.get("id"):
|
|
1371
|
+
is_product = True
|
|
1372
|
+
product_name = data.get("name", "")
|
|
1373
|
+
product_projects = self._get_projects_for_product(uid)
|
|
1374
|
+
|
|
1375
|
+
if not is_product:
|
|
1376
|
+
proj_result = self.client.product_project_pairs(uid)
|
|
1377
|
+
if proj_result.get("status") == 1:
|
|
1378
|
+
projects = proj_result.get("data", {}).get("projects", [])
|
|
1379
|
+
if projects:
|
|
1380
|
+
is_product = True
|
|
1381
|
+
product_projects = projects
|
|
1382
|
+
|
|
1383
|
+
# ── 检查是否为 project(查 zt_project 表或通过 task browse 探测) ──
|
|
1384
|
+
try:
|
|
1385
|
+
from zenbot.db_resolver import check_project_exists_from_db
|
|
1386
|
+
proj_info = check_project_exists_from_db(uid)
|
|
1387
|
+
if proj_info:
|
|
1388
|
+
is_project = True
|
|
1389
|
+
project_name = proj_info.get("project_name")
|
|
1390
|
+
project_product_id = proj_info.get("product_id")
|
|
1391
|
+
project_product_name = proj_info.get("product_name")
|
|
1392
|
+
except Exception:
|
|
1393
|
+
pass
|
|
1394
|
+
|
|
1395
|
+
if is_product and is_project:
|
|
1396
|
+
return {"type": "both", "product_id": uid, "project_id": uid,
|
|
1397
|
+
"product_name": product_name, "project_name": project_name,
|
|
1398
|
+
"project_product_id": project_product_id,
|
|
1399
|
+
"project_product_name": project_product_name,
|
|
1400
|
+
"projects": product_projects}
|
|
1401
|
+
elif is_product:
|
|
1402
|
+
return {"type": "product", "product_id": uid,
|
|
1403
|
+
"project_id": product_projects[0]["id"] if product_projects else None,
|
|
1404
|
+
"product_name": product_name, "project_name": None,
|
|
1405
|
+
"projects": product_projects}
|
|
1406
|
+
elif is_project:
|
|
1407
|
+
return {"type": "project", "product_id": project_product_id,
|
|
1408
|
+
"project_id": uid,
|
|
1409
|
+
"product_name": project_product_name, "project_name": project_name,
|
|
1410
|
+
"projects": []}
|
|
1411
|
+
else:
|
|
1412
|
+
return {"type": "unknown", "product_id": None, "project_id": uid,
|
|
1413
|
+
"product_name": None, "project_name": None, "projects": []}
|
|
1414
|
+
|
|
1415
|
+
def _get_projects_for_product(self, product_id: str) -> list:
|
|
1416
|
+
"""获取产品下的项目列表,返回 [{"id": "...", "name": "..."}]"""
|
|
1417
|
+
result = self.client.product_project_pairs(product_id)
|
|
1418
|
+
if result.get("status") == 1:
|
|
1419
|
+
return result.get("data", {}).get("projects", [])
|
|
1420
|
+
return []
|
|
1421
|
+
|
|
1422
|
+
def _resolve_project_and_product(self, product_id, requested_project_id=None):
|
|
1222
1423
|
"""
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1424
|
+
智能解析任务创建所需的 (project_id, product_id)。
|
|
1425
|
+
|
|
1426
|
+
策略:
|
|
1427
|
+
1) 用户显式传了 project_id → 探测真实类型
|
|
1428
|
+
- "project" → 直接信任
|
|
1429
|
+
- "both"(同时是产品和项目) → 抛 AmbiguousIdError,让上层追问用户
|
|
1430
|
+
- "product"(纯产品) → 修正 product_id,从该产品下取第一个项目
|
|
1431
|
+
2) 没有显式传入 → 从 product_id 的关联项目列表取第一个
|
|
1432
|
+
3) 兜底 project_id="0"
|
|
1433
|
+
|
|
1434
|
+
Returns: (project_id: str, product_id: str)
|
|
1435
|
+
Raises: AmbiguousIdError — 当 ID 同时是产品和项目时
|
|
1227
1436
|
"""
|
|
1228
1437
|
if requested_project_id is not None:
|
|
1229
1438
|
pid = str(requested_project_id).strip()
|
|
1230
1439
|
if pid and pid != "0":
|
|
1231
|
-
|
|
1440
|
+
id_info = self._identify_id_type(pid)
|
|
1441
|
+
_struct_log(logger, logging.INFO, "id_type_detection",
|
|
1442
|
+
component="zentao_nexus",
|
|
1443
|
+
input_id=pid, detected_type=id_info["type"],
|
|
1444
|
+
product_id=id_info.get("product_id"),
|
|
1445
|
+
project_id=id_info.get("project_id"),
|
|
1446
|
+
product_name=id_info.get("product_name"),
|
|
1447
|
+
project_name=id_info.get("project_name"))
|
|
1448
|
+
|
|
1449
|
+
if id_info["type"] == "both":
|
|
1450
|
+
raise AmbiguousIdError(
|
|
1451
|
+
numeric_id=pid,
|
|
1452
|
+
product_name=id_info.get("product_name"),
|
|
1453
|
+
project_name=id_info.get("project_name"),
|
|
1454
|
+
product_projects=id_info.get("projects", []),
|
|
1455
|
+
)
|
|
1456
|
+
elif id_info["type"] == "product":
|
|
1457
|
+
real_product_id = id_info["product_id"] or pid
|
|
1458
|
+
real_project_id = id_info["project_id"]
|
|
1459
|
+
if real_project_id:
|
|
1460
|
+
return real_project_id, real_product_id
|
|
1461
|
+
product_id = real_product_id
|
|
1462
|
+
else:
|
|
1463
|
+
return pid, product_id
|
|
1232
1464
|
|
|
1233
|
-
|
|
1234
|
-
if
|
|
1235
|
-
projects
|
|
1236
|
-
if projects:
|
|
1237
|
-
pid = str(projects[0].get("id", "")).strip()
|
|
1238
|
-
if pid:
|
|
1239
|
-
return pid
|
|
1465
|
+
projects = self._get_projects_for_product(product_id)
|
|
1466
|
+
if projects:
|
|
1467
|
+
return projects[0]["id"], product_id
|
|
1240
1468
|
|
|
1241
|
-
# 尝试从需求数据中推断项目(个别环境下 assignedProjectId/formProjectId 可用)
|
|
1242
1469
|
stories = self.get_story_list(product_id)
|
|
1243
1470
|
if stories.get("status") == 1 and isinstance(stories.get("data"), list):
|
|
1244
1471
|
for story in stories["data"]:
|
|
@@ -1247,9 +1474,9 @@ class ZenTaoNexus:
|
|
|
1247
1474
|
for key in ("assignedProjectId", "formProjectId", "project", "projectID"):
|
|
1248
1475
|
val = str(story.get(key, "")).strip()
|
|
1249
1476
|
if val and val != "0":
|
|
1250
|
-
return val
|
|
1477
|
+
return val, product_id
|
|
1251
1478
|
|
|
1252
|
-
return "0"
|
|
1479
|
+
return "0", product_id
|
|
1253
1480
|
|
|
1254
1481
|
def assign_task(self, task_id, assigned_to, comment=None) -> Dict:
|
|
1255
1482
|
assigned_to = self.resolve_user_account(assigned_to) or assigned_to
|
|
@@ -1534,12 +1761,13 @@ class ZenTaoNexus:
|
|
|
1534
1761
|
if not batch_cases:
|
|
1535
1762
|
return [{"status": 0, "message": "CSV 中无有效用例行"}]
|
|
1536
1763
|
|
|
1537
|
-
|
|
1764
|
+
pid = self.resolve_product_id()
|
|
1765
|
+
result = self.client.testcase_import_batch(pid, batch_cases)
|
|
1538
1766
|
ok = result.get('status') == 1
|
|
1539
1767
|
|
|
1540
1768
|
if ok:
|
|
1541
1769
|
latest = self.client.testcase_browse(
|
|
1542
|
-
|
|
1770
|
+
pid, orderBy='id_desc',
|
|
1543
1771
|
recPerPage=str(len(batch_cases) + 5))
|
|
1544
1772
|
latest_cases = latest.get("data", {}).get("cases", [])
|
|
1545
1773
|
if isinstance(latest_cases, dict):
|