hito_tools 24.8.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 hito_tools might be problematic. Click here for more details.

hito_tools/ad.py ADDED
@@ -0,0 +1,11 @@
1
+ import datetime
2
+
3
+
4
+ def convert_ad_datetime(timestamp):
5
+ """
6
+ Convert an AD timestamp to a standard one
7
+
8
+ :param timestamp: AD timestamp
9
+ :return: standard timestamp
10
+ """
11
+ return datetime.datetime(1601, 1, 1) + datetime.timedelta(seconds=int(timestamp) / 10000000)
hito_tools/agents.py ADDED
@@ -0,0 +1,440 @@
1
+ import csv
2
+ import re
3
+ import unicodedata
4
+ from string import capwords
5
+ from typing import Dict, Set, Tuple
6
+
7
+ from .utils import str_to_list
8
+
9
+ HITO_ARCHIVED_FIELD = "Archivé ?"
10
+ HITO_EMAIL_FIELD = "email"
11
+ HITO_FIRSTNAME_FIELD = "Prénom"
12
+ HITO_LASTNAME_FIELD = "Nom"
13
+ HITO_OFFICE_FIELD = "Bureau"
14
+ HITO_PHONE_FIELD = "Téléphone"
15
+ HITO_RESEDA_EMAIL_FIELD = "ID Connexion"
16
+ HITO_TEAM_FIELD = "Équipe"
17
+
18
+ MAPPINGS_HITO_NAME = "Hito"
19
+ MAPPINGS_RESEDA_NAME = "RESEDA"
20
+
21
+ NSIP_AGENT_PROJECT_FIELD = "Id Projet"
22
+ NSIP_CONTACT_EMAIL_FIELD = "contact_email"
23
+ NSIP_DEPARTURE_DATE = "Date de fin de contrat"
24
+ NSIP_FIRSTNAME_FIELD = "firstname"
25
+ NSIP_LASTNAME_FIELD = "lastname"
26
+ NSIP_OFFICES_FIELD = "offices"
27
+ NSIP_RESEDA_EMAIL_FIELD = "username"
28
+ NSIP_PHONE_NUMBERS = "phone_numbers"
29
+ NSIP_TEAM_ID_FIELD = "team ID"
30
+
31
+ NSIP_INVALID_EMAIL_PATTERN = r"[0-9a-f]+(@in2p3\.fr)?$"
32
+
33
+
34
+ class Agent:
35
+ def __init__(
36
+ self,
37
+ first_name: str,
38
+ last_name: str,
39
+ team: str = None,
40
+ email: str = None,
41
+ reseda_email: str = None,
42
+ team_id: int = None,
43
+ ) -> None:
44
+ self.firstname = first_name
45
+ self.lastname = last_name
46
+ self._email = email
47
+ # email_alias is the lab official email for the agent
48
+ self._email_alias = None
49
+ self._reseda_email = reseda_email
50
+ if len(self.firstname) == 0:
51
+ self.fullname = self.lastname
52
+ elif len(self.lastname) == 0:
53
+ self.fullname = self.firstname
54
+ else:
55
+ self.fullname = f"{self.firstname} {self.lastname}"
56
+ self._projects = set()
57
+ self._team = team
58
+ self._team_id = team_id
59
+ self._offices = set()
60
+ self._phones = set()
61
+ self._matched = False
62
+ self._disabled = False
63
+
64
+ @property
65
+ def ascii_firstname(self) -> str:
66
+ return self._ascii_firstname
67
+
68
+ @property
69
+ def ascii_lastname(self) -> str:
70
+ return self._ascii_lastname
71
+
72
+ @property
73
+ def ascii_name(self) -> str:
74
+ return self._ascii_fullname
75
+
76
+ @property
77
+ def disabled(self) -> bool:
78
+ return self._disabled
79
+
80
+ @property
81
+ def email(self) -> str:
82
+ return self._email
83
+
84
+ @property
85
+ def email_alias(self) -> str:
86
+ return self._email_alias
87
+
88
+ @email_alias.setter
89
+ def email_alias(self, alias: str) -> None:
90
+ self._email_alias = alias
91
+
92
+ @property
93
+ def firstname(self) -> str:
94
+ return self._firstname
95
+
96
+ @firstname.setter
97
+ def firstname(self, firstname: str) -> None:
98
+ self._firstname = firstname
99
+ self._ascii_firstname = ascii_lower_nohyphen(firstname)
100
+
101
+ @property
102
+ def fullname(self) -> str:
103
+ return self._fullname
104
+
105
+ @fullname.setter
106
+ def fullname(self, fullname: str) -> None:
107
+ self._fullname = fullname
108
+ self._ascii_fullname = ascii_lower_nohyphen(fullname)
109
+
110
+ @property
111
+ def lastname(self) -> str:
112
+ return self._lastname
113
+
114
+ @lastname.setter
115
+ def lastname(self, lastname: str) -> None:
116
+ self._lastname = lastname
117
+ self._ascii_lastname = ascii_lower_nohyphen(lastname)
118
+
119
+ @property
120
+ def matched(self) -> bool:
121
+ return self._matched
122
+
123
+ @property
124
+ def offices(self) -> Set[str]:
125
+ return self._offices
126
+
127
+ @offices.setter
128
+ def offices(self, office: str) -> None:
129
+ self._offices.add(office)
130
+
131
+ @property
132
+ def phones(self) -> Set[str]:
133
+ return self._phones
134
+
135
+ @phones.setter
136
+ def phones(self, phone: str) -> None:
137
+ self._phones.add(phone)
138
+
139
+ @property
140
+ def projects(self) -> Set[str]:
141
+ return self._projects
142
+
143
+ @projects.setter
144
+ def projects(self, project: str) -> None:
145
+ self._projects.add(project)
146
+
147
+ @property
148
+ def reseda_email(self) -> str:
149
+ return self._reseda_email
150
+
151
+ @property
152
+ def team(self) -> str:
153
+ return self._team
154
+
155
+ @property
156
+ def team_id(self) -> str:
157
+ return self._team_id
158
+
159
+ def get_emails(self) -> Tuple[str, str]:
160
+ return self._email, self._reseda_email
161
+
162
+ def remove_office(self, office: str) -> None:
163
+ if office in self.offices:
164
+ self._offices.remove(office)
165
+ pass
166
+
167
+ def set_matched(self) -> None:
168
+ self._matched = True
169
+
170
+ def set_disabled(self) -> None:
171
+ self._disabled = True
172
+
173
+
174
+ def nsip_agent_active(agent_email: str):
175
+ """
176
+ Returns true if the email is a valid email, meaning that the agent is active, False otherwise.
177
+
178
+ :param agent_email: email of the agent
179
+ :return: True or False
180
+ """
181
+
182
+ if re.match(NSIP_INVALID_EMAIL_PATTERN, agent_email):
183
+ return False
184
+ else:
185
+ return True
186
+
187
+
188
+ def capitalize_name(name):
189
+ """
190
+ Captitalize a name composed of several words separated by withespaces or hyphens
191
+
192
+ :param name: name to capitalize
193
+ :return: the capitalized name
194
+ """
195
+
196
+ m = re.match(r".*(?P<sep>\s*-\s*)\S", name)
197
+ if m:
198
+ return capwords(name, m.group("sep"))
199
+ else:
200
+ return capwords(name)
201
+
202
+
203
+ def ascii_name(string: str) -> str:
204
+ """
205
+ Helper function to replace accented characters by ASCII characters and replace
206
+ multiple withespaces by only one space
207
+
208
+ :param string: string to convert
209
+ :return: string with only ASCII characters
210
+ """
211
+ ascii_string = unicodedata.normalize("NFD", string).encode("ascii", "ignore").decode("ascii")
212
+ ascii_string = re.sub(r"\s+", " ", ascii_string)
213
+ return ascii_string
214
+
215
+
216
+ def translate_to_ascii(string: str) -> str:
217
+ """
218
+ Helper function to translate a string with accented characters to ASCII and replace all
219
+ consecutive white spaces by a single '-' and suppress existing withespaces around '-'
220
+
221
+ :param string: string to convert
222
+ :return: string with only ASCII characters
223
+ """
224
+ ascii_string = unicodedata.normalize("NFD", string).encode("ascii", "ignore").decode("ascii")
225
+ ascii_string = re.sub(r"\s+", "-", ascii_string)
226
+ return re.sub("-+", "-", ascii_string)
227
+
228
+
229
+ def ascii_lower(string: str) -> str:
230
+ """
231
+ Helper function to translate a string with accented characters to ASCII lowercase
232
+ characters and replace all consecutive white spaces by a single '-'.
233
+
234
+ :param string: string to convert
235
+ :return: string with only ASCII characters
236
+ """
237
+ return translate_to_ascii(string).lower()
238
+
239
+
240
+ def ascii_lower_nohyphen(string: str) -> str:
241
+ """
242
+ Helper function that removes hyphens and whitespaces after all the transformation done by
243
+ ascii-lower()
244
+
245
+ :param string: string to convert
246
+ :return: string with only ASCII characters
247
+ """
248
+ string = re.sub(r"\s", "", string)
249
+ return re.sub("-", "", ascii_lower(string))
250
+
251
+
252
+ def read_hito_agents(
253
+ file: str,
254
+ ignore_if_no_team: bool = False,
255
+ use_archived: bool = False,
256
+ ascii_name_only: bool = False,
257
+ ) -> Dict[str, Agent]:
258
+ """
259
+ Read a Hito export CSV and return a list of Agent as a dict where for each agent 2 keys are
260
+ added: one corresponding ot the full name (givenname + lastname) and the other to the
261
+ lowercase ASCII version of the fullname.
262
+
263
+ :param file: Hito export CSV
264
+ :param ignore_if_no_team: if True ignore agents not assigned to a team
265
+ :param use_archived: if true, also use archived users in the CSV file (if any)
266
+ :param ascii_name_only: use only the ASCII lowercase full name as a key in the agent list
267
+ :return: dict representing the agent list
268
+ """
269
+
270
+ agent_list: Dict[str, Agent] = {}
271
+
272
+ try:
273
+ with open(file, "r", encoding="utf-8") as f:
274
+ csv_reader = csv.DictReader(f, delimiter=";")
275
+ for e in csv_reader:
276
+ if (
277
+ HITO_ARCHIVED_FIELD in e
278
+ and e[HITO_ARCHIVED_FIELD].lower() == "o"
279
+ and not use_archived
280
+ ):
281
+ continue
282
+ agent = Agent(
283
+ e[HITO_FIRSTNAME_FIELD],
284
+ e[HITO_LASTNAME_FIELD],
285
+ e[HITO_TEAM_FIELD],
286
+ e[HITO_EMAIL_FIELD].lower(),
287
+ e[HITO_RESEDA_EMAIL_FIELD].lower(),
288
+ )
289
+ if HITO_OFFICE_FIELD in e:
290
+ for office in str_to_list(e[HITO_OFFICE_FIELD]):
291
+ agent.offices = office
292
+ if HITO_PHONE_FIELD in e:
293
+ for phone_number in str_to_list(e[HITO_PHONE_FIELD]):
294
+ agent.phones = phone_number
295
+ # Ignore agents in Hito not assigned to a team
296
+ if ignore_if_no_team and len(e[HITO_TEAM_FIELD]) == 0:
297
+ print(f"INFO: '{agent.fullname}' doesn't belong to a team: ignoring it.")
298
+ continue
299
+ if not ascii_name_only:
300
+ agent_list[agent.fullname] = agent
301
+ # Also add a lowercase with no hyphen and no accented chars entry to help
302
+ # with matching
303
+ agent_list[agent.ascii_name] = agent
304
+ except: # noqa: E722
305
+ print(f"Error reading Hito CSV ({file})")
306
+ raise
307
+
308
+ return agent_list
309
+
310
+
311
+ def get_nsip_agents(nsip_session, context: str = "NSIP") -> Dict[str, Agent]:
312
+ """
313
+ Function to retrieve agents from NSIP through the NSIP API and return a list of Agent as a
314
+ dict where for each agent 2 keys are added: one corresponding ot the full name (givenname
315
+ + lastname) and the other to the lowercase ASCII version of the fullname.
316
+
317
+ :param nsip_session: a NSIPConnection object
318
+ :param context: either 'NSIP' (all agents presents at least one day during the semester)
319
+ or 'DIRECTORY' (only agents with an active contract)
320
+ :return: dict representing the list of agents
321
+ """
322
+
323
+ agent_list: Dict[str, Agent] = {}
324
+
325
+ agents = nsip_session.get_agent_list(context)
326
+
327
+ for e in agents:
328
+ agent = Agent(
329
+ e["firstname"], e["lastname"], e["team"], e["email"], e["email_reseda"], e["team_id"]
330
+ )
331
+ if e["offices"]:
332
+ for office in e["offices"]:
333
+ if len(office) > 0:
334
+ agent.offices = office
335
+ if e["phoneNumbers"]:
336
+ for number in e["phoneNumbers"]:
337
+ if len(number) > 0:
338
+ agent.phones = number
339
+ if not nsip_agent_active(agent.reseda_email):
340
+ agent.set_disabled()
341
+ agent_list[agent.fullname] = agent
342
+
343
+ return agent_list
344
+
345
+
346
+ def name_mapping_exceptions(file: str) -> Dict[str, str]:
347
+ """
348
+ Read a CSV defining the RESEDA/Hito name mapping for special cases and return them as a dict
349
+ where the key is the NSIP name and the value is the Hito name.
350
+
351
+ :param file: CSV file defining the exceptions
352
+ :return: dict
353
+ """
354
+ hito_nsip_explicit_mappings: Dict[str, str] = {}
355
+
356
+ try:
357
+ with open(file, "r", encoding="utf-8") as f:
358
+ mappings_reader = csv.DictReader(f, delimiter=";")
359
+ for e in mappings_reader:
360
+ hito_nsip_explicit_mappings[e[MAPPINGS_RESEDA_NAME]] = e[MAPPINGS_HITO_NAME]
361
+ except: # noqa: E722
362
+ print(f"Error reading Hito/RESEDA mappings CSV ({file})")
363
+ raise
364
+
365
+ return hito_nsip_explicit_mappings
366
+
367
+
368
+ def match_hito_agent_name(
369
+ project_agent: Agent,
370
+ hito_agent_list: Dict[str, Agent],
371
+ hito_nsip_explicit_mappings: Dict[str, str] = {},
372
+ global_users: Dict[str, str] = {},
373
+ verbose: bool = False,
374
+ ) -> Tuple[str, bool, str]:
375
+ """
376
+ Function returning the Hito agent name matching a NSIP agent name, taking into account a list
377
+ of explicit mappins.
378
+
379
+ :param project_agent: project agent object
380
+ :param hito_agent_list: Hito agent list (a dict where the key is the agent full name)
381
+ :param hito_nsip_explicit_mappings: a list of explicit mappings
382
+ :return: Hito agent name or None if no match found, approximate_match flag (boolean),
383
+ approximate match criteria
384
+ """
385
+
386
+ approximate_matching = False
387
+ match_criteria = ""
388
+ project_agent_name = project_agent.fullname
389
+
390
+ if project_agent_name in hito_nsip_explicit_mappings:
391
+ hito_name = hito_nsip_explicit_mappings[project_agent_name]
392
+ print(
393
+ f"INFO: explicit Hito match defined for NSIP agent '{project_agent_name}': {hito_name}"
394
+ )
395
+ else:
396
+ hito_name = project_agent_name
397
+ hito_ascii_name = ascii_lower_nohyphen(hito_name)
398
+ # global_users are pseudo-users used in the local project CSV that match a team
399
+ if (
400
+ hito_name in hito_agent_list
401
+ or hito_ascii_name in hito_agent_list
402
+ or hito_name in global_users
403
+ ):
404
+ if hito_name not in hito_agent_list and hito_name not in global_users:
405
+ approximate_matching = True
406
+ match_criteria = "fullname spelling"
407
+ hito_name = hito_agent_list[hito_ascii_name].fullname
408
+ else:
409
+ _, project_agent_email = project_agent.get_emails()
410
+ # Remove duplicated entries
411
+ # First attempt to match by emails: as every entry is duplicated (full name + ascii name),
412
+ # 2 matches are expected
413
+ matching_agents = [
414
+ x.fullname
415
+ for x in hito_agent_list.values()
416
+ if re.match(f".*{project_agent_email}$", x.get_emails()[1])
417
+ ]
418
+ expected_matches = 2
419
+ match_criteria = "email"
420
+ if len(matching_agents) == 0:
421
+ # If it failed, attempt a partial match (Hito name at the end of the project name)
422
+ matching_agents = [x for x in hito_agent_list.keys() if re.match(f".*{hito_name}$", x)]
423
+ expected_matches = 1
424
+ match_criteria = "partial fullname"
425
+ if len(matching_agents) == expected_matches:
426
+ approximate_matching = True
427
+ hito_name = matching_agents[0]
428
+ else:
429
+ if len(matching_agents) > 1:
430
+ print(
431
+ (
432
+ f"ERROR: approximate match for project agent '{hito_name}'"
433
+ f" failed, too many matches"
434
+ )
435
+ )
436
+ elif verbose:
437
+ print(f"ERROR: agent {hito_name} not found in Hito.")
438
+ hito_name = None
439
+
440
+ return hito_name, approximate_matching, match_criteria
hito_tools/core.py ADDED
@@ -0,0 +1,66 @@
1
+ # -*- coding: utf-8 -*-
2
+
3
+ """
4
+ Definition of constants and helper functions shared by all scripts
5
+ """
6
+
7
+ import os
8
+ import sys
9
+
10
+
11
+ # Singleton decorator definition
12
+ def singleton(cls):
13
+ instances = {}
14
+
15
+ def getinstance():
16
+ if cls not in instances:
17
+ instances[cls] = cls()
18
+ return instances[cls]
19
+
20
+ return getinstance
21
+
22
+
23
+ @singleton
24
+ class GlobalParams:
25
+ def __init__(self):
26
+ self.logger = None
27
+ self.this_script = None
28
+ self.verbose = False
29
+ self.windows = None
30
+
31
+
32
+ def check_os():
33
+ global_params = GlobalParams()
34
+ if os.name == "nt":
35
+ global_params.windows = True
36
+
37
+
38
+ def debug(msg):
39
+ global_params = GlobalParams()
40
+ if global_params.logger:
41
+ global_params.logger.debug("{}".format(msg))
42
+ elif global_params.verbose:
43
+ print(msg)
44
+
45
+
46
+ def exception_handler(exception_type, exception, traceback, debug_hook=sys.excepthook):
47
+ global_params = GlobalParams()
48
+ if global_params.verbose:
49
+ debug_hook(exception_type, exception, traceback)
50
+ else:
51
+ print("{}: {} (use --verbose for details)".format(exception_type.__name__, exception))
52
+
53
+
54
+ def info(msg):
55
+ global_params = GlobalParams()
56
+ if global_params.logger:
57
+ global_params.logger.info("{}".format(msg))
58
+ else:
59
+ print(msg)
60
+
61
+
62
+ def is_windows():
63
+ global_params = GlobalParams()
64
+ if global_params.windows is None:
65
+ check_os()
66
+ return global_params.windows
@@ -0,0 +1,103 @@
1
+ # Exceptions for applications based on this module
2
+
3
+ from datetime import datetime
4
+
5
+ # Status code in case of errors
6
+ EXIT_STATUS_CONFIG_ERROR = 1
7
+ EXIT_STATUS_INVALID_SQL_VALUE = 20
8
+ EXIT_STATUS_GENERAL_ERROR = 30
9
+
10
+
11
+ class ConfigFileEmpty(Exception):
12
+ def __init__(self, file):
13
+ self.msg = f"No configuration parameter defined in {file}"
14
+ self.status = EXIT_STATUS_CONFIG_ERROR
15
+
16
+ def __str__(self):
17
+ return repr(self.msg)
18
+
19
+
20
+ class ConfigInvalidParamValue(Exception):
21
+ def __init__(self, param, value, file=None):
22
+ if file:
23
+ file_msg = " (file={file})"
24
+ self.msg = f"Invalid configuration parameter value ({value}) for '{param}'{file_msg}"
25
+ self.status = EXIT_STATUS_CONFIG_ERROR
26
+
27
+ def __str__(self):
28
+ return repr(self.msg)
29
+
30
+
31
+ class ConfigMissingParam(Exception):
32
+ def __init__(self, param, file=None):
33
+ if file:
34
+ file_msg = " (file={file})"
35
+ else:
36
+ file_msg = ""
37
+ self.msg = f"Missing required configuration parameter '{param}'{file_msg}"
38
+ self.status = EXIT_STATUS_CONFIG_ERROR
39
+
40
+ def __str__(self):
41
+ return repr(self.msg)
42
+
43
+
44
+ class OptionMissing(Exception):
45
+ def __init__(self, option):
46
+ self.msg = f"Option '{option}' required but missing"
47
+ self.status = EXIT_STATUS_CONFIG_ERROR
48
+
49
+ def __str__(self):
50
+ return repr(self.msg)
51
+
52
+
53
+ class NSIPPeriodAmbiguous(Exception):
54
+ def __init__(self, date, num_matches):
55
+ if date is None:
56
+ date = datetime.now()
57
+ self.msg = f"Several declaration periods ({num_matches}) found in NSIP matching {date}"
58
+ self.status = EXIT_STATUS_GENERAL_ERROR
59
+
60
+ def __str__(self):
61
+ return repr(self.msg)
62
+
63
+
64
+ class NSIPPeriodMissing(Exception):
65
+ def __init__(self, date):
66
+ self.msg = f"No declaration period found in NSIP matching {date}"
67
+ self.status = EXIT_STATUS_GENERAL_ERROR
68
+
69
+ def __str__(self):
70
+ return repr(self.msg)
71
+
72
+
73
+ class SQLArrayMalformedValue(Exception):
74
+ def __init__(self, longtext, value, index):
75
+ self.msg = (
76
+ f"SQL longtext array: malformed value ({value}) at index {index}"
77
+ f" (longtext={longtext})"
78
+ )
79
+ self.status = EXIT_STATUS_INVALID_SQL_VALUE
80
+
81
+ def __str__(self):
82
+ return repr(self.msg)
83
+
84
+
85
+ class SQLInconsistentArrayLen(Exception):
86
+ def __init__(self, longtext, expected_length, actual_length):
87
+ self.msg = (
88
+ f"SQL longtext array: expected length is {expected_length} but actual length"
89
+ f" is {actual_length} (longtext={longtext})"
90
+ )
91
+ self.status = EXIT_STATUS_INVALID_SQL_VALUE
92
+
93
+ def __str__(self):
94
+ return repr(self.msg)
95
+
96
+
97
+ class SQLInvalidArray(Exception):
98
+ def __init__(self, longtext):
99
+ self.msg = f"Invalid SQL longtext array: {longtext}"
100
+ self.status = EXIT_STATUS_INVALID_SQL_VALUE
101
+
102
+ def __str__(self):
103
+ return repr(self.msg)