udata 11.1.2.dev8__py3-none-any.whl → 11.1.2.dev11__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 udata might be problematic. Click here for more details.

Files changed (43) hide show
  1. udata/api/oauth2.py +22 -3
  2. udata/app.py +3 -0
  3. udata/auth/__init__.py +11 -0
  4. udata/auth/forms.py +70 -3
  5. udata/auth/mails.py +6 -0
  6. udata/auth/proconnect.py +127 -0
  7. udata/auth/views.py +57 -2
  8. udata/core/__init__.py +2 -0
  9. udata/core/captchetat.py +80 -0
  10. udata/core/dataset/api.py +2 -2
  11. udata/core/dataset/api_fields.py +3 -4
  12. udata/core/dataset/apiv2.py +6 -6
  13. udata/core/dataset/commands.py +0 -10
  14. udata/core/dataset/constants.py +124 -38
  15. udata/core/dataset/factories.py +2 -1
  16. udata/core/dataset/forms.py +14 -10
  17. udata/core/dataset/models.py +8 -36
  18. udata/core/dataset/rdf.py +76 -54
  19. udata/core/dataset/tasks.py +2 -50
  20. udata/cors.py +19 -2
  21. udata/harvest/backends/ckan/harvesters.py +10 -14
  22. udata/harvest/backends/maaf.py +15 -14
  23. udata/harvest/tests/ckan/test_ckan_backend.py +4 -3
  24. udata/harvest/tests/test_dcat_backend.py +3 -2
  25. udata/i18n.py +7 -32
  26. udata/migrations/2025-09-04-update-legacy-frequencies.py +36 -0
  27. udata/settings.py +27 -0
  28. udata/templates/security/email/reset_instructions.html +1 -1
  29. udata/templates/security/email/reset_instructions.txt +1 -1
  30. udata/tests/api/test_datasets_api.py +41 -12
  31. udata/tests/dataset/test_dataset_model.py +17 -53
  32. udata/tests/dataset/test_dataset_rdf.py +27 -28
  33. udata/translations/udata.pot +226 -150
  34. udata/utils.py +8 -1
  35. {udata-11.1.2.dev8.dist-info → udata-11.1.2.dev11.dist-info}/METADATA +1 -1
  36. {udata-11.1.2.dev8.dist-info → udata-11.1.2.dev11.dist-info}/RECORD +40 -40
  37. udata/templates/mail/frequency_reminder.html +0 -34
  38. udata/templates/mail/frequency_reminder.txt +0 -18
  39. udata/tests/test_i18n.py +0 -93
  40. {udata-11.1.2.dev8.dist-info → udata-11.1.2.dev11.dist-info}/WHEEL +0 -0
  41. {udata-11.1.2.dev8.dist-info → udata-11.1.2.dev11.dist-info}/entry_points.txt +0 -0
  42. {udata-11.1.2.dev8.dist-info → udata-11.1.2.dev11.dist-info}/licenses/LICENSE +0 -0
  43. {udata-11.1.2.dev8.dist-info → udata-11.1.2.dev11.dist-info}/top_level.txt +0 -0
udata/api/oauth2.py CHANGED
@@ -29,7 +29,7 @@ from authlib.oauth2.rfc6750 import BearerTokenValidator
29
29
  from authlib.oauth2.rfc7009 import RevocationEndpoint
30
30
  from authlib.oauth2.rfc7636 import CodeChallenge
31
31
  from bson import ObjectId
32
- from flask import current_app, render_template, request
32
+ from flask import abort, current_app, jsonify, render_template, request
33
33
  from flask_security.utils import verify_password
34
34
  from werkzeug.exceptions import Unauthorized
35
35
 
@@ -40,6 +40,7 @@ from udata.core.storages import default_image_basename, images
40
40
  from udata.i18n import I18nBlueprint
41
41
  from udata.i18n import lazy_gettext as _
42
42
  from udata.mongo import db
43
+ from udata.utils import wants_json
43
44
 
44
45
  blueprint = I18nBlueprint("oauth", __name__, url_prefix="/oauth")
