hito_tools 24.8.dev1__tar.gz → 26.1__tar.gz

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.
@@ -1,18 +1,16 @@
1
- Metadata-Version: 2.1
1
+ Metadata-Version: 2.4
2
2
  Name: hito_tools
3
- Version: 24.8.dev1
3
+ Version: 26.1
4
4
  Summary: Modules for interacting with Hito and NSIP
5
+ License-Expression: BSD-3-Clause
6
+ License-File: LICENSE
5
7
  Author: Michel Jouvin
6
8
  Author-email: michel.jouvin@ijclab.in2p3.fr
7
- Requires-Python: >=3.8,<4.0
9
+ Requires-Python: >3.11
10
+ Classifier: Operating System :: OS Independent
8
11
  Classifier: Programming Language :: Python :: 3
9
- Classifier: Programming Language :: Python :: 3.8
10
- Classifier: Programming Language :: Python :: 3.9
11
- Classifier: Programming Language :: Python :: 3.10
12
- Classifier: Programming Language :: Python :: 3.11
13
- Classifier: Programming Language :: Python :: 3.12
14
- Requires-Dist: pandas (>=2.2)
15
- Requires-Dist: requests (>=2.28,<3.0)
12
+ Project-URL: Bug Tracker, https://gitlab.in2p3.fr/hito/hito_tools/-/issues
13
+ Project-URL: Homepage, https://gitlab.in2p3.fr/hito/hito_tools
16
14
  Description-Content-Type: text/markdown
17
15
 
18
16
  # hito_tools module
