algomancy-gui 0.3.16__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.
Files changed (46) hide show
  1. algomancy_gui/__init__.py +0 -0
  2. algomancy_gui/admin_page/__init__.py +1 -0
  3. algomancy_gui/admin_page/admin.py +362 -0
  4. algomancy_gui/admin_page/sessions.py +57 -0
  5. algomancy_gui/appconfiguration.py +291 -0
  6. algomancy_gui/compare_page/__init__.py +1 -0
  7. algomancy_gui/compare_page/compare.py +360 -0
  8. algomancy_gui/compare_page/kpicard.py +236 -0
  9. algomancy_gui/compare_page/scenarioselector.py +99 -0
  10. algomancy_gui/componentids.py +177 -0
  11. algomancy_gui/contentregistry.py +167 -0
  12. algomancy_gui/cqmloader.py +58 -0
  13. algomancy_gui/data_page/__init__.py +1 -0
  14. algomancy_gui/data_page/data.py +77 -0
  15. algomancy_gui/data_page/datamanagementdeletemodal.py +260 -0
  16. algomancy_gui/data_page/datamanagementderivemodal.py +201 -0
  17. algomancy_gui/data_page/datamanagementdownloadmodal.py +193 -0
  18. algomancy_gui/data_page/datamanagementimportmodal.py +438 -0
  19. algomancy_gui/data_page/datamanagementsavemodal.py +191 -0
  20. algomancy_gui/data_page/datamanagementtopbar.py +123 -0
  21. algomancy_gui/data_page/datamanagementuploadmodal.py +366 -0
  22. algomancy_gui/data_page/dialogcallbacks.py +51 -0
  23. algomancy_gui/data_page/filenamematcher.py +109 -0
  24. algomancy_gui/defaultloader.py +36 -0
  25. algomancy_gui/gui_launcher.py +183 -0
  26. algomancy_gui/home_page/__init__.py +1 -0
  27. algomancy_gui/home_page/home.py +16 -0
  28. algomancy_gui/layout.py +199 -0
  29. algomancy_gui/layouthelpers.py +30 -0
  30. algomancy_gui/managergetters.py +28 -0
  31. algomancy_gui/overview_page/__init__.py +1 -0
  32. algomancy_gui/overview_page/overview.py +20 -0
  33. algomancy_gui/py.typed +0 -0
  34. algomancy_gui/scenario_page/__init__.py +0 -0
  35. algomancy_gui/scenario_page/delete_confirmation.py +29 -0
  36. algomancy_gui/scenario_page/new_scenario_creator.py +104 -0
  37. algomancy_gui/scenario_page/new_scenario_parameters_window.py +154 -0
  38. algomancy_gui/scenario_page/scenario_badge.py +36 -0
  39. algomancy_gui/scenario_page/scenario_cards.py +119 -0
  40. algomancy_gui/scenario_page/scenarios.py +596 -0
  41. algomancy_gui/sessionmanager.py +168 -0
  42. algomancy_gui/settingsmanager.py +43 -0
  43. algomancy_gui/stylingconfigurator.py +740 -0
  44. algomancy_gui-0.3.16.dist-info/METADATA +71 -0
  45. algomancy_gui-0.3.16.dist-info/RECORD +46 -0
  46. algomancy_gui-0.3.16.dist-info/WHEEL +4 -0
