udata 13.0.1.dev12__py3-none-any.whl → 14.4.1.dev7__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 (177) hide show
  1. udata/api/__init__.py +2 -8
  2. udata/api_fields.py +35 -4
  3. udata/app.py +30 -50
  4. udata/auth/__init__.py +29 -6
  5. udata/auth/forms.py +8 -6
  6. udata/auth/views.py +6 -3
  7. udata/commands/__init__.py +2 -14
  8. udata/commands/db.py +13 -25
  9. udata/commands/info.py +0 -16
  10. udata/commands/serve.py +3 -11
  11. udata/commands/tests/test_fixtures.py +9 -9
  12. udata/core/access_type/api.py +1 -1
  13. udata/core/access_type/constants.py +12 -8
  14. udata/core/activity/api.py +5 -6
  15. udata/core/avatars/api.py +43 -0
  16. udata/core/avatars/test_avatar_api.py +30 -0
  17. udata/core/badges/tests/test_commands.py +6 -6
  18. udata/core/csv.py +5 -0
  19. udata/core/dataservices/models.py +15 -3
  20. udata/core/dataservices/tasks.py +7 -0
  21. udata/core/dataset/api.py +2 -0
  22. udata/core/dataset/models.py +2 -2
  23. udata/core/dataset/permissions.py +31 -0
  24. udata/core/dataset/tasks.py +50 -10
  25. udata/core/discussions/models.py +1 -0
  26. udata/core/metrics/__init__.py +0 -6
  27. udata/core/organization/api.py +8 -5
  28. udata/core/organization/mails.py +1 -1
  29. udata/core/organization/models.py +9 -1
  30. udata/core/organization/notifications.py +84 -0
  31. udata/core/organization/permissions.py +1 -1
  32. udata/core/organization/tasks.py +3 -0
  33. udata/core/pages/tests/test_api.py +32 -0
  34. udata/core/post/api.py +24 -69
  35. udata/core/post/models.py +84 -16
  36. udata/core/post/tests/test_api.py +24 -1
  37. udata/core/reports/api.py +18 -0
  38. udata/core/reports/models.py +42 -2
  39. udata/core/reuse/models.py +1 -1
  40. udata/core/reuse/tasks.py +7 -0
  41. udata/core/site/models.py +2 -6
  42. udata/core/spatial/commands.py +2 -4
  43. udata/core/spatial/forms.py +2 -2
  44. udata/core/spatial/models.py +0 -10
  45. udata/core/spatial/tests/test_api.py +1 -36
  46. udata/core/user/models.py +15 -2
  47. udata/cors.py +2 -5
  48. udata/db/migrations.py +279 -0
  49. udata/features/notifications/api.py +7 -18
  50. udata/features/notifications/models.py +56 -0
  51. udata/features/notifications/tasks.py +25 -0
  52. udata/flask_mongoengine/engine.py +0 -4
  53. udata/frontend/__init__.py +3 -122
  54. udata/frontend/markdown.py +2 -1
  55. udata/harvest/actions.py +24 -9
  56. udata/harvest/api.py +30 -22
  57. udata/harvest/backends/__init__.py +21 -9
  58. udata/harvest/backends/base.py +29 -3
  59. udata/harvest/backends/ckan/harvesters.py +13 -2
  60. udata/harvest/backends/dcat.py +3 -0
  61. udata/harvest/backends/maaf.py +1 -0
  62. udata/harvest/commands.py +39 -4
  63. udata/harvest/filters.py +17 -6
  64. udata/harvest/forms.py +9 -6
  65. udata/harvest/models.py +16 -0
  66. udata/harvest/permissions.py +27 -0
  67. udata/harvest/tasks.py +3 -5
  68. udata/harvest/tests/ckan/test_ckan_backend.py +35 -2
  69. udata/harvest/tests/ckan/test_ckan_backend_errors.py +1 -1
  70. udata/harvest/tests/ckan/test_ckan_backend_filters.py +1 -1
  71. udata/harvest/tests/ckan/test_dkan_backend.py +1 -1
  72. udata/harvest/tests/dcat/udata.xml +6 -6
  73. udata/harvest/tests/factories.py +1 -1
  74. udata/harvest/tests/test_actions.py +63 -8
  75. udata/harvest/tests/test_api.py +278 -123
  76. udata/harvest/tests/test_base_backend.py +88 -1
  77. udata/harvest/tests/test_dcat_backend.py +60 -13
  78. udata/harvest/tests/test_filters.py +6 -0
  79. udata/i18n.py +11 -273
  80. udata/mail.py +5 -1
  81. udata/migrations/2025-10-31-create-membership-request-notifications.py +55 -0
  82. udata/migrations/2025-11-13-delete-user-email-index.py +25 -0
  83. udata/migrations/2025-12-04-add-uuid-to-discussion-messages.py +28 -0
  84. udata/models/__init__.py +0 -8
  85. udata/mongo/slug_fields.py +1 -1
  86. udata/rdf.py +45 -6
  87. udata/routing.py +2 -10
  88. udata/sentry.py +4 -10
  89. udata/settings.py +23 -17
  90. udata/tasks.py +4 -3
  91. udata/templates/mail/message.html +5 -31
  92. udata/tests/__init__.py +28 -12
  93. udata/tests/api/__init__.py +108 -21
  94. udata/tests/api/test_activities_api.py +36 -0
  95. udata/tests/api/test_auth_api.py +121 -95
  96. udata/tests/api/test_base_api.py +7 -4
  97. udata/tests/api/test_dataservices_api.py +29 -1
  98. udata/tests/api/test_datasets_api.py +45 -21
  99. udata/tests/api/test_organizations_api.py +192 -197
  100. udata/tests/api/test_reports_api.py +157 -0
  101. udata/tests/api/test_reuses_api.py +147 -147
  102. udata/tests/api/test_security_api.py +12 -12
  103. udata/tests/api/test_swagger.py +4 -4
  104. udata/tests/api/test_tags_api.py +8 -8
  105. udata/tests/api/test_user_api.py +13 -1
  106. udata/tests/apiv2/test_swagger.py +4 -4
  107. udata/tests/apiv2/test_topics.py +1 -1
  108. udata/tests/cli/test_cli_base.py +8 -9
  109. udata/tests/dataset/test_dataset_commands.py +4 -4
  110. udata/tests/dataset/test_dataset_model.py +66 -26
  111. udata/tests/dataset/test_dataset_rdf.py +99 -5
  112. udata/tests/dataset/test_resource_preview.py +0 -1
  113. udata/tests/frontend/test_auth.py +24 -1
  114. udata/tests/frontend/test_csv.py +0 -3
  115. udata/tests/helpers.py +37 -27
  116. udata/tests/organization/test_notifications.py +67 -2
  117. udata/tests/plugin.py +6 -261
  118. udata/tests/site/test_site_csv_exports.py +22 -10
  119. udata/tests/test_activity.py +9 -9
  120. udata/tests/test_cors.py +1 -1
  121. udata/tests/test_dcat_commands.py +2 -2
  122. udata/tests/test_discussions.py +5 -5
  123. udata/tests/test_migrations.py +181 -481
  124. udata/tests/test_notifications.py +15 -57
  125. udata/tests/test_notifications_task.py +43 -0
  126. udata/tests/test_owned.py +81 -1
  127. udata/tests/test_storages.py +25 -19
  128. udata/tests/test_topics.py +77 -61
  129. udata/tests/test_uris.py +33 -0
  130. udata/tests/workers/test_jobs_commands.py +23 -23
  131. udata/translations/ar/LC_MESSAGES/udata.mo +0 -0
  132. udata/translations/ar/LC_MESSAGES/udata.po +187 -108
  133. udata/translations/de/LC_MESSAGES/udata.mo +0 -0
  134. udata/translations/de/LC_MESSAGES/udata.po +187 -108
  135. udata/translations/es/LC_MESSAGES/udata.mo +0 -0
  136. udata/translations/es/LC_MESSAGES/udata.po +187 -108
  137. udata/translations/fr/LC_MESSAGES/udata.mo +0 -0
  138. udata/translations/fr/LC_MESSAGES/udata.po +188 -109
  139. udata/translations/it/LC_MESSAGES/udata.mo +0 -0
  140. udata/translations/it/LC_MESSAGES/udata.po +187 -108
  141. udata/translations/pt/LC_MESSAGES/udata.mo +0 -0
  142. udata/translations/pt/LC_MESSAGES/udata.po +187 -108
  143. udata/translations/sr/LC_MESSAGES/udata.mo +0 -0
  144. udata/translations/sr/LC_MESSAGES/udata.po +187 -108
  145. udata/translations/udata.pot +215 -106
  146. udata/uris.py +0 -2
  147. udata/utils.py +5 -0
  148. udata-14.4.1.dev7.dist-info/METADATA +109 -0
  149. {udata-13.0.1.dev12.dist-info → udata-14.4.1.dev7.dist-info}/RECORD +153 -166
  150. {udata-13.0.1.dev12.dist-info → udata-14.4.1.dev7.dist-info}/entry_points.txt +3 -5
  151. udata/core/followers/views.py +0 -15
  152. udata/core/post/forms.py +0 -30
  153. udata/entrypoints.py +0 -93
  154. udata/features/identicon/__init__.py +0 -0
  155. udata/features/identicon/api.py +0 -13
  156. udata/features/identicon/backends.py +0 -131
  157. udata/features/identicon/tests/__init__.py +0 -0
  158. udata/features/identicon/tests/test_backends.py +0 -18
  159. udata/features/territories/__init__.py +0 -49
  160. udata/features/territories/api.py +0 -25
  161. udata/features/territories/models.py +0 -51
  162. udata/flask_mongoengine/json.py +0 -38
  163. udata/migrations/__init__.py +0 -367
  164. udata/templates/mail/base.html +0 -105
  165. udata/templates/mail/base.txt +0 -6
  166. udata/templates/mail/button.html +0 -3
  167. udata/templates/mail/layouts/1-column.html +0 -19
  168. udata/templates/mail/layouts/2-columns.html +0 -20
  169. udata/templates/mail/layouts/center-panel.html +0 -16
  170. udata/tests/cli/test_db_cli.py +0 -68
  171. udata/tests/features/territories/__init__.py +0 -20
  172. udata/tests/features/territories/test_territories_api.py +0 -185
  173. udata/tests/frontend/test_hooks.py +0 -149
  174. udata-13.0.1.dev12.dist-info/METADATA +0 -133
  175. {udata-13.0.1.dev12.dist-info → udata-14.4.1.dev7.dist-info}/WHEEL +0 -0
  176. {udata-13.0.1.dev12.dist-info → udata-14.4.1.dev7.dist-info}/licenses/LICENSE +0 -0
  177. {udata-13.0.1.dev12.dist-info → udata-14.4.1.dev7.dist-info}/top_level.txt +0 -0
