ositah 24.12.dev1__py3-none-any.whl → 25.6.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 +1191 -1189
  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-24.12.dev1.dist-info → ositah-25.6.dev1.dist-info}/METADATA +150 -149
  37. ositah-25.6.dev1.dist-info/RECORD +46 -0
  38. {ositah-24.12.dev1.dist-info → ositah-25.6.dev1.dist-info}/WHEEL +1 -1
  39. {ositah-24.12.dev1.dist-info → ositah-25.6.dev1.dist-info/licenses}/LICENSE +29 -29
  40. ositah-24.12.dev1.dist-info/RECORD +0 -46
  41. {ositah-24.12.dev1.dist-info → ositah-25.6.dev1.dist-info}/entry_points.txt +0 -0
  42. {ositah-24.12.dev1.dist-info → ositah-25.6.dev1.dist-info}/top_level.txt +0 -0
ositah/apps/export.py CHANGED
@@ -1,1189 +1,1191 @@
1
- # OSITAH sub-application exporting data to NSIP
2
- import math
3
- import re
4
-
5
- import dash_bootstrap_components as dbc
6
- import numpy as np
7
- from dash import dcc, html
8
- from dash.dependencies import ALL, MATCH, Input, Output, State
9
- from dash.exceptions import PreventUpdate
10
-
11
- from ositah.app import app
12
- from ositah.utils.agents import get_agents, get_nsip_agents
13
- from ositah.utils.exceptions import SessionDataMissing
14
- from ositah.utils.menus import (
15
- DATA_SELECTED_SOURCE_ID,
16
- TABLE_TYPE_TABLE,
17
- TEAM_SELECTED_VALUE_ID,
18
- TEAM_SELECTION_DATE_ID,
19
- VALIDATION_PERIOD_SELECTED_ID,
20
- create_progress_bar,
21
- team_list_dropdown,
22
- )
23
- from ositah.utils.period import get_validation_period_dates
24
- from ositah.utils.projects import (
25
- DATA_SOURCE_OSITAH,
26
- category_time_and_unit,
27
- get_hito_projects,
28
- get_nsip_declarations,
29
- get_team_projects,
30
- )
31
- from ositah.utils.utils import (
32
- HITO_ROLE_PROJECT_MGR,
33
- HITO_ROLE_SUPER_ADMIN,
34
- NSIP_COLUMN_NAMES,
35
- TEAM_LIST_ALL_AGENTS,
36
- TIME_UNIT_HOURS_EN,
37
- GlobalParams,
38
- no_session_id_jumbotron,
39
- )
40
-
41
- EXPORT_TAB_MENU_ID = "report-tabs"
42
- TAB_ID_EXPORT_NSIP = "nsip-export-page"
43
- TAB_MENU_EXPORT_NSIP = "Export NSIP"
44
-
45
- TABLE_NSIP_EXPORT_ID = "export-nsip"
46
-
47
- NSIP_EXPORT_BUTTON_LABEL = "Export"
48
- NSIP_EXPORT_BUTTON_ID = "export-nsip-selected-users"
49
- NSIP_EXPORT_SELECT_ALL_ID = "export-nsip-select-all"
50
- NSIP_EXPORT_ALL_SELECTED_ID = "export-nsip-all-selected"
51
- NSIP_EXPORT_STATUS_ID = "export-nsip-status-msg"
52
- NSIP_EXPORT_SELECTION_STATUS_ID = "export-nsip-selection-update-status"
53
-
54
- EXPORT_LOAD_INDICATOR_ID = "export-nsip-load-indicator"
55
- EXPORT_SAVED_LOAD_INDICATOR_ID = "export-nsip-saved-load-indicator"
56
- EXPORT_LOAD_TRIGGER_INTERVAL_ID = "export-nsip-load-callback-interval"
57
- EXPORT_PROGRESS_BAR_MAX_DURATION = 8 # seconds
58
- EXPORT_SAVED_ACTIVE_TAB_ID = "export-nsip-saved-active-tab"
59
-
60
- EXPORT_NSIP_SYNC_INDICATOR_ID = "export-nsip-sync-indicator"
61
- EXPORT_NSIP_SAVED_SYNC_INDICATOR_ID = "export-nsip-saved-sync-indicator"
62
- EXPORT_NSIP_SYNC_FREQUENCY = 2.0 # Average number of sync operations per second
63
- EXPORT_NSIP_SYNC_TRIGGER_INTERVAL_ID = "export-nsip-sync-callback-interval"
64
- EXPORT_NSIP_MULTIPLE_CONTRACTS_ERROR = re.compile(
65
- (
66
- r'"Agent has active multi-contracts in same laboratory - manual action needed\s+'
67
- r"\|\s+idAgentContract\s+:\s+(?P<id1>\d+)\s+\|\s+idAgentContract\s+:\s+(?P<id2>\d+)"
68
- )
69
- )
70
-
71
- NSIP_DECLARATIONS_SELECT_ALL = 0
72
- NSIP_DECLARATIONS_SELECT_UNSYNCHRONIZED = 1
73
- NSIP_DECLARATIONS_SWITCH_ID = "export-nsip-declaration-set-switch"
74
- NSIP_DECLARATIONS_SELECTED_ID = "export-nsip-selected-declaration-set"
75
-
76
-
77
- def export_submenus():
78
- """
79
- Build the tabs menus of the export subapplication
80
-
81
- :return: DBC Tabs
82
- """
83
-
84
- return dbc.Tabs(
85
- [
86
- dbc.Tab(
87
- id=TAB_ID_EXPORT_NSIP,
88
- tab_id=TAB_ID_EXPORT_NSIP,
89
- label=TAB_MENU_EXPORT_NSIP,
90
- ),
91
- dcc.Store(id=NSIP_DECLARATIONS_SELECTED_ID, data=NSIP_DECLARATIONS_SELECT_ALL),
92
- ],
93
- id=EXPORT_TAB_MENU_ID,
94
- )
95
-
96
-
97
- def export_layout():
98
- """
99
- Build the layout for this application, after reading the data if necessary.
100
-
101
- :return: application layout
102
- """
103
-
104
- return html.Div(
105
- [
106
- html.H1("Export des déclarations vers NSIP"),
107
- team_list_dropdown(),
108
- # The following dcc.Store is used to ensure that the the ijclab_export input exists
109
- # before the export page is created
110
- dcc.Store(id=DATA_SELECTED_SOURCE_ID, data=DATA_SOURCE_OSITAH),
111
- html.Div(export_submenus(), id="export-submenus", style={"marginTop": "3em"}),
112
- dcc.Store(id=EXPORT_LOAD_INDICATOR_ID, data=0),
113
- dcc.Store(id=EXPORT_SAVED_LOAD_INDICATOR_ID, data=0),
114
- dcc.Store(id=EXPORT_SAVED_ACTIVE_TAB_ID, data=""),
115
- ]
116
- )
117
-
118
-
119
- def nsip_export_table(team, team_selection_date, period_date: str, declarations_set):
120
- """
121
- Build a table ready to be exported to NSIP from validated declarations. The produced table
122
- can then be exported as a CSV for ingestion by NSIP.
123
-
124
- :param team: selected team
125
- :param team_selection_date: last time the team selection was changed
126
- :param period_date: a date that must be inside the declaration period
127
- :param declarations_set: declaration set to use (all or only non-synchronized)
128
- :return: dbc.Table
129
- """
130
-
131
- if team is None:
132
- return html.Div("")
133
-
134
- global_params = GlobalParams()
135
- columns = global_params.columns
136
- try:
137
- session_data = global_params.session_data
138
- except SessionDataMissing:
139
- return no_session_id_jumbotron()
140
-
141
- start_date, end_date = get_validation_period_dates(period_date)
142
-
143
- if session_data.role in [HITO_ROLE_PROJECT_MGR, HITO_ROLE_SUPER_ADMIN]:
144
- export_disabled = False
145
- else:
146
- export_disabled = True
147
-
148
- if session_data.nsip_declarations is None:
149
- hito_projects = get_hito_projects()
150
- declaration_list = get_team_projects(
151
- team, team_selection_date, period_date, DATA_SOURCE_OSITAH
152
- )
153
- if declaration_list is None or declaration_list.empty:
154
- return dbc.Alert(
155
- f"Aucune données validées n'existe pour l'équipe '{team}'",
156
- color="warning",
157
- )
158
- ositah_total_declarations_num = len(declaration_list)
159
- declaration_list = declaration_list.merge(
160
- hito_projects,
161
- left_on="hito_project_id",
162
- right_on="id",
163
- suffixes=[None, "_y"],
164
- )
165
- agent_list = get_agents(period_date, team)
166
- if team is None or team == TEAM_LIST_ALL_AGENTS:
167
- # If no team is selected, merge left to include declarations from agents whose
168
- # email_auth don't match between Hito and NSIP. It is typically the case for agents
169
- # who are no longer active in NSIP (e.g. agents who left the lab during the
170
- # declaration period).
171
- merge_how = "left"
172
- else:
173
- merge_how = "inner"
174
- declaration_list = declaration_list.merge(
175
- agent_list,
176
- how=merge_how,
177
- left_on=columns["agent_id"],
178
- right_on="id",
179
- suffixes=[None, "_y"],
180
- )
181
- declaration_list[["time", "time_unit"]] = declaration_list.apply(
182
- lambda r: category_time_and_unit(r["category"], r[columns["hours"]], english=True),
183
- axis=1,
184
- result_type="expand",
185
- )
186
- declaration_list["time"] = declaration_list["time"].astype(int, copy=False)
187
- declaration_list["email_auth"] = declaration_list["email_auth"].str.lower()
188
- declaration_list.sort_values(by="email_auth", inplace=True)
189
-
190
- colums_to_delete = []
191
- for columnn in declaration_list.columns.to_list():
192
- if columnn not in [
193
- *NSIP_COLUMN_NAMES.keys(),
194
- columns["statut"],
195
- columns["cem"],
196
- "fullname",
197
- ]:
198
- colums_to_delete.append(columnn)
199
- if len(colums_to_delete) > 0:
200
- declaration_list.drop(columns=colums_to_delete, inplace=True)
201
-
202
- nsip_agents = get_nsip_agents()
203
- declaration_list = declaration_list.merge(
204
- nsip_agents,
205
- how="left",
206
- left_on="email_auth",
207
- right_on="email_reseda",
208
- suffixes=[None, "_nsipa"],
209
- indicator=True,
210
- )
211
-
212
- # For OSITAH entries not found in NSIP, it may be that the agent left the lab during
213
- # the declaration period: in this case there is an entry in NSIP where the RESEDA email
214
- # has been replaced by a UUID allowing to update NSIP data. Check if such an entry
215
- # exists in NSIP with a matching fullname and use it if it exists. It also happens if the
216
- # agent email_auth changed during the period.
217
- # To make matching easier, index is temporarily set to fullname instead of an integer.
218
- nsip_missing_agent_names = declaration_list.loc[
219
- declaration_list["_merge"] == "left_only", columns["fullname"]
220
- ].unique()
221
- declaration_list.set_index(columns["fullname"], inplace=True)
222
- nsip_agents.set_index(columns["fullname"], inplace=True)
223
- for name in nsip_missing_agent_names:
224
- if name in nsip_agents.index:
225
- matching_inactive_nsip_agent = nsip_agents.loc[[name]]
226
- if not matching_inactive_nsip_agent.empty:
227
- # Should always work, raise an exception if it is not the case
228
- declaration_list.update(matching_inactive_nsip_agent, errors="raise")
229
- # Mark the entry as complete
230
- declaration_list.loc[name, "_merge"] = "both"
231
- declaration_list.reset_index(inplace=True)
232
-
233
- declaration_list["nsip_agent_missing"] = declaration_list["_merge"] == "left_only"
234
- declaration_list["optional"] = declaration_list[columns["statut"]].isin(
235
- global_params.declaration_options["optional_statutes"]
236
- )
237
-
238
- # For EC (enseignant chercheurs), apply the ratio defined in the configuration (if any)
239
- # to teaching hours declared to convert them into hours with students
240
- if global_params.teaching_ratio:
241
- ratio = global_params.teaching_ratio["ratio"]
242
- cem = global_params.teaching_ratio["cem"]
243
- if cem:
244
- declaration_list.loc[
245
- (declaration_list.nsip_master == global_params.teaching_ratio["masterproject"])
246
- & (declaration_list[columns["cem"]].isin(cem)),
247
- "time",
248
- ] = np.round(declaration_list["time"] / ratio)
249
- else:
250
- declaration_list.loc[
251
- declaration_list.nsip_master == global_params.teaching_ratio["masterproject"],
252
- "time",
253
- ] = np.round(declaration_list["time"] / ratio)
254
-
255
- # Check that the number of hours doesn't exceed the maximum allowed for activities
256
- # declared in hours
257
- declaration_list["invalid_time"] = False
258
- declaration_list.loc[
259
- declaration_list["time_unit"] == TIME_UNIT_HOURS_EN, "invalid_time"
260
- ] = (declaration_list["time"] > global_params.declaration_options["max_hours"])
261
- declaration_list.drop(columns="_merge", inplace=True)
262
-
263
- nsip_declarations = get_nsip_declarations(start_date, team)
264
- if nsip_declarations.empty:
265
- # nsip_missing is True if the OSITAH declaration has no matching declaration in NSIP
266
- declaration_list["nsip_missing"] = True
267
- # time_unit_mismatch is True only if there is a matching declaration in NSIP and time
268
- # unit differs
269
- declaration_list["time_unit_mismatch"] = False
270
- # mgr_val_time_mismatch indicates that validation time is different in OSITAH and NSIP
271
- declaration_list["mgr_val_time_mismatch"] = False
272
- # nsip_inconsistency is True only if there is a matching declaration in NSIP and
273
- # time differs
274
- declaration_list["nsip_inconsistency"] = False
275
- # ositah_missing is True if a declaratipn is found in NSIP without a matching
276
- # declaration in OSITAH
277
- declaration_list["ositah_missing"] = False
278
- # Other columns expected by the code below
279
- declaration_list["id_declaration"] = np.NaN
280
- else:
281
- declaration_list["nsip_project_id"] = declaration_list["nsip_project_id"].astype(int)
282
- declaration_list["nsip_reference_id"] = declaration_list["nsip_reference_id"].astype(
283
- int
284
- )
285
- # In case nsip_declarations contains only references (no project), create the
286
- # project.name column required later as it is used both for references and projects.
287
- # Also copy reference.name into project.name if it is a reference.
288
- if "project.name" not in nsip_declarations:
289
- nsip_declarations["project.name"] = np.NaN
290
- nsip_declarations.loc[
291
- nsip_declarations["project.name"].isna(),
292
- "nsip_project",
293
- ] = nsip_declarations["reference.name"]
294
- # Merge OSITAH declarations with those possibly present in NSIP
295
- declaration_list = declaration_list.merge(
296
- nsip_declarations,
297
- how="outer",
298
- left_on=["email_reseda", "nsip_project_id", "nsip_reference_id"],
299
- right_on=["agent.email", "project.id", "reference.id"],
300
- suffixes=[None, "_nsipd"],
301
- indicator=True,
302
- )
303
- # Merge all declarations for an agent related to the same NSIP activity, if several
304
- # local activities are associated with one NSIP one. First build the list of distinct
305
- # NSIP activities and aggregate the related time, then merge back this time in the
306
- # declaration list.
307
- nsip_activity_identifier = [
308
- "agent.id",
309
- "project.id",
310
- "reference.id",
311
- "email_auth",
312
- "nsip_project_id",
313
- "nsip_reference_id",
314
- ]
315
- for c in nsip_activity_identifier:
316
- if declaration_list.dtypes[c] == "object":
317
- declaration_list.loc[declaration_list[c].isna(), c] = ""
318
- else:
319
- declaration_list.loc[declaration_list[c].isna(), c] = 0
320
- combined_declarations = (
321
- declaration_list[[*nsip_activity_identifier, "time"]]
322
- .groupby(by=nsip_activity_identifier, as_index=False, sort=False)
323
- .sum()
324
- )
325
- declaration_list = declaration_list.drop(columns="time").drop_duplicates(
326
- nsip_activity_identifier
327
- )
328
- declaration_list = declaration_list.merge(
329
- combined_declarations,
330
- how="inner",
331
- on=nsip_activity_identifier,
332
- suffixes=[None, "_cd"],
333
- )
334
- # time_unit_mismatch is True only if there is a matching declaration in NSIP and time
335
- # unit differs
336
- declaration_list["time_unit_mismatch"] = False
337
- declaration_list.loc[declaration_list["_merge"] == "both", "time_unit_mismatch"] = (
338
- declaration_list.loc[declaration_list["_merge"] == "both", "time_unit"]
339
- != declaration_list.loc[declaration_list["_merge"] == "both", "volume"]
340
- )
341
- # nsip_inconsistency is True only if there is a matching declaration in NSIP and
342
- # time differs
343
- declaration_list["nsip_inconsistency"] = False
344
- declaration_list.loc[declaration_list["_merge"] == "both", "nsip_inconsistency"] = (
345
- declaration_list.loc[declaration_list["_merge"] == "both", "time"]
346
- != declaration_list.loc[declaration_list["_merge"] == "both", "time_nsipd"]
347
- )
348
- # mgr_val_time_mismatch is True only if there is a matching declaration in NSIP and
349
- # manager validation time differs
350
- declaration_list["mgr_val_time_mismatch"] = False
351
- declaration_list.loc[
352
- declaration_list["_merge"] == "both", "mgr_val_time_mismatch"
353
- ] = declaration_list[declaration_list["_merge"] == "both"].apply(
354
- lambda r: (
355
- False
356
- if r["managerValidationDate"]
357
- and re.match(
358
- r["managerValidationDate"], r["validation_time"].date().isoformat()
359
- )
360
- else True
361
- ),
362
- axis=1,
363
- )
364
- # nsip_missing is True if the OSITAH declaration has no matching declaration in NSIP
365
- declaration_list["nsip_missing"] = declaration_list["_merge"] == "left_only"
366
- # ositah_missing is True if a declaration is found in NSIP without a matching
367
- # declaration in OSITAH
368
- declaration_list["ositah_missing"] = declaration_list["_merge"] == "right_only"
369
- for ositah_column, nsip_column in {
370
- "email_auth": "agent.email",
371
- "fullname": "nsip_fullname",
372
- "nsip_project_id": "project.id",
373
- "nsip_reference_id": "reference.id",
374
- "nsip_project": "project.name",
375
- "time": "time_nsipd",
376
- "time_unit": "volume",
377
- }.items():
378
- declaration_list.loc[declaration_list["ositah_missing"], ositah_column] = (
379
- declaration_list[nsip_column]
380
- )
381
- declaration_list.loc[
382
- declaration_list["nsip_agent_missing"].isna(), "nsip_agent_missing"
383
- ] = False
384
- declaration_list.drop(columns="_merge", inplace=True)
385
-
386
- # Mark declarations that are properly synced between OSITAH and NSIP for easier
387
- # processing later
388
- declaration_list["declaration_ok"] = ~declaration_list[
389
- [
390
- "nsip_missing",
391
- "nsip_inconsistency",
392
- "ositah_missing",
393
- "nsip_missing",
394
- "mgr_val_time_mismatch",
395
- "invalid_time",
396
- ]
397
- ].any(axis=1)
398
-
399
- # Define declarations that be selected for NSIP synchronisation as all declarations that
400
- # are not ok, except those corresponding to agents missing in NSIP or that have no
401
- # matching entries in OSITAH
402
- if export_disabled:
403
- declaration_list["selectable"] = False
404
- else:
405
- declaration_list["selectable"] = ~declaration_list["declaration_ok"]
406
- declaration_list.loc[
407
- declaration_list["selectable"] & declaration_list["ositah_missing"],
408
- "selectable",
409
- ] = False
410
- declaration_list.loc[
411
- declaration_list["selectable"] & declaration_list["invalid_time"],
412
- "selectable",
413
- ] = False
414
- declaration_list.loc[
415
- declaration_list["selectable"] & declaration_list["nsip_agent_missing"],
416
- "selectable",
417
- ] = False
418
- declaration_list.loc[
419
- declaration_list["selectable"]
420
- & (declaration_list["nsip_project_id"] == 0)
421
- & (declaration_list["nsip_reference_id"] == 0),
422
- "selectable",
423
- ] = False
424
-
425
- # Reset selected state to False
426
- declaration_list["selected"] = False
427
- # Rset nsip_project_id and nsip_reference_id to NaN if they are equal to 0 so that the
428
- # corresponding cell is empty
429
- declaration_list.loc[declaration_list["nsip_project_id"] == 0, "nsip_project_id"] = np.NaN
430
- declaration_list.loc[declaration_list["nsip_reference_id"] == 0, "nsip_reference_id"] = (
431
- np.NaN
432
- )
433
-
434
- # Sort declarations by email_auth and add index value as column for easier further
435
- # processing
436
- declaration_list.sort_values(by="email_auth", inplace=True)
437
- declaration_list["row_index"] = declaration_list.index
438
-
439
- session_data.nsip_declarations = declaration_list
440
-
441
- else:
442
- declaration_list = session_data.nsip_declarations
443
- ositah_total_declarations_num = session_data.total_declarations_num
444
-
445
- declarations_ok = declaration_list[declaration_list["declaration_ok"]]
446
- declarations_ok_num = len(declarations_ok)
447
- agents_ok_num = len(declarations_ok["email_auth"].unique())
448
- nsip_agent_missing_num = len(
449
- declaration_list.loc[declaration_list["nsip_agent_missing"], "email_auth"].unique()
450
- )
451
- nsip_optional_missing_num = len(
452
- declaration_list.loc[
453
- declaration_list["nsip_agent_missing"] & declaration_list["optional"],
454
- "email_auth",
455
- ].unique()
456
- )
457
- ositah_missing_num = len(declaration_list[declaration_list["ositah_missing"]])
458
- ositah_validated_declarations_num = len(declaration_list) - ositah_missing_num
459
- page_title = [
460
- html.Div(
461
- (
462
- f"Export NSIP des contributions validées de '{team}' du"
463
- f" {start_date.strftime('%Y-%m-%d')} au {end_date.strftime('%Y-%m-%d')}"
464
- )
465
- ),
466
- html.Div(
467
- (
468
- f"Déclarations totales={ositah_total_declarations_num} dont"
469
- f" synchronisées/validées={declarations_ok_num}/"
470
- f"{ositah_validated_declarations_num}, "
471
- f" manquantes OSITAH={ositah_missing_num}"
472
- )
473
- ),
474
- html.Div(
475
- (
476
- f"(agents synchronisés={agents_ok_num},"
477
- f" agents manquants dans NSIP={nsip_agent_missing_num} dont"
478
- f" optionnels={nsip_optional_missing_num})"
479
- )
480
- ),
481
- ]
482
- if team and team != TEAM_LIST_ALL_AGENTS:
483
- page_title.append(
484
- html.Div(
485
- (
486
- "Certains agents peuvent apparaitre non synchronisés s'ils ont quitté"
487
- f" le laboratoire: utiliser '{TEAM_LIST_ALL_AGENTS}' pour vérifier"
488
- ),
489
- style={"fontStyle": "italic", "fontWeight": "bold"},
490
- )
491
- )
492
-
493
- if declarations_set == NSIP_DECLARATIONS_SELECT_ALL:
494
- selected_declarations = declaration_list
495
- else:
496
- selected_declarations = declaration_list[~declaration_list["declaration_ok"]]
497
-
498
- data_columns = list(NSIP_COLUMN_NAMES.keys())
499
- data_columns.remove("email_auth")
500
-
501
- table_header = [
502
- html.Thead(
503
- html.Tr(
504
- [
505
- html.Th(
506
- [
507
- (
508
- dbc.Checkbox(id=NSIP_EXPORT_SELECT_ALL_ID)
509
- if selected_declarations["selectable"].any()
510
- else html.Div()
511
- ),
512
- dcc.Store(id=NSIP_EXPORT_ALL_SELECTED_ID, data=0),
513
- ]
514
- ),
515
- html.Th("email_reseda"),
516
- *[html.Th(c) for c in data_columns],
517
- ]
518
- )
519
- )
520
- ]
521
-
522
- table_body = []
523
- for email in selected_declarations["email_auth"].unique():
524
- tr_list = nsip_build_user_declarations(selected_declarations, email, data_columns)
525
- table_body.extend(tr_list)
526
- table_body = [html.Tbody(table_body)]
527
-
528
- return html.Div(
529
- [
530
- html.Div(
531
- [
532
- dbc.Row(
533
- [
534
- dbc.Col(dbc.Alert(page_title), width=10),
535
- dbc.Col(
536
- [
537
- dbc.Button(
538
- NSIP_EXPORT_BUTTON_LABEL,
539
- id=NSIP_EXPORT_BUTTON_ID,
540
- disabled=True,
541
- ),
542
- ],
543
- width={"size": 1, "offset": 1},
544
- ),
545
- ]
546
- ),
547
- ]
548
- ),
549
- add_nsip_declaration_selection_switch(declarations_set),
550
- html.Div(
551
- dbc.Col(
552
- dbc.Alert(dismissable=True, is_open=False, id=NSIP_EXPORT_STATUS_ID),
553
- width=9,
554
- )
555
- ),
556
- dcc.Store(id=NSIP_EXPORT_SELECTION_STATUS_ID, data=0),
557
- dcc.Store(id=EXPORT_NSIP_SYNC_INDICATOR_ID, data=0),
558
- dcc.Store(id=EXPORT_NSIP_SAVED_SYNC_INDICATOR_ID, data=0),
559
- html.P(""),
560
- dbc.Table(
561
- table_header + table_body,
562
- id={"type": TABLE_TYPE_TABLE, "id": TABLE_NSIP_EXPORT_ID},
563
- bordered=True,
564
- hover=True,
565
- ),
566
- ]
567
- )
568
-
569
-
570
- def nsip_build_user_declarations(declarations, agent_email, data_columns):
571
- """
572
- Build the list of html.Tr corresponding to the various projects af a given user.
573
-
574
- :param declarations: declarations dataframe
575
- :param agent_email: user RESEDA email
576
- :param data_columns: name of columns to add in each row
577
- :return: list of Tr
578
- """
579
-
580
- user_declarations = declarations[declarations.email_auth == agent_email]
581
- tr_list = [
582
- # rowSpan must be len+1 because the first row attached to the email is in a separate Tr
583
- # (rowSpan is in fact the number of Tr following this one attached to it)
584
- html.Tr(
585
- [
586
- (
587
- html.Td(
588
- [
589
- dbc.Checkbox(
590
- id={"type": "nsip-export-user", "id": agent_email},
591
- class_name="nsip-agent-selector",
592
- value=False,
593
- ),
594
- dcc.Store(
595
- id={"type": "nsip-export-user-selected", "id": agent_email},
596
- data=0,
597
- ),
598
- ],
599
- className="align-middle ",
600
- rowSpan=len(user_declarations) + 1,
601
- )
602
- if user_declarations["selectable"].any()
603
- else html.Td(rowSpan=len(user_declarations) + 1)
604
- ),
605
- nsip_build_poject_declaration_cell(user_declarations, "email_auth", None),
606
- dcc.Store(
607
- id={"type": "nsip-selected-user", "id": agent_email},
608
- data=agent_email,
609
- ),
610
- ]
611
- )
612
- ]
613
-
614
- tr_list.extend(
615
- [
616
- html.Tr([nsip_build_poject_declaration_cell(row, c, i) for c in data_columns])
617
- for i, row in declarations[declarations.email_auth == agent_email].iterrows()
618
- ]
619
- )
620
-
621
- return tr_list
622
-
623
-
624
- def nsip_build_poject_declaration_cell(declaration, column, row_index):
625
- """
626
- Build the column cell for one project declaration. Returns a html.Td.
627
-
628
- :param declaration: project declaration row or rows if column='email_auth'
629
- :param column: column name
630
- :param row_index: row index in the dataframe: must be a unique id for the row. Ignored if
631
- declaration is a dataframe.
632
- :return: html.Td hor html.Th if column='email_auth'
633
- """
634
-
635
- if column == "email_auth":
636
- div_id = f"export-row-{declaration.iloc[0]['row_index']}-{column}"
637
- cell_content = [html.Div(declaration.iloc[0]["email_auth"], id=div_id)]
638
- else:
639
- div_id = f"export-row-{row_index}-{column}"
640
- cell_content = [html.Div(declaration[column], id=div_id)]
641
- cell_opt_class, tooltip = project_declaration_class(declaration, column)
642
- if tooltip:
643
- cell_content.append(dbc.Tooltip(tooltip, target=div_id))
644
-
645
- if column == "email_auth":
646
- return html.Th(
647
- cell_content,
648
- className=f"align-middle {cell_opt_class}",
649
- rowSpan=len(declaration) + 1,
650
- )
651
- else:
652
- return html.Td(cell_content, className=f"align-middle {cell_opt_class}")
653
-
654
-
655
- def project_declaration_class(declaration, column):
656
- """
657
- Return the appropriate CSS class for each project declaration cell based on declaration
658
- attributes
659
-
660
- :param declaration: declaration row or rows if column='email_auth'
661
- :param column: column for which CSS must be configured (allow to distingish between time and
662
- time unit)
663
- :return: CSS class names to add, tooltip text
664
- """
665
-
666
- global_params = GlobalParams()
667
-
668
- if column == "time_unit":
669
- if declaration["time_unit_mismatch"]:
670
- return (
671
- "table-warning",
672
- f"Unité incorrecte, requiert : {declaration['volume']}",
673
- )
674
- elif column == "time":
675
- if declaration["declaration_ok"]:
676
- return "table-success", None
677
- elif declaration["ositah_missing"]:
678
- # Flag only the declaration time for missing declarations in OSITAH
679
- return (
680
- "table-danger",
681
- "Declaration trouvée dans NSIP mais absente ou non-validée dans OSITAH",
682
- )
683
- elif declaration["invalid_time"]:
684
- return (
685
- "table-danger",
686
- (
687
- f"Le temps déclaré pour ce type de projet ne peut pas dépasser"
688
- f" {global_params.declaration_options['max_hours']} heures"
689
- ),
690
- )
691
- elif declaration["nsip_inconsistency"]:
692
- # CSS class nsip_inconsistency is returned for time column only if there is a
693
- # time mismatch
694
- return (
695
- "table-warning",
696
- (
697
- f"Le temps déclaré dans OSITAH est différent de celui de NSIP"
698
- f" ({int(declaration['time_nsipd'])})"
699
- ),
700
- )
701
- elif column == "validation_time":
702
- if declaration["mgr_val_time_mismatch"]:
703
- return (
704
- "table-warning",
705
- (
706
- f"La date de validation est différente de celle de NSIP"
707
- f" ({declaration['managerValidationDate']})"
708
- ),
709
- )
710
- elif column in ["nsip_master", "nsip_project"]:
711
- if math.isnan(declaration["nsip_project_id"]) and math.isnan(
712
- declaration["nsip_reference_id"]
713
- ):
714
- return (
715
- "table-danger",
716
- "Pas de projet correspondant dans NSIP: vérifier le referentiel Hito",
717
- )
718
- elif column == "email_auth":
719
- fullname_txt = f"Nom: {declaration.loc[declaration.index[0], 'fullname']}"
720
- if declaration["declaration_ok"].all():
721
- return "table-success", fullname_txt
722
- elif declaration["nsip_agent_missing"].any():
723
- if declaration["optional"].all():
724
- return "table-info", [
725
- html.Div(fullname_txt),
726
- html.Div(
727
- (
728
- f"Agent dont la déclaration est optionnelle non présent dans NSIP"
729
- f" ({declaration.loc[declaration.index[0], 'statut']})"
730
- )
731
- ),
732
- ]
733
- else:
734
- return "table-warning", [
735
- html.Div(fullname_txt),
736
- html.Div("Agent non trouvé dans NSIP"),
737
- ]
738
- elif declaration["ositah_missing"].all():
739
- # Set the cell class to table-danger on the agent email only if all declarations for the
740
- # agent are missing
741
- return "table-danger", [
742
- html.Div(fullname_txt),
743
- html.Div("Toutes les déclarations sont manquantes ou non validées dans OSITAH"),
744
- ]
745
- else:
746
- return "", fullname_txt
747
-
748
- return "", None
749
-
750
-
751
- def add_nsip_declaration_selection_switch(current_set):
752
- """
753
- Add a dbc.RadioItems to select whether to show all declaration or only the not
754
- synchronized ones in NSIP export table.
755
-
756
- :param current_set: currently selected declaration set
757
- :return: dbc.RadioItems
758
- """
759
-
760
- return dbc.Row(
761
- [
762
- dbc.RadioItems(
763
- options=[
764
- {
765
- "label": "Toutes les déclarations",
766
- "value": NSIP_DECLARATIONS_SELECT_ALL,
767
- },
768
- {
769
- "label": "Déclarations non synchronisées uniquement",
770
- "value": NSIP_DECLARATIONS_SELECT_UNSYNCHRONIZED,
771
- },
772
- ],
773
- value=current_set,
774
- id=NSIP_DECLARATIONS_SWITCH_ID,
775
- inline=True,
776
- ),
777
- ],
778
- justify="center",
779
- )
780
-
781
-
782
- @app.callback(
783
- [
784
- Output(TAB_ID_EXPORT_NSIP, "children"),
785
- Output(EXPORT_SAVED_LOAD_INDICATOR_ID, "data"),
786
- Output(EXPORT_SAVED_ACTIVE_TAB_ID, "data"),
787
- ],
788
- [
789
- Input(EXPORT_LOAD_INDICATOR_ID, "data"),
790
- Input(EXPORT_TAB_MENU_ID, "active_tab"),
791
- Input(TEAM_SELECTED_VALUE_ID, "data"),
792
- Input(DATA_SELECTED_SOURCE_ID, "data"),
793
- Input(NSIP_DECLARATIONS_SELECTED_ID, "data"),
794
- ],
795
- [
796
- State(TEAM_SELECTION_DATE_ID, "data"),
797
- State(EXPORT_SAVED_LOAD_INDICATOR_ID, "data"),
798
- State(VALIDATION_PERIOD_SELECTED_ID, "data"),
799
- State(EXPORT_SAVED_ACTIVE_TAB_ID, "data"),
800
- ],
801
- prevent_initial_call=True,
802
- )
803
- def display_export_table(
804
- load_in_progress,
805
- active_tab,
806
- team,
807
- data_source,
808
- declaration_set,
809
- team_selection_date,
810
- previous_load_in_progress,
811
- period_date: str,
812
- previous_active_tab,
813
- ):
814
- """
815
- Display active tab contents after a team or an active tab change.
816
-
817
- :param load_in_progress: load in progress indicator
818
- :param tab: tab name
819
- :param team: selected team
820
- :param data_source: Hito (non-validated declarations) or OSITAH (validated declarations)
821
- :param declaration_set: declarations subset selected
822
- :param team_selection_date: last time the team selection was changed
823
- :param previous_load_in_progress: previous value of the load_in_progress indicator
824
- :param period_date: a date that must be inside the declaration period
825
- :param previous_active_tab: previously active tab
826
- :return: tab content
827
- """
828
-
829
- tab_contents = []
830
-
831
- # Be sure to fill the return values in the same order as Output are declared
832
- tab_list = [TAB_ID_EXPORT_NSIP]
833
- for tab in tab_list:
834
- if team and len(team) > 0 and tab == active_tab:
835
- if load_in_progress > previous_load_in_progress and active_tab == previous_active_tab:
836
- if active_tab == TAB_ID_EXPORT_NSIP:
837
- tab_contents.append(
838
- nsip_export_table(team, team_selection_date, period_date, declaration_set)
839
- )
840
- else:
841
- tab_contents.append(
842
- dbc.Alert("Erreur interne: tab non supporté"), color="warning"
843
- )
844
- previous_load_in_progress += 1
845
- else:
846
- component = html.Div(
847
- [
848
- create_progress_bar(team, duration=EXPORT_PROGRESS_BAR_MAX_DURATION),
849
- dcc.Interval(
850
- id=EXPORT_LOAD_TRIGGER_INTERVAL_ID,
851
- n_intervals=0,
852
- max_intervals=1,
853
- interval=500,
854
- ),
855
- ]
856
- )
857
- tab_contents.append(component)
858
- else:
859
- tab_contents.append("")
860
-
861
- tab_contents.extend([previous_load_in_progress, active_tab])
862
-
863
- return tab_contents
864
-
865
-
866
- @app.callback(
867
- Output(EXPORT_LOAD_INDICATOR_ID, "data"),
868
- Input(EXPORT_LOAD_TRIGGER_INTERVAL_ID, "n_intervals"),
869
- State(EXPORT_SAVED_LOAD_INDICATOR_ID, "data"),
870
- prevent_initial_call=True,
871
- )
872
- def export_tables_trigger(n, previous_load_indicator):
873
- """
874
- Increment (change) input of display_export_table callback to get it fired a
875
- second time after displaying the progress bar. The output component must be updated each
876
- time the callback is entered to trigger the execution of the other callback, thus the
877
- choice of incrementing it at each call.
878
-
879
- :param n: n_interval property of the dcc.Interval (0 or 1)
880
- :return: 1 increment to previous value
881
- """
882
-
883
- return previous_load_indicator + 1
884
-
885
-
886
- @app.callback(
887
- Output({"type": "nsip-export-user-selected", "id": MATCH}, "data"),
888
- Input({"type": "nsip-export-user", "id": MATCH}, "value"),
889
- State({"type": "nsip-selected-user", "id": MATCH}, "data"),
890
- prevent_initial_call=True,
891
- )
892
- def nsip_export_select_user(state, agent_email):
893
- """
894
- Mark the user as selected for NSIP export.
895
-
896
- :param state: checkbox state
897
- :param agent_email: RESEDA email of the selected user
898
- :return:
899
- """
900
-
901
- global_params = GlobalParams()
902
- try:
903
- session_data = global_params.session_data
904
- except SessionDataMissing:
905
- return no_session_id_jumbotron()
906
- declarations = session_data.nsip_declarations
907
-
908
- if state:
909
- declarations.loc[declarations.email_auth == agent_email, "selected"] = True
910
- else:
911
- declarations.loc[declarations.email_auth == agent_email, "selected"] = False
912
-
913
- return state
914
-
915
-
916
- @app.callback(
917
- Output(NSIP_EXPORT_ALL_SELECTED_ID, "data"),
918
- Input(NSIP_EXPORT_SELECT_ALL_ID, "value"),
919
- prevent_initial_call=True,
920
- )
921
- def nsip_export_select_all_agents(checked):
922
- """
923
- Mark all selectable agents as selected if checked=1 or unselect all otherwise
924
-
925
- :param checked: checkbox value
926
- :return: checkbox value
927
- """
928
-
929
- global_params = GlobalParams()
930
- try:
931
- session_data = global_params.session_data
932
- except SessionDataMissing:
933
- return no_session_id_jumbotron()
934
- declarations = session_data.nsip_declarations
935
-
936
- declarations.loc[declarations.selectable, "selected"] = checked
937
-
938
- return checked
939
-
940
-
941
- # A client-side callback is used to update the selection indicator (checkbox) of all rows after
942
- # clicking the "Select all" button
943
- app.clientside_callback(
944
- """
945
- function define_checkbox_status(checked) {
946
- const checkbox_forms = document.querySelectorAll(".nsip-agent-selector");
947
- checkbox_forms.forEach(function(cb_form) {
948
- const agent = cb_form.querySelector("input");
949
- /*console.log("Updating checkbox for "+agent.id);*/
950
- if ( checked ) {
951
- agent.checked = true;
952
- } else {
953
- agent.checked = false;
954
- }
955
- });
956
- return checked;
957
- }
958
- """,
959
- Output(NSIP_EXPORT_SELECTION_STATUS_ID, "data"),
960
- Input(NSIP_EXPORT_ALL_SELECTED_ID, "data"),
961
- prevent_initial_call=True,
962
- )
963
-
964
-
965
- @app.callback(
966
- Output(NSIP_EXPORT_BUTTON_ID, "children"),
967
- Output(NSIP_EXPORT_BUTTON_ID, "title"),
968
- Output(NSIP_EXPORT_BUTTON_ID, "disabled"),
969
- Input({"type": "nsip-export-user-selected", "id": ALL}, "data"),
970
- Input(NSIP_EXPORT_ALL_SELECTED_ID, "data"),
971
- prevent_initial_call=True,
972
- )
973
- def nsip_export_selected_count(*_):
974
- """
975
- Callback updating the export button label with the number of selected agent, each time a
976
- selection is changed. The button is also disabled if no agent is selected. Cannot be merged
977
- with nsip_export_select_user callback as MATCH and non MATCH output cannot be mixed.
978
-
979
- :param *_: input values are ignored
980
- :return: label of the export button
981
- """
982
-
983
- global_params = GlobalParams()
984
- try:
985
- session_data = global_params.session_data
986
- except SessionDataMissing:
987
- return no_session_id_jumbotron()
988
- declarations = session_data.nsip_declarations
989
-
990
- selected_users = declarations[declarations.selected].email_auth.unique()
991
- if len(selected_users):
992
- selected_count = f" ({len(selected_users)})"
993
- else:
994
- selected_count = ""
995
-
996
- return (
997
- f"{NSIP_EXPORT_BUTTON_LABEL}{selected_count}",
998
- (
999
- f"{len(selected_users)} agent{'s' if len(selected_users) > 1 else ''}"
1000
- f" sélectionné{'s' if len(selected_users) > 1 else ''}"
1001
- ),
1002
- False if len(selected_users) else True,
1003
- )
1004
-
1005
-
1006
- @app.callback(
1007
- Output(NSIP_EXPORT_STATUS_ID, "children"),
1008
- Output(NSIP_EXPORT_STATUS_ID, "is_open"),
1009
- Output(NSIP_EXPORT_STATUS_ID, "color"),
1010
- Output(EXPORT_NSIP_SAVED_SYNC_INDICATOR_ID, "data"),
1011
- Input(NSIP_EXPORT_BUTTON_ID, "n_clicks"),
1012
- Input(EXPORT_NSIP_SYNC_INDICATOR_ID, "data"),
1013
- State(EXPORT_NSIP_SAVED_SYNC_INDICATOR_ID, "data"),
1014
- prevent_initial_call=True,
1015
- )
1016
- def nsip_export_button(n_clicks, sync_indicator, previous_sync_indicator):
1017
- """
1018
- Push into NSIP declarations by all the selected users. All project declarations for the user
1019
- are added/updated. This callback is entered twice: the first time it displays a progess
1020
- bar and start a dcc.Interval, the second time it does the real synchronisation work.
1021
-
1022
- :param n_clicks: checkbox state
1023
- :param sync_indicator: current value of sync indicator
1024
- :param previous_sync_indicator: previous value of sync indicator
1025
- :return: agent/project updates failed
1026
- """
1027
-
1028
- global_params = GlobalParams()
1029
- try:
1030
- session_data = global_params.session_data
1031
- except SessionDataMissing:
1032
- return no_session_id_jumbotron()
1033
- declarations = session_data.nsip_declarations
1034
- selected_declarations = declarations[declarations.selectable & declarations.selected]
1035
- failed_updates = []
1036
-
1037
- updated_declarations = 0
1038
- if n_clicks and n_clicks >= 1:
1039
- if sync_indicator > previous_sync_indicator:
1040
- for row in selected_declarations.itertuples(index=False):
1041
- # One of the possible error during declaration update is that the user has
1042
- # multiple contracts attached to the lab for the current period. In this case
1043
- # the update is retried with the second contract (generally the current one)
1044
- # mentioned in the error message.
1045
- retry_on_error = True
1046
- retry_attempts = 0
1047
- contract = None
1048
- while retry_on_error:
1049
- # nsip_project_id and nsip_reference_id are NaN if undefined and a NaN value is
1050
- # not equal to itself!
1051
- project_type = row.nsip_project_id == row.nsip_project_id
1052
- activity_id = row.nsip_project_id if project_type else row.nsip_reference_id
1053
- (
1054
- status,
1055
- http_status,
1056
- http_reason,
1057
- ) = global_params.nsip.update_declaration(
1058
- row.email_reseda,
1059
- activity_id,
1060
- project_type,
1061
- row.time,
1062
- row.validation_time,
1063
- contract,
1064
- )
1065
-
1066
- if status > 0 and http_status:
1067
- m = EXPORT_NSIP_MULTIPLE_CONTRACTS_ERROR.match(http_reason)
1068
- # Never retry more than once
1069
- if m and (retry_attempts == 0):
1070
- contract = m.group("id2")
1071
- retry_attempts += 1
1072
- print(
1073
- (
1074
- f"Agent {row.email_reseda} has several contracts for the"
1075
- f" current period: retrying update with contract {contract}"
1076
- )
1077
- )
1078
- else:
1079
- retry_on_error = False
1080
- else:
1081
- retry_on_error = False
1082
-
1083
- if status <= 0:
1084
- # Log a message if the declaration was successfully added to NSIP to make
1085
- # diagnostics easier. After a successful addition or update, http_status
1086
- # contains the declaration ID instead of the http status
1087
- if status == 0:
1088
- action = "added to"
1089
- else:
1090
- action = "updated in"
1091
- print(
1092
- (
1093
- f"Declaration {http_status} for user {row.email_auth} (NSIP ID:"
1094
- f" {row.email_reseda}), {'project' if project_type else 'reference'}"
1095
- f" ID {int(activity_id)} {action} NSIP"
1096
- ),
1097
- flush=True,
1098
- )
1099
-
1100
- else:
1101
- if http_status:
1102
- http_error = f" (http status={http_status}, reason={http_reason})"
1103
- else:
1104
- http_error = ""
1105
- print(
1106
- (
1107
- f"ERROR: update of declaration failed for user {row.email_auth}"
1108
- f" (NSIP ID: {row.email_reseda}),"
1109
- f" {'project' if project_type else 'reference'} ID"
1110
- f" {int(activity_id)}{http_error}"
1111
- ),
1112
- flush=True,
1113
- )
1114
- failed_updates.append(
1115
- f"{row.email_auth}/{'projet' if project_type else 'reference'}"
1116
- f"/{int(activity_id)}"
1117
- )
1118
-
1119
- updated_declarations += 1
1120
- previous_sync_indicator += 1
1121
- else:
1122
- component = html.Div(
1123
- [
1124
- create_progress_bar(
1125
- duration=(len(selected_declarations) / EXPORT_NSIP_SYNC_FREQUENCY)
1126
- ),
1127
- dcc.Interval(
1128
- id=EXPORT_NSIP_SYNC_TRIGGER_INTERVAL_ID,
1129
- n_intervals=0,
1130
- max_intervals=1,
1131
- interval=500,
1132
- ),
1133
- ]
1134
- )
1135
- return component, True, "success", previous_sync_indicator
1136
-
1137
- else:
1138
- raise PreventUpdate
1139
-
1140
- if len(failed_updates) == 0:
1141
- update_status_msg = (
1142
- f"Toutes les déclarations sélectionnées ({updated_declarations})"
1143
- f" ont été enregistrées dans NSIP"
1144
- )
1145
- color = "success"
1146
- else:
1147
- update_status_msg = (
1148
- f"{'Un export a' if len(failed_updates) == 1 else 'Plusieurs exports ont'} échoués :"
1149
- f" {', '.join(failed_updates)}"
1150
- )
1151
- color = "warning"
1152
- return update_status_msg, True, color, previous_sync_indicator
1153
-
1154
-
1155
- @app.callback(
1156
- Output(EXPORT_NSIP_SYNC_INDICATOR_ID, "data"),
1157
- Input(EXPORT_NSIP_SYNC_TRIGGER_INTERVAL_ID, "n_intervals"),
1158
- State(EXPORT_NSIP_SAVED_SYNC_INDICATOR_ID, "data"),
1159
- prevent_initial_call=True,
1160
- )
1161
- def nsip_export_button_trigger(n, previous_load_indicator):
1162
- """
1163
- Increment (change) input of nsip_export_button callback to get it fired a
1164
- second time after displaying the progress bar. The output component must be updated each
1165
- time the callback is entered to trigger the execution of the other callback, thus the
1166
- choice of incrementing it at each call.
1167
-
1168
- :param n: n_interval property of the dcc.Interval (0 or 1)
1169
- :return: 1 increment to previous value
1170
- """
1171
-
1172
- return previous_load_indicator + 1
1173
-
1174
-
1175
- @app.callback(
1176
- Output(NSIP_DECLARATIONS_SELECTED_ID, "data"),
1177
- Input(NSIP_DECLARATIONS_SWITCH_ID, "value"),
1178
- prevent_initial_call=True,
1179
- )
1180
- def select_declarations_set(new_set):
1181
- """
1182
- This callback is used to forward to the NSIP export callback the selected declarations set
1183
- through a dcc.Store that exists permanently in the page.
1184
-
1185
- :param new_set: selected declarations set
1186
- :return: same value
1187
- """
1188
-
1189
- return new_set
1
+ # OSITAH sub-application exporting data to NSIP
2
+ import math
3
+ import re
4
+
5
+ import dash_bootstrap_components as dbc
6
+ import numpy as np
7
+ from dash import dcc, html
8
+ from dash.dependencies import ALL, MATCH, Input, Output, State
9
+ from dash.exceptions import PreventUpdate
10
+
11
+ from ositah.app import app
12
+ from ositah.utils.agents import get_agents, get_nsip_agents
13
+ from ositah.utils.exceptions import SessionDataMissing
14
+ from ositah.utils.menus import (
15
+ DATA_SELECTED_SOURCE_ID,
16
+ TABLE_TYPE_TABLE,
17
+ TEAM_SELECTED_VALUE_ID,
18
+ TEAM_SELECTION_DATE_ID,
19
+ VALIDATION_PERIOD_SELECTED_ID,
20
+ create_progress_bar,
21
+ team_list_dropdown,
22
+ )
23
+ from ositah.utils.period import get_validation_period_dates
24
+ from ositah.utils.projects import (
25
+ DATA_SOURCE_OSITAH,
26
+ category_time_and_unit,
27
+ get_hito_projects,
28
+ get_nsip_declarations,
29
+ get_team_projects,
30
+ )
31
+ from ositah.utils.utils import (
32
+ HITO_ROLE_PROJECT_MGR,
33
+ HITO_ROLE_SUPER_ADMIN,
34
+ NSIP_COLUMN_NAMES,
35
+ TEAM_LIST_ALL_AGENTS,
36
+ TIME_UNIT_HOURS_EN,
37
+ GlobalParams,
38
+ no_session_id_jumbotron,
39
+ )
40
+
41
+ EXPORT_TAB_MENU_ID = "report-tabs"
42
+ TAB_ID_EXPORT_NSIP = "nsip-export-page"
43
+ TAB_MENU_EXPORT_NSIP = "Export NSIP"
44
+
45
+ TABLE_NSIP_EXPORT_ID = "export-nsip"
46
+
47
+ NSIP_EXPORT_BUTTON_LABEL = "Export"
48
+ NSIP_EXPORT_BUTTON_ID = "export-nsip-selected-users"
49
+ NSIP_EXPORT_SELECT_ALL_ID = "export-nsip-select-all"
50
+ NSIP_EXPORT_ALL_SELECTED_ID = "export-nsip-all-selected"
51
+ NSIP_EXPORT_STATUS_ID = "export-nsip-status-msg"
52
+ NSIP_EXPORT_SELECTION_STATUS_ID = "export-nsip-selection-update-status"
53
+
54
+ EXPORT_LOAD_INDICATOR_ID = "export-nsip-load-indicator"
55
+ EXPORT_SAVED_LOAD_INDICATOR_ID = "export-nsip-saved-load-indicator"
56
+ EXPORT_LOAD_TRIGGER_INTERVAL_ID = "export-nsip-load-callback-interval"
57
+ EXPORT_PROGRESS_BAR_MAX_DURATION = 8 # seconds
58
+ EXPORT_SAVED_ACTIVE_TAB_ID = "export-nsip-saved-active-tab"
59
+
60
+ EXPORT_NSIP_SYNC_INDICATOR_ID = "export-nsip-sync-indicator"
61
+ EXPORT_NSIP_SAVED_SYNC_INDICATOR_ID = "export-nsip-saved-sync-indicator"
62
+ EXPORT_NSIP_SYNC_FREQUENCY = 2.0 # Average number of sync operations per second
63
+ EXPORT_NSIP_SYNC_TRIGGER_INTERVAL_ID = "export-nsip-sync-callback-interval"
64
+ EXPORT_NSIP_MULTIPLE_CONTRACTS_ERROR = re.compile(
65
+ (
66
+ r'"Agent has active multi-contracts in same laboratory - manual action needed\s+'
67
+ r"\|\s+idAgentContract\s+:\s+(?P<id1>\d+)\s+\|\s+idAgentContract\s+:\s+(?P<id2>\d+)"
68
+ )
69
+ )
70
+
71
+ NSIP_DECLARATIONS_SELECT_ALL = 0
72
+ NSIP_DECLARATIONS_SELECT_UNSYNCHRONIZED = 1
73
+ NSIP_DECLARATIONS_SWITCH_ID = "export-nsip-declaration-set-switch"
74
+ NSIP_DECLARATIONS_SELECTED_ID = "export-nsip-selected-declaration-set"
75
+
76
+
77
+ def export_submenus():
78
+ """
79
+ Build the tabs menus of the export subapplication
80
+
81
+ :return: DBC Tabs
82
+ """
83
+
84
+ return dbc.Tabs(
85
+ [
86
+ dbc.Tab(
87
+ id=TAB_ID_EXPORT_NSIP,
88
+ tab_id=TAB_ID_EXPORT_NSIP,
89
+ label=TAB_MENU_EXPORT_NSIP,
90
+ ),
91
+ dcc.Store(id=NSIP_DECLARATIONS_SELECTED_ID, data=NSIP_DECLARATIONS_SELECT_ALL),
92
+ ],
93
+ id=EXPORT_TAB_MENU_ID,
94
+ )
95
+
96
+
97
+ def export_layout():
98
+ """
99
+ Build the layout for this application, after reading the data if necessary.
100
+
101
+ :return: application layout
102
+ """
103
+
104
+ return html.Div(
105
+ [
106
+ html.H1("Export des déclarations vers NSIP"),
107
+ team_list_dropdown(),
108
+ # The following dcc.Store is used to ensure that the the ijclab_export input exists
109
+ # before the export page is created
110
+ dcc.Store(id=DATA_SELECTED_SOURCE_ID, data=DATA_SOURCE_OSITAH),
111
+ html.Div(export_submenus(), id="export-submenus", style={"marginTop": "3em"}),
112
+ dcc.Store(id=EXPORT_LOAD_INDICATOR_ID, data=0),
113
+ dcc.Store(id=EXPORT_SAVED_LOAD_INDICATOR_ID, data=0),
114
+ dcc.Store(id=EXPORT_SAVED_ACTIVE_TAB_ID, data=""),
115
+ ]
116
+ )
117
+
118
+
119
+ def nsip_export_table(team, team_selection_date, period_date: str, declarations_set):
120
+ """
121
+ Build a table ready to be exported to NSIP from validated declarations. The produced table
122
+ can then be exported as a CSV for ingestion by NSIP.
123
+
124
+ :param team: selected team
125
+ :param team_selection_date: last time the team selection was changed
126
+ :param period_date: a date that must be inside the declaration period
127
+ :param declarations_set: declaration set to use (all or only non-synchronized)
128
+ :return: dbc.Table
129
+ """
130
+
131
+ if team is None:
132
+ return html.Div("")
133
+
134
+ global_params = GlobalParams()
135
+ columns = global_params.columns
136
+ try:
137
+ session_data = global_params.session_data
138
+ except SessionDataMissing:
139
+ return no_session_id_jumbotron()
140
+
141
+ start_date, end_date = get_validation_period_dates(period_date)
142
+
143
+ if session_data.role in [HITO_ROLE_PROJECT_MGR, HITO_ROLE_SUPER_ADMIN]:
144
+ export_disabled = False
145
+ else:
146
+ export_disabled = True
147
+
148
+ if session_data.nsip_declarations is None:
149
+ hito_projects = get_hito_projects()
150
+ declaration_list = get_team_projects(
151
+ team, team_selection_date, period_date, DATA_SOURCE_OSITAH
152
+ )
153
+ if declaration_list is None or declaration_list.empty:
154
+ return dbc.Alert(
155
+ f"Aucune données validées n'existe pour l'équipe '{team}'",
156
+ color="warning",
157
+ )
158
+ ositah_total_declarations_num = len(declaration_list)
159
+ declaration_list = declaration_list.merge(
160
+ hito_projects,
161
+ left_on="hito_project_id",
162
+ right_on="id",
163
+ suffixes=[None, "_y"],
164
+ )
165
+ agent_list = get_agents(period_date, team)
166
+ if team is None or team == TEAM_LIST_ALL_AGENTS:
167
+ # If no team is selected, merge left to include declarations from agents whose
168
+ # email_auth don't match between Hito and NSIP. It is typically the case for agents
169
+ # who are no longer active in NSIP (e.g. agents who left the lab during the
170
+ # declaration period).
171
+ merge_how = "left"
172
+ else:
173
+ merge_how = "inner"
174
+ declaration_list = declaration_list.merge(
175
+ agent_list,
176
+ how=merge_how,
177
+ left_on=columns["agent_id"],
178
+ right_on="id",
179
+ suffixes=[None, "_y"],
180
+ )
181
+ declaration_list[["time", "time_unit"]] = declaration_list.apply(
182
+ lambda r: category_time_and_unit(r["category"], r[columns["hours"]], english=True),
183
+ axis=1,
184
+ result_type="expand",
185
+ )
186
+ declaration_list["time"] = declaration_list["time"].astype(int, copy=False)
187
+ declaration_list["email_auth"] = declaration_list["email_auth"].str.lower()
188
+ declaration_list.sort_values(by="email_auth", inplace=True)
189
+
190
+ colums_to_delete = []
191
+ for columnn in declaration_list.columns.to_list():
192
+ if columnn not in [
193
+ *NSIP_COLUMN_NAMES.keys(),
194
+ columns["statut"],
195
+ columns["cem"],
196
+ "fullname",
197
+ ]:
198
+ colums_to_delete.append(columnn)
199
+ if len(colums_to_delete) > 0:
200
+ declaration_list.drop(columns=colums_to_delete, inplace=True)
201
+
202
+ nsip_agents = get_nsip_agents()
203
+ declaration_list = declaration_list.merge(
204
+ nsip_agents,
205
+ how="left",
206
+ left_on="email_auth",
207
+ right_on="email_reseda",
208
+ suffixes=[None, "_nsipa"],
209
+ indicator=True,
210
+ )
211
+
212
+ # For OSITAH entries not found in NSIP, it may be that the agent left the lab during
213
+ # the declaration period: in this case there is an entry in NSIP where the RESEDA email
214
+ # has been replaced by a UUID allowing to update NSIP data. Check if such an entry
215
+ # exists in NSIP with a matching fullname and use it if it exists. It also happens if the
216
+ # agent email_auth changed during the period.
217
+ # To make matching easier, index is temporarily set to fullname instead of an integer.
218
+ nsip_missing_agent_names = declaration_list.loc[
219
+ declaration_list["_merge"] == "left_only", columns["fullname"]
220
+ ].unique()
221
+ declaration_list.set_index(columns["fullname"], inplace=True)
222
+ nsip_agents.set_index(columns["fullname"], inplace=True)
223
+ for name in nsip_missing_agent_names:
224
+ if name in nsip_agents.index:
225
+ matching_inactive_nsip_agent = nsip_agents.loc[[name]]
226
+ if not matching_inactive_nsip_agent.empty:
227
+ # Should always work, raise an exception if it is not the case
228
+ declaration_list.update(matching_inactive_nsip_agent, errors="raise")
229
+ # Mark the entry as complete
230
+ declaration_list.loc[name, "_merge"] = "both"
231
+ declaration_list.reset_index(inplace=True)
232
+
233
+ declaration_list["nsip_agent_missing"] = declaration_list["_merge"] == "left_only"
234
+ declaration_list["optional"] = declaration_list[columns["statut"]].isin(
235
+ global_params.declaration_options["optional_statutes"]
236
+ )
237
+
238
+ # For EC (enseignant chercheurs), apply the ratio defined in the configuration (if any)
239
+ # to teaching hours declared to convert them into hours with students
240
+ if global_params.teaching_ratio:
241
+ ratio = global_params.teaching_ratio["ratio"]
242
+ cem = global_params.teaching_ratio["cem"]
243
+ if cem:
244
+ declaration_list.loc[
245
+ (declaration_list.nsip_master == global_params.teaching_ratio["masterproject"])
246
+ & (declaration_list[columns["cem"]].isin(cem)),
247
+ "time",
248
+ ] = np.round(declaration_list["time"] / ratio)
249
+ else:
250
+ declaration_list.loc[
251
+ declaration_list.nsip_master == global_params.teaching_ratio["masterproject"],
252
+ "time",
253
+ ] = np.round(declaration_list["time"] / ratio)
254
+
255
+ # Check that the number of hours doesn't exceed the maximum allowed for activities
256
+ # declared in hours
257
+ declaration_list["invalid_time"] = False
258
+ declaration_list.loc[
259
+ declaration_list["time_unit"] == TIME_UNIT_HOURS_EN, "invalid_time"
260
+ ] = (declaration_list["time"] > global_params.declaration_options["max_hours"])
261
+ declaration_list.drop(columns="_merge", inplace=True)
262
+
263
+ nsip_declarations = get_nsip_declarations(start_date, team)
264
+ if nsip_declarations.empty:
265
+ # nsip_missing is True if the OSITAH declaration has no matching declaration in NSIP
266
+ declaration_list["nsip_missing"] = True
267
+ # time_unit_mismatch is True only if there is a matching declaration in NSIP and time
268
+ # unit differs
269
+ declaration_list["time_unit_mismatch"] = False
270
+ # mgr_val_time_mismatch indicates that validation time is different in OSITAH and NSIP
271
+ declaration_list["mgr_val_time_mismatch"] = False
272
+ # nsip_inconsistency is True only if there is a matching declaration in NSIP and
273
+ # time differs
274
+ declaration_list["nsip_inconsistency"] = False
275
+ # ositah_missing is True if a declaratipn is found in NSIP without a matching
276
+ # declaration in OSITAH
277
+ declaration_list["ositah_missing"] = False
278
+ # Other columns expected by the code below
279
+ declaration_list["id_declaration"] = np.NaN
280
+ else:
281
+ declaration_list["nsip_project_id"] = declaration_list["nsip_project_id"].astype(int)
282
+ declaration_list["nsip_reference_id"] = declaration_list["nsip_reference_id"].astype(
283
+ int
284
+ )
285
+ # In case nsip_declarations contains only references (no project), create the
286
+ # project.name column required later as it is used both for references and projects.
287
+ # Also copy reference.name into project.name if it is a reference.
288
+ if "project.name" not in nsip_declarations:
289
+ nsip_declarations["project.name"] = np.NaN
290
+ if "reference.name" not in nsip_declarations:
291
+ nsip_declarations["reference.name"] = np.NaN
292
+ nsip_declarations.loc[
293
+ nsip_declarations["project.name"].isna(),
294
+ "nsip_project",
295
+ ] = nsip_declarations["reference.name"]
296
+ # Merge OSITAH declarations with those possibly present in NSIP
297
+ declaration_list = declaration_list.merge(
298
+ nsip_declarations,
299
+ how="outer",
300
+ left_on=["email_reseda", "nsip_project_id", "nsip_reference_id"],
301
+ right_on=["agent.email", "project.id", "reference.id"],
302
+ suffixes=[None, "_nsipd"],
303
+ indicator=True,
304
+ )
305
+ # Merge all declarations for an agent related to the same NSIP activity, if several
306
+ # local activities are associated with one NSIP one. First build the list of distinct
307
+ # NSIP activities and aggregate the related time, then merge back this time in the
308
+ # declaration list.
309
+ nsip_activity_identifier = [
310
+ "agent.id",
311
+ "project.id",
312
+ "reference.id",
313
+ "email_auth",
314
+ "nsip_project_id",
315
+ "nsip_reference_id",
316
+ ]
317
+ for c in nsip_activity_identifier:
318
+ if declaration_list.dtypes[c] == "object":
319
+ declaration_list.loc[declaration_list[c].isna(), c] = ""
320
+ else:
321
+ declaration_list.loc[declaration_list[c].isna(), c] = 0
322
+ combined_declarations = (
323
+ declaration_list[[*nsip_activity_identifier, "time"]]
324
+ .groupby(by=nsip_activity_identifier, as_index=False, sort=False)
325
+ .sum()
326
+ )
327
+ declaration_list = declaration_list.drop(columns="time").drop_duplicates(
328
+ nsip_activity_identifier
329
+ )
330
+ declaration_list = declaration_list.merge(
331
+ combined_declarations,
332
+ how="inner",
333
+ on=nsip_activity_identifier,
334
+ suffixes=[None, "_cd"],
335
+ )
336
+ # time_unit_mismatch is True only if there is a matching declaration in NSIP and time
337
+ # unit differs
338
+ declaration_list["time_unit_mismatch"] = False
339
+ declaration_list.loc[declaration_list["_merge"] == "both", "time_unit_mismatch"] = (
340
+ declaration_list.loc[declaration_list["_merge"] == "both", "time_unit"]
341
+ != declaration_list.loc[declaration_list["_merge"] == "both", "volume"]
342
+ )
343
+ # nsip_inconsistency is True only if there is a matching declaration in NSIP and
344
+ # time differs
345
+ declaration_list["nsip_inconsistency"] = False
346
+ declaration_list.loc[declaration_list["_merge"] == "both", "nsip_inconsistency"] = (
347
+ declaration_list.loc[declaration_list["_merge"] == "both", "time"]
348
+ != declaration_list.loc[declaration_list["_merge"] == "both", "time_nsipd"]
349
+ )
350
+ # mgr_val_time_mismatch is True only if there is a matching declaration in NSIP and
351
+ # manager validation time differs
352
+ declaration_list["mgr_val_time_mismatch"] = False
353
+ declaration_list.loc[
354
+ declaration_list["_merge"] == "both", "mgr_val_time_mismatch"
355
+ ] = declaration_list[declaration_list["_merge"] == "both"].apply(
356
+ lambda r: (
357
+ False
358
+ if r["managerValidationDate"]
359
+ and re.match(
360
+ r["managerValidationDate"], r["validation_time"].date().isoformat()
361
+ )
362
+ else True
363
+ ),
364
+ axis=1,
365
+ )
366
+ # nsip_missing is True if the OSITAH declaration has no matching declaration in NSIP
367
+ declaration_list["nsip_missing"] = declaration_list["_merge"] == "left_only"
368
+ # ositah_missing is True if a declaration is found in NSIP without a matching
369
+ # declaration in OSITAH
370
+ declaration_list["ositah_missing"] = declaration_list["_merge"] == "right_only"
371
+ for ositah_column, nsip_column in {
372
+ "email_auth": "agent.email",
373
+ "fullname": "nsip_fullname",
374
+ "nsip_project_id": "project.id",
375
+ "nsip_reference_id": "reference.id",
376
+ "nsip_project": "project.name",
377
+ "time": "time_nsipd",
378
+ "time_unit": "volume",
379
+ }.items():
380
+ declaration_list.loc[declaration_list["ositah_missing"], ositah_column] = (
381
+ declaration_list[nsip_column]
382
+ )
383
+ declaration_list.loc[
384
+ declaration_list["nsip_agent_missing"].isna(), "nsip_agent_missing"
385
+ ] = False
386
+ declaration_list.drop(columns="_merge", inplace=True)
387
+
388
+ # Mark declarations that are properly synced between OSITAH and NSIP for easier
389
+ # processing later
390
+ declaration_list["declaration_ok"] = ~declaration_list[
391
+ [
392
+ "nsip_missing",
393
+ "nsip_inconsistency",
394
+ "ositah_missing",
395
+ "nsip_missing",
396
+ "mgr_val_time_mismatch",
397
+ "invalid_time",
398
+ ]
399
+ ].any(axis=1)
400
+
401
+ # Define declarations that be selected for NSIP synchronisation as all declarations that
402
+ # are not ok, except those corresponding to agents missing in NSIP or that have no
403
+ # matching entries in OSITAH
404
+ if export_disabled:
405
+ declaration_list["selectable"] = False
406
+ else:
407
+ declaration_list["selectable"] = ~declaration_list["declaration_ok"]
408
+ declaration_list.loc[
409
+ declaration_list["selectable"] & declaration_list["ositah_missing"],
410
+ "selectable",
411
+ ] = False
412
+ declaration_list.loc[
413
+ declaration_list["selectable"] & declaration_list["invalid_time"],
414
+ "selectable",
415
+ ] = False
416
+ declaration_list.loc[
417
+ declaration_list["selectable"] & declaration_list["nsip_agent_missing"],
418
+ "selectable",
419
+ ] = False
420
+ declaration_list.loc[
421
+ declaration_list["selectable"]
422
+ & (declaration_list["nsip_project_id"] == 0)
423
+ & (declaration_list["nsip_reference_id"] == 0),
424
+ "selectable",
425
+ ] = False
426
+
427
+ # Reset selected state to False
428
+ declaration_list["selected"] = False
429
+ # Rset nsip_project_id and nsip_reference_id to NaN if they are equal to 0 so that the
430
+ # corresponding cell is empty
431
+ declaration_list.loc[declaration_list["nsip_project_id"] == 0, "nsip_project_id"] = np.NaN
432
+ declaration_list.loc[declaration_list["nsip_reference_id"] == 0, "nsip_reference_id"] = (
433
+ np.NaN
434
+ )
435
+
436
+ # Sort declarations by email_auth and add index value as column for easier further
437
+ # processing
438
+ declaration_list.sort_values(by="email_auth", inplace=True)
439
+ declaration_list["row_index"] = declaration_list.index
440
+
441
+ session_data.nsip_declarations = declaration_list
442
+
443
+ else:
444
+ declaration_list = session_data.nsip_declarations
445
+ ositah_total_declarations_num = session_data.total_declarations_num
446
+
447
+ declarations_ok = declaration_list[declaration_list["declaration_ok"]]
448
+ declarations_ok_num = len(declarations_ok)
449
+ agents_ok_num = len(declarations_ok["email_auth"].unique())
450
+ nsip_agent_missing_num = len(
451
+ declaration_list.loc[declaration_list["nsip_agent_missing"], "email_auth"].unique()
452
+ )
453
+ nsip_optional_missing_num = len(
454
+ declaration_list.loc[
455
+ declaration_list["nsip_agent_missing"] & declaration_list["optional"],
456
+ "email_auth",
457
+ ].unique()
458
+ )
459
+ ositah_missing_num = len(declaration_list[declaration_list["ositah_missing"]])
460
+ ositah_validated_declarations_num = len(declaration_list) - ositah_missing_num
461
+ page_title = [
462
+ html.Div(
463
+ (
464
+ f"Export NSIP des contributions validées de '{team}' du"
465
+ f" {start_date.strftime('%Y-%m-%d')} au {end_date.strftime('%Y-%m-%d')}"
466
+ )
467
+ ),
468
+ html.Div(
469
+ (
470
+ f"Déclarations totales={ositah_total_declarations_num} dont"
471
+ f" synchronisées/validées={declarations_ok_num}/"
472
+ f"{ositah_validated_declarations_num}, "
473
+ f" manquantes OSITAH={ositah_missing_num}"
474
+ )
475
+ ),
476
+ html.Div(
477
+ (
478
+ f"(agents synchronisés={agents_ok_num},"
479
+ f" agents manquants dans NSIP={nsip_agent_missing_num} dont"
480
+ f" optionnels={nsip_optional_missing_num})"
481
+ )
482
+ ),
483
+ ]
484
+ if team and team != TEAM_LIST_ALL_AGENTS:
485
+ page_title.append(
486
+ html.Div(
487
+ (
488
+ "Certains agents peuvent apparaitre non synchronisés s'ils ont quitté"
489
+ f" le laboratoire: utiliser '{TEAM_LIST_ALL_AGENTS}' pour vérifier"
490
+ ),
491
+ style={"fontStyle": "italic", "fontWeight": "bold"},
492
+ )
493
+ )
494
+
495
+ if declarations_set == NSIP_DECLARATIONS_SELECT_ALL:
496
+ selected_declarations = declaration_list
497
+ else:
498
+ selected_declarations = declaration_list[~declaration_list["declaration_ok"]]
499
+
500
+ data_columns = list(NSIP_COLUMN_NAMES.keys())
501
+ data_columns.remove("email_auth")
502
+
503
+ table_header = [
504
+ html.Thead(
505
+ html.Tr(
506
+ [
507
+ html.Th(
508
+ [
509
+ (
510
+ dbc.Checkbox(id=NSIP_EXPORT_SELECT_ALL_ID)
511
+ if selected_declarations["selectable"].any()
512
+ else html.Div()
513
+ ),
514
+ dcc.Store(id=NSIP_EXPORT_ALL_SELECTED_ID, data=0),
515
+ ]
516
+ ),
517
+ html.Th("email_reseda"),
518
+ *[html.Th(c) for c in data_columns],
519
+ ]
520
+ )
521
+ )
522
+ ]
523
+
524
+ table_body = []
525
+ for email in selected_declarations["email_auth"].unique():
526
+ tr_list = nsip_build_user_declarations(selected_declarations, email, data_columns)
527
+ table_body.extend(tr_list)
528
+ table_body = [html.Tbody(table_body)]
529
+
530
+ return html.Div(
531
+ [
532
+ html.Div(
533
+ [
534
+ dbc.Row(
535
+ [
536
+ dbc.Col(dbc.Alert(page_title), width=10),
537
+ dbc.Col(
538
+ [
539
+ dbc.Button(
540
+ NSIP_EXPORT_BUTTON_LABEL,
541
+ id=NSIP_EXPORT_BUTTON_ID,
542
+ disabled=True,
543
+ ),
544
+ ],
545
+ width={"size": 1, "offset": 1},
546
+ ),
547
+ ]
548
+ ),
549
+ ]
550
+ ),
551
+ add_nsip_declaration_selection_switch(declarations_set),
552
+ html.Div(
553
+ dbc.Col(
554
+ dbc.Alert(dismissable=True, is_open=False, id=NSIP_EXPORT_STATUS_ID),
555
+ width=9,
556
+ )
557
+ ),
558
+ dcc.Store(id=NSIP_EXPORT_SELECTION_STATUS_ID, data=0),
559
+ dcc.Store(id=EXPORT_NSIP_SYNC_INDICATOR_ID, data=0),
560
+ dcc.Store(id=EXPORT_NSIP_SAVED_SYNC_INDICATOR_ID, data=0),
561
+ html.P(""),
562
+ dbc.Table(
563
+ table_header + table_body,
564
+ id={"type": TABLE_TYPE_TABLE, "id": TABLE_NSIP_EXPORT_ID},
565
+ bordered=True,
566
+ hover=True,
567
+ ),
568
+ ]
569
+ )
570
+
571
+
572
+ def nsip_build_user_declarations(declarations, agent_email, data_columns):
573
+ """
574
+ Build the list of html.Tr corresponding to the various projects af a given user.
575
+
576
+ :param declarations: declarations dataframe
577
+ :param agent_email: user RESEDA email
578
+ :param data_columns: name of columns to add in each row
579
+ :return: list of Tr
580
+ """
581
+
582
+ user_declarations = declarations[declarations.email_auth == agent_email]
583
+ tr_list = [
584
+ # rowSpan must be len+1 because the first row attached to the email is in a separate Tr
585
+ # (rowSpan is in fact the number of Tr following this one attached to it)
586
+ html.Tr(
587
+ [
588
+ (
589
+ html.Td(
590
+ [
591
+ dbc.Checkbox(
592
+ id={"type": "nsip-export-user", "id": agent_email},
593
+ class_name="nsip-agent-selector",
594
+ value=False,
595
+ ),
596
+ dcc.Store(
597
+ id={"type": "nsip-export-user-selected", "id": agent_email},
598
+ data=0,
599
+ ),
600
+ ],
601
+ className="align-middle ",
602
+ rowSpan=len(user_declarations) + 1,
603
+ )
604
+ if user_declarations["selectable"].any()
605
+ else html.Td(rowSpan=len(user_declarations) + 1)
606
+ ),
607
+ nsip_build_poject_declaration_cell(user_declarations, "email_auth", None),
608
+ dcc.Store(
609
+ id={"type": "nsip-selected-user", "id": agent_email},
610
+ data=agent_email,
611
+ ),
612
+ ]
613
+ )
614
+ ]
615
+
616
+ tr_list.extend(
617
+ [
618
+ html.Tr([nsip_build_poject_declaration_cell(row, c, i) for c in data_columns])
619
+ for i, row in declarations[declarations.email_auth == agent_email].iterrows()
620
+ ]
621
+ )
622
+
623
+ return tr_list
624
+
625
+
626
+ def nsip_build_poject_declaration_cell(declaration, column, row_index):
627
+ """
628
+ Build the column cell for one project declaration. Returns a html.Td.
629
+
630
+ :param declaration: project declaration row or rows if column='email_auth'
631
+ :param column: column name
632
+ :param row_index: row index in the dataframe: must be a unique id for the row. Ignored if
633
+ declaration is a dataframe.
634
+ :return: html.Td hor html.Th if column='email_auth'
635
+ """
636
+
637
+ if column == "email_auth":
638
+ div_id = f"export-row-{declaration.iloc[0]['row_index']}-{column}"
639
+ cell_content = [html.Div(declaration.iloc[0]["email_auth"], id=div_id)]
640
+ else:
641
+ div_id = f"export-row-{row_index}-{column}"
642
+ cell_content = [html.Div(declaration[column], id=div_id)]
643
+ cell_opt_class, tooltip = project_declaration_class(declaration, column)
644
+ if tooltip:
645
+ cell_content.append(dbc.Tooltip(tooltip, target=div_id))
646
+
647
+ if column == "email_auth":
648
+ return html.Th(
649
+ cell_content,
650
+ className=f"align-middle {cell_opt_class}",
651
+ rowSpan=len(declaration) + 1,
652
+ )
653
+ else:
654
+ return html.Td(cell_content, className=f"align-middle {cell_opt_class}")
655
+
656
+
657
+ def project_declaration_class(declaration, column):
658
+ """
659
+ Return the appropriate CSS class for each project declaration cell based on declaration
660
+ attributes
661
+
662
+ :param declaration: declaration row or rows if column='email_auth'
663
+ :param column: column for which CSS must be configured (allow to distingish between time and
664
+ time unit)
665
+ :return: CSS class names to add, tooltip text
666
+ """
667
+
668
+ global_params = GlobalParams()
669
+
670
+ if column == "time_unit":
671
+ if declaration["time_unit_mismatch"]:
672
+ return (
673
+ "table-warning",
674
+ f"Unité incorrecte, requiert : {declaration['volume']}",
675
+ )
676
+ elif column == "time":
677
+ if declaration["declaration_ok"]:
678
+ return "table-success", None
679
+ elif declaration["ositah_missing"]:
680
+ # Flag only the declaration time for missing declarations in OSITAH
681
+ return (
682
+ "table-danger",
683
+ "Declaration trouvée dans NSIP mais absente ou non-validée dans OSITAH",
684
+ )
685
+ elif declaration["invalid_time"]:
686
+ return (
687
+ "table-danger",
688
+ (
689
+ f"Le temps déclaré pour ce type de projet ne peut pas dépasser"
690
+ f" {global_params.declaration_options['max_hours']} heures"
691
+ ),
692
+ )
693
+ elif declaration["nsip_inconsistency"]:
694
+ # CSS class nsip_inconsistency is returned for time column only if there is a
695
+ # time mismatch
696
+ return (
697
+ "table-warning",
698
+ (
699
+ f"Le temps déclaré dans OSITAH est différent de celui de NSIP"
700
+ f" ({int(declaration['time_nsipd'])})"
701
+ ),
702
+ )
703
+ elif column == "validation_time":
704
+ if declaration["mgr_val_time_mismatch"]:
705
+ return (
706
+ "table-warning",
707
+ (
708
+ f"La date de validation est différente de celle de NSIP"
709
+ f" ({declaration['managerValidationDate']})"
710
+ ),
711
+ )
712
+ elif column in ["nsip_master", "nsip_project"]:
713
+ if math.isnan(declaration["nsip_project_id"]) and math.isnan(
714
+ declaration["nsip_reference_id"]
715
+ ):
716
+ return (
717
+ "table-danger",
718
+ "Pas de projet correspondant dans NSIP: vérifier le referentiel Hito",
719
+ )
720
+ elif column == "email_auth":
721
+ fullname_txt = f"Nom: {declaration.loc[declaration.index[0], 'fullname']}"
722
+ if declaration["declaration_ok"].all():
723
+ return "table-success", fullname_txt
724
+ elif declaration["nsip_agent_missing"].any():
725
+ if declaration["optional"].all():
726
+ return "table-info", [
727
+ html.Div(fullname_txt),
728
+ html.Div(
729
+ (
730
+ f"Agent dont la déclaration est optionnelle non présent dans NSIP"
731
+ f" ({declaration.loc[declaration.index[0], 'statut']})"
732
+ )
733
+ ),
734
+ ]
735
+ else:
736
+ return "table-warning", [
737
+ html.Div(fullname_txt),
738
+ html.Div("Agent non trouvé dans NSIP"),
739
+ ]
740
+ elif declaration["ositah_missing"].all():
741
+ # Set the cell class to table-danger on the agent email only if all declarations for the
742
+ # agent are missing
743
+ return "table-danger", [
744
+ html.Div(fullname_txt),
745
+ html.Div("Toutes les déclarations sont manquantes ou non validées dans OSITAH"),
746
+ ]
747
+ else:
748
+ return "", fullname_txt
749
+
750
+ return "", None
751
+
752
+
753
+ def add_nsip_declaration_selection_switch(current_set):
754
+ """
755
+ Add a dbc.RadioItems to select whether to show all declaration or only the not
756
+ synchronized ones in NSIP export table.
757
+
758
+ :param current_set: currently selected declaration set
759
+ :return: dbc.RadioItems
760
+ """
761
+
762
+ return dbc.Row(
763
+ [
764
+ dbc.RadioItems(
765
+ options=[
766
+ {
767
+ "label": "Toutes les déclarations",
768
+ "value": NSIP_DECLARATIONS_SELECT_ALL,
769
+ },
770
+ {
771
+ "label": "Déclarations non synchronisées uniquement",
772
+ "value": NSIP_DECLARATIONS_SELECT_UNSYNCHRONIZED,
773
+ },
774
+ ],
775
+ value=current_set,
776
+ id=NSIP_DECLARATIONS_SWITCH_ID,
777
+ inline=True,
778
+ ),
779
+ ],
780
+ justify="center",
781
+ )
782
+
783
+
784
+ @app.callback(
785
+ [
786
+ Output(TAB_ID_EXPORT_NSIP, "children"),
787
+ Output(EXPORT_SAVED_LOAD_INDICATOR_ID, "data"),
788
+ Output(EXPORT_SAVED_ACTIVE_TAB_ID, "data"),
789
+ ],
790
+ [
791
+ Input(EXPORT_LOAD_INDICATOR_ID, "data"),
792
+ Input(EXPORT_TAB_MENU_ID, "active_tab"),
793
+ Input(TEAM_SELECTED_VALUE_ID, "data"),
794
+ Input(DATA_SELECTED_SOURCE_ID, "data"),
795
+ Input(NSIP_DECLARATIONS_SELECTED_ID, "data"),
796
+ ],
797
+ [
798
+ State(TEAM_SELECTION_DATE_ID, "data"),
799
+ State(EXPORT_SAVED_LOAD_INDICATOR_ID, "data"),
800
+ State(VALIDATION_PERIOD_SELECTED_ID, "data"),
801
+ State(EXPORT_SAVED_ACTIVE_TAB_ID, "data"),
802
+ ],
803
+ prevent_initial_call=True,
804
+ )
805
+ def display_export_table(
806
+ load_in_progress,
807
+ active_tab,
808
+ team,
809
+ data_source,
810
+ declaration_set,
811
+ team_selection_date,
812
+ previous_load_in_progress,
813
+ period_date: str,
814
+ previous_active_tab,
815
+ ):
816
+ """
817
+ Display active tab contents after a team or an active tab change.
818
+
819
+ :param load_in_progress: load in progress indicator
820
+ :param tab: tab name
821
+ :param team: selected team
822
+ :param data_source: Hito (non-validated declarations) or OSITAH (validated declarations)
823
+ :param declaration_set: declarations subset selected
824
+ :param team_selection_date: last time the team selection was changed
825
+ :param previous_load_in_progress: previous value of the load_in_progress indicator
826
+ :param period_date: a date that must be inside the declaration period
827
+ :param previous_active_tab: previously active tab
828
+ :return: tab content
829
+ """
830
+
831
+ tab_contents = []
832
+
833
+ # Be sure to fill the return values in the same order as Output are declared
834
+ tab_list = [TAB_ID_EXPORT_NSIP]
835
+ for tab in tab_list:
836
+ if team and len(team) > 0 and tab == active_tab:
837
+ if load_in_progress > previous_load_in_progress and active_tab == previous_active_tab:
838
+ if active_tab == TAB_ID_EXPORT_NSIP:
839
+ tab_contents.append(
840
+ nsip_export_table(team, team_selection_date, period_date, declaration_set)
841
+ )
842
+ else:
843
+ tab_contents.append(
844
+ dbc.Alert("Erreur interne: tab non supporté"), color="warning"
845
+ )
846
+ previous_load_in_progress += 1
847
+ else:
848
+ component = html.Div(
849
+ [
850
+ create_progress_bar(team, duration=EXPORT_PROGRESS_BAR_MAX_DURATION),
851
+ dcc.Interval(
852
+ id=EXPORT_LOAD_TRIGGER_INTERVAL_ID,
853
+ n_intervals=0,
854
+ max_intervals=1,
855
+ interval=500,
856
+ ),
857
+ ]
858
+ )
859
+ tab_contents.append(component)
860
+ else:
861
+ tab_contents.append("")
862
+
863
+ tab_contents.extend([previous_load_in_progress, active_tab])
864
+
865
+ return tab_contents
866
+
867
+
868
+ @app.callback(
869
+ Output(EXPORT_LOAD_INDICATOR_ID, "data"),
870
+ Input(EXPORT_LOAD_TRIGGER_INTERVAL_ID, "n_intervals"),
871
+ State(EXPORT_SAVED_LOAD_INDICATOR_ID, "data"),
872
+ prevent_initial_call=True,
873
+ )
874
+ def export_tables_trigger(n, previous_load_indicator):
875
+ """
876
+ Increment (change) input of display_export_table callback to get it fired a
877
+ second time after displaying the progress bar. The output component must be updated each
878
+ time the callback is entered to trigger the execution of the other callback, thus the
879
+ choice of incrementing it at each call.
880
+
881
+ :param n: n_interval property of the dcc.Interval (0 or 1)
882
+ :return: 1 increment to previous value
883
+ """
884
+
885
+ return previous_load_indicator + 1
886
+
887
+
888
+ @app.callback(
889
+ Output({"type": "nsip-export-user-selected", "id": MATCH}, "data"),
890
+ Input({"type": "nsip-export-user", "id": MATCH}, "value"),
891
+ State({"type": "nsip-selected-user", "id": MATCH}, "data"),
892
+ prevent_initial_call=True,
893
+ )
894
+ def nsip_export_select_user(state, agent_email):
895
+ """
896
+ Mark the user as selected for NSIP export.
897
+
898
+ :param state: checkbox state
899
+ :param agent_email: RESEDA email of the selected user
900
+ :return:
901
+ """
902
+
903
+ global_params = GlobalParams()
904
+ try:
905
+ session_data = global_params.session_data
906
+ except SessionDataMissing:
907
+ return no_session_id_jumbotron()
908
+ declarations = session_data.nsip_declarations
909
+
910
+ if state:
911
+ declarations.loc[declarations.email_auth == agent_email, "selected"] = True
912
+ else:
913
+ declarations.loc[declarations.email_auth == agent_email, "selected"] = False
914
+
915
+ return state
916
+
917
+
918
+ @app.callback(
919
+ Output(NSIP_EXPORT_ALL_SELECTED_ID, "data"),
920
+ Input(NSIP_EXPORT_SELECT_ALL_ID, "value"),
921
+ prevent_initial_call=True,
922
+ )
923
+ def nsip_export_select_all_agents(checked):
924
+ """
925
+ Mark all selectable agents as selected if checked=1 or unselect all otherwise
926
+
927
+ :param checked: checkbox value
928
+ :return: checkbox value
929
+ """
930
+
931
+ global_params = GlobalParams()
932
+ try:
933
+ session_data = global_params.session_data
934
+ except SessionDataMissing:
935
+ return no_session_id_jumbotron()
936
+ declarations = session_data.nsip_declarations
937
+
938
+ declarations.loc[declarations.selectable, "selected"] = checked
939
+
940
+ return checked
941
+
942
+
943
+ # A client-side callback is used to update the selection indicator (checkbox) of all rows after
944
+ # clicking the "Select all" button
945
+ app.clientside_callback(
946
+ """
947
+ function define_checkbox_status(checked) {
948
+ const checkbox_forms = document.querySelectorAll(".nsip-agent-selector");
949
+ checkbox_forms.forEach(function(cb_form) {
950
+ const agent = cb_form.querySelector("input");
951
+ /*console.log("Updating checkbox for "+agent.id);*/
952
+ if ( checked ) {
953
+ agent.checked = true;
954
+ } else {
955
+ agent.checked = false;
956
+ }
957
+ });
958
+ return checked;
959
+ }
960
+ """,
961
+ Output(NSIP_EXPORT_SELECTION_STATUS_ID, "data"),
962
+ Input(NSIP_EXPORT_ALL_SELECTED_ID, "data"),
963
+ prevent_initial_call=True,
964
+ )
965
+
966
+
967
+ @app.callback(
968
+ Output(NSIP_EXPORT_BUTTON_ID, "children"),
969
+ Output(NSIP_EXPORT_BUTTON_ID, "title"),
970
+ Output(NSIP_EXPORT_BUTTON_ID, "disabled"),
971
+ Input({"type": "nsip-export-user-selected", "id": ALL}, "data"),
972
+ Input(NSIP_EXPORT_ALL_SELECTED_ID, "data"),
973
+ prevent_initial_call=True,
974
+ )
975
+ def nsip_export_selected_count(*_):
976
+ """
977
+ Callback updating the export button label with the number of selected agent, each time a
978
+ selection is changed. The button is also disabled if no agent is selected. Cannot be merged
979
+ with nsip_export_select_user callback as MATCH and non MATCH output cannot be mixed.
980
+
981
+ :param *_: input values are ignored
982
+ :return: label of the export button
983
+ """
984
+
985
+ global_params = GlobalParams()
986
+ try:
987
+ session_data = global_params.session_data
988
+ except SessionDataMissing:
989
+ return no_session_id_jumbotron()
990
+ declarations = session_data.nsip_declarations
991
+
992
+ selected_users = declarations[declarations.selected].email_auth.unique()
993
+ if len(selected_users):
994
+ selected_count = f" ({len(selected_users)})"
995
+ else:
996
+ selected_count = ""
997
+
998
+ return (
999
+ f"{NSIP_EXPORT_BUTTON_LABEL}{selected_count}",
1000
+ (
1001
+ f"{len(selected_users)} agent{'s' if len(selected_users) > 1 else ''}"
1002
+ f" sélectionné{'s' if len(selected_users) > 1 else ''}"
1003
+ ),
1004
+ False if len(selected_users) else True,
1005
+ )
1006
+
1007
+
1008
+ @app.callback(
1009
+ Output(NSIP_EXPORT_STATUS_ID, "children"),
1010
+ Output(NSIP_EXPORT_STATUS_ID, "is_open"),
1011
+ Output(NSIP_EXPORT_STATUS_ID, "color"),
1012
+ Output(EXPORT_NSIP_SAVED_SYNC_INDICATOR_ID, "data"),
1013
+ Input(NSIP_EXPORT_BUTTON_ID, "n_clicks"),
1014
+ Input(EXPORT_NSIP_SYNC_INDICATOR_ID, "data"),
1015
+ State(EXPORT_NSIP_SAVED_SYNC_INDICATOR_ID, "data"),
1016
+ prevent_initial_call=True,
1017
+ )
1018
+ def nsip_export_button(n_clicks, sync_indicator, previous_sync_indicator):
1019
+ """
1020
+ Push into NSIP declarations by all the selected users. All project declarations for the user
1021
+ are added/updated. This callback is entered twice: the first time it displays a progess
1022
+ bar and start a dcc.Interval, the second time it does the real synchronisation work.
1023
+
1024
+ :param n_clicks: checkbox state
1025
+ :param sync_indicator: current value of sync indicator
1026
+ :param previous_sync_indicator: previous value of sync indicator
1027
+ :return: agent/project updates failed
1028
+ """
1029
+
1030
+ global_params = GlobalParams()
1031
+ try:
1032
+ session_data = global_params.session_data
1033
+ except SessionDataMissing:
1034
+ return no_session_id_jumbotron()
1035
+ declarations = session_data.nsip_declarations
1036
+ selected_declarations = declarations[declarations.selectable & declarations.selected]
1037
+ failed_updates = []
1038
+
1039
+ updated_declarations = 0
1040
+ if n_clicks and n_clicks >= 1:
1041
+ if sync_indicator > previous_sync_indicator:
1042
+ for row in selected_declarations.itertuples(index=False):
1043
+ # One of the possible error during declaration update is that the user has
1044
+ # multiple contracts attached to the lab for the current period. In this case
1045
+ # the update is retried with the second contract (generally the current one)
1046
+ # mentioned in the error message.
1047
+ retry_on_error = True
1048
+ retry_attempts = 0
1049
+ contract = None
1050
+ while retry_on_error:
1051
+ # nsip_project_id and nsip_reference_id are NaN if undefined and a NaN value is
1052
+ # not equal to itself!
1053
+ project_type = row.nsip_project_id == row.nsip_project_id
1054
+ activity_id = row.nsip_project_id if project_type else row.nsip_reference_id
1055
+ (
1056
+ status,
1057
+ http_status,
1058
+ http_reason,
1059
+ ) = global_params.nsip.update_declaration(
1060
+ row.email_reseda,
1061
+ activity_id,
1062
+ project_type,
1063
+ row.time,
1064
+ row.validation_time,
1065
+ contract,
1066
+ )
1067
+
1068
+ if status > 0 and http_status:
1069
+ m = EXPORT_NSIP_MULTIPLE_CONTRACTS_ERROR.match(http_reason)
1070
+ # Never retry more than once
1071
+ if m and (retry_attempts == 0):
1072
+ contract = m.group("id2")
1073
+ retry_attempts += 1
1074
+ print(
1075
+ (
1076
+ f"Agent {row.email_reseda} has several contracts for the"
1077
+ f" current period: retrying update with contract {contract}"
1078
+ )
1079
+ )
1080
+ else:
1081
+ retry_on_error = False
1082
+ else:
1083
+ retry_on_error = False
1084
+
1085
+ if status <= 0:
1086
+ # Log a message if the declaration was successfully added to NSIP to make
1087
+ # diagnostics easier. After a successful addition or update, http_status
1088
+ # contains the declaration ID instead of the http status
1089
+ if status == 0:
1090
+ action = "added to"
1091
+ else:
1092
+ action = "updated in"
1093
+ print(
1094
+ (
1095
+ f"Declaration {http_status} for user {row.email_auth} (NSIP ID:"
1096
+ f" {row.email_reseda}), {'project' if project_type else 'reference'}"
1097
+ f" ID {int(activity_id)} {action} NSIP"
1098
+ ),
1099
+ flush=True,
1100
+ )
1101
+
1102
+ else:
1103
+ if http_status:
1104
+ http_error = f" (http status={http_status}, reason={http_reason})"
1105
+ else:
1106
+ http_error = ""
1107
+ print(
1108
+ (
1109
+ f"ERROR: update of declaration failed for user {row.email_auth}"
1110
+ f" (NSIP ID: {row.email_reseda}),"
1111
+ f" {'project' if project_type else 'reference'} ID"
1112
+ f" {int(activity_id)}{http_error}"
1113
+ ),
1114
+ flush=True,
1115
+ )
1116
+ failed_updates.append(
1117
+ f"{row.email_auth}/{'projet' if project_type else 'reference'}"
1118
+ f"/{int(activity_id)}"
1119
+ )
1120
+
1121
+ updated_declarations += 1
1122
+ previous_sync_indicator += 1
1123
+ else:
1124
+ component = html.Div(
1125
+ [
1126
+ create_progress_bar(
1127
+ duration=(len(selected_declarations) / EXPORT_NSIP_SYNC_FREQUENCY)
1128
+ ),
1129
+ dcc.Interval(
1130
+ id=EXPORT_NSIP_SYNC_TRIGGER_INTERVAL_ID,
1131
+ n_intervals=0,
1132
+ max_intervals=1,
1133
+ interval=500,
1134
+ ),
1135
+ ]
1136
+ )
1137
+ return component, True, "success", previous_sync_indicator
1138
+
1139
+ else:
1140
+ raise PreventUpdate
1141
+
1142
+ if len(failed_updates) == 0:
1143
+ update_status_msg = (
1144
+ f"Toutes les déclarations sélectionnées ({updated_declarations})"
1145
+ f" ont été enregistrées dans NSIP"
1146
+ )
1147
+ color = "success"
1148
+ else:
1149
+ update_status_msg = (
1150
+ f"{'Un export a' if len(failed_updates) == 1 else 'Plusieurs exports ont'} échoués :"
1151
+ f" {', '.join(failed_updates)}"
1152
+ )
1153
+ color = "warning"
1154
+ return update_status_msg, True, color, previous_sync_indicator
1155
+
1156
+
1157
+ @app.callback(
1158
+ Output(EXPORT_NSIP_SYNC_INDICATOR_ID, "data"),
1159
+ Input(EXPORT_NSIP_SYNC_TRIGGER_INTERVAL_ID, "n_intervals"),
1160
+ State(EXPORT_NSIP_SAVED_SYNC_INDICATOR_ID, "data"),
1161
+ prevent_initial_call=True,
1162
+ )
1163
+ def nsip_export_button_trigger(n, previous_load_indicator):
1164
+ """
1165
+ Increment (change) input of nsip_export_button callback to get it fired a
1166
+ second time after displaying the progress bar. The output component must be updated each
1167
+ time the callback is entered to trigger the execution of the other callback, thus the
1168
+ choice of incrementing it at each call.
1169
+
1170
+ :param n: n_interval property of the dcc.Interval (0 or 1)
1171
+ :return: 1 increment to previous value
1172
+ """
1173
+
1174
+ return previous_load_indicator + 1
1175
+
1176
+
1177
+ @app.callback(
1178
+ Output(NSIP_DECLARATIONS_SELECTED_ID, "data"),
1179
+ Input(NSIP_DECLARATIONS_SWITCH_ID, "value"),
1180
+ prevent_initial_call=True,
1181
+ )
1182
+ def select_declarations_set(new_set):
1183
+ """
1184
+ This callback is used to forward to the NSIP export callback the selected declarations set
1185
+ through a dcc.Store that exists permanently in the page.
1186
+
1187
+ :param new_set: selected declarations set
1188
+ :return: same value
1189
+ """
1190
+
1191
+ return new_set