@@ -0,0 +1,596 @@
1
+ from dash import (
2
+ callback_context,
3
+ html,
4
+ dcc,
5
+ callback,
6
+ Output,
7
+ Input,
8
+ ALL,
9
+ get_app,
10
+ ctx,
11
+ no_update,
12
+ State,
13
+ )
14
+ from dash.exceptions import PreventUpdate
15
+
16
+ from algomancy_scenario import ScenarioStatus
17
+ from ..componentids import (
18
+ SCENARIO_PROCESS_BUTTON,
19
+ SCENARIO_CREATOR_MODAL,
20
+ SCENARIO_TAG_INPUT,
21
+ SCENARIO_DATA_INPUT,
22
+ SCENARIO_ALGO_INPUT,
23
+ ALGO_PARAMS_WINDOW_ID,
24
+ ALGO_PARAMS_ENTRY_CARD,
25
+ SCENARIO_NEW_BUTTON,
26
+ SCENARIO_DELETE_MODAL,
27
+ SCENARIO_DELETE_BUTTON,
28
+ SCENARIO_CONFIRM_DELETE_BUTTON,
29
+ SCENARIO_CANCEL_DELETE_BUTTON,
30
+ SCENARIO_PAGE,
31
+ ACTIVE_SESSION,
32
+ SCENARIO_LIST_UPDATE_STORE,
33
+ SCENARIO_TO_DELETE,
34
+ SCENARIO_SELECTED_ID_STORE,
35
+ SCENARIO_ALERT,
36
+ SCENARIO_CREATOR_OPEN_BUTTON,
37
+ SCENARIO_PROG_INTERVAL,
38
+ SCENARIO_CURRENTLY_RUNNING_STORE,
39
+ SCENARIO_PROG_TEXT,
40
+ SCENARIO_PROG_BAR,
41
+ SCENARIO_PROG_COLLAPSE,
42
+ SCENARIO_LIST,
43
+ SCENARIO_SELECTED,
44
+ SCENARIO_CARD,
45
+ )
46
+ from .new_scenario_parameters_window import (
47
+ create_algo_parameters_entry_card_body,
48
+ )
49
+ from .scenario_cards import scenario_cards
50
+ from ..contentregistry import ContentRegistry
51
+ from algomancy_gui.managergetters import get_scenario_manager, get_manager
52
+
53
+ from ..layouthelpers import create_wrapped_content_div
54
+ from .delete_confirmation import (
55
+ delete_confirmation_modal,
56
+ )
57
+ from .new_scenario_creator import new_scenario_creator
58
+
59
+ import dash_bootstrap_components as dbc
60
+
61
+ from .scenario_cards import hidden_card
62
+ from algomancy_scenario import ScenarioManager
63
+ from ..settingsmanager import SettingsManager
64
+
65
+
66
+ def scenario_page():
67
+ return html.Div(id=SCENARIO_PAGE)
68
+
69
+
70
+ # --- general page setup ---
71
+ def content_div() -> html.Div:
72
+ return html.Div(
73
+ id=SCENARIO_SELECTED,
74
+ className="mt-2 scenario-page-content",
75
+ )
76
+
77
+
78
+ @callback(
79
+ Output(SCENARIO_PAGE, "children"),
80
+ Input(ACTIVE_SESSION, "data"),
81
+ )
82
+ def render_scenario_page(active_session_name):
83
+ """
84
+ Creates the scenarios page layout with scenario management functionality.
85
+
86
+ This page allows users to create, view, process, and delete scenarios.
87
+
88
+ Returns:
89
+ html.Div: A Dash HTML component representing the scenarios page
90
+ """
91
+
92
+ settings: SettingsManager = get_app().server.settings
93
+ content = create_wrapped_content_div(
94
+ content_div(), settings.show_loading_on_scenariopage, settings.use_cqm_loader
95
+ )
96
+ page = [
97
+ html.H2("Manage Scenarios"),
98
+ new_scenario_creator(active_session_name),
99
+ delete_confirmation_modal(),
100
+ dcc.Store(id=SCENARIO_LIST_UPDATE_STORE),
101
+ dcc.Store(id=SCENARIO_TO_DELETE),
102
+ dcc.Store(id=SCENARIO_SELECTED_ID_STORE),
103
+ dbc.Alert(id=SCENARIO_ALERT, dismissable=True, is_open=False, color="danger"),
104
+ # Two-column main content area:
105
+ dbc.Row(
106
+ [
107
+ # Left: Compact scenario list
108
+ dbc.Col(
109
+ [
110
+ # Add the open modal button above the list
111
+ dbc.Button(
112
+ "Create New Scenario",
113
+ id=SCENARIO_CREATOR_OPEN_BUTTON,
114
+ className="mb-1 new-scenario-button",
115
+ ),
116
+ dbc.Collapse(
117
+ [
118
+ html.Div(
119
+ [
120
+ dcc.Interval(
121
+ id=SCENARIO_PROG_INTERVAL,
122
+ n_intervals=0,
123
+ interval=1000,
124
+ disabled=False,
125
+ ),
126
+ dcc.Store(id=SCENARIO_CURRENTLY_RUNNING_STORE),
127
+ html.P(
128
+ "Processing: placeholder",
129
+ id=SCENARIO_PROG_TEXT,
130
+ className="mt-2",
131
+ ),
132
+ dbc.Progress(
133
+ id=SCENARIO_PROG_BAR,
134
+ className="mt-2 scenario-progress-bar",
135
+ label="",
136
+ value=0,
137
+ ),
138
+ ]
139
+ )
140
+ ],
141
+ id=SCENARIO_PROG_COLLAPSE,
142
+ is_open=False,
143
+ ),
144
+ html.H4("Scenarios", className="mt-2"),
145
+ html.Div(
146
+ [
147
+ html.Div(
148
+ [hidden_card()],
149
+ id=SCENARIO_LIST,
150
+ style={
151
+ "overflowY": "auto",
152
+ "maxHeight": "70vh",
153
+ "minWidth": "200px",
154
+ "borderRight": "1px solid #ddd",
155
+ "paddingRight": "12px",
156
+ },
157
+ )
158
+ ],
159
+ style={
160
+ "height": "70vh",
161
+ "overflowY": "auto",
162
+ "backgroundColor": "var(--background-color)",
163
+ "borderRadius": "6px",
164
+ },
165
+ ),
166
+ ],
167
+ width=2,
168
+ style={"paddingLeft": "0", "paddingRight": "0"},
169
+ ),
170
+ # Right: Selected scenario details
171
+ dbc.Col(content, width=10, style={"paddingLeft": "24px"}),
172
+ ],
173
+ style={"height": "100%"},
174
+ ),
175
+ ]
176
+ return page
177
+
178
+
179
+ @callback(
180
+ Output(SCENARIO_LIST_UPDATE_STORE, "data", allow_duplicate=True),
181
+ Output(SCENARIO_SELECTED, "children", allow_duplicate=True),
182
+ Output(SCENARIO_SELECTED_ID_STORE, "data", allow_duplicate=True),
183
+ Input({"type": SCENARIO_CARD, "index": ALL}, "n_clicks"),
184
+ State(ACTIVE_SESSION, "data"),
185
+ prevent_initial_call=True,
186
+ )
187
+ def select_scenario(card_clicks, session_id: str):
188
+ sm: ScenarioManager = get_scenario_manager(get_app().server, session_id)
189
+ cr: ContentRegistry = get_app().server.content_registry
190
+
191
+ triggered = ctx.triggered_id
192
+ if isinstance(triggered, dict) and triggered["type"] == SCENARIO_CARD:
193
+ selected_card_id = triggered["index"]
194
+ s = sm.get_by_id(selected_card_id)
195
+ if s:
196
+ return "scenario selected", cr.scenario_content(s), selected_card_id
197
+
198
+ return no_update, no_update, no_update
199
+
200
+
201
+ # --- Page Initialization Callback ---
202
+ @callback(
203
+ Output(SCENARIO_LIST_UPDATE_STORE, "data"),
204
+ Input("url", "pathname"),
205
+ State(SCENARIO_SELECTED_ID_STORE, "data"),
206
+ State(ACTIVE_SESSION, "data"),
207
+ prevent_initial_call=False,
208
+ )
209
+ def initialize_page(pathname, selected_id, session_id):
210
+ """
211
+ Initializes the scenario page when it is loaded.
212
+
213
+ Args:
214
+ pathname (str): Current URL pathname
215
+ selected_id (str): ID of currently selected scenario
216
+ session_id (str): ID of active session
217
+
218
+ Returns:
219
+ tuple: (
220
+ scenario cards component,
221
+ delete modal visibility,
222
+ ID of scenario to delete,
223
+ selected scenario display,
224
+ selected scenario ID
225
+ )
226
+ """
227
+ scenario_manager: ScenarioManager = get_scenario_manager(
228
+ get_app().server, session_id
229
+ )
230
+
231
+ # Only initialize on page load
232
+ if pathname and "scenario" in pathname:
233
+ if scenario_manager.list_scenarios():
234
+ return "page initialized"
235
+ return None
236
+
237
+
238
+ # --- Process Scenario Callback ---
239
+ @callback(
240
+ Output(SCENARIO_PROG_INTERVAL, "disabled", allow_duplicate=True),
241
+ Input({"type": SCENARIO_PROCESS_BUTTON, "index": ALL}, "n_clicks"),
242
+ State(ACTIVE_SESSION, "data"),
243
+ prevent_initial_call=True,
244
+ )
245
+ def process_scenario(process_clicks, session_id):
246
+ """
247
+ Processes a scenario when the process button is clicked.
248
+
249
+ Depending on the scenario's status, this will:
250
+ - CREATED: enqueue processing
251
+ - QUEUED/PROCESSING: request cancel
252
+ - COMPLETE/FAILED: refresh (reset to CREATED)
253
+
254
+ Args:
255
+ process_clicks (list): List of click counts for process buttons
256
+
257
+ Returns:
258
+ bool | dash.no_update: Whether the progress interval should be disabled.
259
+ """
260
+ sm: ScenarioManager = get_scenario_manager(get_app().server, session_id)
261
+
262
+ triggered = ctx.triggered_id
263
+ if (
264
+ isinstance(triggered, dict)
265
+ and triggered["type"] == SCENARIO_PROCESS_BUTTON
266
+ and sum(process_clicks) > 0
267
+ ):
268
+ scenario = sm.get_by_id(triggered["index"])
269
+ if not scenario:
270
+ return no_update
271
+
272
+ if scenario.status == ScenarioStatus.CREATED:
273
+ sm.process_scenario_async(scenario)
274
+ return False # enable progress interval
275
+ elif scenario.status in (ScenarioStatus.QUEUED, ScenarioStatus.PROCESSING):
276
+ scenario.cancel(logger=sm.logger)
277
+ return no_update
278
+ elif scenario.status in (ScenarioStatus.COMPLETE, ScenarioStatus.FAILED):
279
+ scenario.refresh(logger=sm.logger)
280
+ return no_update
281
+
282
+ return no_update
283
+
284
+
285
+ def get_currently_processing_info(sm):
286
+ value = sm.currently_processing.progress
287
+ label = f"{value:.0f}%" if value > 10 else ""
288
+ message = f"Processing: {sm.currently_processing.tag}" # todo use textwrap to abbreviate tag
289
+ return value, label, message
290
+
291
+
292
+ @callback(
293
+ [
294
+ Output(SCENARIO_PROG_BAR, "value"),
295
+ Output(SCENARIO_PROG_BAR, "label"),
296
+ Output(SCENARIO_PROG_TEXT, "children"),
297
+ Output(SCENARIO_PROG_COLLAPSE, "is_open"),
298
+ Output(SCENARIO_PROG_INTERVAL, "disabled", allow_duplicate=True),
299
+ Output(SCENARIO_CURRENTLY_RUNNING_STORE, "data"),
300
+ ],
301
+ Input(SCENARIO_PROG_INTERVAL, "n_intervals"),
302
+ State(SCENARIO_CURRENTLY_RUNNING_STORE, "data"),
303
+ State(ACTIVE_SESSION, "data"),
304
+ prevent_initial_call=True,
305
+ )
306
+ def update_progress(n_intervals, msg, session_id):
307
+ sm: ScenarioManager = get_scenario_manager(get_app().server, session_id)
308
+ if sm.currently_processing:
309
+ value, label, message = get_currently_processing_info(sm)
310
+ if message != msg:
311
+ return value, label, message, True, False, message
312
+ else:
313
+ return value, label, message, True, False, no_update
314
+
315
+ return 0, "", "", False, True, ""
316
+
317
+
318
+ @callback(
319
+ Output(SCENARIO_CREATOR_MODAL, "is_open"),
320
+ Input(SCENARIO_CREATOR_OPEN_BUTTON, "n_clicks"),
321
+ Input(f"{SCENARIO_CREATOR_MODAL}-cancel", "n_clicks"),
322
+ State(SCENARIO_CREATOR_MODAL, "is_open"),
323
+ prevent_initial_call=True,
324
+ )
325
+ def toggle_scenario_creator_modal(open_click, cancel_click, is_open):
326
+ ctx = callback_context
327
+ if not ctx.triggered:
328
+ return no_update
329
+ triggered_id = ctx.triggered[0]["prop_id"].split(".")[0]
330
+ if triggered_id == SCENARIO_CREATOR_OPEN_BUTTON and not is_open:
331
+ return True
332
+ elif triggered_id in [f"{SCENARIO_CREATOR_MODAL}-cancel"]:
333
+ return False
334
+ return is_open
335
+
336
+
337
+ @callback(
338
+ Output(SCENARIO_TAG_INPUT, "value"),
339
+ Output(SCENARIO_DATA_INPUT, "value"),
340
+ Output(SCENARIO_ALGO_INPUT, "value"),
341
+ Input(SCENARIO_CREATOR_MODAL, "is_open"),
342
+ prevent_initial_call=True,
343
+ )
344
+ def refresh_on_close(is_open):
345
+ if not is_open:
346
+ return "", "", ""
347
+ return no_update, no_update, no_update
348
+
349
+
350
+ @callback(
351
+ Output(ALGO_PARAMS_WINDOW_ID, "is_open"),
352
+ Output(ALGO_PARAMS_ENTRY_CARD, "children"),
353
+ Input(SCENARIO_ALGO_INPUT, "value"),
354
+ State(ACTIVE_SESSION, "data"),
355
+ prevent_initial_call=True,
356
+ )
357
+ def open_algo_params_window(algo_name, session_id):
358
+ if algo_name:
359
+ try:
360
+ return True, create_algo_parameters_entry_card_body(algo_name)
361
+ except AssertionError:
362
+ return False, ""
363
+ return False, ""
364
+
365
+
366
+ # --- Scenario Creation Callback ---
367
+ @callback(
368
+ Output(SCENARIO_LIST_UPDATE_STORE, "data", allow_duplicate=True),
369
+ Output(SCENARIO_ALERT, "children", allow_duplicate=True),
370
+ Output(SCENARIO_ALERT, "is_open", allow_duplicate=True),
371
+ Output(SCENARIO_CREATOR_MODAL, "is_open", allow_duplicate=True),
372
+ Output(SCENARIO_PROG_INTERVAL, "disabled", allow_duplicate=True),
373
+ Input(SCENARIO_NEW_BUTTON, "n_clicks"),
374
+ State(SCENARIO_TAG_INPUT, "value"),
375
+ State(SCENARIO_DATA_INPUT, "value"),
376
+ State(SCENARIO_ALGO_INPUT, "value"),
377
+ State({"type": "algo-param-input", "param": ALL}, "value"),
378
+ State(SCENARIO_SELECTED_ID_STORE, "data"),
379
+ State(ACTIVE_SESSION, "data"),
380
+ prevent_initial_call=True,
381
+ )
382
+ def create_scenario(
383
+ create_clicks, tag, dataset, algorithm, algo_param_values, selected_id, session_id
384
+ ):
385
+ # Now algo_param_values is a list containing the values of each param input, in DOM order!
386
+ # You can also get their IDs from dash.callback_context.inputs_list for mapping
387
+
388
+ if not tag:
389
+ return no_update, "Tag is required", True, False, no_update
390
+ if not dataset:
391
+ return no_update, "Dataset is required", True, False, no_update
392
+ if not algorithm:
393
+ return no_update, "Algorithm is required", True, False, no_update
394
+
395
+ scenario_manager: ScenarioManager = get_scenario_manager(
396
+ get_app().server, session_id
397
+ )
398
+ interval_disabled = False if scenario_manager.auto_run_scenarios else no_update
399
+
400
+ algo_param_shell, data_param_shell = scenario_manager.get_associated_parameters(
401
+ algorithm
402
+ )
403
+
404
+ param_ids = [s["id"] for s in callback_context.states_list[3]]
405
+ algo_params = {
406
+ pid["param"]: value
407
+ for pid, value in zip(param_ids, algo_param_values)
408
+ if algo_param_shell.contains(pid["param"])
409
+ }
410
+
411
+ try:
412
+ scenario_manager.create_scenario(tag, dataset, algorithm, algo_params)
413
+ return "new scenario created", "", False, False, interval_disabled
414
+ except Exception as e:
415
+ get_manager(get_app().server).logger.log_traceback(e)
416
+ return no_update, f"Error: {e}", True, False, no_update
417
+
418
+
419
+ # --- Delete Modal Open Callback ---
420
+ @callback(
421
+ Output(SCENARIO_DELETE_MODAL, "is_open", allow_duplicate=True),
422
+ Output(SCENARIO_TO_DELETE, "data", allow_duplicate=True),
423
+ Input({"type": SCENARIO_DELETE_BUTTON, "index": ALL}, "n_clicks"),
424
+ State(ACTIVE_SESSION, "data"),
425
+ prevent_initial_call=True,
426
+ )
427
+ def open_delete_modal(delete_clicks, session_id):
428
+ """
429
+ Opens the delete confirmation modal when a delete button is clicked.
430
+ Prevents opening on refresh or unrelated status updates.
431
+ """
432
+ ctx = callback_context
433
+ if ctx.triggered and isinstance(ctx.triggered_id, dict):
434
+ if ctx.triggered_id.get("type") == SCENARIO_DELETE_BUTTON:
435
+ try:
436
+ # get the index in delete_clicks that is nonzero
437
+ idx = [i for i, e in enumerate(delete_clicks) if e != 0][0]
438
+ except IndexError:
439
+ return no_update, no_update
440
+ # Check that idx is a valid index in the list
441
+ if 0 <= idx < len(delete_clicks) and delete_clicks[idx]:
442
+ return True, get_scenario_manager(
443
+ get_app().server, session_id
444
+ ).list_scenarios()[idx].id
445
+ return no_update, no_update
446
+
447
+
448
+ # --- Delete Confirmation Callback ---
449
+ @callback(
450
+ Output(SCENARIO_LIST_UPDATE_STORE, "data", allow_duplicate=True),
451
+ Output(SCENARIO_DELETE_MODAL, "is_open", allow_duplicate=True),
452
+ Output(SCENARIO_SELECTED, "children", allow_duplicate=True),
453
+ Output(SCENARIO_SELECTED_ID_STORE, "data", allow_duplicate=True),
454
+ Input(SCENARIO_CONFIRM_DELETE_BUTTON, "n_clicks"),
455
+ State(SCENARIO_TO_DELETE, "data"),
456
+ State(SCENARIO_SELECTED_ID_STORE, "data"),
457
+ State(ACTIVE_SESSION, "data"),
458
+ prevent_initial_call=True,
459
+ )
460
+ def confirm_delete_scenario(
461
+ confirm_clicks, scenario_to_delete, selected_id, session_id
462
+ ):
463
+ """
464
+ Deletes a scenario when the confirm delete button is clicked.
465
+
466
+ Args:
467
+ confirm_clicks (int): Number of clicks on the confirm delete button
468
+ scenario_to_delete (str): ID of scenario marked for deletion
469
+ selected_id (str): ID of currently selected scenario
470
+ session_id (str): ID of active session
471
+
472
+ Returns:
473
+ tuple: (
474
+ updated scenario cards component,
475
+ delete modal visibility,
476
+ selected scenario display,
477
+ selected scenario ID
478
+ )
479
+ """
480
+ scenario_manager: ScenarioManager = get_scenario_manager(
481
+ get_app().server, session_id
482
+ )
483
+ if scenario_to_delete is not None:
484
+ scenario_manager.delete_scenario(scenario_to_delete)
485
+
486
+ if scenario_to_delete == selected_id:
487
+ return "scenario deleted", False, "No scenario selected.", None
488
+
489
+ return no_update, False, no_update, no_update
490
+
491
+
492
+ # --- Cancel Delete Callback ---
493
+ @callback(
494
+ Output(SCENARIO_DELETE_MODAL, "is_open", allow_duplicate=True),
495
+ Input(SCENARIO_CANCEL_DELETE_BUTTON, "n_clicks"),
496
+ prevent_initial_call=True,
497
+ )
498
+ def cancel_delete_scenario(cancel_clicks):
499
+ """
500
+ Closes the delete confirmation modal when the cancel button is clicked.
501
+
502
+ Args:
503
+ cancel_clicks (int): Number of clicks on the cancel delete button
504
+
505
+ Returns:
506
+ Delete modal visibility
507
+ """
508
+ return False
509
+
510
+
511
+ @callback(
512
+ Output(SCENARIO_LIST_UPDATE_STORE, "data", allow_duplicate=True),
513
+ Input(SCENARIO_CURRENTLY_RUNNING_STORE, "data"),
514
+ prevent_initial_call=True,
515
+ )
516
+ def trigger_refresh(msg):
517
+ return "processing update triggered"
518
+
519
+
520
+ @callback(
521
+ Output(SCENARIO_LIST, "children"),
522
+ Input(SCENARIO_LIST_UPDATE_STORE, "data"),
523
+ State(SCENARIO_SELECTED_ID_STORE, "data"),
524
+ State(ACTIVE_SESSION, "data"),
525
+ prevent_initial_call=True,
526
+ )
527
+ def refresh_cards(message, selected_id, session_id):
528
+ scenario_manager: ScenarioManager = get_scenario_manager(
529
+ get_app().server, session_id
530
+ )
531
+
532
+ if selected_id in scenario_manager.list_ids():
533
+ return scenario_cards(scenario_manager, selected_id)
534
+ else:
535
+ return scenario_cards(scenario_manager, None)
536
+
537
+
538
+ @callback(
539
+ [
540
+ Output({"type": SCENARIO_CARD, "index": ALL}, "className"),
541
+ Output(SCENARIO_SELECTED_ID_STORE, "data"),
542
+ ],
543
+ [Input({"type": SCENARIO_CARD, "index": ALL}, "n_clicks")],
544
+ [
545
+ State({"type": SCENARIO_CARD, "index": ALL}, "id"),
546
+ State(SCENARIO_SELECTED_ID_STORE, "data"),
547
+ ],
548
+ # prevent_initial_call=True
549
+ )
550
+ def handle_scenario_card_click(n_clicks_list, card_ids, selected_scenario_id):
551
+ """
552
+ Handle scenario card selection - applies the 'selected' class to the clicked card
553
+ and removes it from all other cards.
554
+
555
+ Args:
556
+ n_clicks_list: List of click counts for all scenario cards
557
+ card_ids: List of card IDs
558
+ selected_scenario_id: Currently selected scenario ID
559
+
560
+ Returns:
561
+ tuple: (list of class names for all cards, newly selected scenario ID)
562
+ """
563
+ # If callback triggered without a click
564
+ if not ctx.triggered:
565
+ # On initial load, set default classes based on stored selection
566
+ if selected_scenario_id:
567
+ return [
568
+ "scenario-card selected"
569
+ if card_id["index"] == selected_scenario_id
570
+ else "scenario-card"
571
+ for card_id in card_ids
572
+ ], selected_scenario_id
573
+ else:
574
+ # No card selected initially
575
+ return ["scenario-card"] * len(card_ids), None
576
+
577
+ # Get the ID of the clicked card
578
+ triggered_id = ctx.triggered[0]["prop_id"].split(".")[0]
579
+ if not triggered_id:
580
+ raise PreventUpdate
581
+
582
+ # Extract the card index from the JSON string
583
+ import json
584
+
585
+ triggered_component = json.loads(triggered_id)
586
+ clicked_card_id = triggered_component["index"]
587
+
588
+ # Set the clicked card as selected and all others as not selected
589
+ new_class_names = []
590
+ for card_id in card_ids:
591
+ if card_id["index"] == clicked_card_id:
592
+ new_class_names.append("scenario-card selected")
593
+ else:
594
+ new_class_names.append("scenario-card")
595
+
596
+ return new_class_names, clicked_card_id