ositah 25.6.dev1__py3-none-any.whl → 25.9.dev2__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 +1209 -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 +1179 -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.dev2.dist-info}/METADATA +149 -150
  37. ositah-25.9.dev2.dist-info/RECORD +46 -0
  38. {ositah-25.6.dev1.dist-info → ositah-25.9.dev2.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.dev2.dist-info}/WHEEL +0 -0
  41. {ositah-25.6.dev1.dist-info → ositah-25.9.dev2.dist-info}/entry_points.txt +0 -0
  42. {ositah-25.6.dev1.dist-info → ositah-25.9.dev2.dist-info}/top_level.txt +0 -0
ositah/apps/analysis.py CHANGED
@@ -1,785 +1,785 @@
1
- # OSITAH sub-application to analyse data to NSIP
2
- from typing import Dict
3
-
4
- import dash
5
- import dash_bootstrap_components as dbc
6
- import numpy as np
7
- import plotly.express as px
8
- from dash import dcc, html
9
- from dash.dependencies import Input, Output, State
10
- from dash.exceptions import PreventUpdate
11
-
12
- from ositah.app import app
13
- from ositah.utils.cache import clear_cached_data
14
- from ositah.utils.menus import (
15
- DATA_SELECTED_SOURCE_ID,
16
- DATA_SELECTION_SOURCE_ID,
17
- TABLE_TYPE_DUMMY_STORE,
18
- TABLE_TYPE_TABLE,
19
- TEAM_SELECTED_VALUE_ID,
20
- TEAM_SELECTION_DATE_ID,
21
- VALIDATION_PERIOD_SELECTED_ID,
22
- build_accordion,
23
- create_progress_bar,
24
- team_list_dropdown,
25
- )
26
- from ositah.utils.period import get_validation_period_dates
27
- from ositah.utils.projects import (
28
- DATA_SOURCE_HITO,
29
- DATA_SOURCE_OSITAH,
30
- build_projects_data,
31
- get_team_projects,
32
- )
33
- from ositah.utils.utils import WEEK_HOURS, GlobalParams, general_error_jumbotron
34
-
35
- ANALYSIS_TAB_MENU_ID = "report-tabs"
36
- TAB_ID_ANALYSIS_GRAPHICS = "graphics-page"
37
- TAB_ID_ANALYSIS_IJCLAB = "project-report-page"
38
-
39
- TAB_MENU_ANALYSIS_GRAPHICS = "Graphiques"
40
- TAB_MENU_ANALYSIS_IJCLAB = "Rapports"
41
-
42
- TABLE_TEAM_PROJECTS_ID = "analysis-ijclab"
43
-
44
- ANALYSIS_LOAD_INDICATOR_ID = "analysis-others-data-load-indicator"
45
- ANALYSIS_SAVED_INDICATOR_ID = "analysis-others-saved-data-load-indicator"
46
- ANALYSIS_TRIGGER_INTERVAL_ID = "analysis-others-display-callback-interval"
47
- ANALYSIS_PROGRESS_BAR_MAX_DURATION = 8 # seconds
48
- ANALYSIS_SAVED_ACTIVE_TAB_ID = "analysis-saved-active-tab"
49
-
50
- GRAPHICS_DROPDOWN_ID = "graphics-type-selection"
51
- GRAPHICS_DROPDOWN_MENU = "Types de graphique"
52
- GRAPHICS_DM_CATEGORY_TIME_ID = "graphics-cateogry-time"
53
- GRAPHICS_DM_CATEGORY_TIME_MENU = "Catégorie d'activités"
54
- GRAPHICS_DM_LOCAL_PROJECTS_TIME_ID = "graphics-local-projects-time"
55
- GRAPHICS_DM_LOCAL_PROJECTS_TIME_MENU = "Projets locaux"
56
- GRAPHICS_DM_NSIP_PROJECTS_TIME_ID = "graphics-nsip-projects-time"
57
- GRAPHICS_DM_NSIP_PROJECTS_TIME_MENU = "Projets NSIP"
58
- GRAPHICS_DM_TEACHING_ACTIVITIES_TIME_ID = "graphics-teaching-activities-time"
59
- GRAPHICS_DM_TEACHING_ACTIVITIES_TIME_MENU = "Enseignement"
60
- GRAPHICS_DM_CONSULTANCY_ACTIVITIES_TIME_ID = "graphics-consultancy-activities-time"
61
- GRAPHICS_DM_CONSULTANCY_ACTIVITIES_TIME_MENU = "Consultance & Expertise"
62
- GRAPHICS_DM_SUPPORT_ACTIVITIES_TIME_ID = "graphics-support-activities-time"
63
- GRAPHICS_DM_SUPPORT_ACTIVITIES_TIME_MENU = "Service & Support"
64
- GRAPHICS_AREA_DIV_ID = "graphics-area"
65
-
66
-
67
- def define_exported_column_names() -> Dict[str, str]:
68
- """
69
- Function to build the EXPORT_COLUMN_NAMES dict from colum names defined in global parameters
70
-
71
- :return: dict
72
- """
73
-
74
- global_params = GlobalParams()
75
- columns = global_params.columns
76
-
77
- return {
78
- columns["category"]: "Type d'activité",
79
- columns["fullname"]: "Agent",
80
- columns["hours"]: "Nombre d'heures",
81
- columns["masterproject"]: "Masterprojet",
82
- columns["team"]: "Equipe",
83
- columns["project"]: "Projet",
84
- }
85
-
86
-
87
- # Maps column names from queries to displayed column names in table/CSV
88
- EXPORT_COLUMN_NAMES = define_exported_column_names()
89
-
90
-
91
- def ijclab_team_export_table(team, team_selection_date, period_date: str, source):
92
- """
93
- Build the project list contributed by the selected team and the related time declarations and
94
- return a table.
95
-
96
- :param team: selected team
97
- :param team_selection_date: last time the team selection was changed
98
- :param period_date: a date that must be inside the declaration period
99
- :param source: whether to use Hito (non validated) or OSITAH (validated) as a data source
100
- :return: dbc.Table
101
- """
102
-
103
- if team is None:
104
- return html.Div("")
105
-
106
- global_params = GlobalParams()
107
- columns = global_params.columns
108
-
109
- start_date, end_date = get_validation_period_dates(period_date)
110
-
111
- projects_data, declaration_list = build_projects_data(
112
- team, team_selection_date, period_date, source
113
- )
114
- if projects_data is None or declaration_list is None:
115
- if source == DATA_SOURCE_HITO:
116
- msg = f"L'équipe '{team}' ne contribue à aucun projet"
117
- else:
118
- msg = f"Aucune données validées n'existe pour l'équipe '{team}'"
119
- msg += (
120
- f" pour la période du {start_date.strftime('%Y-%m-%d')} au"
121
- f" {end_date.strftime('%Y-%m-%d')}"
122
- )
123
- return html.Div([dbc.Alert(msg, color="warning"), add_source_selection_switch(source)])
124
-
125
- table_columns = [columns["masterproject"], columns["project"], columns["hours"]]
126
-
127
- table_header = [
128
- html.Thead(
129
- html.Tr(
130
- [
131
- *[
132
- html.Th(
133
- [
134
- html.I(f"{EXPORT_COLUMN_NAMES[c]} "),
135
- html.I(className="fas fa-sort mr-3"),
136
- ],
137
- className="text-center",
138
- )
139
- for c in table_columns
140
- ],
141
- ]
142
- )
143
- )
144
- ]
145
-
146
- table_body = [
147
- html.Tbody(
148
- [
149
- html.Tr(
150
- [
151
- html.Td(
152
- projects_data.iloc[i - 1][columns["masterproject"]],
153
- className="align-middle",
154
- key=f"analysis-table-cell-{i}-masterproject",
155
- ),
156
- html.Td(
157
- projects_data.iloc[i - 1][columns["project"]],
158
- className="align-middle",
159
- key=f"analysis-table-cell-{i}-project",
160
- ),
161
- html.Td(
162
- build_accordion(
163
- i,
164
- projects_data.iloc[i - 1][columns["hours"]],
165
- project_agents_time(
166
- declaration_list,
167
- projects_data.iloc[i - 1][columns["activity"]],
168
- ),
169
- f"{projects_data.iloc[i-1][columns['weeks']]} semaines",
170
- ),
171
- className="accordion",
172
- key=f"analysis-table-cell-{i}-time",
173
- ),
174
- ]
175
- )
176
- for i in range(1, len(projects_data) + 1)
177
- ]
178
- )
179
- ]
180
-
181
- if source == DATA_SOURCE_OSITAH:
182
- page_title = f"Contributions par projet validées de '{team}'"
183
- else:
184
- page_title = f"Contributions par projet déclarées (non validées) de '{team}'"
185
- page_title += f" du {start_date.strftime('%Y-%m-%d')} au {end_date.strftime('%Y-%m-%d')}"
186
-
187
- return html.Div(
188
- [
189
- html.Div(
190
- [
191
- dbc.Row(
192
- [
193
- dbc.Col(dbc.Alert(page_title), width=8),
194
- dbc.Col(
195
- [
196
- dbc.Button("Export CSV", id="ijclab-export-file-button"),
197
- dcc.Download(id="ijclab-export-file-download"),
198
- ],
199
- width={"size": 2, "offset": 2},
200
- ),
201
- ]
202
- ),
203
- add_source_selection_switch(source),
204
- ]
205
- ),
206
- html.P(""),
207
- dbc.Table(
208
- table_header + table_body,
209
- id={"type": TABLE_TYPE_TABLE, "id": TABLE_TEAM_PROJECTS_ID},
210
- bordered=True,
211
- hover=True,
212
- striped=True,
213
- class_name="sortable",
214
- ),
215
- ]
216
- )
217
-
218
-
219
- def ijclab_graphics(team, team_selection_date, period_date: str, source):
220
- """
221
- Build various graphics from declarations. This function just creates the basic structure of
222
- the graphic page and read the data. The actual graphic will be displayed by the callback
223
- associated with the dropdown menu used to select the graphics type.
224
-
225
- :param team: selected team
226
- :param team_selection_date: last time the team selection was changed
227
- :param period_date: a date that must be inside the declaration period
228
- :param source: whether to use Hito (non validated) or OSITAH (validated) as a data source
229
- :return: graphics and associated menus
230
- """
231
-
232
- if team is None:
233
- return html.Div("")
234
-
235
- start_date, end_date = get_validation_period_dates(period_date)
236
-
237
- projects_data, declaration_list = build_projects_data(
238
- team, team_selection_date, period_date, source
239
- )
240
- if projects_data is None or declaration_list is None:
241
- if source == DATA_SOURCE_HITO:
242
- msg = f"L'équipe '{team}' ne contribue à aucun projet"
243
- else:
244
- msg = f"Aucune données validées n'existe pour l'équipe '{team}'"
245
- msg += (
246
- f" pour la période du {start_date.strftime('%Y-%m-%d')} au"
247
- f" {end_date.strftime('%Y-%m-%d')}"
248
- )
249
- return html.Div([dbc.Alert(msg, color="warning"), add_source_selection_switch(source)])
250
-
251
- return html.Div(
252
- [
253
- dbc.Row(
254
- [
255
- dbc.Col(add_source_selection_switch(source), width=8),
256
- dbc.Col(graphics_dropdown_menu(), width={"size": 3, "offset": 1}),
257
- ]
258
- ),
259
- html.Div(id=GRAPHICS_AREA_DIV_ID),
260
- ]
261
- )
262
-
263
-
264
- def add_source_selection_switch(current_source):
265
- """
266
- Add a dbc.RadioItems to select the data source.
267
-
268
- :param current_source: currently selected source
269
- :return: dbc.RadioItems
270
- """
271
-
272
- return dbc.Row(
273
- [
274
- dbc.RadioItems(
275
- options=[
276
- {"label": "Toutes les déclarations", "value": DATA_SOURCE_HITO},
277
- {
278
- "label": "Déclarations validées uniquement",
279
- "value": DATA_SOURCE_OSITAH,
280
- },
281
- ],
282
- value=current_source,
283
- id=DATA_SELECTION_SOURCE_ID,
284
- inline=True,
285
- ),
286
- ],
287
- justify="center",
288
- )
289
-
290
-
291
- def project_agents_time(declarations, project):
292
- """
293
- Return a HTML Div with the list of agents who contributed to the project and their
294
- declared time.
295
-
296
- :param declarations: dataframe with the contribution of each agent to each project
297
- :param project: project fullname
298
- :return:
299
- """
300
-
301
- global_params = GlobalParams()
302
- columns = global_params.columns
303
-
304
- project_agents = declarations[declarations[columns["activity"]] == project]
305
- project_agents.loc[:, columns["hours"]] = np.round(project_agents[columns["hours"]]).astype(
306
- "int"
307
- )
308
- project_agents.loc[:, columns["weeks"]] = np.round(
309
- project_agents.loc[:, columns["hours"]] / WEEK_HOURS, 1
310
- )
311
- if global_params.analysis_params["contributions_sorted_by_name"]:
312
- sort_by = ["nom", columns["hours"]]
313
- sort_ascending = True
314
- else:
315
- sort_by = [columns["hours"], "nom"]
316
- sort_ascending = False
317
- project_agents.sort_values(
318
- by=sort_by, ascending=sort_ascending, ignore_index=True, inplace=True
319
- )
320
- return html.Div(
321
- [
322
- html.Div(
323
- (
324
- f"{project_agents.iloc[i]['fullname']}:"
325
- f" {project_agents.iloc[i][columns['hours']]}"
326
- f" ({project_agents.iloc[i][columns['weeks']]} sem.)"
327
- )
328
- )
329
- for i in range(len(project_agents))
330
- ]
331
- )
332
-
333
-
334
- def graphics_dropdown_menu():
335
- """
336
- Build the dropdown menu to select the graphics type
337
-
338
- :return: dropdown menu
339
- """
340
-
341
- return dbc.DropdownMenu(
342
- [
343
- dbc.DropdownMenuItem(
344
- GRAPHICS_DM_CATEGORY_TIME_MENU,
345
- id=GRAPHICS_DM_CATEGORY_TIME_ID,
346
- n_clicks=0,
347
- ),
348
- dbc.DropdownMenuItem(divider=True),
349
- dbc.DropdownMenuItem(
350
- GRAPHICS_DM_NSIP_PROJECTS_TIME_MENU,
351
- id=GRAPHICS_DM_NSIP_PROJECTS_TIME_ID,
352
- n_clicks=0,
353
- ),
354
- dbc.DropdownMenuItem(
355
- GRAPHICS_DM_LOCAL_PROJECTS_TIME_MENU,
356
- id=GRAPHICS_DM_LOCAL_PROJECTS_TIME_ID,
357
- n_clicks=0,
358
- ),
359
- dbc.DropdownMenuItem(divider=True),
360
- dbc.DropdownMenuItem(
361
- GRAPHICS_DM_TEACHING_ACTIVITIES_TIME_MENU,
362
- id=GRAPHICS_DM_TEACHING_ACTIVITIES_TIME_ID,
363
- n_clicks=0,
364
- ),
365
- dbc.DropdownMenuItem(
366
- GRAPHICS_DM_CONSULTANCY_ACTIVITIES_TIME_MENU,
367
- id=GRAPHICS_DM_CONSULTANCY_ACTIVITIES_TIME_ID,
368
- n_clicks=0,
369
- ),
370
- dbc.DropdownMenuItem(
371
- GRAPHICS_DM_SUPPORT_ACTIVITIES_TIME_MENU,
372
- id=GRAPHICS_DM_SUPPORT_ACTIVITIES_TIME_ID,
373
- n_clicks=0,
374
- ),
375
- ],
376
- id=GRAPHICS_DROPDOWN_ID,
377
- label=GRAPHICS_DROPDOWN_MENU,
378
- )
379
-
380
-
381
- def analysis_submenus():
382
- """
383
- Build the tabs menus of the export subapplication
384
-
385
- :return: DBC Tabs
386
- """
387
-
388
- return dbc.Tabs(
389
- [
390
- dbc.Tab(
391
- id=TAB_ID_ANALYSIS_IJCLAB,
392
- tab_id=TAB_ID_ANALYSIS_IJCLAB,
393
- label=TAB_MENU_ANALYSIS_IJCLAB,
394
- ),
395
- dbc.Tab(
396
- id=TAB_ID_ANALYSIS_GRAPHICS,
397
- tab_id=TAB_ID_ANALYSIS_GRAPHICS,
398
- label=TAB_MENU_ANALYSIS_GRAPHICS,
399
- ),
400
- ],
401
- id=ANALYSIS_TAB_MENU_ID,
402
- )
403
-
404
-
405
- def analysis_layout():
406
- """
407
- Build the layout for this application, after reading the data if necessary.
408
-
409
- :return: application layout
410
- """
411
-
412
- return html.Div(
413
- [
414
- html.H1("Analyse des déclarations"),
415
- team_list_dropdown(),
416
- # The following dcc.Store is used to ensure that the the ijclab_export input exists
417
- # before the export page is created
418
- dcc.Store(id=DATA_SELECTED_SOURCE_ID, data=DATA_SOURCE_HITO),
419
- html.Div(analysis_submenus(), id="analysis-submenus", style={"marginTop": "3em"}),
420
- dcc.Store(id=ANALYSIS_LOAD_INDICATOR_ID, data=0),
421
- dcc.Store(id=ANALYSIS_SAVED_INDICATOR_ID, data=0),
422
- dcc.Store(id=ANALYSIS_SAVED_ACTIVE_TAB_ID, data=""),
423
- dcc.Store(
424
- id={"type": TABLE_TYPE_DUMMY_STORE, "id": TABLE_TEAM_PROJECTS_ID},
425
- data=0,
426
- ),
427
- ]
428
- )
429
-
430
-
431
- @app.callback(
432
- Output(DATA_SELECTED_SOURCE_ID, "data"),
433
- Input(DATA_SELECTION_SOURCE_ID, "value"),
434
- State(DATA_SELECTED_SOURCE_ID, "data"),
435
- prevent_initial_call=True,
436
- )
437
- def select_data_source(new_source, previous_source):
438
- """
439
- This callback is used to forward to the export callback the selected source through a
440
- dcc.Store that exists before the page is created. It also clears the data cache if
441
- the source has been changed.
442
-
443
- :param new_source: value to forward to the dcc.Store
444
- :param previous_source: previous value of the selection
445
- :return: new_source value
446
- """
447
-
448
- if new_source != previous_source:
449
- clear_cached_data()
450
-
451
- return new_source
452
-
453
-
454
- @app.callback(
455
- [
456
- Output(TAB_ID_ANALYSIS_IJCLAB, "children"),
457
- Output(TAB_ID_ANALYSIS_GRAPHICS, "children"),
458
- Output(ANALYSIS_SAVED_INDICATOR_ID, "data"),
459
- Output(ANALYSIS_SAVED_ACTIVE_TAB_ID, "data"),
460
- ],
461
- [
462
- Input(ANALYSIS_LOAD_INDICATOR_ID, "data"),
463
- Input(ANALYSIS_TAB_MENU_ID, "active_tab"),
464
- Input(TEAM_SELECTED_VALUE_ID, "data"),
465
- Input(DATA_SELECTED_SOURCE_ID, "data"),
466
- ],
467
- [
468
- State(TEAM_SELECTION_DATE_ID, "data"),
469
- State(ANALYSIS_SAVED_INDICATOR_ID, "data"),
470
- State(VALIDATION_PERIOD_SELECTED_ID, "data"),
471
- State(ANALYSIS_SAVED_ACTIVE_TAB_ID, "data"),
472
- ],
473
- prevent_initial_call=True,
474
- )
475
- def display_analysis_tables(
476
- load_in_progress,
477
- active_tab,
478
- team,
479
- data_source,
480
- team_selection_date,
481
- previous_load_in_progress,
482
- period_date: str,
483
- previous_active_tab,
484
- ):
485
- """
486
- Display active tab contents after a team or an active tab change. Exact action depends on the
487
- value of the load in progress indicator. If it is equal to the previous value, it means this
488
- is the start of the update process: progress bar is displayed and a dcc.Interval is created
489
- to schedule again this callback after incrementing the load in progress indicator. This causes
490
- the callback to be reentered and this time it triggers the real processing for the tab
491
- resulting in the final update of the active tab contents. An empty content is returned for
492
- inactive tabs.
493
-
494
- :param load_in_progress: load in progress indicator
495
- :param tab: tab name
496
- :param team: selected team
497
- :param data_source: Hito (non-validated declarations) or OSITAH (validated declarations)
498
- :param team_selection_date: last time the team selection was changed
499
- :param previous_load_in_progress: previous value of the load_in_progress indicator
500
- :param period_date: a date that must be inside the declaration period
501
- :param previous_active_tab: previously active tab
502
- :return: tab content
503
- """
504
-
505
- tab_contents = []
506
-
507
- # Be sure to fill the return values in the same order as Output are declared
508
- tab_list = [TAB_ID_ANALYSIS_IJCLAB, TAB_ID_ANALYSIS_GRAPHICS]
509
- for tab in tab_list:
510
- if team and len(team) > 0 and tab == active_tab:
511
- if load_in_progress > previous_load_in_progress and active_tab == previous_active_tab:
512
- if tab == TAB_ID_ANALYSIS_IJCLAB:
513
- tab_contents.append(
514
- ijclab_team_export_table(
515
- team, team_selection_date, period_date, data_source
516
- )
517
- )
518
- elif tab == TAB_ID_ANALYSIS_GRAPHICS:
519
- tab_contents.append(
520
- ijclab_graphics(team, team_selection_date, period_date, data_source)
521
- )
522
- else:
523
- tab_contents.append(
524
- dbc.Alert("Erreur interne: tab non supporté"), color="warning"
525
- )
526
- previous_load_in_progress += 1
527
- else:
528
- component = html.Div(
529
- [
530
- create_progress_bar(team, duration=ANALYSIS_PROGRESS_BAR_MAX_DURATION),
531
- dcc.Interval(
532
- id=ANALYSIS_TRIGGER_INTERVAL_ID,
533
- n_intervals=0,
534
- max_intervals=1,
535
- interval=500,
536
- ),
537
- ]
538
- )
539
- tab_contents.append(component)
540
- else:
541
- tab_contents.append("")
542
-
543
- tab_contents.extend([previous_load_in_progress, active_tab])
544
-
545
- return tab_contents
546
-
547
-
548
- @app.callback(
549
- Output(ANALYSIS_LOAD_INDICATOR_ID, "data"),
550
- Input(ANALYSIS_TRIGGER_INTERVAL_ID, "n_intervals"),
551
- State(ANALYSIS_SAVED_INDICATOR_ID, "data"),
552
- prevent_initial_call=True,
553
- )
554
- def display_tables_trigger(n, previous_load_indicator):
555
- """
556
- Increment (change) of the input of display_tables_trigger callback to get it fired a
557
- second time after displaying the progress bar. The output component must be updated each
558
- time the callback is entered to trigger the execution of the other callback, thus the
559
- choice of incrementing it at each call.
560
-
561
- :param n: n_interval property of the dcc.Interval (0 or 1)
562
- :return: 1 increment to previous value
563
- """
564
-
565
- return previous_load_indicator + 1
566
-
567
-
568
- @app.callback(
569
- Output("ijclab-export-file-download", "data"),
570
- Input("ijclab-export-file-button", "n_clicks"),
571
- [
572
- State(TEAM_SELECTED_VALUE_ID, "data"),
573
- State(TEAM_SELECTION_DATE_ID, "data"),
574
- State(DATA_SELECTED_SOURCE_ID, "data"),
575
- State(VALIDATION_PERIOD_SELECTED_ID, "data"),
576
- ],
577
- prevent_initial_call=True,
578
- )
579
- def ijclab_export_to_csv(_, team, team_selection_date, source, period_date):
580
- """
581
- Generate a CSV file for the selected team, using the appropriate data source.
582
-
583
- :param _: unused, just an input to trigger the callback
584
- :param team: selected team
585
- :param team_selection_date: timestamp of the last change in team selection
586
- :param period_date: a date that must be inside the declaration period
587
- :return: None
588
- """
589
-
590
- global_params = GlobalParams()
591
- columns = global_params.columns
592
-
593
- declaration_list = get_team_projects(team, team_selection_date, period_date, source)
594
- if declaration_list is None:
595
- return dbc.Alert(
596
- f"L'équipe '{team}' ne contribue à aucun projet actuellement",
597
- color="warning",
598
- )
599
-
600
- exported_data = declaration_list[
601
- [
602
- columns["masterproject"],
603
- columns["project"],
604
- columns["category"],
605
- columns["fullname"],
606
- columns["team"],
607
- columns["hours"],
608
- ]
609
- ]
610
- exported_data.loc[:, columns["hours"]] = np.round(exported_data[columns["hours"]]).astype("int")
611
- column_renames = {}
612
- for c in exported_data.columns.tolist():
613
- if c in EXPORT_COLUMN_NAMES:
614
- column_renames[c] = EXPORT_COLUMN_NAMES[c]
615
- if len(column_renames.keys()) > 0:
616
- exported_data.rename(columns=column_renames, inplace=True)
617
-
618
- return dict(
619
- content=exported_data.to_csv(index=False, sep=";"),
620
- filename="project_contributions.csv",
621
- )
622
-
623
-
624
- @app.callback(
625
- Output(GRAPHICS_AREA_DIV_ID, "children"),
626
- Output(GRAPHICS_DROPDOWN_ID, "label"),
627
- Output(GRAPHICS_AREA_DIV_ID, "style"),
628
- Input(GRAPHICS_DM_CATEGORY_TIME_ID, "n_clicks"),
629
- Input(GRAPHICS_DM_NSIP_PROJECTS_TIME_ID, "n_clicks"),
630
- Input(GRAPHICS_DM_LOCAL_PROJECTS_TIME_ID, "n_clicks"),
631
- Input(GRAPHICS_DM_TEACHING_ACTIVITIES_TIME_ID, "n_clicks"),
632
- Input(GRAPHICS_DM_CONSULTANCY_ACTIVITIES_TIME_ID, "n_clicks"),
633
- Input(GRAPHICS_DM_SUPPORT_ACTIVITIES_TIME_ID, "n_clicks"),
634
- State(TEAM_SELECTED_VALUE_ID, "data"),
635
- State(TEAM_SELECTION_DATE_ID, "data"),
636
- State(VALIDATION_PERIOD_SELECTED_ID, "data"),
637
- State(DATA_SELECTED_SOURCE_ID, "data"),
638
- State(GRAPHICS_DROPDOWN_ID, "label"),
639
- prevent_initial_call=True,
640
- )
641
- def display_graphics(
642
- _1,
643
- _2,
644
- _3,
645
- _4,
646
- _5,
647
- _6,
648
- team,
649
- team_selection_date,
650
- period_date,
651
- data_source,
652
- dropdown_label,
653
- ):
654
- """
655
- Display the selected graphics type
656
-
657
- :param _n: n_clicks property for each menu item used as input
658
- :param team: selected team
659
- :param team_selection_date: last time the team selection was changed
660
- :param period_date: a date that must be inside the declaration period
661
- :param data_source: Hito (non-validated declarations) or OSITAH (validated declarations)
662
- :param dropdown_label: Dropdown menu label
663
- :return: dcc.Graph
664
- """
665
-
666
- global_params = GlobalParams()
667
- columns = global_params.columns
668
-
669
- ctx = dash.callback_context
670
- if not ctx.triggered:
671
- raise PreventUpdate
672
- else:
673
- selected_item = ctx.triggered[0]["prop_id"].split(".")[0]
674
-
675
- projects_data, _ = build_projects_data(team, team_selection_date, period_date, data_source)
676
-
677
- if selected_item in [
678
- GRAPHICS_DM_NSIP_PROJECTS_TIME_ID,
679
- GRAPHICS_DM_LOCAL_PROJECTS_TIME_ID,
680
- GRAPHICS_DM_SUPPORT_ACTIVITIES_TIME_ID,
681
- ]:
682
- if selected_item == GRAPHICS_DM_NSIP_PROJECTS_TIME_ID:
683
- activity_data = projects_data.loc[projects_data[columns["category"]] == "nsip_project"]
684
- fig_title = "Temps par masterprojet et projet NSIP"
685
- y_column = columns["masterproject"]
686
- new_dropdown_label = GRAPHICS_DM_NSIP_PROJECTS_TIME_MENU
687
- elif selected_item == GRAPHICS_DM_LOCAL_PROJECTS_TIME_ID:
688
- activity_data = projects_data.loc[projects_data[columns["category"]] == "local_project"]
689
- fig_title = "Temps par projet local"
690
- y_column = "project_short"
691
- new_dropdown_label = GRAPHICS_DM_LOCAL_PROJECTS_TIME_MENU
692
- elif selected_item == GRAPHICS_DM_SUPPORT_ACTIVITIES_TIME_ID:
693
- activity_data = projects_data.loc[projects_data[columns["category"]] == "service"]
694
- fig_title = "Activités de Service & Support"
695
- y_column = "project_short"
696
- new_dropdown_label = GRAPHICS_DM_SUPPORT_ACTIVITIES_TIME_MENU
697
- else:
698
- return general_error_jumbotron(
699
- f"Erreur interne : '{selected_item}' non supporté pour un graphique en barre"
700
- )
701
-
702
- bar_num = len(activity_data[columns["project"]].unique())
703
- fig_height = "calc(100vh - 300px)"
704
-
705
- if activity_data.empty:
706
- fig = None
707
- fig_area_style = None
708
- else:
709
- fig = px.bar(
710
- activity_data,
711
- x=columns["hours"],
712
- y=y_column,
713
- color=columns["project"],
714
- orientation="h",
715
- height=200 + (30 * bar_num),
716
- title=fig_title,
717
- )
718
- fig.update_layout(
719
- showlegend=False,
720
- yaxis={"categoryorder": "category descending"},
721
- )
722
- fig_area_style = {
723
- "max-height": fig_height,
724
- "overflow-y": "scroll",
725
- "position": "relative",
726
- }
727
-
728
- elif selected_item in [
729
- GRAPHICS_DM_CATEGORY_TIME_ID,
730
- GRAPHICS_DM_TEACHING_ACTIVITIES_TIME_ID,
731
- GRAPHICS_DM_CONSULTANCY_ACTIVITIES_TIME_ID,
732
- ]:
733
- if selected_item == GRAPHICS_DM_CATEGORY_TIME_ID:
734
- activity_data = projects_data
735
- fig_title = "Temps par catégorie d'activités"
736
- y_column = columns["category"]
737
- new_dropdown_label = GRAPHICS_DM_CATEGORY_TIME_MENU
738
- elif selected_item == GRAPHICS_DM_TEACHING_ACTIVITIES_TIME_ID:
739
- activity_data = projects_data.loc[projects_data[columns["category"]] == "enseignement"]
740
- fig_title = "Activités d'enseignement"
741
- y_column = columns["project"]
742
- new_dropdown_label = GRAPHICS_DM_TEACHING_ACTIVITIES_TIME_MENU
743
- elif selected_item == GRAPHICS_DM_CONSULTANCY_ACTIVITIES_TIME_ID:
744
- activity_data = projects_data.loc[projects_data[columns["category"]] == "consultance"]
745
- fig_title = "Activités de Consultance et Expertise"
746
- y_column = columns["project"]
747
- new_dropdown_label = GRAPHICS_DM_CONSULTANCY_ACTIVITIES_TIME_MENU
748
- else:
749
- return general_error_jumbotron(
750
- f"Erreur interne : '{selected_item}' non supporté pour un graphique en barre"
751
- )
752
-
753
- fig_area_style = None
754
- fig_height = 400
755
-
756
- if activity_data.empty:
757
- fig = None
758
- else:
759
- fig = px.pie(
760
- activity_data,
761
- values=columns["hours"],
762
- names=y_column,
763
- title=fig_title,
764
- height=fig_height,
765
- )
766
-
767
- else:
768
- return (
769
- general_error_jumbotron(f"Graphics type '{selected_item}' not yet implemented"),
770
- dropdown_label,
771
- None,
772
- )
773
-
774
- if fig is None:
775
- return (
776
- dbc.Alert(f"Aucune activité correspondant à {new_dropdown_label}", color="warning"),
777
- dropdown_label,
778
- fig_area_style,
779
- )
780
- else:
781
- return (
782
- dcc.Graph("graphics-figure", figure=fig),
783
- new_dropdown_label,
784
- fig_area_style,
785
- )
1
+ # OSITAH sub-application to analyse data to NSIP
2
+ from typing import Dict
3
+
4
+ import dash
5
+ import dash_bootstrap_components as dbc
6
+ import numpy as np
7
+ import plotly.express as px
8
+ from dash import dcc, html
9
+ from dash.dependencies import Input, Output, State
10
+ from dash.exceptions import PreventUpdate
11
+
12
+ from ositah.app import app
13
+ from ositah.utils.cache import clear_cached_data
14
+ from ositah.utils.menus import (
15
+ DATA_SELECTED_SOURCE_ID,
16
+ DATA_SELECTION_SOURCE_ID,
17
+ TABLE_TYPE_DUMMY_STORE,
18
+ TABLE_TYPE_TABLE,
19
+ TEAM_SELECTED_VALUE_ID,
20
+ TEAM_SELECTION_DATE_ID,
21
+ VALIDATION_PERIOD_SELECTED_ID,
22
+ build_accordion,
23
+ create_progress_bar,
24
+ team_list_dropdown,
25
+ )
26
+ from ositah.utils.period import get_validation_period_dates
27
+ from ositah.utils.projects import (
28
+ DATA_SOURCE_HITO,
29
+ DATA_SOURCE_OSITAH,
30
+ build_projects_data,
31
+ get_team_projects,
32
+ )
33
+ from ositah.utils.utils import WEEK_HOURS, GlobalParams, general_error_jumbotron
34
+
35
+ ANALYSIS_TAB_MENU_ID = "report-tabs"
36
+ TAB_ID_ANALYSIS_GRAPHICS = "graphics-page"
37
+ TAB_ID_ANALYSIS_IJCLAB = "project-report-page"
38
+
39
+ TAB_MENU_ANALYSIS_GRAPHICS = "Graphiques"
40
+ TAB_MENU_ANALYSIS_IJCLAB = "Rapports"
41
+
42
+ TABLE_TEAM_PROJECTS_ID = "analysis-ijclab"
43
+
44
+ ANALYSIS_LOAD_INDICATOR_ID = "analysis-others-data-load-indicator"
45
+ ANALYSIS_SAVED_INDICATOR_ID = "analysis-others-saved-data-load-indicator"
46
+ ANALYSIS_TRIGGER_INTERVAL_ID = "analysis-others-display-callback-interval"
47
+ ANALYSIS_PROGRESS_BAR_MAX_DURATION = 8 # seconds
48
+ ANALYSIS_SAVED_ACTIVE_TAB_ID = "analysis-saved-active-tab"
49
+
50
+ GRAPHICS_DROPDOWN_ID = "graphics-type-selection"
51
+ GRAPHICS_DROPDOWN_MENU = "Types de graphique"
52
+ GRAPHICS_DM_CATEGORY_TIME_ID = "graphics-cateogry-time"
53
+ GRAPHICS_DM_CATEGORY_TIME_MENU = "Catégorie d'activités"
54
+ GRAPHICS_DM_LOCAL_PROJECTS_TIME_ID = "graphics-local-projects-time"
55
+ GRAPHICS_DM_LOCAL_PROJECTS_TIME_MENU = "Projets locaux"
56
+ GRAPHICS_DM_NSIP_PROJECTS_TIME_ID = "graphics-nsip-projects-time"
57
+ GRAPHICS_DM_NSIP_PROJECTS_TIME_MENU = "Projets NSIP"
58
+ GRAPHICS_DM_TEACHING_ACTIVITIES_TIME_ID = "graphics-teaching-activities-time"
59
+ GRAPHICS_DM_TEACHING_ACTIVITIES_TIME_MENU = "Enseignement"
60
+ GRAPHICS_DM_CONSULTANCY_ACTIVITIES_TIME_ID = "graphics-consultancy-activities-time"
61
+ GRAPHICS_DM_CONSULTANCY_ACTIVITIES_TIME_MENU = "Consultance & Expertise"
62
+ GRAPHICS_DM_SUPPORT_ACTIVITIES_TIME_ID = "graphics-support-activities-time"
63
+ GRAPHICS_DM_SUPPORT_ACTIVITIES_TIME_MENU = "Service & Support"
64
+ GRAPHICS_AREA_DIV_ID = "graphics-area"
65
+
66
+
67
+ def define_exported_column_names() -> Dict[str, str]:
68
+ """
69
+ Function to build the EXPORT_COLUMN_NAMES dict from colum names defined in global parameters
70
+
71
+ :return: dict
72
+ """
73
+
74
+ global_params = GlobalParams()
75
+ columns = global_params.columns
76
+
77
+ return {
78
+ columns["category"]: "Type d'activité",
79
+ columns["fullname"]: "Agent",
80
+ columns["hours"]: "Nombre d'heures",
81
+ columns["masterproject"]: "Masterprojet",
82
+ columns["team"]: "Equipe",
83
+ columns["project"]: "Projet",
84
+ }
85
+
86
+
87
+ # Maps column names from queries to displayed column names in table/CSV
88
+ EXPORT_COLUMN_NAMES = define_exported_column_names()
89
+
90
+
91
+ def ijclab_team_export_table(team, team_selection_date, period_date: str, source):
92
+ """
93
+ Build the project list contributed by the selected team and the related time declarations and
94
+ return a table.
95
+
96
+ :param team: selected team
97
+ :param team_selection_date: last time the team selection was changed
98
+ :param period_date: a date that must be inside the declaration period
99
+ :param source: whether to use Hito (non validated) or OSITAH (validated) as a data source
100
+ :return: dbc.Table
101
+ """
102
+
103
+ if team is None:
104
+ return html.Div("")
105
+
106
+ global_params = GlobalParams()
107
+ columns = global_params.columns
108
+
109
+ start_date, end_date = get_validation_period_dates(period_date)
110
+
111
+ projects_data, declaration_list = build_projects_data(
112
+ team, team_selection_date, period_date, source
113
+ )
114
+ if projects_data is None or declaration_list is None:
115
+ if source == DATA_SOURCE_HITO:
116
+ msg = f"L'équipe '{team}' ne contribue à aucun projet"
117
+ else:
118
+ msg = f"Aucune données validées n'existe pour l'équipe '{team}'"
119
+ msg += (
120
+ f" pour la période du {start_date.strftime('%Y-%m-%d')} au"
121
+ f" {end_date.strftime('%Y-%m-%d')}"
122
+ )
123
+ return html.Div([dbc.Alert(msg, color="warning"), add_source_selection_switch(source)])
124
+
125
+ table_columns = [columns["masterproject"], columns["project"], columns["hours"]]
126
+
127
+ table_header = [
128
+ html.Thead(
129
+ html.Tr(
130
+ [
131
+ *[
132
+ html.Th(
133
+ [
134
+ html.I(f"{EXPORT_COLUMN_NAMES[c]} "),
135
+ html.I(className="fas fa-sort mr-3"),
136
+ ],
137
+ className="text-center",
138
+ )
139
+ for c in table_columns
140
+ ],
141
+ ]
142
+ )
143
+ )
144
+ ]
145
+
146
+ table_body = [
147
+ html.Tbody(
148
+ [
149
+ html.Tr(
150
+ [
151
+ html.Td(
152
+ projects_data.iloc[i - 1][columns["masterproject"]],
153
+ className="align-middle",
154
+ key=f"analysis-table-cell-{i}-masterproject",
155
+ ),
156
+ html.Td(
157
+ projects_data.iloc[i - 1][columns["project"]],
158
+ className="align-middle",
159
+ key=f"analysis-table-cell-{i}-project",
160
+ ),
161
+ html.Td(
162
+ build_accordion(
163
+ i,
164
+ projects_data.iloc[i - 1][columns["hours"]],
165
+ project_agents_time(
166
+ declaration_list,
167
+ projects_data.iloc[i - 1][columns["activity"]],
168
+ ),
169
+ f"{projects_data.iloc[i-1][columns['weeks']]} semaines",
170
+ ),
171
+ className="accordion",
172
+ key=f"analysis-table-cell-{i}-time",
173
+ ),
174
+ ]
175
+ )
176
+ for i in range(1, len(projects_data) + 1)
177
+ ]
178
+ )
179
+ ]
180
+
181
+ if source == DATA_SOURCE_OSITAH:
182
+ page_title = f"Contributions par projet validées de '{team}'"
183
+ else:
184
+ page_title = f"Contributions par projet déclarées (non validées) de '{team}'"
185
+ page_title += f" du {start_date.strftime('%Y-%m-%d')} au {end_date.strftime('%Y-%m-%d')}"
186
+
187
+ return html.Div(
188
+ [
189
+ html.Div(
190
+ [
191
+ dbc.Row(
192
+ [
193
+ dbc.Col(dbc.Alert(page_title), width=8),
194
+ dbc.Col(
195
+ [
196
+ dbc.Button("Export CSV", id="ijclab-export-file-button"),
197
+ dcc.Download(id="ijclab-export-file-download"),
198
+ ],
199
+ width={"size": 2, "offset": 2},
200
+ ),
201
+ ]
202
+ ),
203
+ add_source_selection_switch(source),
204
+ ]
205
+ ),
206
+ html.P(""),
207
+ dbc.Table(
208
+ table_header + table_body,
209
+ id={"type": TABLE_TYPE_TABLE, "id": TABLE_TEAM_PROJECTS_ID},
210
+ bordered=True,
211
+ hover=True,
212
+ striped=True,
213
+ class_name="sortable",
214
+ ),
215
+ ]
216
+ )
217
+
218
+
219
+ def ijclab_graphics(team, team_selection_date, period_date: str, source):
220
+ """
221
+ Build various graphics from declarations. This function just creates the basic structure of
222
+ the graphic page and read the data. The actual graphic will be displayed by the callback
223
+ associated with the dropdown menu used to select the graphics type.
224
+
225
+ :param team: selected team
226
+ :param team_selection_date: last time the team selection was changed
227
+ :param period_date: a date that must be inside the declaration period
228
+ :param source: whether to use Hito (non validated) or OSITAH (validated) as a data source
229
+ :return: graphics and associated menus
230
+ """
231
+
232
+ if team is None:
233
+ return html.Div("")
234
+
235
+ start_date, end_date = get_validation_period_dates(period_date)
236
+
237
+ projects_data, declaration_list = build_projects_data(
238
+ team, team_selection_date, period_date, source
239
+ )
240
+ if projects_data is None or declaration_list is None:
241
+ if source == DATA_SOURCE_HITO:
242
+ msg = f"L'équipe '{team}' ne contribue à aucun projet"
243
+ else:
244
+ msg = f"Aucune données validées n'existe pour l'équipe '{team}'"
245
+ msg += (
246
+ f" pour la période du {start_date.strftime('%Y-%m-%d')} au"
247
+ f" {end_date.strftime('%Y-%m-%d')}"
248
+ )
249
+ return html.Div([dbc.Alert(msg, color="warning"), add_source_selection_switch(source)])
250
+
251
+ return html.Div(
252
+ [
253
+ dbc.Row(
254
+ [
255
+ dbc.Col(add_source_selection_switch(source), width=8),
256
+ dbc.Col(graphics_dropdown_menu(), width={"size": 3, "offset": 1}),
257
+ ]
258
+ ),
259
+ html.Div(id=GRAPHICS_AREA_DIV_ID),
260
+ ]
261
+ )
262
+
263
+
264
+ def add_source_selection_switch(current_source):
265
+ """
266
+ Add a dbc.RadioItems to select the data source.
267
+
268
+ :param current_source: currently selected source
269
+ :return: dbc.RadioItems
270
+ """
271
+
272
+ return dbc.Row(
273
+ [
274
+ dbc.RadioItems(
275
+ options=[
276
+ {"label": "Toutes les déclarations", "value": DATA_SOURCE_HITO},
277
+ {
278
+ "label": "Déclarations validées uniquement",
279
+ "value": DATA_SOURCE_OSITAH,
280
+ },
281
+ ],
282
+ value=current_source,
283
+ id=DATA_SELECTION_SOURCE_ID,
284
+ inline=True,
285
+ ),
286
+ ],
287
+ justify="center",
288
+ )
289
+
290
+
291
+ def project_agents_time(declarations, project):
292
+ """
293
+ Return a HTML Div with the list of agents who contributed to the project and their
294
+ declared time.
295
+
296
+ :param declarations: dataframe with the contribution of each agent to each project
297
+ :param project: project fullname
298
+ :return:
299
+ """
300
+
301
+ global_params = GlobalParams()
302
+ columns = global_params.columns
303
+
304
+ project_agents = declarations[declarations[columns["activity"]] == project]
305
+ project_agents.loc[:, columns["hours"]] = np.round(project_agents[columns["hours"]]).astype(
306
+ "int"
307
+ )
308
+ project_agents.loc[:, columns["weeks"]] = np.round(
309
+ project_agents.loc[:, columns["hours"]] / WEEK_HOURS, 1
310
+ )
311
+ if global_params.analysis_params["contributions_sorted_by_name"]:
312
+ sort_by = ["nom", columns["hours"]]
313
+ sort_ascending = True
314
+ else:
315
+ sort_by = [columns["hours"], "nom"]
316
+ sort_ascending = False
317
+ project_agents.sort_values(
318
+ by=sort_by, ascending=sort_ascending, ignore_index=True, inplace=True
319
+ )
320
+ return html.Div(
321
+ [
322
+ html.Div(
323
+ (
324
+ f"{project_agents.iloc[i]['fullname']}:"
325
+ f" {project_agents.iloc[i][columns['hours']]}"
326
+ f" ({project_agents.iloc[i][columns['weeks']]} sem.)"
327
+ )
328
+ )
329
+ for i in range(len(project_agents))
330
+ ]
331
+ )
332
+
333
+
334
+ def graphics_dropdown_menu():
335
+ """
336
+ Build the dropdown menu to select the graphics type
337
+
338
+ :return: dropdown menu
339
+ """
340
+
341
+ return dbc.DropdownMenu(
342
+ [
343
+ dbc.DropdownMenuItem(
344
+ GRAPHICS_DM_CATEGORY_TIME_MENU,
345
+ id=GRAPHICS_DM_CATEGORY_TIME_ID,
346
+ n_clicks=0,
347
+ ),
348
+ dbc.DropdownMenuItem(divider=True),
349
+ dbc.DropdownMenuItem(
350
+ GRAPHICS_DM_NSIP_PROJECTS_TIME_MENU,
351
+ id=GRAPHICS_DM_NSIP_PROJECTS_TIME_ID,
352
+ n_clicks=0,
353
+ ),
354
+ dbc.DropdownMenuItem(
355
+ GRAPHICS_DM_LOCAL_PROJECTS_TIME_MENU,
356
+ id=GRAPHICS_DM_LOCAL_PROJECTS_TIME_ID,
357
+ n_clicks=0,
358
+ ),
359
+ dbc.DropdownMenuItem(divider=True),
360
+ dbc.DropdownMenuItem(
361
+ GRAPHICS_DM_TEACHING_ACTIVITIES_TIME_MENU,
362
+ id=GRAPHICS_DM_TEACHING_ACTIVITIES_TIME_ID,
363
+ n_clicks=0,
364
+ ),
365
+ dbc.DropdownMenuItem(
366
+ GRAPHICS_DM_CONSULTANCY_ACTIVITIES_TIME_MENU,
367
+ id=GRAPHICS_DM_CONSULTANCY_ACTIVITIES_TIME_ID,
368
+ n_clicks=0,
369
+ ),
370
+ dbc.DropdownMenuItem(
371
+ GRAPHICS_DM_SUPPORT_ACTIVITIES_TIME_MENU,
372
+ id=GRAPHICS_DM_SUPPORT_ACTIVITIES_TIME_ID,
373
+ n_clicks=0,
374
+ ),
375
+ ],
376
+ id=GRAPHICS_DROPDOWN_ID,
377
+ label=GRAPHICS_DROPDOWN_MENU,
378
+ )
379
+
380
+
381
+ def analysis_submenus():
382
+ """
383
+ Build the tabs menus of the export subapplication
384
+
385
+ :return: DBC Tabs
386
+ """
387
+
388
+ return dbc.Tabs(
389
+ [
390
+ dbc.Tab(
391
+ id=TAB_ID_ANALYSIS_IJCLAB,
392
+ tab_id=TAB_ID_ANALYSIS_IJCLAB,
393
+ label=TAB_MENU_ANALYSIS_IJCLAB,
394
+ ),
395
+ dbc.Tab(
396
+ id=TAB_ID_ANALYSIS_GRAPHICS,
397
+ tab_id=TAB_ID_ANALYSIS_GRAPHICS,
398
+ label=TAB_MENU_ANALYSIS_GRAPHICS,
399
+ ),
400
+ ],
401
+ id=ANALYSIS_TAB_MENU_ID,
402
+ )
403
+
404
+
405
+ def analysis_layout():
406
+ """
407
+ Build the layout for this application, after reading the data if necessary.
408
+
409
+ :return: application layout
410
+ """
411
+
412
+ return html.Div(
413
+ [
414
+ html.H1("Analyse des déclarations"),
415
+ team_list_dropdown(),
416
+ # The following dcc.Store is used to ensure that the the ijclab_export input exists
417
+ # before the export page is created
418
+ dcc.Store(id=DATA_SELECTED_SOURCE_ID, data=DATA_SOURCE_HITO),
419
+ html.Div(analysis_submenus(), id="analysis-submenus", style={"marginTop": "3em"}),
420
+ dcc.Store(id=ANALYSIS_LOAD_INDICATOR_ID, data=0),
421
+ dcc.Store(id=ANALYSIS_SAVED_INDICATOR_ID, data=0),
422
+ dcc.Store(id=ANALYSIS_SAVED_ACTIVE_TAB_ID, data=""),
423
+ dcc.Store(
424
+ id={"type": TABLE_TYPE_DUMMY_STORE, "id": TABLE_TEAM_PROJECTS_ID},
425
+ data=0,
426
+ ),
427
+ ]
428
+ )
429
+
430
+
431
+ @app.callback(
432
+ Output(DATA_SELECTED_SOURCE_ID, "data"),
433
+ Input(DATA_SELECTION_SOURCE_ID, "value"),
434
+ State(DATA_SELECTED_SOURCE_ID, "data"),
435
+ prevent_initial_call=True,
436
+ )
437
+ def select_data_source(new_source, previous_source):
438
+ """
439
+ This callback is used to forward to the export callback the selected source through a
440
+ dcc.Store that exists before the page is created. It also clears the data cache if
441
+ the source has been changed.
442
+
443
+ :param new_source: value to forward to the dcc.Store
444
+ :param previous_source: previous value of the selection
445
+ :return: new_source value
446
+ """
447
+
448
+ if new_source != previous_source:
449
+ clear_cached_data()
450
+
451
+ return new_source
452
+
453
+
454
+ @app.callback(
455
+ [
456
+ Output(TAB_ID_ANALYSIS_IJCLAB, "children"),
457
+ Output(TAB_ID_ANALYSIS_GRAPHICS, "children"),
458
+ Output(ANALYSIS_SAVED_INDICATOR_ID, "data"),
459
+ Output(ANALYSIS_SAVED_ACTIVE_TAB_ID, "data"),
460
+ ],
461
+ [
462
+ Input(ANALYSIS_LOAD_INDICATOR_ID, "data"),
463
+ Input(ANALYSIS_TAB_MENU_ID, "active_tab"),
464
+ Input(TEAM_SELECTED_VALUE_ID, "data"),
465
+ Input(DATA_SELECTED_SOURCE_ID, "data"),
466
+ ],
467
+ [
468
+ State(TEAM_SELECTION_DATE_ID, "data"),
469
+ State(ANALYSIS_SAVED_INDICATOR_ID, "data"),
470
+ State(VALIDATION_PERIOD_SELECTED_ID, "data"),
471
+ State(ANALYSIS_SAVED_ACTIVE_TAB_ID, "data"),
472
+ ],
473
+ prevent_initial_call=True,
474
+ )
475
+ def display_analysis_tables(
476
+ load_in_progress,
477
+ active_tab,
478
+ team,
479
+ data_source,
480
+ team_selection_date,
481
+ previous_load_in_progress,
482
+ period_date: str,
483
+ previous_active_tab,
484
+ ):
485
+ """
486
+ Display active tab contents after a team or an active tab change. Exact action depends on the
487
+ value of the load in progress indicator. If it is equal to the previous value, it means this
488
+ is the start of the update process: progress bar is displayed and a dcc.Interval is created
489
+ to schedule again this callback after incrementing the load in progress indicator. This causes
490
+ the callback to be reentered and this time it triggers the real processing for the tab
491
+ resulting in the final update of the active tab contents. An empty content is returned for
492
+ inactive tabs.
493
+
494
+ :param load_in_progress: load in progress indicator
495
+ :param tab: tab name
496
+ :param team: selected team
497
+ :param data_source: Hito (non-validated declarations) or OSITAH (validated declarations)
498
+ :param team_selection_date: last time the team selection was changed
499
+ :param previous_load_in_progress: previous value of the load_in_progress indicator
500
+ :param period_date: a date that must be inside the declaration period
501
+ :param previous_active_tab: previously active tab
502
+ :return: tab content
503
+ """
504
+
505
+ tab_contents = []
506
+
507
+ # Be sure to fill the return values in the same order as Output are declared
508
+ tab_list = [TAB_ID_ANALYSIS_IJCLAB, TAB_ID_ANALYSIS_GRAPHICS]
509
+ for tab in tab_list:
510
+ if team and len(team) > 0 and tab == active_tab:
511
+ if load_in_progress > previous_load_in_progress and active_tab == previous_active_tab:
512
+ if tab == TAB_ID_ANALYSIS_IJCLAB:
513
+ tab_contents.append(
514
+ ijclab_team_export_table(
515
+ team, team_selection_date, period_date, data_source
516
+ )
517
+ )
518
+ elif tab == TAB_ID_ANALYSIS_GRAPHICS:
519
+ tab_contents.append(
520
+ ijclab_graphics(team, team_selection_date, period_date, data_source)
521
+ )
522
+ else:
523
+ tab_contents.append(
524
+ dbc.Alert("Erreur interne: tab non supporté"), color="warning"
525
+ )
526
+ previous_load_in_progress += 1
527
+ else:
528
+ component = html.Div(
529
+ [
530
+ create_progress_bar(team, duration=ANALYSIS_PROGRESS_BAR_MAX_DURATION),
531
+ dcc.Interval(
532
+ id=ANALYSIS_TRIGGER_INTERVAL_ID,
533
+ n_intervals=0,
534
+ max_intervals=1,
535
+ interval=500,
536
+ ),
537
+ ]
538
+ )
539
+ tab_contents.append(component)
540
+ else:
541
+ tab_contents.append("")
542
+
543
+ tab_contents.extend([previous_load_in_progress, active_tab])
544
+
545
+ return tab_contents
546
+
547
+
548
+ @app.callback(
549
+ Output(ANALYSIS_LOAD_INDICATOR_ID, "data"),
550
+ Input(ANALYSIS_TRIGGER_INTERVAL_ID, "n_intervals"),
551
+ State(ANALYSIS_SAVED_INDICATOR_ID, "data"),
552
+ prevent_initial_call=True,
553
+ )
554
+ def display_tables_trigger(n, previous_load_indicator):
555
+ """
556
+ Increment (change) of the input of display_tables_trigger callback to get it fired a
557
+ second time after displaying the progress bar. The output component must be updated each
558
+ time the callback is entered to trigger the execution of the other callback, thus the
559
+ choice of incrementing it at each call.
560
+
561
+ :param n: n_interval property of the dcc.Interval (0 or 1)
562
+ :return: 1 increment to previous value
563
+ """
564
+
565
+ return previous_load_indicator + 1
566
+
567
+
568
+ @app.callback(
569
+ Output("ijclab-export-file-download", "data"),
570
+ Input("ijclab-export-file-button", "n_clicks"),
571
+ [
572
+ State(TEAM_SELECTED_VALUE_ID, "data"),
573
+ State(TEAM_SELECTION_DATE_ID, "data"),
574
+ State(DATA_SELECTED_SOURCE_ID, "data"),
575
+ State(VALIDATION_PERIOD_SELECTED_ID, "data"),
576
+ ],
577
+ prevent_initial_call=True,
578
+ )
579
+ def ijclab_export_to_csv(_, team, team_selection_date, source, period_date):
580
+ """
581
+ Generate a CSV file for the selected team, using the appropriate data source.
582
+
583
+ :param _: unused, just an input to trigger the callback
584
+ :param team: selected team
585
+ :param team_selection_date: timestamp of the last change in team selection
586
+ :param period_date: a date that must be inside the declaration period
587
+ :return: None
588
+ """
589
+
590
+ global_params = GlobalParams()
591
+ columns = global_params.columns
592
+
593
+ declaration_list = get_team_projects(team, team_selection_date, period_date, source)
594
+ if declaration_list is None:
595
+ return dbc.Alert(
596
+ f"L'équipe '{team}' ne contribue à aucun projet actuellement",
597
+ color="warning",
598
+ )
599
+
600
+ exported_data = declaration_list[
601
+ [
602
+ columns["masterproject"],
603
+ columns["project"],
604
+ columns["category"],
605
+ columns["fullname"],
606
+ columns["team"],
607
+ columns["hours"],
608
+ ]
609
+ ]
610
+ exported_data.loc[:, columns["hours"]] = np.round(exported_data[columns["hours"]]).astype("int")
611
+ column_renames = {}
612
+ for c in exported_data.columns.tolist():
613
+ if c in EXPORT_COLUMN_NAMES:
614
+ column_renames[c] = EXPORT_COLUMN_NAMES[c]
615
+ if len(column_renames.keys()) > 0:
616
+ exported_data.rename(columns=column_renames, inplace=True)
617
+
618
+ return dict(
619
+ content=exported_data.to_csv(index=False, sep=";"),
620
+ filename="project_contributions.csv",
621
+ )
622
+
623
+
624
+ @app.callback(
625
+ Output(GRAPHICS_AREA_DIV_ID, "children"),
626
+ Output(GRAPHICS_DROPDOWN_ID, "label"),
627
+ Output(GRAPHICS_AREA_DIV_ID, "style"),
628
+ Input(GRAPHICS_DM_CATEGORY_TIME_ID, "n_clicks"),
629
+ Input(GRAPHICS_DM_NSIP_PROJECTS_TIME_ID, "n_clicks"),
630
+ Input(GRAPHICS_DM_LOCAL_PROJECTS_TIME_ID, "n_clicks"),
631
+ Input(GRAPHICS_DM_TEACHING_ACTIVITIES_TIME_ID, "n_clicks"),
632
+ Input(GRAPHICS_DM_CONSULTANCY_ACTIVITIES_TIME_ID, "n_clicks"),
633
+ Input(GRAPHICS_DM_SUPPORT_ACTIVITIES_TIME_ID, "n_clicks"),
634
+ State(TEAM_SELECTED_VALUE_ID, "data"),
635
+ State(TEAM_SELECTION_DATE_ID, "data"),
636
+ State(VALIDATION_PERIOD_SELECTED_ID, "data"),
637
+ State(DATA_SELECTED_SOURCE_ID, "data"),
638
+ State(GRAPHICS_DROPDOWN_ID, "label"),
639
+ prevent_initial_call=True,
640
+ )
641
+ def display_graphics(
642
+ _1,
643
+ _2,
644
+ _3,
645
+ _4,
646
+ _5,
647
+ _6,
648
+ team,
649
+ team_selection_date,
650
+ period_date,
651
+ data_source,
652
+ dropdown_label,
653
+ ):
654
+ """
655
+ Display the selected graphics type
656
+
657
+ :param _n: n_clicks property for each menu item used as input
658
+ :param team: selected team
659
+ :param team_selection_date: last time the team selection was changed
660
+ :param period_date: a date that must be inside the declaration period
661
+ :param data_source: Hito (non-validated declarations) or OSITAH (validated declarations)
662
+ :param dropdown_label: Dropdown menu label
663
+ :return: dcc.Graph
664
+ """
665
+
666
+ global_params = GlobalParams()
667
+ columns = global_params.columns
668
+
669
+ ctx = dash.callback_context
670
+ if not ctx.triggered:
671
+ raise PreventUpdate
672
+ else:
673
+ selected_item = ctx.triggered[0]["prop_id"].split(".")[0]
674
+
675
+ projects_data, _ = build_projects_data(team, team_selection_date, period_date, data_source)
676
+
677
+ if selected_item in [
678
+ GRAPHICS_DM_NSIP_PROJECTS_TIME_ID,
679
+ GRAPHICS_DM_LOCAL_PROJECTS_TIME_ID,
680
+ GRAPHICS_DM_SUPPORT_ACTIVITIES_TIME_ID,
681
+ ]:
682
+ if selected_item == GRAPHICS_DM_NSIP_PROJECTS_TIME_ID:
683
+ activity_data = projects_data.loc[projects_data[columns["category"]] == "nsip_project"]
684
+ fig_title = "Temps par masterprojet et projet NSIP"
685
+ y_column = columns["masterproject"]
686
+ new_dropdown_label = GRAPHICS_DM_NSIP_PROJECTS_TIME_MENU
687
+ elif selected_item == GRAPHICS_DM_LOCAL_PROJECTS_TIME_ID:
688
+ activity_data = projects_data.loc[projects_data[columns["category"]] == "local_project"]
689
+ fig_title = "Temps par projet local"
690
+ y_column = "project_short"
691
+ new_dropdown_label = GRAPHICS_DM_LOCAL_PROJECTS_TIME_MENU
692
+ elif selected_item == GRAPHICS_DM_SUPPORT_ACTIVITIES_TIME_ID:
693
+ activity_data = projects_data.loc[projects_data[columns["category"]] == "service"]
694
+ fig_title = "Activités de Service & Support"
695
+ y_column = "project_short"
696
+ new_dropdown_label = GRAPHICS_DM_SUPPORT_ACTIVITIES_TIME_MENU
697
+ else:
698
+ return general_error_jumbotron(
699
+ f"Erreur interne : '{selected_item}' non supporté pour un graphique en barre"
700
+ )
701
+
702
+ bar_num = len(activity_data[columns["project"]].unique())
703
+ fig_height = "calc(100vh - 300px)"
704
+
705
+ if activity_data.empty:
706
+ fig = None
707
+ fig_area_style = None
708
+ else:
709
+ fig = px.bar(
710
+ activity_data,
711
+ x=columns["hours"],
712
+ y=y_column,
713
+ color=columns["project"],
714
+ orientation="h",
715
+ height=200 + (30 * bar_num),
716
+ title=fig_title,
717
+ )
718
+ fig.update_layout(
719
+ showlegend=False,
720
+ yaxis={"categoryorder": "category descending"},
721
+ )
722
+ fig_area_style = {
723
+ "max-height": fig_height,
724
+ "overflow-y": "scroll",
725
+ "position": "relative",
726
+ }
727
+
728
+ elif selected_item in [
729
+ GRAPHICS_DM_CATEGORY_TIME_ID,
730
+ GRAPHICS_DM_TEACHING_ACTIVITIES_TIME_ID,
731
+ GRAPHICS_DM_CONSULTANCY_ACTIVITIES_TIME_ID,
732
+ ]:
733
+ if selected_item == GRAPHICS_DM_CATEGORY_TIME_ID:
734
+ activity_data = projects_data
735
+ fig_title = "Temps par catégorie d'activités"
736
+ y_column = columns["category"]
737
+ new_dropdown_label = GRAPHICS_DM_CATEGORY_TIME_MENU
738
+ elif selected_item == GRAPHICS_DM_TEACHING_ACTIVITIES_TIME_ID:
739
+ activity_data = projects_data.loc[projects_data[columns["category"]] == "enseignement"]
740
+ fig_title = "Activités d'enseignement"
741
+ y_column = columns["project"]
742
+ new_dropdown_label = GRAPHICS_DM_TEACHING_ACTIVITIES_TIME_MENU
743
+ elif selected_item == GRAPHICS_DM_CONSULTANCY_ACTIVITIES_TIME_ID:
744
+ activity_data = projects_data.loc[projects_data[columns["category"]] == "consultance"]
745
+ fig_title = "Activités de Consultance et Expertise"
746
+ y_column = columns["project"]
747
+ new_dropdown_label = GRAPHICS_DM_CONSULTANCY_ACTIVITIES_TIME_MENU
748
+ else:
749
+ return general_error_jumbotron(
750
+ f"Erreur interne : '{selected_item}' non supporté pour un graphique en barre"
751
+ )
752
+
753
+ fig_area_style = None
754
+ fig_height = 400
755
+
756
+ if activity_data.empty:
757
+ fig = None
758
+ else:
759
+ fig = px.pie(
760
+ activity_data,
761
+ values=columns["hours"],
762
+ names=y_column,
763
+ title=fig_title,
764
+ height=fig_height,
765
+ )
766
+
767
+ else:
768
+ return (
769
+ general_error_jumbotron(f"Graphics type '{selected_item}' not yet implemented"),
770
+ dropdown_label,
771
+ None,
772
+ )
773
+
774
+ if fig is None:
775
+ return (
776
+ dbc.Alert(f"Aucune activité correspondant à {new_dropdown_label}", color="warning"),
777
+ dropdown_label,
778
+ fig_area_style,
779
+ )
780
+ else:
781
+ return (
782
+ dcc.Graph("graphics-figure", figure=fig),
783
+ new_dropdown_label,
784
+ fig_area_style,
785
+ )