ositah 24.4.dev1__py3-none-any.whl → 24.7.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.
Files changed (47) hide show
  1. {ositah-24.4.dev1.dist-info → ositah-24.7.dev1.dist-info}/METADATA +1 -1
  2. ositah-24.7.dev1.dist-info/RECORD +6 -0
  3. {ositah-24.4.dev1.dist-info → ositah-24.7.dev1.dist-info}/WHEEL +1 -1
  4. ositah/__init__.py +0 -0
  5. ositah/app.py +0 -17
  6. ositah/apps/__init__.py +0 -0
  7. ositah/apps/analysis.py +0 -774
  8. ositah/apps/configuration/__init__.py +0 -0
  9. ositah/apps/configuration/callbacks.py +0 -917
  10. ositah/apps/configuration/main.py +0 -542
  11. ositah/apps/configuration/parameters.py +0 -74
  12. ositah/apps/configuration/tools.py +0 -112
  13. ositah/apps/export.py +0 -1172
  14. ositah/apps/validation/__init__.py +0 -0
  15. ositah/apps/validation/callbacks.py +0 -240
  16. ositah/apps/validation/main.py +0 -89
  17. ositah/apps/validation/parameters.py +0 -25
  18. ositah/apps/validation/tables.py +0 -654
  19. ositah/apps/validation/tools.py +0 -533
  20. ositah/assets/arrow_down_up.svg +0 -4
  21. ositah/assets/ositah.css +0 -54
  22. ositah/assets/sort_ascending.svg +0 -5
  23. ositah/assets/sort_descending.svg +0 -6
  24. ositah/assets/sorttable.js +0 -499
  25. ositah/main.py +0 -449
  26. ositah/ositah.example.cfg +0 -215
  27. ositah/static/style.css +0 -54
  28. ositah/templates/base.html +0 -22
  29. ositah/templates/bootstrap_login.html +0 -38
  30. ositah/templates/login_form.html +0 -27
  31. ositah/utils/__init__.py +0 -0
  32. ositah/utils/agents.py +0 -117
  33. ositah/utils/authentication.py +0 -287
  34. ositah/utils/cache.py +0 -19
  35. ositah/utils/core.py +0 -13
  36. ositah/utils/exceptions.py +0 -64
  37. ositah/utils/hito_db.py +0 -51
  38. ositah/utils/hito_db_model.py +0 -245
  39. ositah/utils/menus.py +0 -339
  40. ositah/utils/period.py +0 -135
  41. ositah/utils/projects.py +0 -1175
  42. ositah/utils/teams.py +0 -42
  43. ositah/utils/utils.py +0 -459
  44. ositah-24.4.dev1.dist-info/RECORD +0 -46
  45. {ositah-24.4.dev1.dist-info → ositah-24.7.dev1.dist-info}/LICENSE +0 -0
  46. {ositah-24.4.dev1.dist-info → ositah-24.7.dev1.dist-info}/entry_points.txt +0 -0
  47. {ositah-24.4.dev1.dist-info → ositah-24.7.dev1.dist-info}/top_level.txt +0 -0
