ositah 25.6.dev1__py3-none-any.whl → 25.9.dev1__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.

Potentially problematic release.


This version of ositah might be problematic. Click here for more details.

Files changed (42) hide show
  1. ositah/app.py +17 -17
  2. ositah/apps/analysis.py +785 -785
  3. ositah/apps/configuration/callbacks.py +916 -916
  4. ositah/apps/configuration/main.py +546 -546
  5. ositah/apps/configuration/parameters.py +74 -74
  6. ositah/apps/configuration/tools.py +112 -112
  7. ositah/apps/export.py +1208 -1191
  8. ositah/apps/validation/callbacks.py +240 -240
  9. ositah/apps/validation/main.py +89 -89
  10. ositah/apps/validation/parameters.py +25 -25
  11. ositah/apps/validation/tables.py +646 -646
  12. ositah/apps/validation/tools.py +552 -552
  13. ositah/assets/arrow_down_up.svg +3 -3
  14. ositah/assets/ositah.css +53 -53
  15. ositah/assets/sort_ascending.svg +4 -4
  16. ositah/assets/sort_descending.svg +5 -5
  17. ositah/assets/sorttable.js +499 -499
  18. ositah/main.py +449 -449
  19. ositah/ositah.example.cfg +229 -229
  20. ositah/static/style.css +53 -53
  21. ositah/templates/base.html +22 -22
  22. ositah/templates/bootstrap_login.html +38 -38
  23. ositah/templates/login_form.html +26 -26
  24. ositah/utils/agents.py +124 -124
  25. ositah/utils/authentication.py +287 -287
  26. ositah/utils/cache.py +19 -19
  27. ositah/utils/core.py +13 -13
  28. ositah/utils/exceptions.py +64 -64
  29. ositah/utils/hito_db.py +51 -51
  30. ositah/utils/hito_db_model.py +253 -253
  31. ositah/utils/menus.py +339 -339
  32. ositah/utils/period.py +139 -139
  33. ositah/utils/projects.py +1178 -1178
  34. ositah/utils/teams.py +42 -42
  35. ositah/utils/utils.py +474 -474
  36. {ositah-25.6.dev1.dist-info → ositah-25.9.dev1.dist-info}/METADATA +149 -150
  37. ositah-25.9.dev1.dist-info/RECORD +46 -0
  38. {ositah-25.6.dev1.dist-info → ositah-25.9.dev1.dist-info}/licenses/LICENSE +29 -29
  39. ositah-25.6.dev1.dist-info/RECORD +0 -46
  40. {ositah-25.6.dev1.dist-info → ositah-25.9.dev1.dist-info}/WHEEL +0 -0
  41. {ositah-25.6.dev1.dist-info → ositah-25.9.dev1.dist-info}/entry_points.txt +0 -0
  42. {ositah-25.6.dev1.dist-info → ositah-25.9.dev1.dist-info}/top_level.txt +0 -0
