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,552 +1,552 @@
1
- """
2
- Various functions used by Validation sub-application
3
- """
4
-
5
- from datetime import date, datetime
6
-
7
- import dash_bootstrap_components as dbc
8
- import pandas as pd
9
- from dash import html
10
-
11
- from ositah.apps.validation.parameters import (
12
- VALIDATION_DECLARATIONS_SELECT_ALL,
13
- VALIDATION_DECLARATIONS_SELECT_NOT_VALIDATED,
14
- VALIDATION_DECLARATIONS_SELECT_VALIDATED,
15
- VALIDATION_DECLARATIONS_SWITCH_ID,
16
- )
17
- from ositah.utils.exceptions import SessionDataMissing
18
- from ositah.utils.hito_db import get_db
19
- from ositah.utils.period import get_validation_period_data
20
- from ositah.utils.projects import (
21
- CATEGORY_DEFAULT,
22
- DATA_SOURCE_HITO,
23
- get_team_projects,
24
- project_time,
25
- time_unit,
26
- )
27
- from ositah.utils.utils import (
28
- SEMESTER_HOURS,
29
- TIME_UNIT_HOURS,
30
- TIME_UNIT_HOURS_EN,
31
- TIME_UNIT_WEEKS,
32
- WEEK_HOURS,
33
- GlobalParams,
34
- no_session_id_jumbotron,
35
- )
36
-
37
-
38
- def activity_time_cell(row, column, row_index):
39
- """
40
- Build the cell content for an activity time, adding the appropriate class and in case of
41
- inconsistencies between the Hito declared time and the validated time, add a tooltip
42
- to display both values.
43
-
44
- :param row: declaration row
45
- :param column: column name for the cell to build
46
- :param row_index: row index of the current row
47
- :return: html.Td
48
- """
49
-
50
- global_params = GlobalParams()
51
-
52
- classes = "text-center align-middle"
53
- cell_value = int(round(row[column]))
54
- cell_id = f"validation-table-value-{row_index}-{column}"
55
-
56
- if column == "percent_global":
57
- thresholds = global_params.declaration_options["thresholds"]["current"]
58
- percent = round(row["percent_global"], 1)
59
- if percent <= thresholds["low"]:
60
- percent_class = "table-danger"
61
- tooltip_txt = f"Percentage low (<={thresholds['low']}%)"
62
- elif percent <= thresholds["suspect"]:
63
- percent_class = "table-warning"
64
- tooltip_txt = (
65
- f"Percentage suspect (>{thresholds['low']}% and" f" <={thresholds['suspect']}%)"
66
- )
67
- elif percent > thresholds["good"]:
68
- percent_class = "table-info"
69
- tooltip_txt = f"Percentage too high (>{thresholds['good']}%)"
70
- else:
71
- percent_class = "table-success"
72
-
73
- contents = [html.Div(percent, id=cell_id)]
74
- if percent_class != "table-success":
75
- contents.append(dbc.Tooltip(html.Div(tooltip_txt), target=cell_id))
76
-
77
- classes += f" {percent_class}"
78
-
79
- elif (
80
- (column != "enseignement" or not global_params.teaching_ratio)
81
- and time_unit(column) == TIME_UNIT_HOURS_EN
82
- and row[column] > global_params.declaration_options["max_hours"]
83
- ):
84
- contents = [
85
- html.Div(cell_value, id=cell_id),
86
- dbc.Tooltip(
87
- html.Div(
88
- (
89
- f"Déclaration supérieure au maximum"
90
- f" ({global_params.declaration_options['max_hours']} heures)"
91
- )
92
- ),
93
- target=cell_id,
94
- ),
95
- ]
96
- classes += " table-danger"
97
-
98
- elif row.hito_missing:
99
- validated_column = f"{column}_val"
100
- contents = [
101
- html.Div(int(round(row[validated_column])), id=cell_id),
102
- dbc.Tooltip(
103
- [
104
- html.Div(f"Déclaration validée: {round(row[validated_column], 3)}"),
105
- html.Div("Déclaration Hito correspondante supprimée"),
106
- ],
107
- target=cell_id,
108
- ),
109
- ]
110
- classes += " validated_hito_missing"
111
-
112
- elif row.validated and not row[f"{column}_time_ok"]:
113
- validated_column = f"{column}_val"
114
- contents = [
115
- html.Div(cell_value, id=cell_id),
116
- dbc.Tooltip(
117
- [
118
- html.Div(f"Dernière déclaration: {round(row[column], 3)}"),
119
- html.Div(f"Déclaration validée: {round(row[validated_column], 3)}"),
120
- ],
121
- target=cell_id,
122
- ),
123
- ]
124
- classes += " table-warning"
125
-
126
- else:
127
- contents = [html.Div(cell_value, id=cell_id)]
128
- if row.suspect:
129
- contents.append(
130
- dbc.Tooltip(
131
- [
132
- html.Div("Déclaration suspecte: vérifier les quotités déclarées"),
133
- ],
134
- target=cell_id,
135
- )
136
- )
137
- classes += " table-warning"
138
-
139
- return html.Td(contents, className=classes, key=f"validation-table-cell-{row}-{column}")
140
-
141
-
142
- def agent_tooltip_txt(agent_data, data_columns):
143
- """
144
- Build the tooltip text associated with an agent.
145
-
146
- :param agent_data: the dataframe row corresponding to the agent
147
- :param data_columns: the list of columns to use to compute the total time
148
- :return: list of html elements
149
- """
150
-
151
- global_params = GlobalParams()
152
-
153
- if agent_data["hito_missing"]:
154
- tooltip_detail = "Déclarations validées supprimées de Hito"
155
- else:
156
- tooltip_detail = f"Total (semaines) : {total_time(agent_data, data_columns)}"
157
- tooltip_txt = [
158
- html.Div(
159
- f"{global_params.column_titles['team']}: {agent_data[global_params.columns['team']]}"
160
- ),
161
- html.Div(tooltip_detail),
162
- ]
163
-
164
- return tooltip_txt
165
-
166
-
167
- def add_validation_declaration_selection_switch(current_set):
168
- """
169
- Add a dbc.RadioItems to select whether to show all declaration or only a subset.
170
-
171
- :param current_set: currently selected declaration set
172
- :return: dbc.RadioItems
173
- """
174
-
175
- return dbc.Row(
176
- [
177
- dbc.RadioItems(
178
- options=[
179
- {
180
- "label": "Toutes les déclarations",
181
- "value": VALIDATION_DECLARATIONS_SELECT_ALL,
182
- },
183
- {
184
- "label": "Déclarations non validées uniquement",
185
- "value": VALIDATION_DECLARATIONS_SELECT_NOT_VALIDATED,
186
- },
187
- {
188
- "label": "Déclarations validées uniquement",
189
- "value": VALIDATION_DECLARATIONS_SELECT_VALIDATED,
190
- },
191
- ],
192
- value=current_set,
193
- id=VALIDATION_DECLARATIONS_SWITCH_ID,
194
- inline=True,
195
- ),
196
- ],
197
- justify="center",
198
- )
199
-
200
-
201
- def agent_list(dataframe: pd.DataFrame) -> pd.DataFrame:
202
- global_params = GlobalParams()
203
- fullname_df = pd.DataFrame()
204
- fullname_df[global_params.columns["fullname"]] = dataframe[
205
- global_params.columns["fullname"]
206
- ].drop_duplicates()
207
- return fullname_df
208
-
209
-
210
- def total_time(row, categories, rounded=True) -> int:
211
- """
212
- Compute total time declared by an agent in weeks
213
-
214
- :param row: dataframe row for an agent
215
- :param categories: list of categories to sum up
216
- :param rounded: if true, returns the rounded value
217
- :return: number of weeks declared
218
- """
219
-
220
- global_params = GlobalParams()
221
-
222
- weeks_number = 0
223
- hours_number = 0
224
- for category in categories:
225
- if global_params.time_unit[category] == TIME_UNIT_WEEKS:
226
- weeks_number += row[category]
227
- elif global_params.time_unit[category] == TIME_UNIT_HOURS:
228
- hours_number += row[category]
229
- else:
230
- raise Exception(
231
- (
232
- f"Unsupported time unit '{global_params.time_unit[category]}'"
233
- f" for category {category}"
234
- )
235
- )
236
-
237
- weeks_number += hours_number / WEEK_HOURS
238
-
239
- if rounded:
240
- return int(round(weeks_number))
241
- else:
242
- return weeks_number
243
-
244
-
245
- def category_time(dataframe, category) -> None:
246
- """
247
- Convert the number of hours into the time unit of the category. Keep track of the
248
- conversions done to prevent doing it twice.
249
-
250
- :param dataframe: dataframe to update
251
- :param category: category name
252
- :return: none, dataframe updated
253
- """
254
-
255
- global_params = GlobalParams()
256
-
257
- # Default time unit is hour
258
- unit_hour = True
259
- if category in global_params.time_unit:
260
- if global_params.time_unit[category] == TIME_UNIT_WEEKS:
261
- unit_hour = False
262
- elif global_params.time_unit[category] != TIME_UNIT_HOURS:
263
- raise Exception(
264
- (
265
- f"Unsupported time unit '{global_params.time_unit[category]}'"
266
- f" for category {category}"
267
- )
268
- )
269
-
270
- if not unit_hour:
271
- dataframe[category] = dataframe[category] / WEEK_HOURS
272
-
273
- return
274
-
275
-
276
- def agent_project_time(agent: str) -> html.Div:
277
- """
278
- Return a HTML Div with the list of projects and the time spent on them
279
-
280
- :param agent: agent fullname
281
- :return: html.Div
282
- """
283
-
284
- global_params = GlobalParams()
285
- columns = global_params.columns
286
-
287
- try:
288
- session_data = global_params.session_data
289
- df = session_data.project_declarations[
290
- session_data.project_declarations[global_params.columns["fullname"]] == agent
291
- ]
292
-
293
- return html.Div(
294
- [
295
- html.P(
296
- (
297
- f"{df.iloc[i][global_params.columns['activity']]}:"
298
- f" {' '.join(project_time(df.iloc[i][columns['activity']], df.iloc[i][columns['hours']]))}" # noqa: E501
299
- )
300
- )
301
- for i in range(len(df))
302
- ]
303
- )
304
-
305
- except SessionDataMissing:
306
- return no_session_id_jumbotron()
307
-
308
-
309
- def category_declarations(
310
- project_declarations: pd.DataFrame, use_cache: bool = True
311
- ) -> pd.DataFrame:
312
- """
313
- Process the project declarations (time per project) and convert it into declarations by
314
- category of projects.
315
-
316
- :param project_declarations: project declarations to consolidate
317
- :param use_cache: if True, use and update cache
318
- :return: dataframe
319
- """
320
-
321
- global_params = GlobalParams()
322
- columns = global_params.columns
323
-
324
- try:
325
- session_data = global_params.session_data
326
- except SessionDataMissing:
327
- return no_session_id_jumbotron()
328
-
329
- # Check if there is a cached version
330
- if session_data.category_declarations is not None and use_cache:
331
- return session_data.category_declarations
332
-
333
- if project_declarations is not None:
334
- category_declarations = project_declarations.copy()
335
- categories = [CATEGORY_DEFAULT]
336
- categories.extend(global_params.project_categories.keys())
337
- for category in categories:
338
- category_declarations[category] = category_declarations.loc[
339
- category_declarations[columns["category"]] == category, columns["hours"]
340
- ]
341
- category_declarations.drop(columns=columns["hours"], inplace=True)
342
- category_declarations.fillna(0, inplace=True)
343
- category_declarations = category_declarations.infer_objects(copy=False)
344
-
345
- agg_functions = {c: "sum" for c in categories}
346
- agg_functions["suspect"] = "any"
347
- category_declarations_pt = pd.pivot_table(
348
- category_declarations,
349
- index=[
350
- global_params.columns["fullname"],
351
- global_params.columns["team"],
352
- global_params.columns["agent_id"],
353
- ],
354
- values=[*categories, "suspect"],
355
- aggfunc=agg_functions,
356
- )
357
- category_declarations = pd.DataFrame(category_declarations_pt.to_records())
358
-
359
- category_declarations["total_hours"] = category_declarations[categories].sum(axis=1)
360
- category_declarations["percent_global"] = (
361
- category_declarations["total_hours"] / SEMESTER_HOURS * 100
362
- )
363
-
364
- # Convert category time into the appropriate unit
365
- for column_name in categories:
366
- category_time(category_declarations, column_name)
367
-
368
- else:
369
- raise Exception("Project declarations are not defined")
370
-
371
- if use_cache:
372
- session_data.category_declarations = category_declarations
373
-
374
- return category_declarations
375
-
376
-
377
- def define_declaration_thresholds(period_date: str):
378
- """
379
- Define the declaration thresholds (low, suspect, normal) for the current period
380
-
381
- :param period_date: a date that must be inside the declaration period
382
- """
383
- global_params = GlobalParams()
384
-
385
- period_datetime = date.fromisoformat(period_date)
386
- if period_datetime.month >= 7:
387
- global_params.declaration_options["thresholds"]["current"] = (
388
- global_params.declaration_options["thresholds"]["s2"]
389
- )
390
- else:
391
- global_params.declaration_options["thresholds"]["current"] = (
392
- global_params.declaration_options["thresholds"]["s1"]
393
- )
394
-
395
-
396
- def get_validation_data(agent_id, period_date: str, session=None):
397
- """
398
- Return the validation data for an agent or None if there is no entry in the database for this
399
- agent_id.
400
-
401
- :param agent_id: agent_id of the agent to check
402
- :param session: DB session to use (default one if None)
403
- :param period_date: a date that must be inside the declaration period
404
- :return: an OSITAHValidation object or None
405
- """
406
- from ositah.utils.hito_db_model import OSITAHValidation
407
-
408
- db = get_db()
409
- if session is None:
410
- session = db.session
411
-
412
- validation_period = get_validation_period_data(period_date)
413
- return (
414
- session.query(OSITAHValidation)
415
- .filter_by(agent_id=agent_id, period_id=validation_period.id)
416
- .order_by(OSITAHValidation.timestamp.desc())
417
- .first()
418
- )
419
-
420
-
421
- def get_validation_status(agent_id, period_date: str, session=None):
422
- """
423
- Return True if the agent entry has been validated, False otherwise (including if there is no
424
- validation entry for the agent)
425
-
426
- :param agent_id: agent_id of the agent to check
427
- :param session: DB session to use
428
- :param period_date: a date that must be inside the declaration period
429
- :return: boolean
430
- """
431
-
432
- validation_data = get_validation_data(agent_id, period_date, session)
433
- if validation_data is None:
434
- return False
435
- else:
436
- return validation_data.validated
437
-
438
-
439
- def get_all_validation_status(period_date: str):
440
- """
441
- Returns the list of agents whose declaration has been validated as a dataframe. It is intended
442
- to be used to build the validation table but should not be used when updating the status, as
443
- it may have been updated by somebody else.
444
-
445
- :param period_date: a date that must be inside the declaration period
446
- :return: dataframe
447
- """
448
-
449
- from ositah.utils.hito_db_model import OSITAHValidation
450
-
451
- db = get_db()
452
- validation_period = get_validation_period_data(period_date)
453
- # By design, only one entry in the declaration period can be with the status validated,
454
- # except if the database has been messed up...
455
- validation_query = OSITAHValidation.query.filter(
456
- OSITAHValidation.period_id == validation_period.id,
457
- OSITAHValidation.validated,
458
- )
459
- validation_data = pd.read_sql(validation_query.statement, con=db.engine)
460
- if validation_data is None:
461
- validation_data = pd.DataFrame()
462
- else:
463
- validation_data.set_index("agent_id", inplace=True)
464
-
465
- return validation_data
466
-
467
-
468
- def validation_started(period_date: str):
469
- """
470
- Compare the current date with the validation start date (validation_date) and return True
471
- if the validation has started, False otherwise.
472
-
473
- :param period_date: date included in the declaration period
474
- :return: boolean
475
- """
476
-
477
- current_date = datetime.now()
478
- period_params = get_validation_period_data(period_date)
479
- if current_date >= period_params.validation_date:
480
- return True
481
- else:
482
- return False
483
-
484
-
485
- def project_declaration_snapshot(
486
- agent_id,
487
- validation_id,
488
- team,
489
- team_selection_date,
490
- period_date,
491
- db_session=None,
492
- commit=False,
493
- ):
494
- """
495
- Save into table ositah_validation_project_declaration the validated project declarations
496
- for an agent.
497
-
498
- :param agent_id: agent (ID) whose project declarations must be saved
499
- :param validation_id: validation ID associated with the declarations snapshot
500
- :param db_session: session for the current transaction. If None, use the default one.
501
- :param commit: if false, do not commit added rows
502
- :return: None
503
- """
504
-
505
- from ositah.utils.hito_db_model import OSITAHProjectDeclaration
506
-
507
- global_params = GlobalParams()
508
- columns = global_params.columns
509
- try:
510
- session_data = global_params.session_data
511
- except SessionDataMissing:
512
- return no_session_id_jumbotron()
513
-
514
- db = get_db()
515
- if db_session:
516
- session = db_session
517
- else:
518
- session = db.session
519
-
520
- # The cache cannot be used directly as the callback may run on a server where the session
521
- # cache for the current user doesn't exist yet
522
- project_declarations = get_team_projects(
523
- team, team_selection_date, period_date, DATA_SOURCE_HITO
524
- )
525
- agent_projects = project_declarations[project_declarations[columns["agent_id"]] == agent_id]
526
- if agent_projects is None:
527
- print(f"ERROR: no declaration found for agent ID '{agent_id}' (internal error)")
528
- else:
529
- for _, project in agent_projects.iterrows():
530
- declaration = OSITAHProjectDeclaration(
531
- projet=project[columns["project"]],
532
- masterprojet=project[columns["masterproject"]],
533
- category=project[columns["category"]],
534
- hours=project[columns["hours"]],
535
- quotite=project[columns["quotite"]],
536
- validation_id=validation_id,
537
- hito_project_id=project[columns["activity_id"]],
538
- )
539
- try:
540
- session.add(declaration)
541
- except Exception:
542
- # If the default session is used, let the caller process the exception and
543
- # eventually do the rollback
544
- if db_session:
545
- session.rollback()
546
- raise
547
-
548
- # Reset the cache of validated declarations if a modification occured
549
- session_data.reset_validated_declarations_cache()
550
-
551
- if commit:
552
- session.commit
1
+ """
2
+ Various functions used by Validation sub-application
3
+ """
4
+
5
+ from datetime import date, datetime
6
+
7
+ import dash_bootstrap_components as dbc
8
+ import pandas as pd
9
+ from dash import html
10
+
11
+ from ositah.apps.validation.parameters import (
12
+ VALIDATION_DECLARATIONS_SELECT_ALL,
13
+ VALIDATION_DECLARATIONS_SELECT_NOT_VALIDATED,
14
+ VALIDATION_DECLARATIONS_SELECT_VALIDATED,
15
+ VALIDATION_DECLARATIONS_SWITCH_ID,
16
+ )
17
+ from ositah.utils.exceptions import SessionDataMissing
18
+ from ositah.utils.hito_db import get_db
19
+ from ositah.utils.period import get_validation_period_data
20
+ from ositah.utils.projects import (
21
+ CATEGORY_DEFAULT,
22
+ DATA_SOURCE_HITO,
23
+ get_team_projects,
24
+ project_time,
25
+ time_unit,
26
+ )
27
+ from ositah.utils.utils import (
28
+ SEMESTER_HOURS,
29
+ TIME_UNIT_HOURS,
30
+ TIME_UNIT_HOURS_EN,
31
+ TIME_UNIT_WEEKS,
32
+ WEEK_HOURS,
33
+ GlobalParams,
34
+ no_session_id_jumbotron,
35
+ )
36
+
37
+
38
+ def activity_time_cell(row, column, row_index):
39
+ """
40
+ Build the cell content for an activity time, adding the appropriate class and in case of
41
+ inconsistencies between the Hito declared time and the validated time, add a tooltip
42
+ to display both values.
43
+
44
+ :param row: declaration row
45
+ :param column: column name for the cell to build
46
+ :param row_index: row index of the current row
47
+ :return: html.Td
48
+ """
49
+
50
+ global_params = GlobalParams()
51
+
52
+ classes = "text-center align-middle"
53
+ cell_value = int(round(row[column]))
54
+ cell_id = f"validation-table-value-{row_index}-{column}"
55
+
56
+ if column == "percent_global":
57
+ thresholds = global_params.declaration_options["thresholds"]["current"]
58
+ percent = round(row["percent_global"], 1)
59
+ if percent <= thresholds["low"]:
60
+ percent_class = "table-danger"
61
+ tooltip_txt = f"Percentage low (<={thresholds['low']}%)"
62
+ elif percent <= thresholds["suspect"]:
63
+ percent_class = "table-warning"
64
+ tooltip_txt = (
65
+ f"Percentage suspect (>{thresholds['low']}% and" f" <={thresholds['suspect']}%)"
66
+ )
67
+ elif percent > thresholds["good"]:
68
+ percent_class = "table-info"
69
+ tooltip_txt = f"Percentage too high (>{thresholds['good']}%)"
70
+ else:
71
+ percent_class = "table-success"
72
+
73
+ contents = [html.Div(percent, id=cell_id)]
74
+ if percent_class != "table-success":
75
+ contents.append(dbc.Tooltip(html.Div(tooltip_txt), target=cell_id))
76
+
77
+ classes += f" {percent_class}"
78
+
79
+ elif (
80
+ (column != "enseignement" or not global_params.teaching_ratio)
81
+ and time_unit(column) == TIME_UNIT_HOURS_EN
82
+ and row[column] > global_params.declaration_options["max_hours"]
83
+ ):
84
+ contents = [
85
+ html.Div(cell_value, id=cell_id),
86
+ dbc.Tooltip(
87
+ html.Div(
88
+ (
89
+ f"Déclaration supérieure au maximum"
90
+ f" ({global_params.declaration_options['max_hours']} heures)"
91
+ )
92
+ ),
93
+ target=cell_id,
94
+ ),
95
+ ]
96
+ classes += " table-danger"
97
+
98
+ elif row.hito_missing:
99
+ validated_column = f"{column}_val"
100
+ contents = [
101
+ html.Div(int(round(row[validated_column])), id=cell_id),
102
+ dbc.Tooltip(
103
+ [
104
+ html.Div(f"Déclaration validée: {round(row[validated_column], 3)}"),
105
+ html.Div("Déclaration Hito correspondante supprimée"),
106
+ ],
107
+ target=cell_id,
108
+ ),
109
+ ]
110
+ classes += " validated_hito_missing"
111
+
112
+ elif row.validated and not row[f"{column}_time_ok"]:
113
+ validated_column = f"{column}_val"
114
+ contents = [
115
+ html.Div(cell_value, id=cell_id),
116
+ dbc.Tooltip(
117
+ [
118
+ html.Div(f"Dernière déclaration: {round(row[column], 3)}"),
119
+ html.Div(f"Déclaration validée: {round(row[validated_column], 3)}"),
120
+ ],
121
+ target=cell_id,
122
+ ),
123
+ ]
124
+ classes += " table-warning"
125
+
126
+ else:
127
+ contents = [html.Div(cell_value, id=cell_id)]
128
+ if row.suspect:
129
+ contents.append(
130
+ dbc.Tooltip(
131
+ [
132
+ html.Div("Déclaration suspecte: vérifier les quotités déclarées"),
133
+ ],
134
+ target=cell_id,
135
+ )
136
+ )
137
+ classes += " table-warning"
138
+
139
+ return html.Td(contents, className=classes, key=f"validation-table-cell-{row}-{column}")
140
+
141
+
142
+ def agent_tooltip_txt(agent_data, data_columns):
143
+ """
144
+ Build the tooltip text associated with an agent.
145
+
146
+ :param agent_data: the dataframe row corresponding to the agent
147
+ :param data_columns: the list of columns to use to compute the total time
148
+ :return: list of html elements
149
+ """
150
+
151
+ global_params = GlobalParams()
152
+
153
+ if agent_data["hito_missing"]:
154
+ tooltip_detail = "Déclarations validées supprimées de Hito"
155
+ else:
156
+ tooltip_detail = f"Total (semaines) : {total_time(agent_data, data_columns)}"
157
+ tooltip_txt = [
158
+ html.Div(
159
+ f"{global_params.column_titles['team']}: {agent_data[global_params.columns['team']]}"
160
+ ),
161
+ html.Div(tooltip_detail),
162
+ ]
163
+
164
+ return tooltip_txt
165
+
166
+
167
+ def add_validation_declaration_selection_switch(current_set):
168
+ """
169
+ Add a dbc.RadioItems to select whether to show all declaration or only a subset.
170
+
171
+ :param current_set: currently selected declaration set
172
+ :return: dbc.RadioItems
173
+ """
174
+
175
+ return dbc.Row(
176
+ [
177
+ dbc.RadioItems(
178
+ options=[
179
+ {
180
+ "label": "Toutes les déclarations",
181
+ "value": VALIDATION_DECLARATIONS_SELECT_ALL,
182
+ },
183
+ {
184
+ "label": "Déclarations non validées uniquement",
185
+ "value": VALIDATION_DECLARATIONS_SELECT_NOT_VALIDATED,
186
+ },
187
+ {
188
+ "label": "Déclarations validées uniquement",
189
+ "value": VALIDATION_DECLARATIONS_SELECT_VALIDATED,
190
+ },
191
+ ],
192
+ value=current_set,
193
+ id=VALIDATION_DECLARATIONS_SWITCH_ID,
194
+ inline=True,
195
+ ),
196
+ ],
197
+ justify="center",
198
+ )
199
+
200
+
201
+ def agent_list(dataframe: pd.DataFrame) -> pd.DataFrame:
202
+ global_params = GlobalParams()
203
+ fullname_df = pd.DataFrame()
204
+ fullname_df[global_params.columns["fullname"]] = dataframe[
205
+ global_params.columns["fullname"]
206
+ ].drop_duplicates()
207
+ return fullname_df
208
+
209
+
210
+ def total_time(row, categories, rounded=True) -> int:
211
+ """
212
+ Compute total time declared by an agent in weeks
213
+
214
+ :param row: dataframe row for an agent
215
+ :param categories: list of categories to sum up
216
+ :param rounded: if true, returns the rounded value
217
+ :return: number of weeks declared
218
+ """
219
+
220
+ global_params = GlobalParams()
221
+
222
+ weeks_number = 0
223
+ hours_number = 0
224
+ for category in categories:
225
+ if global_params.time_unit[category] == TIME_UNIT_WEEKS:
226
+ weeks_number += row[category]
227
+ elif global_params.time_unit[category] == TIME_UNIT_HOURS:
228
+ hours_number += row[category]
229
+ else:
230
+ raise Exception(
231
+ (
232
+ f"Unsupported time unit '{global_params.time_unit[category]}'"
233
+ f" for category {category}"
234
+ )
235
+ )
236
+
237
+ weeks_number += hours_number / WEEK_HOURS
238
+
239
+ if rounded:
240
+ return int(round(weeks_number))
241
+ else:
242
+ return weeks_number
243
+
244
+
245
+ def category_time(dataframe, category) -> None:
246
+ """
247
+ Convert the number of hours into the time unit of the category. Keep track of the
248
+ conversions done to prevent doing it twice.
249
+
250
+ :param dataframe: dataframe to update
251
+ :param category: category name
252
+ :return: none, dataframe updated
253
+ """
254
+
255
+ global_params = GlobalParams()
256
+
257
+ # Default time unit is hour
258
+ unit_hour = True
259
+ if category in global_params.time_unit:
260
+ if global_params.time_unit[category] == TIME_UNIT_WEEKS:
261
+ unit_hour = False
262
+ elif global_params.time_unit[category] != TIME_UNIT_HOURS:
263
+ raise Exception(
264
+ (
265
+ f"Unsupported time unit '{global_params.time_unit[category]}'"
266
+ f" for category {category}"
267
+ )
268
+ )
269
+
270
+ if not unit_hour:
271
+ dataframe[category] = dataframe[category] / WEEK_HOURS
272
+
273
+ return
274
+
275
+
276
+ def agent_project_time(agent: str) -> html.Div:
277
+ """
278
+ Return a HTML Div with the list of projects and the time spent on them
279
+
280
+ :param agent: agent fullname
281
+ :return: html.Div
282
+ """
283
+
284
+ global_params = GlobalParams()
285
+ columns = global_params.columns
286
+
287
+ try:
288
+ session_data = global_params.session_data
289
+ df = session_data.project_declarations[
290
+ session_data.project_declarations[global_params.columns["fullname"]] == agent
291
+ ]
292
+
293
+ return html.Div(
294
+ [
295
+ html.P(
296
+ (
297
+ f"{df.iloc[i][global_params.columns['activity']]}:"
298
+ f" {' '.join(project_time(df.iloc[i][columns['activity']], df.iloc[i][columns['hours']]))}" # noqa: E501
299
+ )
300
+ )
301
+ for i in range(len(df))
302
+ ]
303
+ )
304
+
305
+ except SessionDataMissing:
306
+ return no_session_id_jumbotron()
307
+
308
+
309
+ def category_declarations(
310
+ project_declarations: pd.DataFrame, use_cache: bool = True
311
+ ) -> pd.DataFrame:
312
+ """
313
+ Process the project declarations (time per project) and convert it into declarations by
314
+ category of projects.
315
+
316
+ :param project_declarations: project declarations to consolidate
317
+ :param use_cache: if True, use and update cache
318
+ :return: dataframe
319
+ """
320
+
321
+ global_params = GlobalParams()
322
+ columns = global_params.columns
323
+
324
+ try:
325
+ session_data = global_params.session_data
326
+ except SessionDataMissing:
327
+ return no_session_id_jumbotron()
328
+
329
+ # Check if there is a cached version
330
+ if session_data.category_declarations is not None and use_cache:
331
+ return session_data.category_declarations
332
+
333
+ if project_declarations is not None:
334
+ category_declarations = project_declarations.copy()
335
+ categories = [CATEGORY_DEFAULT]
336
+ categories.extend(global_params.project_categories.keys())
337
+ for category in categories:
338
+ category_declarations[category] = category_declarations.loc[
339
+ category_declarations[columns["category"]] == category, columns["hours"]
340
+ ]
341
+ category_declarations.drop(columns=columns["hours"], inplace=True)
342
+ category_declarations.fillna(0, inplace=True)
343
+ category_declarations = category_declarations.infer_objects(copy=False)
344
+
345
+ agg_functions = {c: "sum" for c in categories}
346
+ agg_functions["suspect"] = "any"
347
+ category_declarations_pt = pd.pivot_table(
348
+ category_declarations,
349
+ index=[
350
+ global_params.columns["fullname"],
351
+ global_params.columns["team"],
352
+ global_params.columns["agent_id"],
353
+ ],
354
+ values=[*categories, "suspect"],
355
+ aggfunc=agg_functions,
356
+ )
357
+ category_declarations = pd.DataFrame(category_declarations_pt.to_records())
358
+
359
+ category_declarations["total_hours"] = category_declarations[categories].sum(axis=1)
360
+ category_declarations["percent_global"] = (
361
+ category_declarations["total_hours"] / SEMESTER_HOURS * 100
362
+ )
363
+
364
+ # Convert category time into the appropriate unit
365
+ for column_name in categories:
366
+ category_time(category_declarations, column_name)
367
+
368
+ else:
369
+ raise Exception("Project declarations are not defined")
370
+
371
+ if use_cache:
372
+ session_data.category_declarations = category_declarations
373
+
374
+ return category_declarations
375
+
376
+
377
+ def define_declaration_thresholds(period_date: str):
378
+ """
379
+ Define the declaration thresholds (low, suspect, normal) for the current period
380
+
381
+ :param period_date: a date that must be inside the declaration period
382
+ """
383
+ global_params = GlobalParams()
384
+
385
+ period_datetime = date.fromisoformat(period_date)
386
+ if period_datetime.month >= 7:
387
+ global_params.declaration_options["thresholds"]["current"] = (
388
+ global_params.declaration_options["thresholds"]["s2"]
389
+ )
390
+ else:
391
+ global_params.declaration_options["thresholds"]["current"] = (
392
+ global_params.declaration_options["thresholds"]["s1"]
393
+ )
394
+
395
+
396
+ def get_validation_data(agent_id, period_date: str, session=None):
397
+ """
398
+ Return the validation data for an agent or None if there is no entry in the database for this
399
+ agent_id.
400
+
401
+ :param agent_id: agent_id of the agent to check
402
+ :param session: DB session to use (default one if None)
403
+ :param period_date: a date that must be inside the declaration period
404
+ :return: an OSITAHValidation object or None
405
+ """
406
+ from ositah.utils.hito_db_model import OSITAHValidation
407
+
408
+ db = get_db()
409
+ if session is None:
410
+ session = db.session
411
+
412
+ validation_period = get_validation_period_data(period_date)
413
+ return (
414
+ session.query(OSITAHValidation)
415
+ .filter_by(agent_id=agent_id, period_id=validation_period.id)
416
+ .order_by(OSITAHValidation.timestamp.desc())
417
+ .first()
418
+ )
419
+
420
+
421
+ def get_validation_status(agent_id, period_date: str, session=None):
422
+ """
423
+ Return True if the agent entry has been validated, False otherwise (including if there is no
424
+ validation entry for the agent)
425
+
426
+ :param agent_id: agent_id of the agent to check
427
+ :param session: DB session to use
428
+ :param period_date: a date that must be inside the declaration period
429
+ :return: boolean
430
+ """
431
+
432
+ validation_data = get_validation_data(agent_id, period_date, session)
433
+ if validation_data is None:
434
+ return False
435
+ else:
436
+ return validation_data.validated
437
+
438
+
439
+ def get_all_validation_status(period_date: str):
440
+ """
441
+ Returns the list of agents whose declaration has been validated as a dataframe. It is intended
442
+ to be used to build the validation table but should not be used when updating the status, as
443
+ it may have been updated by somebody else.
444
+
445
+ :param period_date: a date that must be inside the declaration period
446
+ :return: dataframe
447
+ """
448
+
449
+ from ositah.utils.hito_db_model import OSITAHValidation
450
+
451
+ db = get_db()
452
+ validation_period = get_validation_period_data(period_date)
453
+ # By design, only one entry in the declaration period can be with the status validated,
454
+ # except if the database has been messed up...
455
+ validation_query = OSITAHValidation.query.filter(
456
+ OSITAHValidation.period_id == validation_period.id,
457
+ OSITAHValidation.validated,
458
+ )
459
+ validation_data = pd.read_sql(validation_query.statement, con=db.engine)
460
+ if validation_data is None:
461
+ validation_data = pd.DataFrame()
462
+ else:
463
+ validation_data.set_index("agent_id", inplace=True)
464
+
465
+ return validation_data
466
+
467
+
468
+ def validation_started(period_date: str):
469
+ """
470
+ Compare the current date with the validation start date (validation_date) and return True
471
+ if the validation has started, False otherwise.
472
+
473
+ :param period_date: date included in the declaration period
474
+ :return: boolean
475
+ """
476
+
477
+ current_date = datetime.now()
478
+ period_params = get_validation_period_data(period_date)
479
+ if current_date >= period_params.validation_date:
480
+ return True
481
+ else:
482
+ return False
483
+
484
+
485
+ def project_declaration_snapshot(
486
+ agent_id,
487
+ validation_id,
488
+ team,
489
+ team_selection_date,
490
+ period_date,
491
+ db_session=None,
492
+ commit=False,
493
+ ):
494
+ """
495
+ Save into table ositah_validation_project_declaration the validated project declarations
496
+ for an agent.
497
+
498
+ :param agent_id: agent (ID) whose project declarations must be saved
499
+ :param validation_id: validation ID associated with the declarations snapshot
500
+ :param db_session: session for the current transaction. If None, use the default one.
501
+ :param commit: if false, do not commit added rows
502
+ :return: None
503
+ """
504
+
505
+ from ositah.utils.hito_db_model import OSITAHProjectDeclaration
506
+
507
+ global_params = GlobalParams()
508
+ columns = global_params.columns
509
+ try:
510
+ session_data = global_params.session_data
511
+ except SessionDataMissing:
512
+ return no_session_id_jumbotron()
513
+
514
+ db = get_db()
515
+ if db_session:
516
+ session = db_session
517
+ else:
518
+ session = db.session
519
+
520
+ # The cache cannot be used directly as the callback may run on a server where the session
521
+ # cache for the current user doesn't exist yet
522
+ project_declarations = get_team_projects(
523
+ team, team_selection_date, period_date, DATA_SOURCE_HITO
524
+ )
525
+ agent_projects = project_declarations[project_declarations[columns["agent_id"]] == agent_id]
526
+ if agent_projects is None:
527
+ print(f"ERROR: no declaration found for agent ID '{agent_id}' (internal error)")
528
+ else:
529
+ for _, project in agent_projects.iterrows():
530
+ declaration = OSITAHProjectDeclaration(
531
+ projet=project[columns["project"]],
532
+ masterprojet=project[columns["masterproject"]],
533
+ category=project[columns["category"]],
534
+ hours=project[columns["hours"]],
535
+ quotite=project[columns["quotite"]],
536
+ validation_id=validation_id,
537
+ hito_project_id=project[columns["activity_id"]],
538
+ )
539
+ try:
540
+ session.add(declaration)
541
+ except Exception:
542
+ # If the default session is used, let the caller process the exception and
543
+ # eventually do the rollback
544
+ if db_session:
545
+ session.rollback()
546
+ raise
547
+
548
+ # Reset the cache of validated declarations if a modification occured
549
+ session_data.reset_validated_declarations_cache()
550
+
551
+ if commit:
552
+ session.commit