ositah 25.6.dev1__py3-none-any.whl → 25.9.dev1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


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

Files changed (42) hide show
  1. ositah/app.py +17 -17
  2. ositah/apps/analysis.py +785 -785
  3. ositah/apps/configuration/callbacks.py +916 -916
  4. ositah/apps/configuration/main.py +546 -546
  5. ositah/apps/configuration/parameters.py +74 -74
  6. ositah/apps/configuration/tools.py +112 -112
  7. ositah/apps/export.py +1208 -1191
  8. ositah/apps/validation/callbacks.py +240 -240
  9. ositah/apps/validation/main.py +89 -89
  10. ositah/apps/validation/parameters.py +25 -25
  11. ositah/apps/validation/tables.py +646 -646
  12. ositah/apps/validation/tools.py +552 -552
  13. ositah/assets/arrow_down_up.svg +3 -3
  14. ositah/assets/ositah.css +53 -53
  15. ositah/assets/sort_ascending.svg +4 -4
  16. ositah/assets/sort_descending.svg +5 -5
  17. ositah/assets/sorttable.js +499 -499
  18. ositah/main.py +449 -449
  19. ositah/ositah.example.cfg +229 -229
  20. ositah/static/style.css +53 -53
  21. ositah/templates/base.html +22 -22
  22. ositah/templates/bootstrap_login.html +38 -38
  23. ositah/templates/login_form.html +26 -26
  24. ositah/utils/agents.py +124 -124
  25. ositah/utils/authentication.py +287 -287
  26. ositah/utils/cache.py +19 -19
  27. ositah/utils/core.py +13 -13
  28. ositah/utils/exceptions.py +64 -64
  29. ositah/utils/hito_db.py +51 -51
  30. ositah/utils/hito_db_model.py +253 -253
  31. ositah/utils/menus.py +339 -339
  32. ositah/utils/period.py +139 -139
  33. ositah/utils/projects.py +1178 -1178
  34. ositah/utils/teams.py +42 -42
  35. ositah/utils/utils.py +474 -474
  36. {ositah-25.6.dev1.dist-info → ositah-25.9.dev1.dist-info}/METADATA +149 -150
  37. ositah-25.9.dev1.dist-info/RECORD +46 -0
  38. {ositah-25.6.dev1.dist-info → ositah-25.9.dev1.dist-info}/licenses/LICENSE +29 -29
  39. ositah-25.6.dev1.dist-info/RECORD +0 -46
  40. {ositah-25.6.dev1.dist-info → ositah-25.9.dev1.dist-info}/WHEEL +0 -0
  41. {ositah-25.6.dev1.dist-info → ositah-25.9.dev1.dist-info}/entry_points.txt +0 -0
  42. {ositah-25.6.dev1.dist-info → ositah-25.9.dev1.dist-info}/top_level.txt +0 -0