45
46
  oauth = AuthorizationServer()
@@ -292,18 +293,31 @@ class BearerToken(BearerTokenValidator):
292
293
  return token.revoked
293
294
 
294
295
 
295
- @blueprint.route("/token", methods=["POST"], localize=False, endpoint="token")
296
+ @blueprint.route("/token", methods=["POST"], endpoint="token")
296
297
  @csrf.exempt
297
298
  def access_token():
298
299
  return oauth.create_token_response()
299
300
 
300
301
 
301
- @blueprint.route("/revoke", methods=["POST"], localize=False)
302
+ @blueprint.route("/revoke", methods=["POST"])
302
303
  @csrf.exempt
303
304
  def revoke_token():
304
305
  return oauth.create_endpoint_response(RevokeToken.ENDPOINT_NAME)
305
306
 
306
307
 
308
+ @blueprint.route("/client_info", methods=["GET"])
309
+ def client_info(*args, **kwargs):
310
+ if not current_user or not current_user.is_authenticated:
311
+ abort(401)
312
+
313
+ try:
314
+ grant = oauth.get_consent_grant(end_user=current_user)
315
+ except OAuth2Error as error:
316
+ return error.error
317
+
318
+ return jsonify({"client": {"name": grant.client.name}, "scopes": ["default"]})
319
+
320
+
307
321
  @blueprint.route("/authorize", methods=["GET", "POST"])
308
322
  @login_required
309
323
  def authorize(*args, **kwargs):
@@ -313,8 +327,13 @@ def authorize(*args, **kwargs):
313
327
  except OAuth2Error as error:
314
328
  return error.error
315
329
  # Bypass authorization screen for internal clients
330
+ # It's not used right now…
316
331
  if grant.client.internal:
317
332
  return oauth.create_authorization_response(grant_user=current_user)
333
+
334
+ if wants_json():
335
+ return jsonify({"client": {"name": grant.client.name}, "scopes": ["default"]})
336
+
318
337
  return render_template("api/oauth_authorize.html", grant=grant)
319
338
  elif request.method == "POST":
320
339
  accept = "accept" in request.form
udata/app.py CHANGED
@@ -223,6 +223,7 @@ def register_extensions(app):
223
223
  sentry,
224
224
  tasks,
225
225
  )
226
+ from udata.auth import proconnect
226
227
 
227
228
  cors.init_app(app)
228
229
  tasks.init_app(app)
@@ -236,6 +237,8 @@ def register_extensions(app):
236
237
  mail.init_app(app)
237
238
  search.init_app(app)
238
239
  sentry.init_app(app)
240
+ proconnect.init_app(app)
241
+
239
242
  app.after_request(return_404_html_if_requested)
240
243
  app.register_error_handler(NotFound, page_not_found)
241
244
  return app
udata/auth/__init__.py CHANGED
@@ -39,6 +39,7 @@ def init_app(app):
39
39
  from udata.models import datastore
40
40
 
