udata 12.0.2.dev10__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 (272) 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 -4
  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 +10 -12
  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.py +15 -24
  29. udata/core/dataset/api_fields.py +11 -0
  30. udata/core/dataset/apiv2.py +11 -0
  31. udata/core/dataset/constants.py +0 -1
  32. udata/core/dataset/forms.py +29 -0
  33. udata/core/dataset/models.py +24 -42
  34. udata/core/dataset/rdf.py +2 -1
  35. udata/core/dataset/search.py +2 -2
  36. udata/core/dataset/tasks.py +86 -8
  37. udata/core/discussions/mails.py +63 -0
  38. udata/core/discussions/tasks.py +4 -18
  39. udata/core/metrics/__init__.py +0 -6
  40. udata/core/organization/api.py +20 -14
  41. udata/core/organization/mails.py +144 -0
  42. udata/core/organization/models.py +2 -1
  43. udata/core/organization/rdf.py +3 -3
  44. udata/core/organization/search.py +1 -1
  45. udata/core/organization/tasks.py +21 -49
  46. udata/core/pages/tests/test_api.py +0 -2
  47. udata/core/reuse/api.py +29 -3
  48. udata/core/reuse/mails.py +21 -0
  49. udata/core/reuse/models.py +10 -1
  50. udata/core/reuse/search.py +1 -1
  51. udata/core/reuse/tasks.py +2 -3
  52. udata/core/site/api.py +27 -19
  53. udata/core/site/models.py +2 -6
  54. udata/core/site/rdf.py +2 -2
  55. udata/core/spatial/tests/test_api.py +17 -20
  56. udata/core/spatial/tests/test_models.py +3 -3
  57. udata/core/user/mails.py +54 -0
  58. udata/core/user/models.py +2 -3
  59. udata/core/user/tasks.py +8 -23
  60. udata/core/user/tests/test_user_model.py +2 -6
  61. udata/entrypoints.py +0 -6
  62. udata/features/identicon/tests/test_backends.py +3 -13
  63. udata/forms/fields.py +3 -3
  64. udata/forms/widgets.py +2 -2
  65. udata/frontend/__init__.py +3 -32
  66. udata/harvest/actions.py +4 -9
  67. udata/harvest/api.py +5 -14
  68. udata/harvest/backends/__init__.py +20 -11
  69. udata/harvest/backends/base.py +2 -2
  70. udata/harvest/backends/ckan/harvesters.py +2 -1
  71. udata/harvest/backends/dcat.py +3 -0
  72. udata/harvest/backends/maaf.py +1 -0
  73. udata/harvest/commands.py +6 -4
  74. udata/harvest/forms.py +9 -6
  75. udata/harvest/tasks.py +3 -5
  76. udata/harvest/tests/ckan/test_ckan_backend.py +300 -337
  77. udata/harvest/tests/ckan/test_ckan_backend_errors.py +94 -99
  78. udata/harvest/tests/ckan/test_ckan_backend_filters.py +128 -122
  79. udata/harvest/tests/ckan/test_dkan_backend.py +39 -51
  80. udata/harvest/tests/dcat/bnodes.xml +17 -1
  81. udata/harvest/tests/dcat/datara--5a26b0f6-0ccf-46ad-ac58-734054b91977.rdf.xml +255 -0
  82. udata/harvest/tests/dcat/datara--f40c3860-7236-4b30-a141-23b8ae33f7b2.rdf.xml +289 -0
  83. udata/harvest/tests/factories.py +1 -1
  84. udata/harvest/tests/test_actions.py +11 -9
  85. udata/harvest/tests/test_api.py +4 -5
  86. udata/harvest/tests/test_base_backend.py +5 -4
  87. udata/harvest/tests/test_dcat_backend.py +72 -16
  88. udata/harvest/tests/test_models.py +2 -4
  89. udata/harvest/tests/test_notifications.py +2 -4
  90. udata/harvest/tests/test_tasks.py +2 -3
  91. udata/mail.py +90 -53
  92. udata/migrations/2025-01-05-dataservices-fields-changes.py +8 -14
  93. udata/migrations/2025-10-21-remove-ckan-harvest-modified-at.py +28 -0
  94. udata/migrations/2025-10-29-harvesters-sources-integrity.py +27 -0
  95. udata/models/__init__.py +0 -2
  96. udata/mongo/extras_fields.py +4 -3
  97. udata/mongo/taglist_field.py +3 -3
  98. udata/rdf.py +65 -20
  99. udata/sentry.py +3 -4
  100. udata/settings.py +15 -13
  101. udata/tags.py +5 -5
  102. udata/tasks.py +3 -3
  103. udata/templates/mail/message.html +65 -0
  104. udata/templates/mail/message.txt +16 -0
  105. udata/tests/__init__.py +40 -58
  106. udata/tests/api/__init__.py +87 -2
  107. udata/tests/api/test_activities_api.py +17 -23
  108. udata/tests/api/test_auth_api.py +2 -4
  109. udata/tests/api/test_contact_points.py +48 -54
  110. udata/tests/api/test_dataservices_api.py +65 -97
  111. udata/tests/api/test_datasets_api.py +171 -56
  112. udata/tests/api/test_me_api.py +4 -6
  113. udata/tests/api/test_organizations_api.py +19 -38
  114. udata/tests/api/test_reports_api.py +0 -4
  115. udata/tests/api/test_reuses_api.py +99 -23
  116. udata/tests/api/test_security_api.py +124 -0
  117. udata/tests/api/test_swagger.py +2 -3
  118. udata/tests/api/test_tags_api.py +6 -7
  119. udata/tests/api/test_transfer_api.py +0 -2
  120. udata/tests/api/test_user_api.py +8 -10
  121. udata/tests/apiv2/test_datasets.py +0 -4
  122. udata/tests/apiv2/test_me_api.py +0 -2
  123. udata/tests/apiv2/test_organizations.py +0 -2
  124. udata/tests/apiv2/test_swagger.py +2 -3
  125. udata/tests/apiv2/test_topics.py +0 -2
  126. udata/tests/cli/test_cli_base.py +14 -12
  127. udata/tests/cli/test_db_cli.py +51 -54
  128. udata/tests/contact_point/test_contact_point_models.py +2 -2
  129. udata/tests/dataservice/test_csv_adapter.py +2 -5
  130. udata/tests/dataservice/test_dataservice_rdf.py +64 -4
  131. udata/tests/dataservice/test_dataservice_tasks.py +36 -38
  132. udata/tests/dataset/test_csv_adapter.py +2 -5
  133. udata/tests/dataset/test_dataset_actions.py +2 -4
  134. udata/tests/dataset/test_dataset_commands.py +2 -4
  135. udata/tests/dataset/test_dataset_events.py +3 -3
  136. udata/tests/dataset/test_dataset_model.py +6 -7
  137. udata/tests/dataset/test_dataset_rdf.py +205 -16
  138. udata/tests/dataset/test_dataset_recommendations.py +2 -2
  139. udata/tests/dataset/test_dataset_tasks.py +66 -68
  140. udata/tests/dataset/test_resource_preview.py +39 -48
  141. udata/tests/dataset/test_transport_tasks.py +2 -2
  142. udata/tests/features/territories/__init__.py +0 -6
  143. udata/tests/features/territories/test_territories_api.py +25 -24
  144. udata/tests/forms/test_current_user_field.py +2 -2
  145. udata/tests/forms/test_dict_field.py +2 -4
  146. udata/tests/forms/test_extras_fields.py +2 -3
  147. udata/tests/forms/test_image_field.py +2 -2
  148. udata/tests/forms/test_model_field.py +2 -4
  149. udata/tests/forms/test_publish_as_field.py +2 -4
  150. udata/tests/forms/test_user_forms.py +26 -29
  151. udata/tests/frontend/test_auth.py +2 -3
  152. udata/tests/frontend/test_csv.py +5 -6
  153. udata/tests/frontend/test_error_handlers.py +2 -3
  154. udata/tests/frontend/test_hooks.py +5 -7
  155. udata/tests/frontend/test_markdown.py +3 -4
  156. udata/tests/helpers.py +2 -7
  157. udata/tests/metrics/test_metrics.py +52 -48
  158. udata/tests/metrics/test_tasks.py +154 -150
  159. udata/tests/organization/test_csv_adapter.py +2 -5
  160. udata/tests/organization/test_notifications.py +2 -4
  161. udata/tests/organization/test_organization_model.py +3 -4
  162. udata/tests/organization/test_organization_rdf.py +6 -12
  163. udata/tests/plugin.py +6 -110
  164. udata/tests/reuse/test_reuse_model.py +3 -4
  165. udata/tests/site/test_site_api.py +0 -2
  166. udata/tests/site/test_site_csv_exports.py +0 -2
  167. udata/tests/site/test_site_metrics.py +2 -4
  168. udata/tests/site/test_site_model.py +2 -2
  169. udata/tests/site/test_site_rdf.py +85 -29
  170. udata/tests/test_activity.py +3 -3
  171. udata/tests/test_api_fields.py +6 -9
  172. udata/tests/test_cors.py +0 -2
  173. udata/tests/test_dcat_commands.py +2 -3
  174. udata/tests/test_discussions.py +2 -7
  175. udata/tests/test_mail.py +150 -114
  176. udata/tests/test_migrations.py +413 -419
  177. udata/tests/test_model.py +10 -11
  178. udata/tests/test_notifications.py +2 -3
  179. udata/tests/test_owned.py +3 -3
  180. udata/tests/test_rdf.py +19 -15
  181. udata/tests/test_routing.py +5 -5
  182. udata/tests/test_storages.py +6 -5
  183. udata/tests/test_tags.py +2 -4
  184. udata/tests/test_topics.py +2 -4
  185. udata/tests/test_transfer.py +4 -5
  186. udata/tests/topic/test_topic_tasks.py +25 -27
  187. udata/tests/user/test_user_rdf.py +2 -8
  188. udata/tests/user/test_user_tasks.py +3 -5
  189. udata/tests/workers/test_jobs_commands.py +2 -2
  190. udata/tests/workers/test_tasks_routing.py +27 -27
  191. udata/translations/ar/LC_MESSAGES/udata.mo +0 -0
  192. udata/translations/ar/LC_MESSAGES/udata.po +369 -435
  193. udata/translations/de/LC_MESSAGES/udata.mo +0 -0
  194. udata/translations/de/LC_MESSAGES/udata.po +371 -437
  195. udata/translations/es/LC_MESSAGES/udata.mo +0 -0
  196. udata/translations/es/LC_MESSAGES/udata.po +369 -435
  197. udata/translations/fr/LC_MESSAGES/udata.mo +0 -0
  198. udata/translations/fr/LC_MESSAGES/udata.po +381 -447
  199. udata/translations/it/LC_MESSAGES/udata.mo +0 -0
  200. udata/translations/it/LC_MESSAGES/udata.po +371 -437
  201. udata/translations/pt/LC_MESSAGES/udata.mo +0 -0
  202. udata/translations/pt/LC_MESSAGES/udata.po +371 -437
  203. udata/translations/sr/LC_MESSAGES/udata.mo +0 -0
  204. udata/translations/sr/LC_MESSAGES/udata.po +372 -438
  205. udata/translations/udata.pot +379 -440
  206. udata/utils.py +66 -4
  207. {udata-12.0.2.dev10.dist-info → udata-13.0.1.dev21.dist-info}/METADATA +1 -4
  208. {udata-12.0.2.dev10.dist-info → udata-13.0.1.dev21.dist-info}/RECORD +212 -256
  209. udata/linkchecker/__init__.py +0 -0
  210. udata/linkchecker/backends.py +0 -31
  211. udata/linkchecker/checker.py +0 -75
  212. udata/linkchecker/commands.py +0 -21
  213. udata/linkchecker/models.py +0 -9
  214. udata/linkchecker/tasks.py +0 -55
  215. udata/templates/mail/account_deleted.html +0 -5
  216. udata/templates/mail/account_deleted.txt +0 -6
  217. udata/templates/mail/account_inactivity.html +0 -40
  218. udata/templates/mail/account_inactivity.txt +0 -31
  219. udata/templates/mail/badge_added_association.html +0 -33
  220. udata/templates/mail/badge_added_association.txt +0 -11
  221. udata/templates/mail/badge_added_certified.html +0 -33
  222. udata/templates/mail/badge_added_certified.txt +0 -11
  223. udata/templates/mail/badge_added_company.html +0 -33
  224. udata/templates/mail/badge_added_company.txt +0 -11
  225. udata/templates/mail/badge_added_local_authority.html +0 -33
  226. udata/templates/mail/badge_added_local_authority.txt +0 -11
  227. udata/templates/mail/badge_added_public_service.html +0 -33
  228. udata/templates/mail/badge_added_public_service.txt +0 -11
  229. udata/templates/mail/discussion_closed.html +0 -47
  230. udata/templates/mail/discussion_closed.txt +0 -16
  231. udata/templates/mail/inactive_account_deleted.html +0 -5
  232. udata/templates/mail/inactive_account_deleted.txt +0 -6
  233. udata/templates/mail/membership_refused.html +0 -20
  234. udata/templates/mail/membership_refused.txt +0 -11
  235. udata/templates/mail/membership_request.html +0 -46
  236. udata/templates/mail/membership_request.txt +0 -12
  237. udata/templates/mail/new_discussion.html +0 -44
  238. udata/templates/mail/new_discussion.txt +0 -15
  239. udata/templates/mail/new_discussion_comment.html +0 -45
  240. udata/templates/mail/new_discussion_comment.txt +0 -16
  241. udata/templates/mail/new_member.html +0 -27
  242. udata/templates/mail/new_member.txt +0 -11
  243. udata/templates/mail/new_reuse.html +0 -37
  244. udata/templates/mail/new_reuse.txt +0 -9
  245. udata/templates/mail/test.html +0 -6
  246. udata/templates/mail/test.txt +0 -6
  247. udata/templates/mail/user_mail_card.html +0 -26
  248. udata/templates/security/email/base.html +0 -105
  249. udata/templates/security/email/base.txt +0 -6
  250. udata/templates/security/email/button.html +0 -3
  251. udata/templates/security/email/change_notice.html +0 -22
  252. udata/templates/security/email/change_notice.txt +0 -8
  253. udata/templates/security/email/confirmation_instructions.html +0 -20
  254. udata/templates/security/email/confirmation_instructions.txt +0 -7
  255. udata/templates/security/email/login_instructions.html +0 -19
  256. udata/templates/security/email/login_instructions.txt +0 -7
  257. udata/templates/security/email/reset_instructions.html +0 -24
  258. udata/templates/security/email/reset_instructions.txt +0 -9
  259. udata/templates/security/email/reset_notice.html +0 -11
  260. udata/templates/security/email/reset_notice.txt +0 -4
  261. udata/templates/security/email/welcome.html +0 -24
  262. udata/templates/security/email/welcome.txt +0 -9
  263. udata/templates/security/email/welcome_existing.html +0 -32
  264. udata/templates/security/email/welcome_existing.txt +0 -14
  265. udata/terms.md +0 -6
  266. udata/tests/frontend/__init__.py +0 -23
  267. udata/tests/metrics/conftest.py +0 -15
  268. udata/tests/test_linkchecker.py +0 -277
  269. {udata-12.0.2.dev10.dist-info → udata-13.0.1.dev21.dist-info}/WHEEL +0 -0
  270. {udata-12.0.2.dev10.dist-info → udata-13.0.1.dev21.dist-info}/entry_points.txt +0 -0
  271. {udata-12.0.2.dev10.dist-info → udata-13.0.1.dev21.dist-info}/licenses/LICENSE +0 -0
  272. {udata-12.0.2.dev10.dist-info → udata-13.0.1.dev21.dist-info}/top_level.txt +0 -0