@@ -1,646 +1,646 @@
1
- """
2
- Functions to build tables associated with tab layouts
3
- """
4
-
5
- import dash_bootstrap_components as dbc
6
- import numpy as np
7
- import pandas as pd
8
- from dash import dcc, html
9
- from flask import session
10
-
11
- from ositah.apps.validation.parameters import (
12
- TABLE_COLUMN_VALIDATION,
13
- TABLE_ID_DECLARATION_STATS,
14
- TABLE_ID_MISSING_AGENTS,
15
- TABLE_ID_VALIDATION,
16
- VALIDATION_DECLARATIONS_SELECT_ALL,
17
- VALIDATION_DECLARATIONS_SELECT_NOT_VALIDATED,
18
- VALIDATION_DECLARATIONS_SELECT_VALIDATED,
19
- )
20
- from ositah.apps.validation.tools import (
21
- activity_time_cell,
22
- add_validation_declaration_selection_switch,
23
- agent_project_time,
24
- agent_tooltip_txt,
25
- category_declarations,
26
- define_declaration_thresholds,
27
- get_all_validation_status,
28
- validation_started,
29
- )
30
- from ositah.utils.agents import get_agents
31
- from ositah.utils.exceptions import InvalidHitoProjectName, SessionDataMissing
32
- from ositah.utils.menus import TABLE_TYPE_TABLE, build_accordion, ositah_jumbotron
33
- from ositah.utils.projects import (
34
- CATEGORY_DEFAULT,
35
- DATA_SOURCE_HITO,
36
- DATA_SOURCE_OSITAH,
37
- get_team_projects,
38
- time_unit,
39
- )
40
- from ositah.utils.utils import (
41
- SEMESTER_WEEKS,
42
- TEAM_LIST_ALL_AGENTS,
43
- GlobalParams,
44
- general_error_jumbotron,
45
- no_session_id_jumbotron,
46
- )
47
-
48
-
49
- def build_validation_table(team, team_selection_date, declaration_set: int, period_date: str):
50
- """
51
- Build the agent list of the selected team with their declarations. Returns a table.
52
-
53
- :param team: selected team
54
- :param team_selection_date: last time the team selection was changed
55
- :param declaration_set: selected declaration set (all, validated or non-validated ones)
56
- :param period_date: a date that must be inside the declaration period
57
- :return: DBC table
58
- """
59
- global_params = GlobalParams()
60
- column_names = global_params.columns
61
- try:
62
- session_data = global_params.session_data
63
- except SessionDataMissing:
64
- return no_session_id_jumbotron()
65
-
66
- if session["user_email"] in global_params.roles["read-only"]:
67
- validation_disabled = True
68
- else:
69
- if session_data.role in global_params.validation_params["override_period"]:
70
- validation_disabled = False
71
- else:
72
- validation_disabled = not validation_started(period_date)
73
-
74
- validation_data = get_all_validation_status(period_date)
75
- define_declaration_thresholds(period_date)
76
-
77
- try:
78
- project_declarations = get_team_projects(
79
- team, team_selection_date, period_date, DATA_SOURCE_HITO
80
- )
81
- except InvalidHitoProjectName as e:
82
- return ositah_jumbotron(
83
- "Error loading projects",
84
- e.msg,
85
- title_class="text-danger",
86
- )
87
-
88
- if project_declarations is None:
89
- return html.Div(
90
- [
91
- dbc.Alert(
92
- f"Aucune déclaration effectuée pour l'équipe '{team}'",
93
- color="warning",
94
- ),
95
- ]
96
- )
97
- declarations = category_declarations(project_declarations)
98
-
99
- if team == TEAM_LIST_ALL_AGENTS:
100
- declaration_list = declarations
101
- else:
102
- declaration_list = declarations[declarations[column_names["team"]].str.match(team)]
103
- declaration_list["validation_disabled"] = validation_disabled
104
-
105
- data_columns = [CATEGORY_DEFAULT]
106
- data_columns.extend(global_params.project_categories.keys())
107
-
108
- try:
109
- validated_project_declarations = get_team_projects(
110
- team, team_selection_date, period_date, DATA_SOURCE_OSITAH, use_cache=False
111
- )
112
- except InvalidHitoProjectName as e:
113
- return ositah_jumbotron(
114
- "Error loading projects",
115
- e.msg,
116
- title_class="text-danger",
117
- )
118
-
119
- if validated_project_declarations is None:
120
- declaration_list["validated"] = False
121
- declaration_list["hito_missing"] = False
122
- validated_declarations_num = 0
123
- hito_missing_num = 0
124
- hito_missing_msg = ""
125
- else:
126
- validated_declarations_num = len(validated_project_declarations)
127
- validated_declarations = category_declarations(
128
- validated_project_declarations, use_cache=False
129
- )
130
- # Do an outer merge to detect validated declarations removed from Hito
131
- declaration_list = declaration_list.merge(
132
- validated_declarations,
133
- how="outer",
134
- on=column_names["agent_id"],
135
- indicator=True,
136
- suffixes=[None, "_val"],
137
- )
138
- declaration_list["validated"] = (declaration_list._merge == "both") | (
139
- declaration_list._merge == "right_only"
140
- )
141
- declaration_list["hito_missing"] = declaration_list._merge == "right_only"
142
- hito_missing_num = len(declaration_list.loc[declaration_list["hito_missing"]])
143
- if hito_missing_num > 0:
144
- columns_fillna = {c: 0 for c in [*data_columns, "total_hours", "percent_global"]}
145
- declaration_list.loc[declaration_list["hito_missing"]] = declaration_list.fillna(
146
- value=columns_fillna
147
- )
148
- declaration_list.loc[declaration_list["hito_missing"], column_names["fullname"]] = (
149
- declaration_list[f"{column_names['fullname']}_val"]
150
- )
151
- declaration_list.loc[declaration_list["hito_missing"], column_names["team"]] = (
152
- declaration_list[f"{column_names['team']}_val"]
153
- )
154
- declaration_list.loc[declaration_list["hito_missing"], "suspect"] = True
155
- declaration_list.loc[declaration_list["hito_missing"], "validation_disabled"] = True
156
- declaration_list.sort_values(by=column_names["fullname"], inplace=True)
157
- hito_missing_msg = (
158
- f" dont {hito_missing_num} supprimé"
159
- f"{'s' if hito_missing_num > 1 else ''} de Hito"
160
- )
161
- else:
162
- hito_missing_msg = ""
163
- for category in data_columns:
164
- time_ok_column = f"{category}_time_ok"
165
- declaration_list.loc[declaration_list.validated, time_ok_column] = np.isclose(
166
- declaration_list.loc[declaration_list.validated, category],
167
- declaration_list.loc[declaration_list.validated, f"{category}_val"],
168
- rtol=1e-5,
169
- atol=0,
170
- )
171
- validated_number = len(declaration_list[declaration_list["validated"]])
172
-
173
- if declaration_set == VALIDATION_DECLARATIONS_SELECT_ALL:
174
- selected_declarations = declaration_list
175
- elif declaration_set == VALIDATION_DECLARATIONS_SELECT_VALIDATED:
176
- selected_declarations = declaration_list[declaration_list["validated"]]
177
- elif declaration_set == VALIDATION_DECLARATIONS_SELECT_NOT_VALIDATED:
178
- selected_declarations = declaration_list[~declaration_list["validated"]]
179
- else:
180
- return general_error_jumbotron(f"Invalid declaration set ID ({declaration_set})")
181
- selected_declarations.sort_values(by=["fullname"], ignore_index=True, inplace=True)
182
-
183
- columns = [*data_columns]
184
- columns.insert(0, column_names["fullname"])
185
- columns.append("percent_global")
186
- rows_number = len(selected_declarations)
187
-
188
- table_header = [
189
- html.Thead(
190
- html.Tr(
191
- [
192
- *[
193
- html.Th(
194
- [
195
- html.Div(
196
- [
197
- html.I(f"{global_params.column_titles[c]} "),
198
- ]
199
- ),
200
- html.Div(time_unit(c, english=False, parenthesis=True)),
201
- ],
202
- className="text-center",
203
- )
204
- for c in columns
205
- ],
206
- html.Th(TABLE_COLUMN_VALIDATION),
207
- ],
208
- )
209
- )
210
- ]
211
-
212
- table_body = [
213
- html.Tbody(
214
- [
215
- html.Tr(
216
- [
217
- html.Td(
218
- build_accordion(
219
- i,
220
- selected_declarations.iloc[i - 1][column_names["fullname"]],
221
- agent_project_time(
222
- selected_declarations.iloc[i - 1][column_names["fullname"]]
223
- ),
224
- agent_tooltip_txt(selected_declarations.iloc[i - 1], data_columns),
225
- (
226
- "validated_hito_missing"
227
- if selected_declarations.iloc[i - 1]["hito_missing"]
228
- else ""
229
- ),
230
- ),
231
- className="accordion",
232
- key=f"validation-table-cell-{i}-fullname",
233
- ),
234
- *[
235
- activity_time_cell(selected_declarations.iloc[i - 1], c, i)
236
- for c in data_columns
237
- ],
238
- activity_time_cell(selected_declarations.iloc[i - 1], "percent_global", i),
239
- html.Td(
240
- [
241
- dbc.Checklist(
242
- options=[
243
- {
244
- "label": "",
245
- "value": 1,
246
- "disabled": selected_declarations.iloc[i - 1][
247
- "validation_disabled"
248
- ],
249
- }
250
- ],
251
- value=[
252
- int(
253
- selected_declarations.iloc[i - 1][
254
- column_names["agent_id"]
255
- ]
256
- in validation_data.index
257
- )
258
- ],
259
- id={"type": "validation-switch", "id": i},
260
- key=f"validation-switch-{i}",
261
- switch=True,
262
- ),
263
- # The dcc.Store is created to ease validation callback management
264
- # by passing the agent ID and providing an Output object (but
265
- # nothing will be written in it).
266
- dcc.Store(
267
- id={"type": "validation-agent-id", "id": i},
268
- data=selected_declarations.iloc[i - 1][
269
- column_names["agent_id"]
270
- ],
271
- ),
272
- ],
273
- className="align-middle",
274
- key=f"validation-table-cell-{i}-switch",
275
- ),
276
- ],
277
- key=f"validation-table-row-{i}",
278
- )
279
- for i in range(1, rows_number + 1)
280
- ]
281
- )
282
- ]
283
-
284
- return html.Div(
285
- [
286
- dbc.Alert(
287
- [
288
- html.Div(
289
- html.B(
290
- (
291
- f"Nombre d'agents de l'équipe '{team}' ayant déclaré :"
292
- f" {len(selected_declarations)} (agents validés={validated_number}"
293
- f"{hito_missing_msg}"
294
- f", déclarations validées/totales="
295
- f"{validated_declarations_num}/{len(project_declarations)})"
296
- ),
297
- className="agent_count",
298
- )
299
- ),
300
- html.Div(
301
- html.Em(
302
- (
303
- f"Temps déclarés de l'équipe / total ="
304
- f" {round(selected_declarations['percent_global'].mean(), 1)}%"
305
- f" (100%={SEMESTER_WEEKS} semaines)"
306
- )
307
- )
308
- ),
309
- ]
310
- ),
311
- html.P(),
312
- add_validation_declaration_selection_switch(declaration_set),
313
- dbc.Table(
314
- table_header + table_body,
315
- id={"type": TABLE_TYPE_TABLE, "id": TABLE_ID_VALIDATION},
316
- bordered=True,
317
- hover=True,
318
- striped=True,
319
- class_name="sortable",
320
- ),
321
- ]
322
- )
323
-
324
-
325
- def build_missing_agents_table(team, team_selection_date, period_date: str):
326
- """
327
- Function to build a table listing all agents that have not declared yet their time on projects
328
-
329
- :param team: selected team
330
- :param team_selection_date: last time the team selection was changed
331
- :param period_date: a date that must be inside the declaration period
332
- :return: missing agent page
333
- """
334
- global_params = GlobalParams()
335
- column_names = global_params.columns
336
- declaration_options = global_params.declaration_options
337
-
338
- try:
339
- declarations = get_team_projects(team, team_selection_date, period_date)
340
- except InvalidHitoProjectName as e:
341
- return ositah_jumbotron(
342
- "Error loading projects",
343
- e.msg,
344
- title_class="text-danger",
345
- )
346
-
347
- agent_list = get_agents(period_date, team)
348
-
349
- if declarations is None:
350
- missing_agents = agent_list
351
- else:
352
- missing_agents = build_missing_agents(declarations, agent_list)
353
-
354
- rows_number = len(missing_agents)
355
- table_columns = ["fullname", "team"]
356
-
357
- table_header = [
358
- html.Thead(
359
- html.Tr(
360
- [
361
- html.Th(
362
- [
363
- html.I(f"{global_params.column_titles[c]} "),
364
- ]
365
- )
366
- for c in table_columns
367
- ],
368
- )
369
- )
370
- ]
371
-
372
- table_body = [
373
- html.Tbody(
374
- [
375
- html.Tr(
376
- [
377
- html.Td(
378
- missing_agents.iloc[i][column_names[c]],
379
- className="align-middle",
380
- )
381
- for c in table_columns
382
- ]
383
- )
384
- for i in range(rows_number)
385
- ]
386
- )
387
- ]
388
-
389
- return html.Div(
390
- [
391
- dbc.Alert(
392
- [
393
- html.Div(
394
- html.B(
395
- (
396
- f"Nombre d'agents de l'équipe '{team}' sans déclaration :"
397
- f" {len(missing_agents)}"
398
- )
399
- )
400
- ),
401
- html.Div(
402
- html.Em(
403
- (
404
- f"Statuts non inclus :"
405
- f" {', '.join(declaration_options['optional_statutes'])}"
406
- )
407
- )
408
- ),
409
- html.Div(
410
- html.Em(
411
- (
412
- f"Equipes non incluses :"
413
- f" {', '.join(declaration_options['optional_teams'])}"
414
- )
415
- )
416
- ),
417
- ],
418
- class_name="agent_count",
419
- ),
420
- html.P(),
421
- dbc.Table(
422
- table_header + table_body,
423
- id={"type": TABLE_TYPE_TABLE, "id": TABLE_ID_MISSING_AGENTS},
424
- bordered=True,
425
- hover=True,
426
- striped=True,
427
- class_name="sortable",
428
- ),
429
- ]
430
- )
431
-
432
-
433
- def build_statistics_table(team, team_selection_date, period_date: str):
434
- """
435
- Function to build a table listing the number of declarations and missing declarations per team
436
-
437
- :param team: selected team
438
- :param team_selection_date: last time the team selection was changed
439
- :param period_date: a date that must be inside the declaration period
440
- :return: missing agent page
441
- """
442
- global_params = GlobalParams()
443
- column_names = global_params.columns
444
- declaration_options = global_params.declaration_options
445
-
446
- try:
447
- session_data = global_params.session_data
448
- except SessionDataMissing:
449
- return no_session_id_jumbotron()
450
-
451
- try:
452
- declarations = get_team_projects(team, team_selection_date, period_date)
453
- except InvalidHitoProjectName as e:
454
- return ositah_jumbotron(
455
- "Error loading projects",
456
- e.msg,
457
- title_class="text-danger",
458
- )
459
-
460
- agent_list = get_agents(period_date, team)
461
-
462
- add_no_team_row = False
463
- if declarations is None:
464
- team_declarations = pd.DataFrame({"declarations_number": 0}, index=[team])
465
- team_declarations["missings_number"] = len(agent_list)
466
- missing_declarations = pd.DataFrame()
467
- else:
468
- team_agent_declarations = declarations.drop_duplicates(
469
- subset=[column_names["team"], column_names["fullname"]]
470
- )
471
- # If team is None, set it to empty string
472
- if len(team_agent_declarations.loc[team_agent_declarations.team.isna(), "team"]) > 0:
473
- team_agent_declarations.loc[team_agent_declarations.team.isna(), "team"] = ""
474
- add_no_team_row = True
475
- team_declarations = (
476
- team_agent_declarations[column_names["team"]]
477
- .value_counts()
478
- .to_frame(name="declarations_number")
479
- )
480
-
481
- missing_agents = build_missing_agents(declarations, agent_list)
482
- missing_declarations = (
483
- missing_agents[column_names["team"]].value_counts().to_frame(name="missings_number")
484
- )
485
- # If team is None, set it to empty string
486
- if len(missing_declarations.loc[missing_declarations.index == ""]) > 0:
487
- add_no_team_row = True
488
-
489
- team_list = pd.DataFrame(index=session_data.agent_teams)
490
- if team == TEAM_LIST_ALL_AGENTS:
491
- team_list.drop(index=TEAM_LIST_ALL_AGENTS, inplace=True)
492
- if add_no_team_row:
493
- team_list = pd.concat([team_list, pd.DataFrame(index=[""])])
494
- else:
495
- team_list = team_list[team_list.index.str.match(team)]
496
- team_agents = agent_list[column_names["team"]].value_counts().to_frame(name="agents_number")
497
- team_declarations = pd.merge(
498
- team_declarations,
499
- team_list,
500
- how="outer",
501
- left_index=True,
502
- right_index=True,
503
- sort=True,
504
- ).fillna(0)
505
- team_declarations = pd.merge(team_declarations, team_agents, left_index=True, right_index=True)
506
-
507
- if len(missing_declarations):
508
- team_declarations = pd.merge(
509
- team_declarations,
510
- missing_declarations,
511
- how="outer",
512
- left_index=True,
513
- right_index=True,
514
- sort=True,
515
- ).fillna(0)
516
- else:
517
- team_declarations["missings_number"] = 0
518
-
519
- declarations_total = int(sum(team_declarations["declarations_number"]))
520
- missings_total = int(sum(team_declarations["missings_number"]))
521
-
522
- data_columns = ["declarations_number", "missings_number"]
523
-
524
- table_header = [
525
- html.Thead(
526
- html.Tr(
527
- [
528
- html.Th(
529
- [
530
- html.I(f"{global_params.column_titles['team']} "),
531
- ]
532
- ),
533
- *[
534
- html.Th(
535
- html.Div(
536
- [
537
- html.I(f"{global_params.column_titles[c]} "),
538
- ]
539
- ),
540
- className="text-center",
541
- )
542
- for c in data_columns
543
- ],
544
- ],
545
- )
546
- )
547
- ]
548
-
549
- table_body = [
550
- html.Tbody(
551
- [
552
- html.Tr(
553
- [
554
- html.Td(i),
555
- *[
556
- html.Td(
557
- team_declarations.loc[i, column_names[c]],
558
- className="text-center",
559
- )
560
- for c in data_columns
561
- ],
562
- ]
563
- )
564
- for i in team_declarations.index.values
565
- ]
566
- )
567
- ]
568
-
569
- return html.Div(
570
- [
571
- dbc.Alert(
572
- [
573
- html.Div(
574
- html.B(
575
- (
576
- f"Statistiques des déclarations pour l'équipe '{team}' :"
577
- f" effectuées={declarations_total}, manquantes={missings_total}"
578
- )
579
- )
580
- ),
581
- html.Div(
582
- html.Em(
583
- (
584
- f"Statuts non inclus dans les déclarations manquantes :"
585
- f" {', '.join(declaration_options['optional_statutes'])}"
586
- )
587
- )
588
- ),
589
- html.Div(
590
- html.Em(
591
- (
592
- f"Equipes non incluses dans les déclarations manquantes :"
593
- f" {', '.join(declaration_options['optional_teams'])}"
594
- )
595
- )
596
- ),
597
- ],
598
- class_name="agent_count",
599
- ),
600
- html.P(),
601
- dbc.Table(
602
- table_header + table_body,
603
- id={"type": TABLE_TYPE_TABLE, "id": TABLE_ID_DECLARATION_STATS},
604
- bordered=True,
605
- hover=True,
606
- striped=True,
607
- class_name="sortable",
608
- ),
609
- ]
610
- )
611
-
612
-
613
- def build_missing_agents(declarations, agents, mandatory_only: bool = True):
614
- """
615
- Build the missing agents list and return it as a dataframe. It allows not toking into
616
- consideration the agent declarations for agents whose declaration is not mandatory
617
- (e.g. fellows).
618
-
619
- :param declarations: project declarations
620
- :param agents: list of agents who are supposed to do a declaration
621
- :param mandatory_only: ignore agents whose declaration is optional
622
- :return: missing agents dataframe
623
- """
624
-
625
- global_params = GlobalParams()
626
- column_names = global_params.columns
627
-
628
- declared_agents = pd.DataFrame(
629
- {column_names["agent_id"]: declarations[column_names["agent_id"]].unique()}
630
- )
631
- if mandatory_only:
632
- agent_list = agents[~agents.optional]
633
- else:
634
- agent_list = agents
635
- missing_agents = pd.merge(
636
- agent_list,
637
- declared_agents,
638
- on=column_names["agent_id"],
639
- how="outer",
640
- indicator=True,
641
- )
642
- missing_agents = missing_agents[missing_agents._merge == "left_only"].sort_values(
643
- by=column_names["fullname"]
644
- )
645
-
646
- return missing_agents
1
+ """
2
+ Functions to build tables associated with tab layouts
3
+ """
4
+
5
+ import dash_bootstrap_components as dbc
6
+ import numpy as np
7
+ import pandas as pd
8
+ from dash import dcc, html
9
+ from flask import session
10
+
11
+ from ositah.apps.validation.parameters import (
12
+ TABLE_COLUMN_VALIDATION,
13
+ TABLE_ID_DECLARATION_STATS,
14
+ TABLE_ID_MISSING_AGENTS,
15
+ TABLE_ID_VALIDATION,
16
+ VALIDATION_DECLARATIONS_SELECT_ALL,
17
+ VALIDATION_DECLARATIONS_SELECT_NOT_VALIDATED,
18
+ VALIDATION_DECLARATIONS_SELECT_VALIDATED,
19
+ )
20
+ from ositah.apps.validation.tools import (
21
+ activity_time_cell,
22
+ add_validation_declaration_selection_switch,
23
+ agent_project_time,
24
+ agent_tooltip_txt,
25
+ category_declarations,
26
+ define_declaration_thresholds,
27
+ get_all_validation_status,
28
+ validation_started,
29
+ )
30
+ from ositah.utils.agents import get_agents
31
+ from ositah.utils.exceptions import InvalidHitoProjectName, SessionDataMissing
32
+ from ositah.utils.menus import TABLE_TYPE_TABLE, build_accordion, ositah_jumbotron
33
+ from ositah.utils.projects import (
34
+ CATEGORY_DEFAULT,
35
+ DATA_SOURCE_HITO,
36
+ DATA_SOURCE_OSITAH,
37
+ get_team_projects,
38
+ time_unit,
39
+ )
40
+ from ositah.utils.utils import (
41
+ SEMESTER_WEEKS,
42
+ TEAM_LIST_ALL_AGENTS,
43
+ GlobalParams,
44
+ general_error_jumbotron,
45
+ no_session_id_jumbotron,
46
+ )
47
+
48
+
49
+ def build_validation_table(team, team_selection_date, declaration_set: int, period_date: str):
50
+ """
51
+ Build the agent list of the selected team with their declarations. Returns a table.
52
+
53
+ :param team: selected team
54
+ :param team_selection_date: last time the team selection was changed
55
+ :param declaration_set: selected declaration set (all, validated or non-validated ones)
56
+ :param period_date: a date that must be inside the declaration period
57
+ :return: DBC table
58
+ """
59
+ global_params = GlobalParams()
60
+ column_names = global_params.columns
61
+ try:
62
+ session_data = global_params.session_data
63
+ except SessionDataMissing:
64
+ return no_session_id_jumbotron()
65
+
66
+ if session["user_email"] in global_params.roles["read-only"]:
67
+ validation_disabled = True
68
+ else:
69
+ if session_data.role in global_params.validation_params["override_period"]:
70
+ validation_disabled = False
71
+ else:
72
+ validation_disabled = not validation_started(period_date)
73
+
74
+ validation_data = get_all_validation_status(period_date)
75
+ define_declaration_thresholds(period_date)
76
+
77
+ try:
78
+ project_declarations = get_team_projects(
79
+ team, team_selection_date, period_date, DATA_SOURCE_HITO
80
+ )
81
+ except InvalidHitoProjectName as e:
82
+ return ositah_jumbotron(
83
+ "Error loading projects",
84
+ e.msg,
85
+ title_class="text-danger",
86
+ )
87
+
88
+ if project_declarations is None:
89
+ return html.Div(
90
+ [
91
+ dbc.Alert(
92
+ f"Aucune déclaration effectuée pour l'équipe '{team}'",
93
+ color="warning",
94
+ ),
95
+ ]
96
+ )
97
+ declarations = category_declarations(project_declarations)
98
+
99
+ if team == TEAM_LIST_ALL_AGENTS:
100
+ declaration_list = declarations
101
+ else:
102
+ declaration_list = declarations[declarations[column_names["team"]].str.match(team)]
103
+ declaration_list["validation_disabled"] = validation_disabled
104
+
105
+ data_columns = [CATEGORY_DEFAULT]
106
+ data_columns.extend(global_params.project_categories.keys())
107
+
108
+ try:
109
+ validated_project_declarations = get_team_projects(
110
+ team, team_selection_date, period_date, DATA_SOURCE_OSITAH, use_cache=False
111
+ )
112
+ except InvalidHitoProjectName as e:
113
+ return ositah_jumbotron(
114
+ "Error loading projects",
115
+ e.msg,
116
+ title_class="text-danger",
117
+ )
118
+
119
+ if validated_project_declarations is None:
120
+ declaration_list["validated"] = False
121
+ declaration_list["hito_missing"] = False
122
+ validated_declarations_num = 0
123
+ hito_missing_num = 0
124
+ hito_missing_msg = ""
125
+ else:
126
+ validated_declarations_num = len(validated_project_declarations)
127
+ validated_declarations = category_declarations(
128
+ validated_project_declarations, use_cache=False
129
+ )
130
+ # Do an outer merge to detect validated declarations removed from Hito
131
+ declaration_list = declaration_list.merge(
132
+ validated_declarations,
133
+ how="outer",
134
+ on=column_names["agent_id"],
135
+ indicator=True,
136
+ suffixes=[None, "_val"],
137
+ )
138
+ declaration_list["validated"] = (declaration_list._merge == "both") | (
139
+ declaration_list._merge == "right_only"
140
+ )
141
+ declaration_list["hito_missing"] = declaration_list._merge == "right_only"
142
+ hito_missing_num = len(declaration_list.loc[declaration_list["hito_missing"]])
143
+ if hito_missing_num > 0:
144
+ columns_fillna = {c: 0 for c in [*data_columns, "total_hours", "percent_global"]}
145
+ declaration_list.loc[declaration_list["hito_missing"]] = declaration_list.fillna(
146
+ value=columns_fillna
147
+ )
148
+ declaration_list.loc[declaration_list["hito_missing"], column_names["fullname"]] = (
149
+ declaration_list[f"{column_names['fullname']}_val"]
150
+ )
151
+ declaration_list.loc[declaration_list["hito_missing"], column_names["team"]] = (
152
+ declaration_list[f"{column_names['team']}_val"]
153
+ )
154
+ declaration_list.loc[declaration_list["hito_missing"], "suspect"] = True
155
+ declaration_list.loc[declaration_list["hito_missing"], "validation_disabled"] = True
156
+ declaration_list.sort_values(by=column_names["fullname"], inplace=True)
157
+ hito_missing_msg = (
158
+ f" dont {hito_missing_num} supprimé"
159
+ f"{'s' if hito_missing_num > 1 else ''} de Hito"
160
+ )
161
+ else:
162
+ hito_missing_msg = ""
163
+ for category in data_columns:
164
+ time_ok_column = f"{category}_time_ok"
165
+ declaration_list.loc[declaration_list.validated, time_ok_column] = np.isclose(
166
+ declaration_list.loc[declaration_list.validated, category],
167
+ declaration_list.loc[declaration_list.validated, f"{category}_val"],
168
+ rtol=1e-5,
169
+ atol=0,
170
+ )
171
+ validated_number = len(declaration_list[declaration_list["validated"]])
172
+
173
+ if declaration_set == VALIDATION_DECLARATIONS_SELECT_ALL:
174
+ selected_declarations = declaration_list
175
+ elif declaration_set == VALIDATION_DECLARATIONS_SELECT_VALIDATED:
176
+ selected_declarations = declaration_list[declaration_list["validated"]]
177
+ elif declaration_set == VALIDATION_DECLARATIONS_SELECT_NOT_VALIDATED:
178
+ selected_declarations = declaration_list[~declaration_list["validated"]]
179
+ else:
180
+ return general_error_jumbotron(f"Invalid declaration set ID ({declaration_set})")
181
+ selected_declarations.sort_values(by=["fullname"], ignore_index=True, inplace=True)
182
+
183
+ columns = [*data_columns]
184
+ columns.insert(0, column_names["fullname"])
185
+ columns.append("percent_global")
186
+ rows_number = len(selected_declarations)
187
+
188
+ table_header = [
189
+ html.Thead(
190
+ html.Tr(
191
+ [
192
+ *[
193
+ html.Th(
194
+ [
195
+ html.Div(
196
+ [
197
+ html.I(f"{global_params.column_titles[c]} "),
198
+ ]
199
+ ),
200
+ html.Div(time_unit(c, english=False, parenthesis=True)),
201
+ ],
202
+ className="text-center",
203
+ )
204
+ for c in columns
205
+ ],
206
+ html.Th(TABLE_COLUMN_VALIDATION),
207
+ ],
208
+ )
209
+ )
210
+ ]
211
+
212
+ table_body = [
213
+ html.Tbody(
214
+ [
215
+ html.Tr(
216
+ [
217
+ html.Td(
218
+ build_accordion(
219
+ i,
220
+ selected_declarations.iloc[i - 1][column_names["fullname"]],
221
+ agent_project_time(
222
+ selected_declarations.iloc[i - 1][column_names["fullname"]]
223
+ ),
224
+ agent_tooltip_txt(selected_declarations.iloc[i - 1], data_columns),
225
+ (
226
+ "validated_hito_missing"
227
+ if selected_declarations.iloc[i - 1]["hito_missing"]
228
+ else ""
229
+ ),
230
+ ),
231
+ className="accordion",
232
+ key=f"validation-table-cell-{i}-fullname",
233
+ ),
234
+ *[
235
+ activity_time_cell(selected_declarations.iloc[i - 1], c, i)
236
+ for c in data_columns
237
+ ],
238
+ activity_time_cell(selected_declarations.iloc[i - 1], "percent_global", i),
239
+ html.Td(
240
+ [
241
+ dbc.Checklist(
242
+ options=[
243
+ {
244
+ "label": "",
245
+ "value": 1,
246
+ "disabled": selected_declarations.iloc[i - 1][
247
+ "validation_disabled"
248
+ ],
249
+ }
250
+ ],
251
+ value=[
252
+ int(
253
+ selected_declarations.iloc[i - 1][
254
+ column_names["agent_id"]
255
+ ]
256
+ in validation_data.index
257
+ )
258
+ ],
259
+ id={"type": "validation-switch", "id": i},
260
+ key=f"validation-switch-{i}",
261
+ switch=True,
262
+ ),
263
+ # The dcc.Store is created to ease validation callback management
264
+ # by passing the agent ID and providing an Output object (but
265
+ # nothing will be written in it).
266
+ dcc.Store(
267
+ id={"type": "validation-agent-id", "id": i},
268
+ data=selected_declarations.iloc[i - 1][
269
+ column_names["agent_id"]
270
+ ],
271
+ ),
272
+ ],
273
+ className="align-middle",
274
+ key=f"validation-table-cell-{i}-switch",
275
+ ),
276
+ ],
277
+ key=f"validation-table-row-{i}",
278
+ )
279
+ for i in range(1, rows_number + 1)
280
+ ]
281
+ )
282
+ ]
283
+
284
+ return html.Div(
285
+ [
286
+ dbc.Alert(
287
+ [
288
+ html.Div(
289
+ html.B(
290
+ (
291
+ f"Nombre d'agents de l'équipe '{team}' ayant déclaré :"
292
+ f" {len(selected_declarations)} (agents validés={validated_number}"
293
+ f"{hito_missing_msg}"
294
+ f", déclarations validées/totales="
295
+ f"{validated_declarations_num}/{len(project_declarations)})"
296
+ ),
297
+ className="agent_count",
298
+ )
299
+ ),
300
+ html.Div(
301
+ html.Em(
302
+ (
303
+ f"Temps déclarés de l'équipe / total ="
304
+ f" {round(selected_declarations['percent_global'].mean(), 1)}%"
305
+ f" (100%={SEMESTER_WEEKS} semaines)"
306
+ )
307
+ )
308
+ ),
309
+ ]
310
+ ),
311
+ html.P(),
312
+ add_validation_declaration_selection_switch(declaration_set),
313
+ dbc.Table(
314
+ table_header + table_body,
315
+ id={"type": TABLE_TYPE_TABLE, "id": TABLE_ID_VALIDATION},
316
+ bordered=True,
317
+ hover=True,
318
+ striped=True,
319
+ class_name="sortable",
320
+ ),
321
+ ]
322
+ )
323
+
324
+
325
+ def build_missing_agents_table(team, team_selection_date, period_date: str):
326
+ """
327
+ Function to build a table listing all agents that have not declared yet their time on projects
328
+
329
+ :param team: selected team
330
+ :param team_selection_date: last time the team selection was changed
331
+ :param period_date: a date that must be inside the declaration period
332
+ :return: missing agent page
333
+ """
334
+ global_params = GlobalParams()
335
+ column_names = global_params.columns
336
+ declaration_options = global_params.declaration_options
337
+
338
+ try:
339
+ declarations = get_team_projects(team, team_selection_date, period_date)
340
+ except InvalidHitoProjectName as e:
341
+ return ositah_jumbotron(
342
+ "Error loading projects",
343
+ e.msg,
344
+ title_class="text-danger",
345
+ )
346
+
347
+ agent_list = get_agents(period_date, team)
348
+
349
+ if declarations is None:
350
+ missing_agents = agent_list
351
+ else:
352
+ missing_agents = build_missing_agents(declarations, agent_list)
353
+
354
+ rows_number = len(missing_agents)
355
+ table_columns = ["fullname", "team"]
356
+
357
+ table_header = [
358
+ html.Thead(
359
+ html.Tr(
360
+ [
361
+ html.Th(
362
+ [
363
+ html.I(f"{global_params.column_titles[c]} "),
364
+ ]
365
+ )
366
+ for c in table_columns
367
+ ],
368
+ )
369
+ )
370
+ ]
371
+
372
+ table_body = [
373
+ html.Tbody(
374
+ [
375
+ html.Tr(
376
+ [
377
+ html.Td(
378
+ missing_agents.iloc[i][column_names[c]],
379
+ className="align-middle",
380
+ )
381
+ for c in table_columns
382
+ ]
383
+ )
384
+ for i in range(rows_number)
385
+ ]
386
+ )
387
+ ]
388
+
389
+ return html.Div(
390
+ [
391
+ dbc.Alert(
392
+ [
393
+ html.Div(
394
+ html.B(
395
+ (
396
+ f"Nombre d'agents de l'équipe '{team}' sans déclaration :"
397
+ f" {len(missing_agents)}"
398
+ )
399
+ )
400
+ ),
401
+ html.Div(
402
+ html.Em(
403
+ (
404
+ f"Statuts non inclus :"
405
+ f" {', '.join(declaration_options['optional_statutes'])}"
406
+ )
407
+ )
408
+ ),
409
+ html.Div(
410
+ html.Em(
411
+ (
412
+ f"Equipes non incluses :"
413
+ f" {', '.join(declaration_options['optional_teams'])}"
414
+ )
415
+ )
416
+ ),
417
+ ],
418
+ class_name="agent_count",
419
+ ),
420
+ html.P(),
421
+ dbc.Table(
422
+ table_header + table_body,
423
+ id={"type": TABLE_TYPE_TABLE, "id": TABLE_ID_MISSING_AGENTS},
424
+ bordered=True,
425
+ hover=True,
426
+ striped=True,
427
+ class_name="sortable",
428
+ ),
429
+ ]
430
+ )
431
+
432
+
433
+ def build_statistics_table(team, team_selection_date, period_date: str):
434
+ """
435
+ Function to build a table listing the number of declarations and missing declarations per team
436
+
437
+ :param team: selected team
438
+ :param team_selection_date: last time the team selection was changed
439
+ :param period_date: a date that must be inside the declaration period
440
+ :return: missing agent page
441
+ """
442
+ global_params = GlobalParams()
443
+ column_names = global_params.columns
444
+ declaration_options = global_params.declaration_options
445
+
446
+ try:
447
+ session_data = global_params.session_data
448
+ except SessionDataMissing:
449
+ return no_session_id_jumbotron()
450
+
451
+ try:
452
+ declarations = get_team_projects(team, team_selection_date, period_date)
453
+ except InvalidHitoProjectName as e:
454
+ return ositah_jumbotron(
455
+ "Error loading projects",
456
+ e.msg,
457
+ title_class="text-danger",
458
+ )
459
+
460
+ agent_list = get_agents(period_date, team)
461
+
462
+ add_no_team_row = False
463
+ if declarations is None:
464
+ team_declarations = pd.DataFrame({"declarations_number": 0}, index=[team])
465
+ team_declarations["missings_number"] = len(agent_list)
466
+ missing_declarations = pd.DataFrame()
467
+ else:
468
+ team_agent_declarations = declarations.drop_duplicates(
469
+ subset=[column_names["team"], column_names["fullname"]]
470
+ )
471
+ # If team is None, set it to empty string
472
+ if len(team_agent_declarations.loc[team_agent_declarations.team.isna(), "team"]) > 0:
473
+ team_agent_declarations.loc[team_agent_declarations.team.isna(), "team"] = ""
474
+ add_no_team_row = True
475
+ team_declarations = (
476
+ team_agent_declarations[column_names["team"]]
477
+ .value_counts()
478
+ .to_frame(name="declarations_number")
479
+ )
480
+
481
+ missing_agents = build_missing_agents(declarations, agent_list)
482
+ missing_declarations = (
483
+ missing_agents[column_names["team"]].value_counts().to_frame(name="missings_number")
484
+ )
485
+ # If team is None, set it to empty string
486
+ if len(missing_declarations.loc[missing_declarations.index == ""]) > 0:
487
+ add_no_team_row = True
488
+
489
+ team_list = pd.DataFrame(index=session_data.agent_teams)
490
+ if team == TEAM_LIST_ALL_AGENTS:
491
+ team_list.drop(index=TEAM_LIST_ALL_AGENTS, inplace=True)
492
+ if add_no_team_row:
493
+ team_list = pd.concat([team_list, pd.DataFrame(index=[""])])
494
+ else:
495
+ team_list = team_list[team_list.index.str.match(team)]
496
+ team_agents = agent_list[column_names["team"]].value_counts().to_frame(name="agents_number")
497
+ team_declarations = pd.merge(
498
+ team_declarations,
499
+ team_list,
500
+ how="outer",
501
+ left_index=True,
502
+ right_index=True,
503
+ sort=True,
504
+ ).fillna(0)
505
+ team_declarations = pd.merge(team_declarations, team_agents, left_index=True, right_index=True)
506
+
507
+ if len(missing_declarations):
508
+ team_declarations = pd.merge(
509
+ team_declarations,
510
+ missing_declarations,
511
+ how="outer",
512
+ left_index=True,
513
+ right_index=True,
514
+ sort=True,
515
+ ).fillna(0)
516
+ else:
517
+ team_declarations["missings_number"] = 0
518
+
519
+ declarations_total = int(sum(team_declarations["declarations_number"]))
520
+ missings_total = int(sum(team_declarations["missings_number"]))
521
+
522
+ data_columns = ["declarations_number", "missings_number"]
523
+
524
+ table_header = [
525
+ html.Thead(
526
+ html.Tr(
527
+ [
528
+ html.Th(
529
+ [
530
+ html.I(f"{global_params.column_titles['team']} "),
531
+ ]
532
+ ),
533
+ *[
534
+ html.Th(
535
+ html.Div(
536
+ [
537
+ html.I(f"{global_params.column_titles[c]} "),
538
+ ]
539
+ ),
540
+ className="text-center",
541
+ )
542
+ for c in data_columns
543
+ ],
544
+ ],
545
+ )
546
+ )
547
+ ]
548
+
549
+ table_body = [
550
+ html.Tbody(
551
+ [
552
+ html.Tr(
553
+ [
554
+ html.Td(i),
555
+ *[
556
+ html.Td(
557
+ team_declarations.loc[i, column_names[c]],
558
+ className="text-center",
559
+ )
560
+ for c in data_columns
561
+ ],
562
+ ]
563
+ )
564
+ for i in team_declarations.index.values
565
+ ]
566
+ )
567
+ ]
568
+
569
+ return html.Div(
570
+ [
571
+ dbc.Alert(
572
+ [
573
+ html.Div(
574
+ html.B(
575
+ (
576
+ f"Statistiques des déclarations pour l'équipe '{team}' :"
577
+ f" effectuées={declarations_total}, manquantes={missings_total}"
578
+ )
579
+ )
580
+ ),
581
+ html.Div(
582
+ html.Em(
583
+ (
584
+ f"Statuts non inclus dans les déclarations manquantes :"
585
+ f" {', '.join(declaration_options['optional_statutes'])}"
586
+ )
587
+ )
588
+ ),
589
+ html.Div(
590
+ html.Em(
591
+ (
592
+ f"Equipes non incluses dans les déclarations manquantes :"
593
+ f" {', '.join(declaration_options['optional_teams'])}"
594
+ )
595
+ )
596
+ ),
597
+ ],
598
+ class_name="agent_count",
599
+ ),
600
+ html.P(),
601
+ dbc.Table(
602
+ table_header + table_body,
603
+ id={"type": TABLE_TYPE_TABLE, "id": TABLE_ID_DECLARATION_STATS},
604
+ bordered=True,
605
+ hover=True,
606
+ striped=True,
607
+ class_name="sortable",
608
+ ),
609
+ ]
610
+ )
611
+
612
+
613
+ def build_missing_agents(declarations, agents, mandatory_only: bool = True):
614
+ """
615
+ Build the missing agents list and return it as a dataframe. It allows not toking into
616
+ consideration the agent declarations for agents whose declaration is not mandatory
617
+ (e.g. fellows).
618
+
619
+ :param declarations: project declarations
620
+ :param agents: list of agents who are supposed to do a declaration
621
+ :param mandatory_only: ignore agents whose declaration is optional
622
+ :return: missing agents dataframe
623
+ """
624
+
625
+ global_params = GlobalParams()
626
+ column_names = global_params.columns
627
+
628
+ declared_agents = pd.DataFrame(
629
+ {column_names["agent_id"]: declarations[column_names["agent_id"]].unique()}
630
+ )
631
+ if mandatory_only:
632
+ agent_list = agents[~agents.optional]
633
+ else:
634
+ agent_list = agents
635
+ missing_agents = pd.merge(
636
+ agent_list,
637
+ declared_agents,
638
+ on=column_names["agent_id"],
639
+ how="outer",
640
+ indicator=True,
641
+ )
642
+ missing_agents = missing_agents[missing_agents._merge == "left_only"].sort_values(
643
+ by=column_names["fullname"]
644
+ )
645
+
646
+ return missing_agents