udata 12.0.2.dev15__py3-none-any.whl → 13.0.1.dev21__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 (258) hide show
  1. udata/api/__init__.py +1 -0
  2. udata/api_fields.py +10 -4
  3. udata/app.py +11 -10
  4. udata/auth/__init__.py +9 -10
  5. udata/auth/mails.py +137 -45
  6. udata/auth/views.py +5 -12
  7. udata/commands/__init__.py +2 -3
  8. udata/commands/info.py +1 -3
  9. udata/commands/tests/test_fixtures.py +6 -3
  10. udata/core/access_type/api.py +18 -0
  11. udata/core/access_type/constants.py +98 -0
  12. udata/core/access_type/models.py +44 -0
  13. udata/core/activity/models.py +1 -1
  14. udata/core/badges/models.py +1 -1
  15. udata/core/badges/tasks.py +35 -1
  16. udata/core/badges/tests/test_commands.py +2 -4
  17. udata/core/badges/tests/test_model.py +2 -2
  18. udata/core/badges/tests/test_tasks.py +55 -0
  19. udata/core/constants.py +1 -0
  20. udata/core/contact_point/models.py +8 -0
  21. udata/core/dataservices/api.py +3 -3
  22. udata/core/dataservices/apiv2.py +3 -1
  23. udata/core/dataservices/constants.py +0 -29
  24. udata/core/dataservices/models.py +44 -44
  25. udata/core/dataservices/rdf.py +2 -1
  26. udata/core/dataservices/search.py +5 -9
  27. udata/core/dataservices/tasks.py +33 -0
  28. udata/core/dataset/api_fields.py +11 -0
  29. udata/core/dataset/apiv2.py +11 -0
  30. udata/core/dataset/constants.py +0 -1
  31. udata/core/dataset/forms.py +29 -0
  32. udata/core/dataset/models.py +16 -4
  33. udata/core/dataset/rdf.py +2 -1
  34. udata/core/dataset/search.py +2 -2
  35. udata/core/dataset/tasks.py +86 -8
  36. udata/core/discussions/mails.py +63 -0
  37. udata/core/discussions/tasks.py +4 -18
  38. udata/core/metrics/__init__.py +0 -6
  39. udata/core/organization/api.py +3 -1
  40. udata/core/organization/mails.py +144 -0
  41. udata/core/organization/models.py +2 -1
  42. udata/core/organization/search.py +1 -1
  43. udata/core/organization/tasks.py +21 -49
  44. udata/core/pages/tests/test_api.py +0 -2
  45. udata/core/reuse/api.py +27 -1
  46. udata/core/reuse/mails.py +21 -0
  47. udata/core/reuse/models.py +10 -1
  48. udata/core/reuse/search.py +1 -1
  49. udata/core/reuse/tasks.py +2 -3
  50. udata/core/site/models.py +2 -6
  51. udata/core/spatial/tests/test_api.py +17 -20
  52. udata/core/spatial/tests/test_models.py +3 -3
  53. udata/core/user/mails.py +54 -0
  54. udata/core/user/models.py +2 -3
  55. udata/core/user/tasks.py +8 -23
  56. udata/core/user/tests/test_user_model.py +2 -6
  57. udata/entrypoints.py +0 -5
  58. udata/features/identicon/tests/test_backends.py +3 -13
  59. udata/forms/fields.py +3 -3
  60. udata/forms/widgets.py +2 -2
  61. udata/frontend/__init__.py +3 -32
  62. udata/harvest/actions.py +4 -9
  63. udata/harvest/api.py +5 -14
  64. udata/harvest/backends/__init__.py +20 -11
  65. udata/harvest/backends/base.py +2 -2
  66. udata/harvest/backends/ckan/harvesters.py +2 -1
  67. udata/harvest/backends/dcat.py +3 -0
  68. udata/harvest/backends/maaf.py +1 -0
  69. udata/harvest/commands.py +6 -4
  70. udata/harvest/forms.py +9 -6
  71. udata/harvest/tasks.py +3 -5
  72. udata/harvest/tests/ckan/test_ckan_backend.py +300 -337
  73. udata/harvest/tests/ckan/test_ckan_backend_errors.py +94 -99
  74. udata/harvest/tests/ckan/test_ckan_backend_filters.py +128 -122
  75. udata/harvest/tests/ckan/test_dkan_backend.py +39 -51
  76. udata/harvest/tests/dcat/datara--5a26b0f6-0ccf-46ad-ac58-734054b91977.rdf.xml +255 -0
  77. udata/harvest/tests/dcat/datara--f40c3860-7236-4b30-a141-23b8ae33f7b2.rdf.xml +289 -0
  78. udata/harvest/tests/factories.py +1 -1
  79. udata/harvest/tests/test_actions.py +11 -9
  80. udata/harvest/tests/test_api.py +4 -5
  81. udata/harvest/tests/test_base_backend.py +5 -4
  82. udata/harvest/tests/test_dcat_backend.py +50 -19
  83. udata/harvest/tests/test_models.py +2 -4
  84. udata/harvest/tests/test_notifications.py +2 -4
  85. udata/harvest/tests/test_tasks.py +2 -3
  86. udata/mail.py +90 -53
  87. udata/migrations/2025-01-05-dataservices-fields-changes.py +8 -14
  88. udata/migrations/2025-10-21-remove-ckan-harvest-modified-at.py +28 -0
  89. udata/migrations/2025-10-29-harvesters-sources-integrity.py +27 -0
  90. udata/mongo/taglist_field.py +3 -3
  91. udata/rdf.py +32 -15
  92. udata/sentry.py +3 -4
  93. udata/settings.py +7 -2
  94. udata/tags.py +5 -5
  95. udata/tasks.py +3 -3
  96. udata/templates/mail/message.html +65 -0
  97. udata/templates/mail/message.txt +16 -0
  98. udata/tests/__init__.py +40 -58
  99. udata/tests/api/__init__.py +87 -2
  100. udata/tests/api/test_activities_api.py +17 -23
  101. udata/tests/api/test_auth_api.py +2 -4
  102. udata/tests/api/test_contact_points.py +48 -54
  103. udata/tests/api/test_dataservices_api.py +57 -37
  104. udata/tests/api/test_datasets_api.py +146 -49
  105. udata/tests/api/test_me_api.py +4 -6
  106. udata/tests/api/test_organizations_api.py +19 -38
  107. udata/tests/api/test_reports_api.py +0 -4
  108. udata/tests/api/test_reuses_api.py +92 -19
  109. udata/tests/api/test_security_api.py +124 -0
  110. udata/tests/api/test_swagger.py +2 -3
  111. udata/tests/api/test_tags_api.py +6 -7
  112. udata/tests/api/test_transfer_api.py +0 -2
  113. udata/tests/api/test_user_api.py +8 -10
  114. udata/tests/apiv2/test_datasets.py +0 -4
  115. udata/tests/apiv2/test_me_api.py +0 -2
  116. udata/tests/apiv2/test_organizations.py +0 -2
  117. udata/tests/apiv2/test_swagger.py +2 -3
  118. udata/tests/apiv2/test_topics.py +0 -2
  119. udata/tests/cli/test_cli_base.py +14 -12
  120. udata/tests/cli/test_db_cli.py +51 -54
  121. udata/tests/contact_point/test_contact_point_models.py +2 -2
  122. udata/tests/dataservice/test_csv_adapter.py +2 -5
  123. udata/tests/dataservice/test_dataservice_rdf.py +8 -6
  124. udata/tests/dataservice/test_dataservice_tasks.py +36 -38
  125. udata/tests/dataset/test_csv_adapter.py +2 -5
  126. udata/tests/dataset/test_dataset_actions.py +2 -4
  127. udata/tests/dataset/test_dataset_commands.py +2 -4
  128. udata/tests/dataset/test_dataset_events.py +3 -3
  129. udata/tests/dataset/test_dataset_model.py +6 -7
  130. udata/tests/dataset/test_dataset_rdf.py +201 -12
  131. udata/tests/dataset/test_dataset_recommendations.py +2 -2
  132. udata/tests/dataset/test_dataset_tasks.py +66 -68
  133. udata/tests/dataset/test_resource_preview.py +39 -48
  134. udata/tests/dataset/test_transport_tasks.py +2 -2
  135. udata/tests/features/territories/__init__.py +0 -6
  136. udata/tests/features/territories/test_territories_api.py +25 -24
  137. udata/tests/forms/test_current_user_field.py +2 -2
  138. udata/tests/forms/test_dict_field.py +2 -4
  139. udata/tests/forms/test_extras_fields.py +2 -3
  140. udata/tests/forms/test_image_field.py +2 -2
  141. udata/tests/forms/test_model_field.py +2 -4
  142. udata/tests/forms/test_publish_as_field.py +2 -4
  143. udata/tests/forms/test_user_forms.py +26 -29
  144. udata/tests/frontend/test_auth.py +2 -3
  145. udata/tests/frontend/test_csv.py +5 -6
  146. udata/tests/frontend/test_error_handlers.py +2 -3
  147. udata/tests/frontend/test_hooks.py +5 -7
  148. udata/tests/frontend/test_markdown.py +3 -4
  149. udata/tests/helpers.py +2 -7
  150. udata/tests/metrics/test_metrics.py +52 -48
  151. udata/tests/metrics/test_tasks.py +154 -150
  152. udata/tests/organization/test_csv_adapter.py +2 -5
  153. udata/tests/organization/test_notifications.py +2 -4
  154. udata/tests/organization/test_organization_model.py +3 -4
  155. udata/tests/organization/test_organization_rdf.py +2 -8
  156. udata/tests/plugin.py +6 -110
  157. udata/tests/reuse/test_reuse_model.py +3 -4
  158. udata/tests/site/test_site_api.py +0 -2
  159. udata/tests/site/test_site_csv_exports.py +0 -2
  160. udata/tests/site/test_site_metrics.py +2 -4
  161. udata/tests/site/test_site_model.py +2 -2
  162. udata/tests/site/test_site_rdf.py +4 -7
  163. udata/tests/test_activity.py +3 -3
  164. udata/tests/test_api_fields.py +6 -9
  165. udata/tests/test_cors.py +0 -2
  166. udata/tests/test_dcat_commands.py +2 -3
  167. udata/tests/test_discussions.py +2 -7
  168. udata/tests/test_mail.py +150 -114
  169. udata/tests/test_migrations.py +413 -419
  170. udata/tests/test_model.py +10 -11
  171. udata/tests/test_notifications.py +2 -3
  172. udata/tests/test_owned.py +3 -3
  173. udata/tests/test_rdf.py +19 -15
  174. udata/tests/test_routing.py +5 -5
  175. udata/tests/test_storages.py +6 -5
  176. udata/tests/test_tags.py +2 -4
  177. udata/tests/test_topics.py +2 -4
  178. udata/tests/test_transfer.py +4 -5
  179. udata/tests/topic/test_topic_tasks.py +25 -27
  180. udata/tests/user/test_user_rdf.py +2 -8
  181. udata/tests/user/test_user_tasks.py +3 -5
  182. udata/tests/workers/test_jobs_commands.py +2 -2
  183. udata/tests/workers/test_tasks_routing.py +27 -27
  184. udata/translations/ar/LC_MESSAGES/udata.mo +0 -0
  185. udata/translations/ar/LC_MESSAGES/udata.po +369 -435
  186. udata/translations/de/LC_MESSAGES/udata.mo +0 -0
  187. udata/translations/de/LC_MESSAGES/udata.po +371 -437
  188. udata/translations/es/LC_MESSAGES/udata.mo +0 -0
  189. udata/translations/es/LC_MESSAGES/udata.po +369 -435
  190. udata/translations/fr/LC_MESSAGES/udata.mo +0 -0
  191. udata/translations/fr/LC_MESSAGES/udata.po +381 -447
  192. udata/translations/it/LC_MESSAGES/udata.mo +0 -0
  193. udata/translations/it/LC_MESSAGES/udata.po +371 -437
  194. udata/translations/pt/LC_MESSAGES/udata.mo +0 -0
  195. udata/translations/pt/LC_MESSAGES/udata.po +371 -437
  196. udata/translations/sr/LC_MESSAGES/udata.mo +0 -0
  197. udata/translations/sr/LC_MESSAGES/udata.po +372 -438
  198. udata/translations/udata.pot +379 -440
  199. udata/utils.py +14 -2
  200. {udata-12.0.2.dev15.dist-info → udata-13.0.1.dev21.dist-info}/METADATA +1 -2
  201. {udata-12.0.2.dev15.dist-info → udata-13.0.1.dev21.dist-info}/RECORD +205 -242
  202. udata/templates/mail/account_deleted.html +0 -5
  203. udata/templates/mail/account_deleted.txt +0 -6
  204. udata/templates/mail/account_inactivity.html +0 -40
  205. udata/templates/mail/account_inactivity.txt +0 -31
  206. udata/templates/mail/badge_added_association.html +0 -33
  207. udata/templates/mail/badge_added_association.txt +0 -11
  208. udata/templates/mail/badge_added_certified.html +0 -33
  209. udata/templates/mail/badge_added_certified.txt +0 -11
  210. udata/templates/mail/badge_added_company.html +0 -33
  211. udata/templates/mail/badge_added_company.txt +0 -11
  212. udata/templates/mail/badge_added_local_authority.html +0 -33
  213. udata/templates/mail/badge_added_local_authority.txt +0 -11
  214. udata/templates/mail/badge_added_public_service.html +0 -33
  215. udata/templates/mail/badge_added_public_service.txt +0 -11
  216. udata/templates/mail/discussion_closed.html +0 -47
  217. udata/templates/mail/discussion_closed.txt +0 -16
  218. udata/templates/mail/inactive_account_deleted.html +0 -5
  219. udata/templates/mail/inactive_account_deleted.txt +0 -6
  220. udata/templates/mail/membership_refused.html +0 -20
  221. udata/templates/mail/membership_refused.txt +0 -11
  222. udata/templates/mail/membership_request.html +0 -46
  223. udata/templates/mail/membership_request.txt +0 -12
  224. udata/templates/mail/new_discussion.html +0 -44
  225. udata/templates/mail/new_discussion.txt +0 -15
  226. udata/templates/mail/new_discussion_comment.html +0 -45
  227. udata/templates/mail/new_discussion_comment.txt +0 -16
  228. udata/templates/mail/new_member.html +0 -27
  229. udata/templates/mail/new_member.txt +0 -11
  230. udata/templates/mail/new_reuse.html +0 -37
  231. udata/templates/mail/new_reuse.txt +0 -9
  232. udata/templates/mail/test.html +0 -6
  233. udata/templates/mail/test.txt +0 -6
  234. udata/templates/mail/user_mail_card.html +0 -26
  235. udata/templates/security/email/base.html +0 -105
  236. udata/templates/security/email/base.txt +0 -6
  237. udata/templates/security/email/button.html +0 -3
  238. udata/templates/security/email/change_notice.html +0 -22
  239. udata/templates/security/email/change_notice.txt +0 -8
  240. udata/templates/security/email/confirmation_instructions.html +0 -20
  241. udata/templates/security/email/confirmation_instructions.txt +0 -7
  242. udata/templates/security/email/login_instructions.html +0 -19
  243. udata/templates/security/email/login_instructions.txt +0 -7
  244. udata/templates/security/email/reset_instructions.html +0 -24
  245. udata/templates/security/email/reset_instructions.txt +0 -9
  246. udata/templates/security/email/reset_notice.html +0 -11
  247. udata/templates/security/email/reset_notice.txt +0 -4
  248. udata/templates/security/email/welcome.html +0 -24
  249. udata/templates/security/email/welcome.txt +0 -9
  250. udata/templates/security/email/welcome_existing.html +0 -32
  251. udata/templates/security/email/welcome_existing.txt +0 -14
  252. udata/terms.md +0 -6
  253. udata/tests/frontend/__init__.py +0 -23
  254. udata/tests/metrics/conftest.py +0 -15
  255. {udata-12.0.2.dev15.dist-info → udata-13.0.1.dev21.dist-info}/WHEEL +0 -0
  256. {udata-12.0.2.dev15.dist-info → udata-13.0.1.dev21.dist-info}/entry_points.txt +0 -0
  257. {udata-12.0.2.dev15.dist-info → udata-13.0.1.dev21.dist-info}/licenses/LICENSE +0 -0
  258. {udata-12.0.2.dev15.dist-info → udata-13.0.1.dev21.dist-info}/top_level.txt +0 -0