@@ -1,916 +1,916 @@
1
- """
2
- Dash callbacks for Configuration applications
3
- """
4
-
5
- from datetime import datetime
6
- from typing import Any, List
7
-
8
- import dash_bootstrap_components as dbc
9
- from dash import callback_context, html
10
- from dash.dependencies import Input, Output, State
11
- from dash.exceptions import PreventUpdate
12
-
13
- from ositah.app import app
14
- from ositah.apps.configuration.parameters import *
15
- from ositah.apps.configuration.tools import (
16
- get_masterprojects_items,
17
- get_projects_items,
18
- list_box_empty,
19
- )
20
- from ositah.utils.exceptions import InvalidCallbackInput
21
- from ositah.utils.menus import TABLE_TYPE_TABLE, GlobalParams
22
- from ositah.utils.period import get_declaration_periods
23
- from ositah.utils.projects import (
24
- MASTERPROJECT_DELETED_ACTIVITY,
25
- NSIP_CLASS_OTHER_ACTIVITY,
26
- NSIP_CLASS_PROJECT,
27
- add_activity,
28
- add_activity_teams,
29
- get_all_hito_activities,
30
- get_hito_nsip_activities,
31
- get_nsip_activities,
32
- nsip_activity_name_id,
33
- ositah2hito_project_name,
34
- reenable_activity,
35
- remove_activity,
36
- remove_activity_teams,
37
- update_activity_name,
38
- )
39
- from ositah.utils.teams import get_project_team_ids
40
-
41
-
42
- def check_activity_changes(project_activity: bool):
43
- """
44
- Retrieve the list of activity changes in NSIP compared to the Hito projects
45
-
46
- :param project_activity: true for projects, false for other activities
47
- :return: dataframe with changed activities
48
- """
49
-
50
- nsip_activities = get_nsip_activities(project_activity)
51
- if nsip_activities is None or nsip_activities.empty:
52
- raise PreventUpdate
53
-
54
- hito_projects = get_hito_nsip_activities(project_activity)
55
-
56
- if project_activity:
57
- merge_left_attr = "nsip_project_id"
58
- else:
59
- merge_left_attr = "nsip_reference_id"
60
- activity_changes = hito_projects.merge(
61
- nsip_activities,
62
- how="outer",
63
- left_on=merge_left_attr,
64
- right_on="id",
65
- indicator=True,
66
- suffixes=[None, "_nsip"],
67
- )
68
- activity_changes["status"] = None
69
- activity_changes.loc[activity_changes._merge == "left_only", "status"] = PROJECT_CHANGE_REMOVED
70
- activity_changes.loc[activity_changes._merge == "right_only", "status"] = PROJECT_CHANGE_ADDED
71
- activity_changes.loc[
72
- (activity_changes._merge == "both")
73
- & (
74
- (activity_changes.nsip_project != activity_changes.ositah_name)
75
- | (activity_changes["master_project.name"] != activity_changes.nsip_master)
76
- ),
77
- "status",
78
- ] = PROJECT_CHANGE_CHANGED
79
- activity_changes.drop(activity_changes[activity_changes.status.isna()].index, inplace=True)
80
-
81
- # Set NaN to 0 in referentiel_id and id_nsip as np.NaN is a float and prevent casting to int.
82
- activity_changes.loc[activity_changes["referentiel_id"].isna(), "referentiel_id"] = 0
83
- activity_changes.loc[activity_changes["id_nsip"].isna(), "id_nsip"] = 0
84
- activity_changes["referentiel_id"] = activity_changes.referentiel_id.astype(int)
85
- activity_changes["id_nsip"] = activity_changes.id_nsip.astype(int)
86
-
87
- return activity_changes
88
-
89
-
90
- @app.callback(
91
- Output(TAB_ID_ACTIVITY_TEAMS, "children"),
92
- Output(TAB_ID_NSIP_PROJECT_SYNC, "children"),
93
- Output(TAB_ID_DECLARATION_PERIODS, "children"),
94
- Output(TAB_ID_PROJECT_MGT, "children"),
95
- Input(CONFIGURATION_TAB_MENU_ID, "active_tab"),
96
- )
97
- def display_tab_content(active_tab):
98
- from ositah.apps.configuration.main import (
99
- declaration_periods_layout,
100
- nsip_sync_layout,
101
- project_mgt_layout,
102
- project_teams_layout,
103
- )
104
-
105
- if active_tab == TAB_ID_ACTIVITY_TEAMS:
106
- return project_teams_layout(), "", "", ""
107
- elif active_tab == TAB_ID_NSIP_PROJECT_SYNC:
108
- return "", nsip_sync_layout(), "", ""
109
- elif active_tab == TAB_ID_DECLARATION_PERIODS:
110
- return "", "", declaration_periods_layout(), ""
111
- elif active_tab == TAB_ID_PROJECT_MGT:
112
- return "", "", "", project_mgt_layout()
113
- else:
114
- return "", "", "", ""
115
-
116
-
117
- @app.callback(
118
- Output(NSIP_SYNC_CONTENT_ID, "children"),
119
- Output(NSIP_SYNC_APPLY_DIFF_ID, "disabled"),
120
- Input(NSIP_SYNC_SHOW_DIFF_ID, "n_clicks"),
121
- Input(NSIP_SYNC_APPLY_DIFF_ID, "n_clicks"),
122
- State(NSIP_SYNC_ACTIVITY_TYPE_ID, "value"),
123
- prevent_initial_call=True,
124
- )
125
- def show_nsip_activity_differences(_, __, activity_type):
126
- """
127
- Handle buttons from the NSIP synchronisation tab. Values of input parameters are not used.
128
- Do nothing if no button was clicked.
129
-
130
- :param activity_type: type of NSIP activity (integer)
131
- :return: contents of NSIP_SYNC_CONTENT_ID components
132
- """
133
-
134
- global_params = GlobalParams()
135
-
136
- if not callback_context.triggered:
137
- return PreventUpdate
138
- else:
139
- button_id = callback_context.triggered[0]["prop_id"].split(".")[0]
140
-
141
- if activity_type == NSIP_SYNC_ACTIVITY_TYPE_PROJECT:
142
- project_activity = True
143
- else:
144
- project_activity = False
145
-
146
- activity_changes = check_activity_changes(project_activity)
147
-
148
- if button_id == NSIP_SYNC_SHOW_DIFF_ID:
149
- add_num = len(activity_changes[activity_changes.status == PROJECT_CHANGE_ADDED])
150
- change_num = len(activity_changes[activity_changes.status == PROJECT_CHANGE_CHANGED])
151
- remove_num = len(activity_changes[activity_changes.status == PROJECT_CHANGE_REMOVED])
152
- total_changes = add_num + change_num + remove_num
153
-
154
- if add_num or change_num or remove_num:
155
- data_columns = {
156
- # TO BE FIXED: Column names (nsip_master, nsip_project, ositah_name)
157
- # are misleading...
158
- "Masterprojet OSITAH": "nsip_master",
159
- "Projet OSITAH": "nsip_project",
160
- "ID OSITAH": "id",
161
- "ID Référentiel": "referentiel_id",
162
- "Masterprojet NSIP": "master_project.name",
163
- "Projet NSIP": "ositah_name",
164
- "ID NSIP": "id_nsip",
165
- "Statut": "status",
166
- }
167
- theader = [html.Thead(html.Tr([html.Th(c) for c in data_columns.keys()]))]
168
-
169
- tbody = [
170
- html.Tbody(
171
- [
172
- html.Tr([html.Td(row[c]) for c in data_columns.values()])
173
- for i, row in activity_changes.sort_values(
174
- by=["nsip_master", "nsip_project"]
175
- ).iterrows()
176
- ]
177
- )
178
- ]
179
-
180
- table = dbc.Table(
181
- theader + tbody,
182
- id={"type": TABLE_TYPE_TABLE, "id": TABLE_NSIP_PROJECT_SYNC_ID},
183
- bordered=True,
184
- hover=True,
185
- striped=True,
186
- class_name="sortable",
187
- )
188
-
189
- summary_msg = html.B(
190
- (
191
- f"{total_changes} changements NSIP / OSITAH : {add_num} additions,"
192
- f" {remove_num} suppressions, {change_num} modifications"
193
- )
194
- )
195
-
196
- apply_button_disabled = False
197
-
198
- else:
199
- table = html.Div()
200
- if project_activity:
201
- summary_msg = "Les projets NSIP et OSITAH sont synchronisés"
202
- else:
203
- summary_msg = "Les activités NSIP et OSITAH sont synchronisées"
204
- apply_button_disabled = True
205
-
206
- return (
207
- html.Div(
208
- [
209
- dbc.Alert(summary_msg),
210
- html.P(),
211
- table,
212
- ]
213
- ),
214
- apply_button_disabled,
215
- )
216
-
217
- elif button_id == NSIP_SYNC_APPLY_DIFF_ID:
218
- add_failed = {}
219
- change_failed = {}
220
- delete_failed = {}
221
-
222
- for _, activity in activity_changes[
223
- activity_changes.status == PROJECT_CHANGE_CHANGED
224
- ].iterrows():
225
- status, error_msg = update_activity_name(
226
- activity.id,
227
- activity.referentiel_id,
228
- activity.id_nsip,
229
- activity["master_project.name"],
230
- activity.ositah_name,
231
- )
232
- if status:
233
- change_failed[f"{activity['nsip_name_id']}"] = error_msg
234
-
235
- for _, activity in activity_changes[
236
- activity_changes.status == PROJECT_CHANGE_ADDED
237
- ].iterrows():
238
- activity_full_name = ositah2hito_project_name(
239
- activity["master_project.name"], activity.ositah_name
240
- )
241
- activity_teams = get_project_team_ids(global_params.project_teams, activity_full_name)
242
- status, error_msg = add_activity(
243
- activity.id_nsip,
244
- activity["master_project.name"],
245
- activity.ositah_name,
246
- activity_teams,
247
- project_activity,
248
- )
249
- if status:
250
- add_failed[
251
- (
252
- f"{activity['master_project.name']} / {activity.ositah_name}"
253
- f" (NSIP ID: {activity.id_nsip})"
254
- )
255
- ] = error_msg
256
-
257
- for _, activity in activity_changes[
258
- activity_changes.status == PROJECT_CHANGE_REMOVED
259
- ].iterrows():
260
- _, _, project_id, reference_id = nsip_activity_name_id(
261
- activity.nsip_name_id,
262
- NSIP_CLASS_PROJECT if project_activity else NSIP_CLASS_OTHER_ACTIVITY,
263
- )
264
- if project_activity:
265
- nsip_id = project_id
266
- else:
267
- nsip_id = reference_id
268
- status, error_msg = remove_activity(
269
- activity.id, activity.referentiel_id, nsip_id, project_activity
270
- )
271
- if status:
272
- delete_failed[f"{activity['nsip_name_id']}"] = error_msg
273
-
274
- if (
275
- len(change_failed.keys()) == 0
276
- and len(add_failed.keys()) == 0
277
- and len(delete_failed) == 0
278
- ):
279
- return dbc.Alert("All changes applied successfully to Hito"), True
280
- else:
281
- alert_msg = []
282
- if len(change_failed.keys()) > 0:
283
- alert_msg.append(
284
- html.P(html.B("Erreur durant la mise à jour des activités suivantes :"))
285
- )
286
- alert_msg.append(html.Ul([html.Li(f"{k}: {v}") for k, v in change_failed.items()]))
287
- if len(add_failed.keys()) > 0:
288
- alert_msg.append(html.P(html.B("Erreur durant l'ajout des activités suivantes :")))
289
- alert_msg.append(html.Ul([html.Li(f"{k}: {v}") for k, v in add_failed.items()]))
290
- if len(delete_failed.keys()) > 0:
291
- alert_msg.append(
292
- html.P(html.B("Erreur durant la suppression des activités suivantes :"))
293
- )
294
- alert_msg.append(html.Ul([html.Li(f"{k}: {v}") for k, v in delete_failed.items()]))
295
- return dbc.Alert(alert_msg), True
296
-
297
- else:
298
- return dbc.Alert("Bouton invalide", color="danger"), True
299
-
300
-
301
- @app.callback(
302
- Output(ACTIVITY_TEAMS_PROJECTS_ID, "options"),
303
- Output(ACTIVITY_TEAMS_PROJECTS_ID, "value"),
304
- Input(ACTIVITY_TEAMS_MASTERPROJECTS_ID, "value"),
305
- State(ACTIVITY_TEAMS_PROJECT_ACTIVITY_ID, "data"),
306
- prevent_initial_call=True,
307
- )
308
- def activity_team_projects(masterproject, project_activity):
309
- """
310
- Display the list of projects associated with the selected masterproject in the project
311
- list box. Also reset the project selected value.
312
-
313
- :param masterproject: selected master project
314
- :param project_activity: True if it is a project rather than a Hito activity
315
- :return: list box options
316
- """
317
-
318
- items = get_projects_items(masterproject, project_activity, None)
319
- if len(items):
320
- return items, None
321
- else:
322
- return [], None
323
-
324
-
325
- @app.callback(
326
- Output(PROJECT_MGT_MASTERPROJECT_LIST_ID, "options"),
327
- Output(PROJECT_MGT_MASTERPROJECT_LIST_ID, "value"),
328
- Input(PROJECT_MGT_PROJECT_TYPE_ID, "value"),
329
- State(PROJECT_MGT_PROJECT_ACTIVITY_ID, "data"),
330
- prevent_initial_call=True,
331
- )
332
- def project_mgt_masterprojects(category, project_activity):
333
- """
334
- Display the list of projects associated with the selected masterproject in the project
335
- list box. Also reset the masterproject selected value.
336
-
337
- :param category: a number indicating if NSIP, local or disabled projects must be displayed
338
- :param project_activity: True if it is a project rather than a Hito activity
339
- :return: list box options, selected value reset to an empty string
340
- """
341
-
342
- if category is None:
343
- return [], ""
344
- else:
345
- items = get_masterprojects_items(project_activity, category)
346
- return items, ""
347
-
348
-
349
- @app.callback(
350
- Output(PROJECT_MGT_PROJECT_LIST_ID, "options"),
351
- Output(PROJECT_MGT_PROJECT_LIST_ID, "value"),
352
- Input(PROJECT_MGT_MASTERPROJECT_LIST_ID, "value"),
353
- Input(PROJECT_MGT_PROJECT_TYPE_ID, "value"),
354
- State(PROJECT_MGT_PROJECT_ACTIVITY_ID, "data"),
355
- prevent_initial_call=True,
356
- )
357
- def project_mgt_projects(masterproject, category, project_activity):
358
- """
359
- Display the list of projects associated with the selected masterproject in the project
360
- list box. If local projects are selected, the masterproject value is empty.
361
- Also reset the project selected value.
362
-
363
- :param masterproject: selected master project
364
- :param category: a number indicating if NSIP, local or disabled projects must be displayed
365
- :param project_activity: True if it is a project rather than a Hito activity
366
- :return: list box options
367
- """
368
-
369
- if masterproject or category == PROJECT_MGT_PROJECT_TYPE_LOCAL:
370
- items = get_projects_items(masterproject, project_activity, category)
371
- return items, ""
372
- else:
373
- return [], ""
374
-
375
-
376
- @app.callback(
377
- Output(PROJECT_MGT_ACTION_BUTTON_ID, "children"),
378
- Output(PROJECT_MGT_ACTION_BUTTON_ID, "style"),
379
- Output(PROJECT_MGT_ACTION_BUTTON_ID, "disabled"),
380
- Input(PROJECT_MGT_PROJECT_LIST_ID, "value"),
381
- State(PROJECT_MGT_PROJECT_TYPE_ID, "value"),
382
- prevent_initial_call=True,
383
- )
384
- def project_mgt_enable_action(activity, category):
385
- """
386
- If a project has been selected, enable the button with the appropriate action depending on
387
- activity type. If no project is elected, hide and disable the action button.
388
-
389
- :param activity: name of the selected activity
390
- :param category: whether the project is a local project, a NSIP project or a disabled one
391
- """
392
-
393
- if activity:
394
- if category == PROJECT_MGT_PROJECT_TYPE_DISABLED:
395
- action = PROJECT_MGT_ACTION_BUTTON_ENABLE
396
- else:
397
- action = PROJECT_MGT_ACTION_BUTTON_DISABLE
398
- return action, {"visibility": "visible"}, False
399
- else:
400
- return "", {"visibility": "hidden"}, True
401
-
402
-
403
- @app.callback(
404
- Output(PROJECT_MGT_STATUS_ID, "is_open"),
405
- Output(PROJECT_MGT_STATUS_ID, "children"),
406
- Output(PROJECT_MGT_STATUS_ID, "color"),
407
- Input(PROJECT_MGT_ACTION_BUTTON_ID, "n_clicks"),
408
- State(PROJECT_MGT_ACTION_BUTTON_ID, "children"),
409
- State(PROJECT_MGT_PROJECT_LIST_ID, "value"),
410
- State(PROJECT_MGT_PROJECT_ACTIVITY_ID, "data"),
411
- prevent_initial_call=True,
412
- )
413
- def project_mgt_execute_action(n_clicks, action, activity, is_project):
414
- """
415
- Execute action associated with the button. Actual action is retrieved from the
416
- button label.
417
-
418
- :param n_clicks: number of clicks, ignored
419
- :param action: the button label
420
- :param activity: name of the project/activity
421
- :param is_project: true if a project, false if is an Hito activity
422
- :return: status message and color
423
- """
424
-
425
- if action == PROJECT_MGT_ACTION_BUTTON_DISABLE:
426
- return True, "Désactivation projet : pas encore implémentée", "warning"
427
- elif action == PROJECT_MGT_ACTION_BUTTON_ENABLE:
428
- status, status_msg = reenable_activity(activity, is_project, MASTERPROJECT_DELETED_ACTIVITY)
429
- if status == 0:
430
- return True, f"Projet {activity} réactivé avec succès", "success"
431
- else:
432
- alert_msg = html.Div(
433
- html.P(f"Erreur durant la réactivation du projet {activity}"), html.P(error_msg)
434
- )
435
- return True, alert_msg, "danger"
436
- else:
437
- return True, f"Internal error: invalid action ({action})", "danger"
438
-
439
-
440
- @app.callback(
441
- Output(ACTIVITY_TEAMS_LAB_TEAMS_ID, "options"),
442
- Input(ACTIVITY_TEAMS_PROJECTS_ID, "value"),
443
- State(ACTIVITY_TEAMS_MASTERPROJECTS_ID, "value"),
444
- prevent_initial_call=True,
445
- )
446
- def display_teams(project, masterproject):
447
- """
448
- Display the Hito teams not yet associated with the selected project in the lab team
449
- list box
450
-
451
- :param project: selected project
452
- :param masterproject: selected master project
453
- :return: list box options for lab teams
454
- """
455
-
456
- global_params = GlobalParams()
457
- session_data = global_params.session_data
458
-
459
- if masterproject and project:
460
- lab_team_items = []
461
- for team in sorted(session_data.agent_teams):
462
- lab_team_items.append({"label": team, "value": team})
463
- return lab_team_items
464
-
465
- else:
466
- return []
467
-
468
-
469
- @app.callback(
470
- Output(ACTIVITY_TEAMS_BUTTON_ADD_ID, "disabled"),
471
- Input(ACTIVITY_TEAMS_LAB_TEAMS_ID, "value"),
472
- )
473
- def enable_team_update_buttons(selected_team):
474
- """
475
- Enable the button allowing to add the selected team in lab teams into the project teams
476
-
477
- :param selected_team: selected lab team
478
- :return: boolean
479
- """
480
- if selected_team:
481
- return False
482
- else:
483
- return True
484
-
485
-
486
- @app.callback(
487
- Output(ACTIVITY_TEAMS_BUTTON_REMOVE_ID, "disabled"),
488
- Input(ACTIVITY_TEAMS_SELECTED_TEAMS_ID, "value"),
489
- )
490
- def enable_team_remove_buttons(selected_team):
491
- """
492
- Enable the button allowing to remove a team from the project teams
493
-
494
- :param selected_team: selected lab team
495
- :return: boolean
496
- """
497
- if selected_team:
498
- return False
499
- else:
500
- return True
501
-
502
-
503
- @app.callback(
504
- Output(ACTIVITY_TEAMS_SELECTED_TEAMS_ID, "options"),
505
- Output(ACTIVITY_TEAMS_ADDED_TEAMS_ID, "data"),
506
- Output(ACTIVITY_TEAMS_REMOVED_TEAMS_ID, "data"),
507
- Output(ACTIVITY_TEAMS_BUTTON_UPDATE_ID, "disabled"),
508
- Output(ACTIVITY_TEAMS_BUTTON_CANCEL_ID, "disabled"),
509
- Output(ACTIVITY_TEAMS_LAB_TEAMS_ID, "value"),
510
- Output(ACTIVITY_TEAMS_SELECTED_TEAMS_ID, "value"),
511
- Input(ACTIVITY_TEAMS_BUTTON_ADD_ID, "n_clicks"),
512
- Input(ACTIVITY_TEAMS_BUTTON_REMOVE_ID, "n_clicks"),
513
- Input(ACTIVITY_TEAMS_PROJECTS_ID, "value"),
514
- State(ACTIVITY_TEAMS_MASTERPROJECTS_ID, "value"),
515
- State(ACTIVITY_TEAMS_LAB_TEAMS_ID, "value"),
516
- State(ACTIVITY_TEAMS_SELECTED_TEAMS_ID, "options"),
517
- State(ACTIVITY_TEAMS_SELECTED_TEAMS_ID, "value"),
518
- State(ACTIVITY_TEAMS_PROJECT_ACTIVITY_ID, "data"),
519
- State(ACTIVITY_TEAMS_ADDED_TEAMS_ID, "data"),
520
- State(ACTIVITY_TEAMS_REMOVED_TEAMS_ID, "data"),
521
- prevent_initial_call=True,
522
- )
523
- def update_selected_teams(
524
- update_n_click: int,
525
- cancel_n_click: int,
526
- project,
527
- masterproject,
528
- selected_lab_team,
529
- team_list_items,
530
- selected_activity_team,
531
- project_activity,
532
- added_teams,
533
- removed_teams,
534
- ):
535
- """
536
- Update the project teams list box after a project selection change or a lab team
537
- selection.
538
-
539
- :param update_n_click: clicks (ignored) for update button
540
- :param cancel_n_click: clicks (ignored) for cancel button
541
- :param project: selected project
542
- :param masterproject: selected masterproject
543
- :param selected_lab_team: selected team in lab teams (for addition)
544
- :param team_list_items: current contents of project teams list box
545
- :param selected_lab_team: selected team in project teams (for removal)
546
- :param project_activity: True if it is a project rather than a Hito activity
547
- :param added_teams: list of teams to be added
548
- :param removed_teams: list of teams to be removed
549
- :return: list box options for project teams and update/cancel buttons
550
- """
551
-
552
- ctx = callback_context
553
- if not ctx.triggered:
554
- raise PreventUpdate
555
- else:
556
- active_input = ctx.triggered[0]["prop_id"].split(".")[0]
557
-
558
- # Selected project changed: reset the list to project teams
559
- if active_input == ACTIVITY_TEAMS_PROJECTS_ID:
560
- global_params = GlobalParams()
561
- session_data = global_params.session_data
562
- activities = get_all_hito_activities(project_activity)
563
- activity_teams = activities[
564
- (activities.masterproject == masterproject) & (activities.project == project)
565
- ]["team_name"]
566
- team_list_items = []
567
- for team in sorted(activity_teams):
568
- team_disabled = False if team in session_data.agent_teams else True
569
- team_list_items.append({"label": team, "value": team, "disabled": team_disabled})
570
- return team_list_items, [], [], True, True, None, None
571
-
572
- # A team being added to the project
573
- elif active_input == ACTIVITY_TEAMS_BUTTON_ADD_ID:
574
- team_present = False
575
- if team_list_items and not list_box_empty(team_list_items):
576
- for item in team_list_items:
577
- if item["value"] == selected_lab_team:
578
- team_present = True
579
- break
580
- else:
581
- team_list_items = []
582
- if team_present:
583
- raise PreventUpdate
584
- else:
585
- if selected_lab_team in removed_teams:
586
- removed_teams.remove(selected_lab_team)
587
- added_teams.append(selected_lab_team)
588
- team_list_items.append(
589
- {
590
- "label": selected_lab_team,
591
- "value": selected_lab_team,
592
- "disabled": False,
593
- }
594
- )
595
- return (
596
- sorted(team_list_items, key=lambda x: x["label"]),
597
- added_teams,
598
- removed_teams,
599
- False,
600
- False,
601
- None,
602
- None,
603
- )
604
-
605
- elif active_input == ACTIVITY_TEAMS_BUTTON_REMOVE_ID:
606
- if team_list_items and not list_box_empty(team_list_items):
607
- team_list_items.remove(
608
- {
609
- "label": selected_activity_team,
610
- "value": selected_activity_team,
611
- "disabled": False,
612
- }
613
- )
614
- removed_teams.append(selected_activity_team)
615
- if selected_activity_team in added_teams:
616
- added_teams.remove(selected_activity_team)
617
- return (
618
- sorted(team_list_items, key=lambda x: x["label"]),
619
- added_teams,
620
- removed_teams,
621
- False,
622
- False,
623
- None,
624
- None,
625
- )
626
-
627
- # Should not happen...
628
- else:
629
- raise PreventUpdate
630
-
631
- else:
632
- raise InvalidCallbackInput(active_input)
633
-
634
-
635
- @app.callback(
636
- Output(ACTIVITY_TEAMS_STATUS_ID, "children"),
637
- Output(ACTIVITY_TEAMS_STATUS_ID, "is_open"),
638
- Output(ACTIVITY_TEAMS_STATUS_ID, "color"),
639
- Output(ACTIVITY_TEAMS_RESET_INDICATOR_ID, "data"),
640
- Input(ACTIVITY_TEAMS_BUTTON_UPDATE_ID, "n_clicks"),
641
- Input(ACTIVITY_TEAMS_BUTTON_CANCEL_ID, "n_clicks"),
642
- State(ACTIVITY_TEAMS_MASTERPROJECTS_ID, "value"),
643
- State(ACTIVITY_TEAMS_PROJECTS_ID, "value"),
644
- State(ACTIVITY_TEAMS_ADDED_TEAMS_ID, "data"),
645
- State(ACTIVITY_TEAMS_REMOVED_TEAMS_ID, "data"),
646
- State(ACTIVITY_TEAMS_PROJECT_ACTIVITY_ID, "data"),
647
- State(ACTIVITY_TEAMS_RESET_INDICATOR_ID, "data"),
648
- prevent_initial_call=True,
649
- )
650
- def update_activity_teams(
651
- update_n_click: int,
652
- cancel_n_click: int,
653
- masterproject: str,
654
- project: str,
655
- added_teams: List[str],
656
- removed_teams: List[str],
657
- project_activity: bool,
658
- reset_indicator: int,
659
- ):
660
- """
661
- Update the team list associated with the selected activity and if successful, reset the
662
- page elements (increment the reset indicator). Also clear the activity cache to force
663
- updating it from the database. This callback is also used to cancel the current
664
- modifications if the cancel button is clicked.
665
-
666
- :param update_n_click: clicks (ignored) for update button
667
- :param cancel_n_click: clicks (ignored) for cancel button
668
- :param masterproject: selected masterproject
669
- :param project: selected project
670
- :param added_teams: list of teams to add to the selected project
671
- :param removed_teams: list of teams to remove from the selected project
672
- :param project_activity: if true, an Hito project else an Hito activity
673
- :return: status msg, its color and the flag to reset the page elements
674
- """
675
-
676
- ctx = callback_context
677
- if not ctx.triggered:
678
- raise PreventUpdate
679
- else:
680
- active_input = ctx.triggered[0]["prop_id"].split(".")[0]
681
-
682
- global_params = GlobalParams()
683
- session_data = global_params.session_data
684
-
685
- if active_input == ACTIVITY_TEAMS_BUTTON_CANCEL_ID:
686
- return "", False, "success", reset_indicator + 1
687
- else:
688
- status_msg = []
689
- status = 0
690
-
691
- if len(added_teams):
692
- add_status, add_status_msg = add_activity_teams(
693
- masterproject, project, added_teams, project_activity
694
- )
695
- status += add_status
696
- if add_status == 0:
697
- status_msg.append(
698
- html.Div(
699
- f"'{masterproject}/{project}' team list updated:"
700
- f" '{', '.join(added_teams)}' added"
701
- )
702
- )
703
- else:
704
- status_msg.append(
705
- html.Div(
706
- f"Failed to add '{', '.join(added_teams)}' to '{masterproject}/{project}'"
707
- f" team list: {add_status_msg}",
708
- )
709
- )
710
-
711
- if len(removed_teams):
712
- remove_status, remove_status_msg = remove_activity_teams(
713
- masterproject, project, removed_teams, project_activity
714
- )
715
- status += remove_status
716
- if remove_status == 0:
717
- status_msg.append(
718
- html.Div(
719
- f"'{masterproject}/{project}' team list updated:"
720
- f" '{', '.join(removed_teams)}' removed"
721
- )
722
- )
723
- else:
724
- status_msg.append(
725
- html.Div(
726
- f"Failed to remove '{', '.join(added_teams)}' from "
727
- f"'{masterproject}/{project}' team list: {remove_status_msg}",
728
- )
729
- )
730
-
731
- session_data.set_hito_activities(None, project_activity)
732
-
733
- if status == 0:
734
- status_color = "success"
735
- else:
736
- status_color = "danger"
737
-
738
- return (
739
- status_msg,
740
- True,
741
- status_color,
742
- reset_indicator + 1,
743
- )
744
-
745
-
746
- @app.callback(
747
- Output(ACTIVITY_TEAMS_MASTERPROJECTS_ID, "value"),
748
- Input(ACTIVITY_TEAMS_RESET_INDICATOR_ID, "data"),
749
- prevent_initial_call=True,
750
- )
751
- def reset_mastproject_selection(_):
752
- """
753
- Reset the masterprojet selection to None after the teams for the currently selected
754
- project has been successfully updated.
755
-
756
- :param _: reset indicator (value not used)
757
- :return: masterproject selection
758
- """
759
- return None
760
-
761
-
762
- @app.callback(
763
- Output(DECLARATION_PERIOD_NAME_ID, "value"),
764
- Output(DECLARATION_PERIOD_NAME_ID, "readonly"),
765
- Output(DECLARATION_PERIOD_START_DATE_ID, "date"),
766
- Output(DECLARATION_PERIOD_START_DATE_ID, "disabled"),
767
- Output(DECLARATION_PERIOD_END_DATE_ID, "date"),
768
- Output(DECLARATION_PERIOD_END_DATE_ID, "disabled"),
769
- Output(DECLARATION_PERIOD_VALIDATION_DATE_ID, "date"),
770
- Output(DECLARATION_PERIOD_VALIDATION_DATE_ID, "disabled"),
771
- Output(DECLARATION_PERIOD_PARAMS_ID, "style"),
772
- Output(DECLARATION_PERIODS_CREATE_CLICK_ID, "data"),
773
- Output(DECLARATION_PERIODS_SAVE_NEW_ID, "disabled"),
774
- Output(DECLARATION_PERIODS_STATUS_HIDDEN_ID, "data"),
775
- Input(DECLARATION_PERIODS_ID, "value"),
776
- Input(DECLARATION_PERIODS_CREATE_NEW_ID, "n_clicks"),
777
- State(DECLARATION_PERIODS_CREATE_CLICK_ID, "data"),
778
- State(DECLARATION_PERIODS_STATUS_HIDDEN_ID, "data"),
779
- State(DECLARATION_PERIODS_STATUS_VISIBLE_ID, "data"),
780
- prevent_initial_call=True,
781
- )
782
- def display_period_params(
783
- period_index: str,
784
- num_clicks: int,
785
- num_clicks_previous: int,
786
- status_hidden: int,
787
- status_visible: int,
788
- ) -> (str, bool, str, bool, str, bool, str, bool, str, int, int):
789
- """
790
- This callback displays the parameters of a selected period. It can be called either by selecting
791
- an existing period or creating a new one.
792
- """
793
- periods = get_declaration_periods(descending=False)
794
-
795
- # Hide the status alert after this callback (both variables must be equal)
796
- if status_hidden != status_visible:
797
- status_hidden = status_visible
798
-
799
- if num_clicks is not None and num_clicks != num_clicks_previous:
800
- today = datetime.now()
801
- if today.month < 7:
802
- start_month = 1
803
- end_month = 6
804
- end_day = 30
805
- semester = 1
806
- else:
807
- start_month = 7
808
- end_month = 12
809
- end_day = 31
810
- semester = 2
811
- period_name = f"{today.year}-S{semester}"
812
- start_date = datetime(today.year, start_month, 1)
813
- end_date = datetime(today.year, end_month, end_day, 23, 59)
814
- validation_date = datetime(today.year, end_month, 15)
815
- saved_clicks = num_clicks
816
- period_save_disabled = False
817
-
818
- else:
819
- try:
820
- period_index = int(period_index)
821
- if period_index < len(periods):
822
- period_name = periods[period_index].name
823
- start_date = periods[period_index].start_date
824
- end_date = periods[period_index].end_date
825
- validation_date = periods[period_index].validation_date
826
- saved_clicks = num_clicks_previous
827
- period_save_disabled = True
828
- else:
829
- raise Exception(
830
- f"internal error: period_index has an invalid value ({period_index})"
831
- )
832
- except Exception as e:
833
- raise e
834
-
835
- return (
836
- period_name,
837
- True,
838
- start_date,
839
- True,
840
- end_date,
841
- True,
842
- validation_date,
843
- True,
844
- {"visibility": "visible"},
845
- saved_clicks,
846
- period_save_disabled,
847
- status_hidden,
848
- )
849
-
850
-
851
- @app.callback(
852
- Output(DECLARATION_PERIODS_STATUS_ID, "children"),
853
- Output(DECLARATION_PERIODS_STATUS_ID, "color"),
854
- Output(DECLARATION_PERIODS_STATUS_VISIBLE_ID, "data"),
855
- Input(DECLARATION_PERIODS_SAVE_NEW_ID, "n_clicks"),
856
- State(DECLARATION_PERIOD_NAME_ID, "value"),
857
- State(DECLARATION_PERIOD_START_DATE_ID, "date"),
858
- State(DECLARATION_PERIOD_END_DATE_ID, "date"),
859
- State(DECLARATION_PERIOD_VALIDATION_DATE_ID, "date"),
860
- State(DECLARATION_PERIODS_STATUS_HIDDEN_ID, "data"),
861
- State(DECLARATION_PERIODS_STATUS_VISIBLE_ID, "data"),
862
- prevent_initial_call=True,
863
- )
864
- def save_new_period(
865
- n_clicks: int,
866
- name: str,
867
- start_date: str,
868
- end_date: str,
869
- validation_date: str,
870
- status_hidden: int,
871
- status_visible: int,
872
- ) -> (Any, str, int):
873
- """
874
- Save new period in database
875
- """
876
- from ositah.utils.hito_db import get_db
877
- from ositah.utils.hito_db_model import OSITAHValidationPeriod
878
-
879
- db = get_db()
880
-
881
- # Display the status alert after this callback (status_visible must be greater
882
- # than status_hidden)
883
- if status_visible <= status_hidden:
884
- status_visible = status_hidden + 1
885
-
886
- period = OSITAHValidationPeriod(
887
- name=name,
888
- start_date=start_date,
889
- end_date=end_date,
890
- validation_date=validation_date,
891
- )
892
- try:
893
- db.session.add(period)
894
- db.session.commit()
895
- except Exception as e:
896
- return (html.Div(repr(e)), "danger", status_visible)
897
-
898
- return (
899
- html.Div([f"Nouvelle période {name} ajoutée. ", html.A("Recharger", href="")]),
900
- "success",
901
- status_visible,
902
- )
903
-
904
-
905
- @app.callback(
906
- Output(DECLARATION_PERIODS_STATUS_ID, "is_open"),
907
- Input(DECLARATION_PERIODS_STATUS_HIDDEN_ID, "data"),
908
- Input(DECLARATION_PERIODS_STATUS_VISIBLE_ID, "data"),
909
- prevent_initial_call=True,
910
- )
911
- def display_declaration_periods_status(status_hidden: int, status_visible: int) -> bool:
912
- """
913
- Callback to control whether the declaration periods status must be displayed or hidden.
914
- If status_visible > status_hidden, it must be displayed, else it must be hidden.
915
- """
916
- return status_visible > status_hidden
1
+ """
2
+ Dash callbacks for Configuration applications
3
+ """
4
+
5
+ from datetime import datetime
6
+ from typing import Any, List
7
+
8
+ import dash_bootstrap_components as dbc
9
+ from dash import callback_context, html
10
+ from dash.dependencies import Input, Output, State
11
+ from dash.exceptions import PreventUpdate
12
+
13
+ from ositah.app import app
14
+ from ositah.apps.configuration.parameters import *
15
+ from ositah.apps.configuration.tools import (
16
+ get_masterprojects_items,
17
+ get_projects_items,
18
+ list_box_empty,
19
+ )
20
+ from ositah.utils.exceptions import InvalidCallbackInput
21
+ from ositah.utils.menus import TABLE_TYPE_TABLE, GlobalParams
22
+ from ositah.utils.period import get_declaration_periods
23
+ from ositah.utils.projects import (
24
+ MASTERPROJECT_DELETED_ACTIVITY,
25
+ NSIP_CLASS_OTHER_ACTIVITY,
26
+ NSIP_CLASS_PROJECT,
27
+ add_activity,
28
+ add_activity_teams,
29
+ get_all_hito_activities,
30
+ get_hito_nsip_activities,
31
+ get_nsip_activities,
32
+ nsip_activity_name_id,
33
+ ositah2hito_project_name,
34
+ reenable_activity,
35
+ remove_activity,
36
+ remove_activity_teams,
37
+ update_activity_name,
38
+ )
39
+ from ositah.utils.teams import get_project_team_ids
40
+
41
+
42
+ def check_activity_changes(project_activity: bool):
43
+ """
44
+ Retrieve the list of activity changes in NSIP compared to the Hito projects
45
+
46
+ :param project_activity: true for projects, false for other activities
47
+ :return: dataframe with changed activities
48
+ """
49
+
50
+ nsip_activities = get_nsip_activities(project_activity)
51
+ if nsip_activities is None or nsip_activities.empty:
52
+ raise PreventUpdate
53
+
54
+ hito_projects = get_hito_nsip_activities(project_activity)
55
+
56
+ if project_activity:
57
+ merge_left_attr = "nsip_project_id"
58
+ else:
59
+ merge_left_attr = "nsip_reference_id"
60
+ activity_changes = hito_projects.merge(
61
+ nsip_activities,
62
+ how="outer",
63
+ left_on=merge_left_attr,
64
+ right_on="id",
65
+ indicator=True,
66
+ suffixes=[None, "_nsip"],
67
+ )
68
+ activity_changes["status"] = None
69
+ activity_changes.loc[activity_changes._merge == "left_only", "status"] = PROJECT_CHANGE_REMOVED
70
+ activity_changes.loc[activity_changes._merge == "right_only", "status"] = PROJECT_CHANGE_ADDED
71
+ activity_changes.loc[
72
+ (activity_changes._merge == "both")
73
+ & (
74
+ (activity_changes.nsip_project != activity_changes.ositah_name)
75
+ | (activity_changes["master_project.name"] != activity_changes.nsip_master)
76
+ ),
77
+ "status",
78
+ ] = PROJECT_CHANGE_CHANGED
79
+ activity_changes.drop(activity_changes[activity_changes.status.isna()].index, inplace=True)
80
+
81
+ # Set NaN to 0 in referentiel_id and id_nsip as np.NaN is a float and prevent casting to int.
82
+ activity_changes.loc[activity_changes["referentiel_id"].isna(), "referentiel_id"] = 0
83
+ activity_changes.loc[activity_changes["id_nsip"].isna(), "id_nsip"] = 0
84
+ activity_changes["referentiel_id"] = activity_changes.referentiel_id.astype(int)
85
+ activity_changes["id_nsip"] = activity_changes.id_nsip.astype(int)
86
+
87
+ return activity_changes
88
+
89
+
90
+ @app.callback(
91
+ Output(TAB_ID_ACTIVITY_TEAMS, "children"),
92
+ Output(TAB_ID_NSIP_PROJECT_SYNC, "children"),
93
+ Output(TAB_ID_DECLARATION_PERIODS, "children"),
94
+ Output(TAB_ID_PROJECT_MGT, "children"),
95
+ Input(CONFIGURATION_TAB_MENU_ID, "active_tab"),
96
+ )
97
+ def display_tab_content(active_tab):
98
+ from ositah.apps.configuration.main import (
99
+ declaration_periods_layout,
100
+ nsip_sync_layout,
101
+ project_mgt_layout,
102
+ project_teams_layout,
103
+ )
104
+
105
+ if active_tab == TAB_ID_ACTIVITY_TEAMS:
106
+ return project_teams_layout(), "", "", ""
107
+ elif active_tab == TAB_ID_NSIP_PROJECT_SYNC:
108
+ return "", nsip_sync_layout(), "", ""
109
+ elif active_tab == TAB_ID_DECLARATION_PERIODS:
110
+ return "", "", declaration_periods_layout(), ""
111
+ elif active_tab == TAB_ID_PROJECT_MGT:
112
+ return "", "", "", project_mgt_layout()
113
+ else:
114
+ return "", "", "", ""
115
+
116
+
117
+ @app.callback(
118
+ Output(NSIP_SYNC_CONTENT_ID, "children"),
119
+ Output(NSIP_SYNC_APPLY_DIFF_ID, "disabled"),
120
+ Input(NSIP_SYNC_SHOW_DIFF_ID, "n_clicks"),
121
+ Input(NSIP_SYNC_APPLY_DIFF_ID, "n_clicks"),
122
+ State(NSIP_SYNC_ACTIVITY_TYPE_ID, "value"),
123
+ prevent_initial_call=True,
124
+ )
125
+ def show_nsip_activity_differences(_, __, activity_type):
126
+ """
127
+ Handle buttons from the NSIP synchronisation tab. Values of input parameters are not used.
128
+ Do nothing if no button was clicked.
129
+
130
+ :param activity_type: type of NSIP activity (integer)
131
+ :return: contents of NSIP_SYNC_CONTENT_ID components
132
+ """
133
+
134
+ global_params = GlobalParams()
135
+
136
+ if not callback_context.triggered:
137
+ return PreventUpdate
138
+ else:
139
+ button_id = callback_context.triggered[0]["prop_id"].split(".")[0]
140
+
141
+ if activity_type == NSIP_SYNC_ACTIVITY_TYPE_PROJECT:
142
+ project_activity = True
143
+ else:
144
+ project_activity = False
145
+
146
+ activity_changes = check_activity_changes(project_activity)
147
+
148
+ if button_id == NSIP_SYNC_SHOW_DIFF_ID:
149
+ add_num = len(activity_changes[activity_changes.status == PROJECT_CHANGE_ADDED])
150
+ change_num = len(activity_changes[activity_changes.status == PROJECT_CHANGE_CHANGED])
151
+ remove_num = len(activity_changes[activity_changes.status == PROJECT_CHANGE_REMOVED])
152
+ total_changes = add_num + change_num + remove_num
153
+
154
+ if add_num or change_num or remove_num:
155
+ data_columns = {
156
+ # TO BE FIXED: Column names (nsip_master, nsip_project, ositah_name)
157
+ # are misleading...
158
+ "Masterprojet OSITAH": "nsip_master",
159
+ "Projet OSITAH": "nsip_project",
160
+ "ID OSITAH": "id",
161
+ "ID Référentiel": "referentiel_id",
162
+ "Masterprojet NSIP": "master_project.name",
163
+ "Projet NSIP": "ositah_name",
164
+ "ID NSIP": "id_nsip",
165
+ "Statut": "status",
166
+ }
167
+ theader = [html.Thead(html.Tr([html.Th(c) for c in data_columns.keys()]))]
168
+
169
+ tbody = [
170
+ html.Tbody(
171
+ [
172
+ html.Tr([html.Td(row[c]) for c in data_columns.values()])
173
+ for i, row in activity_changes.sort_values(
174
+ by=["nsip_master", "nsip_project"]
175
+ ).iterrows()
176
+ ]
177
+ )
178
+ ]
179
+
180
+ table = dbc.Table(
181
+ theader + tbody,
182
+ id={"type": TABLE_TYPE_TABLE, "id": TABLE_NSIP_PROJECT_SYNC_ID},
183
+ bordered=True,
184
+ hover=True,
185
+ striped=True,
186
+ class_name="sortable",
187
+ )
188
+
189
+ summary_msg = html.B(
190
+ (
191
+ f"{total_changes} changements NSIP / OSITAH : {add_num} additions,"
192
+ f" {remove_num} suppressions, {change_num} modifications"
193
+ )
194
+ )
195
+
196
+ apply_button_disabled = False
197
+
198
+ else:
199
+ table = html.Div()
200
+ if project_activity:
201
+ summary_msg = "Les projets NSIP et OSITAH sont synchronisés"
202
+ else:
203
+ summary_msg = "Les activités NSIP et OSITAH sont synchronisées"
204
+ apply_button_disabled = True
205
+
206
+ return (
207
+ html.Div(
208
+ [
209
+ dbc.Alert(summary_msg),
210
+ html.P(),
211
+ table,
212
+ ]
213
+ ),
214
+ apply_button_disabled,
215
+ )
216
+
217
+ elif button_id == NSIP_SYNC_APPLY_DIFF_ID:
218
+ add_failed = {}
219
+ change_failed = {}
220
+ delete_failed = {}
221
+
222
+ for _, activity in activity_changes[
223
+ activity_changes.status == PROJECT_CHANGE_CHANGED
224
+ ].iterrows():
225
+ status, error_msg = update_activity_name(
226
+ activity.id,
227
+ activity.referentiel_id,
228
+ activity.id_nsip,
229
+ activity["master_project.name"],
230
+ activity.ositah_name,
231
+ )
232
+ if status:
233
+ change_failed[f"{activity['nsip_name_id']}"] = error_msg
234
+
235
+ for _, activity in activity_changes[
236
+ activity_changes.status == PROJECT_CHANGE_ADDED
237
+ ].iterrows():
238
+ activity_full_name = ositah2hito_project_name(
239
+ activity["master_project.name"], activity.ositah_name
240
+ )
241
+ activity_teams = get_project_team_ids(global_params.project_teams, activity_full_name)
242
+ status, error_msg = add_activity(
243
+ activity.id_nsip,
244
+ activity["master_project.name"],
245
+ activity.ositah_name,
246
+ activity_teams,
247
+ project_activity,
248
+ )
249
+ if status:
250
+ add_failed[
251
+ (
252
+ f"{activity['master_project.name']} / {activity.ositah_name}"
253
+ f" (NSIP ID: {activity.id_nsip})"
254
+ )
255
+ ] = error_msg
256
+
257
+ for _, activity in activity_changes[
258
+ activity_changes.status == PROJECT_CHANGE_REMOVED
259
+ ].iterrows():
260
+ _, _, project_id, reference_id = nsip_activity_name_id(
261
+ activity.nsip_name_id,
262
+ NSIP_CLASS_PROJECT if project_activity else NSIP_CLASS_OTHER_ACTIVITY,
263
+ )
264
+ if project_activity:
265
+ nsip_id = project_id
266
+ else:
267
+ nsip_id = reference_id
268
+ status, error_msg = remove_activity(
269
+ activity.id, activity.referentiel_id, nsip_id, project_activity
270
+ )
271
+ if status:
272
+ delete_failed[f"{activity['nsip_name_id']}"] = error_msg
273
+
274
+ if (
275
+ len(change_failed.keys()) == 0
276
+ and len(add_failed.keys()) == 0
277
+ and len(delete_failed) == 0
278
+ ):
279
+ return dbc.Alert("All changes applied successfully to Hito"), True
280
+ else:
281
+ alert_msg = []
282
+ if len(change_failed.keys()) > 0:
283
+ alert_msg.append(
284
+ html.P(html.B("Erreur durant la mise à jour des activités suivantes :"))
285
+ )
286
+ alert_msg.append(html.Ul([html.Li(f"{k}: {v}") for k, v in change_failed.items()]))
287
+ if len(add_failed.keys()) > 0:
288
+ alert_msg.append(html.P(html.B("Erreur durant l'ajout des activités suivantes :")))
289
+ alert_msg.append(html.Ul([html.Li(f"{k}: {v}") for k, v in add_failed.items()]))
290
+ if len(delete_failed.keys()) > 0:
291
+ alert_msg.append(
292
+ html.P(html.B("Erreur durant la suppression des activités suivantes :"))
293
+ )
294
+ alert_msg.append(html.Ul([html.Li(f"{k}: {v}") for k, v in delete_failed.items()]))
295
+ return dbc.Alert(alert_msg), True
296
+
297
+ else:
298
+ return dbc.Alert("Bouton invalide", color="danger"), True
299
+
300
+
301
+ @app.callback(
302
+ Output(ACTIVITY_TEAMS_PROJECTS_ID, "options"),
303
+ Output(ACTIVITY_TEAMS_PROJECTS_ID, "value"),
304
+ Input(ACTIVITY_TEAMS_MASTERPROJECTS_ID, "value"),
305
+ State(ACTIVITY_TEAMS_PROJECT_ACTIVITY_ID, "data"),
306
+ prevent_initial_call=True,
307
+ )
308
+ def activity_team_projects(masterproject, project_activity):
309
+ """
310
+ Display the list of projects associated with the selected masterproject in the project
311
+ list box. Also reset the project selected value.
312
+
313
+ :param masterproject: selected master project
314
+ :param project_activity: True if it is a project rather than a Hito activity
315
+ :return: list box options
316
+ """
317
+
318
+ items = get_projects_items(masterproject, project_activity, None)
319
+ if len(items):
320
+ return items, None
321
+ else:
322
+ return [], None
323
+
324
+
325
+ @app.callback(
326
+ Output(PROJECT_MGT_MASTERPROJECT_LIST_ID, "options"),
327
+ Output(PROJECT_MGT_MASTERPROJECT_LIST_ID, "value"),
328
+ Input(PROJECT_MGT_PROJECT_TYPE_ID, "value"),
329
+ State(PROJECT_MGT_PROJECT_ACTIVITY_ID, "data"),
330
+ prevent_initial_call=True,
331
+ )
332
+ def project_mgt_masterprojects(category, project_activity):
333
+ """
334
+ Display the list of projects associated with the selected masterproject in the project
335
+ list box. Also reset the masterproject selected value.
336
+
337
+ :param category: a number indicating if NSIP, local or disabled projects must be displayed
338
+ :param project_activity: True if it is a project rather than a Hito activity
339
+ :return: list box options, selected value reset to an empty string
340
+ """
341
+
342
+ if category is None:
343
+ return [], ""
344
+ else:
345
+ items = get_masterprojects_items(project_activity, category)
346
+ return items, ""
347
+
348
+
349
+ @app.callback(
350
+ Output(PROJECT_MGT_PROJECT_LIST_ID, "options"),
351
+ Output(PROJECT_MGT_PROJECT_LIST_ID, "value"),
352
+ Input(PROJECT_MGT_MASTERPROJECT_LIST_ID, "value"),
353
+ Input(PROJECT_MGT_PROJECT_TYPE_ID, "value"),
354
+ State(PROJECT_MGT_PROJECT_ACTIVITY_ID, "data"),
355
+ prevent_initial_call=True,
356
+ )
357
+ def project_mgt_projects(masterproject, category, project_activity):
358
+ """
359
+ Display the list of projects associated with the selected masterproject in the project
360
+ list box. If local projects are selected, the masterproject value is empty.
361
+ Also reset the project selected value.
362
+
363
+ :param masterproject: selected master project
364
+ :param category: a number indicating if NSIP, local or disabled projects must be displayed
365
+ :param project_activity: True if it is a project rather than a Hito activity
366
+ :return: list box options
367
+ """
368
+
369
+ if masterproject or category == PROJECT_MGT_PROJECT_TYPE_LOCAL:
370
+ items = get_projects_items(masterproject, project_activity, category)
371
+ return items, ""
372
+ else:
373
+ return [], ""
374
+
375
+
376
+ @app.callback(
377
+ Output(PROJECT_MGT_ACTION_BUTTON_ID, "children"),
378
+ Output(PROJECT_MGT_ACTION_BUTTON_ID, "style"),
379
+ Output(PROJECT_MGT_ACTION_BUTTON_ID, "disabled"),
380
+ Input(PROJECT_MGT_PROJECT_LIST_ID, "value"),
381
+ State(PROJECT_MGT_PROJECT_TYPE_ID, "value"),
382
+ prevent_initial_call=True,
383
+ )
384
+ def project_mgt_enable_action(activity, category):
385
+ """
386
+ If a project has been selected, enable the button with the appropriate action depending on
387
+ activity type. If no project is elected, hide and disable the action button.
388
+
389
+ :param activity: name of the selected activity
390
+ :param category: whether the project is a local project, a NSIP project or a disabled one
391
+ """
392
+
393
+ if activity:
394
+ if category == PROJECT_MGT_PROJECT_TYPE_DISABLED:
395
+ action = PROJECT_MGT_ACTION_BUTTON_ENABLE
396
+ else:
397
+ action = PROJECT_MGT_ACTION_BUTTON_DISABLE
398
+ return action, {"visibility": "visible"}, False
399
+ else:
400
+ return "", {"visibility": "hidden"}, True
401
+
402
+
403
+ @app.callback(
404
+ Output(PROJECT_MGT_STATUS_ID, "is_open"),
405
+ Output(PROJECT_MGT_STATUS_ID, "children"),
406
+ Output(PROJECT_MGT_STATUS_ID, "color"),
407
+ Input(PROJECT_MGT_ACTION_BUTTON_ID, "n_clicks"),
408
+ State(PROJECT_MGT_ACTION_BUTTON_ID, "children"),
409
+ State(PROJECT_MGT_PROJECT_LIST_ID, "value"),
410
+ State(PROJECT_MGT_PROJECT_ACTIVITY_ID, "data"),
411
+ prevent_initial_call=True,
412
+ )
413
+ def project_mgt_execute_action(n_clicks, action, activity, is_project):
414
+ """
415
+ Execute action associated with the button. Actual action is retrieved from the
416
+ button label.
417
+
418
+ :param n_clicks: number of clicks, ignored
419
+ :param action: the button label
420
+ :param activity: name of the project/activity
421
+ :param is_project: true if a project, false if is an Hito activity
422
+ :return: status message and color
423
+ """
424
+
425
+ if action == PROJECT_MGT_ACTION_BUTTON_DISABLE:
426
+ return True, "Désactivation projet : pas encore implémentée", "warning"
427
+ elif action == PROJECT_MGT_ACTION_BUTTON_ENABLE:
428
+ status, status_msg = reenable_activity(activity, is_project, MASTERPROJECT_DELETED_ACTIVITY)
429
+ if status == 0:
430
+ return True, f"Projet {activity} réactivé avec succès", "success"
431
+ else:
432
+ alert_msg = html.Div(
433
+ html.P(f"Erreur durant la réactivation du projet {activity}"), html.P(error_msg)
434
+ )
435
+ return True, alert_msg, "danger"
436
+ else:
437
+ return True, f"Internal error: invalid action ({action})", "danger"
438
+
439
+
440
+ @app.callback(
441
+ Output(ACTIVITY_TEAMS_LAB_TEAMS_ID, "options"),
442
+ Input(ACTIVITY_TEAMS_PROJECTS_ID, "value"),
443
+ State(ACTIVITY_TEAMS_MASTERPROJECTS_ID, "value"),
444
+ prevent_initial_call=True,
445
+ )
446
+ def display_teams(project, masterproject):
447
+ """
448
+ Display the Hito teams not yet associated with the selected project in the lab team
449
+ list box
450
+
451
+ :param project: selected project
452
+ :param masterproject: selected master project
453
+ :return: list box options for lab teams
454
+ """
455
+
456
+ global_params = GlobalParams()
457
+ session_data = global_params.session_data
458
+
459
+ if masterproject and project:
460
+ lab_team_items = []
461
+ for team in sorted(session_data.agent_teams):
462
+ lab_team_items.append({"label": team, "value": team})
463
+ return lab_team_items
464
+
465
+ else:
466
+ return []
467
+
468
+
469
+ @app.callback(
470
+ Output(ACTIVITY_TEAMS_BUTTON_ADD_ID, "disabled"),
471
+ Input(ACTIVITY_TEAMS_LAB_TEAMS_ID, "value"),
472
+ )
473
+ def enable_team_update_buttons(selected_team):
474
+ """
475
+ Enable the button allowing to add the selected team in lab teams into the project teams
476
+
477
+ :param selected_team: selected lab team
478
+ :return: boolean
479
+ """
480
+ if selected_team:
481
+ return False
482
+ else:
483
+ return True
484
+
485
+
486
+ @app.callback(
487
+ Output(ACTIVITY_TEAMS_BUTTON_REMOVE_ID, "disabled"),
488
+ Input(ACTIVITY_TEAMS_SELECTED_TEAMS_ID, "value"),
489
+ )
490
+ def enable_team_remove_buttons(selected_team):
491
+ """
492
+ Enable the button allowing to remove a team from the project teams
493
+
494
+ :param selected_team: selected lab team
495
+ :return: boolean
496
+ """
497
+ if selected_team:
498
+ return False
499
+ else:
500
+ return True
501
+
502
+
503
+ @app.callback(
504
+ Output(ACTIVITY_TEAMS_SELECTED_TEAMS_ID, "options"),
505
+ Output(ACTIVITY_TEAMS_ADDED_TEAMS_ID, "data"),
506
+ Output(ACTIVITY_TEAMS_REMOVED_TEAMS_ID, "data"),
507
+ Output(ACTIVITY_TEAMS_BUTTON_UPDATE_ID, "disabled"),
508
+ Output(ACTIVITY_TEAMS_BUTTON_CANCEL_ID, "disabled"),
509
+ Output(ACTIVITY_TEAMS_LAB_TEAMS_ID, "value"),
510
+ Output(ACTIVITY_TEAMS_SELECTED_TEAMS_ID, "value"),
511
+ Input(ACTIVITY_TEAMS_BUTTON_ADD_ID, "n_clicks"),
512
+ Input(ACTIVITY_TEAMS_BUTTON_REMOVE_ID, "n_clicks"),
513
+ Input(ACTIVITY_TEAMS_PROJECTS_ID, "value"),
514
+ State(ACTIVITY_TEAMS_MASTERPROJECTS_ID, "value"),
515
+ State(ACTIVITY_TEAMS_LAB_TEAMS_ID, "value"),
516
+ State(ACTIVITY_TEAMS_SELECTED_TEAMS_ID, "options"),
517
+ State(ACTIVITY_TEAMS_SELECTED_TEAMS_ID, "value"),
518
+ State(ACTIVITY_TEAMS_PROJECT_ACTIVITY_ID, "data"),
519
+ State(ACTIVITY_TEAMS_ADDED_TEAMS_ID, "data"),
520
+ State(ACTIVITY_TEAMS_REMOVED_TEAMS_ID, "data"),
521
+ prevent_initial_call=True,
522
+ )
523
+ def update_selected_teams(
524
+ update_n_click: int,
525
+ cancel_n_click: int,
526
+ project,
527
+ masterproject,
528
+ selected_lab_team,
529
+ team_list_items,
530
+ selected_activity_team,
531
+ project_activity,
532
+ added_teams,
533
+ removed_teams,
534
+ ):
535
+ """
536
+ Update the project teams list box after a project selection change or a lab team
537
+ selection.
538
+
539
+ :param update_n_click: clicks (ignored) for update button
540
+ :param cancel_n_click: clicks (ignored) for cancel button
541
+ :param project: selected project
542
+ :param masterproject: selected masterproject
543
+ :param selected_lab_team: selected team in lab teams (for addition)
544
+ :param team_list_items: current contents of project teams list box
545
+ :param selected_lab_team: selected team in project teams (for removal)
546
+ :param project_activity: True if it is a project rather than a Hito activity
547
+ :param added_teams: list of teams to be added
548
+ :param removed_teams: list of teams to be removed
549
+ :return: list box options for project teams and update/cancel buttons
550
+ """
551
+
552
+ ctx = callback_context
553
+ if not ctx.triggered:
554
+ raise PreventUpdate
555
+ else:
556
+ active_input = ctx.triggered[0]["prop_id"].split(".")[0]
557
+
558
+ # Selected project changed: reset the list to project teams
559
+ if active_input == ACTIVITY_TEAMS_PROJECTS_ID:
560
+ global_params = GlobalParams()
561
+ session_data = global_params.session_data
562
+ activities = get_all_hito_activities(project_activity)
563
+ activity_teams = activities[
564
+ (activities.masterproject == masterproject) & (activities.project == project)
565
+ ]["team_name"]
566
+ team_list_items = []
567
+ for team in sorted(activity_teams):
568
+ team_disabled = False if team in session_data.agent_teams else True
569
+ team_list_items.append({"label": team, "value": team, "disabled": team_disabled})
570
+ return team_list_items, [], [], True, True, None, None
571
+
572
+ # A team being added to the project
573
+ elif active_input == ACTIVITY_TEAMS_BUTTON_ADD_ID:
574
+ team_present = False
575
+ if team_list_items and not list_box_empty(team_list_items):
576
+ for item in team_list_items:
577
+ if item["value"] == selected_lab_team:
578
+ team_present = True
579
+ break
580
+ else:
581
+ team_list_items = []
582
+ if team_present:
583
+ raise PreventUpdate
584
+ else:
585
+ if selected_lab_team in removed_teams:
586
+ removed_teams.remove(selected_lab_team)
587
+ added_teams.append(selected_lab_team)
588
+ team_list_items.append(
589
+ {
590
+ "label": selected_lab_team,
591
+ "value": selected_lab_team,
592
+ "disabled": False,
593
+ }
594
+ )
595
+ return (
596
+ sorted(team_list_items, key=lambda x: x["label"]),
597
+ added_teams,
598
+ removed_teams,
599
+ False,
600
+ False,
601
+ None,
602
+ None,
603
+ )
604
+
605
+ elif active_input == ACTIVITY_TEAMS_BUTTON_REMOVE_ID:
606
+ if team_list_items and not list_box_empty(team_list_items):
607
+ team_list_items.remove(
608
+ {
609
+ "label": selected_activity_team,
610
+ "value": selected_activity_team,
611
+ "disabled": False,
612
+ }
613
+ )
614
+ removed_teams.append(selected_activity_team)
615
+ if selected_activity_team in added_teams:
616
+ added_teams.remove(selected_activity_team)
617
+ return (
618
+ sorted(team_list_items, key=lambda x: x["label"]),
619
+ added_teams,
620
+ removed_teams,
621
+ False,
622
+ False,
623
+ None,
624
+ None,
625
+ )
626
+
627
+ # Should not happen...
628
+ else:
629
+ raise PreventUpdate
630
+
631
+ else:
632
+ raise InvalidCallbackInput(active_input)
633
+
634
+
635
+ @app.callback(
636
+ Output(ACTIVITY_TEAMS_STATUS_ID, "children"),
637
+ Output(ACTIVITY_TEAMS_STATUS_ID, "is_open"),
638
+ Output(ACTIVITY_TEAMS_STATUS_ID, "color"),
639
+ Output(ACTIVITY_TEAMS_RESET_INDICATOR_ID, "data"),
640
+ Input(ACTIVITY_TEAMS_BUTTON_UPDATE_ID, "n_clicks"),
641
+ Input(ACTIVITY_TEAMS_BUTTON_CANCEL_ID, "n_clicks"),
642
+ State(ACTIVITY_TEAMS_MASTERPROJECTS_ID, "value"),
643
+ State(ACTIVITY_TEAMS_PROJECTS_ID, "value"),
644
+ State(ACTIVITY_TEAMS_ADDED_TEAMS_ID, "data"),
645
+ State(ACTIVITY_TEAMS_REMOVED_TEAMS_ID, "data"),
646
+ State(ACTIVITY_TEAMS_PROJECT_ACTIVITY_ID, "data"),
647
+ State(ACTIVITY_TEAMS_RESET_INDICATOR_ID, "data"),
648
+ prevent_initial_call=True,
649
+ )
650
+ def update_activity_teams(
651
+ update_n_click: int,
652
+ cancel_n_click: int,
653
+ masterproject: str,
654
+ project: str,
655
+ added_teams: List[str],
656
+ removed_teams: List[str],
657
+ project_activity: bool,
658
+ reset_indicator: int,
659
+ ):
660
+ """
661
+ Update the team list associated with the selected activity and if successful, reset the
662
+ page elements (increment the reset indicator). Also clear the activity cache to force
663
+ updating it from the database. This callback is also used to cancel the current
664
+ modifications if the cancel button is clicked.
665
+
666
+ :param update_n_click: clicks (ignored) for update button
667
+ :param cancel_n_click: clicks (ignored) for cancel button
668
+ :param masterproject: selected masterproject
669
+ :param project: selected project
670
+ :param added_teams: list of teams to add to the selected project
671
+ :param removed_teams: list of teams to remove from the selected project
672
+ :param project_activity: if true, an Hito project else an Hito activity
673
+ :return: status msg, its color and the flag to reset the page elements
674
+ """
675
+
676
+ ctx = callback_context
677
+ if not ctx.triggered:
678
+ raise PreventUpdate
679
+ else:
680
+ active_input = ctx.triggered[0]["prop_id"].split(".")[0]
681
+
682
+ global_params = GlobalParams()
683
+ session_data = global_params.session_data
684
+
685
+ if active_input == ACTIVITY_TEAMS_BUTTON_CANCEL_ID:
686
+ return "", False, "success", reset_indicator + 1
687
+ else:
688
+ status_msg = []
689
+ status = 0
690
+
691
+ if len(added_teams):
692
+ add_status, add_status_msg = add_activity_teams(
693
+ masterproject, project, added_teams, project_activity
694
+ )
695
+ status += add_status
696
+ if add_status == 0:
697
+ status_msg.append(
698
+ html.Div(
699
+ f"'{masterproject}/{project}' team list updated:"
700
+ f" '{', '.join(added_teams)}' added"
701
+ )
702
+ )
703
+ else:
704
+ status_msg.append(
705
+ html.Div(
706
+ f"Failed to add '{', '.join(added_teams)}' to '{masterproject}/{project}'"
707
+ f" team list: {add_status_msg}",
708
+ )
709
+ )
710
+
711
+ if len(removed_teams):
712
+ remove_status, remove_status_msg = remove_activity_teams(
713
+ masterproject, project, removed_teams, project_activity
714
+ )
715
+ status += remove_status
716
+ if remove_status == 0:
717
+ status_msg.append(
718
+ html.Div(
719
+ f"'{masterproject}/{project}' team list updated:"
720
+ f" '{', '.join(removed_teams)}' removed"
721
+ )
722
+ )
723
+ else:
724
+ status_msg.append(
725
+ html.Div(
726
+ f"Failed to remove '{', '.join(added_teams)}' from "
727
+ f"'{masterproject}/{project}' team list: {remove_status_msg}",
728
+ )
729
+ )
730
+
731
+ session_data.set_hito_activities(None, project_activity)
732
+
733
+ if status == 0:
734
+ status_color = "success"
735
+ else:
736
+ status_color = "danger"
737
+
738
+ return (
739
+ status_msg,
740
+ True,
741
+ status_color,
742
+ reset_indicator + 1,
743
+ )
744
+
745
+
746
+ @app.callback(
747
+ Output(ACTIVITY_TEAMS_MASTERPROJECTS_ID, "value"),
748
+ Input(ACTIVITY_TEAMS_RESET_INDICATOR_ID, "data"),
749
+ prevent_initial_call=True,
750
+ )
751
+ def reset_mastproject_selection(_):
752
+ """
753
+ Reset the masterprojet selection to None after the teams for the currently selected
754
+ project has been successfully updated.
755
+
756
+ :param _: reset indicator (value not used)
757
+ :return: masterproject selection
758
+ """
759
+ return None
760
+
761
+
762
+ @app.callback(
763
+ Output(DECLARATION_PERIOD_NAME_ID, "value"),
764
+ Output(DECLARATION_PERIOD_NAME_ID, "readonly"),
765
+ Output(DECLARATION_PERIOD_START_DATE_ID, "date"),
766
+ Output(DECLARATION_PERIOD_START_DATE_ID, "disabled"),
767
+ Output(DECLARATION_PERIOD_END_DATE_ID, "date"),
768
+ Output(DECLARATION_PERIOD_END_DATE_ID, "disabled"),
769
+ Output(DECLARATION_PERIOD_VALIDATION_DATE_ID, "date"),
770
+ Output(DECLARATION_PERIOD_VALIDATION_DATE_ID, "disabled"),
771
+ Output(DECLARATION_PERIOD_PARAMS_ID, "style"),
772
+ Output(DECLARATION_PERIODS_CREATE_CLICK_ID, "data"),
773
+ Output(DECLARATION_PERIODS_SAVE_NEW_ID, "disabled"),
774
+ Output(DECLARATION_PERIODS_STATUS_HIDDEN_ID, "data"),
775
+ Input(DECLARATION_PERIODS_ID, "value"),
776
+ Input(DECLARATION_PERIODS_CREATE_NEW_ID, "n_clicks"),
777
+ State(DECLARATION_PERIODS_CREATE_CLICK_ID, "data"),
778
+ State(DECLARATION_PERIODS_STATUS_HIDDEN_ID, "data"),
779
+ State(DECLARATION_PERIODS_STATUS_VISIBLE_ID, "data"),
780
+ prevent_initial_call=True,
781
+ )
782
+ def display_period_params(
783
+ period_index: str,
784
+ num_clicks: int,
785
+ num_clicks_previous: int,
786
+ status_hidden: int,
787
+ status_visible: int,
788
+ ) -> (str, bool, str, bool, str, bool, str, bool, str, int, int):
789
+ """
790
+ This callback displays the parameters of a selected period. It can be called either by selecting
791
+ an existing period or creating a new one.
792
+ """
793
+ periods = get_declaration_periods(descending=False)
794
+
795
+ # Hide the status alert after this callback (both variables must be equal)
796
+ if status_hidden != status_visible:
797
+ status_hidden = status_visible
798
+
799
+ if num_clicks is not None and num_clicks != num_clicks_previous:
800
+ today = datetime.now()
801
+ if today.month < 7:
802
+ start_month = 1
803
+ end_month = 6
804
+ end_day = 30
805
+ semester = 1
806
+ else:
807
+ start_month = 7
808
+ end_month = 12
809
+ end_day = 31
810
+ semester = 2
811
+ period_name = f"{today.year}-S{semester}"
812
+ start_date = datetime(today.year, start_month, 1)
813
+ end_date = datetime(today.year, end_month, end_day, 23, 59)
814
+ validation_date = datetime(today.year, end_month, 15)
815
+ saved_clicks = num_clicks
816
+ period_save_disabled = False
817
+
818
+ else:
819
+ try:
820
+ period_index = int(period_index)
821
+ if period_index < len(periods):
822
+ period_name = periods[period_index].name
823
+ start_date = periods[period_index].start_date
824
+ end_date = periods[period_index].end_date
825
+ validation_date = periods[period_index].validation_date
826
+ saved_clicks = num_clicks_previous
827
+ period_save_disabled = True
828
+ else:
829
+ raise Exception(
830
+ f"internal error: period_index has an invalid value ({period_index})"
831
+ )
832
+ except Exception as e:
833
+ raise e
834
+
835
+ return (
836
+ period_name,
837
+ True,
838
+ start_date,
839
+ True,
840
+ end_date,
841
+ True,
842
+ validation_date,
843
+ True,
844
+ {"visibility": "visible"},
845
+ saved_clicks,
846
+ period_save_disabled,
847
+ status_hidden,
848
+ )
849
+
850
+
851
+ @app.callback(
852
+ Output(DECLARATION_PERIODS_STATUS_ID, "children"),
853
+ Output(DECLARATION_PERIODS_STATUS_ID, "color"),
854
+ Output(DECLARATION_PERIODS_STATUS_VISIBLE_ID, "data"),
855
+ Input(DECLARATION_PERIODS_SAVE_NEW_ID, "n_clicks"),
856
+ State(DECLARATION_PERIOD_NAME_ID, "value"),
857
+ State(DECLARATION_PERIOD_START_DATE_ID, "date"),
858
+ State(DECLARATION_PERIOD_END_DATE_ID, "date"),
859
+ State(DECLARATION_PERIOD_VALIDATION_DATE_ID, "date"),
860
+ State(DECLARATION_PERIODS_STATUS_HIDDEN_ID, "data"),
861
+ State(DECLARATION_PERIODS_STATUS_VISIBLE_ID, "data"),
862
+ prevent_initial_call=True,
863
+ )
864
+ def save_new_period(
865
+ n_clicks: int,
866
+ name: str,
867
+ start_date: str,
868
+ end_date: str,
869
+ validation_date: str,
870
+ status_hidden: int,
871
+ status_visible: int,
872
+ ) -> (Any, str, int):
873
+ """
874
+ Save new period in database
875
+ """
876
+ from ositah.utils.hito_db import get_db
877
+ from ositah.utils.hito_db_model import OSITAHValidationPeriod
878
+
879
+ db = get_db()
880
+
881
+ # Display the status alert after this callback (status_visible must be greater
882
+ # than status_hidden)
883
+ if status_visible <= status_hidden:
884
+ status_visible = status_hidden + 1
885
+
886
+ period = OSITAHValidationPeriod(
887
+ name=name,
888
+ start_date=start_date,
889
+ end_date=end_date,
890
+ validation_date=validation_date,
891
+ )
892
+ try:
893
+ db.session.add(period)
894
+ db.session.commit()
895
+ except Exception as e:
896
+ return (html.Div(repr(e)), "danger", status_visible)
897
+
898
+ return (
899
+ html.Div([f"Nouvelle période {name} ajoutée. ", html.A("Recharger", href="")]),
900
+ "success",
901
+ status_visible,
902
+ )
903
+
904
+
905
+ @app.callback(
906
+ Output(DECLARATION_PERIODS_STATUS_ID, "is_open"),
907
+ Input(DECLARATION_PERIODS_STATUS_HIDDEN_ID, "data"),
908
+ Input(DECLARATION_PERIODS_STATUS_VISIBLE_ID, "data"),
909
+ prevent_initial_call=True,
910
+ )
911
+ def display_declaration_periods_status(status_hidden: int, status_visible: int) -> bool:
912
+ """
913
+ Callback to control whether the declaration periods status must be displayed or hidden.
914
+ If status_visible > status_hidden, it must be displayed, else it must be hidden.
915
+ """
916
+ return status_visible > status_hidden