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
@@ -1,287 +1,287 @@
1
- # Module to handle user login with flask-multipass, a multi-backend authentication module for Flask
2
- # The code is largely based on a simplified version of what Indico is doing and is focused on using
3
- # LDAP (IJCLab ActiveDirectory) as the backend.
4
- #
5
- # There is no attempt to store session data in a database.
6
-
7
- import functools
8
- import json
9
- from urllib.parse import urlparse
10
- from uuid import uuid1
11
-
12
- from flask import flash, redirect, request, session
13
- from flask_multipass import InvalidCredentials, Multipass, NoSuchUser
14
-
15
- from ositah.utils.utils import GlobalParams
16
-
17
- # Redirect URL for login and logout
18
- LOGIN_URL = "/login"
19
- LOGOUT_URL = "/logout"
20
-
21
-
22
- # Session validity duration (in hours), i.e. max time since the last use
23
- SESSION_MAX_DURATION = 4
24
-
25
-
26
- # List of authenticated users
27
- user_list = {}
28
- identity_list = {}
29
-
30
-
31
- class User:
32
- def __init__(self, first_name=None, last_name=None, email=None):
33
- self._id = uuid1()
34
- self._firstname = first_name
35
- self._lastname = last_name
36
- self._email = email
37
- self._identities = []
38
-
39
- def add_identity(self, identity):
40
- self._identities.append(identity)
41
-
42
- @property
43
- def identities(self):
44
- return self._identities
45
-
46
- @property
47
- def email(self):
48
- return self._email
49
-
50
- def get_first_identity(self):
51
- if len(self._identities) >= 1:
52
- return self._identities[0]
53
- else:
54
- return Exception(f"No identity defined for user '{self.email}'")
55
-
56
- @property
57
- def id(self):
58
- return self._id
59
-
60
-
61
- class Identity:
62
- def __init__(self, provider=None, identifier=None):
63
- self._id = identifier
64
- self._provider = provider
65
- self._multipass_data = None
66
-
67
- @property
68
- def id(self):
69
- return self._id
70
-
71
- @property
72
- def multipass_data(self):
73
- return self._multipass_data
74
-
75
- @multipass_data.setter
76
- def multipass_data(self, data):
77
- self._multipass_data = data
78
-
79
- @property
80
- def provider(self):
81
- return self._provider
82
-
83
-
84
- class OSITAHMultipass(Multipass):
85
- def init_app(self, app):
86
- super(OSITAHMultipass, self).init_app(app)
87
- with app.app_context():
88
- self._check_default_provider()
89
-
90
- def _check_default_provider(self):
91
- # Ensure that there is maximum one sync provider
92
- sync_providers = [
93
- p for p in self.identity_providers.values() if p.settings.get("synced_fields")
94
- ]
95
- if len(sync_providers) > 1:
96
- raise ValueError("There can only be one sync provider.")
97
- # Ensure that there is exactly one form-based default auth provider
98
- auth_providers = list(self.auth_providers.values())
99
- external_providers = [p for p in auth_providers if p.is_external]
100
- local_providers = [p for p in auth_providers if not p.is_external]
101
- if any(p.settings.get("default") for p in external_providers):
102
- raise ValueError("The default provider cannot be external")
103
- if all(p.is_external for p in auth_providers):
104
- return
105
- default_providers = [p for p in auth_providers if p.settings.get("default")]
106
- if len(default_providers) > 1:
107
- raise ValueError("There can only be one default auth provider")
108
- elif not default_providers:
109
- if len(local_providers) == 1:
110
- local_providers[0].settings["default"] = True
111
- else:
112
- raise ValueError("There is no default auth provider")
113
-
114
- def handle_auth_error(self, exc, redirect_to_login=False):
115
- if isinstance(exc, (NoSuchUser, InvalidCredentials)):
116
- print("Invalid credentials")
117
- else:
118
- exc_str = str(exc)
119
- print(
120
- "Authentication via %s failed: %s (%r)",
121
- exc.provider.name if exc.provider else None,
122
- exc_str,
123
- exc.details,
124
- )
125
- return super(OSITAHMultipass, self).handle_auth_error(
126
- exc, redirect_to_login=redirect_to_login
127
- )
128
-
129
-
130
- def configure_multipass_ldap(app, provider_title):
131
- """
132
- Configure Flask_multipass from configuration. Inspired from Indico and its configuration file.
133
- Required flask_multipass with PR #42.
134
-
135
- :param app: Flask app
136
- :param provider_title: text associated with the auth/identity provider
137
- :return: none
138
- """
139
-
140
- global_params = GlobalParams()
141
- config = global_params.ldap
142
-
143
- if not config or len(config) == 0:
144
- raise Exception("Missing LDAP configuration")
145
-
146
- ldap_config = {
147
- "uri": config["uri"],
148
- "bind_dn": config["bind_dn"],
149
- "bind_password": config["password"],
150
- "timeout": 30,
151
- "verify_cert": True,
152
- "page_size": 10000,
153
- "uid": "sAMAccountName",
154
- "user_base": config["base_dn"],
155
- "user_filter": "(objectcategory=user)",
156
- }
157
-
158
- auth_provider = {
159
- "ldap": {
160
- "type": "ldap",
161
- "title": provider_title,
162
- "ldap": ldap_config,
163
- },
164
- }
165
-
166
- identity_provider = {
167
- "ldap": {
168
- "type": "ldap",
169
- "title": provider_title,
170
- "ldap": ldap_config,
171
- "identifier_field": "mail",
172
- "accepted_users": "all",
173
- "mapping": {
174
- "first_name": "givenName",
175
- "last_name": "sn",
176
- "email": "mail",
177
- "affiliation": "company",
178
- "phone": "telephoneNumber",
179
- },
180
- "trusted_email": True,
181
- },
182
- }
183
-
184
- app.config["MULTIPASS_AUTH_PROVIDERS"] = auth_provider
185
- app.config["MULTIPASS_IDENTITY_PROVIDERS"] = identity_provider
186
- app.config["MULTIPASS_PROVIDER_MAP"] = {"ldap": "ldap"}
187
- app.config["MULTIPASS_IDENTITY_INFO_KEYS"] = {"first_name", "last_name", "email"}
188
- app.config["MULTIPASS_LOGIN_FORM_TEMPLATE"] = "login_form.html"
189
- app.config["MULTIPASS_SUCCESS_ENDPOINT"] = "/"
190
- app.config["MULTIPASS_FAILURE_MESSAGE"] = "Login failed: {error}"
191
-
192
-
193
- multipass = OSITAHMultipass()
194
-
195
-
196
- @multipass.identity_handler
197
- def identity_handler(identity_info):
198
- if identity_info.identifier in identity_list:
199
- user = identity_list[identity_info.identifier]
200
- identity = user.get_first_identity()
201
- else:
202
- if identity_info.data["email"] in user_list:
203
- user = user_list[identity_info.data["email"]]
204
- else:
205
- user = User(**identity_info.data.to_dict())
206
- user_list[user.email] = user
207
- identity = Identity(
208
- provider=identity_info.provider.name, identifier=identity_info.identifier
209
- )
210
- user.add_identity(identity)
211
- identity_list[identity.id] = user
212
- identity.multipass_data = json.dumps(identity_info.multipass_data)
213
- session["user_id"] = identity.id
214
- flash("Received IdentityInfo: {}".format(identity_info), "success")
215
-
216
-
217
- def login_required(view):
218
- """
219
- A decorator to require login on Flask views
220
-
221
- :param view: a function
222
- :return: decorated function
223
- """
224
-
225
- @functools.wraps(view)
226
- def wrapped_view(**kwargs):
227
- redirect_path = urlparse(request.base_url).path
228
- if len(redirect_path) == 0:
229
- redirect_path = "/"
230
- if "user_id" not in session:
231
- if redirect_path != "/favicon.ico":
232
- return redirect(f"{LOGIN_URL}?next={redirect_path}")
233
- elif redirect_path == LOGOUT_URL:
234
- remove_session()
235
- return multipass.logout("/", clear_session=True)
236
-
237
- return view(**kwargs)
238
-
239
- return wrapped_view
240
-
241
-
242
- def protect_views(app):
243
- for view_func in app.server.view_functions:
244
- if view_func.startswith("/<path:path>"):
245
- app.server.view_functions[view_func] = login_required(
246
- app.server.view_functions[view_func]
247
- )
248
-
249
- return app
250
-
251
-
252
- def remove_session():
253
- """
254
- Remove a session from the database and do additional session cleanup.
255
-
256
- :return: None
257
- """
258
-
259
- from sqlalchemy import delete
260
-
261
- from ositah.utils.hito_db import get_db
262
- from ositah.utils.hito_db_model import OSITAHSession
263
-
264
- global_params = GlobalParams()
265
-
266
- if "uid" in session:
267
- del global_params.session_data
268
-
269
- if session["user_id"] in identity_list:
270
- del user_list[identity_list[session["user_id"]].email]
271
- del identity_list[session["user_id"]]
272
- else:
273
- print(
274
- (
275
- f"WARNING: attempt to delete a non-existing user/identity"
276
- f" {session['uid']} (user={session['user_id']})"
277
- )
278
- )
279
-
280
- if "user_email" in session:
281
- sql_cmd = delete(OSITAHSession).where(
282
- OSITAHSession.id == session["uid"],
283
- OSITAHSession.email == session["user_email"],
284
- )
285
- db = get_db()
286
- db.session.execute(sql_cmd)
287
- db.session.commit()
1
+ # Module to handle user login with flask-multipass, a multi-backend authentication module for Flask
2
+ # The code is largely based on a simplified version of what Indico is doing and is focused on using
3
+ # LDAP (IJCLab ActiveDirectory) as the backend.
4
+ #
5
+ # There is no attempt to store session data in a database.
6
+
7
+ import functools
8
+ import json
9
+ from urllib.parse import urlparse
10
+ from uuid import uuid1
11
+
12
+ from flask import flash, redirect, request, session
13
+ from flask_multipass import InvalidCredentials, Multipass, NoSuchUser
14
+
15
+ from ositah.utils.utils import GlobalParams
16
+
17
+ # Redirect URL for login and logout
18
+ LOGIN_URL = "/login"
19
+ LOGOUT_URL = "/logout"
20
+
21
+
22
+ # Session validity duration (in hours), i.e. max time since the last use
23
+ SESSION_MAX_DURATION = 4
24
+
25
+
26
+ # List of authenticated users
27
+ user_list = {}
28
+ identity_list = {}
29
+
30
+
31
+ class User:
32
+ def __init__(self, first_name=None, last_name=None, email=None):
33
+ self._id = uuid1()
34
+ self._firstname = first_name
35
+ self._lastname = last_name
36
+ self._email = email
37
+ self._identities = []
38
+
39
+ def add_identity(self, identity):
40
+ self._identities.append(identity)
41
+
42
+ @property
43
+ def identities(self):
44
+ return self._identities
45
+
46
+ @property
47
+ def email(self):
48
+ return self._email
49
+
50
+ def get_first_identity(self):
51
+ if len(self._identities) >= 1:
52
+ return self._identities[0]
53
+ else:
54
+ return Exception(f"No identity defined for user '{self.email}'")
55
+
56
+ @property
57
+ def id(self):
58
+ return self._id
59
+
60
+
61
+ class Identity:
62
+ def __init__(self, provider=None, identifier=None):
63
+ self._id = identifier
64
+ self._provider = provider
65
+ self._multipass_data = None
66
+
67
+ @property
68
+ def id(self):
69
+ return self._id
70
+
71
+ @property
72
+ def multipass_data(self):
73
+ return self._multipass_data
74
+
75
+ @multipass_data.setter
76
+ def multipass_data(self, data):
77
+ self._multipass_data = data
78
+
79
+ @property
80
+ def provider(self):
81
+ return self._provider
82
+
83
+
84
+ class OSITAHMultipass(Multipass):
85
+ def init_app(self, app):
86
+ super(OSITAHMultipass, self).init_app(app)
87
+ with app.app_context():
88
+ self._check_default_provider()
89
+
90
+ def _check_default_provider(self):
91
+ # Ensure that there is maximum one sync provider
92
+ sync_providers = [
93
+ p for p in self.identity_providers.values() if p.settings.get("synced_fields")
94
+ ]
95
+ if len(sync_providers) > 1:
96
+ raise ValueError("There can only be one sync provider.")
97
+ # Ensure that there is exactly one form-based default auth provider
98
+ auth_providers = list(self.auth_providers.values())
99
+ external_providers = [p for p in auth_providers if p.is_external]
100
+ local_providers = [p for p in auth_providers if not p.is_external]
101
+ if any(p.settings.get("default") for p in external_providers):
102
+ raise ValueError("The default provider cannot be external")
103
+ if all(p.is_external for p in auth_providers):
104
+ return
105
+ default_providers = [p for p in auth_providers if p.settings.get("default")]
106
+ if len(default_providers) > 1:
107
+ raise ValueError("There can only be one default auth provider")
108
+ elif not default_providers:
109
+ if len(local_providers) == 1:
110
+ local_providers[0].settings["default"] = True
111
+ else:
112
+ raise ValueError("There is no default auth provider")
113
+
114
+ def handle_auth_error(self, exc, redirect_to_login=False):
115
+ if isinstance(exc, (NoSuchUser, InvalidCredentials)):
116
+ print("Invalid credentials")
117
+ else:
118
+ exc_str = str(exc)
119
+ print(
120
+ "Authentication via %s failed: %s (%r)",
121
+ exc.provider.name if exc.provider else None,
122
+ exc_str,
123
+ exc.details,
124
+ )
125
+ return super(OSITAHMultipass, self).handle_auth_error(
126
+ exc, redirect_to_login=redirect_to_login
127
+ )
128
+
129
+
130
+ def configure_multipass_ldap(app, provider_title):
131
+ """
132
+ Configure Flask_multipass from configuration. Inspired from Indico and its configuration file.
133
+ Required flask_multipass with PR #42.
134
+
135
+ :param app: Flask app
136
+ :param provider_title: text associated with the auth/identity provider
137
+ :return: none
138
+ """
139
+
140
+ global_params = GlobalParams()
141
+ config = global_params.ldap
142
+
143
+ if not config or len(config) == 0:
144
+ raise Exception("Missing LDAP configuration")
145
+
146
+ ldap_config = {
147
+ "uri": config["uri"],
148
+ "bind_dn": config["bind_dn"],
149
+ "bind_password": config["password"],
150
+ "timeout": 30,
151
+ "verify_cert": True,
152
+ "page_size": 10000,
153
+ "uid": "sAMAccountName",
154
+ "user_base": config["base_dn"],
155
+ "user_filter": "(objectcategory=user)",
156
+ }
157
+
158
+ auth_provider = {
159
+ "ldap": {
160
+ "type": "ldap",
161
+ "title": provider_title,
162
+ "ldap": ldap_config,
163
+ },
164
+ }
165
+
166
+ identity_provider = {
167
+ "ldap": {
168
+ "type": "ldap",
169
+ "title": provider_title,
170
+ "ldap": ldap_config,
171
+ "identifier_field": "mail",
172
+ "accepted_users": "all",
173
+ "mapping": {
174
+ "first_name": "givenName",
175
+ "last_name": "sn",
176
+ "email": "mail",
177
+ "affiliation": "company",
178
+ "phone": "telephoneNumber",
179
+ },
180
+ "trusted_email": True,
181
+ },
182
+ }
183
+
184
+ app.config["MULTIPASS_AUTH_PROVIDERS"] = auth_provider
185
+ app.config["MULTIPASS_IDENTITY_PROVIDERS"] = identity_provider
186
+ app.config["MULTIPASS_PROVIDER_MAP"] = {"ldap": "ldap"}
187
+ app.config["MULTIPASS_IDENTITY_INFO_KEYS"] = {"first_name", "last_name", "email"}
188
+ app.config["MULTIPASS_LOGIN_FORM_TEMPLATE"] = "login_form.html"
189
+ app.config["MULTIPASS_SUCCESS_ENDPOINT"] = "/"
190
+ app.config["MULTIPASS_FAILURE_MESSAGE"] = "Login failed: {error}"
191
+
192
+
193
+ multipass = OSITAHMultipass()
194
+
195
+
196
+ @multipass.identity_handler
197
+ def identity_handler(identity_info):
198
+ if identity_info.identifier in identity_list:
199
+ user = identity_list[identity_info.identifier]
200
+ identity = user.get_first_identity()
201
+ else:
202
+ if identity_info.data["email"] in user_list:
203
+ user = user_list[identity_info.data["email"]]
204
+ else:
205
+ user = User(**identity_info.data.to_dict())
206
+ user_list[user.email] = user
207
+ identity = Identity(
208
+ provider=identity_info.provider.name, identifier=identity_info.identifier
209
+ )
210
+ user.add_identity(identity)
211
+ identity_list[identity.id] = user
212
+ identity.multipass_data = json.dumps(identity_info.multipass_data)
213
+ session["user_id"] = identity.id
214
+ flash("Received IdentityInfo: {}".format(identity_info), "success")
215
+
216
+
217
+ def login_required(view):
218
+ """
219
+ A decorator to require login on Flask views
220
+
221
+ :param view: a function
222
+ :return: decorated function
223
+ """
224
+
225
+ @functools.wraps(view)
226
+ def wrapped_view(**kwargs):
227
+ redirect_path = urlparse(request.base_url).path
228
+ if len(redirect_path) == 0:
229
+ redirect_path = "/"
230
+ if "user_id" not in session:
231
+ if redirect_path != "/favicon.ico":
232
+ return redirect(f"{LOGIN_URL}?next={redirect_path}")
233
+ elif redirect_path == LOGOUT_URL:
234
+ remove_session()
235
+ return multipass.logout("/", clear_session=True)
236
+
237
+ return view(**kwargs)
238
+
239
+ return wrapped_view
240
+
241
+
242
+ def protect_views(app):
243
+ for view_func in app.server.view_functions:
244
+ if view_func.startswith("/<path:path>"):
245
+ app.server.view_functions[view_func] = login_required(
246
+ app.server.view_functions[view_func]
247
+ )
248
+
249
+ return app
250
+
251
+
252
+ def remove_session():
253
+ """
254
+ Remove a session from the database and do additional session cleanup.
255
+
256
+ :return: None
257
+ """
258
+
259
+ from sqlalchemy import delete
260
+
261
+ from ositah.utils.hito_db import get_db
262
+ from ositah.utils.hito_db_model import OSITAHSession
263
+
264
+ global_params = GlobalParams()
265
+
266
+ if "uid" in session:
267
+ del global_params.session_data
268
+
269
+ if session["user_id"] in identity_list:
270
+ del user_list[identity_list[session["user_id"]].email]
271
+ del identity_list[session["user_id"]]
272
+ else:
273
+ print(
274
+ (
275
+ f"WARNING: attempt to delete a non-existing user/identity"
276
+ f" {session['uid']} (user={session['user_id']})"
277
+ )
278
+ )
279
+
280
+ if "user_email" in session:
281
+ sql_cmd = delete(OSITAHSession).where(
282
+ OSITAHSession.id == session["uid"],
283
+ OSITAHSession.email == session["user_email"],
284
+ )
285
+ db = get_db()
286
+ db.session.execute(sql_cmd)
287
+ db.session.commit()
ositah/utils/cache.py CHANGED
@@ -1,19 +1,19 @@
1
- # Helper functions to manage the data cache
2
-
3
- from ositah.utils.exceptions import SessionDataMissing
4
- from ositah.utils.utils import GlobalParams, no_session_id_jumbotron
5
-
6
-
7
- def clear_cached_data():
8
- """
9
- Clear the data cached by the previous requests
10
-
11
- :return: None
12
- """
13
-
14
- global_params = GlobalParams()
15
- try:
16
- session_data = global_params.session_data
17
- session_data.reset_caches()
18
- except SessionDataMissing:
19
- return no_session_id_jumbotron()
1
+ # Helper functions to manage the data cache
2
+
3
+ from ositah.utils.exceptions import SessionDataMissing
4
+ from ositah.utils.utils import GlobalParams, no_session_id_jumbotron
5
+
6
+
7
+ def clear_cached_data():
8
+ """
9
+ Clear the data cached by the previous requests
10
+
11
+ :return: None
12
+ """
13
+
14
+ global_params = GlobalParams()
15
+ try:
16
+ session_data = global_params.session_data
17
+ session_data.reset_caches()
18
+ except SessionDataMissing:
19
+ return no_session_id_jumbotron()
ositah/utils/core.py CHANGED
@@ -1,13 +1,13 @@
1
- # Module with utility functions
2
-
3
-
4
- # Singleton decorator definition
5
- def singleton(cls):
6
- instances = {}
7
-
8
- def getinstance(*args, **kwargs):
9
- if cls not in instances:
10
- instances[cls] = cls(*args, **kwargs)
11
- return instances[cls]
12
-
13
- return getinstance
1
+ # Module with utility functions
2
+
3
+
4
+ # Singleton decorator definition
5
+ def singleton(cls):
6
+ instances = {}
7
+
8
+ def getinstance(*args, **kwargs):
9
+ if cls not in instances:
10
+ instances[cls] = cls(*args, **kwargs)
11
+ return instances[cls]
12
+
13
+ return getinstance