ositah/utils/projects.py DELETED
@@ -1,1175 +0,0 @@
1
- # Helper functions related to projects and time declarations
2
-
3
- import re
4
- from datetime import datetime
5
- from typing import List, Tuple
6
-
7
- import numpy as np
8
- import pandas as pd
9
- from sqlalchemy.orm import joinedload
10
-
11
- from ositah.utils.agents import get_agents
12
- from ositah.utils.cache import clear_cached_data
13
- from ositah.utils.exceptions import InvalidDataSource, InvalidHitoProjectName
14
- from ositah.utils.hito_db import get_db
15
- from ositah.utils.period import get_validation_period_data
16
- from ositah.utils.utils import (
17
- DAY_HOURS,
18
- TEAM_LIST_ALL_AGENTS,
19
- TIME_UNIT_HOURS,
20
- TIME_UNIT_HOURS_EN,
21
- TIME_UNIT_HOURS_FR,
22
- TIME_UNIT_WEEKS,
23
- TIME_UNIT_WEEKS_EN,
24
- TIME_UNIT_WEEKS_FR,
25
- WEEK_HOURS,
26
- GlobalParams,
27
- )
28
-
29
- CATEGORY_DEFAULT = "nsip_project"
30
-
31
- DATA_SOURCE_HITO = "hito"
32
- DATA_SOURCE_OSITAH = "ositah"
33
-
34
- NSIP_CLASS_OTHER_ACTIVITY = "activitensipreferentiel"
35
- NSIP_CLASS_PROJECT = "projetnsipreferentiel"
36
-
37
- MASTERPROJECT_DELETED_ACTIVITY = "Disabled"
38
- MASTERPROJECT_LOCAL_PROJECT = "Local Projects"
39
-
40
- NSIP_PROJECT_ORDER = 1
41
- LOCAL_PROJECT_ORDER = 2
42
- NSIP_ACIVITY_ORDER = 3
43
- DISABLED_ACTIVITY_ORDER = 9999
44
-
45
-
46
- def hito2ositah_project_name(hito_name):
47
- """
48
- Split a Hito project name into a masterprojet and project name
49
-
50
- :param hito_name: Hito name with masterprojet and project name separated by a /
51
- :return: masterprojet and project name
52
- """
53
- masterproject, project_name = hito_name.split(" / ", 2)
54
- return masterproject, project_name
55
-
56
-
57
- def ositah2hito_project_name(masterproject, project):
58
- """
59
- Build the Hito/NSIP project name from the masterproject and project
60
-
61
- :param masterproject: masterproject name
62
- :param project: project name
63
- :return: Hito/NSIP project fullname
64
- """
65
- return " / ".join([masterproject, project])
66
-
67
-
68
- def nsip2ositah_project_name(masterproject, project):
69
- """
70
- Build the OSITAH project name from the NSIP project name, removing the master project
71
- name if it is at the head of the NSIP project name, except if the master project name
72
- and the project name are identical.
73
-
74
- :param masterproject: masterproject name
75
- :param project: project name
76
- :return: OSITAH project name (without the masterproject name)
77
- """
78
-
79
- if project != masterproject:
80
- m = re.match(rf"{masterproject}\s+\-\s+(?P<project>.*)", project)
81
- if m:
82
- project = m.group("project")
83
-
84
- return project
85
-
86
-
87
- def category_from_activity(category_patterns, activity) -> str:
88
- """
89
- Return the activity category if the activity matches the pattern. Else an empty string.
90
- Called as a lambda to build the category column.
91
-
92
- :param category_patterns: category patterns to match against the activity (dict where
93
- the key is the pattern and the value is the category)
94
- :param activity: activity name
95
- :return: category or np.Nan
96
- """
97
-
98
- for pattern, category in category_patterns.items():
99
- if re.match(pattern.lower(), activity.lower()):
100
- return category
101
-
102
- return np.NaN
103
-
104
-
105
- def activity_from_project(project):
106
- """
107
- Return the activity the project belongs to.
108
-
109
- :param project: project name
110
- :return: activity name
111
- """
112
-
113
- global_params = GlobalParams()
114
-
115
- for activity, pattern in global_params.project_categories.items():
116
- if re.match(pattern, project):
117
- return activity
118
-
119
- return CATEGORY_DEFAULT
120
-
121
-
122
- def reference_masterproject(reference_type):
123
- """
124
- Return an OSITAH masterproject for a NSIP reference, based on its type.
125
- Masterprojects for each type is defined in the configuration. A reference type
126
- without a match or with an empty value is ignored (np.NaN returned).
127
-
128
- :param reference_type: NSIP reference type
129
- :return: matching master project
130
- """
131
-
132
- global_params = GlobalParams()
133
-
134
- for type_pattern, masterproject in global_params.reference_masterprojects.items():
135
- if re.match(type_pattern.lower(), reference_type.lower()):
136
- if len(masterproject) > 0:
137
- return masterproject
138
- else:
139
- return np.NaN
140
-
141
- return np.NaN
142
-
143
-
144
- def time_unit(category, short=False, english=True, parenthesis=False) -> str:
145
- """
146
- Return the time unit as defined in the configuration as a string. If the category/column is
147
- not in the configuration, return an empty string.
148
-
149
- :param category: project category/class
150
- :param short: if true, return abbreviated unit names
151
- :param english: return english unit names if true. Also implies short=False
152
- :param parenthesis: if True, enclose the string in ()
153
- :return: time unit for the category as a string
154
- """
155
-
156
- global_params = GlobalParams()
157
-
158
- if english:
159
- unit_w = TIME_UNIT_WEEKS_EN
160
- unit_h = TIME_UNIT_HOURS_EN
161
- else:
162
- if short:
163
- unit_w = "sem."
164
- unit_h = "h"
165
- else:
166
- unit_w = TIME_UNIT_WEEKS_FR
167
- unit_h = TIME_UNIT_HOURS_FR
168
-
169
- if category in global_params.time_unit:
170
- if global_params.time_unit[category] == TIME_UNIT_WEEKS:
171
- unit_str = unit_w
172
- elif global_params.time_unit[category] == TIME_UNIT_HOURS:
173
- unit_str = unit_h
174
- else:
175
- raise Exception(
176
- (
177
- f"Unsupported time unit '{global_params.time_unit[category]}'"
178
- f" for category {category}"
179
- )
180
- )
181
- else:
182
- return ""
183
-
184
- if parenthesis:
185
- return f"({unit_str})"
186
- else:
187
- return unit_str
188
-
189
-
190
- def category_time_and_unit(category, hours, short=True, english=False) -> Tuple[int, str]:
191
- """
192
- Return the rounded category time in the appropriate unit and the category time unit
193
-
194
- :param category: project category/class
195
- :param hours: number of hours
196
- :param short: if true, return abbreviated unit names
197
- :param english: return english unit names if true. Also implies short=False
198
- :return: project time, project unit
199
- """
200
-
201
- global_params = GlobalParams()
202
-
203
- unit = time_unit(category, short, english)
204
-
205
- if global_params.time_unit[category] == "w":
206
- declared_time = f"{int(round(hours / WEEK_HOURS))}"
207
- else:
208
- declared_time = f"{int(round(hours))}"
209
-
210
- return declared_time, unit
211
-
212
-
213
- def project_time(project, hours):
214
- """
215
- Return the rounded project time in the appropriate unit and the project time unit
216
-
217
- :param project: project name
218
- :param hours: number of hours
219
- :return: project time, abbreviated project unit
220
- """
221
-
222
- return category_time_and_unit(activity_from_project(project), hours)
223
-
224
-
225
- def get_team_projects(
226
- team,
227
- team_selection_date,
228
- period_date: datetime,
229
- source=DATA_SOURCE_HITO,
230
- use_cache: bool = True,
231
- ):
232
- """
233
- Query the Hito database and return a dataframe will all the project contributions for a given
234
- team. The dataframe has one row for each each agent contribution to each project.
235
-
236
- :param team: selected team or TEAM_LIST_ALL_AGENTS for all teams
237
- :param team_selection_date: last time the team selection was changed
238
- :param period_date: a date that must be inside the declaration period
239
- :param source: whether to use Hito (non validated) or OSITAH (validated) as a data source
240
- :param use_cache: if true, use the cache if defined and up-to-date or update it with the
241
- new declarations
242
- :return: dataframe or None if the query returned no entry
243
- """
244
-
245
- from ositah.utils.hito_db_model import (
246
- ActiviteDetail,
247
- Agent,
248
- OSITAHProjectDeclaration,
249
- OSITAHValidation,
250
- Projet,
251
- Team,
252
- )
253
-
254
- global_params = GlobalParams()
255
- columns = global_params.columns
256
- session_data = global_params.session_data
257
- db = get_db()
258
-
259
- validation_period = get_validation_period_data(period_date)
260
-
261
- # Check if there is a cached version
262
- if session_data.project_declarations is not None and use_cache:
263
- if (
264
- session_data.project_declarations_source is None
265
- or source != session_data.project_declarations_source
266
- or datetime.fromisoformat(team_selection_date) > session_data.cache_date
267
- ):
268
- # Cache must be refreshed if the selected source doesn't match the cached one or if
269
- # the team has been modified since the cache was loaded (required for multi-worker
270
- # configurations as the team selection does not necessarily happen on the same worker
271
- # than the later processing). In a multi-worker configuration is used it may also
272
- # happen that the declaration source is not defined if it was initially initialised
273
- # on another worker
274
- clear_cached_data()
275
- else:
276
- return session_data.project_declarations
277
-
278
- if source == DATA_SOURCE_OSITAH:
279
- # The query relies on the fact that only one validation entry can be in the validated
280
- # state for a given period, something enforced by the declaration validation.
281
- # When a team is specified, display all projects from this team and the children teams
282
- query = (
283
- OSITAHProjectDeclaration.query.join(
284
- OSITAHValidation,
285
- OSITAHProjectDeclaration.validation_id == OSITAHValidation.id,
286
- )
287
- .join(Agent, Agent.id == OSITAHValidation.agent_id)
288
- .join(Team, Team.id == Agent.team_id)
289
- .add_entity(Agent)
290
- .add_entity(Team)
291
- .add_entity(OSITAHValidation)
292
- .filter(OSITAHValidation.validated)
293
- .filter(OSITAHValidation.period_id == validation_period.id)
294
- )
295
- if team != TEAM_LIST_ALL_AGENTS:
296
- query = query.filter(Team.nom.ilike(f"{team}%"))
297
- declarations = pd.read_sql(query.statement, db.session.bind)
298
- if len(declarations) == 0:
299
- return None
300
- declarations.rename(columns={"id": columns["activity_id"]}, inplace=True)
301
- declarations.rename(columns={"id_1": columns["agent_id"]}, inplace=True)
302
- declarations.rename(columns={"nom_1": columns["team"]}, inplace=True)
303
- declarations.rename(columns={"hours": columns["hours"]}, inplace=True)
304
- declarations.rename(columns={"id_3": "validation_id"}, inplace=True)
305
- declarations.rename(columns={"timestamp": "validation_time"}, inplace=True)
306
- # Drop statut column to avoid conflicts in future merge with the Agent table
307
- declarations.drop(columns=["statut"], inplace=True)
308
- # Ensure that email_auth is defined and if it is not, replace it by the email.
309
- declarations.loc[declarations[columns["email_auth"]].isna(), columns["email_auth"]] = (
310
- declarations[columns["email"]]
311
- )
312
- declarations[columns["activity"]] = declarations.apply(
313
- lambda row: ositah2hito_project_name(
314
- row[columns["masterproject"]], row[columns["project"]]
315
- ),
316
- axis=1,
317
- )
318
-
319
- elif source == DATA_SOURCE_HITO:
320
- # For team names, we want to keep the agent team name instead of the team_name in
321
- # activity_details so it must be specified explicitely in the join with Team table
322
- query = (
323
- ActiviteDetail.query.join(Projet)
324
- .join(Agent)
325
- .join(Team, Team.id == Agent.team_id)
326
- .add_entity(Projet)
327
- .add_entity(Agent)
328
- .add_entity(Team)
329
- .filter(
330
- ActiviteDetail.date >= validation_period.start_date,
331
- ActiviteDetail.date <= validation_period.end_date,
332
- )
333
- )
334
- if team != TEAM_LIST_ALL_AGENTS:
335
- query = query.filter(Team.nom.ilike(f"{team}%"))
336
- daily_declarations = pd.read_sql(query.statement, db.session.bind)
337
- if len(daily_declarations) == 0:
338
- return None
339
- # Pandas add a suffix to duplicate column names, the first one being unchanged, the
340
- # second being suffixed _1...
341
- daily_declarations.drop(columns=["id", "id_1", "id_2"], inplace=True)
342
- daily_declarations.rename(columns={"agent_id": columns["agent_id"]}, inplace=True)
343
- daily_declarations.rename(columns={"libelle": columns["activity"]}, inplace=True)
344
- daily_declarations.rename(columns={"nom_1": columns["team"]}, inplace=True)
345
- for column in [columns["hours"], columns["percent"]]:
346
- daily_declarations[column] = daily_declarations[column].astype(float)
347
- # Ensure that email_auth is defined and if it is not, replace it by the email. If left
348
- # undefined, the entries will not be present in the pivot table as it is part of the index.
349
- daily_declarations.loc[
350
- daily_declarations[columns["email_auth"]].isna(), columns["email_auth"]
351
- ] = daily_declarations[columns["email"]]
352
- # Rebuild agent quotite by comparing the time declared with the percent computed by Hito
353
- # based on the quotite
354
- daily_declarations[columns["quotite"]] = (
355
- daily_declarations[columns["hours"]] / DAY_HOURS * 100
356
- ) / daily_declarations[columns["percent"]]
357
- global_declarations_pt = pd.pivot_table(
358
- daily_declarations,
359
- index=[
360
- columns["lastname"],
361
- columns["firstname"],
362
- columns["activity"],
363
- columns["activity_id"],
364
- columns["team"],
365
- columns["agent_id"],
366
- columns["email_auth"],
367
- columns["email"],
368
- ],
369
- values=[columns["hours"], columns["quotite"]],
370
- aggfunc={columns["hours"]: np.sum, columns["quotite"]: np.mean},
371
- )
372
- declarations = pd.DataFrame(global_declarations_pt.to_records())
373
- declarations[[columns["masterproject"], columns["project"]]] = declarations[
374
- columns["activity"]
375
- ].str.split(" / ", n=1, expand=True)
376
- # An entry in the pseudo master project MASTERPROJECT_DELETED_ACTIVITY is a special
377
- # case corresponding to deleted NSIP projects: the real name is in the project part that
378
- # must be parsed as for any other project
379
- declarations["project_saved"] = np.NaN
380
- declarations["project_saved"] = declarations["project_saved"].astype("object")
381
- declarations.loc[
382
- declarations[columns["masterproject"]] == MASTERPROJECT_DELETED_ACTIVITY,
383
- "project_saved",
384
- ] = declarations[columns["project"]]
385
- # Not sure why the following line doesn't work (masterproject and project set to NaN
386
- # if no row matches the indexing condition... An issue has been open:
387
- # https://github.com/pandas-dev/pandas/issues/44726.
388
- # declarations.loc[
389
- # declarations.project_saved.notna(),
390
- # [columns["masterproject"], columns["project"]],
391
- # ] = declarations.project_saved.str.split(" / ", n=1, expand=True)
392
- #
393
- # The following workaround fails if project_saved contains only np.NaN. It is a known
394
- # issue in Panda 1.3.4, see https://github.com/pandas-dev/pandas/issues/35807
395
- # declarations[
396
- # ["newmaster", "newproject"]
397
- # ] = declarations.project_saved.str.split(" / ", n=1, expand=True)
398
- #
399
- # Workaround based on
400
- # https://github.com/pandas-dev/pandas/issues/35807#issuecomment-676912441. If no row
401
- # matches the condition, only one column is created thus the need to check they all
402
- # exist.
403
- tmp_columns = ["newmaster", "newproject"]
404
- saved_projects = (
405
- declarations["project_saved"]
406
- .str.split("/", expand=True, n=len(tmp_columns) - 1)
407
- .rename(columns={k: name for k, name in enumerate(tmp_columns)})
408
- )
409
- for column in tmp_columns:
410
- if column not in saved_projects.columns:
411
- saved_projects[column] = np.NaN
412
- declarations = declarations.join(saved_projects)
413
- declarations.loc[declarations.project_saved.notna(), columns["masterproject"]] = (
414
- declarations.newmaster
415
- )
416
- declarations.loc[declarations.project_saved.notna(), columns["project"]] = (
417
- declarations.newproject
418
- )
419
- declarations.drop(columns=["newmaster", "newproject"], inplace=True)
420
- declarations.loc[declarations.project_saved.notna(), columns["activity"]] = (
421
- declarations.project_saved
422
- )
423
-
424
- # Detect project names not matching the format "masterproject / project"
425
- invalid_hito_projects = declarations.loc[declarations[columns["project"]].isnull()]
426
- if not invalid_hito_projects.empty:
427
- raise InvalidHitoProjectName(
428
- pd.Series(invalid_hito_projects[columns["masterproject"]]).unique()
429
- )
430
-
431
- else:
432
- raise InvalidDataSource(source)
433
-
434
- declarations[columns["fullname"]] = declarations[
435
- [columns["lastname"], columns["firstname"]]
436
- ].agg(" ".join, axis=1)
437
- declarations[columns["category"]] = declarations.apply(
438
- lambda row: category_from_activity(
439
- global_params.category_patterns, row[columns["activity"]]
440
- ),
441
- axis=1,
442
- )
443
- declarations.loc[declarations[columns["category"]].isna(), "category"] = CATEGORY_DEFAULT
444
-
445
- # Check quotite < 50% and flag the entry as suspect (generally means confusion between quotite
446
- # and percent during declaration)
447
- declarations["suspect"] = declarations[columns["quotite"]] < 0.5
448
-
449
- if use_cache:
450
- session_data.set_project_declarations(declarations, source)
451
-
452
- return declarations
453
-
454
-
455
- def get_all_hito_activities(project_activity: bool):
456
- """
457
- Retrieve all projects or all activities defined in Hito with their associated teams
458
-
459
- :param project_activity: if true, return all projects, else all Hito activities
460
- :return: dataframe
461
- """
462
-
463
- from ositah.utils.hito_db_model import Activite, Projet
464
-
465
- global_params = GlobalParams()
466
- session_data = global_params.session_data
467
- db = get_db()
468
-
469
- # Check if there is a cached version
470
- if session_data.get_hito_activities(project_activity) is not None:
471
- return session_data.get_hito_activities(project_activity)
472
-
473
- else:
474
- if project_activity:
475
- Activity = Projet
476
- else:
477
- Activity = Activite
478
-
479
- query = Activity.query.options(joinedload(Activity.teams))
480
- activities = pd.read_sql(query.statement, db.session.bind)
481
- activities[["masterproject", "project"]] = activities.libelle.str.split(
482
- " / ", n=1, expand=True
483
- )
484
- activities.rename(columns={"description_1": "team_description"}, inplace=True)
485
- activities.rename(columns={"nom": "team_name"}, inplace=True)
486
-
487
- session_data.set_hito_activities(activities, project_activity)
488
-
489
- return activities
490
-
491
-
492
- def build_projects_data(team, team_selection_date, period_date: str, source):
493
- """
494
- Build the project list contributed by the selected team and return it as a dataframe
495
-
496
- :param team: selected team
497
- :param team_selection_date: last time the team selection was changed
498
- :param period_date: a date that must be inside the declaration period
499
- :param source: whether to use Hito (non validated) or OSITAH (validated) as a data source
500
- :return: dataframe with projects data, dataframe with agent declarations
501
- """
502
-
503
- global_params = GlobalParams()
504
- columns = global_params.columns
505
- session_data = global_params.session_data
506
-
507
- declaration_list = get_team_projects(team, team_selection_date, period_date, source)
508
- if declaration_list is None:
509
- return None, None
510
-
511
- projects_data = session_data.projects_data
512
- if projects_data is None:
513
- projects_data_pt = pd.pivot_table(
514
- declaration_list,
515
- index=[
516
- columns["masterproject"],
517
- columns["project"],
518
- columns["activity"],
519
- columns["category"],
520
- ],
521
- values=[columns["hours"]],
522
- aggfunc={columns["hours"]: np.sum},
523
- )
524
- projects_data = pd.DataFrame(projects_data_pt.to_records())
525
- projects_data[columns["hours"]] = np.round(projects_data[columns["hours"]]).astype("int")
526
- projects_data[columns["weeks"]] = np.round(projects_data[columns["hours"]] / WEEK_HOURS, 1)
527
- short_name_len = 25
528
- projects_data["project_short"] = projects_data[columns["project"]]
529
- projects_data.loc[
530
- projects_data["project_short"].str.len() > short_name_len, "project_short"
531
- ] = projects_data["project_short"].str.slice_replace(start=short_name_len - 4, repl="...")
532
- session_data.projects_data = projects_data
533
-
534
- return projects_data, declaration_list
535
-
536
-
537
- def get_hito_nsip_activities(project_activity: bool = True):
538
- """
539
- Return a dataframe with all the NSIP activities defined in Hito. Activities can be
540
- either projects or "references" (other activities). An activity is considered as a
541
- NSIP activity if it has a matching entry in Hito referentiel.
542
-
543
- :param project_activity: if true, return projects, else references
544
- :return: dataframe
545
- """
546
-
547
- from ositah.utils.hito_db_model import Projet, Referentiel
548
-
549
- db = get_db()
550
-
551
- if project_activity:
552
- project_join_id = Projet.projet_nsip_referentiel_id
553
- referentiel_class = "projetnsipreferentiel"
554
- else:
555
- project_join_id = Projet.activite_nsip_referentiel_id
556
- referentiel_class = "activitensipreferentiel"
557
-
558
- activity_query = (
559
- Projet.query.join(Referentiel, Referentiel.id == project_join_id)
560
- .add_entity(Referentiel)
561
- .filter(
562
- Referentiel.object_class == referentiel_class,
563
- )
564
- )
565
-
566
- activities = pd.read_sql(activity_query.statement, db.session.bind)
567
-
568
- activities.drop(
569
- columns=[
570
- "ordre",
571
- "projet_nsip_referentiel_id",
572
- "activite_nsip_referentiel_id",
573
- ],
574
- inplace=True,
575
- )
576
- activities.rename(columns={"id_1": "referentiel_id"}, inplace=True)
577
- activities.rename(columns={"libelle_1": "nsip_name_id"}, inplace=True)
578
-
579
- if activities.empty:
580
- activities[["nsip_master", "nsip_project", "nsip_project_id", "nsip_reference_id"]] = [
581
- np.NaN,
582
- np.NaN,
583
- np.NaN,
584
- np.NaN,
585
- ]
586
- else:
587
- activities[["nsip_master", "nsip_project", "nsip_project_id", "nsip_reference_id"]] = (
588
- activities.apply(
589
- lambda v: nsip_activity_name_id(v["nsip_name_id"], v["class"]),
590
- axis=1,
591
- result_type="expand",
592
- )
593
- )
594
- activities["nsip_project_id"] = activities["nsip_project_id"].astype(int)
595
- activities["nsip_reference_id"] = activities["nsip_reference_id"].astype(int)
596
-
597
- return activities
598
-
599
-
600
- def get_hito_projects():
601
- """
602
- Return a dataframe with the information about all projects with validated declarations
603
- in the current declaration period defined in Hito and their relationship to NSIP, if relevant.
604
-
605
- :return: Hito project dataframe
606
- """
607
-
608
- from ositah.utils.hito_db_model import (
609
- OSITAHProjectDeclaration,
610
- OSITAHValidation,
611
- Projet,
612
- Referentiel,
613
- )
614
-
615
- db = get_db()
616
-
617
- projects_query = (
618
- Projet.query.join(Referentiel, Referentiel.id == Projet.projet_nsip_referentiel_id)
619
- .join(OSITAHProjectDeclaration)
620
- .join(OSITAHValidation)
621
- .add_entity(Referentiel)
622
- .add_entity(OSITAHValidation)
623
- .filter(
624
- Referentiel.object_class == "projetnsipreferentiel",
625
- OSITAHValidation.validated,
626
- )
627
- )
628
- projects = pd.read_sql(projects_query.statement, db.session.bind)
629
-
630
- activities_query = (
631
- Projet.query.join(Referentiel, Referentiel.id == Projet.activite_nsip_referentiel_id)
632
- .join(OSITAHProjectDeclaration)
633
- .join(OSITAHValidation)
634
- .add_entity(Referentiel)
635
- .add_entity(OSITAHValidation)
636
- .filter(
637
- Referentiel.object_class == "activitensipreferentiel",
638
- OSITAHValidation.validated,
639
- )
640
- )
641
- activities = pd.read_sql(activities_query.statement, db.session.bind)
642
-
643
- projects_activities = pd.concat([projects, activities], ignore_index=True)
644
-
645
- projects_activities.drop(
646
- columns=[
647
- "id_2",
648
- "ordre",
649
- "projet_nsip_referentiel_id",
650
- "activite_nsip_referentiel_id",
651
- "agent_id",
652
- ],
653
- inplace=True,
654
- )
655
- projects_activities.rename(columns={"id_1": "referentiel_id"}, inplace=True)
656
- projects_activities.rename(columns={"libelle_1": "nsip_name_id"}, inplace=True)
657
- projects_activities.drop_duplicates(subset=["id"], inplace=True)
658
-
659
- if projects_activities.empty:
660
- projects_activities[
661
- ["nsip_master", "nsip_project", "nsip_project_id", "nsip_reference_id"]
662
- ] = [np.NaN, np.NaN, np.NaN, np.NaN]
663
- else:
664
- projects_activities[
665
- ["nsip_master", "nsip_project", "nsip_project_id", "nsip_reference_id"]
666
- ] = projects_activities.apply(
667
- lambda v: nsip_activity_name_id(v["nsip_name_id"], v["class"]),
668
- axis=1,
669
- result_type="expand",
670
- )
671
- projects_activities["nsip_project_id"] = projects_activities["nsip_project_id"].astype(int)
672
- projects_activities["nsip_reference_id"] = projects_activities["nsip_reference_id"].astype(
673
- int
674
- )
675
-
676
- return projects_activities
677
-
678
-
679
- def nsip_activity_name_id(hito_name: str, type: str) -> List[str]:
680
- """
681
- Split the NISP activity project name in Hito referentiel in 3 parts: masterproject name,
682
- project name, project ID and return the project ID as the project ID (3d value) or reference
683
- ID (4th value) depending on the activity type. The unused ID is set to 0 rather than np.NaN
684
- or pd.NA as the column may be used in merges.
685
-
686
- :param hito_name: activity name in Hito referentiel
687
- :param type: referentiel class
688
- :return:
689
- """
690
-
691
- m = re.match(
692
- r"(?P<master>.*?)\s+/\s+(?P<project>.*)\s+\(NSIP ID:\s*(?P<id>\w+)\)$",
693
- hito_name,
694
- )
695
- if m:
696
- try:
697
- _ = int(m.group("id"))
698
- except ValueError:
699
- print(
700
- (
701
- f"ERROR: invalid NSIP ID in Hito referentiel for '{m.group('master')} /"
702
- f" {m.group('project')}' (ID={m.group('id')})"
703
- )
704
- )
705
- return m.group("master"), m.group("project"), 0, 0
706
- if type == NSIP_CLASS_PROJECT:
707
- project_id = m.group("id")
708
- reference_id = 0
709
- else:
710
- project_id = 0
711
- reference_id = m.group("id")
712
- return m.group("master"), m.group("project"), project_id, reference_id
713
- else:
714
- print(
715
- (
716
- f"ERROR: invalid Hito referentiel entry format, cannot be parsed as"
717
- f" master/project/id ({hito_name})"
718
- )
719
- )
720
- return np.NaN, np.NaN, 0, 0
721
-
722
-
723
- def get_nsip_declarations(period_date: str, team: str):
724
- """
725
- Return the NSIP declaration list for the declaration period matching a given date (the
726
- date must be included in the period) as a dataframe
727
-
728
- :param period_date: date that must be inside the period
729
- :param team: selected team
730
- :return: declaration list as a dataframe
731
- """
732
-
733
- global_params = GlobalParams()
734
-
735
- if global_params.nsip:
736
- declarations = pd.json_normalize(global_params.nsip.get_declarations(period_date))
737
- declarations.rename(columns={"id": "id_declaration"}, inplace=True)
738
- # Set NaN to 0 in reference as np.NaN is a float and prevent casting to int. As it will
739
- # be used in a merge better to have a 0 than a NaN.
740
- if "project.id" in declarations.columns:
741
- declarations.loc[declarations["project.id"].isna(), "project.id"] = 0
742
- declarations["project.id"] = declarations["project.id"].astype(int)
743
- else:
744
- declarations["project.id"] = 0
745
- if "reference.id" in declarations.columns:
746
- declarations.loc[declarations["reference.id"].isna(), "reference.id"] = 0
747
- declarations["reference.id"] = declarations["reference.id"].astype(int)
748
- else:
749
- declarations["reference.id"] = 0
750
- declarations["nsip_fullname"] = (
751
- declarations["agent.lastname"] + " " + declarations["agent.firstname"]
752
- )
753
-
754
- if team != TEAM_LIST_ALL_AGENTS:
755
- team_agents = get_agents(period_date, team)
756
- agent_emails = team_agents["email_auth"]
757
- declarations = declarations.merge(
758
- agent_emails,
759
- how="inner",
760
- left_on="agent.email",
761
- right_on="email_auth",
762
- suffixes=[None, "_agent"],
763
- )
764
-
765
- return declarations
766
-
767
- else:
768
- return None
769
-
770
-
771
- def get_nsip_activities(project_activity: bool):
772
- """
773
- Retrieve laboratory activities defined in NSIP and return them in a dataframe.
774
- Activities can be either projects or references (other activities).
775
-
776
- :param project_activity: true for projects, false for other activities
777
- :return: dataframe or None if NSIP is not configured
778
- """
779
-
780
- global_params = GlobalParams()
781
-
782
- if global_params.nsip:
783
- activities = pd.json_normalize(
784
- global_params.nsip.get_activities(project_activity), record_prefix=True
785
- )
786
- if not activities.empty:
787
- if project_activity:
788
- activities["ositah_name"] = activities.apply(
789
- lambda p: nsip2ositah_project_name(p["master_project.name"], p["name"]),
790
- axis=1,
791
- )
792
- else:
793
- activities["master_project.name"] = activities.apply(
794
- lambda p: reference_masterproject(p["type"]),
795
- axis=1,
796
- )
797
- activities.drop(
798
- activities[activities["master_project.name"].isna()].index,
799
- inplace=True,
800
- )
801
- activities["ositah_name"] = activities["name"]
802
-
803
- return activities
804
- else:
805
- return None
806
-
807
-
808
- def build_activity_libelle(
809
- nsip_id: str,
810
- master_project: str,
811
- project: str,
812
- ):
813
- """
814
- Build Hito project name and referentiel entry name from NSIP master project, project name and
815
- project id.
816
-
817
- :param nsip_id: NSIP ID for the project
818
- :param master_project: master project name
819
- :param project: project name
820
- :return: Hito project name, Hito referentiel name
821
- """
822
-
823
- new_project_name = f"{master_project} / {project}"
824
- new_referentiel_name = f"{new_project_name} (NSIP ID: {nsip_id})"
825
- return new_project_name, new_referentiel_name
826
-
827
-
828
- def update_activity_name(
829
- hito_project_id: str,
830
- hito_referentiel_id: str,
831
- nsip_id: str,
832
- master_project: str,
833
- project: str,
834
- ):
835
- """
836
- Update a project name in Hito, both in the referentiel and in the project/activity table.
837
-
838
- :param hito_project_id: Hito project ID
839
- :param hito_referentiel_id: Hito referentiel ID for the project
840
- :param nsip_id: NSIP ID for the project
841
- :param master_project: master project name
842
- :param project: project name
843
- :return: 0 if update succeeded, non-zero if an error occured, error_msg if
844
- an error occured
845
- """
846
-
847
- from ositah.utils.hito_db_model import Projet, Referentiel
848
-
849
- db = get_db()
850
-
851
- status = 0 # Assume success
852
- error_msg = ""
853
- new_project_name, new_referentiel_name = build_activity_libelle(
854
- nsip_id,
855
- master_project,
856
- project,
857
- )
858
-
859
- try:
860
- referentiel_entry = Referentiel.query.filter(Referentiel.id == hito_referentiel_id).first()
861
- project_entry = Projet.query.filter(Projet.id == hito_project_id).first()
862
- referentiel_entry.libelle = new_referentiel_name
863
- project_entry.libelle = new_project_name
864
- change_log_msg = f"Modifié le {datetime.now()}"
865
- if project_entry.description:
866
- project_entry.description += f"; {change_log_msg}"
867
- else:
868
- project_entry.description = change_log_msg
869
- db.session.commit()
870
- except Exception as e:
871
- status = 1
872
- error_msg = getattr(e, "message", repr(e))
873
- db.session.rollback()
874
-
875
- return status, error_msg
876
-
877
-
878
- def add_activity(
879
- nsip_id: str,
880
- master_project: str,
881
- project: str,
882
- activity_teams: List[str],
883
- project_activity: bool,
884
- ):
885
- """
886
- Adds a new project in Hito referenciel and in Hito project/activity table
887
-
888
- :param nsip_id: NSIP ID for the project
889
- :param master_project: master project name
890
- :param project: project name
891
- :param activity_teams: list of team IDs associated with the project
892
- :param project_activity: if True it is a NSIP project, else a NSIP activity
893
- :return: 0 if update succeeded, non-zero if an error occured, error_msg if
894
- an error occured
895
- """
896
-
897
- from ositah.utils.hito_db_model import Projet, Referentiel, Team
898
-
899
- db = get_db()
900
-
901
- status = 0 # Assume success
902
- error_msg = ""
903
- project_name, referentiel_name = build_activity_libelle(
904
- nsip_id,
905
- master_project,
906
- project,
907
- )
908
-
909
- if project_activity:
910
- entry_class = "projetnsipreferentiel"
911
- entry_order = NSIP_PROJECT_ORDER
912
- else:
913
- entry_class = "activitensipreferentiel"
914
- entry_order = NSIP_ACIVITY_ORDER
915
-
916
- try:
917
- referentiel_entry = Referentiel(
918
- libelle=referentiel_name,
919
- object_class=entry_class,
920
- ordre=entry_order,
921
- )
922
- activity_entry = Projet(
923
- libelle=project_name,
924
- description=f"Créé le {datetime.now()}",
925
- ordre=entry_order,
926
- )
927
- if activity_teams:
928
- activity_entry.teams = Team.query.filter(Team.id.in_(activity_teams)).all()
929
- db.session.add(referentiel_entry)
930
- db.session.add(activity_entry)
931
- db.session.commit()
932
- # Define relationship between activity and referentiel entry after creating them so that
933
- # the referentiel ID generated by the DB server can be accessed
934
- if project_activity:
935
- activity_entry.projet_nsip_referentiel_id = referentiel_entry.id
936
- else:
937
- activity_entry.activite_nsip_referentiel_id = referentiel_entry.id
938
- db.session.commit()
939
-
940
- except Exception as e:
941
- status = 1
942
- error_msg = getattr(e, "message", repr(e))
943
- db.session.rollback()
944
-
945
- return status, error_msg
946
-
947
-
948
- def remove_activity(
949
- hito_project_id: str,
950
- hito_referentiel_id: str,
951
- nsip_id: str,
952
- project_activity: bool,
953
- ):
954
- """
955
- Remove the association between a Hito activity (project or reference) and NSIP. The Hito
956
- activity is kept as it may be referenced by other objects but its description is updated
957
- to mention that it is no longer in NSIP. The project name is updated so that it appears in the
958
- pseudo-masterproject NSIP_DELETED_MASTERPROJECT. Associated teams are removed.
959
-
960
- :param hito_project_id: Hito project ID
961
- :param hito_referentiel_id: Hito referentiel ID for the project
962
- :param nsip_id: NSIP ID for the project
963
- :param project_activity: if True it is a NSIP project, else a NSIP activity
964
- :return: 0 if update succeeded, non-zero if an error occured, error_msg if
965
- an error occured
966
- """
967
-
968
- from ositah.utils.hito_db_model import Projet, Referentiel
969
-
970
- db = get_db()
971
-
972
- status = 0 # Assume success
973
- error_msg = ""
974
-
975
- try:
976
- referentiel_entry = Referentiel.query.filter(Referentiel.id == hito_referentiel_id).first()
977
- db.session.query()
978
- activity_entry = Projet.query.filter(Projet.id == hito_project_id).first()
979
- if project_activity:
980
- activity_entry.projet_nsip_referentiel_id = None
981
- else:
982
- activity_entry.activite_nsip_referentiel_id = None
983
- change_log_msg = f"Desactivé le {datetime.now()} (NSIP ID={nsip_id})"
984
- if activity_entry.description:
985
- activity_entry.description += f"; {change_log_msg}"
986
- else:
987
- activity_entry.description = change_log_msg
988
- activity_entry.libelle = f"{MASTERPROJECT_DELETED_ACTIVITY} / {activity_entry.libelle}"
989
- activity_entry.ordre = DISABLED_ACTIVITY_ORDER
990
- if len(activity_entry.teams) > 0:
991
- activity_entry.teams.clear()
992
- db.session.delete(referentiel_entry)
993
- db.session.commit()
994
- except Exception as e:
995
- status = 1
996
- error_msg = getattr(e, "message", repr(e))
997
- db.session.rollback()
998
-
999
- return status, error_msg
1000
-
1001
-
1002
- def add_activity_teams(
1003
- masterproject: str, project: str, team_list: List[str], project_activity: bool
1004
- ):
1005
- """
1006
- Add teams to an activity.
1007
-
1008
- :param masterproject: activity masterproject name
1009
- :param project: activity project name
1010
- :param team_list: list of team names to add
1011
- :param project_activity: if true, an Hito project else an Hito activity
1012
- :return: status (0 if success), error_msg (empty if success)
1013
- """
1014
-
1015
- from ositah.utils.hito_db_model import Activite, Projet, Team
1016
-
1017
- db = get_db()
1018
-
1019
- status = 0 # Assume success
1020
- error_msg = ""
1021
-
1022
- if project_activity:
1023
- Activity = Projet
1024
- else:
1025
- Activity = Activite
1026
-
1027
- activity_name = ositah2hito_project_name(masterproject, project)
1028
- activity = Activity.query.filter(Activity.libelle == activity_name).first()
1029
-
1030
- if activity:
1031
- try:
1032
- for team in team_list:
1033
- team_object = Team.query.filter(Team.nom == team).first()
1034
- activity.teams.append(team_object)
1035
- db.session.commit()
1036
- except Exception as e:
1037
- status = 1
1038
- error_msg = getattr(e, "message", repr(e))
1039
- db.session.rollback()
1040
-
1041
- return status, error_msg
1042
-
1043
-
1044
- def remove_activity_teams(
1045
- masterproject: str, project: str, team_list: List[str], project_activity: bool
1046
- ):
1047
- """
1048
- Remove teams from an activity. If the team is not present in teams list, silently
1049
- ignore it.
1050
-
1051
- :param masterproject: activity masterproject name
1052
- :param project: activity project name
1053
- :param team_list: list of team names to remove
1054
- :param project_activity: if true, an Hito project else an Hito activity
1055
- :return: status (0 if success), error_msg (empty if success)
1056
- """
1057
-
1058
- from ositah.utils.hito_db_model import Activite, Projet, Team
1059
-
1060
- db = get_db()
1061
-
1062
- status = 0 # Assume success
1063
- error_msg = ""
1064
-
1065
- if project_activity:
1066
- Activity = Projet
1067
- else:
1068
- Activity = Activite
1069
-
1070
- activity_name = ositah2hito_project_name(masterproject, project)
1071
- activity = Activity.query.filter(Activity.libelle == activity_name).first()
1072
-
1073
- if activity:
1074
- try:
1075
- for team in team_list:
1076
- team_object = Team.query.filter(Team.nom == team).first()
1077
- if team_object in activity.teams:
1078
- activity.teams.remove(team_object)
1079
- db.session.commit()
1080
- except Exception as e:
1081
- status = 1
1082
- error_msg = getattr(e, "message", repr(e))
1083
- db.session.rollback()
1084
-
1085
- return status, error_msg
1086
-
1087
-
1088
- def reenable_activity(activity_name: str, project_activity: bool, name_prefix: str = None):
1089
- """
1090
- Reenable a disabled activity. This involves:
1091
- - Updating master project to match the original one
1092
- - If it was an NSIP project, recreate the referentiel entry
1093
-
1094
- :param activity_name: activity name
1095
- :param project_activity: if true, an Hito project else an Hito activity
1096
- :param name_prefix: activity name prefix for deleted or local activities
1097
- :return: status and error message if any
1098
- """
1099
-
1100
- from ositah.utils.hito_db_model import Activite, Projet, Referentiel
1101
-
1102
- db = get_db()
1103
-
1104
- status = 0 # Assume success
1105
- error_msg = ""
1106
-
1107
- if project_activity:
1108
- Activity = Projet
1109
- else:
1110
- Activity = Activite
1111
-
1112
- # Retrieve activity attributes and NSIP ID if present in description
1113
- if name_prefix:
1114
- activity_full_name = f"{name_prefix} / {activity_name}"
1115
- else:
1116
- activity_full_name = activity_name
1117
- activity_entry = Activity.query.filter(Activity.libelle == activity_full_name).first()
1118
-
1119
- m = re.search(r"\(NSIP ID\=(?P<id>\d+)\)$", activity_entry.description)
1120
- if m:
1121
- nsip_id = m.group("id")
1122
- else:
1123
- nsip_id = None
1124
-
1125
- # Check if an entry exist in the referentiel for the NSIP ID: if not, create it
1126
- nsip_entry = Referentiel.query.filter(
1127
- Referentiel.libelle.ilike(f"%(NSIP ID = {nsip_id})")
1128
- ).first()
1129
- if not nsip_entry:
1130
- master_project, activity = hito2ositah_project_name(activity_name)
1131
- project_name, referentiel_name = build_activity_libelle(
1132
- nsip_id,
1133
- master_project,
1134
- activity,
1135
- )
1136
-
1137
- if project_activity:
1138
- entry_class = "projetnsipreferentiel"
1139
- entry_order = NSIP_PROJECT_ORDER
1140
- else:
1141
- entry_class = "activitensipreferentiel"
1142
- entry_order = NSIP_ACIVITY_ORDER
1143
-
1144
- referentiel_entry = Referentiel(
1145
- libelle=referentiel_name,
1146
- object_class=entry_class,
1147
- ordre=entry_order,
1148
- )
1149
- else:
1150
- referentiel_entry = None
1151
-
1152
- # Create referentiel entry if necessary and update activity
1153
- try:
1154
- if referentiel_entry:
1155
- db.session.add(referentiel_entry)
1156
- activity_entry.libelle = activity_name
1157
- activity_entry.description = f"Modifié le {datetime.now()}"
1158
- db.session.commit()
1159
- # Define relationship between activity and referentiel entry after creating them so that
1160
- # the referentiel ID generated by the DB server can be accessed
1161
- if project_activity:
1162
- activity_entry.projet_nsip_referentiel_id = referentiel_entry.id
1163
- else:
1164
- activity_entry.activite_nsip_referentiel_id = referentiel_entry.id
1165
- db.session.commit()
1166
-
1167
- except Exception as e:
1168
- status = 1
1169
- error_msg = getattr(e, "message", repr(e))
1170
- db.session.rollback()
1171
-
1172
- # Clear cached data to force a refresh of project list
1173
- clear_cached_data()
1174
-
1175
- return status, error_msg