udata/api/__init__.py CHANGED
@@ -333,6 +333,7 @@ def marshal_page_with(func):
333
333
 
334
334
  def init_app(app):
335
335
  # Load all core APIs
336
+ import udata.core.access_type.api # noqa
336
337
  import udata.core.activity.api # noqa
337
338
  import udata.core.spatial.api # noqa
338
339
  import udata.core.user.api # noqa
udata/api_fields.py CHANGED
@@ -93,12 +93,14 @@ def convert_db_to_field(key, field, info) -> tuple[Callable | None, Callable | N
93
93
  # TODO: this is currently never used. We may remove it if the auto-conversion
94
94
  # is always good enough.
95
95
  return info.get("convert_to"), info.get("convert_to")
96
- elif isinstance(field, mongo_fields.StringField):
96
+ elif isinstance(field, mongo_fields.StringField) or isinstance(field, mongo_fields.EnumField):
97
97
  constructor = (
98
98
  custom_restx_fields.Markdown if info.get("markdown", False) else restx_fields.String
99
99
  )
100
- params["min_length"] = field.min_length
101
- params["max_length"] = field.max_length
100
+ if isinstance(field, mongo_fields.StringField):
101
+ params["min_length"] = field.min_length
102
+ params["max_length"] = field.max_length
103
+ # EnumField define choices too from the enum values.
102
104
  params["enum"] = field.choices
103
105
  if field.validation:
104
106
  params["validation"] = validation_to_type(field.validation)
@@ -215,6 +217,9 @@ def convert_db_to_field(key, field, info) -> tuple[Callable | None, Callable | N
215
217
  # the referenced model, if not we return a String (and RestX will call the `str()` of the model
216
218
  # when returning from an endpoint)
217
219
  nested_fields: dict | None = info.get("nested_fields")
220
+ if nested_fields is None and hasattr(field.document_type_obj, "__ref_fields__"):
221
+ nested_fields = field.document_type_obj.__ref_fields__
222
+
218
223
  if nested_fields is None:
219
224
  # If there is no `nested_fields` convert the object to the string representation.
220
225
  constructor_read = restx_fields.String
@@ -323,7 +328,8 @@ def generate_fields(**kwargs) -> Callable:
323
328
  kwargs.get("nested_filters", {})
324
329
  )
325
330
 
326
- read_fields["id"] = restx_fields.String(required=True, readonly=True)
331
+ if issubclass(cls, db.Document) or issubclass(cls, db.DynamicDocument):
332
+ read_fields["id"] = restx_fields.String(required=True, readonly=True)
327
333
 
328
334
  classes_by_names[cls.__name__] = cls
329
335
  save_class_by_parents(cls)
udata/app.py CHANGED
@@ -20,6 +20,7 @@ from flask import (
20
20
  )
21
21
  from flask_caching import Cache
22
22
  from flask_wtf.csrf import CSRFProtect
23
+ from importlib_metadata import entry_points
23
24
  from speaklater import is_lazy_string
24
25
  from werkzeug.exceptions import NotFound
25
26
  from werkzeug.middleware.proxy_fix import ProxyFix
@@ -200,12 +201,21 @@ def create_app(config="udata.settings.Defaults", override=None, init_logging=ini
200
201
  def standalone(app):
201
202
  """Factory for an all in one application"""
202
203
  from udata import api, core, frontend
204
+ from udata.features import notifications
203
205
 
204
206
  core.init_app(app)
205
207
  frontend.init_app(app)
206
208
  api.init_app(app)
209
+ notifications.init_app(app)
210
+
211
+ eps = entry_points(group="udata.plugins")
212
+ for ep in eps:
213
+ plugin_module = ep.load()
207
214
 
208
- register_features(app)
215
+ if hasattr(plugin_module, "init_app"):
216
+ plugin_module.init_app(app)
217
+ else:
218
+ log.error(f"Plugin {ep.name} ({ep.value}) doesn't expose an `init_app()` function.")
209
219
 
210
220
  return app
211
221
 
@@ -278,12 +288,3 @@ def page_not_found(e: NotFound):
278
288
  return render_template("404.html", homepage_url=homepage_url()), 404
279
289
 
280
290
  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,6 +1,6 @@
1
1
  import logging
2
2
 
3
- from flask import current_app, render_template
3
+ from flask import render_template
4
4
  from flask_principal import Permission as BasePermission
5
5
  from flask_principal import PermissionDenied as PermissionDenied
6
6
  from flask_principal import RoleNeed as RoleNeed
@@ -10,17 +10,18 @@ from flask_security import Security as Security
10
10
  from flask_security import current_user as current_user
11
11
  from flask_security import login_required as login_required
12
12
  from flask_security import login_user as login_user
13
- from werkzeug.utils import import_string
13
+
14
+ from . import mails
14
15
 
15
16
  log = logging.getLogger(__name__)
16
17
 
17
18
 
18
- def render_security_template(*args, **kwargs):
19
- try:
20
- render = import_string(current_app.config.get("SECURITY_RENDER"))
21
- except Exception:
22
- render = render_template
23
- return render(*args, **kwargs)
19
+ def render_security_template(template_name_or_list, **kwargs):
20
+ result = mails.render_mail_template(template_name_or_list, **kwargs)
21
+ if result is not None:
22
+ return result
23
+
24
+ return render_template(template_name_or_list, **kwargs)
24
25
 
25
26
 
26
27
  security = Security()
@@ -44,7 +45,6 @@ def init_app(app):
44
45
  ExtendedRegisterForm,
45
46
  ExtendedResetPasswordForm,
46
47
  )
47
- from .mails import UdataMailUtil
48
48
  from .password_validation import UdataPasswordUtil
49
49
  from .views import create_security_blueprint
50
50
 
@@ -71,7 +71,6 @@ def init_app(app):
71
71
  register_form=ExtendedRegisterForm,
72
72
  reset_password_form=ExtendedResetPasswordForm,
73
73
  forgot_password_form=ExtendedForgotPasswordForm,
74
- mail_util_cls=UdataMailUtil,
75
74
  password_util_cls=UdataPasswordUtil,
76
75
  )
