_context.py 51 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179
  1. # Copyright 2021-2024 Avaiga Private Limited
  2. #
  3. # Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with
  4. # the License. You may obtain a copy of the License at
  5. #
  6. # http://www.apache.org/licenses/LICENSE-2.0
  7. #
  8. # Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on
  9. # an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the
  10. # specific language governing permissions and limitations under the License.
  11. import json
  12. import typing as t
  13. from collections import defaultdict
  14. from numbers import Number
  15. from threading import Lock
  16. try:
  17. import zoneinfo
  18. except ImportError:
  19. from backports import zoneinfo # type: ignore[no-redef]
  20. import pandas as pd
  21. from dateutil import parser
  22. from taipy.config import Config
  23. from taipy.core import (
  24. Cycle,
  25. DataNode,
  26. DataNodeId,
  27. Job,
  28. Scenario,
  29. ScenarioId,
  30. Sequence,
  31. SequenceId,
  32. Submission,
  33. SubmissionId,
  34. cancel_job,
  35. create_scenario,
  36. delete_job,
  37. get_cycles_scenarios,
  38. get_data_nodes,
  39. get_jobs,
  40. is_deletable,
  41. is_editable,
  42. is_promotable,
  43. is_readable,
  44. is_submittable,
  45. set_primary,
  46. )
  47. from taipy.core import delete as core_delete
  48. from taipy.core import get as core_get
  49. from taipy.core import submit as core_submit
  50. from taipy.core.notification import CoreEventConsumerBase, EventEntityType
  51. from taipy.core.notification.event import Event, EventOperation
  52. from taipy.core.notification.notifier import Notifier
  53. from taipy.core.reason import ReasonCollection
  54. from taipy.core.submission.submission_status import SubmissionStatus
  55. from taipy.core.taipy import can_create
  56. from taipy.gui import Gui, State
  57. from taipy.gui._warnings import _warn
  58. from taipy.gui.gui import _DoNotUpdate
  59. from taipy.gui.utils._map_dict import _MapDict
  60. from ._adapters import (
  61. CustomScenarioFilter,
  62. _EntityType,
  63. _get_entity_property,
  64. _GuiCoreDatanodeAdapter,
  65. _GuiCoreScenarioProperties,
  66. _invoke_action,
  67. )
  68. class _GuiCoreContext(CoreEventConsumerBase):
  69. __PROP_ENTITY_ID = "id"
  70. __PROP_ENTITY_COMMENT = "comment"
  71. __PROP_CONFIG_ID = "config"
  72. __PROP_DATE = "date"
  73. __PROP_ENTITY_NAME = "name"
  74. __PROP_SCENARIO_PRIMARY = "primary"
  75. __PROP_SCENARIO_TAGS = "tags"
  76. __ENTITY_PROPS = (__PROP_CONFIG_ID, __PROP_DATE, __PROP_ENTITY_NAME)
  77. __ACTION = "action"
  78. _CORE_CHANGED_NAME = "core_changed"
  79. def __init__(self, gui: Gui) -> None:
  80. self.gui = gui
  81. self.scenario_by_cycle: t.Optional[t.Dict[t.Optional[Cycle], t.List[Scenario]]] = None
  82. self.data_nodes_by_owner: t.Optional[t.Dict[t.Optional[str], t.List[DataNode]]] = None
  83. self.scenario_configs: t.Optional[t.List[t.Tuple[str, str]]] = None
  84. self.jobs_list: t.Optional[t.List[Job]] = None
  85. self.client_submission: t.Dict[str, SubmissionStatus] = {}
  86. # register to taipy core notification
  87. reg_id, reg_queue = Notifier.register()
  88. # locks
  89. self.lock = Lock()
  90. self.submissions_lock = Lock()
  91. # lazy_start
  92. self.__started = False
  93. # super
  94. super().__init__(reg_id, reg_queue)
  95. def __lazy_start(self):
  96. if self.__started:
  97. return
  98. self.__started = True
  99. self.start()
  100. def process_event(self, event: Event):
  101. self.__lazy_start()
  102. if event.entity_type == EventEntityType.SCENARIO:
  103. with self.gui._get_autorization(system=True):
  104. self.scenario_refresh(
  105. event.entity_id
  106. if event.operation == EventOperation.DELETION or is_readable(t.cast(ScenarioId, event.entity_id))
  107. else None
  108. )
  109. elif event.entity_type == EventEntityType.SEQUENCE and event.entity_id:
  110. sequence = None
  111. try:
  112. with self.gui._get_autorization(system=True):
  113. sequence = (
  114. core_get(event.entity_id)
  115. if event.operation != EventOperation.DELETION
  116. and is_readable(t.cast(SequenceId, event.entity_id))
  117. else None
  118. )
  119. if sequence and hasattr(sequence, "parent_ids") and sequence.parent_ids:
  120. self.broadcast_core_changed({"scenario": list(sequence.parent_ids)})
  121. except Exception as e:
  122. _warn(f"Access to sequence {event.entity_id} failed", e)
  123. elif event.entity_type == EventEntityType.JOB:
  124. with self.lock:
  125. self.jobs_list = None
  126. self.broadcast_core_changed({"jobs": event.entity_id})
  127. elif event.entity_type == EventEntityType.SUBMISSION:
  128. self.submission_status_callback(event.entity_id, event)
  129. elif event.entity_type == EventEntityType.DATA_NODE:
  130. with self.lock:
  131. self.data_nodes_by_owner = None
  132. self.broadcast_core_changed(
  133. {"datanode": event.entity_id if event.operation != EventOperation.DELETION else True}
  134. )
  135. def broadcast_core_changed(self, payload: t.Dict[str, t.Any], client_id: t.Optional[str] = None):
  136. self.gui._broadcast(_GuiCoreContext._CORE_CHANGED_NAME, payload, client_id)
  137. def scenario_refresh(self, scenario_id: t.Optional[str]):
  138. with self.lock:
  139. self.scenario_by_cycle = None
  140. self.data_nodes_by_owner = None
  141. self.broadcast_core_changed({"scenario": scenario_id or True})
  142. def submission_status_callback(self, submission_id: t.Optional[str] = None, event: t.Optional[Event] = None):
  143. if not submission_id or not is_readable(t.cast(SubmissionId, submission_id)):
  144. return
  145. submission = None
  146. new_status = None
  147. payload: t.Optional[t.Dict[str, t.Any]] = None
  148. client_id: t.Optional[str] = None
  149. try:
  150. last_status = self.client_submission.get(submission_id)
  151. if not last_status:
  152. return
  153. submission = t.cast(Submission, core_get(submission_id))
  154. if not submission or not submission.entity_id:
  155. return
  156. payload = {}
  157. new_status = t.cast(SubmissionStatus, submission.submission_status)
  158. client_id = submission.properties.get("client_id")
  159. if client_id:
  160. running_tasks = {}
  161. with self.gui._get_autorization(client_id):
  162. for job in submission.jobs:
  163. job = job if isinstance(job, Job) else core_get(job)
  164. running_tasks[job.task.id] = (
  165. SubmissionStatus.RUNNING.value
  166. if job.is_running()
  167. else SubmissionStatus.PENDING.value
  168. if job.is_pending()
  169. else None
  170. )
  171. payload.update(tasks=running_tasks)
  172. if last_status != new_status:
  173. # callback
  174. submission_name = submission.properties.get("on_submission")
  175. if submission_name:
  176. self.gui.invoke_callback(
  177. client_id,
  178. submission_name,
  179. [
  180. core_get(submission.id),
  181. {
  182. "submission_status": new_status.name,
  183. "submittable_entity": core_get(submission.entity_id),
  184. **(event.metadata if event else {}),
  185. },
  186. ],
  187. submission.properties.get("module_context"),
  188. )
  189. with self.submissions_lock:
  190. if new_status in (
  191. SubmissionStatus.COMPLETED,
  192. SubmissionStatus.FAILED,
  193. SubmissionStatus.CANCELED,
  194. ):
  195. self.client_submission.pop(submission_id, None)
  196. else:
  197. self.client_submission[submission_id] = new_status
  198. except Exception as e:
  199. _warn(f"Submission ({submission_id}) is not available", e)
  200. finally:
  201. if payload is not None:
  202. payload.update(jobs=True)
  203. entity_id = submission.entity_id if submission else None
  204. if entity_id:
  205. payload.update(scenario=entity_id)
  206. if new_status:
  207. payload.update(submission=new_status.value)
  208. self.broadcast_core_changed(payload, client_id)
  209. def no_change_adapter(self, entity: t.List):
  210. return entity
  211. def cycle_adapter(self, cycle: Cycle, sorts: t.Optional[t.List[t.Dict[str, t.Any]]] = None):
  212. self.__lazy_start()
  213. try:
  214. if (
  215. isinstance(cycle, Cycle)
  216. and is_readable(cycle.id)
  217. and core_get(cycle.id) is not None
  218. and self.scenario_by_cycle
  219. ):
  220. return [
  221. cycle.id,
  222. cycle.get_simple_label(),
  223. self.get_sorted_scenario_list(self.scenario_by_cycle.get(cycle, []), sorts),
  224. _EntityType.CYCLE.value,
  225. False,
  226. ]
  227. except Exception as e:
  228. _warn(
  229. f"Access to {type(cycle).__name__} " + f"({cycle.id if hasattr(cycle, 'id') else 'No_id'})" + " failed",
  230. e,
  231. )
  232. return None
  233. def scenario_adapter(self, scenario: Scenario):
  234. self.__lazy_start()
  235. if isinstance(scenario, (tuple, list)):
  236. return scenario
  237. try:
  238. if isinstance(scenario, Scenario) and is_readable(scenario.id) and core_get(scenario.id) is not None:
  239. return [
  240. scenario.id,
  241. scenario.get_simple_label(),
  242. None,
  243. _EntityType.SCENARIO.value,
  244. scenario.is_primary,
  245. ]
  246. except Exception as e:
  247. _warn(
  248. f"Access to {type(scenario).__name__} "
  249. + f"({scenario.id if hasattr(scenario, 'id') else 'No_id'})"
  250. + " failed",
  251. e,
  252. )
  253. return None
  254. def filter_entities(
  255. self, cycle_scenario: t.List, col: str, col_type: str, is_dn: bool, action: str, val: t.Any, col_fn=None
  256. ):
  257. cycle_scenario[2] = [
  258. e for e in cycle_scenario[2] if _invoke_action(e, col, col_type, is_dn, action, val, col_fn)
  259. ]
  260. return cycle_scenario
  261. def adapt_scenarios(self, cycle: t.List):
  262. cycle[2] = [self.scenario_adapter(e) for e in cycle[2]]
  263. return cycle
  264. def get_sorted_scenario_list(
  265. self,
  266. entities: t.Union[t.List[t.Union[Cycle, Scenario]], t.List[Scenario]],
  267. sorts: t.Optional[t.List[t.Dict[str, t.Any]]],
  268. ):
  269. if sorts:
  270. sorted_list = entities
  271. for sd in reversed(sorts):
  272. col = sd.get("col", "")
  273. order = sd.get("order", True)
  274. sorted_list = sorted(sorted_list, key=_get_entity_property(col, Scenario, Cycle), reverse=not order)
  275. else:
  276. sorted_list = sorted(entities, key=_get_entity_property("creation_date", Scenario, Cycle))
  277. return [self.cycle_adapter(e, sorts) if isinstance(e, Cycle) else e for e in sorted_list]
  278. def get_filtered_scenario_list(
  279. self,
  280. entities: t.List[t.Union[t.List, Scenario]],
  281. filters: t.Optional[t.List[t.Dict[str, t.Any]]],
  282. ):
  283. if not filters:
  284. return entities
  285. # filtering
  286. filtered_list = list(entities)
  287. for fd in filters:
  288. col = fd.get("col", "")
  289. is_datanode_prop = _GuiCoreScenarioProperties.is_datanode_property(col)
  290. col_type = fd.get("type", "no type")
  291. col_fn = cp[0] if (cp := col.split("(")) and len(cp) > 1 else None
  292. val = fd.get("value")
  293. action = fd.get("action", "")
  294. customs = CustomScenarioFilter._get_custom(col)
  295. if customs:
  296. with self.gui._set_locals_context(customs[0] or None):
  297. fn = self.gui._get_user_function(customs[1])
  298. if callable(fn):
  299. col = fn
  300. if (
  301. isinstance(col, str)
  302. and next(filter(lambda s: not s.isidentifier(), (col_fn or col).split(".")), False) is True
  303. ):
  304. _warn(f'Error filtering with "{col_fn or col}": not a valid Python identifier.')
  305. continue
  306. # level 1 filtering
  307. filtered_list = [
  308. e
  309. for e in filtered_list
  310. if not isinstance(e, Scenario)
  311. or _invoke_action(e, col, col_type, is_datanode_prop, action, val, col_fn)
  312. ]
  313. # level 2 filtering
  314. filtered_list = [
  315. e
  316. if isinstance(e, Scenario)
  317. else self.filter_entities(e, col, col_type, is_datanode_prop, action, val, col_fn)
  318. for e in filtered_list
  319. ]
  320. # remove empty cycles
  321. return [e for e in filtered_list if isinstance(e, Scenario) or (isinstance(e, (tuple, list)) and len(e[2]))]
  322. def get_scenarios(
  323. self,
  324. scenarios: t.Optional[t.List[t.Union[Cycle, Scenario]]],
  325. filters: t.Optional[t.List[t.Dict[str, t.Any]]],
  326. sorts: t.Optional[t.List[t.Dict[str, t.Any]]],
  327. ):
  328. self.__lazy_start()
  329. cycles_scenarios: t.List[t.Union[Cycle, Scenario]] = []
  330. with self.lock:
  331. # always needed to get scenarios for a cycle in cycle_adapter
  332. if self.scenario_by_cycle is None:
  333. self.scenario_by_cycle = get_cycles_scenarios()
  334. if scenarios is None:
  335. for cycle, c_scenarios in self.scenario_by_cycle.items():
  336. if cycle is None:
  337. cycles_scenarios.extend(c_scenarios)
  338. else:
  339. cycles_scenarios.append(cycle)
  340. if scenarios is not None:
  341. cycles_scenarios = scenarios
  342. adapted_list = self.get_sorted_scenario_list(cycles_scenarios, sorts)
  343. adapted_list = self.get_filtered_scenario_list(adapted_list, filters)
  344. return adapted_list
  345. def select_scenario(self, state: State, id: str, payload: t.Dict[str, str]):
  346. self.__lazy_start()
  347. args = payload.get("args")
  348. if args is None or not isinstance(args, list) or len(args) < 2:
  349. return
  350. state.assign(args[0], args[1])
  351. def get_scenario_by_id(self, id: str) -> t.Optional[Scenario]:
  352. self.__lazy_start()
  353. if not id or not is_readable(t.cast(ScenarioId, id)):
  354. return None
  355. try:
  356. return core_get(t.cast(ScenarioId, id))
  357. except Exception:
  358. return None
  359. def get_scenario_configs(self):
  360. self.__lazy_start()
  361. with self.lock:
  362. if self.scenario_configs is None:
  363. configs = Config.scenarios
  364. if isinstance(configs, dict):
  365. self.scenario_configs = [(id, f"{c.id}") for id, c in configs.items() if id != "default"]
  366. return self.scenario_configs
  367. def crud_scenario(self, state: State, id: str, payload: t.Dict[str, str]): # noqa: C901
  368. self.__lazy_start()
  369. args = payload.get("args")
  370. start_idx = 3
  371. if (
  372. args is None
  373. or not isinstance(args, list)
  374. or len(args) < start_idx + 3
  375. or not isinstance(args[start_idx], bool)
  376. or not isinstance(args[start_idx + 1], bool)
  377. or not isinstance(args[start_idx + 2], dict)
  378. ):
  379. return
  380. error_var = payload.get("error_id")
  381. update = args[start_idx]
  382. delete = args[start_idx + 1]
  383. data = args[start_idx + 2]
  384. with_dialog = True if len(args) < start_idx + 4 else bool(args[start_idx + 3])
  385. scenario = None
  386. user_scenario = None
  387. name = data.get(_GuiCoreContext.__PROP_ENTITY_NAME)
  388. if update:
  389. scenario_id = data.get(_GuiCoreContext.__PROP_ENTITY_ID)
  390. if delete:
  391. if not (reason := is_deletable(scenario_id)):
  392. state.assign(error_var, f"Scenario. {scenario_id} is not deletable: {_get_reason(reason)}.")
  393. return
  394. try:
  395. core_delete(scenario_id)
  396. except Exception as e:
  397. state.assign(error_var, f"Error deleting Scenario. {e}")
  398. else:
  399. if not self.__check_readable_editable(state, scenario_id, "Scenario", error_var):
  400. return
  401. scenario = core_get(scenario_id)
  402. else:
  403. if with_dialog:
  404. config_id = data.get(_GuiCoreContext.__PROP_CONFIG_ID)
  405. scenario_config = Config.scenarios.get(config_id)
  406. if with_dialog and scenario_config is None:
  407. state.assign(error_var, f"Invalid configuration id ({config_id})")
  408. return
  409. date_str = data.get(_GuiCoreContext.__PROP_DATE)
  410. try:
  411. date = parser.parse(date_str) if isinstance(date_str, str) else None
  412. except Exception as e:
  413. state.assign(error_var, f"Invalid date ({date_str}).{e}")
  414. return
  415. else:
  416. scenario_config = None
  417. date = None
  418. scenario_id = None
  419. try:
  420. gui = state.get_gui()
  421. on_creation = args[0] if isinstance(args[0], str) else None
  422. on_creation_function = gui._get_user_function(on_creation) if on_creation else None
  423. if callable(on_creation_function):
  424. try:
  425. res = gui._call_function_with_state(
  426. on_creation_function,
  427. [
  428. id,
  429. {
  430. "action": on_creation,
  431. "config": scenario_config,
  432. "date": date,
  433. "label": name,
  434. "properties": {v.get("key"): v.get("value") for v in data.get("properties", {})},
  435. },
  436. ],
  437. )
  438. if isinstance(res, Scenario):
  439. # everything's fine
  440. user_scenario = res
  441. scenario_id = user_scenario.id
  442. state.assign(error_var, "")
  443. return
  444. if res:
  445. # do not create
  446. state.assign(error_var, f"{res}")
  447. return
  448. except Exception as e: # pragma: no cover
  449. if not gui._call_on_exception(on_creation, e):
  450. _warn(f"on_creation(): Exception raised in '{on_creation}()'", e)
  451. state.assign(
  452. error_var,
  453. f"Error creating Scenario with '{on_creation}()'. {e}",
  454. )
  455. return
  456. elif on_creation is not None:
  457. _warn(f"on_creation(): '{on_creation}' is not a function.")
  458. elif not with_dialog:
  459. if len(Config.scenarios) == 2:
  460. scenario_config = next(sc for k, sc in Config.scenarios.items() if k != "default")
  461. else:
  462. state.assign(
  463. error_var,
  464. "Error creating Scenario: only one scenario config needed "
  465. + f"({len(Config.scenarios) - 1}) found.",
  466. )
  467. return
  468. scenario = create_scenario(scenario_config, date, name)
  469. scenario_id = scenario.id
  470. except Exception as e:
  471. state.assign(error_var, f"Error creating Scenario. {e}")
  472. finally:
  473. self.scenario_refresh(scenario_id)
  474. if (scenario or user_scenario) and (sel_scenario_var := args[1] if isinstance(args[1], str) else None):
  475. try:
  476. var_name, _ = gui._get_real_var_name(sel_scenario_var)
  477. self.gui._update_var(var_name, scenario or user_scenario, on_change=args[2])
  478. except Exception as e: # pragma: no cover
  479. _warn("Can't find value variable name in context", e)
  480. if scenario:
  481. if not (reason := is_editable(scenario)):
  482. state.assign(error_var, f"Scenario {scenario_id or name} is not editable: {_get_reason(reason)}.")
  483. return
  484. with scenario as sc:
  485. sc.properties[_GuiCoreContext.__PROP_ENTITY_NAME] = name
  486. if props := data.get("properties"):
  487. try:
  488. new_keys = [prop.get("key") for prop in props]
  489. for key in t.cast(dict, sc.properties).keys():
  490. if key and key not in _GuiCoreContext.__ENTITY_PROPS and key not in new_keys:
  491. t.cast(dict, sc.properties).pop(key, None)
  492. for prop in props:
  493. key = prop.get("key")
  494. if key and key not in _GuiCoreContext.__ENTITY_PROPS:
  495. sc._properties[key] = prop.get("value")
  496. state.assign(error_var, "")
  497. except Exception as e:
  498. state.assign(error_var, f"Error creating Scenario. {e}")
  499. @staticmethod
  500. def __assign_var(state: State, var_name: t.Optional[str], msg: str):
  501. if var_name:
  502. state.assign(var_name, msg)
  503. def edit_entity(self, state: State, id: str, payload: t.Dict[str, str]):
  504. self.__lazy_start()
  505. args = payload.get("args")
  506. if args is None or not isinstance(args, list) or len(args) < 1 or not isinstance(args[0], dict):
  507. return
  508. error_var = payload.get("error_id")
  509. data = args[0]
  510. entity_id = data.get(_GuiCoreContext.__PROP_ENTITY_ID)
  511. sequence = data.get("sequence")
  512. if not self.__check_readable_editable(state, entity_id, "Scenario", error_var):
  513. return
  514. scenario: Scenario = core_get(entity_id)
  515. if scenario:
  516. try:
  517. if not sequence:
  518. if isinstance(sequence, str) and (name := data.get(_GuiCoreContext.__PROP_ENTITY_NAME)):
  519. scenario.add_sequence(name, data.get("task_ids"))
  520. else:
  521. primary = data.get(_GuiCoreContext.__PROP_SCENARIO_PRIMARY)
  522. if primary is True:
  523. if not (reason := is_promotable(scenario)):
  524. _GuiCoreContext.__assign_var(
  525. state, error_var, f"Scenario {entity_id} is not promotable: {_get_reason(reason)}."
  526. )
  527. return
  528. set_primary(scenario)
  529. self.__edit_properties(scenario, data)
  530. else:
  531. if data.get("del", False):
  532. scenario.remove_sequence(sequence)
  533. else:
  534. name = data.get(_GuiCoreContext.__PROP_ENTITY_NAME)
  535. if sequence != name:
  536. scenario.rename_sequence(sequence, name)
  537. if seqEntity := scenario.sequences.get(name):
  538. seqEntity.tasks = data.get("task_ids")
  539. self.__edit_properties(seqEntity, data)
  540. else:
  541. _GuiCoreContext.__assign_var(
  542. state,
  543. error_var,
  544. f"Sequence {name} is not available in Scenario {entity_id}.",
  545. )
  546. return
  547. _GuiCoreContext.__assign_var(state, error_var, "")
  548. except Exception as e:
  549. _GuiCoreContext.__assign_var(state, error_var, f"Error updating {type(scenario).__name__}. {e}")
  550. def submit_entity(self, state: State, id: str, payload: t.Dict[str, str]):
  551. self.__lazy_start()
  552. args = payload.get("args")
  553. if args is None or not isinstance(args, list) or len(args) < 1 or not isinstance(args[0], dict):
  554. return
  555. data = args[0]
  556. error_var = payload.get("error_id")
  557. try:
  558. scenario_id = data.get(_GuiCoreContext.__PROP_ENTITY_ID)
  559. entity = core_get(scenario_id)
  560. if sequence := data.get("sequence"):
  561. entity = entity.sequences.get(sequence)
  562. if not (reason := is_submittable(entity)):
  563. _GuiCoreContext.__assign_var(
  564. state,
  565. error_var,
  566. f"{'Sequence' if sequence else 'Scenario'} {sequence or scenario_id} is not submittable: "
  567. + f"{_get_reason(reason)}.",
  568. )
  569. return
  570. if entity:
  571. on_submission = data.get("on_submission_change")
  572. submission_entity = core_submit(
  573. entity,
  574. on_submission=on_submission,
  575. client_id=self.gui._get_client_id(),
  576. module_context=self.gui._get_locals_context(),
  577. )
  578. with self.submissions_lock:
  579. self.client_submission[submission_entity.id] = submission_entity.submission_status
  580. if Config.core.mode == "development":
  581. with self.submissions_lock:
  582. self.client_submission[submission_entity.id] = SubmissionStatus.SUBMITTED
  583. self.submission_status_callback(submission_entity.id)
  584. _GuiCoreContext.__assign_var(state, error_var, "")
  585. except Exception as e:
  586. _GuiCoreContext.__assign_var(state, error_var, f"Error submitting entity. {e}")
  587. def get_filtered_datanode_list(
  588. self,
  589. entities: t.List[t.Union[t.List, DataNode]],
  590. filters: t.Optional[t.List[t.Dict[str, t.Any]]],
  591. ):
  592. if not filters or not entities:
  593. return entities
  594. # filtering
  595. filtered_list = list(entities)
  596. for fd in filters:
  597. col = fd.get("col", "")
  598. col_type = fd.get("type", "no type")
  599. col_fn = cp[0] if (cp := col.split("(")) and len(cp) > 1 else None
  600. val = fd.get("value")
  601. action = fd.get("action", "")
  602. customs = CustomScenarioFilter._get_custom(col)
  603. if customs:
  604. with self.gui._set_locals_context(customs[0] or None):
  605. fn = self.gui._get_user_function(customs[1])
  606. if callable(fn):
  607. col = fn
  608. if (
  609. isinstance(col, str)
  610. and next(filter(lambda s: not s.isidentifier(), (col_fn or col).split(".")), False) is True
  611. ):
  612. _warn(f'Error filtering with "{col_fn or col}": not a valid Python identifier.')
  613. continue
  614. # level 1 filtering
  615. filtered_list = [
  616. e
  617. for e in filtered_list
  618. if not isinstance(e, DataNode) or _invoke_action(e, col, col_type, False, action, val, col_fn)
  619. ]
  620. # level 3 filtering
  621. filtered_list = [
  622. e if isinstance(e, DataNode) else self.filter_entities(d, col, col_type, False, action, val, col_fn)
  623. for e in filtered_list
  624. for d in e[2]
  625. ]
  626. # remove empty cycles
  627. return [e for e in filtered_list if isinstance(e, DataNode) or (isinstance(e, (tuple, list)) and len(e[2]))]
  628. def get_sorted_datanode_list(
  629. self,
  630. entities: t.Union[
  631. t.List[t.Union[Cycle, Scenario, DataNode]], t.List[t.Union[Scenario, DataNode]], t.List[DataNode]
  632. ],
  633. sorts: t.Optional[t.List[t.Dict[str, t.Any]]],
  634. adapt_dn=False,
  635. ):
  636. if not entities:
  637. return entities
  638. if sorts:
  639. sorted_list = entities
  640. for sd in reversed(sorts):
  641. col = sd.get("col", "")
  642. order = sd.get("order", True)
  643. sorted_list = sorted(sorted_list, key=_get_entity_property(col, DataNode), reverse=not order)
  644. else:
  645. sorted_list = entities
  646. return [self.data_node_adapter(e, sorts, adapt_dn) for e in sorted_list]
  647. def __do_datanodes_tree(self):
  648. if self.data_nodes_by_owner is None:
  649. self.data_nodes_by_owner = defaultdict(list)
  650. for dn in get_data_nodes():
  651. self.data_nodes_by_owner[dn.owner_id].append(dn)
  652. def get_datanodes_tree(
  653. self,
  654. scenarios: t.Optional[t.Union[Scenario, t.List[Scenario]]],
  655. datanodes: t.Optional[t.List[DataNode]],
  656. filters: t.Optional[t.List[t.Dict[str, t.Any]]],
  657. sorts: t.Optional[t.List[t.Dict[str, t.Any]]],
  658. ):
  659. self.__lazy_start()
  660. base_list = []
  661. with self.lock:
  662. self.__do_datanodes_tree()
  663. if datanodes is None:
  664. if scenarios is None:
  665. base_list = (self.data_nodes_by_owner or {}).get(None, []) + (
  666. self.get_scenarios(None, None, None) or []
  667. )
  668. else:
  669. if isinstance(scenarios, (list, tuple)) and len(scenarios) > 1:
  670. base_list = scenarios
  671. else:
  672. if self.data_nodes_by_owner:
  673. owners = scenarios if isinstance(scenarios, (list, tuple)) else [scenarios]
  674. base_list = [d for owner in owners for d in (self.data_nodes_by_owner).get(owner.id, [])]
  675. else:
  676. base_list = []
  677. else:
  678. base_list = datanodes
  679. adapted_list = self.get_sorted_datanode_list(base_list, sorts)
  680. return self.get_filtered_datanode_list(adapted_list, filters)
  681. def data_node_adapter(
  682. self,
  683. data: t.Union[Cycle, Scenario, Sequence, DataNode],
  684. sorts: t.Optional[t.List[t.Dict[str, t.Any]]] = None,
  685. adapt_dn=True,
  686. ):
  687. self.__lazy_start()
  688. if isinstance(data, tuple):
  689. raise NotImplementedError
  690. if isinstance(data, list):
  691. if data[2] and isinstance(data[2][0], (Cycle, Scenario, Sequence, DataNode)):
  692. data[2] = self.get_sorted_datanode_list(data[2], sorts, False)
  693. return data
  694. try:
  695. if hasattr(data, "id") and is_readable(data.id) and core_get(data.id) is not None:
  696. if isinstance(data, DataNode):
  697. return (
  698. [data.id, data.get_simple_label(), None, _EntityType.DATANODE.value, False]
  699. if adapt_dn
  700. else data
  701. )
  702. with self.lock:
  703. self.__do_datanodes_tree()
  704. if self.data_nodes_by_owner:
  705. if isinstance(data, Cycle):
  706. return [
  707. data.id,
  708. data.get_simple_label(),
  709. self.get_sorted_datanode_list(
  710. self.data_nodes_by_owner.get(data.id, [])
  711. + (self.scenario_by_cycle or {}).get(data, []),
  712. sorts,
  713. False,
  714. ),
  715. _EntityType.CYCLE.value,
  716. False,
  717. ]
  718. elif isinstance(data, Scenario):
  719. return [
  720. data.id,
  721. data.get_simple_label(),
  722. self.get_sorted_datanode_list(
  723. self.data_nodes_by_owner.get(data.id, []) + list(data.sequences.values()),
  724. sorts,
  725. False,
  726. ),
  727. _EntityType.SCENARIO.value,
  728. data.is_primary,
  729. ]
  730. elif isinstance(data, Sequence):
  731. if datanodes := self.data_nodes_by_owner.get(data.id):
  732. return [
  733. data.id,
  734. data.get_simple_label(),
  735. self.get_sorted_datanode_list(datanodes, sorts, False),
  736. _EntityType.SEQUENCE.value,
  737. ]
  738. except Exception as e:
  739. _warn(
  740. f"Access to {type(data)} ({data.id if hasattr(data, 'id') else 'No_id'}) failed",
  741. e,
  742. )
  743. return None
  744. def get_jobs_list(self):
  745. self.__lazy_start()
  746. with self.lock:
  747. if self.jobs_list is None:
  748. self.jobs_list = get_jobs()
  749. return self.jobs_list
  750. def job_adapter(self, job):
  751. self.__lazy_start()
  752. try:
  753. if hasattr(job, "id") and is_readable(job.id) and core_get(job.id) is not None:
  754. if isinstance(job, Job):
  755. entity = core_get(job.owner_id)
  756. return (
  757. job.id,
  758. job.get_simple_label(),
  759. [],
  760. entity.get_simple_label() if entity else "",
  761. entity.id if entity else "",
  762. job.submit_id,
  763. job.creation_date,
  764. job.status.value,
  765. _get_reason(is_deletable(job)),
  766. _get_reason(is_readable(job)),
  767. _get_reason(is_editable(job)),
  768. )
  769. except Exception as e:
  770. _warn(f"Access to job ({job.id if hasattr(job, 'id') else 'No_id'}) failed", e)
  771. return None
  772. def act_on_jobs(self, state: State, id: str, payload: t.Dict[str, str]):
  773. self.__lazy_start()
  774. args = payload.get("args")
  775. if args is None or not isinstance(args, list) or len(args) < 1 or not isinstance(args[0], dict):
  776. return
  777. data = args[0]
  778. job_ids = data.get(_GuiCoreContext.__PROP_ENTITY_ID)
  779. job_action = data.get(_GuiCoreContext.__ACTION)
  780. if job_action and isinstance(job_ids, list):
  781. errs = []
  782. if job_action == "delete":
  783. for job_id in job_ids:
  784. if not (reason := is_readable(job_id)):
  785. errs.append(f"Job {job_id} is not readable: {_get_reason(reason)}.")
  786. continue
  787. if not (reason := is_deletable(job_id)):
  788. errs.append(f"Job {job_id} is not deletable: {_get_reason(reason)}.")
  789. continue
  790. try:
  791. delete_job(core_get(job_id))
  792. except Exception as e:
  793. errs.append(f"Error deleting job. {e}")
  794. elif job_action == "cancel":
  795. for job_id in job_ids:
  796. if not (reason := is_readable(job_id)):
  797. errs.append(f"Job {job_id} is not readable: {_get_reason(reason)}.")
  798. continue
  799. if not (reason := is_editable(job_id)):
  800. errs.append(f"Job {job_id} is not cancelable: {_get_reason(reason)}.")
  801. continue
  802. try:
  803. cancel_job(job_id)
  804. except Exception as e:
  805. errs.append(f"Error canceling job. {e}")
  806. _GuiCoreContext.__assign_var(state, payload.get("error_id"), "<br/>".join(errs) if errs else "")
  807. def edit_data_node(self, state: State, id: str, payload: t.Dict[str, str]):
  808. self.__lazy_start()
  809. args = payload.get("args")
  810. if args is None or not isinstance(args, list) or len(args) < 1 or not isinstance(args[0], dict):
  811. return
  812. error_var = payload.get("error_id")
  813. data = args[0]
  814. entity_id = data.get(_GuiCoreContext.__PROP_ENTITY_ID)
  815. if not self.__check_readable_editable(state, entity_id, "DataNode", error_var):
  816. return
  817. entity: DataNode = core_get(entity_id)
  818. if isinstance(entity, DataNode):
  819. try:
  820. self.__edit_properties(entity, data)
  821. _GuiCoreContext.__assign_var(state, error_var, "")
  822. except Exception as e:
  823. _GuiCoreContext.__assign_var(state, error_var, f"Error updating Datanode. {e}")
  824. def lock_datanode_for_edit(self, state: State, id: str, payload: t.Dict[str, str]):
  825. self.__lazy_start()
  826. args = payload.get("args")
  827. if args is None or not isinstance(args, list) or len(args) < 1 or not isinstance(args[0], dict):
  828. return
  829. data = args[0]
  830. error_var = payload.get("error_id")
  831. entity_id = data.get(_GuiCoreContext.__PROP_ENTITY_ID)
  832. if not self.__check_readable_editable(state, entity_id, "Datanode", error_var):
  833. return
  834. lock = data.get("lock", True)
  835. entity: DataNode = core_get(entity_id)
  836. if isinstance(entity, DataNode):
  837. try:
  838. if lock:
  839. entity.lock_edit(self.gui._get_client_id())
  840. else:
  841. entity.unlock_edit(self.gui._get_client_id())
  842. _GuiCoreContext.__assign_var(state, error_var, "")
  843. except Exception as e:
  844. _GuiCoreContext.__assign_var(state, error_var, f"Error locking Datanode. {e}")
  845. def __edit_properties(self, entity: t.Union[Scenario, Sequence, DataNode], data: t.Dict[str, str]):
  846. with entity as ent:
  847. if isinstance(ent, Scenario):
  848. tags = data.get(_GuiCoreContext.__PROP_SCENARIO_TAGS)
  849. if isinstance(tags, (list, tuple)):
  850. ent.tags = set(tags)
  851. name = data.get(_GuiCoreContext.__PROP_ENTITY_NAME)
  852. if isinstance(name, str):
  853. if hasattr(ent, _GuiCoreContext.__PROP_ENTITY_NAME):
  854. setattr(ent, _GuiCoreContext.__PROP_ENTITY_NAME, name)
  855. else:
  856. ent.properties[_GuiCoreContext.__PROP_ENTITY_NAME] = name
  857. props = data.get("properties")
  858. if isinstance(props, (list, tuple)):
  859. for prop in props:
  860. key = prop.get("key")
  861. if key and key not in _GuiCoreContext.__ENTITY_PROPS:
  862. ent.properties[key] = prop.get("value")
  863. deleted_props = data.get("deleted_properties")
  864. if isinstance(deleted_props, (list, tuple)):
  865. for prop in deleted_props:
  866. key = prop.get("key")
  867. if key and key not in _GuiCoreContext.__ENTITY_PROPS:
  868. ent.properties.pop(key, None)
  869. def get_scenarios_for_owner(self, owner_id: str):
  870. self.__lazy_start()
  871. cycles_scenarios: t.List[t.Union[Scenario, Cycle]] = []
  872. with self.lock:
  873. if self.scenario_by_cycle is None:
  874. self.scenario_by_cycle = get_cycles_scenarios()
  875. if owner_id:
  876. if owner_id == "GLOBAL":
  877. for cycle, scenarios in self.scenario_by_cycle.items():
  878. if cycle is None:
  879. cycles_scenarios.extend(scenarios)
  880. else:
  881. cycles_scenarios.append(cycle)
  882. elif is_readable(t.cast(ScenarioId, owner_id)):
  883. entity = core_get(owner_id)
  884. if entity and (scenarios_cycle := self.scenario_by_cycle.get(t.cast(Cycle, entity))):
  885. cycles_scenarios.extend(scenarios_cycle)
  886. elif isinstance(entity, Scenario):
  887. cycles_scenarios.append(entity)
  888. return sorted(cycles_scenarios, key=_get_entity_property("creation_date", Scenario))
  889. def get_data_node_history(self, id: str):
  890. self.__lazy_start()
  891. if id and (dn := core_get(id)) and isinstance(dn, DataNode):
  892. res = []
  893. for e in dn.edits:
  894. job_id = e.get("job_id")
  895. job: t.Optional[Job] = None
  896. if job_id:
  897. if not (reason := is_readable(job_id)):
  898. job_id += f" is not readable: {_get_reason(reason)}."
  899. else:
  900. job = core_get(job_id)
  901. res.append(
  902. (
  903. e.get("timestamp"),
  904. job_id if job_id else e.get("writer_identifier", ""),
  905. f"Execution of task {job.task.get_simple_label()}."
  906. if job and job.task
  907. else e.get("comment", ""),
  908. )
  909. )
  910. return sorted(res, key=lambda r: r[0], reverse=True)
  911. return _DoNotUpdate()
  912. def __check_readable_editable(self, state: State, id: str, ent_type: str, var: t.Optional[str]):
  913. if not (reason := is_readable(t.cast(ScenarioId, id))):
  914. _GuiCoreContext.__assign_var(state, var, f"{ent_type} {id} is not readable: {_get_reason(reason)}.")
  915. return False
  916. if not (reason := is_editable(t.cast(ScenarioId, id))):
  917. _GuiCoreContext.__assign_var(state, var, f"{ent_type} {id} is not editable: {_get_reason(reason)}.")
  918. return False
  919. return True
  920. def update_data(self, state: State, id: str, payload: t.Dict[str, str]):
  921. self.__lazy_start()
  922. args = payload.get("args")
  923. if args is None or not isinstance(args, list) or len(args) < 1 or not isinstance(args[0], dict):
  924. return
  925. data = args[0]
  926. error_var = payload.get("error_id")
  927. entity_id = data.get(_GuiCoreContext.__PROP_ENTITY_ID)
  928. if not self.__check_readable_editable(state, entity_id, "DataNode", error_var):
  929. return
  930. entity: DataNode = core_get(entity_id)
  931. if isinstance(entity, DataNode):
  932. try:
  933. entity.write(
  934. parser.parse(data.get("value"))
  935. if data.get("type") == "date"
  936. else int(data.get("value"))
  937. if data.get("type") == "int"
  938. else float(data.get("value"))
  939. if data.get("type") == "float"
  940. else data.get("value"),
  941. comment=data.get(_GuiCoreContext.__PROP_ENTITY_COMMENT),
  942. )
  943. entity.unlock_edit(self.gui._get_client_id())
  944. _GuiCoreContext.__assign_var(state, error_var, "")
  945. except Exception as e:
  946. _GuiCoreContext.__assign_var(state, error_var, f"Error updating Datanode value. {e}")
  947. _GuiCoreContext.__assign_var(state, payload.get("data_id"), entity_id) # this will update the data value
  948. def tabular_data_edit(self, state: State, var_name: str, payload: dict): # noqa:C901
  949. self.__lazy_start()
  950. error_var = payload.get("error_id")
  951. user_data = payload.get("user_data", {})
  952. dn_id = user_data.get("dn_id")
  953. if not self.__check_readable_editable(state, dn_id, "DataNode", error_var):
  954. return
  955. datanode = core_get(dn_id) if dn_id else None
  956. if isinstance(datanode, DataNode):
  957. try:
  958. idx = t.cast(int, payload.get("index"))
  959. col = t.cast(str, payload.get("col"))
  960. tz = payload.get("tz")
  961. val = (
  962. parser.parse(str(payload.get("value"))).astimezone(zoneinfo.ZoneInfo(tz)).replace(tzinfo=None)
  963. if tz is not None
  964. else payload.get("value")
  965. )
  966. # user_value = payload.get("user_value")
  967. data = self.__read_tabular_data(datanode)
  968. new_data: t.Any = None
  969. if isinstance(data, (pd.DataFrame, pd.Series)):
  970. if isinstance(data, pd.DataFrame):
  971. data.at[idx, col] = val
  972. elif isinstance(data, pd.Series):
  973. data.at[idx] = val
  974. new_data = data
  975. elif isinstance(data, (dict, _MapDict)):
  976. row = data.get(col, None)
  977. data_tuple = False
  978. if isinstance(row, tuple):
  979. row = list(row)
  980. data_tuple = True
  981. if isinstance(row, list):
  982. row[idx] = val
  983. if data_tuple:
  984. data[col] = tuple(row)
  985. new_data = data
  986. else:
  987. _GuiCoreContext.__assign_var(
  988. state,
  989. error_var,
  990. "Error updating Datanode: dict values must be list or tuple.",
  991. )
  992. else:
  993. data_tuple = False
  994. if isinstance(data, tuple):
  995. data_tuple = True
  996. data = list(data)
  997. if isinstance(data, list):
  998. row = data[idx]
  999. row_tuple = False
  1000. if isinstance(row, tuple):
  1001. row = list(row)
  1002. row_tuple = True
  1003. if isinstance(row, list):
  1004. row[int(col)] = val
  1005. if row_tuple:
  1006. data[idx] = tuple(row)
  1007. new_data = data
  1008. elif col == "0" and (isinstance(row, (str, Number)) or "date" in type(row).__name__):
  1009. data[idx] = val
  1010. new_data = data
  1011. else:
  1012. _GuiCoreContext.__assign_var(
  1013. state,
  1014. error_var,
  1015. "Error updating Datanode: cannot handle multi-column list value.",
  1016. )
  1017. if data_tuple and new_data is not None:
  1018. new_data = tuple(new_data)
  1019. else:
  1020. _GuiCoreContext.__assign_var(
  1021. state,
  1022. error_var,
  1023. "Error updating Datanode tabular value: type does not support at[] indexer.",
  1024. )
  1025. if new_data is not None:
  1026. datanode.write(new_data, comment=user_data.get(_GuiCoreContext.__PROP_ENTITY_COMMENT))
  1027. _GuiCoreContext.__assign_var(state, error_var, "")
  1028. except Exception as e:
  1029. _GuiCoreContext.__assign_var(state, error_var, f"Error updating Datanode tabular value. {e}")
  1030. _GuiCoreContext.__assign_var(state, payload.get("data_id"), dn_id)
  1031. def get_data_node_properties(self, id: str):
  1032. self.__lazy_start()
  1033. if id and is_readable(t.cast(DataNodeId, id)) and (dn := core_get(id)) and isinstance(dn, DataNode):
  1034. try:
  1035. return (
  1036. (
  1037. (k, f"{v}")
  1038. for k, v in dn._get_user_properties().items()
  1039. if k != _GuiCoreContext.__PROP_ENTITY_NAME
  1040. ),
  1041. )
  1042. except Exception:
  1043. return None
  1044. return None
  1045. def __read_tabular_data(self, datanode: DataNode):
  1046. return datanode.read()
  1047. def get_data_node_tabular_data(self, id: str):
  1048. self.__lazy_start()
  1049. if id and is_readable(t.cast(DataNodeId, id)) and (dn := core_get(id)) and isinstance(dn, DataNode):
  1050. if dn.is_ready_for_reading or (dn.edit_in_progress and dn.editor_id == self.gui._get_client_id()):
  1051. try:
  1052. value = self.__read_tabular_data(dn)
  1053. if _GuiCoreDatanodeAdapter._is_tabular_data(dn, value):
  1054. return value
  1055. except Exception:
  1056. return None
  1057. return None
  1058. def get_data_node_tabular_columns(self, id: str):
  1059. self.__lazy_start()
  1060. if id and is_readable(t.cast(DataNodeId, id)) and (dn := core_get(id)) and isinstance(dn, DataNode):
  1061. if dn.is_ready_for_reading or (dn.edit_in_progress and dn.editor_id == self.gui._get_client_id()):
  1062. try:
  1063. value = self.__read_tabular_data(dn)
  1064. if _GuiCoreDatanodeAdapter._is_tabular_data(dn, value):
  1065. return self.gui._tbl_cols(
  1066. True, True, "{}", json.dumps({"data": "tabular_data"}), tabular_data=value
  1067. )
  1068. except Exception:
  1069. return None
  1070. return None
  1071. def get_data_node_chart_config(self, id: str):
  1072. self.__lazy_start()
  1073. if id and is_readable(t.cast(DataNodeId, id)) and (dn := core_get(id)) and isinstance(dn, DataNode):
  1074. if dn.is_ready_for_reading or (dn.edit_in_progress and dn.editor_id == self.gui._get_client_id()):
  1075. try:
  1076. return self.gui._chart_conf(
  1077. True,
  1078. True,
  1079. "{}",
  1080. json.dumps({"data": "tabular_data"}),
  1081. tabular_data=self.__read_tabular_data(dn),
  1082. )
  1083. except Exception:
  1084. return None
  1085. return None
  1086. def on_dag_select(self, state: State, id: str, payload: t.Dict[str, str]):
  1087. self.__lazy_start()
  1088. args = payload.get("args")
  1089. if args is None or not isinstance(args, list) or len(args) < 2:
  1090. return
  1091. on_action_function = self.gui._get_user_function(args[1]) if args[1] else None
  1092. if callable(on_action_function):
  1093. try:
  1094. entity = (
  1095. core_get(args[0])
  1096. if (reason := is_readable(args[0]))
  1097. else f"{args[0]} is not readable: {_get_reason(reason)}"
  1098. )
  1099. self.gui._call_function_with_state(
  1100. on_action_function,
  1101. [entity],
  1102. )
  1103. except Exception as e:
  1104. if not self.gui._call_on_exception(args[1], e):
  1105. _warn(f"dag.on_action(): Exception raised in '{args[1]}()' with '{args[0]}'", e)
  1106. elif args[1]:
  1107. _warn(f"dag.on_action(): Invalid function '{args[1]}()'.")
  1108. def get_creation_reason(self):
  1109. self.__lazy_start()
  1110. return "" if (reason := can_create()) else f"Cannot create scenario: {_get_reason(reason)}"
  1111. def _get_reason(reason: t.Union[bool, ReasonCollection]):
  1112. return reason.reasons if isinstance(reason, ReasonCollection) else " "