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