@@ -1,103 +1,105 @@
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)
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 = f" (file={file})"
24
+ else:
25
+ file_msg = ""
26
+ self.msg = f"Invalid configuration parameter value ({value}) for '{param}'{file_msg}"
27
+ self.status = EXIT_STATUS_CONFIG_ERROR
28
+
29
+ def __str__(self):
30
+ return repr(self.msg)
31
+
32
+
33
+ class ConfigMissingParam(Exception):
34
+ def __init__(self, param, file=None):
35
+ if file:
36
+ file_msg = f" (file={file})"
37
+ else:
38
+ file_msg = ""
39
+ self.msg = f"Missing required configuration parameter '{param}'{file_msg}"
40
+ self.status = EXIT_STATUS_CONFIG_ERROR
41
+
42
+ def __str__(self):
43
+ return repr(self.msg)
44
+
45
+
46
+ class OptionMissing(Exception):
47
+ def __init__(self, option):
48
+ self.msg = f"Option '{option}' required but missing"
49
+ self.status = EXIT_STATUS_CONFIG_ERROR
50
+
51
+ def __str__(self):
52
+ return repr(self.msg)
53
+
54
+
55
+ class NSIPPeriodAmbiguous(Exception):
56
+ def __init__(self, date, num_matches):
57
+ if date is None:
58
+ date = datetime.now()
59
+ self.msg = f"Several declaration periods ({num_matches}) found in NSIP matching {date}"
60
+ self.status = EXIT_STATUS_GENERAL_ERROR
61
+
62
+ def __str__(self):
63
+ return repr(self.msg)
64
+
65
+
66
+ class NSIPPeriodMissing(Exception):
67
+ def __init__(self, date):
68
+ self.msg = f"No declaration period found in NSIP matching {date}"
69
+ self.status = EXIT_STATUS_GENERAL_ERROR
70
+
71
+ def __str__(self):
72
+ return repr(self.msg)
73
+
74
+
75
+ class SQLArrayMalformedValue(Exception):
76
+ def __init__(self, longtext, value, index):
77
+ self.msg = (
78
+ f"SQL longtext array: malformed value ({value}) at index {index}"
79
+ f" (longtext={longtext})"
80
+ )
81
+ self.status = EXIT_STATUS_INVALID_SQL_VALUE
82
+
83
+ def __str__(self):
84
+ return repr(self.msg)
85
+
86
+
87
+ class SQLInconsistentArrayLen(Exception):
88
+ def __init__(self, longtext, expected_length, actual_length):
89
+ self.msg = (
90
+ f"SQL longtext array: expected length is {expected_length} but actual length"
91
+ f" is {actual_length} (longtext={longtext})"
92
+ )
93
+ self.status = EXIT_STATUS_INVALID_SQL_VALUE
94
+
95
+ def __str__(self):
96
+ return repr(self.msg)
97
+
98
+
99
+ class SQLInvalidArray(Exception):
100
+ def __init__(self, longtext):
101
+ self.msg = f"Invalid SQL longtext array: {longtext}"
102
+ self.status = EXIT_STATUS_INVALID_SQL_VALUE
103
+
104
+ def __str__(self):
105
+ return repr(self.msg)
@@ -1,371 +1,499 @@
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
- )
1
+ # Module to handle NSIP interaction
2
+ import datetime
3
+ import re
4
+ from io import StringIO
5
+ from typing import Dict, List, Optional
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
+ AGENT_HAS_MULTIPLE_CONTRACTS_PATTERN = re.compile(
34
+ (
35
+ r'"Agent has active multi-contracts in same laboratory - manual action needed\s+'
36
+ r"\|\s+idAgentContract\s+:\s+(?P<id1>\d+)\s+\|\s+idAgentContract\s+:\s+(?P<id2>\d+)"
37
+ )
38
+ )
39
+ AGENT_NOT_IN_PROJECT_PATTERN = re.compile(
40
+ r'"Error : This project has not been affected to this agent, declaration forbidden"$'
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.api_params = {
69
+ "agent_api": agent_api,
70
+ "institute_api": institute_api,
71
+ "lab_api": lab_api,
72
+ }
73
+
74
+ def _get_api_url(self, api_category: str, api_name: str) -> str:
75
+ """
76
+ Return the API URL for the specified api_name or raise an exception if
77
+ the API name parameter is missing in the configuration. Assumes that base
78
+ configuration (server_url, base_url...) has already been checked.
79
+
80
+ :param api_category: name of the API configuration category (key in self.api_params)
81
+ :param api_name: API name
82
+ :return: API URL
83
+ """
84
+
85
+ if api_name not in self.api_params[api_category]:
86
+ raise ConfigMissingParam(f"nsip/{api_category}/{api_name}")
87
+
88
+ return (
89
+ f"{self.server_url}{self.api_params[api_category]['base_url']}"
90
+ f"{self.api_params[api_category][api_name]}"
91
+ )
92
+
93
+ def _check_multiple_contracts(self, reason: str) -> Optional[str]:
94
+ """
95
+ One of the possible error during agent-related updates is that the user has
96
+ multiple contracts attached to the lab for the current period. In this case
97
+ the reason contains the different contract IDs: used the last one
98
+ the update is retried with the second contract (generally the current one)
99
+ mentioned in the error message.
100
+
101
+ :param reason: error message returned by API
102
+ :return: contract ID if it is the multiple contract error or None
103
+ """
104
+
105
+ m = AGENT_HAS_MULTIPLE_CONTRACTS_PATTERN.match(reason)
106
+ if m:
107
+ contract = m.group("id2")
108
+ else:
109
+ contract = None
110
+
111
+ return contract
112
+
113
+ def get_agent_list(self, context: str = "NSIP"):
114
+ """
115
+ Retrieve NSIP agents from NSIP API and return a dict built from the retrieved JSON
116
+
117
+ :param context: either 'NSIP' (all agents presents at least one day during the semester)
118
+ or 'DIRECTORY' (only agents with an active contract)
119
+ :return: dict representing the JSON anwser
120
+ """
121
+
122
+ url = self._get_api_url("lab_api", "agent_list")
123
+ r = requests.get(
124
+ url,
125
+ headers={"Authorization": f"Bearer {self.token}"},
126
+ params={"context": context},
127
+ )
128
+ if r.status_code != HTTP_STATUS_OK:
129
+ raise NSIPRequestFailure(r.status_code, url)
130
+
131
+ agents = r.json()
132
+
133
+ return agents
134
+
135
+ def add_agent_to_project(self, project_id: str, email: str, start_date: datetime.datetime):
136
+ """
137
+ Add an agent to an NSIP project
138
+
139
+ :param project_id: project ID
140
+ :param email: user's email
141
+ :param start_date: start date for agent in the project
142
+ :return: status (0 for successful update, a positive value if an error occured),
143
+ http_status, http_reason
144
+ """
145
+
146
+ contract = None
147
+ url = self._get_api_url("agent_api", "project_assign")
148
+ params = {
149
+ "projectId": project_id,
150
+ "startDate": start_date.date().isoformat(),
151
+ "context": "NSIP",
152
+ }
153
+
154
+ retry = True
155
+ retry_attempts = 0
156
+ while retry:
157
+ # adding agent is retried only for some specific errors
158
+ retry = False
159
+
160
+ if contract:
161
+ print(
162
+ f"INFO: adding agent {email} to project {project_id} using contract {contract}"
163
+ )
164
+ params["idAgentContract"] = contract
165
+ else:
166
+ params["emailReseda"] = email
167
+
168
+ r = requests.post(url, headers={"Authorization": f"Bearer {self.token}"}, params=params)
169
+
170
+ if r.status_code == HTTP_STATUS_OK:
171
+ status = 0
172
+ elif r.status_code == HTTP_STATUS_CREATED:
173
+ status = 0
174
+ print(f"INFO: agent {email} successfully added to project {project_id}")
175
+ elif r.status_code == HTTP_STATUS_NOT_FOUND:
176
+ contract = self._check_multiple_contracts(r.text)
177
+ if retry_attempts == 0 and contract is not None:
178
+ retry = True
179
+ print(
180
+ (
181
+ f"Agent {email} has several contracts for the"
182
+ f" current period: retrying update with contract {contract}"
183
+ )
184
+ )
185
+ else:
186
+ status = 1
187
+ else:
188
+ status = 1
189
+
190
+ return status, r.status_code, r.text
191
+
192
+ def update_agent(
193
+ self,
194
+ reseda_email: str,
195
+ team_id: str = None,
196
+ email: str = None,
197
+ phones: List[str] = None,
198
+ offices: List[str] = None,
199
+ ) -> int:
200
+ """
201
+ Update agent attributes. Every attribute can be omitted if it should not
202
+ be modified. reseda_email is used to identify the user to modify and is
203
+ the only required parameter. An agent cannot be added through the API, it
204
+ has to exist before.
205
+
206
+ :param reseda_email: the user resedaEmail, used to identify the user
207
+ :param team_id: ID of user's new team
208
+ :param email: user's new email
209
+ :param phones: user's new phone list
210
+ :param offices: user's new office list
211
+ :return: status (0 for successful update, a positive value if an error occured),
212
+ http_status, http_reason
213
+ """
214
+
215
+ params = {"emailReseda": reseda_email, "context": "DIRECTORY"}
216
+ url = self._get_api_url("agent_api", "agent_update")
217
+ if team_id is not None:
218
+ params["teamId"] = team_id
219
+ if email is not None:
220
+ params["contactEmail"] = email
221
+ if phones is not None:
222
+ # To clear the phones, an empty string must be passed to work around an API error
223
+ if len(phones) == 0:
224
+ phones.add("")
225
+ params["phoneNumbers"] = json.dumps(phones, iterable_as_array=True)
226
+ if offices is not None:
227
+ # To clear the offices, an empty string must be passed to work around an API error
228
+ if len(offices) == 0:
229
+ offices.add("")
230
+ params["offices"] = json.dumps(offices, iterable_as_array=True)
231
+ r = requests.put(url, headers={"Authorization": f"Bearer {self.token}"}, params=params)
232
+
233
+ if r.status_code == HTTP_STATUS_OK:
234
+ status = 0
235
+ else:
236
+ status = 1
237
+ return status, r.status_code, r.text
238
+
239
+ def update_declaration(
240
+ self,
241
+ email: str,
242
+ project_id: str,
243
+ project_type: bool,
244
+ time: int,
245
+ period_start: str,
246
+ validation_date: datetime.date = None,
247
+ ) -> int:
248
+ """
249
+ Add or update a project declaration for a user specified by its RESEDA email
250
+
251
+ :param email: RESEDA email of the selected user
252
+ :param project_id: ID of the selected project
253
+ :param project_type: if True, it is a project, else it is a reference (other activities)
254
+ :param time: time spent on the project in the unit appropriate for the project (hour or
255
+ week)
256
+ :param period_start: start date of the validation period
257
+ :param validation_date: validation date
258
+ :return: status (0 for successful add, -1 for successful update, positive value if errors),
259
+ http_status if errors else declaration ID added/modified, http_reason
260
+ """
261
+
262
+ contract = None
263
+ url = self._get_api_url("agent_api", "declaration_add")
264
+ if project_type:
265
+ params = {"projectId": int(project_id), "referenceId": ""}
266
+ else:
267
+ params = {"projectId": "", "referenceId": int(project_id)}
268
+ params["context"] = "NSIP"
269
+ params["time"] = time
270
+ if validation_date:
271
+ validation_date_str = validation_date.date().isoformat()
272
+ params["managerValidationDate"] = validation_date_str
273
+
274
+ retry = True
275
+ while retry:
276
+ declaration_id = ""
277
+ # declaration update is retried only for some specific errors
278
+ retry = False
279
+
280
+ if contract:
281
+ print(f"INFO: updating {email} declaration using contract {contract}")
282
+ params["idAgentContract"] = contract
283
+ else:
284
+ params["emailReseda"] = email
285
+
286
+ r = requests.post(url, headers={"Authorization": f"Bearer {self.token}"}, params=params)
287
+ r.text
288
+
289
+ if r.status_code == HTTP_STATUS_OK:
290
+ m = DECLARATION_ADDED_PATTERN.match(r.text)
291
+ if m:
292
+ declaration_id = m.group("decl_id")
293
+ else:
294
+ print(
295
+ f"ERROR: unable to extract declaration number from request"
296
+ f" reason ({r.text})"
297
+ )
298
+ status = 0
299
+
300
+ elif r.status_code == HTTP_STATUS_FORBIDDEN:
301
+ # If http status is Forbidden, parse the associated message. If it is the expected
302
+ # message for an already existing declaration, retrieve the declaration ID and
303
+ # update it.
304
+ m = DECLARATION_EXISTS_PATTERN.match(r.text)
305
+ if m:
306
+ declaration_id = m.group("decl_id")
307
+ url = self._get_api_url("agent_api", "declaration_update")
308
+ params = {"id": declaration_id, "time": time, "context": "NSIP"}
309
+ if validation_date_str:
310
+ params["managerValidationDate"] = validation_date_str
311
+ status = -1
312
+ r = requests.put(
313
+ url,
314
+ headers={"Authorization": f"Bearer {self.token}"},
315
+ params=params,
316
+ )
317
+
318
+ elif r.status_code == HTTP_STATUS_NOT_FOUND:
319
+ m = AGENT_NOT_IN_PROJECT_PATTERN.match(r.text)
320
+ if m:
321
+ user_add_status, http_status, http_reason = self.add_agent_to_project(
322
+ project_id,
323
+ email,
324
+ period_start,
325
+ )
326
+ if user_add_status == 0:
327
+ retry = True
328
+ else:
329
+ print(
330
+ f"ERROR: failed to add agent {email} to project {project_id}"
331
+ f" (error={http_status}, reason={http_reason})"
332
+ )
333
+ status = -2
334
+ else:
335
+ contract = self._check_multiple_contracts(r.text)
336
+ if contract:
337
+ retry = True
338
+ print(
339
+ (
340
+ f"Agent {email} has several contracts for the"
341
+ f" current period: retrying update with contract {contract}"
342
+ )
343
+ )
344
+
345
+ if r.status_code == HTTP_STATUS_OK:
346
+ return status, declaration_id, r.text
347
+ else:
348
+ return 1, r.status_code, r.text
349
+
350
+ def get_declaration_period_id(self, period_date: datetime):
351
+ """
352
+ Return the declaration ID for the declaration period matching a given date (the date must
353
+ be included in the period.
354
+
355
+ :param period_date: date that must be inside the period
356
+ :return: declaration period ID
357
+ """
358
+
359
+ url = self._get_api_url("institute_api", "declaration_period_list")
360
+
361
+ r = requests.get(url, headers={"Authorization": f"Bearer {self.token}"})
362
+ if r.status_code != HTTP_STATUS_OK:
363
+ raise NSIPRequestFailure(r.status_code, url)
364
+
365
+ periods = pd.read_json(StringIO(r.content.decode()))
366
+ periods["startDateDeclaration"] = pd.to_datetime(periods.startDateDeclaration)
367
+ periods["endDateDeclaration"] = pd.to_datetime(periods.endDateDeclaration)
368
+ selected_period = periods.loc[
369
+ (periods.startDateDeclaration <= period_date)
370
+ & (periods.endDateDeclaration > period_date)
371
+ ]
372
+ if len(selected_period) == 0:
373
+ raise NSIPPeriodMissing(period_date)
374
+ elif len(selected_period) > 1:
375
+ raise NSIPPeriodAmbiguous(period_date, len(selected_period))
376
+
377
+ return selected_period.iloc[0]["id"]
378
+
379
+ def get_declarations(self, period_date: datetime):
380
+ """
381
+ Return the NSIP declaration list for the declaration period matching a given date (the
382
+ date must be included in the period).
383
+
384
+ :param period_date: date that must be inside the period
385
+ :return: declaration list as a dict
386
+ """
387
+
388
+ period_id = self.get_declaration_period_id(period_date)
389
+
390
+ url = self._get_api_url("lab_api", "declaration_list")
391
+ params = {"idPeriod": period_id}
392
+ r = requests.get(url, headers={"Authorization": f"Bearer {self.token}"}, params=params)
393
+ if r.status_code != HTTP_STATUS_OK:
394
+ raise NSIPRequestFailure(r.status_code, url)
395
+
396
+ declarations = r.json()
397
+
398
+ return declarations
399
+
400
+ def get_activities(self, project_activity: bool):
401
+ """
402
+ Return the list of projects for the laboratory defined in NSIP
403
+
404
+ :param project_activity: true for projects, false for other activities
405
+ :return: activity list as a list
406
+ """
407
+
408
+ if project_activity:
409
+ activity_api = "project_list"
410
+ else:
411
+ activity_api = "reference_list"
412
+
413
+ url = self._get_api_url("lab_api", activity_api)
414
+ r = requests.get(
415
+ url,
416
+ headers={"Authorization": f"Bearer {self.token}"},
417
+ )
418
+ if r.status_code != HTTP_STATUS_OK:
419
+ raise NSIPRequestFailure(r.status_code, url)
420
+
421
+ activities = r.json()
422
+
423
+ return activities
424
+
425
+ def get_teams(self):
426
+ """
427
+ Return the list of lab teams as a list of dict
428
+
429
+ :return: list
430
+ """
431
+
432
+ url = self._get_api_url("lab_api", "team_list")
433
+ r = requests.get(url, headers={"Authorization": f"Bearer {self.token}"})
434
+ if r.status_code != HTTP_STATUS_OK:
435
+ raise NSIPRequestFailure(r.status_code, url)
436
+
437
+ teams = r.json()
438
+
439
+ return teams
440
+
441
+
442
+ def check_nsip_base_config(nsip_config) -> bool:
443
+ """
444
+ Check that main parameters are present in the NSIP configuration.
445
+ Return True if it is the case or raise an exception otherwise
446
+
447
+ :param nsip_config: dict containing NSIP parameters
448
+ :return: None
449
+ """
450
+
451
+ required_keys = {"server_url", "token"}
452
+ api_keys = [k for k in nsip_config if re.match(r".*_api$", k)]
453
+
454
+ for k in required_keys:
455
+ if k not in nsip_config:
456
+ raise ConfigMissingParam(f"nsip/{k}")
457
+
458
+ for k in api_keys:
459
+ required_subkeys = {"base_url"}
460
+ for sk in required_subkeys:
461
+ if sk not in nsip_config[k]:
462
+ raise ConfigMissingParam(f"nsip/{k}/{sk}")
463
+
464
+ return
465
+
466
+
467
+ def nsip_session_init(nsip_config):
468
+ """
469
+ Initialize the NSIP session, using the configuration parameters. It is valid for an
470
+ application not to initalize all APIs.
471
+
472
+ :param nsip_config: dict containing the NSIP configuration
473
+ :return: a NSIPConnection object
474
+ """
475
+
476
+ check_nsip_base_config(nsip_config)
477
+
478
+ if "agent_api" in nsip_config:
479
+ agent_api = nsip_config["agent_api"]
480
+ else:
481
+ agent_api = None
482
+
483
+ if "lab_api" in nsip_config:
484
+ lab_api = nsip_config["lab_api"]
485
+ else:
486
+ lab_api = None
487
+
488
+ if "institute_api" in nsip_config:
489
+ institute_api = nsip_config["institute_api"]
490
+ else:
491
+ institute_api = None
492
+
493
+ return NSIPConnection(
494
+ nsip_config["server_url"],
495
+ nsip_config["token"],
496
+ agent_api,
497
+ lab_api,
498
+ institute_api,
499
+ )
@@ -1,31 +1,20 @@
1
- [build-system]
2
- requires = [ "poetry-core",]
3
- build-backend = "poetry.core.masonry.api"
4
-
5
- [project]
6
- classifiers = [
7
- "License :: OSI Approved :: MIT License",
8
- "Operating System :: OS Independent",
9
- "Programming Language :: Python :: 3",
10
- ]
11
-
12
- [tool]
13
-
14
- [tool.poetry]
15
- name = "hito_tools"
16
- version = "24.8.dev1"
17
- description = "Modules for interacting with Hito and NSIP"
18
- readme = "README.md"
19
- authors = [ "Michel Jouvin <michel.jouvin@ijclab.in2p3.fr>" ]
20
-
21
- [tool.poetry.dependencies]
22
- python = "^3.8"
23
- pandas = ">=2.2"
24
- requests = "^2.28"
25
-
26
- [project.license]
27
- text = "BSD 3-Clause License"
28
-
29
- [project.urls]
30
- Homepage = "https://gitlab.in2p3.fr/hito/hito_tools"
31
- "Bug Tracker" = "https://gitlab.in2p3.fr/hito/hito_tools/-/issues"
1
+ [build-system]
2
+ requires = [ "poetry-core",]
3
+ build-backend = "poetry.core.masonry.api"
4
+
5
+ [project]
6
+ name = "hito_tools"
7
+ version = "26.1"
8
+ description = "Modules for interacting with Hito and NSIP"
9
+ readme = "README.md"
10
+ authors = [ { name = "Michel Jouvin", email = "michel.jouvin@ijclab.in2p3.fr" } ]
11
+ license = "BSD-3-Clause"
12
+ requires-python = ">3.11"
13
+ classifiers = [
14
+ "Operating System :: OS Independent",
15
+ "Programming Language :: Python :: 3",
16
+ ]
17
+
18
+ [project.urls]
19
+ Homepage = "https://gitlab.in2p3.fr/hito/hito_tools"
20
+ "Bug Tracker" = "https://gitlab.in2p3.fr/hito/hito_tools/-/issues"
File without changes
File without changes