77
76
 
udata/auth/mails.py CHANGED
@@ -1,54 +1,146 @@
1
+ """
2
+ We have our own system to build mails without Jinja templates with `MailMessage`.
3
+ To connect our system with the system from flask_security we need to override the Jinja
4
+ `render_template` function, create our MailMessage, and generate the HTML or text version.
5
+ `flask_security` then call the standard mail method from flask to send these strings.
6
+
7
+ In `render_mail_template` we support a few mails but not all. We could fallback to the regular
8
+ Jinja render function, but since we don't have any mails' templates defined in our application
9
+ the render function will crash, so we crash early in the `render_mail_template` function.
10
+
11
+ Note that `flask_security` have default templates for all mails but we create our own blueprint
12
+ specifying our `template` folder for templates and the system is not intelligent enough to try
13
+ our folder before fallbacking to the templates inside the `flask_security` package.
14
+ """
15
+
1
16
  import logging
2
17
 
3
- import email_validator
4
18
  from flask import current_app
5
19
 
6
- from udata.tasks import task
20
+ from udata.mail import MailCTA, MailMessage
7
21
 
8
22
  log = logging.getLogger(__name__)
9
23
 
10
24
 
11
- @task
12
- def sendmail(msg):
13
- debug = current_app.config.get("DEBUG", False)
14
- send_mail = current_app.config.get("SEND_MAIL", not debug)
15
- if send_mail:
16
- mail = current_app.extensions.get("mail")
17
- mail.send(msg)
25
+ def render_mail_template(template_name_or_list: str | list[str], **kwargs):
26
+ if not isinstance(template_name_or_list, str):
27
+ return None
28
+
29
+ if not template_name_or_list.startswith("security/email/"):
30
+ return None
31
+
32
+ if not template_name_or_list.endswith(".txt") and not template_name_or_list.endswith(".html"):
33
+ return None
34
+
35
+ (name, format) = template_name_or_list.removeprefix("security/email/").split(".")
36
+
37
+ mail_message = None
38
+
39
+ match name:
40
+ case "welcome":
41
+ mail_message = welcome(**kwargs)
42
+ case "welcome_existing":
43
+ mail_message = welcome_existing(**kwargs)
44
+ case "confirmation_instructions":
45
+ mail_message = confirmation_instructions(**kwargs)
46
+ case "reset_instructions":
47
+ mail_message = reset_instructions(**kwargs)
48
+ case "reset_notice":
49
+ mail_message = reset_notice(**kwargs)
50
+ case "change_notice":
51
+ mail_message = change_notice(**kwargs)
52
+ case _:
53
+ raise Exception(f"Unknown mail message template: {name}")
54
+
55
+ if format == "txt":
56
+ return mail_message.text(kwargs.get("user"))
57
+ elif format == "html":
58
+ return mail_message.html(kwargs.get("user"))
18
59
  else:
