ositah 24.7.dev1__py3-none-any.whl → 24.7.dev3__py3-none-any.whl

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