hito_tools 24.8__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/nsip.py ADDED
@@ -0,0 +1,371 @@
1
+ # Module to handle NSIP interaction
2
+ import datetime
3
+ import re
4
+ from io import StringIO
5
+ from typing import Dict, List
6
+
7
+ import pandas as pd
8
+
9
+ # FIXME: would be better to allow proper verification of the host certificate...
10
+ import requests
11
+ import simplejson as json
12
+ from requests.packages.urllib3.exceptions import InsecureRequestWarning
13
+
14
+ from .exceptions import ConfigMissingParam, NSIPPeriodAmbiguous, NSIPPeriodMissing
15
+
16
+ requests.packages.urllib3.disable_warnings(InsecureRequestWarning)
17
+
18
+ # Define exit code in case of errors
19
+ EXIT_STATUS_NSIP_API_PARAMS = 10
20
+ EXIT_STATUS_NSIP_API_ERROR = 11
21
+
22
+ # Define some constants related to HTTP
23
+ HTTP_STATUS_OK = 200
24
+ HTTP_STATUS_CREATED = 201
25
+ HTTP_STATUS_ACCEPTED = 202
26
+ HTTP_STATUS_NO_CONTENT = 204
27
+ HTTP_STATUS_BAD_REQUEST = 400
28
+ HTTP_STATUS_UNAUTHORIZED = 401
29
+ HTTP_STATUS_FORBIDDEN = 403
30
+ HTTP_STATUS_NOT_FOUND = 404
31
+ HTTP_STATUS_CONFLICT = 409
32
+
33
+ # Required config params describing the NSIP API in the configuration file
34
+ # The key is the API subpart and the value a set of required parameters describing the sub-urls.
35
+ # 'base_url' is an implicitly required parameter for each API part.
36
+ NSIP_API_REQUIRED_CONFIG = {
37
+ "agent_api": ["declaration_add", "declaration_delete", "declaration_update"],
38
+ "institute_api": ["declaration_period_list"],
39
+ "lab_api": ["agent_list", "declaration_list"],
40
+ }
41
+
42
+ DECLARATION_EXISTS_PATTERN = re.compile(
43
+ r'"A declaration (?P<decl_id>\d+) already exists for this agent.*"$'
44
+ )
45
+ DECLARATION_ADDED_PATTERN = re.compile(r'"Declaration (?P<decl_id>\d+) successfully created"$')
46
+
47
+
48
+ class NSIPRequestFailure(Exception):
49
+ def __init__(self, code, url):
50
+ self.msg = f"NSIP agent API request failure (Status={code}, URL={url})"
51
+ self.status = EXIT_STATUS_NSIP_API_ERROR
52
+
53
+ def __str__(self):
54
+ return repr(self.msg)
55
+
56
+
57
+ class NSIPConnection:
58
+ def __init__(
59
+ self,
60
+ server_url: str,
61
+ bearer_token: Dict[str, str],
62
+ agent_api: str,
63
+ lab_api: Dict[str, str],
64
+ institute_api: Dict[str, str],
65
+ ) -> None:
66
+ self.server_url = server_url
67
+ self.token = bearer_token
68
+ self.agent_api = agent_api
69
+ self.institute_api = institute_api
70
+ self.lab_api = lab_api
71
+
72
+ def get_agent_list(self, context: str = "NSIP"):
73
+ """
74
+ Retrieve NSIP agents from NSIP API and return a dict built from the retrieved JSON
75
+
76
+ :param context: either 'NSIP' (all agents presents at least one day during the semester)
77
+ or 'DIRECTORY' (only agents with an active contract)
78
+ :return: dict representing the JSON anwser
79
+ """
80
+
81
+ url = f"{self.server_url}{self.lab_api['base_url']}{self.lab_api['agent_list']}"
82
+ r = requests.get(
83
+ url,
84
+ headers={"Authorization": f"Bearer {self.token}"},
85
+ params={"context": context},
86
+ )
87
+ if r.status_code != HTTP_STATUS_OK:
88
+ raise NSIPRequestFailure(r.status_code, url)
89
+
90
+ agents = r.json()
91
+
92
+ return agents
93
+
94
+ def update_agent(
95
+ self,
96
+ reseda_email: str,
97
+ team_id: str = None,
98
+ email: str = None,
99
+ phones: List[str] = None,
100
+ offices: List[str] = None,
101
+ ) -> int:
102
+ """
103
+ Update agent attributes. Every attribute can be omitted if it should not
104
+ be modified. reseda_email is used to identify the user to modify and is
105
+ the only required parameter. An agent cannot be added through the API, it
106
+ has to exist before.
107
+
108
+ :param reseda_email: the user resedaEmail, used to identify the user
109
+ :param team_id: ID of user's new team
110
+ :param email: user's new email
111
+ :param phones: user's new phone list
112
+ :param offices: user's new office list
113
+ :return: status (0 for successful update, a positive value if an error occured),
114
+ http_status, http_reason
115
+ """
116
+
117
+ params = {"emailReseda": reseda_email, "context": "DIRECTORY"}
118
+ url = f"{self.server_url}{self.agent_api['base_url']}{self.agent_api['agent_update']}"
119
+ if team_id is not None:
120
+ params["teamId"] = team_id
121
+ if email is not None:
122
+ params["contactEmail"] = email
123
+ if phones is not None:
124
+ # To clear the phones, an empty string must be passed to work around an API error
125
+ if len(phones) == 0:
126
+ phones.add("")
127
+ params["phoneNumbers"] = json.dumps(phones, iterable_as_array=True)
128
+ if offices is not None:
129
+ # To clear the offices, an empty string must be passed to work around an API error
130
+ if len(offices) == 0:
131
+ offices.add("")
132
+ params["offices"] = json.dumps(offices, iterable_as_array=True)
133
+ r = requests.put(url, headers={"Authorization": f"Bearer {self.token}"}, params=params)
134
+ if r.content:
135
+ reason = r.content.decode("utf-8")
136
+ else:
137
+ reason = ""
138
+
139
+ if r.status_code == HTTP_STATUS_OK:
140
+ status = 0
141
+ else:
142
+ status = 1
143
+ return status, r.status_code, reason
144
+
145
+ def update_declaration(
146
+ self,
147
+ email: str,
148
+ project_id: str,
149
+ project_type: bool,
150
+ time: int,
151
+ validation_date: datetime.date = None,
152
+ contract: int = None,
153
+ ) -> int:
154
+ """
155
+ Add or update a project declaration for a user specified by its RESEDA email
156
+
157
+ :param email: RESEDA email of the selected user
158
+ :param project_id: ID of the selected project
159
+ :param project_type: if True, it is a project, else it is a reference (other activities)
160
+ :param time: time spent on the project in the unit appropriate for the project (hour or
161
+ week)
162
+ :param validation_date: validation date
163
+ :param contract: contract ID in case the agent has multiple contracts for the period
164
+ :return: status (0 for successful add, -1 for successful update, positive value if errors),
165
+ http_status if errors else declaration ID added/modified, http_reason
166
+ """
167
+
168
+ url = f"{self.server_url}{self.agent_api['base_url']}{self.agent_api['declaration_add']}"
169
+ if project_type:
170
+ params = {"projectId": int(project_id), "referenceId": ""}
171
+ else:
172
+ params = {"projectId": "", "referenceId": int(project_id)}
173
+ params["context"] = "NSIP"
174
+ if contract:
175
+ print(f"INFO: updating {email} declaration using contract {contract}")
176
+ params["idAgentContract"] = contract
177
+ else:
178
+ params["emailReseda"] = email
179
+ params["time"] = time
180
+ if validation_date:
181
+ validation_date_str = validation_date.date().isoformat()
182
+ params["managerValidationDate"] = validation_date_str
183
+ r = requests.post(url, headers={"Authorization": f"Bearer {self.token}"}, params=params)
184
+ if r.content:
185
+ reason = r.content.decode("utf-8")
186
+ else:
187
+ reason = ""
188
+
189
+ if r.status_code == HTTP_STATUS_OK:
190
+ m = DECLARATION_ADDED_PATTERN.match(reason)
191
+ if m:
192
+ declaration_id = m.group("decl_id")
193
+ else:
194
+ print(f"ERROR: unable to extract declaration number from request reason ({reason})")
195
+ status = 0
196
+
197
+ elif r.status_code == HTTP_STATUS_FORBIDDEN:
198
+ # If http status is Forbidden, parse the associated message. If it is the expected
199
+ # message for an already existing declaration, retrieve the declaration ID and
200
+ # update it.
201
+ m = DECLARATION_EXISTS_PATTERN.match(reason)
202
+ if m:
203
+ declaration_id = m.group("decl_id")
204
+ url = (
205
+ f"{self.server_url}{self.agent_api['base_url']}"
206
+ f"{self.agent_api['declaration_update']}"
207
+ )
208
+ params = {"id": declaration_id, "time": time, "context": "NSIP"}
209
+ if validation_date_str:
210
+ params["managerValidationDate"] = validation_date_str
211
+ status = -1
212
+ r = requests.put(
213
+ url,
214
+ headers={"Authorization": f"Bearer {self.token}"},
215
+ params=params,
216
+ )
217
+
218
+ if r.status_code == HTTP_STATUS_OK:
219
+ return status, declaration_id, reason
220
+ else:
221
+ return 1, r.status_code, reason
222
+
223
+ def get_declaration_period_id(self, period_date: datetime):
224
+ """
225
+ Return the declaration ID for the declaration period matching a given date (the date must
226
+ be included in the period.
227
+
228
+ :param period_date: date that must be inside the period
229
+ :return: declaration period ID
230
+ """
231
+
232
+ url = (
233
+ f"{self.server_url}{self.institute_api['base_url']}"
234
+ f"{self.institute_api['declaration_period_list']}"
235
+ )
236
+
237
+ r = requests.get(url, headers={"Authorization": f"Bearer {self.token}"})
238
+ if r.status_code != HTTP_STATUS_OK:
239
+ raise NSIPRequestFailure(r.status_code, url)
240
+
241
+ periods = pd.read_json(StringIO(r.content.decode()))
242
+ periods["startDateDeclaration"] = pd.to_datetime(periods.startDateDeclaration)
243
+ periods["endDateDeclaration"] = pd.to_datetime(periods.endDateDeclaration)
244
+ selected_period = periods.loc[
245
+ (periods.startDateDeclaration <= period_date)
246
+ & (periods.endDateDeclaration > period_date)
247
+ ]
248
+ if len(selected_period) == 0:
249
+ raise NSIPPeriodMissing(period_date)
250
+ elif len(selected_period) > 1:
251
+ raise NSIPPeriodAmbiguous(period_date, len(selected_period))
252
+
253
+ return selected_period.iloc[0]["id"]
254
+
255
+ def get_declarations(self, period_date: datetime):
256
+ """
257
+ Return the NSIP declaration list for the declaration period matching a given date (the
258
+ date must be included in the period).
259
+
260
+ :param period_date: date that must be inside the period
261
+ :return: declaration list as a dict
262
+ """
263
+
264
+ period_id = self.get_declaration_period_id(period_date)
265
+
266
+ url = f"{self.server_url}{self.lab_api['base_url']}{self.lab_api['declaration_list']}"
267
+ params = {"idPeriod": period_id}
268
+ r = requests.get(url, headers={"Authorization": f"Bearer {self.token}"}, params=params)
269
+ if r.status_code != HTTP_STATUS_OK:
270
+ raise NSIPRequestFailure(r.status_code, url)
271
+
272
+ declarations = r.json()
273
+
274
+ return declarations
275
+
276
+ def get_activities(self, project_activity: bool):
277
+ """
278
+ Return the list of projects for the laboratory defined in NSIP
279
+
280
+ :param project_activity: true for projects, false for other activities
281
+ :return: activity list as a list
282
+ """
283
+
284
+ if project_activity:
285
+ activity_api = self.lab_api["project_list"]
286
+ else:
287
+ activity_api = self.lab_api["reference_list"]
288
+
289
+ url = f"{self.server_url}{self.lab_api['base_url']}{activity_api}"
290
+ r = requests.get(
291
+ url,
292
+ headers={"Authorization": f"Bearer {self.token}"},
293
+ )
294
+ if r.status_code != HTTP_STATUS_OK:
295
+ raise NSIPRequestFailure(r.status_code, url)
296
+
297
+ activities = r.json()
298
+
299
+ return activities
300
+
301
+ def get_teams(self):
302
+ """
303
+ Return the list of lab teams as a list of dict
304
+
305
+ :return: list
306
+ """
307
+
308
+ url = f"{self.server_url}{self.lab_api['base_url']}{self.lab_api['team_list']}"
309
+ r = requests.get(url, headers={"Authorization": f"Bearer {self.token}"})
310
+ if r.status_code != HTTP_STATUS_OK:
311
+ raise NSIPRequestFailure(r.status_code, url)
312
+
313
+ teams = r.json()
314
+
315
+ return teams
316
+
317
+
318
+ def check_nsip_config(nsip_config) -> bool:
319
+ """
320
+ Check the NSIP parameters and return True if it os or raise an exception otherwise
321
+
322
+ :param nsip_config: dict containing NSIP parameters
323
+ :return:
324
+ """
325
+
326
+ required_keys = set(NSIP_API_REQUIRED_CONFIG.keys())
327
+ required_keys.update(["server_url", "token"])
328
+ for k in required_keys:
329
+ if k not in nsip_config:
330
+ raise ConfigMissingParam(f"nsip/{k}")
331
+ if k in NSIP_API_REQUIRED_CONFIG:
332
+ required_subkeys = set(NSIP_API_REQUIRED_CONFIG[k])
333
+ required_subkeys.add("base_url")
334
+ for sk in required_subkeys:
335
+ if sk not in nsip_config[k]:
336
+ raise ConfigMissingParam(f"nsip/{k}/{sk}")
337
+
338
+ return True
339
+
340
+
341
+ def nsip_session_init(nsip_config):
342
+ """
343
+ Initialize the NSIP session, using the configuration parameters. It is valid for an
344
+ application not to initalize all APIs.
345
+
346
+ :param nsip_config: dict containing the NSIP configuration
347
+ :return: a NSIPConnection object
348
+ """
349
+
350
+ if "agent_api" in nsip_config:
351
+ agent_api = nsip_config["agent_api"]
352
+ else:
353
+ agent_api = None
354
+
355
+ if "lab_api" in nsip_config:
356
+ lab_api = nsip_config["lab_api"]
357
+ else:
358
+ lab_api = None
359
+
360
+ if "institute_api" in nsip_config:
361
+ institute_api = nsip_config["institute_api"]
362
+ else:
363
+ institute_api = None
364
+
365
+ return NSIPConnection(
366
+ nsip_config["server_url"],
367
+ nsip_config["token"],
368
+ agent_api,
369
+ lab_api,
370
+ institute_api,
371
+ )
hito_tools/projects.py ADDED
@@ -0,0 +1,175 @@
1
+ import csv
2
+ import re
3
+ from typing import Dict, Set
4
+
5
+ from .agents import Agent
6
+
7
+ NSIP_ACTIVIY_NAME = "name"
8
+ NSIP_ACTIVITY_ID = "id"
9
+ NSIP_ACTIVIY_TYPE = "type"
10
+ NSIP_MASTERPROJECT_FIELD = "Master projet"
11
+ NSIP_PROJECT_FIELD = "Projet"
12
+ NSIP_PROJECT_ID_FIELD = "id projet"
13
+
14
+ LOCAL_MASTERPROJECT_NAME = "Local projects"
15
+ LOCAL_PROJECT_NAME_FIELD = "PROJET"
16
+ LOCAL_CSV_LAST_FIELD_BEFORE_AGENTS = "TOTAL \nnb semaines"
17
+
18
+
19
+ class ProjectActivity:
20
+ def __init__(self, master_project: str, project_name: str, nsip_id: str = None) -> None:
21
+ # Ensure that the project name in Hito starts by the master project name to ensure unicity.
22
+ # Use / as separator between master project and project names (used by Hito to identify
23
+ # project groups)
24
+ m = re.match(rf"{master_project}\s+\-\s+(?P<project>.*)", project_name)
25
+ if m:
26
+ project_name = m.group("project")
27
+ if len(master_project) == 0:
28
+ self._name = project_name
29
+ elif len(project_name) == 0:
30
+ self._name = master_project
31
+ else:
32
+ self._name = f"{master_project} / {project_name}"
33
+ self._id = nsip_id
34
+
35
+ @property
36
+ def id(self) -> str:
37
+ return self._id
38
+
39
+ @property
40
+ def name(self) -> str:
41
+ return self._name
42
+
43
+
44
+ class Project(ProjectActivity):
45
+ def __init__(self, masterproject: str, project: str, nsip_id: str = None) -> None:
46
+ super(Project, self).__init__(masterproject, project, nsip_id)
47
+ self._teams = set()
48
+
49
+ @property
50
+ def teams(self) -> Set:
51
+ return self._teams
52
+
53
+ @teams.setter
54
+ def teams(self, name: str) -> None:
55
+ self._teams.add(name)
56
+
57
+
58
+ def read_nsip_projects(file: str) -> Dict[str, ProjectActivity]:
59
+ """
60
+ Read a NSIP projects CSV file and return the project list as a dict where the key is the
61
+ project name and the value is a Project object.
62
+
63
+ :param file: NSIP project CSV
64
+ :return: list of projects as a dict
65
+ """
66
+
67
+ project_list: Dict[str, ProjectActivity] = {}
68
+
69
+ try:
70
+ with open(file, "r", encoding="utf-8") as f:
71
+ nsip_reader = csv.DictReader(f, delimiter=";")
72
+ for e in nsip_reader:
73
+ project = Project(
74
+ e[NSIP_MASTERPROJECT_FIELD],
75
+ e[NSIP_PROJECT_FIELD],
76
+ e[NSIP_PROJECT_ID_FIELD],
77
+ )
78
+ project_list[e[NSIP_PROJECT_ID_FIELD]] = project
79
+ except: # noqa: E722
80
+ print(f"Error reading NSIP projects CSV ({file})")
81
+ raise
82
+
83
+ return project_list
84
+
85
+
86
+ def read_nsip_activities(
87
+ file: str, masterproject_names: Dict[str, str] = {}
88
+ ) -> Dict[str, ProjectActivity]:
89
+ """
90
+ Read a NSIP activities CSV file and return the activity list as a dict where the key is
91
+ the activity name and the value is a Project object. The master project is derived from
92
+ the activity type, the actual name being defined in the configuration. When the actual name
93
+ is None, the category is disabled.
94
+
95
+ :param file: NSIP project CSV
96
+ :param masterproject_names: a dict that defines the actual name of a NSIP category or disable
97
+ its use.
98
+ :return: list of projects as a dict
99
+ """
100
+
101
+ project_list: Dict[str, ProjectActivity] = {}
102
+
103
+ try:
104
+ with open(file, "r", encoding="utf-8") as f:
105
+ nsip_reader = csv.DictReader(f, delimiter=";")
106
+ for e in nsip_reader:
107
+ m = re.match(r"(?P<category>.*)reference$", e[NSIP_ACTIVIY_TYPE])
108
+ if m:
109
+ category = m.group("category")
110
+ else:
111
+ category = e[NSIP_ACTIVIY_TYPE]
112
+ if category in masterproject_names:
113
+ master_project = masterproject_names[category]
114
+ if master_project is None:
115
+ continue
116
+ else:
117
+ master_project = category
118
+ project = Project(master_project, e[NSIP_ACTIVIY_NAME], e[NSIP_ACTIVITY_ID])
119
+ project_list[project.name] = project
120
+ except: # noqa: E722
121
+ print(f"Error reading NSIP activities CSV ({file})")
122
+ raise
123
+
124
+ return project_list
125
+
126
+
127
+ def read_local_projects(file: str) -> Dict[str, ProjectActivity]:
128
+ """
129
+ Read the CSV of local projects maintained by CeMaP and return the project list and agent list.
130
+ The project list is as a dict where the key is the project name and the value is a Project
131
+ object. In this CSV, agents assigned to projects are column names.
132
+
133
+ :param file: local project CSV
134
+ :return: list of projects as a dict
135
+ """
136
+
137
+ project_list: Dict[str, ProjectActivity] = {}
138
+ agent_list: Dict[str, Agent] = {}
139
+
140
+ try:
141
+ with open(file, "r", encoding="utf-8") as f:
142
+ nsip_reader = csv.DictReader(f, delimiter=";")
143
+ agents_found = False
144
+
145
+ # Retrieve agents
146
+ for field in nsip_reader.fieldnames:
147
+ if agents_found:
148
+ agent = Agent("", field)
149
+ agent_list[agent.get_fullname()] = agent
150
+ elif field == LOCAL_CSV_LAST_FIELD_BEFORE_AGENTS:
151
+ agents_found = True
152
+ if len(agent_list.keys()) == 0:
153
+ raise Exception(
154
+ (
155
+ f"Malformed CSV for local projects: column"
156
+ f" '{LOCAL_CSV_LAST_FIELD_BEFORE_AGENTS}' missing"
157
+ )
158
+ )
159
+
160
+ # Retrieve projects and set the project list for each agent
161
+ # Ignore entries with an empty project name
162
+ for e in nsip_reader:
163
+ if len(e[LOCAL_PROJECT_NAME_FIELD]) == 0:
164
+ continue
165
+ project = Project(LOCAL_MASTERPROJECT_NAME, e[LOCAL_PROJECT_NAME_FIELD])
166
+ project_list[project.name] = project
167
+ for agent in agent_list.values():
168
+ if e[agent.get_fullname()]:
169
+ agent.add_project(project.name)
170
+
171
+ except: # noqa: E722
172
+ print(f"Error reading NSIP projects CSV ({file})")
173
+ raise
174
+
175
+ return project_list, agent_list
hito_tools/teams.py ADDED
@@ -0,0 +1,40 @@
1
+ from typing import Dict
2
+
3
+ TEAM_CSV_TEAM_ID = "id"
4
+ TEAM_CSV_TEAM_NAME = "name"
5
+ NSIP_NO_TEAM_ID = "770"
6
+
7
+
8
+ class Team:
9
+ def __init__(self, name: str, id: str) -> None:
10
+ self._name = name
11
+ self._id = id
12
+
13
+ @property
14
+ def id(self) -> str:
15
+ return self._id
16
+
17
+ @property
18
+ def name(self) -> str:
19
+ return self._name
20
+
21
+
22
+ def get_nsip_team_ids(nsip_session) -> Dict[str, Team]:
23
+ """
24
+ Return the team list read from NSIP as a dict where the key is the team name and
25
+ the value a Team object. The team name is the friendlyName attribute if defined, else
26
+ the name attribute.
27
+
28
+ :return: dict
29
+ """
30
+
31
+ teams = {}
32
+ team_list = nsip_session.get_teams()
33
+ for t in team_list:
34
+ if "friendlyName" in t and t["friendlyName"]:
35
+ team_name = t["friendlyName"]
36
+ else:
37
+ team_name = t["name"]
38
+ teams[team_name] = Team(team_name, t["id"])
39
+
40
+ return teams