19
- log.warning(msg)
20
-
21
-
22
- class UdataMailUtil:
23
- def __init__(self, app):
24
- self.app = app
25
-
26
- def send_mail(self, template, subject, recipient, sender, body, html, user, **kwargs):
27
- from flask_mail import Message
28
-
29
- # In Flask-Mail, sender can be a two element tuple -- (name, address)
30
- if isinstance(sender, tuple) and len(sender) == 2:
31
- sender = (str(sender[0]), str(sender[1]))
32
- else:
33
- sender = str(sender)
34
- msg = Message(str(subject), sender=sender, recipients=[recipient])
35
- msg.body = body
36
- msg.html = html
37
-
38
- sendmail.delay(msg)
39
-
40
- def normalize(self, email):
41
- # Called at registration and login
42
- # Calls validate method with deliverability check disabled to prevent
43
- # login failure for existing users without a valid email domain name
44
- return self.validate(email, validation_args={"check_deliverability": False})
45
-
46
- def validate(self, email, validation_args=None):
47
- # Called at registration only
48
- # Checks email domain name deliverability
49
- # To prevent false email to register
50
- validator_args = self.app.config["SECURITY_EMAIL_VALIDATOR_ARGS"] or {}
51
- if validation_args:
52
- validator_args.update(validation_args)
53
- valid = email_validator.validate_email(email, **validator_args)
54
- return valid.email
60
+ raise Exception(f"Mail message with unknown format: {name} (txt or html supported)")
61
+
62
+
63
+ def welcome(confirmation_link: str, **kwargs) -> MailMessage:
64
+ from udata.i18n import lazy_gettext as _
65
+
66
+ return MailMessage(
67
+ subject=_("Confirm your email address"),
68
+ paragraphs=[
69
+ _("Welcome to %(site)s!", site=current_app.config["SITE_TITLE"]),
70
+ _("Please confirm your email address."),
71
+ MailCTA(_("Confirm your email address"), confirmation_link),
72
+ ],
73
+ )
74
+
75
+
76
+ def welcome_existing(recovery_link: str, **kwargs) -> MailMessage:
77
+ from udata.i18n import lazy_gettext as _
78
+
79
+ return MailMessage(
80
+ subject=_("Your email address is already associated with an account"),
81
+ paragraphs=[
82
+ _(
83
+ "Someone (you?) tried to create an account on %(site)s with your email.",
84
+ site=current_app.config["SITE_TITLE"],
85
+ ),
86
+ _("If you forgot your password, you can reset it."),
87
+ MailCTA(_("Reset your password"), recovery_link),
88
+ ],
89
+ )
90
+
91
+
92
+ def confirmation_instructions(confirmation_link: str, **kwargs) -> MailMessage:
93
+ from udata.i18n import lazy_gettext as _
94
+
95
+ return MailMessage(
96
+ subject=_("Confirm your email address"),
97
+ paragraphs=[
98
+ _("Please confirm your email address."),
99
+ MailCTA(_("Confirm your email address"), confirmation_link),
100
+ ],
101
+ )
102
+
103
+
104
+ def reset_instructions(reset_token: str, **kwargs) -> MailMessage:
105
+ from udata.i18n import lazy_gettext as _
106
+ from udata.uris import cdata_url
107
+
108
+ return MailMessage(
109
+ subject=_("Reset your password"),
110
+ paragraphs=[
111
+ _(
112
+ "Someone requested a password reset for your %(site)s account.",
113
+ site=current_app.config["SITE_TITLE"],
114
+ ),
115
+ _("If this wasn't you, please ignore this email."),
116
+ MailCTA(_("Reset your password"), cdata_url(f"/reset/{reset_token}")),
117
+ ],
118
+ )
119
+
120
+
121
+ def reset_notice(**kwargs) -> MailMessage:
122
+ from udata.i18n import lazy_gettext as _
123
+
124
+ return MailMessage(
125
+ subject=_("Your password has been reset"),
126
+ paragraphs=[
127
+ _("Your data.gouv.fr password has been reset."),
128
+ ],
129
+ )
130
+
131
+
132
+ def change_notice(**kwargs) -> MailMessage:
133
+ from udata.i18n import lazy_gettext as _
134
+ from udata.uris import cdata_url
135
+
136
+ return MailMessage(
137
+ subject=_("Your password has been changed"),
138
+ paragraphs=[
139
+ _(
140
+ "Your %(site)s account password has been changed.",
141
+ site=current_app.config["SITE_TITLE"],
142
+ ),
143
+ _("If you did not change your password, please reset it."),
144
+ MailCTA(_("Reset your password"), cdata_url("/reset/")),
145
+ ],
146
+ )
udata/auth/views.py CHANGED
@@ -6,7 +6,6 @@ from flask_security.utils import (
6
6
  hash_data,
7
7
  login_user,
8
8
  logout_user,
9
- send_mail,
10
9
  verify_hash,
11
10
  )