41
41
  from .forms import (
42
+ ExtendedForgotPasswordForm,
42
43
  ExtendedLoginForm,
43
44
  ExtendedRegisterForm,
44
45
  ExtendedResetPasswordForm,
@@ -47,6 +48,15 @@ def init_app(app):
47
48
  from .password_validation import UdataPasswordUtil
48
49
  from .views import create_security_blueprint
49
50
 
51
+ # We want to alias SECURITY_POST_CONFIRM_VIEW to the CDATA_BASE_URL (the homepage)
52
+ # but can't do it in `settings.py` because it's not defined yet (`CDATA_BASE_URL` is set
53
+ # in the env)
54
+ # :SecurityPostConfirmViewAtRuntime
55
+ if app.config["CDATA_BASE_URL"]:
56
+ app.config.setdefault(
57
+ "SECURITY_POST_CONFIRM_VIEW", app.config["CDATA_BASE_URL"] + "?flash=post_confirm"
58
+ )
59
+
50
60
  security.init_app(
51
61
  app,
52
62
  datastore,
@@ -56,6 +66,7 @@ def init_app(app):
56
66
  confirm_register_form=ExtendedRegisterForm,
57
67
  register_form=ExtendedRegisterForm,
58
68
  reset_password_form=ExtendedResetPasswordForm,
69
+ forgot_password_form=ExtendedForgotPasswordForm,
59
70
  mail_util_cls=UdataMailUtil,
60
71
  password_util_cls=UdataPasswordUtil,
61
72
  )
udata/auth/forms.py CHANGED
@@ -1,14 +1,37 @@
1
1
  import datetime
2
+ import logging
2
3
 
4
+ import requests
3
5
  from flask import current_app
4
6
  from flask_login import current_user
5
- from flask_security.forms import Form, LoginForm, RegisterForm, ResetPasswordForm
6
-
7
+ from flask_security.forms import (
8
+ ForgotPasswordForm,
9
+ Form,
10
+ LoginForm,
11
+ RegisterForm,
12
+ ResetPasswordForm,
13
+ )
14
+
15
+ from udata.core.captchetat import bearer_token
7
16
  from udata.forms import fields, validators
8
17
  from udata.i18n import lazy_gettext as _
9
18
 
19
+ log = logging.getLogger(__name__)
20
+
21
+
22
+ class WithCaptcha:
23
+ captcha_code = fields.StringField(_("Captcha code"))
24
+ captcha_uuid = fields.StringField(_("Captcha ID"))
25
+
26
+ def validate_captcha(self):
27
+ if check_captchetat(self.captcha_uuid.data, self.captcha_code.data):
28
+ return True
29
+
30
+ self.captcha_code.errors = [_("Invalid Captcha")]
31
+ return False
32
+
10
33
 
11
- class ExtendedRegisterForm(RegisterForm):
34
+ class ExtendedRegisterForm(WithCaptcha, RegisterForm):
12
35
  first_name = fields.StringField(
13
36
  _("First name"),
14
37
  [
@@ -23,12 +46,21 @@ class ExtendedRegisterForm(RegisterForm):
23
46
  validators.NoURLs(_("URLs not allowed in this field")),
24
47
  ],
25
48
  )
49
+ accept_conditions = fields.BooleanField(
50
+ _("J'accepte les conditions générales d'utilisation"),
51
+ validators=[
52
+ validators.DataRequired(message=_("Vous devez accepter les CGU pour continuer."))
53
+ ],
54
+ )
26
55
 
27
56
  def validate(self, **kwargs):
28
57
  # no register allowed when read only mode is on
29
58
  if not super().validate(**kwargs) or current_app.config.get("READ_ONLY_MODE"):
30
59
  return False
31
60
 
61
+ if not self.validate_captcha():
62
+ return False
63
+
32
64
  return True
33
65
 
34
66
 
@@ -57,6 +89,17 @@ class ExtendedResetPasswordForm(ResetPasswordForm):
57
89
  return True
58
90
 
59
91
 
92
+ class ExtendedForgotPasswordForm(WithCaptcha, ForgotPasswordForm):
93
+ def validate(self, **kwargs):
94
+ if not super().validate(**kwargs):
95
+ return False
96
+
97
+ if not self.validate_captcha():
98
+ return False
99
+
100
+ return True
101
+
102
+
60
103
  class ChangeEmailForm(Form):
61
104
  new_email = fields.StringField(_("New email"), [validators.DataRequired(), validators.Email()])
62
105
  new_email_confirm = fields.StringField(
@@ -77,3 +120,27 @@ class ChangeEmailForm(Form):
77
120
  )
78
121
  return False
79
122
  return True
123
+
124
+
125
+ def check_captchetat(id: str, code: str) -> bool:
126
+ captchetat_url = current_app.config.get("CAPTCHETAT_BASE_URL")
127
+ if not captchetat_url:
128
+ return True
129
+
130
+ if not id or not code:
131
+ return False
132
+
133
+ headers = {"Authorization": "Bearer " + bearer_token()}
134
+ try:
135
+ resp = requests.post(
136
+ f"{captchetat_url}/valider-captcha",
137
+ headers=headers,
138
+ json={
139
+ "uuid": id,
140
+ "code": code,
141
+ },
142
+ )
143
+ return resp.text == "true"
144
+ except requests.exceptions.RequestException as err:
145
+ log.error(f"Failed to query CaptchEtat: {err}")
146
+ return True
udata/auth/mails.py CHANGED
@@ -1,8 +1,12 @@
1
+ import logging
2
+
1
3
  import email_validator
2
4
  from flask import current_app
3
5
 
4
6
  from udata.tasks import task
5
7
 
8
+ log = logging.getLogger(__name__)
9
+
6
10
 
7
11
  @task
8
12
  def sendmail(msg):
@@ -11,6 +15,8 @@ def sendmail(msg):
11
15
  if send_mail:
12
16
  mail = current_app.extensions.get("mail")
13
17
  mail.send(msg)
18
+ else:
19
+ log.warning(msg)
14
20
 
15
21
 
16
22
  class UdataMailUtil:
@@ -0,0 +1,127 @@
1
+ from datetime import datetime
2
+
3
+ from authlib.common.urls import add_params_to_uri
4
+ from authlib.integrations.flask_client import OAuth
5
+ from flask import abort, redirect, request, session, url_for
6
+ from werkzeug.security import gen_salt
7
+
8
+ from udata.api import API, api
9
+ from udata.auth import login_user
10
+ from udata.uris import homepage_url
11
+
12
+ ns = api.namespace("proconnect", "Proconnect related operations")
13
+ oauth = OAuth()
14
+ # blueprint = I18nBlueprint("proconnect", __name__, url_prefix="/proconnect")
15
+ STATE_KEY = "proconnect_state"
16
+ ID_TOKEN_KEY = "id_token"
17
+
18
+
19
+ def init_app(app):
20
+ if app.config.get("PROCONNECT_OPENID_CONF_URL"):
21
+ # ProConnect SSO
22
+ oauth.init_app(app)
23
+ oauth.register(
24
+ name="proconnect",
25
+ client_id=app.config.get("PROCONNECT_CLIENT_ID"),
26
+ client_secret=app.config.get("PROCONNECT_CLIENT_SECRET"),
27
+ server_metadata_url=app.config.get("PROCONNECT_OPENID_CONF_URL"),
28
+ client_kwargs={"scope": app.config.get("PROCONNECT_SCOPE")},
29
+ )
30
+
31
+
32
+ def get_logout_url():
33
+ id_token = session.get(ID_TOKEN_KEY)
34
+ if id_token is None:
35
+ # No id_token, so no way to logout from ProConnect.
36
+ return None
37
+
38
+ metadata = oauth.proconnect.load_server_metadata()
39
+ end_session_endpoint = metadata["end_session_endpoint"]
40
+ # Generate a random state that we send to ProConnect, they'll return it so we can check it.
41
+ state = gen_salt(50)
42
+ session[STATE_KEY] = state
43
+ redirect_uri = url_for("api.proconnect_logout", _external=True)
44
+
45
+ return add_params_to_uri(
46
+ end_session_endpoint,
47
+ (
48
+ ("id_token_hint", id_token),
49
+ ("state", state),
50
+ ("post_logout_redirect_uri", redirect_uri),
51
+ ),
52
+ )
53
+
54
+
55
+ @ns.route("/login/", endpoint="proconnect_login")
56
+ class ProconnectLoginAPI(API):
57
+ def get(self):
58
+ redirect_uri = url_for("api.proconnect_auth", _external=True)
59
+ return oauth.proconnect.authorize_redirect(redirect_uri, acr_values="eidas1")
60
+
61
+
62
+ @ns.route("/auth", endpoint="proconnect_auth")
63
+ class ProconnectAuthAPI(API):
64
+ def get(self):
65
+ from udata.models import datastore
66
+
67
+ token = oauth.proconnect.authorize_access_token()
68
+ # Store the user info in the session, it'll be used when logging out from ProConnect.
69
+ session[ID_TOKEN_KEY] = token[ID_TOKEN_KEY]
70
+ # /!\ DIRTY HACK.
71
+ # authlib expects the userinfo to either be in the token["id_token"] as a jwt...
72
+ # but in this case, it's not there, it's some other information.
73
+ # We thus need to go get the userinfo from the userinfo_endpoint, but authlib
74
+ # expects it to be plain json. However, proconnect returns a jwt.
75
+ # So we can't use authlib's client.userinfo() helper, we need to do it ourselves.
76
+ metadata = oauth.proconnect.load_server_metadata()
77
+ resp = oauth.proconnect.get(metadata["userinfo_endpoint"])
78
+ resp.raise_for_status()
79
+ # Create a new token that `client.parse_id_token` expects. Replace the initial
80
+ # `id_token` with the jwt we received from the `userinfo_endpoint`.
81
+ userinfo_token = token.copy()
82
+ userinfo_token[ID_TOKEN_KEY] = resp.content
83
+ proconnect_user = oauth.proconnect.parse_id_token(userinfo_token, nonce=None)
84
+ # We now have the user information decoded from the jwt, ready to be used.
85
+ user = datastore.find_user(email=proconnect_user["email"])
86
+ if not user:
87
+ user = datastore.create_user(
88
+ email=proconnect_user["email"],
89
+ first_name=proconnect_user.get("given_name"),
90
+ last_name=proconnect_user.get("usual_name"),
91
+ confirmed_at=datetime.now(),
92
+ )
93
+
94
+ if not login_user(user):
95
+ return {"message": "ProConnect Authentication failed"}, 401
96
+
97
+ return redirect(homepage_url(flash="connected"))
98
+
99
+
100
+ @ns.route("/logout_oauth", endpoint="proconnect_logout_oauth")
101
+ class ProconnectLogoutOAuthAPI(API):
102
+ def get(self):
103
+ # At the time of this writing, authlib didn't implement OpenIDC session management:
104
+ # https://github.com/lepture/authlib/issues/292
105
+ # So we implement it ourselves. This code may be simplified (or even removed?) in the future
106
+ # if we update to a version that supports it.
107
+ end_session_url = get_logout_url()
108
+ if end_session_url is None:
109
+ return redirect(url_for("security.logout"))
110
+
111
+ return redirect(end_session_url)
112
+
113
+
114
+ @ns.route("/logout", endpoint="proconnect_logout")
115
+ class ProconnectLogoutAPI(API):
116
+ def get(self):
117
+ # Double check that the request hasn't been forged by checking the random "state" we provided.
118
+ state = request.args["state"]
119
+ stored_state = session.get(STATE_KEY)
120
+ if state != stored_state:
121
+ abort(401)
122
+
123
+ # We're logged out from ProConnect, cleanup the ProConnect related data from the session.
124
+ session.pop(ID_TOKEN_KEY, None)
125
+ session.pop(STATE_KEY, None)
126
+
127
+ return redirect(url_for("security.logout"))
udata/auth/views.py CHANGED
@@ -1,4 +1,4 @@
1
- from flask import current_app, redirect, url_for
1
+ from flask import current_app, jsonify, redirect, request, url_for
2
2
  from flask_login import current_user, login_required
3
3
  from flask_security.utils import (
4
4
  check_and_get_token_status,
@@ -21,10 +21,13 @@ from flask_security.views import (
21
21
  send_login,
22
22
  token_login,
23
23
  )
24
+ from flask_wtf.csrf import generate_csrf
24
25
  from werkzeug.local import LocalProxy
25
26
 
27
+ from udata.auth.proconnect import get_logout_url
26
28
  from udata.i18n import lazy_gettext as _
27
29
  from udata.uris import homepage_url
30
+ from udata.utils import wants_json
28
31
 
29
32
  from .forms import ChangeEmailForm
30
33
 
@@ -98,6 +101,16 @@ def confirm_change_email(token):
98
101
  return redirect(homepage_url(flash="change_email_confirmed"))
99
102
 
100
103
 
104
+ def get_csrf():
105
+ # We need to have a public endpoint for getting a CSRF token.
106
+ # In Flask, we can query the form with an Accept:application/json,
107
+ # for example: GET `/login` to get a JSON with the CSRF token.
108
+ # It's not working in our implementation because GET `/login` is routed to
109
+ # cdata and not udata. So we need to have an endpoint existing only on udata
110
+ # so we can fetch a valid CSRF token.
111
+ return jsonify({"response": {"csrf_token": generate_csrf()}})
112
+
113
+
101
114
  @login_required
102
115
  def change_email():
103
116
  """Change email page."""
@@ -107,6 +120,10 @@ def change_email():
107
120
  if form.validate_on_submit():
108
121
  new_email = form.new_email.data
109
122
  send_change_email_confirmation_instructions(current_user, new_email)
123
+
124
+ if wants_json():
125
+ return jsonify({})
126
+
110
127
  return redirect(
111
128
  homepage_url(
112
129
  flash="change_email",
@@ -116,6 +133,9 @@ def change_email():
116
133
  )
117
134
  )
118
135
 
136
+ if wants_json():
137
+ return jsonify({"response": {"csrf_token": generate_csrf()}})
138
+
119
139
  return _security.render_template("security/change_email.html", change_email_form=form)
120
140
 
121
141
 
@@ -134,7 +154,9 @@ def create_security_blueprint(app, state, import_name):
134
154
  template_folder="templates",
135
155
  )
136
156
 
137
- bp.route(app.config["SECURITY_LOGOUT_URL"], endpoint="logout")(logout)
157
+ bp.route(app.config["SECURITY_LOGOUT_URL"], methods=["GET", "POST"], endpoint="logout")(
158
+ logout_with_proconnect_url
159
+ )
138
160
 
139
161
  if state.passwordless:
140
162
  bp.route(app.config["SECURITY_LOGIN_URL"], methods=["GET", "POST"], endpoint="login")(
@@ -187,5 +209,38 @@ def create_security_blueprint(app, state, import_name):
187
209
  )(confirm_change_email)
188
210
 
189
211
  bp.route("/change-email", methods=["GET", "POST"], endpoint="change_email")(change_email)
212
+ bp.route("/get-csrf", methods=["GET"], endpoint="get_csrf")(get_csrf)
190
213
 
191
214
  return bp
215
+
216
+
217
+ def logout_with_proconnect_url():
218
+ """
219
+ Extends the flask-security `logout` by returning the ProConnect logout URL (if any)
220
+ so `cdata` can redirect to it if the user was connected via ProConnect.
221
+ """
222
+ # after the redirection to ProConnect logout, the user will be redirected
223
+ # to our logout again with ProconnectLogoutAPI.
224
+ if request.method == "POST" and wants_json():
225
+ proconnect_logout_url = get_logout_url()
226
+
227
+ if proconnect_logout_url:
228
+ return jsonify(
229
+ {
230
+ "proconnect_logout_url": get_logout_url(),
231
+ }
232
+ )
233
+
234
+ # Calling the flask-security logout endpoint
235
+ logout()
236
+
237
+ # But rewriting the response since we want to redirect with a flash
238
+ # query param for cdata. Flask-Security redirects to the homepage without
239
+ # any information.
240
+ # PS: in a normal logout it's a JSON request, but after logout from ProConnect
241
+ # the user is redirected to this endpoint as a normal HTTP request, so we must
242
+ # manage the basic redirection in this case.
243
+ if request.method == "POST" and wants_json():
244
+ return jsonify({})
245
+
246
+ return redirect(homepage_url(flash="logout"))
udata/core/__init__.py CHANGED
@@ -1,5 +1,7 @@
1
1
  from importlib import import_module
2
2
 
3
+ import udata.core.captchetat # noqa
4
+
3
5
  MODULES = (
4
6
  "metrics",
5
7
  "storages",
@@ -0,0 +1,80 @@
1
+ import logging
2
+
3
+ import requests
4
+ from flask import abort, current_app, make_response
5
+
6
+ from udata.api import API, apiv2
7
+ from udata.app import cache
8
+
9
+ log = logging.getLogger(__name__)
10
+
11
+ ns = apiv2.namespace("captchetat", "CaptchEtat related operations")
12
+ captchetat_parser = apiv2.parser()
13
+ captchetat_parser.add_argument(
14
+ "get", type=str, location="args", help="type of data wanted from captchetat"
15
+ )
16
+ captchetat_parser.add_argument("c", type=str, location="args", help="captcha name")
17
+ captchetat_parser.add_argument(
18
+ "t",
19
+ type=str,
20
+ location="args",
21
+ help="this is a technical argument auto-generated for audio content",
22
+ )
23
+
24
+ CAPTCHETAT_ERROR = "CaptchEtat request didn't failed but didn't contain any access_token"
25
+
26
+
27
+ def bearer_token():
28
+ """Get CaptchEtat bearer token from cache or get a new one from CaptchEtat Oauth server"""
29
+ token_cache_key = current_app.config.get("CAPTCHETAT_TOKEN_CACHE_KEY")
30
+ url = current_app.config.get("CAPTCHETAT_OAUTH_BASE_URL")
31
+ previous_value = cache.get(token_cache_key)
32
+ if previous_value:
33
+ return previous_value
34
+ log.debug(f"New access token requested from {url}")
35
+ try:
36
+ oauth = requests.post(
37
+ f"{url}/api/oauth/token",
38
+ data={
39
+ "grant_type": "client_credentials",
40
+ "scope": "piste.captchetat",
41
+ "client_id": current_app.config.get("CAPTCHETAT_CLIENT_ID"),
42
+ "client_secret": current_app.config.get("CAPTCHETAT_CLIENT_SECRET"),
43
+ },
44
+ )
45
+ oauth.raise_for_status()
46
+ body = oauth.json()
47
+ access_token = body.get("access_token")
48
+ if not access_token:
49
+ raise requests.exceptions.RequestException(CAPTCHETAT_ERROR)
50
+ cache.set(token_cache_key, access_token, timeout=body.get("expires_in", 0))
51
+ except requests.exceptions.RequestException as request_exception:
52
+ log.exception(f"Error while getting access token from {url}")
53
+ raise request_exception
54
+ else:
55
+ return access_token
56
+
57
+
58
+ @ns.route("/", endpoint="captchetat")
59
+ class CaptchEtatAPI(API):
60
+ @apiv2.expect(captchetat_parser)
61
+ @apiv2.doc("captchetat")
62
+ def get(self):
63
+ """CaptchEtat endpoint for captcha generation and validation"""
64
+ args = captchetat_parser.parse_args()
65
+ try:
66
+ token = bearer_token()
67
+ headers = {}
68
+ if token:
69
+ headers = {"Authorization": "Bearer " + token}
70
+ captchetat_url = current_app.config.get("CAPTCHETAT_BASE_URL")
71
+ req = requests.get(
72
+ f"{captchetat_url}/simple-captcha-endpoint", headers=headers, params=args
73
+ )
74
+ req.raise_for_status()
75
+ except requests.exceptions.RequestException:
76
+ abort(500, description="Catptcha internal error")
77
+
78
+ resp = make_response(bytes(req.content))
79
+ resp.headers["Content-Type"] = req.headers.get("Content-Type")
80
+ return resp
udata/core/dataset/api.py CHANGED
@@ -64,7 +64,7 @@ from .api_fields import (
64
64
  upload_community_fields,
65
65
  upload_fields,
66
66
  )
67
- from .constants import RESOURCE_TYPES, UPDATE_FREQUENCIES
67
+ from .constants import RESOURCE_TYPES, UpdateFrequency
68
68
  from .exceptions import (
69
69
  SchemasCacheUnavailableException,
70
70
  SchemasCatalogNotFoundException,
@@ -890,7 +890,7 @@ class FrequenciesAPI(API):
890
890
  @api.marshal_list_with(frequency_fields)
891
891
  def get(self):
892
892
  """List all available frequencies"""
893
- return [{"id": id, "label": label} for id, label in UPDATE_FREQUENCIES.items()]
893
+ return [{"id": f.id, "label": f.label} for f in UpdateFrequency]
894
894
 
895
895
 
896
896
  @ns.route("/extensions/", endpoint="allowed_extensions")
@@ -9,11 +9,10 @@ from udata.core.user.api_fields import user_ref_fields
9
9
  from .constants import (
10
10
  CHECKSUM_TYPES,
11
11
  DEFAULT_CHECKSUM_TYPE,
12
- DEFAULT_FREQUENCY,
13
12
  DEFAULT_LICENSE,
14
13
  RESOURCE_FILETYPES,
15
14
  RESOURCE_TYPES,
16
- UPDATE_FREQUENCIES,
15
+ UpdateFrequency,
17
16
  )
18
17
 
19
18
  checksum_fields = api.model(
@@ -361,8 +360,8 @@ dataset_fields = api.model(
361
360
  "frequency": fields.String(
362
361
  description="The update frequency",
363
362
  required=True,
364
- enum=list(UPDATE_FREQUENCIES),
365
- default=DEFAULT_FREQUENCY,
363
+ enum=list(UpdateFrequency),
364
+ default=UpdateFrequency.UNKNOWN,
366
365
  ),
367
366
  "frequency_date": fields.ISODateTime(
368
367
  description=(
@@ -30,7 +30,7 @@ from .api_fields import (
30
30
  temporal_coverage_fields,
31
31
  user_ref_fields,
32
32
  )
33
- from .constants import DEFAULT_FREQUENCY, DEFAULT_LICENSE, FULL_OBJECTS_HEADER, UPDATE_FREQUENCIES
33
+ from .constants import DEFAULT_LICENSE, FULL_OBJECTS_HEADER, UpdateFrequency
34
34
  from .models import CommunityResource, Dataset
35
35
  from .search import DatasetSearch
36
36
 
@@ -157,13 +157,13 @@ dataset_fields = apiv2.model(
157
157
  ),
158
158
  "frequency": fields.Raw(
159
159
  attribute=lambda d: {
160
- "id": d.frequency or DEFAULT_FREQUENCY,
161
- "label": UPDATE_FREQUENCIES.get(d.frequency or DEFAULT_FREQUENCY),
160
+ "id": (d.frequency or UpdateFrequency.UNKNOWN).id,
161
+ "label": (d.frequency or UpdateFrequency.UNKNOWN).label,
162
162
  }
163
163
  if request.headers.get(FULL_OBJECTS_HEADER, False, bool)
164
- else d.frequency,
165
- enum=list(UPDATE_FREQUENCIES),
166
- default=DEFAULT_FREQUENCY,
164
+ else (d.frequency or UpdateFrequency.UNKNOWN),
165
+ enum=list(UpdateFrequency),
166
+ default=UpdateFrequency.UNKNOWN,
167
167
  required=True,
168
168
  description="The update frequency (full Frequency object if `X-Get-Datasets-Full-Objects` is set, ID of the frequency otherwise)",
169
169
  ),
@@ -10,7 +10,6 @@ from udata.core.dataset.constants import DEFAULT_LICENSE
10
10
  from udata.models import Dataset, License
11
11
 
12
12
  from . import actions
13
- from .tasks import send_frequency_reminder
14
13
 
15
14
  log = logging.getLogger(__name__)
16
15
 
@@ -66,15 +65,6 @@ def licenses(source=DEFAULT_LICENSE_FILE):
66
65
  success("Done")
67
66
 
68
67
 
69
- @cli.command()
70
- def frequency_reminder():
71
- """Send a unique email per organization to members
72
-
73
- to remind them they have outdated datasets on the website.
74
- """
75
- send_frequency_reminder()
76
-
77
-
78
68
  @cli.group("dataset")
79
69
  def grp():
80
70
  """Dataset related operations"""