@@ -1,4 +1,5 @@
1
1
  from udata.api import api, base_reference, fields
2
+ from udata.core.access_type.models import AccessAudience
2
3
  from udata.core.badges.fields import badge_fields
3
4
  from udata.core.contact_point.api_fields import contact_point_fields
4
5
  from udata.core.organization.api_fields import org_ref_fields
@@ -287,6 +288,11 @@ DEFAULT_MASK = ",".join(
287
288
  "temporal_coverage",
288
289
  "spatial",
289
290
  "license",
291
+ "access_type",
292
+ "access_audiences",
293
+ "authorization_request_url",
294
+ "access_type_reason_category",
295
+ "access_type_reason",
290
296
  "uri",
291
297
  "page",
292
298
  "last_update",
@@ -394,6 +400,11 @@ dataset_fields = api.model(
394
400
  "license": fields.String(
395
401
  attribute="license.id", default=DEFAULT_LICENSE["id"], description="The dataset license"
396
402
  ),
403
+ "access_type": fields.String(allow_null=True),
404
+ "access_audiences": fields.List(fields.Nested(AccessAudience.__read_fields__)),
405
+ "authorization_request_url": fields.String(allow_null=True),
406
+ "access_type_reason_category": fields.String(allow_null=True),
407
+ "access_type_reason": fields.String(allow_null=True),
397
408
  "uri": fields.String(
398
409
  attribute=lambda d: d.self_api_url(),
399
410
  description="The API URI for this dataset",
@@ -7,6 +7,7 @@ from flask_restx import marshal
7
7
 
8
8
  from udata import search
9
9
  from udata.api import API, apiv2, fields
10
+ from udata.core.access_type.models import AccessAudience
10
11
  from udata.core.contact_point.api_fields import contact_point_fields
11
12
  from udata.core.dataset.api_fields import license_fields
12
13
  from udata.core.organization.api_fields import member_user_with_email_fields
@@ -62,6 +63,11 @@ DEFAULT_MASK_APIV2 = ",".join(
62
63
  "temporal_coverage",
63
64
  "spatial",
64
65
  "license",
66
+ "access_type",
67
+ "access_audiences",
68
+ "access_type_reason_category",
69
+ "access_type_reason",
70
+ "authorization_request_url",
65
71
  "uri",
66
72
  "page",
67
73
  "last_update",
@@ -202,6 +208,11 @@ dataset_fields = apiv2.model(
202
208
  default=DEFAULT_LICENSE["id"],
203
209
  description="The dataset license (full License object if `X-Get-Datasets-Full-Objects` is set, ID of the license otherwise)",
204
210
  ),
211
+ "access_type": fields.String(allow_null=True),
212
+ "access_audiences": fields.Nested(AccessAudience.__read_fields__),
213
+ "authorization_request_url": fields.String(allow_null=True),
214
+ "access_type_reason_category": fields.String(allow_null=True),
215
+ "access_type_reason": fields.String(allow_null=True),
205
216
  "uri": fields.String(
206
217
  attribute=lambda d: d.self_api_url(),
207
218
  description="The API URI for this dataset",
@@ -166,7 +166,6 @@ DEFAULT_CHECKSUM_TYPE = "sha1"
166
166
  PIVOTAL_DATA = "pivotal-data"
167
167
  SPD = "spd"
168
168
  INSPIRE = "inspire"
169
- HVD = "hvd"
170
169
  SL = "sl"
171
170
  SR = "sr"
172
171
  CLOSED_FORMATS = ("pdf", "doc", "docx", "word", "xls", "excel", "xlsx")
@@ -1,3 +1,10 @@
1
+ from udata.core.access_type.constants import (
2
+ AccessAudienceCondition,
3
+ AccessAudienceType,
4
+ AccessType,
5
+ InspireLimitationCategory,
6
+ )
7
+ from udata.core.access_type.models import AccessAudience
1
8
  from udata.core.spatial.forms import SpatialCoverageField
2
9
  from udata.core.storages import resources
3
10
  from udata.forms import ModelForm, fields, validators
@@ -116,6 +123,8 @@ class CommunityResourceForm(BaseResourceForm):
116
123
 
117
124
 
118
125
  def unmarshal_frequency(form, field):
126
+ if field.data is None:
127
+ return
119
128
  # We don't need to worry about invalid field.data being fed to UpdateFrequency here,
120
129
  # since the API will already have ensured incoming data matches the field definition,
121
130
  # which in our case is an enum of valid UpdateFrequency values.
@@ -139,6 +148,13 @@ def validate_contact_point(form, field):
139
148
  )
140
149
 
141
150
 
151
+ class AccessAudienceForm(ModelForm):
152
+ model_class = AccessAudience
153
+
154
+ role = fields.SelectField(choices=[(e.value, e.value) for e in AccessAudienceType])
155
+ condition = fields.SelectField(choices=[(e.value, e.value) for e in AccessAudienceCondition])
156
+
157
+
142
158
  class DatasetForm(ModelForm):
143
159
  model_class = Dataset
144
160
 
@@ -157,6 +173,19 @@ class DatasetForm(ModelForm):
157
173
  description=_("A short description of the dataset."),
158
174
  )
159
175
  license = fields.ModelSelectField(_("License"), model=License, allow_blank=True)
176
+ access_type = fields.SelectField(
177
+ choices=[(e.value, e.value) for e in AccessType],
178
+ default=AccessType.OPEN,
179
+ validators=[validators.optional()],
180
+ )
181
+ access_audiences = fields.NestedModelList(AccessAudienceForm)
182
+ authorization_request_url = fields.StringField(_("Authorization request URL"))
183
+ access_type_reason_category = fields.SelectField(
184
+ _("Access type reason category"),
185
+ choices=[(e.value, e.label) for e in InspireLimitationCategory],
186
+ validators=[validators.optional()],
187
+ )
188
+ access_type_reason = fields.StringField(_("Access type reason"))
160
189
  frequency = fields.SelectField(
161
190
  _("Update frequency"),
162
191
  choices=list(UpdateFrequency),
@@ -1,6 +1,6 @@
1
1
  import logging
2
2
  import re
3
- from datetime import datetime, timedelta
3
+ from datetime import datetime
4
4
  from pydoc import locate
5
5
  from typing import Self
6
6
  from urllib.parse import urlparse
@@ -8,8 +8,8 @@ from urllib.parse import urlparse
8
8
  import Levenshtein
9
9
  import requests
10
10
  from blinker import signal
11
- from dateutil.parser import parse as parse_dt
12
11
  from flask import current_app, url_for
12
+ from flask_babel import LazyString
13
13
  from mongoengine import ValidationError as MongoEngineValidationError
14
14
  from mongoengine.fields import DateTimeField
15
15
  from mongoengine.signals import post_save, pre_init, pre_save
@@ -18,7 +18,10 @@ from werkzeug.utils import cached_property
18
18
  from udata.api_fields import field
19
19
  from udata.app import cache
20
20
  from udata.core import storages
21
+ from udata.core.access_type.constants import AccessType
22
+ from udata.core.access_type.models import WithAccessType, check_only_one_condition_per_role
21
23
  from udata.core.activity.models import Auditable
24
+ from udata.core.constants import HVD
22
25
  from udata.core.dataset.preview import TabularAPIPreview
23
26
  from udata.core.linkable import Linkable
24
27
  from udata.core.metrics.helpers import get_stock_metrics
@@ -36,7 +39,6 @@ from .constants import (
36
39
  CLOSED_FORMATS,
37
40
  DEFAULT_LICENSE,
38
41
  DESCRIPTION_SHORT_SIZE_LIMIT,
39
- HVD,
40
42
  INSPIRE,
41
43
  MAX_DISTANCE,
42
44
  PIVOTAL_DATA,
@@ -63,7 +65,7 @@ __all__ = (
63
65
  "ResourceSchema",
64
66
  )
65
67
 
66
- BADGES: dict[str, str] = {
68
+ BADGES: dict[str, LazyString] = {
67
69
  PIVOTAL_DATA: _("Pivotal data"),
68
70
  SPD: _("Reference data public service"),
69
71
  INSPIRE: _("Inspire"),
@@ -369,7 +371,13 @@ class ResourceMixin(object):
369
371
  mime = db.StringField()
370
372
  filesize = db.IntField() # `size` is a reserved keyword for mongoengine.
371
373
  fs_filename = db.StringField()
372
- extras = db.ExtrasField()
374
+ extras = db.ExtrasField(
375
+ {
376
+ "check:available": db.BooleanField,
377
+ "check:status": db.IntField,
378
+ "check:date": db.DateTimeField,
379
+ }
380
+ )
373
381
  harvest = db.EmbeddedDocumentField(HarvestResourceMetadata)
374
382
  schema = db.EmbeddedDocumentField(Schema)
375
383
 
@@ -428,41 +436,6 @@ class ResourceMixin(object):
428
436
  """
429
437
  return self.extras.get("check:available", "unknown")
430
438
 
431
- def need_check(self):
432
- """Does the resource needs to be checked against its linkchecker?
433
-
434
- We check unavailable resources often, unless they go over the
435
- threshold. Available resources are checked less and less frequently
436
- based on their historical availability.
437
- """
438
- min_cache_duration, max_cache_duration, ko_threshold = [
439
- current_app.config.get(k)
440
- for k in (
441
- "LINKCHECKING_MIN_CACHE_DURATION",
442
- "LINKCHECKING_MAX_CACHE_DURATION",
443
- "LINKCHECKING_UNAVAILABLE_THRESHOLD",
444
- )
445
- ]
446
- count_availability = self.extras.get("check:count-availability", 1)
447
- is_available = self.check_availability()
448
- if is_available == "unknown":
449
- return True
450
- elif is_available or count_availability > ko_threshold:
451
- delta = min(min_cache_duration * count_availability, max_cache_duration)
452
- else:
453
- delta = min_cache_duration
454
- if self.extras.get("check:date"):
455
- limit_date = datetime.utcnow() - timedelta(minutes=delta)
456
- check_date = self.extras["check:date"]
457
- if not isinstance(check_date, datetime):
458
- try:
459
- check_date = parse_dt(check_date)
460
- except (ValueError, TypeError):
461
- return True
462
- if check_date >= limit_date:
463
- return False
464
- return True
465
-
466
439
  @property
467
440
  def latest(self):
468
441
  """
@@ -560,7 +533,9 @@ class DatasetBadgeMixin(BadgeMixin):
560
533
  __badges__ = BADGES
561
534
 
562
535
 
563
- class Dataset(Auditable, WithMetrics, DatasetBadgeMixin, Owned, Linkable, db.Document):
536
+ class Dataset(
537
+ Auditable, WithMetrics, WithAccessType, DatasetBadgeMixin, Owned, Linkable, db.Document
538
+ ):
564
539
  title = field(db.StringField(required=True))
565
540
  acronym = field(db.StringField(max_length=128))
566
541
  # /!\ do not set directly the slug when creating or updating a dataset
@@ -711,6 +686,10 @@ class Dataset(Auditable, WithMetrics, DatasetBadgeMixin, Owned, Linkable, db.Doc
711
686
 
712
687
  self.quality_cached = self.compute_quality()
713
688
 
689
+ check_only_one_condition_per_role(self.access_audiences)
690
+ if self.access_type and self.access_type != AccessType.OPEN:
691
+ self.license = None
692
+
714
693
  for key, value in self.extras.items():
715
694
  if not key.startswith("custom:"):
716
695
  continue
@@ -810,10 +789,13 @@ class Dataset(Auditable, WithMetrics, DatasetBadgeMixin, Owned, Linkable, db.Doc
810
789
 
811
790
  def compute_last_update(self):
812
791
  """
813
- Use the more recent date we would have on resources (harvest, modified).
792
+ If dataset is harvested and its metadata contains a modified_at date, use it.
793
+ Else, use the more recent date we would have at the resource level (harvest, modified).
814
794
  Default to dataset last_modified if no resource.
815
795
  Resources should be fetched when calling this method.
816
796
  """
797
+ if self.harvest and self.harvest.modified_at:
798
+ return self.harvest.modified_at
817
799
  if self.resources:
818
800
  return max([res.last_modified for res in self.resources])
819
801
  else:
udata/core/dataset/rdf.py CHANGED
@@ -16,6 +16,7 @@ from rdflib.namespace import RDF
16
16
  from rdflib.resource import Resource as RdfResource
17
17
 
18
18
  from udata import i18n, uris
19
+ from udata.core.constants import HVD
19
20
  from udata.core.dataset.models import HarvestDatasetMetadata, HarvestResourceMetadata
20
21
  from udata.core.spatial.models import SpatialCoverage
21
22
  from udata.harvest.exceptions import HarvestSkipException
@@ -330,7 +331,7 @@ def dataset_to_rdf(dataset: Dataset, graph: Graph | None = None) -> RdfResource:
330
331
 
331
332
  # Add DCAT-AP HVD properties if the dataset is tagged hvd.
332
333
  # See https://semiceu.github.io/DCAT-AP/releases/2.2.0-hvd/
333
- is_hvd = current_app.config["HVD_SUPPORT"] and "hvd" in dataset.tags
334
+ is_hvd = current_app.config["HVD_SUPPORT"] and any(b.kind == HVD for b in dataset.badges)
334
335
  if is_hvd:
335
336
  d.add(DCATAP.applicableLegislation, URIRef(HVD_LEGISLATION))
336
337
 
@@ -54,8 +54,8 @@ class DatasetSearch(ModelSearchAdapter):
54
54
  }
55
55
 
56
56
  @classmethod
57
- def is_indexable(cls, dataset):
58
- return dataset.deleted is None and dataset.archived is None and not dataset.private
57
+ def is_indexable(cls, dataset: Dataset):
58
+ return dataset.is_visible
59
59
 
60
60
  @classmethod
61
61
  def mongo_search(cls, args):
@@ -1,6 +1,6 @@
1
1
  import collections
2
2
  import os
3
- from datetime import datetime
3
+ from datetime import date, datetime
4
4
  from tempfile import NamedTemporaryFile
5
5
 
6
6
  from celery.utils.log import get_task_logger
@@ -9,9 +9,15 @@ from mongoengine import ValidationError
9
9
 
10
10
  from udata import models as udata_models
11
11
  from udata.core import csv, storages
12
+ from udata.core.badges import tasks as badge_tasks
13
+ from udata.core.constants import HVD
12
14
  from udata.core.dataservices.models import Dataservice
15
+ from udata.core.dataset.constants import INSPIRE
16
+ from udata.core.organization.constants import CERTIFIED, PUBLIC_SERVICE
17
+ from udata.core.organization.models import Organization
13
18
  from udata.harvest.models import HarvestJob
14
19
  from udata.models import Activity, Discussion, Follow, TopicElement, Transfer, db
20
+ from udata.storage.s3 import store_bytes
15
21
  from udata.tasks import job
16
22
 
17
23
  from .models import Checksum, CommunityResource, Dataset, Resource
@@ -85,12 +91,14 @@ def get_queryset(model_cls):
85
91
  return model_cls.objects.filter(**params).no_cache()
86
92
 
87
93
 
94
+ def get_resource_for_csv_export_model(model, dataset):
95
+ for resource in dataset.resources:
96
+ if resource.extras.get("csv-export:model", "") == model:
97
+ return resource
98
+
99
+
88
100
  def get_or_create_resource(r_info, model, dataset):
89
- resource = None
90
- for r in dataset.resources:
91
- if r.extras.get("csv-export:model", "") == model:
92
- resource = r
93
- break
101
+ resource = get_resource_for_csv_export_model(model, dataset)
94
102
  if resource:
95
103
  for k, v in r_info.items():
96
104
  setattr(resource, k, v)
@@ -121,11 +129,16 @@ def store_resource(csvfile, model, dataset):
121
129
  return get_or_create_resource(r_info, model, dataset)
122
130
 
123
131
 
124
- def export_csv_for_model(model, dataset):
132
+ def export_csv_for_model(model, dataset, replace: bool = False):
125
133
  model_cls = getattr(udata_models, model.capitalize(), None)
126
134
  if not model_cls:
127
135
  log.error("Unknow model %s" % model)
128
136
  return
137
+
138
+ fs_filename_to_remove = None
139
+ if existing_resource := get_resource_for_csv_export_model(model, dataset):
140
+ fs_filename_to_remove = existing_resource.fs_filename
141
+
129
142
  queryset = get_queryset(model_cls)
130
143
  adapter = csv.get_adapter(model_cls)
131
144
  if not adapter:
@@ -151,6 +164,10 @@ def export_csv_for_model(model, dataset):
151
164
  else:
152
165
  dataset.last_modified_internal = datetime.utcnow()
153
166
  dataset.save()
167
+ # remove previous catalog if exists and replace is True
168
+ if replace and fs_filename_to_remove:
169
+ storages.resources.delete(fs_filename_to_remove)
170
+ return resource
154
171
  finally:
155
172
  csvfile.close()
156
173
  os.unlink(csvfile.name)
@@ -179,7 +196,23 @@ def export_csv(self, model=None):
179
196
 
180
197
  models = (model,) if model else ALLOWED_MODELS
181
198
  for model in models:
182
- export_csv_for_model(model, dataset)
199
+ resource = export_csv_for_model(model, dataset, replace=True)
200
+
201
+ # If we are the first day of the month, archive today catalogs
202
+ if (
203
+ current_app.config["EXPORT_CSV_ARCHIVE_S3_BUCKET"]
204
+ and resource
205
+ and date.today().day == 1
206
+ ):
207
+ log.info(
208
+ f"Archiving {model} csv catalog on {current_app.config['EXPORT_CSV_ARCHIVE_S3_BUCKET']} bucket"
209
+ )
210
+ with storages.resources.open(resource.fs_filename, "rb") as f:
211
+ store_bytes(
212
+ bucket=current_app.config["EXPORT_CSV_ARCHIVE_S3_BUCKET"],
213
+ filename=f"{current_app.config['EXPORT_CSV_ARCHIVE_S3_FILENAME_PREFIX']}{resource.title}",
214
+ bytes=f.read(),
215
+ )
183
216
 
184
217
 
185
218
  @job("bind-tabular-dataservice")
@@ -213,3 +246,48 @@ def bind_tabular_dataservice(self):
213
246
  log.error(exc_info=e)
214
247
 
215
248
  log.info(f"Bound {datasets.count()} datasets to TabularAPI dataservice")
249
+
250
+
251
+ @badge_tasks.register(model=Dataset, badge=HVD)
252
+ def update_dataset_hvd_badge() -> None:
253
+ """
254
+ Update HVD badges to candidate datasets, based on the hvd tag.
255
+ Only datasets owned by certified and public service organizations are candidate to have a HVD badge.
256
+ """
257
+ if not current_app.config["HVD_SUPPORT"]:
258
+ log.error("You need to set HVD_SUPPORT if you want to update dataset hvd badge")
259
+ return
260
+ public_certified_orgs = (
261
+ Organization.objects(badges__kind=PUBLIC_SERVICE).filter(badges__kind=CERTIFIED).only("id")
262
+ )
263
+
264
+ datasets = Dataset.objects(
265
+ tags="hvd", badges__kind__ne="hvd", organization__in=public_certified_orgs
266
+ )
267
+ log.info(f"Adding HVD badge to {datasets.count()} datasets")
268
+ for dataset in datasets:
269
+ dataset.add_badge(HVD)
270
+
271
+ datasets = Dataset.objects(tags__nin=["hvd"], badges__kind="hvd")
272
+ log.info(f"Removing HVD badge from {datasets.count()} datasets")
273
+ for dataset in datasets:
274
+ dataset.remove_badge(HVD)
275
+
276
+
277
+ @badge_tasks.register(model=Dataset, badge=INSPIRE)
278
+ def update_dataset_inspire_badge() -> None:
279
+ """
280
+ Update INSPIRE badges to candidate datasets, based on the inspire tag.
281
+ """
282
+ if not current_app.config["INSPIRE_SUPPORT"]:
283
+ log.error("You need to set INSPIRE_SUPPORT if you want to update dataset INSPIRE badge")
284
+ return
285
+ datasets = Dataset.objects(tags="inspire", badges__kind__ne="inspire")
286
+ log.info(f"Adding INSPIRE badge to {datasets.count()} datasets")
287
+ for dataset in datasets:
288
+ dataset.add_badge(INSPIRE)
289
+
290
+ datasets = Dataset.objects(tags__nin=["inspire"], badges__kind="inspire")
291
+ log.info(f"Removing INSPIRE badge from {datasets.count()} datasets")
292
+ for dataset in datasets:
293
+ dataset.remove_badge(INSPIRE)
@@ -0,0 +1,63 @@
1
+ from udata.core.discussions.models import Discussion, Message
2
+ from udata.i18n import lazy_gettext as _
3
+ from udata.mail import LabelledContent, MailCTA, MailMessage, ParagraphWithLinks
4
+
5
+
6
+ def new_discussion(discussion: Discussion) -> MailMessage:
7
+ return MailMessage(
8
+ subject=_(
9
+ "A new discussion has been opened on your %(type)s",
10
+ type=discussion.subject.verbose_name,
11
+ ),
12
+ paragraphs=[
13
+ ParagraphWithLinks(
14
+ _(
15
+ "You have a new discussion from %(user_or_org)s on your %(type)s %(object)s",
16
+ user_or_org=discussion.organization or discussion.user,
17
+ type=discussion.subject.verbose_name,
18
+ object=discussion.subject,
19
+ )
20
+ ),
21
+ LabelledContent(_("Discussion title:"), discussion.title, inline=True),
22
+ LabelledContent(_("Comment:"), discussion.discussion[0].content),
23
+ MailCTA(_("Reply"), discussion.url_for()),
24
+ ],
25
+ )
26
+
27
+
28
+ def new_discussion_comment(discussion: Discussion, comment: Message) -> MailMessage:
29
+ return MailMessage(
30
+ subject=_("A new comment has been added to a discussion"),
31
+ paragraphs=[
32
+ ParagraphWithLinks(
33
+ _(
34
+ "You have a new comment from %(user_or_org)s on your %(type)s %(object)s",
35
+ user_or_org=comment.posted_by_org_or_user,
36
+ type=discussion.subject.verbose_name,
37
+ object=discussion.subject,
38
+ )
39
+ ),
40
+ LabelledContent(_("Discussion title:"), discussion.title, inline=True),
41
+ LabelledContent(_("Comment:"), comment.content),
42
+ MailCTA(_("Reply"), discussion.url_for()),
43
+ ],
44
+ )
45
+
46
+
47
+ def discussion_closed(discussion: Discussion, comment: Message | None) -> MailMessage:
48
+ return MailMessage(
49
+ subject=_("A discussion has been closed"),
50
+ paragraphs=[
51
+ ParagraphWithLinks(
52
+ _(
53
+ "The discussion you participated in on the %(type)s %(object)s has been closed by %(user_or_org)s.",
54
+ user_or_org=discussion.closed_by_org_or_user,
55
+ type=discussion.subject.verbose_name,
56
+ object=discussion.subject,
57
+ )
58
+ ),
59
+ LabelledContent(_("Discussion title:"), discussion.title, inline=True),
60
+ LabelledContent(_("Comment:"), comment.content) if comment else None,
61
+ MailCTA(_("View the discussion"), discussion.url_for()),
62
+ ],
63
+ )
@@ -1,7 +1,6 @@
1
- from udata import mail
2
- from udata.i18n import lazy_gettext as _
3
1
  from udata.tasks import connect, get_logger
4
2
 
3
+ from . import mails
5
4
  from .constants import NOTIFY_DISCUSSION_SUBJECTS
6
5
  from .models import Discussion
7
6
  from .signals import on_discussion_closed, on_new_discussion, on_new_discussion_comment
@@ -22,15 +21,7 @@ def owner_recipients(discussion):
22
21
  def notify_new_discussion(discussion_id):
23
22
  discussion = Discussion.objects.get(pk=discussion_id)
24
23
  if isinstance(discussion.subject, NOTIFY_DISCUSSION_SUBJECTS):
25
- recipients = owner_recipients(discussion)
26
- subject = _("Your %(type)s have a new discussion", type=discussion.subject.verbose_name)
27
- mail.send(
28
- subject,
29
- recipients,
30
- "new_discussion",
31
- discussion=discussion,
32
- message=discussion.discussion[0],
33
- )
24
+ mails.new_discussion(discussion).send(owner_recipients(discussion))
34
25
  else:
35
26
  log.warning("Unrecognized discussion subject type %s", type(discussion.subject))
36
27
 
@@ -42,11 +33,7 @@ def notify_new_discussion_comment(discussion_id, message=None):
42
33
  if isinstance(discussion.subject, NOTIFY_DISCUSSION_SUBJECTS):
43
34
  recipients = owner_recipients(discussion) + [m.posted_by for m in discussion.discussion]
44
35
  recipients = list({u.id: u for u in recipients if u != message.posted_by}.values())
45
- subject = _("%(user)s commented your discussion", user=message.posted_by_name)
46
-
47
- mail.send(
48
- subject, recipients, "new_discussion_comment", discussion=discussion, message=message
49
- )
36
+ mails.new_discussion_comment(discussion, message).send(recipients)
50
37
  else:
51
38
  log.warning("Unrecognized discussion subject type %s", type(discussion.subject))
52
39
 
@@ -58,7 +45,6 @@ def notify_discussion_closed(discussion_id, message=None):
58
45
  if isinstance(discussion.subject, NOTIFY_DISCUSSION_SUBJECTS):
59
46
  recipients = owner_recipients(discussion) + [m.posted_by for m in discussion.discussion]
60
47
  recipients = list({u.id: u for u in recipients if u != discussion.closed_by}.values())
61
- subject = _("A discussion has been closed")
62
- mail.send(subject, recipients, "discussion_closed", discussion=discussion, message=message)
48
+ mails.discussion_closed(discussion, message).send(recipients)
63
49
  else:
64
50
  log.warning("Unrecognized discussion subject type %s", type(discussion.subject))
@@ -1,6 +1,3 @@
1
- from udata import entrypoints
2
-
3
-
4
1
  def init_app(app):
5
2
  # Load all core metrics
6
3
  import udata.core.user.metrics # noqa
@@ -9,6 +6,3 @@ def init_app(app):
9
6
  import udata.core.dataset.metrics # noqa
10
7
  import udata.core.reuse.metrics # noqa
11
8
  import udata.core.followers.metrics # noqa
12
-
13
- # Load metrics from plugins
14
- entrypoints.get_enabled("udata.metrics", app)
@@ -13,7 +13,7 @@ from udata.core.contact_point.api import ContactPointApiParser
13
13
  from udata.core.contact_point.api_fields import contact_point_fields, contact_point_page_fields
14
14
  from udata.core.dataservices.csv import DataserviceCsvAdapter
15
15
  from udata.core.dataservices.models import Dataservice
16
- from udata.core.dataset.api import DatasetApiParser
16
+ from udata.core.dataset.api import DatasetApiParser, catalog_parser
17
17
  from udata.core.dataset.api_fields import dataset_page_fields
18
18
  from udata.core.dataset.csv import DatasetCsvAdapter, ResourcesCsvAdapter
19
19
  from udata.core.dataset.models import Dataset
@@ -29,7 +29,6 @@ from udata.core.storages.api import (
29
29
  )
30
30
  from udata.models import ContactPoint
31
31
  from udata.rdf import RDF_EXTENSIONS, graph_response, negociate_content
32
- from udata.utils import multi_to_dict
33
32
 
34
33
  from .api_fields import (
35
34
  member_fields,
@@ -50,7 +49,7 @@ from .forms import (
50
49
  from .models import Member, MembershipRequest, Organization
51
50
  from .permissions import EditOrganizationPermission, OrganizationPrivatePermission
52
51
  from .rdf import build_org_catalog
53
- from .tasks import notify_membership_request, notify_membership_response
52
+ from .tasks import notify_membership_request, notify_membership_response, notify_new_member
54
53
 
55
54
  DEFAULT_SORTING = "-created_at"
56
55
  SUGGEST_SORTING = "-metrics.followers"
@@ -234,32 +233,37 @@ class DatasetsResourcesCsvAPI(API):
234
233
  class OrganizationRdfAPI(API):
235
234
  @api.doc("rdf_organization")
236
235
  def get(self, org):
237
- format = RDF_EXTENSIONS[negociate_content()]
238
- url = url_for("api.organization_rdf_format", org=org.id, format=format)
236
+ _format = RDF_EXTENSIONS[negociate_content()]
237
+ # We sanitize the args used as kwargs in url_for
238
+ params = catalog_parser.parse_args()
239
+ url = url_for("api.organization_rdf_format", org=org.id, _format=_format, **params)
239
240
  return redirect(url)
240
241
 
241
242
 
242
- @ns.route("/<org:org>/catalog.<format>", endpoint="organization_rdf_format", doc=common_doc)
243
+ @ns.route("/<org:org>/catalog.<_format>", endpoint="organization_rdf_format", doc=common_doc)
243
244
  @api.response(404, "Organization not found")
244
245
  @api.response(410, "Organization has been deleted")
245
246
  class OrganizationRdfFormatAPI(API):
246
247
  @api.doc("rdf_organization_format")
247
- def get(self, org, format):
248
+ @api.expect(catalog_parser)
249
+ def get(self, org, _format):
248
250
  if org.deleted:
249
251
  api.abort(410)
250
- params = multi_to_dict(request.args)
251
- page = int(params.get("page", 1))
252
- page_size = int(params.get("page_size", 100))
253
- datasets = Dataset.objects(organization=org).visible().paginate(page, page_size)
252
+ params = catalog_parser.parse_args()
253
+ datasets = DatasetApiParser.parse_filters(
254
+ Dataset.objects(organization=org).visible(), params
255
+ )
256
+ datasets = datasets.paginate(params["page"], params["page_size"])
257
+
254
258
  dataservices = (
255
259
  Dataservice.objects(organization=org)
256
260
  .visible()
257
- .filter_by_dataset_pagination(datasets, page)
261
+ .filter_by_dataset_pagination(datasets, params["page"])
258
262
  )
259
- catalog = build_org_catalog(org, datasets, dataservices, format=format)
263
+ catalog = build_org_catalog(org, datasets, dataservices, _format=_format, **params)
260
264
  # bypass flask-restplus make_response, since graph_response
261
265
  # is handling the content negociation directly
262
- return make_response(*graph_response(catalog, format))
266
+ return make_response(*graph_response(catalog, _format))
263
267
 
264
268
 
265
269
  @ns.route("/badges/", endpoint="available_organization_badges")
@@ -468,6 +472,8 @@ class MemberAPI(API):
468
472
  org.count_members()
469
473
  org.save()
470
474
 
475
+ notify_new_member.delay(str(org.id), str(member.user.email))
476
+
471
477
  return member, 201
472
478
 
473
479
  @api.secure