12
11
  from flask_security.views import (
@@ -25,10 +24,10 @@ from flask_wtf.csrf import generate_csrf
25
24
  from werkzeug.local import LocalProxy
26
25
 
27
26
  from udata.auth.proconnect import get_logout_url
28
- from udata.i18n import lazy_gettext as _
29
27
  from udata.uris import homepage_url
30
28
  from udata.utils import wants_json
31
29
 
30
+ from . import mails
32
31
  from .forms import ChangeEmailForm
33
32
 
34
33
  _security = LocalProxy(lambda: current_app.extensions["security"])
@@ -47,16 +46,10 @@ def slash_url_suffix(url, suffix):
47
46
  def send_change_email_confirmation_instructions(user, new_email):
48
47
  data = [str(current_user.fs_uniquifier), hash_data(current_user.email), new_email]
49
48
  token = _security.confirm_serializer.dumps(data)
50
- confirmation_link = url_for("security.confirm_change_email", token=token, _external=True)
51
-
52
- subject = _("Confirm change of email instructions")
53
- send_mail(
54
- subject=subject,
55
- recipient=new_email,
56
- template="confirmation_instructions",
57
- user=current_user,
58
- confirmation_link=confirmation_link,
59
- )
49
+
50
+ mails.confirmation_instructions(
51
+ confirmation_link=url_for("security.confirm_change_email", token=token, _external=True)
52
+ ).send(new_email)
60
53
 
61
54
 
62
55
  def confirm_change_email_token_status(token):
@@ -4,12 +4,11 @@ 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
9
  from udata import entrypoints
11
10
  from udata.app import VERBOSE_LOGGERS, create_app, standalone
12
- from udata.utils import safe_unicode
11
+ from udata.utils import get_udata_version, safe_unicode
13
12
 
14
13
  log = logging.getLogger(__name__)
15
14
 
@@ -253,7 +252,7 @@ class UdataGroup(FlaskGroup):
253
252
  def print_version(ctx, param, value):
254
253
  if not value or ctx.resilient_parsing:
255
254
  return
256
- click.echo(pkg_resources.get_distribution("udata").version)
255
+ click.echo(get_udata_version())
257
256
  ctx.exit()
258
257
 
259
258
 
udata/commands/info.py CHANGED
@@ -43,9 +43,7 @@ def plugins():
43
43
  plugins = current_app.config["PLUGINS"]
44
44
  for name, description in entrypoints.ENTRYPOINTS.items():
45
45
  echo("{0} ({1})".format(white(description), name))
46
- if name == "udata.themes":
47
- actives = [current_app.config["THEME"]]
48
- elif name == "udata.avatars":
46
+ if name == "udata.avatars":
49
47
  actives = [avatar_config("provider")]
50
48
  else:
51
49
  actives = plugins
@@ -17,12 +17,12 @@ from udata.core.discussions.factories import DiscussionFactory, MessageDiscussio
17
17
  from udata.core.organization.factories import OrganizationFactory
18
18
  from udata.core.organization.models import Member
19
19
  from udata.core.reuse.factories import ReuseFactory
20
+ from udata.core.spam.models import SpamMixin
20
21
  from udata.core.user.factories import UserFactory
22
+ from udata.tests.api import PytestOnlyAPITestCase
21
23
 
22
24
 
23
- @pytest.mark.usefixtures("clean_db")
24
- class FixturesTest:
25
- @pytest.mark.frontend
25
+ class FixturesTest(PytestOnlyAPITestCase):
26
26
  @pytest.mark.options(FIXTURE_DATASET_SLUGS=["some-test-dataset-slug"])
27
27
  def test_generate_fixtures_file_then_import(self, app, cli, api, monkeypatch):
28
28
  """Test generating fixtures from the current env, then importing them back."""
@@ -108,7 +108,10 @@ class FixturesTest:
108
108
 
109
109
  def test_import_fixtures_from_default_file(self, cli):
110
110
  """Test importing fixtures from udata.commands.fixture.DEFAULT_FIXTURE_FILE."""
111
+ # Deactivate spam detection when testing import fixtures
112
+ SpamMixin.detect_spam_enabled = False
111
113
  cli("import-fixtures")
114
+ SpamMixin.detect_spam_enabled = True
112
115
  assert models.Organization.objects.count() > 0
113
116
  assert models.Dataset.objects.count() > 0
114
117
  assert models.Reuse.objects.count() > 0
@@ -0,0 +1,18 @@
1
+ from flask import Blueprint
2
+
3
+ from udata.api import API, api
4
+ from udata.core.access_type.constants import InspireLimitationCategory
5
+
6
+ blueprint = Blueprint("access_type", __name__, url_prefix="/api/1/access_type")
7
+ ns = api.namespace("access_type", "Access type related operations")
8
+
9
+
10
+ @ns.route("/reason_categories/", endpoint="reason_categories")
11
+ class ReasonCategoriesAPI(API):
12
+ @api.doc("reason_categories")
13
+ def get(self):
14
+ """List all limitation reason categories"""
15
+ return [
16
+ {"value": category.value, "label": category.label}
17
+ for category in InspireLimitationCategory
18
+ ]
@@ -0,0 +1,98 @@
1
+ from enum import StrEnum, auto
2
+ from typing import assert_never
3
+
4
+ from udata.i18n import lazy_gettext as _
5
+
6
+
7
+ class AccessType(StrEnum):
8
+ OPEN = auto()
9
+ OPEN_WITH_ACCOUNT = auto()
10
+ RESTRICTED = auto()
11
+
12
+
13
+ class AccessAudienceType(StrEnum):
14
+ ADMINISTRATION = "local_authority_and_administration"
15
+ COMPANY = "company_and_association"
16
+ PRIVATE = "private"
17
+
18
+
19
+ class AccessAudienceCondition(StrEnum):
20
+ YES = "yes"
21
+ NO = "no"
22
+ UNDER_CONDITIONS = "under_condition"
23
+
24
+
25
+ class InspireLimitationCategory(StrEnum):
26
+ """INSPIRE Directive Article 13(1) limitation reason categories"""
27
+
28
+ PUBLIC_AUTHORITIES = "confidentiality_of_proceedings_of_public_authorities"
29
+ INTERNATIONAL_RELATIONS = "international_relations_public_security_or_national_defence"
30
+ COURSE_OF_JUSTICE = "course_of_justice_or_fair_trial"
31
+ COMMERCIAL_CONFIDENTIALITY = "confidentiality_of_commercial_or_industrial_information"
32
+ INTELLECTUAL_PROPERTY = "intellectual_property_rights"
33
+ PERSONAL_DATA = "confidentiality_of_personal_data"
34
+ VOLUNTARY_SUPPLIER = "protection_of_voluntary_information_suppliers"
35
+ ENVIRONMENTAL_PROTECTION = "protection_of_environment"
36
+
37
+ @property
38
+ def label(self):
39
+ """Returns the label for this limitation category."""
40
+ match self:
41
+ case InspireLimitationCategory.PUBLIC_AUTHORITIES:
42
+ return _("Confidentiality of public authorities proceedings")
43
+ case InspireLimitationCategory.INTERNATIONAL_RELATIONS:
44
+ return _("International relations, public security or national defence")
45
+ case InspireLimitationCategory.COURSE_OF_JUSTICE:
46
+ return _("Course of justice")
47
+ case InspireLimitationCategory.COMMERCIAL_CONFIDENTIALITY:
48
+ return _("Commercial or industrial confidentiality")
49
+ case InspireLimitationCategory.INTELLECTUAL_PROPERTY:
50
+ return _("Intellectual property rights")
51
+ case InspireLimitationCategory.PERSONAL_DATA:
52
+ return _("Personal data confidentiality")
53
+ case InspireLimitationCategory.VOLUNTARY_SUPPLIER:
54
+ return _("Protection of voluntary information suppliers")
55
+ case InspireLimitationCategory.ENVIRONMENTAL_PROTECTION:
56
+ return _("Environmental protection")
57
+ case _:
58
+ assert_never(self)
59
+
60
+ @property
61
+ def definition(self):
62
+ """Returns the definition for this limitation category."""
63
+ match self:
64
+ case InspireLimitationCategory.PUBLIC_AUTHORITIES:
65
+ return _(
66
+ "The confidentiality of the proceedings of public authorities, where such confidentiality is provided for by law."
67
+ )
68
+ case InspireLimitationCategory.INTERNATIONAL_RELATIONS:
69
+ return _("International relations, public security or national defence.")
70
+ case InspireLimitationCategory.COURSE_OF_JUSTICE:
71
+ return _(
72
+ "The course of justice, the ability of any person to receive a fair trial or the ability of a public authority to conduct an enquiry of a criminal or disciplinary nature."
73
+ )
74
+ case InspireLimitationCategory.COMMERCIAL_CONFIDENTIALITY:
75
+ return _(
76
+ "The confidentiality of commercial or industrial information, where such confidentiality is provided for by national or Community law to protect a legitimate economic interest."
77
+ )
78
+ case InspireLimitationCategory.INTELLECTUAL_PROPERTY:
79
+ return _("Intellectual property rights.")
80
+ case InspireLimitationCategory.PERSONAL_DATA:
81
+ return _(
82
+ "The confidentiality of personal data and/or files relating to a natural person where that person has not consented to the disclosure."
83
+ )
84
+ case InspireLimitationCategory.VOLUNTARY_SUPPLIER:
85
+ return _(
86
+ "The interests or protection of any person who supplied the information requested on a voluntary basis without being under a legal obligation."
87
+ )
88
+ case InspireLimitationCategory.ENVIRONMENTAL_PROTECTION:
89
+ return _(
90
+ "The protection of the environment to which such information relates, such as the location of rare species."
91
+ )
92
+ case _:
93
+ assert_never(self)
94
+
95
+ @classmethod
96
+ def get_labels(cls):
97
+ """Returns a dictionary of all INSPIRE limitation category labels."""
98
+ return {member: member.label for member in cls}
@@ -0,0 +1,44 @@
1
+ from udata.api_fields import field, generate_fields
2
+ from udata.core.access_type.constants import (
3
+ AccessAudienceCondition,
4
+ AccessAudienceType,
5
+ AccessType,
6
+ InspireLimitationCategory,
7
+ )
8
+ from udata.i18n import lazy_gettext as _
9
+ from udata.models import db
10
+ from udata.mongo.errors import FieldValidationError
11
+
12
+
13
+ @generate_fields()
14
+ class AccessAudience(db.EmbeddedDocument):
15
+ role = field(db.StringField(choices=[e.value for e in AccessAudienceType]), filterable={})
16
+ condition = field(
17
+ db.StringField(choices=[e.value for e in AccessAudienceCondition]), filterable={}
18
+ )
19
+
20
+
21
+ def check_only_one_condition_per_role(access_audiences, **_kwargs):
22
+ roles = set(e["role"] for e in access_audiences)
23
+ if len(roles) != len(access_audiences):
24
+ raise FieldValidationError(
25
+ _("You can only set one condition for a given access audience role"),
26
+ field="access_audiences",
27
+ )
28
+
29
+
30
+ class WithAccessType:
31
+ access_type = field(
32
+ db.EnumField(AccessType, default=AccessType.OPEN),
33
+ filterable={},
34
+ )
35
+ access_audiences = field(
36
+ db.EmbeddedDocumentListField(AccessAudience),
37
+ checks=[check_only_one_condition_per_role],
38
+ )
39
+
40
+ authorization_request_url = field(db.URLField())
41
+ access_type_reason_category = field(
42
+ db.StringField(choices=[e.value for e in InspireLimitationCategory]), allow_null=True
43
+ )
44
+ access_type_reason = field(db.StringField())
@@ -129,7 +129,7 @@ class Auditable(object):
129
129
  if kwargs.get("created"):
130
130
  cls.on_create.send(document)
131
131
  elif len(changed_fields):
132
- previous = getattr(document, "_previous_changed_fields", None)
132
+ previous = getattr(document, "_previous_changed_fields", {})
133
133
  # Filter changed_fields since mongoengine raises some false positive occurences
134
134
  changed_fields = filter_changed_fields(document, previous, changed_fields)
135
135
  if changed_fields:
@@ -57,7 +57,7 @@ class BadgeMixin:
57
57
  msg = "Unknown badge type for {model}: {kind}"
58
58
  raise db.ValidationError(msg.format(model=self.__class__.__name__, kind=kind))
59
59
  badge = self._fields["badges"].field.document_type(kind=kind)
60
- if current_user.is_authenticated:
60
+ if current_user and current_user.is_authenticated:
61
61
  badge.created_by = current_user.id
62
62
 
63
63
  self.update(__raw__={"$push": {"badges": {"$each": [badge.to_mongo()], "$position": 0}}})
@@ -1,9 +1,15 @@
1
- from udata.tasks import get_logger, task
1
+ from typing import Any
2
+
3
+ from mongoengine.base import TopLevelDocumentMetaclass
4
+
5
+ from udata.tasks import get_logger, job, task
2
6
 
3
7
  from .signals import on_badge_added
4
8
 
5
9
  log = get_logger(__name__)
6
10
 
11
+ _badge_jobs: dict[tuple[TopLevelDocumentMetaclass, str], Any] = {}
12
+
7
13
 
8
14
  def notify_new_badge(cls, kind):
9
15
  def wrapper(func):
@@ -17,3 +23,31 @@ def notify_new_badge(cls, kind):
17
23
  return t
18
24
 
19
25
  return wrapper
26
+
27
+
28
+ def register(model: TopLevelDocumentMetaclass, badge: str):
29
+ """Register a job to update some badge"""
30
+
31
+ def inner(func):
32
+ _badge_jobs[(model, badge)] = func
33
+ return func
34
+
35
+ return inner
36
+
37
+
38
+ def get_badge_job(model, badge):
39
+ return _badge_jobs.get((model, badge))
40
+
41
+
42
+ @job(name="update-badges")
43
+ def update_badges(self, badges: list[str] = []) -> None:
44
+ from udata.core.dataservices.models import Dataservice
45
+ from udata.models import Dataset, Organization, Reuse
46
+
47
+ for model in [Dataset, Reuse, Organization, Dataservice]:
48
+ for badge in model.__badges__:
49
+ if badges and badge not in badges:
50
+ continue
51
+ if adapter := get_badge_job(model, badge):
52
+ log.info(f"Running {model.__name__} {badge} job")
53
+ adapter()