udata/api/__init__.py CHANGED
@@ -1,8 +1,6 @@
1
- import inspect
2
1
  import logging
3
2
  import urllib.parse
4
3
  from functools import wraps
5
- from importlib import import_module
6
4
 
7
5
  import mongoengine
8
6
  from flask import (
@@ -19,7 +17,7 @@ from flask_restx import Api, Resource
19
17
  from flask_restx.reqparse import RequestParser
20
18
  from flask_storage import UnauthorizedFileType
21
19
 
22
- from udata import entrypoints, tracking
20
+ from udata import tracking
23
21
  from udata.app import csrf
24
22
  from udata.auth import Permission, PermissionDenied, RoleNeed, current_user, login_user
25
23
  from udata.i18n import get_locale
@@ -358,13 +356,9 @@ def init_app(app):
358
356
  import udata.core.contact_point.api # noqa
359
357
  import udata.features.transfer.api # noqa
360
358
  import udata.features.notifications.api # noqa
361
- import udata.features.identicon.api # noqa
362
- import udata.features.territories.api # noqa
359
+ import udata.core.avatars.api # noqa
363
360
  import udata.harvest.api # noqa
364
361
 
365
- for module in entrypoints.get_enabled("udata.apis", app).values():
366
- module if inspect.ismodule(module) else import_module(module)
367
-
368
362
  # api.init_app(app)
369
363
  app.register_blueprint(apiv1_blueprint)
370
364
  app.register_blueprint(apiv2_blueprint)
udata/api_fields.py CHANGED
@@ -29,7 +29,7 @@ import flask_restx.fields as restx_fields
29
29
  import mongoengine
30
30
  import mongoengine.fields as mongo_fields
31
31
  from bson import DBRef, ObjectId
32
- from flask import Request
32
+ from flask import Request, request
33
33
  from flask_restx import marshal
34
34
  from flask_restx.inputs import boolean
35
35
  from flask_restx.reqparse import RequestParser
@@ -55,8 +55,8 @@ classes_by_parents = {}
55
55
 
56
56
 
57
57
  class GenericField(restx_fields.Raw):
58
- def __init__(self, fields_by_type):
59
- super().__init__(self)
58
+ def __init__(self, fields_by_type, **kwargs):
59
+ super(GenericField, self).__init__(**kwargs)
60
60
  self.default = None
61
61
  self.fields_by_type = fields_by_type
62
62
 
@@ -230,6 +230,23 @@ def convert_db_to_field(key, field, info) -> tuple[Callable | None, Callable | N
230
230
 
231
231
  write_params["description"] = "ID of the reference"
232
232
  constructor_write = restx_fields.String
233
+ elif isinstance(field, mongo_fields.GenericEmbeddedDocumentField):
234
+ generic_fields = {
235
+ cls.__name__: convert_db_to_field(
236
+ f"{key}.{cls.__name__}",
237
+ # Instead of having GenericEmbeddedDocumentField() we'll create fields for each
238
+ # of the subclasses with EmbededdDocumentField(MembershipRequestNotificationDetails)…
239
+ mongoengine.fields.EmbeddedDocumentField(cls),
240
+ info,
241
+ )
242
+ for cls in field.choices
243
+ }
244
+
245
+ def constructor_read(**kwargs):
246
+ return GenericField({k: v[0].model for k, v in generic_fields.items()}, **kwargs)
247
+
248
+ def constructor_write(**kwargs):
249
+ return GenericField({k: v[1].model for k, v in generic_fields.items()}, **kwargs)
233
250
  elif isinstance(field, mongo_fields.EmbeddedDocumentField):
234
251
  nested_fields = info.get("nested_fields")
235
252
  if nested_fields is not None:
@@ -322,12 +339,12 @@ def generate_fields(**kwargs) -> Callable:
322
339
  write_fields: dict = {}
323
340
  ref_fields: dict = {}
324
341
  sortables: list = kwargs.get("additional_sorts", [])
342
+ default_sort: list = kwargs.get("default_sort", None)
325
343
 
326
344
  filterables: list[dict] = kwargs.get("standalone_filters", [])
327
345
  nested_filters: dict[str, dict] = get_fields_with_nested_filters(
328
346
  kwargs.get("nested_filters", {})
329
347
  )
330
-
331
348
  if issubclass(cls, db.Document) or issubclass(cls, db.DynamicDocument):
332
349
  read_fields["id"] = restx_fields.String(required=True, readonly=True)
333
350
 
@@ -472,6 +489,7 @@ def generate_fields(**kwargs) -> Callable:
472
489
  type=str,
473
490
  location="args",
474
491
  choices=choices,
492
+ default=default_sort,
475
493
  help="The field (and direction) on which sorting apply",
476
494
  )
477
495
 
@@ -511,6 +529,9 @@ def generate_fields(**kwargs) -> Callable:
511
529
  phrase_query: str = " ".join([f'"{elem}"' for elem in args["q"].split(" ")])
512
530
  base_query = base_query.search_text(phrase_query)
513
531
 
532
+ if "sort" not in request.args:
533
+ base_query = base_query.order_by("$text_score")
534
+
514
535
  for filterable in filterables:
515
536
  # If it's from an `nested_filter`, use the custom label instead of the key,
516
537
  # eg use `organization_badge` instead of `organization.badges` which is
@@ -652,6 +673,9 @@ def patch(obj, request) -> type:
652
673
  model_attribute = getattr(obj.__class__, key)
653
674
  info = getattr(model_attribute, "__additional_field_info__", {})
654
675
 
676
+ if value == "" and isinstance(model_attribute, mongo_fields.StringField):
677
+ value = None
678
+
655
679
  if hasattr(model_attribute, "from_input"):
656
680
  value = model_attribute.from_input(value)
657
681
  elif isinstance(model_attribute, mongoengine.fields.ListField) and isinstance(
@@ -677,6 +701,13 @@ def patch(obj, request) -> type:
677
701
  value["id"],
678
702
  document_type=db.resolve_model(value["class"]),
679
703
  )
704
+ elif value and isinstance(
705
+ model_attribute,
706
+ mongoengine.fields.GenericEmbeddedDocumentField,
707
+ ):
708
+ generic_key = info.get("generic_key", DEFAULT_GENERIC_KEY)
709
+ embedded_field = classes_by_names[value[generic_key]]
710
+ value = patch(embedded_field(), value)
680
711
  elif value and isinstance(
681
712
  model_attribute,
682
713
  mongoengine.fields.EmbeddedDocumentField,
udata/app.py CHANGED
@@ -1,30 +1,32 @@
1
- import datetime
2
- import importlib
3
1
  import logging
4
2
  import os
5
3
  import types
4
+ from datetime import datetime
5
+ from importlib.metadata import entry_points
6
6
  from os.path import abspath, dirname, exists, isfile, join
7
7
 
8
8
  import bson
9
+ from bson import json_util
9
10
  from flask import Blueprint as BaseBlueprint
10
11
  from flask import (
11
12
  Flask,
12
13
  abort,
13
14
  g,
14
- json,
15
15
  jsonify,
16
16
  make_response,
17
17
  render_template,
18
18
  request,
19
19
  send_from_directory,
20
20
  )
21
+ from flask.json.provider import DefaultJSONProvider
21
22
  from flask_caching import Cache
22
23
  from flask_wtf.csrf import CSRFProtect
23
- from speaklater import is_lazy_string
24
+ from mongoengine import EmbeddedDocument
25
+ from mongoengine.base import BaseDocument
24
26
  from werkzeug.exceptions import NotFound
25
27
  from werkzeug.middleware.proxy_fix import ProxyFix
26
28
 
27
- from udata import cors, entrypoints
29
+ from udata import cors
28
30
 
29
31
  APP_NAME = __name__.split(".")[0]
30
32
  ROOT_DIR = abspath(join(dirname(__file__)))
@@ -108,9 +110,9 @@ class Blueprint(BaseBlueprint):
108
110
  return wrapper
109
111
 
110
112
 
111
- class UDataJsonEncoder(json.JSONEncoder):
113
+ class UdataJsonProvider(DefaultJSONProvider):
112
114
  """
113
- A JSONEncoder subclass to encode unsupported types:
115
+ A JSONProvider subclass to encode unsupported types:
114
116
 
115
117
  - ObjectId
116
118
  - datetime
@@ -120,21 +122,16 @@ class UDataJsonEncoder(json.JSONEncoder):
120
122
  Ensure an app context is always present.
121
123
  """
122
124
 
123
- def default(self, obj):
124
- if is_lazy_string(obj):
125
+ @staticmethod
126
+ def default(obj):
127
+ if isinstance(obj, BaseDocument) or isinstance(obj, EmbeddedDocument):
128
+ return json_util._json_convert(obj.to_mongo())
129
+ elif isinstance(obj, bson.ObjectId):
125
130
  return str(obj)
126
- elif isinstance(obj, bson.objectid.ObjectId):
127
- return str(obj)
128
- elif isinstance(obj, datetime.datetime):
131
+ elif isinstance(obj, datetime):
129
132
  return obj.isoformat()
130
- elif hasattr(obj, "to_dict"):
131
- return obj.to_dict()
132
- elif hasattr(obj, "serialize"):
133
- return obj.serialize()
134
- # Serialize Raw data for Document and EmbeddedDocument.
135
- elif hasattr(obj, "_data"):
136
- return obj._data
137
- return super(UDataJsonEncoder, self).default(obj)
133
+
134
+ return super(UdataJsonProvider, UdataJsonProvider).default(obj)
138
135
 
139
136
 
140
137
  # These loggers are very verbose
@@ -148,8 +145,6 @@ def init_logging(app):
148
145
  debug = app.debug or app.config.get("TESTING")
149
146
  log_level = logging.DEBUG if debug else logging.WARNING
150
147
  app.logger.setLevel(log_level)
151
- for name in entrypoints.get_roots(): # Entrypoints loggers
152
- logging.getLogger(name).setLevel(log_level)
153
148
  for logger in VERBOSE_LOGGERS:
154
149
  logging.getLogger(logger).setLevel(logging.WARNING)
155
150
  return app
@@ -168,24 +163,11 @@ def create_app(config="udata.settings.Defaults", override=None, init_logging=ini
168
163
  if override:
169
164
  app.config.from_object(override)
170
165
 
171
- # Loads defaults from plugins
172
- for pkg in entrypoints.get_roots(app):
173
- if pkg == "udata":
174
- continue # Defaults are already loaded
175
- module = "{}.settings".format(pkg)
176
- try:
177
- settings = importlib.import_module(module)
178
- except ImportError:
179
- continue
180
- for key, default in settings.__dict__.items():
181
- if key.startswith("__"):
182
- continue
183
- app.config.setdefault(key, default)
184
-
185
- app.json_encoder = UDataJsonEncoder
166
+ app.json_provider_class = UdataJsonProvider
167
+ app.json = app.json_provider_class(app)
186
168
 
187
169
  # `ujson` doesn't support `cls` parameter https://github.com/ultrajson/ultrajson/issues/124
188
- app.config["RESTX_JSON"] = {"cls": UDataJsonEncoder}
170
+ app.config["RESTX_JSON"] = {"default": UdataJsonProvider.default}
189
171
 
190
172
  app.debug = app.config["DEBUG"] and not app.config["TESTING"]
191
173
 
@@ -200,12 +182,21 @@ def create_app(config="udata.settings.Defaults", override=None, init_logging=ini
200
182
  def standalone(app):
201
183
  """Factory for an all in one application"""
202
184
  from udata import api, core, frontend
185
+ from udata.features import notifications
203
186
 
204
187
  core.init_app(app)
205
188
  frontend.init_app(app)
206
189
  api.init_app(app)
190
+ notifications.init_app(app)
191
+
192
+ eps = entry_points(group="udata.plugins")
193
+ for ep in eps:
194
+ plugin_module = ep.load()
207
195
 
208
- register_features(app)
196
+ if hasattr(plugin_module, "init_app"):
197
+ plugin_module.init_app(app)
198
+ else:
199
+ log.error(f"Plugin {ep.name} ({ep.value}) doesn't expose an `init_app()` function.")
209
200
 
210
201
  return app
211
202
 
@@ -215,7 +206,6 @@ def register_extensions(app):
215
206
  auth,
216
207
  i18n,
217
208
  mail,
218
- models,
219
209
  mongo,
220
210
  notifications, # noqa
221
211
  routing,
@@ -229,7 +219,6 @@ def register_extensions(app):
229
219
  tasks.init_app(app)
230
220
  i18n.init_app(app)
231
221
  mongo.init_app(app)
232
- models.init_app(app)
233
222
  routing.init_app(app)
234
223
  auth.init_app(app)
235
224
  cache.init_app(app)
@@ -278,12 +267,3 @@ def page_not_found(e: NotFound):
278
267
  return render_template("404.html", homepage_url=homepage_url()), 404
279
268
 
280
269
  return jsonify({"error": e.description, "status": 404}), 404
281
-
282
-
283
- def register_features(app):
284
- from udata.features import notifications
285
-
286
- notifications.init_app(app)
287
-
288
- for ep in entrypoints.get_enabled("udata.plugins", app).values():
289
- ep.init_app(app)
udata/auth/__init__.py CHANGED
@@ -1,4 +1,5 @@
1
1
  import logging
2
+ import typing as t
2
3
 
3
4
  from flask import render_template
4
5
  from flask_principal import Permission as BasePermission
@@ -10,6 +11,7 @@ from flask_security import Security as Security
10
11
  from flask_security import current_user as current_user
11
12
  from flask_security import login_required as login_required
12
13
  from flask_security import login_user as login_user
14
+ from flask_security import mail_util
13
15
 
14
16
  from . import mails
15
17
 
@@ -24,9 +26,6 @@ def render_security_template(template_name_or_list, **kwargs):
24
26
  return render_template(template_name_or_list, **kwargs)
25
27
 
26
28
 
27
- security = Security()
28
-
29
-
30
29
  class Permission(BasePermission):
31
30
  def __init__(self, *needs):
32
31
  """Let administrator bypass all permissions"""
@@ -36,6 +35,24 @@ class Permission(BasePermission):
36
35
  admin_permission = Permission()
37
36
 
38
37
 
38
+ class NoopMailUtil(mail_util.MailUtil):
39
+ def send_mail(
40
+ self,
41
+ template: str,
42
+ subject: str,
43
+ recipient: str,
44
+ sender: t.Union[str, tuple],
45
+ body: str,
46
+ html: t.Optional[str],
47
+ **kwargs: t.Any,
48
+ ) -> None:
49
+ log.debug(f"Sending mail {subject} to {recipient}")
50
+ log.debug(body)
51
+ log.debug(html)
52
+
53
+ return None
54
+
55
+
39
56
  def init_app(app):
40
57
  from udata.models import datastore
41
58
 
@@ -61,19 +78,25 @@ def init_app(app):
61
78
  app.config["CDATA_BASE_URL"] + "?flash=confirm_error",
62
79
  )
63
80
 
64
- security.init_app(
65
- app,
81
+ # Same logic as in our own mail system :DisableMail
82
+ debug = app.config.get("DEBUG", False)
83
+ send_mail = app.config.get("SEND_MAIL", not debug)
84
+ mail_util_cls = mail_util.MailUtil if send_mail else NoopMailUtil
85
+
86
+ security = Security(
66
87
  datastore,
67
88
  register_blueprint=False,
68
89
  render_template=render_security_template,
69
90
  login_form=ExtendedLoginForm,
70
- confirm_register_form=ExtendedRegisterForm,
71
91
  register_form=ExtendedRegisterForm,
72
92
  reset_password_form=ExtendedResetPasswordForm,
73
93
  forgot_password_form=ExtendedForgotPasswordForm,
74
94
  password_util_cls=UdataPasswordUtil,
95
+ mail_util_cls=mail_util_cls,
75
96
  )
76
97
 
98
+ security.init_app(app, datastore, register_blueprint=False)
99
+
77
100
  security_bp = create_security_blueprint(app, app.extensions["security"], "security_blueprint")
78
101
 
79
102
  app.register_blueprint(security_bp)
udata/auth/forms.py CHANGED
@@ -8,7 +8,7 @@ from flask_security.forms import (
8
8
  ForgotPasswordForm,
9
9
  Form,
10
10
  LoginForm,
11
- RegisterForm,
11
+ RegisterFormV2,
12
12
  ResetPasswordForm,
13
13
  )
14
14
 
@@ -31,7 +31,7 @@ class WithCaptcha:
31
31
  return False
32
32
 
33
33
 
34
- class ExtendedRegisterForm(WithCaptcha, RegisterForm):
34
+ class ExtendedRegisterForm(WithCaptcha, RegisterFormV2):
35
35
  first_name = fields.StringField(
36
36
  _("First name"),
37
37
  [
@@ -54,13 +54,15 @@ class ExtendedRegisterForm(WithCaptcha, RegisterForm):
54
54
  )
55
55
 
56
56
  def validate(self, **kwargs):
57
- # no register allowed when read only mode is on
58
- if not super().validate(**kwargs) or current_app.config.get("READ_ONLY_MODE"):
57
+ if current_app.config.get("READ_ONLY_MODE"):
59
58
  return False
60
59
 
61
60
  if not self.validate_captcha():
62
61
  return False
63
62
 
63
+ if not super().validate(**kwargs):
64
+ return False
65
+
64
66
  return True
65
67
 
66
68
 
@@ -91,10 +93,10 @@ class ExtendedResetPasswordForm(ResetPasswordForm):
91
93
 
92
94
  class ExtendedForgotPasswordForm(WithCaptcha, ForgotPasswordForm):
93
95
  def validate(self, **kwargs):
94
- if not super().validate(**kwargs):
96
+ if not self.validate_captcha():
95
97
  return False
96
98
 
97
- if not self.validate_captcha():
99
+ if not super().validate(**kwargs):
98
100
  return False
99
101
 
100
102
  return True
udata/auth/views.py CHANGED
@@ -84,6 +84,11 @@ def confirm_change_email(token):
84
84
  if flash:
85
85
  return redirect(homepage_url(flash=flash, flash_data=flash_data))
86
86
 
87
+ # Check if the new email is already taken by another user
88
+ existing_user = _datastore.find_user(email=new_email)
89
+ if existing_user and existing_user.id != user.id:
90
+ return redirect(homepage_url(flash="change_email_already_taken"))
91
+
87
92
  if user != current_user:
88
93
  logout_user()
89
94
  login_user(user)
@@ -140,10 +145,8 @@ def create_security_blueprint(app, state, import_name):
140
145
  This creates an I18nBlueprint to use as a base.
141
146
  """
142
147
  bp = I18nBlueprint(
143
- state.blueprint_name,
148
+ "security",
144
149
  import_name,
145
- url_prefix=state.url_prefix,
146
- subdomain=state.subdomain,
147
150
  template_folder="templates",
148
151
  )
149
152
 
@@ -4,12 +4,10 @@ import sys
4
4
  from glob import iglob
5
5
 
6
6
  import click
7
- import pkg_resources
8
7
  from flask.cli import FlaskGroup, ScriptInfo, shell_command
9
8
 
10
- from udata import entrypoints
11
9
  from udata.app import VERBOSE_LOGGERS, create_app, standalone
12
- from udata.utils import safe_unicode
10
+ from udata.utils import get_udata_version, safe_unicode
13
11
 
14
12
  log = logging.getLogger(__name__)
15
13
 
@@ -149,11 +147,6 @@ def init_logging(app):
149
147
  logger.handlers = []
150
148
  logger.addHandler(handler)
151
149
 
152
- for name in entrypoints.get_roots(): # Entrypoints loggers
153
- logger = logging.getLogger(name)
154
- logger.setLevel(log_level)
155
- logger.handlers = []
156
-
157
150
  app.logger.setLevel(log_level)
158
151
  app.logger.handlers = []
159
152
  app.logger.addHandler(handler)
@@ -208,7 +201,6 @@ class UdataGroup(FlaskGroup):
208
201
  Load udata commands from:
209
202
  - `udata.commands.*` module
210
203
  - known internal modules with commands
211
- - plugins exporting a `udata.commands` entrypoint
212
204
  """
213
205
  if self._udata_commands_loaded:
214
206
  return
@@ -229,10 +221,6 @@ class UdataGroup(FlaskGroup):
229
221
  except Exception as e:
230
222
  error("Unable to import {0}".format(module), e)
231
223
 
232
- # Load commands from entry points for enabled plugins
233
- app = ctx.ensure_object(ScriptInfo).load_app()
234
- entrypoints.get_enabled("udata.commands", app)
235
-
236
224
  # Ensure loading happens once
237
225
  self._udata_commands_loaded = False
238
226
 
@@ -253,7 +241,7 @@ class UdataGroup(FlaskGroup):
253
241
  def print_version(ctx, param, value):
254
242
  if not value or ctx.resilient_parsing:
255
243
  return
256
- click.echo(pkg_resources.get_distribution("udata").version)
244
+ click.echo(get_udata_version())
257
245
  ctx.exit()
258
246
 
259
247
 
udata/commands/db.py CHANGED
@@ -11,9 +11,9 @@ import click
11
11
  import mongoengine
12
12
  from bson import DBRef
13
13
 
14
- from udata import migrations
15
14
  from udata.commands import cli, cyan, echo, green, magenta, red, white, yellow
16
15
  from udata.core.dataset.models import Dataset, Resource
16
+ from udata.db import migrations
17
17
  from udata.mongo.document import get_all_models
18
18
 
19
19
  # Date format used to for display
@@ -31,8 +31,7 @@ def grp():
31
31
  def log_status(migration, status):
32
32
  """Properly display a migration status line"""
33
33
  name = os.path.splitext(migration.filename)[0]
34
- display = ":".join((migration.plugin, name)) + " "
35
- log.info("%s [%s]", "{:.<70}".format(display), status)
34
+ echo("{:.<70} [{}]".format(name + " ", status))
36
35
 
37
36
 
38
37
  def status_label(record):
@@ -78,11 +77,6 @@ def migrate(record, dry_run=False):
78
77
  log_status(migration, status)
79
78
  try:
80
79
  output = migration.execute(recordonly=record, dryrun=dry_run)
81
- except migrations.RollbackError as re:
82
- format_output(re.migrate_exc.output, False)
83
- log_status(migration, red("Rollback"))
84
- format_output(re.output, not re.exc)
85
- success = False
86
80
  except migrations.MigrationError as me:
87
81
  format_output(me.output, False, traceback=me.traceback)
88
82
  success = False
@@ -92,35 +86,29 @@ def migrate(record, dry_run=False):
92
86
 
93
87
 
94
88
  @grp.command()
95
- @click.argument("plugin_or_specs")
96
- @click.argument("filename", default=None, required=False, metavar="[FILENAME]")
97
- def unrecord(plugin_or_specs, filename):
89
+ @click.argument("filename")
90
+ def unrecord(filename):
98
91
  """
99
92
  Remove a database migration record.
100
93
 
101
- \b
102
- A record can be expressed with the following syntaxes:
103
- - plugin filename
104
- - plugin filename.js
105
- - plugin:filename
106
- - plugin:fliename.js
94
+ FILENAME is the migration filename (e.g., 2024-01-01-my-migration.py)
107
95
  """
108
- migration = migrations.get(plugin_or_specs, filename)
109
- removed = migration.unrecord()
96
+ removed = migrations.unrecord(filename)
110
97
  if removed:
111
- log.info("Removed migration %s", migration.label)
98
+ echo("Removed migration {}".format(filename))
112
99
  else:
113
- log.error("Migration not found %s", migration.label)
100
+ echo(red("Migration not found {}".format(filename)))
114
101
 
115
102
 
116
103
  @grp.command()
117
- @click.argument("plugin_or_specs")
118
- @click.argument("filename", default=None, required=False, metavar="[FILENAME]")
119
- def info(plugin_or_specs, filename):
104
+ @click.argument("filename")
105
+ def info(filename):
120
106
  """
121
107
  Display detailed info about a migration
108
+
109
+ FILENAME is the migration filename (e.g., 2024-01-01-my-migration.py)
122
110
  """
123
- migration = migrations.get(plugin_or_specs, filename)
111
+ migration = migrations.get(filename)
124
112
  log_status(migration, status_label(migration.record))
125
113
  try:
126
114
  echo(migration.module.__doc__)
udata/commands/info.py CHANGED
@@ -3,9 +3,7 @@ import logging
3
3
  from click import echo
4
4
  from flask import current_app
5
5
 
6
- from udata import entrypoints
7
6
  from udata.commands import KO, OK, cli, green, red, white
8
- from udata.features.identicon.backends import get_config as avatar_config
9
7
 
10
8
  log = logging.getLogger(__name__)
11
9
 
@@ -35,17 +33,3 @@ def config():
35
33
  if key.startswith("__") or not key.isupper():
36
34
  continue
37
35
  echo("{0}: {1}".format(white(key), current_app.config[key]))
38
-
39
-
40
- @grp.command()
41
- def plugins():
42
- """Display some details about the local plugins"""
43
- plugins = current_app.config["PLUGINS"]
44
- for name, description in entrypoints.ENTRYPOINTS.items():
45
- echo("{0} ({1})".format(white(description), name))
46
- if name == "udata.avatars":
47
- actives = [avatar_config("provider")]
48
- else:
49
- actives = plugins
50
- for ep in sorted(entrypoints.iter_all(name), key=by_name):
51
- echo("> {0}: {1}".format(ep.name, is_active(ep, actives)))
udata/commands/serve.py CHANGED
@@ -3,7 +3,7 @@ import os
3
3
 
4
4
  import click
5
5
  from flask import current_app
6
- from flask.cli import DispatchingApp, pass_script_info
6
+ from flask.cli import pass_script_info
7
7
  from werkzeug.serving import run_simple
8
8
 
9
9
  from udata.commands import cli
@@ -26,17 +26,11 @@ log = logging.getLogger(__name__)
26
26
  default=None,
27
27
  help="Enable or disable the debugger. By default the debugger is active if debug is enabled.",
28
28
  )
29
- @click.option(
30
- "--eager-loading/--lazy-loader",
31
- default=None,
32
- help="Enable or disable eager loading. By default eager "
33
- "loading is enabled if the reloader is disabled.",
34
- )
35
29
  @click.option(
36
30
  "--with-threads/--without-threads", default=True, help="Enable or disable multithreading."
37
31
  )
38
32
  @pass_script_info
39
- def serve(info, host, port, reload, debugger, eager_loading, with_threads):
33
+ def serve(info, host, port, reload, debugger, with_threads):
40
34
  """
41
35
  Runs a local udata development server.
42
36
 
@@ -62,10 +56,8 @@ def serve(info, host, port, reload, debugger, eager_loading, with_threads):
62
56
  reload = bool(debug)
63
57
  if debugger is None:
64
58
  debugger = bool(debug)
65
- if eager_loading is None:
66
- eager_loading = not reload
67
59
 
68
- app = DispatchingApp(info.load_app, use_eager_loading=eager_loading)
60
+ app = info.load_app()
69
61
 
70
62
  settings = os.environ.get("UDATA_SETTINGS", os.path.join(os.getcwd(), "udata.cfg"))
71
63
  extra_files = [settings]