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
ositah/utils/utils.py CHANGED
@@ -1,474 +1,474 @@
1
- # Convenience objects for OSITAH application
2
-
3
- from datetime import datetime, timedelta
4
- from typing import List
5
-
6
- import dash_bootstrap_components as dbc
7
- from dash import html
8
- from flask import session
9
- from flask_sqlalchemy import SQLAlchemy
10
- from hito_tools.exceptions import ConfigFileEmpty, ConfigMissingParam
11
- from hito_tools.nsip import nsip_session_init
12
- from hito_tools.utils import load_config_file
13
-
14
- from ositah.app import app
15
-
16
- from .core import singleton
17
- from .exceptions import SessionDataMissing
18
-
19
- CONFIG_DEFAULT_PORT = "8888"
20
-
21
- # Define the dataframe column name to use for each kind of information
22
- # The key is the kind of information, the value is the column name and must be lowercase
23
- COLUMN_NAMES = {
24
- "agent_id": "id",
25
- "activity": "project_fullname",
26
- "activity_id": "projet_id",
27
- "category": "category",
28
- "cem": "cem",
29
- "declarations_number": "declarations_number",
30
- "email": "email",
31
- "email_auth": "email_auth",
32
- "firstname": "prenom",
33
- "fullname": "fullname",
34
- "hours": "nbHeures",
35
- "lastname": "nom",
36
- "masterproject": "masterprojet",
37
- "missings_number": "missings_number",
38
- "percent": "pourcent",
39
- "project": "projet",
40
- "quotite": "quotite",
41
- "statut": "statut",
42
- "team": "team",
43
- "team_id": "team_id",
44
- "weeks": "weeks",
45
- }
46
-
47
- # Define the column names in the NSIP export and the matching column name in the validated
48
- # declarations dataframe
49
- NSIP_COLUMN_NAMES = {
50
- "email_auth": "reseda_eamil",
51
- "nsip_project_id": "projet_id",
52
- "nsip_reference_id": "reference_id",
53
- "nsip_master": "masterprojet",
54
- "nsip_project": "projet",
55
- "time": "time",
56
- "time_unit": "volume",
57
- "validation_time": "timestamp",
58
- "id_declaration": "NSIP declaration ID",
59
- }
60
-
61
- TIME_UNIT_HOURS = "h"
62
- TIME_UNIT_HOURS_EN = "hours"
63
- TIME_UNIT_HOURS_FR = "heures"
64
- TIME_UNIT_WEEKS = "w"
65
- TIME_UNIT_WEEKS_EN = "weeks"
66
- TIME_UNIT_WEEKS_FR = "semaines"
67
- TIME_UNIT_DEFAULT = TIME_UNIT_HOURS
68
-
69
- # Hours per days and per week
70
- DAY_HOURS = 7.7
71
- WEEK_HOURS = 5 * DAY_HOURS
72
- # Semester: assume no week of holidays
73
- SEMESTER_WEEKS = 26
74
- SEMESTER_HOURS = WEEK_HOURS * SEMESTER_WEEKS
75
-
76
- TEAM_LIST_ALL_AGENTS = "Tous les agents"
77
-
78
- HITO_ROLE_PROJECT_MGR = "ROLE_PROJECT_MANAGER"
79
- HITO_ROLE_SUPER_ADMIN = "ROLE_SUPER_ADMIN"
80
- HITO_ROLE_TEAM_MGR = "ROLE_RESP"
81
- # Must be in role power reverse order
82
- AUTHORIZED_ROLES = [HITO_ROLE_SUPER_ADMIN, HITO_ROLE_PROJECT_MGR, HITO_ROLE_TEAM_MGR]
83
-
84
-
85
- class OSITAHSessionData:
86
- def __init__(self):
87
- self._cache_initialisation_date = None
88
- self._category_declarations = None
89
- self._project_declarations = None
90
- self._nsip_declarations = None
91
- self._projects_data = None
92
- self._project_declarations_source = None
93
- self._hito_activities = None
94
- self._hito_projects = None
95
- self._agent_list = None
96
- self._declaration_periods = None
97
- # Use a list for agent_teams to preserve the order
98
- self._agent_teams = []
99
- self._role = None
100
-
101
- @property
102
- def agent_teams(self):
103
- return self._agent_teams
104
-
105
- def add_teams(self, teams: List[str], sort_list=True) -> None:
106
- """
107
- Add a list of teams to agent_teams, without duplicates. The list is then sorted except
108
- is sort_list=False.
109
-
110
- :param teams: a list of team names
111
- :param sort: if true sort the resulting team list
112
- :return: None
113
- """
114
- seen = set(self.agent_teams)
115
- # Ensure that that there is no duplicate in the list
116
- self._agent_teams.extend([x for x in teams if not (x in seen or seen.add(x))])
117
- if sort_list:
118
- self._agent_teams.sort()
119
-
120
- @property
121
- def agent_list(self):
122
- return self._agent_list
123
-
124
- @agent_list.setter
125
- def agent_list(self, agent_list):
126
- self._agent_list = agent_list
127
-
128
- @property
129
- def cache_date(self):
130
- return self._cache_initialisation_date
131
-
132
- @property
133
- def category_declarations(self):
134
- return self._category_declarations
135
-
136
- @category_declarations.setter
137
- def category_declarations(self, declarations):
138
- self._category_declarations = declarations
139
-
140
- @property
141
- def declaration_periods(self):
142
- return self._declaration_periods
143
-
144
- @declaration_periods.setter
145
- def declaration_periods(self, periods):
146
- self._declaration_periods = periods
147
-
148
- def get_hito_activities(self, project_activity: bool):
149
- if project_activity:
150
- return self._hito_projects
151
- else:
152
- return self._hito_activities
153
-
154
- @property
155
- def nsip_declarations(self):
156
- return self._nsip_declarations
157
-
158
- @nsip_declarations.setter
159
- def nsip_declarations(self, declarations):
160
- self._nsip_declarations = declarations
161
- if self._cache_initialisation_date is None:
162
- # Define only if the cache has not yet been initialized
163
- self._cache_initialisation_date = datetime.now()
164
-
165
- @property
166
- def projects_data(self):
167
- return self._projects_data
168
-
169
- @projects_data.setter
170
- def projects_data(self, projects_data):
171
- self._projects_data = projects_data
172
-
173
- @property
174
- def project_declarations(self):
175
- return self._project_declarations
176
-
177
- @property
178
- def project_declarations_source(self):
179
- return self._project_declarations_source
180
-
181
- @property
182
- def role(self):
183
- return self._role
184
-
185
- @role.setter
186
- def role(self, role):
187
- self._role = role
188
-
189
- @property
190
- def total_declarations_num(self):
191
- return len(self._project_declarations)
192
-
193
- def reset_caches(self):
194
- self._category_declarations = None
195
- self._project_declarations = None
196
- self._nsip_declarations = None
197
- self._projects_data = None
198
- self._hito_activities = None
199
- self._hito_projects = None
200
- self._project_declarations_source = None
201
- self._cache_initialisation_date = None
202
- self._agent_list = None
203
- self._validation_data = None
204
- self._total_declarations_num = 0
205
-
206
- def reset_validated_declarations_cache(self):
207
- self._nsip_declarations = None
208
-
209
- def set_hito_activities(self, activities, project_activity: bool):
210
- if project_activity:
211
- self._hito_projects = activities
212
- else:
213
- self._hito_activities = activities
214
-
215
- def set_project_declarations(self, declarations, source):
216
- self._project_declarations = declarations
217
- self._project_declarations_source = source
218
- if self._cache_initialisation_date is None:
219
- # Define only if the cache has not yet been initialized
220
- self._cache_initialisation_date = datetime.now()
221
-
222
-
223
- @singleton
224
- class GlobalParams:
225
- def __init__(self):
226
- self.agent_query = None
227
- self.analysis_params = None
228
- self.category_patterns = {}
229
- self.columns = COLUMN_NAMES
230
- self.column_titles = None
231
- self.declaration_options = None
232
- self.project_categories = None
233
- self.reference_masterprojects = {}
234
- self.roles = {}
235
- self.time_unit = None
236
- self.ldap = None
237
- self.nsip = None
238
- self.project_teams = {}
239
- self.teaching_ratio = None
240
- self.port = CONFIG_DEFAULT_PORT
241
- self.validation_params = None
242
- self._hito_db = None
243
- self._session_data = {}
244
-
245
- @property
246
- def hito_db(self):
247
- if not self._hito_db:
248
- self._hito_db = SQLAlchemy(app.server)
249
- return self._hito_db
250
-
251
- @property
252
- def session_data(self):
253
- """
254
- Returns the session data for the current session. Must not be called if the session UID
255
- is not defined or will raise SessionDataMissing exception.
256
-
257
- :return: session data for the current session
258
- """
259
-
260
- if "uid" in session:
261
- # If 'uid' is defined, it means the user was successfully authenticated.
262
- if session["uid"] not in self._session_data:
263
- # session_data may not exist if a multi-worker configuration is used and the
264
- # authentication (done when moving from one subapp to another one) has been
265
- # done on another worker.
266
- self._session_data[session["uid"]] = OSITAHSessionData()
267
- return self._session_data[session["uid"]]
268
-
269
- else:
270
- raise SessionDataMissing()
271
-
272
- @session_data.deleter
273
- def session_data(self):
274
- if "uid" in session:
275
- if session["uid"] in self._session_data:
276
- del self._session_data[session["uid"]]
277
- else:
278
- print(
279
- (
280
- f"WARNING: attempt to delete non-existing session data for session"
281
- f" {session['uid']} (user={session['user_id']})"
282
- )
283
- )
284
-
285
- else:
286
- raise SessionDataMissing()
287
-
288
-
289
- def define_config_params(file):
290
- """
291
- Validate configuration and define appropriate defaults. Also define global parameters
292
- from configuration.
293
-
294
- :param file: configuration file
295
- :return: updated configuration hash
296
- """
297
-
298
- global_params = GlobalParams()
299
-
300
- config = load_config_file(file, required=True)
301
- if not config:
302
- raise ConfigFileEmpty(file)
303
-
304
- if "server" not in config or not config["server"]:
305
- config["server"] = {}
306
- if "port" in config["server"]:
307
- global_params.port = config["server"]["port"]
308
- if "authentication" in config["server"]:
309
- if "ldap" in config["server"]["authentication"]:
310
- global_params.ldap = config["server"]["authentication"]["ldap"]
311
- for param in ["uri", "base_dn", "bind_dn", "password"]:
312
- if param not in global_params.ldap:
313
- raise ConfigMissingParam(f"server/authentication/ldap/{param}", file)
314
- else:
315
- raise ConfigMissingParam("server/authentication/ldap", file)
316
- if "provider_name" not in config["server"]["authentication"]:
317
- raise ConfigMissingParam("server/authentication/provider_name", file)
318
- else:
319
- raise ConfigMissingParam("server/authentication", file)
320
-
321
- if "hito" not in config:
322
- raise ConfigMissingParam("hito", file)
323
- if "db" not in config["hito"]:
324
- raise ConfigMissingParam("hito/db", file)
325
- if "type" not in config["hito"]["db"]:
326
- raise ConfigMissingParam("hito/db/type", file)
327
- if "location" not in config["hito"]["db"]:
328
- raise ConfigMissingParam("hito/db/location", file)
329
- if "agent_query" not in config["hito"]["db"]:
330
- raise ConfigMissingParam("hito/db/agent_query", file)
331
- if config["hito"]["db"]["type"] == "sqlite":
332
- app.server.config["SQLALCHEMY_DATABASE_URI"] = (
333
- f"sqlite:///{config['hito']['db']['location']}"
334
- )
335
- elif config["hito"]["db"]["type"] == "mysql":
336
- if "user" not in config["hito"]["db"]:
337
- raise ConfigMissingParam("hito/db/user", file)
338
- if "password" not in config["hito"]["db"]:
339
- raise ConfigMissingParam("hito/db/passowrd", file)
340
- app.server.config["SQLALCHEMY_DATABASE_URI"] = (
341
- f"mysql+pymysql://{config['hito']['db']['user']}:{config['hito']['db']['password']}"
342
- f"@{config['hito']['db']['location']}"
343
- )
344
- else:
345
- raise Exception(f"Support for {config['hito']['db']['type']} not yet implemented...")
346
- if "inactivity_timeout" in config["hito"]["db"]:
347
- app.server.config["SQLALCHEMY_POOL_RECYCLE"] = config["hito"]["db"]["inactivity_timeout"]
348
- global_params.agent_query = contextualize_sql_query(
349
- config["hito"]["db"]["agent_query"], config["hito"]["db"]["type"]
350
- )
351
- app.server.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False
352
-
353
- if "categories" in config["hito"]:
354
- global_params.project_categories = config["hito"]["categories"]
355
- global_params.category_patterns = {
356
- v: k for k, v in global_params.project_categories.items()
357
- }
358
- else:
359
- raise ConfigMissingParam("hito/categories", file)
360
-
361
- if "time_unit" in config["hito"]:
362
- global_params.time_unit = config["hito"]["time_unit"]
363
- else:
364
- global_params.time_unit = {}
365
- for category in global_params.project_categories:
366
- if category not in global_params.time_unit:
367
- global_params.time_unit[category] = TIME_UNIT_DEFAULT
368
-
369
- if "titles" in config["hito"]:
370
- global_params.column_titles = config["hito"]["titles"]
371
- else:
372
- raise ConfigMissingParam("hito/titles", file)
373
-
374
- if "declaration" in config:
375
- if "optional_statutes" not in config["declaration"]:
376
- config["declaration"]["optional_statutes"] = []
377
- if "optional_teams" not in config["declaration"]:
378
- config["declaration"]["optional_teams"] = []
379
- else:
380
- config["declaration"] = {}
381
- if "max_hours" not in config["declaration"]:
382
- # Set a very high value
383
- config["declaration"]["max_hours"] = 99999
384
- missing_params = []
385
- for semester in ["s1", "s2"]:
386
- for k in ["low", "suspect", "good"]:
387
- if (
388
- "thresholds" not in config["declaration"]
389
- or semester not in config["declaration"]["thresholds"]
390
- or k not in config["declaration"]["thresholds"][semester]
391
- ):
392
- missing_params.append(f"declaration/thresholds/{semester}/{k}")
393
- if len(missing_params) > 0:
394
- raise ConfigMissingParam(", ".join(missing_params), file)
395
- # Default declaration period date defaults to current day if not explicitly defined
396
- if "default_date" not in config["declaration"]:
397
- if "period_change_delay" in config["declaration"]:
398
- change_delay = config["declaration"]["period_change_delay"]
399
- else:
400
- change_delay = 0
401
- config["declaration"]["default_date"] = datetime.now() - timedelta(days=change_delay)
402
- global_params.declaration_options = config["declaration"]
403
-
404
- if "analysis" not in config:
405
- config["analysis"] = {}
406
- if "contributions_sorted_by_name" not in config["analysis"]:
407
- config["analysis"]["contributions_sorted_by_name"] = True
408
- global_params.analysis_params = config["analysis"]
409
-
410
- if "validation" not in config:
411
- config["validation"] = {}
412
- if "override_period" not in config["validation"]:
413
- config["validation"]["override_period"] = []
414
- global_params.validation_params = config["validation"]
415
-
416
- if "roles" in config:
417
- global_params.roles = config["roles"]
418
- if "read-only" not in global_params.roles:
419
- global_params.roles["read-only"] = []
420
-
421
- if "project_teams" in config:
422
- global_params.project_teams = config["project_teams"]
423
-
424
- if "nsip" in config:
425
- global_params.nsip = nsip_session_init(config["nsip"])
426
- if "reference_masterprojects" in config["nsip"]:
427
- global_params.reference_masterprojects = config["nsip"]["reference_masterprojects"]
428
- if "teaching" in config["nsip"]:
429
- global_params.teaching_ratio = config["nsip"]["teaching"]
430
- if "ratio" not in global_params.teaching_ratio:
431
- raise ConfigMissingParam("nsip/teeaching/ratio", file)
432
- if "masterproject" not in global_params.teaching_ratio:
433
- global_params.teaching_ratio["masterproject"] = "Enseignement Supérieur"
434
- if "cem" not in global_params.teaching_ratio:
435
- global_params.teaching_ratio["cem"] = None
436
-
437
- return config
438
-
439
-
440
- def contextualize_sql_query(query: str, db_type: str) -> str:
441
- """
442
- Function to replace placeholders in the query by the function/statement appropriate for
443
- the actual DB back-end used.
444
-
445
- :param query: the query with the placeholders
446
- :param db_type: the DB back-end type
447
- :return: the query for the selected back-end
448
- """
449
-
450
- if db_type == "sqlite":
451
- query = query.replace("$$TODAY$$", 'DATE("now")')
452
- elif db_type == "mysql":
453
- query = query.replace("$$TODAY$$", "CURRENT_DATE()")
454
- # pymysql uses the query string as a formatter string: % characters must be esacped
455
- query = query.replace("%", "%%")
456
- else:
457
- raise Exception(f"Support for {db_type} not yet implemented...")
458
-
459
- return query
460
-
461
-
462
- def no_session_id_jumbotron(session_id=None):
463
- return html.Div([dbc.Jumbotron(html.P(["Internal error: session ID invalid or undefined"]))])
464
-
465
-
466
- def general_error_jumbotron(error):
467
- """
468
- Print an error message that can be any representable object
469
-
470
- :param error: error object, e.g. an exception
471
- :return: a jumbotron
472
- """
473
-
474
- return html.Div([dbc.Jumbotron(html.P([repr(error)]))])
1
+ # Convenience objects for OSITAH application
2
+
3
+ from datetime import datetime, timedelta
4
+ from typing import List
5
+
6
+ import dash_bootstrap_components as dbc
7
+ from dash import html
8
+ from flask import session
9
+ from flask_sqlalchemy import SQLAlchemy
10
+ from hito_tools.exceptions import ConfigFileEmpty, ConfigMissingParam
11
+ from hito_tools.nsip import nsip_session_init
12
+ from hito_tools.utils import load_config_file
13
+
14
+ from ositah.app import app
15
+
16
+ from .core import singleton
17
+ from .exceptions import SessionDataMissing
18
+
19
+ CONFIG_DEFAULT_PORT = "8888"
20
+
21
+ # Define the dataframe column name to use for each kind of information
22
+ # The key is the kind of information, the value is the column name and must be lowercase
23
+ COLUMN_NAMES = {
24
+ "agent_id": "id",
25
+ "activity": "project_fullname",
26
+ "activity_id": "projet_id",
27
+ "category": "category",
28
+ "cem": "cem",
29
+ "declarations_number": "declarations_number",
30
+ "email": "email",
31
+ "email_auth": "email_auth",
32
+ "firstname": "prenom",
33
+ "fullname": "fullname",
34
+ "hours": "nbHeures",
35
+ "lastname": "nom",
36
+ "masterproject": "masterprojet",
37
+ "missings_number": "missings_number",
38
+ "percent": "pourcent",
39
+ "project": "projet",
40
+ "quotite": "quotite",
41
+ "statut": "statut",
42
+ "team": "team",
43
+ "team_id": "team_id",
44
+ "weeks": "weeks",
45
+ }
46
+
47
+ # Define the column names in the NSIP export and the matching column name in the validated
48
+ # declarations dataframe
49
+ NSIP_COLUMN_NAMES = {
50
+ "email_auth": "reseda_eamil",
51
+ "nsip_project_id": "projet_id",
52
+ "nsip_reference_id": "reference_id",
53
+ "nsip_master": "masterprojet",
54
+ "nsip_project": "projet",
55
+ "time": "time",
56
+ "time_unit": "volume",
57
+ "validation_time": "timestamp",
58
+ "id_declaration": "NSIP declaration ID",
59
+ }
60
+
61
+ TIME_UNIT_HOURS = "h"
62
+ TIME_UNIT_HOURS_EN = "hours"
63
+ TIME_UNIT_HOURS_FR = "heures"
64
+ TIME_UNIT_WEEKS = "w"
65
+ TIME_UNIT_WEEKS_EN = "weeks"
66
+ TIME_UNIT_WEEKS_FR = "semaines"
67
+ TIME_UNIT_DEFAULT = TIME_UNIT_HOURS
68
+
69
+ # Hours per days and per week
70
+ DAY_HOURS = 7.7
71
+ WEEK_HOURS = 5 * DAY_HOURS
72
+ # Semester: assume no week of holidays
73
+ SEMESTER_WEEKS = 26
74
+ SEMESTER_HOURS = WEEK_HOURS * SEMESTER_WEEKS
75
+
76
+ TEAM_LIST_ALL_AGENTS = "Tous les agents"
77
+
78
+ HITO_ROLE_PROJECT_MGR = "ROLE_PROJECT_MANAGER"
79
+ HITO_ROLE_SUPER_ADMIN = "ROLE_SUPER_ADMIN"
80
+ HITO_ROLE_TEAM_MGR = "ROLE_RESP"
81
+ # Must be in role power reverse order
82
+ AUTHORIZED_ROLES = [HITO_ROLE_SUPER_ADMIN, HITO_ROLE_PROJECT_MGR, HITO_ROLE_TEAM_MGR]
83
+
84
+
85
+ class OSITAHSessionData:
86
+ def __init__(self):
87
+ self._cache_initialisation_date = None
88
+ self._category_declarations = None
89
+ self._project_declarations = None
90
+ self._nsip_declarations = None
91
+ self._projects_data = None
92
+ self._project_declarations_source = None
93
+ self._hito_activities = None
94
+ self._hito_projects = None
95
+ self._agent_list = None
96
+ self._declaration_periods = None
97
+ # Use a list for agent_teams to preserve the order
98
+ self._agent_teams = []
99
+ self._role = None
100
+
101
+ @property
102
+ def agent_teams(self):
103
+ return self._agent_teams
104
+
105
+ def add_teams(self, teams: List[str], sort_list=True) -> None:
106
+ """
107
+ Add a list of teams to agent_teams, without duplicates. The list is then sorted except
108
+ is sort_list=False.
109
+
110
+ :param teams: a list of team names
111
+ :param sort: if true sort the resulting team list
112
+ :return: None
113
+ """
114
+ seen = set(self.agent_teams)
115
+ # Ensure that that there is no duplicate in the list
116
+ self._agent_teams.extend([x for x in teams if not (x in seen or seen.add(x))])
117
+ if sort_list:
118
+ self._agent_teams.sort()
119
+
120
+ @property
121
+ def agent_list(self):
122
+ return self._agent_list
123
+
124
+ @agent_list.setter
125
+ def agent_list(self, agent_list):
126
+ self._agent_list = agent_list
127
+
128
+ @property
129
+ def cache_date(self):
130
+ return self._cache_initialisation_date
131
+
132
+ @property
133
+ def category_declarations(self):
134
+ return self._category_declarations
135
+
136
+ @category_declarations.setter
137
+ def category_declarations(self, declarations):
138
+ self._category_declarations = declarations
139
+
140
+ @property
141
+ def declaration_periods(self):
142
+ return self._declaration_periods
143
+
144
+ @declaration_periods.setter
145
+ def declaration_periods(self, periods):
146
+ self._declaration_periods = periods
147
+
148
+ def get_hito_activities(self, project_activity: bool):
149
+ if project_activity:
150
+ return self._hito_projects
151
+ else:
152
+ return self._hito_activities
153
+
154
+ @property
155
+ def nsip_declarations(self):
156
+ return self._nsip_declarations
157
+
158
+ @nsip_declarations.setter
159
+ def nsip_declarations(self, declarations):
160
+ self._nsip_declarations = declarations
161
+ if self._cache_initialisation_date is None:
162
+ # Define only if the cache has not yet been initialized
163
+ self._cache_initialisation_date = datetime.now()
164
+
165
+ @property
166
+ def projects_data(self):
167
+ return self._projects_data
168
+
169
+ @projects_data.setter
170
+ def projects_data(self, projects_data):
171
+ self._projects_data = projects_data
172
+
173
+ @property
174
+ def project_declarations(self):
175
+ return self._project_declarations
176
+
177
+ @property
178
+ def project_declarations_source(self):
179
+ return self._project_declarations_source
180
+
181
+ @property
182
+ def role(self):
183
+ return self._role
184
+
185
+ @role.setter
186
+ def role(self, role):
187
+ self._role = role
188
+
189
+ @property
190
+ def total_declarations_num(self):
191
+ return len(self._project_declarations)
192
+
193
+ def reset_caches(self):
194
+ self._category_declarations = None
195
+ self._project_declarations = None
196
+ self._nsip_declarations = None
197
+ self._projects_data = None
198
+ self._hito_activities = None
199
+ self._hito_projects = None
200
+ self._project_declarations_source = None
201
+ self._cache_initialisation_date = None
202
+ self._agent_list = None
203
+ self._validation_data = None
204
+ self._total_declarations_num = 0
205
+
206
+ def reset_validated_declarations_cache(self):
207
+ self._nsip_declarations = None
208
+
209
+ def set_hito_activities(self, activities, project_activity: bool):
210
+ if project_activity:
211
+ self._hito_projects = activities
212
+ else:
213
+ self._hito_activities = activities
214
+
215
+ def set_project_declarations(self, declarations, source):
216
+ self._project_declarations = declarations
217
+ self._project_declarations_source = source
218
+ if self._cache_initialisation_date is None:
219
+ # Define only if the cache has not yet been initialized
220
+ self._cache_initialisation_date = datetime.now()
221
+
222
+
223
+ @singleton
224
+ class GlobalParams:
225
+ def __init__(self):
226
+ self.agent_query = None
227
+ self.analysis_params = None
228
+ self.category_patterns = {}
229
+ self.columns = COLUMN_NAMES
230
+ self.column_titles = None
231
+ self.declaration_options = None
232
+ self.project_categories = None
233
+ self.reference_masterprojects = {}
234
+ self.roles = {}
235
+ self.time_unit = None
236
+ self.ldap = None
237
+ self.nsip = None
238
+ self.project_teams = {}
239
+ self.teaching_ratio = None
240
+ self.port = CONFIG_DEFAULT_PORT
241
+ self.validation_params = None
242
+ self._hito_db = None
243
+ self._session_data = {}
244
+
245
+ @property
246
+ def hito_db(self):
247
+ if not self._hito_db:
248
+ self._hito_db = SQLAlchemy(app.server)
249
+ return self._hito_db
250
+
251
+ @property
252
+ def session_data(self):
253
+ """
254
+ Returns the session data for the current session. Must not be called if the session UID
255
+ is not defined or will raise SessionDataMissing exception.
256
+
257
+ :return: session data for the current session
258
+ """
259
+
260
+ if "uid" in session:
261
+ # If 'uid' is defined, it means the user was successfully authenticated.
262
+ if session["uid"] not in self._session_data:
263
+ # session_data may not exist if a multi-worker configuration is used and the
264
+ # authentication (done when moving from one subapp to another one) has been
265
+ # done on another worker.
266
+ self._session_data[session["uid"]] = OSITAHSessionData()
267
+ return self._session_data[session["uid"]]
268
+
269
+ else:
270
+ raise SessionDataMissing()
271
+
272
+ @session_data.deleter
273
+ def session_data(self):
274
+ if "uid" in session:
275
+ if session["uid"] in self._session_data:
276
+ del self._session_data[session["uid"]]
277
+ else:
278
+ print(
279
+ (
280
+ f"WARNING: attempt to delete non-existing session data for session"
281
+ f" {session['uid']} (user={session['user_id']})"
282
+ )
283
+ )
284
+
285
+ else:
286
+ raise SessionDataMissing()
287
+
288
+
289
+ def define_config_params(file):
290
+ """
291
+ Validate configuration and define appropriate defaults. Also define global parameters
292
+ from configuration.
293
+
294
+ :param file: configuration file
295
+ :return: updated configuration hash
296
+ """
297
+
298
+ global_params = GlobalParams()
299
+
300
+ config = load_config_file(file, required=True)
301
+ if not config:
302
+ raise ConfigFileEmpty(file)
303
+
304
+ if "server" not in config or not config["server"]:
305
+ config["server"] = {}
306
+ if "port" in config["server"]:
307
+ global_params.port = config["server"]["port"]
308
+ if "authentication" in config["server"]:
309
+ if "ldap" in config["server"]["authentication"]:
310
+ global_params.ldap = config["server"]["authentication"]["ldap"]
311
+ for param in ["uri", "base_dn", "bind_dn", "password"]:
312
+ if param not in global_params.ldap:
313
+ raise ConfigMissingParam(f"server/authentication/ldap/{param}", file)
314
+ else:
315
+ raise ConfigMissingParam("server/authentication/ldap", file)
316
+ if "provider_name" not in config["server"]["authentication"]:
317
+ raise ConfigMissingParam("server/authentication/provider_name", file)
318
+ else:
319
+ raise ConfigMissingParam("server/authentication", file)
320
+
321
+ if "hito" not in config:
322
+ raise ConfigMissingParam("hito", file)
323
+ if "db" not in config["hito"]:
324
+ raise ConfigMissingParam("hito/db", file)
325
+ if "type" not in config["hito"]["db"]:
326
+ raise ConfigMissingParam("hito/db/type", file)
327
+ if "location" not in config["hito"]["db"]:
328
+ raise ConfigMissingParam("hito/db/location", file)
329
+ if "agent_query" not in config["hito"]["db"]:
330
+ raise ConfigMissingParam("hito/db/agent_query", file)
331
+ if config["hito"]["db"]["type"] == "sqlite":
332
+ app.server.config["SQLALCHEMY_DATABASE_URI"] = (
333
+ f"sqlite:///{config['hito']['db']['location']}"
334
+ )
335
+ elif config["hito"]["db"]["type"] == "mysql":
336
+ if "user" not in config["hito"]["db"]:
337
+ raise ConfigMissingParam("hito/db/user", file)
338
+ if "password" not in config["hito"]["db"]:
339
+ raise ConfigMissingParam("hito/db/passowrd", file)
340
+ app.server.config["SQLALCHEMY_DATABASE_URI"] = (
341
+ f"mysql+pymysql://{config['hito']['db']['user']}:{config['hito']['db']['password']}"
342
+ f"@{config['hito']['db']['location']}"
343
+ )
344
+ else:
345
+ raise Exception(f"Support for {config['hito']['db']['type']} not yet implemented...")
346
+ if "inactivity_timeout" in config["hito"]["db"]:
347
+ app.server.config["SQLALCHEMY_POOL_RECYCLE"] = config["hito"]["db"]["inactivity_timeout"]
348
+ global_params.agent_query = contextualize_sql_query(
349
+ config["hito"]["db"]["agent_query"], config["hito"]["db"]["type"]
350
+ )
351
+ app.server.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False
352
+
353
+ if "categories" in config["hito"]:
354
+ global_params.project_categories = config["hito"]["categories"]
355
+ global_params.category_patterns = {
356
+ v: k for k, v in global_params.project_categories.items()
357
+ }
358
+ else:
359
+ raise ConfigMissingParam("hito/categories", file)
360
+
361
+ if "time_unit" in config["hito"]:
362
+ global_params.time_unit = config["hito"]["time_unit"]
363
+ else:
364
+ global_params.time_unit = {}
365
+ for category in global_params.project_categories:
366
+ if category not in global_params.time_unit:
367
+ global_params.time_unit[category] = TIME_UNIT_DEFAULT
368
+
369
+ if "titles" in config["hito"]:
370
+ global_params.column_titles = config["hito"]["titles"]
371
+ else:
372
+ raise ConfigMissingParam("hito/titles", file)
373
+
374
+ if "declaration" in config:
375
+ if "optional_statutes" not in config["declaration"]:
376
+ config["declaration"]["optional_statutes"] = []
377
+ if "optional_teams" not in config["declaration"]:
378
+ config["declaration"]["optional_teams"] = []
379
+ else:
380
+ config["declaration"] = {}
381
+ if "max_hours" not in config["declaration"]:
382
+ # Set a very high value
383
+ config["declaration"]["max_hours"] = 99999
384
+ missing_params = []
385
+ for semester in ["s1", "s2"]:
386
+ for k in ["low", "suspect", "good"]:
387
+ if (
388
+ "thresholds" not in config["declaration"]
389
+ or semester not in config["declaration"]["thresholds"]
390
+ or k not in config["declaration"]["thresholds"][semester]
391
+ ):
392
+ missing_params.append(f"declaration/thresholds/{semester}/{k}")
393
+ if len(missing_params) > 0:
394
+ raise ConfigMissingParam(", ".join(missing_params), file)
395
+ # Default declaration period date defaults to current day if not explicitly defined
396
+ if "default_date" not in config["declaration"]:
397
+ if "period_change_delay" in config["declaration"]:
398
+ change_delay = config["declaration"]["period_change_delay"]
399
+ else:
400
+ change_delay = 0
401
+ config["declaration"]["default_date"] = datetime.now() - timedelta(days=change_delay)
402
+ global_params.declaration_options = config["declaration"]
403
+
404
+ if "analysis" not in config:
405
+ config["analysis"] = {}
406
+ if "contributions_sorted_by_name" not in config["analysis"]:
407
+ config["analysis"]["contributions_sorted_by_name"] = True
408
+ global_params.analysis_params = config["analysis"]
409
+
410
+ if "validation" not in config:
411
+ config["validation"] = {}
412
+ if "override_period" not in config["validation"]:
413
+ config["validation"]["override_period"] = []
414
+ global_params.validation_params = config["validation"]
415
+
416
+ if "roles" in config:
417
+ global_params.roles = config["roles"]
418
+ if "read-only" not in global_params.roles:
419
+ global_params.roles["read-only"] = []
420
+
421
+ if "project_teams" in config:
422
+ global_params.project_teams = config["project_teams"]
423
+
424
+ if "nsip" in config:
425
+ global_params.nsip = nsip_session_init(config["nsip"])
426
+ if "reference_masterprojects" in config["nsip"]:
427
+ global_params.reference_masterprojects = config["nsip"]["reference_masterprojects"]
428
+ if "teaching" in config["nsip"]:
429
+ global_params.teaching_ratio = config["nsip"]["teaching"]
430
+ if "ratio" not in global_params.teaching_ratio:
431
+ raise ConfigMissingParam("nsip/teeaching/ratio", file)
432
+ if "masterproject" not in global_params.teaching_ratio:
433
+ global_params.teaching_ratio["masterproject"] = "Enseignement Supérieur"
434
+ if "cem" not in global_params.teaching_ratio:
435
+ global_params.teaching_ratio["cem"] = None
436
+
437
+ return config
438
+
439
+
440
+ def contextualize_sql_query(query: str, db_type: str) -> str:
441
+ """
442
+ Function to replace placeholders in the query by the function/statement appropriate for
443
+ the actual DB back-end used.
444
+
445
+ :param query: the query with the placeholders
446
+ :param db_type: the DB back-end type
447
+ :return: the query for the selected back-end
448
+ """
449
+
450
+ if db_type == "sqlite":
451
+ query = query.replace("$$TODAY$$", 'DATE("now")')
452
+ elif db_type == "mysql":
453
+ query = query.replace("$$TODAY$$", "CURRENT_DATE()")
454
+ # pymysql uses the query string as a formatter string: % characters must be esacped
455
+ query = query.replace("%", "%%")
456
+ else:
457
+ raise Exception(f"Support for {db_type} not yet implemented...")
458
+
459
+ return query
460
+
461
+
462
+ def no_session_id_jumbotron(session_id=None):
463
+ return html.Div([dbc.Jumbotron(html.P(["Internal error: session ID invalid or undefined"]))])
464
+
465
+
466
+ def general_error_jumbotron(error):
467
+ """
468
+ Print an error message that can be any representable object
469
+
470
+ :param error: error object, e.g. an exception
471
+ :return: a jumbotron
472
+ """
473
+
474
+ return html.Div([dbc.Jumbotron(html.P([repr(error)]))])