udata 14.0.0__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 (130) hide show
  1. udata/api_fields.py +35 -4
  2. udata/app.py +18 -20
  3. udata/auth/__init__.py +29 -6
  4. udata/auth/forms.py +2 -2
  5. udata/auth/views.py +6 -3
  6. udata/commands/serve.py +3 -11
  7. udata/commands/tests/test_fixtures.py +9 -9
  8. udata/core/access_type/api.py +1 -1
  9. udata/core/access_type/constants.py +12 -8
  10. udata/core/activity/api.py +5 -6
  11. udata/core/badges/tests/test_commands.py +6 -6
  12. udata/core/csv.py +5 -0
  13. udata/core/dataservices/models.py +1 -1
  14. udata/core/dataservices/tasks.py +7 -0
  15. udata/core/dataset/api.py +2 -0
  16. udata/core/dataset/models.py +2 -2
  17. udata/core/dataset/permissions.py +31 -0
  18. udata/core/dataset/tasks.py +17 -5
  19. udata/core/discussions/models.py +1 -0
  20. udata/core/organization/api.py +8 -5
  21. udata/core/organization/mails.py +1 -1
  22. udata/core/organization/models.py +9 -1
  23. udata/core/organization/notifications.py +84 -0
  24. udata/core/organization/permissions.py +1 -1
  25. udata/core/organization/tasks.py +3 -0
  26. udata/core/pages/tests/test_api.py +32 -0
  27. udata/core/post/api.py +24 -69
  28. udata/core/post/models.py +84 -16
  29. udata/core/post/tests/test_api.py +24 -1
  30. udata/core/reports/api.py +18 -0
  31. udata/core/reports/models.py +42 -2
  32. udata/core/reuse/models.py +1 -1
  33. udata/core/reuse/tasks.py +7 -0
  34. udata/core/spatial/forms.py +2 -2
  35. udata/core/user/models.py +5 -1
  36. udata/features/notifications/api.py +7 -18
  37. udata/features/notifications/models.py +56 -0
  38. udata/features/notifications/tasks.py +25 -0
  39. udata/flask_mongoengine/engine.py +0 -4
  40. udata/frontend/markdown.py +2 -1
  41. udata/harvest/actions.py +21 -1
  42. udata/harvest/api.py +25 -8
  43. udata/harvest/backends/base.py +27 -1
  44. udata/harvest/backends/ckan/harvesters.py +11 -2
  45. udata/harvest/commands.py +33 -0
  46. udata/harvest/filters.py +17 -6
  47. udata/harvest/models.py +16 -0
  48. udata/harvest/permissions.py +27 -0
  49. udata/harvest/tests/ckan/test_ckan_backend.py +33 -0
  50. udata/harvest/tests/test_actions.py +58 -5
  51. udata/harvest/tests/test_api.py +276 -122
  52. udata/harvest/tests/test_base_backend.py +86 -1
  53. udata/harvest/tests/test_dcat_backend.py +57 -10
  54. udata/harvest/tests/test_filters.py +6 -0
  55. udata/i18n.py +1 -4
  56. udata/mail.py +5 -1
  57. udata/migrations/2025-10-31-create-membership-request-notifications.py +55 -0
  58. udata/migrations/2025-12-04-add-uuid-to-discussion-messages.py +28 -0
  59. udata/mongo/slug_fields.py +1 -1
  60. udata/rdf.py +45 -6
  61. udata/routing.py +2 -2
  62. udata/settings.py +7 -0
  63. udata/tasks.py +1 -0
  64. udata/templates/mail/message.html +5 -31
  65. udata/tests/__init__.py +27 -2
  66. udata/tests/api/__init__.py +108 -21
  67. udata/tests/api/test_activities_api.py +36 -0
  68. udata/tests/api/test_auth_api.py +121 -95
  69. udata/tests/api/test_base_api.py +7 -4
  70. udata/tests/api/test_datasets_api.py +44 -19
  71. udata/tests/api/test_organizations_api.py +192 -197
  72. udata/tests/api/test_reports_api.py +157 -0
  73. udata/tests/api/test_reuses_api.py +147 -147
  74. udata/tests/api/test_security_api.py +12 -12
  75. udata/tests/api/test_swagger.py +4 -4
  76. udata/tests/api/test_tags_api.py +8 -8
  77. udata/tests/api/test_user_api.py +1 -1
  78. udata/tests/apiv2/test_swagger.py +4 -4
  79. udata/tests/cli/test_cli_base.py +8 -9
  80. udata/tests/dataset/test_dataset_commands.py +4 -4
  81. udata/tests/dataset/test_dataset_model.py +66 -26
  82. udata/tests/dataset/test_dataset_rdf.py +99 -5
  83. udata/tests/frontend/test_auth.py +24 -1
  84. udata/tests/frontend/test_csv.py +0 -3
  85. udata/tests/helpers.py +25 -27
  86. udata/tests/organization/test_notifications.py +67 -2
  87. udata/tests/plugin.py +6 -261
  88. udata/tests/site/test_site_csv_exports.py +22 -10
  89. udata/tests/test_activity.py +9 -9
  90. udata/tests/test_dcat_commands.py +2 -2
  91. udata/tests/test_discussions.py +5 -5
  92. udata/tests/test_migrations.py +21 -21
  93. udata/tests/test_notifications.py +15 -57
  94. udata/tests/test_notifications_task.py +43 -0
  95. udata/tests/test_owned.py +81 -1
  96. udata/tests/test_storages.py +25 -19
  97. udata/tests/test_topics.py +77 -61
  98. udata/tests/test_uris.py +33 -0
  99. udata/tests/workers/test_jobs_commands.py +23 -23
  100. udata/translations/ar/LC_MESSAGES/udata.mo +0 -0
  101. udata/translations/ar/LC_MESSAGES/udata.po +187 -108
  102. udata/translations/de/LC_MESSAGES/udata.mo +0 -0
  103. udata/translations/de/LC_MESSAGES/udata.po +187 -108
  104. udata/translations/es/LC_MESSAGES/udata.mo +0 -0
  105. udata/translations/es/LC_MESSAGES/udata.po +187 -108
  106. udata/translations/fr/LC_MESSAGES/udata.mo +0 -0
  107. udata/translations/fr/LC_MESSAGES/udata.po +188 -109
  108. udata/translations/it/LC_MESSAGES/udata.mo +0 -0
  109. udata/translations/it/LC_MESSAGES/udata.po +187 -108
  110. udata/translations/pt/LC_MESSAGES/udata.mo +0 -0
  111. udata/translations/pt/LC_MESSAGES/udata.po +187 -108
  112. udata/translations/sr/LC_MESSAGES/udata.mo +0 -0
  113. udata/translations/sr/LC_MESSAGES/udata.po +187 -108
  114. udata/translations/udata.pot +215 -106
  115. udata/uris.py +0 -2
  116. udata-14.4.1.dev7.dist-info/METADATA +109 -0
  117. {udata-14.0.0.dist-info → udata-14.4.1.dev7.dist-info}/RECORD +121 -123
  118. udata/core/post/forms.py +0 -30
  119. udata/flask_mongoengine/json.py +0 -38
  120. udata/templates/mail/base.html +0 -105
  121. udata/templates/mail/base.txt +0 -6
  122. udata/templates/mail/button.html +0 -3
  123. udata/templates/mail/layouts/1-column.html +0 -19
  124. udata/templates/mail/layouts/2-columns.html +0 -20
  125. udata/templates/mail/layouts/center-panel.html +0 -16
  126. udata-14.0.0.dist-info/METADATA +0 -132
  127. {udata-14.0.0.dist-info → udata-14.4.1.dev7.dist-info}/WHEEL +0 -0
  128. {udata-14.0.0.dist-info → udata-14.4.1.dev7.dist-info}/entry_points.txt +0 -0
  129. {udata-14.0.0.dist-info → udata-14.4.1.dev7.dist-info}/licenses/LICENSE +0 -0
  130. {udata-14.0.0.dist-info → udata-14.4.1.dev7.dist-info}/top_level.txt +0 -0
@@ -12,12 +12,12 @@ from udata.tests.helpers import capture_mails
12
12
 
13
13
  class SecurityAPITest(PytestOnlyAPITestCase):
14
14
  @pytest.mark.options(CAPTCHETAT_BASE_URL=None)
15
- def test_register(self, api):
15
+ def test_register(self):
16
16
  # We cannot test for mail sending since they are sent with Flask
17
17
  # directly and not with our system but if the sending is working
18
18
  # we test the rendering of the mail.
19
19
 
20
- response = api.post(
20
+ response = self.post(
21
21
  url_for("security.register"),
22
22
  {
23
23
  "first_name": "Jane",
@@ -32,13 +32,13 @@ class SecurityAPITest(PytestOnlyAPITestCase):
32
32
  self.assertStatus(response, 200)
33
33
 
34
34
  @pytest.mark.options(CAPTCHETAT_BASE_URL=None, SECURITY_RETURN_GENERIC_RESPONSES=True)
35
- def test_register_existing(self, api):
35
+ def test_register_existing(self):
36
36
  # We cannot test for mail sending since they are sent with Flask
37
37
  # directly and not with our system but if the sending is working
38
38
  # we test the rendering of the mail.
39
39
 
40
40
  UserFactory(email="jane@example.org", confirmed_at=datetime.now())
41
- response = api.post(
41
+ response = self.post(
42
42
  url_for("security.register"),
43
43
  {
44
44
  "first_name": "Jane",
@@ -53,20 +53,20 @@ class SecurityAPITest(PytestOnlyAPITestCase):
53
53
  self.assertStatus(response, 200)
54
54
 
55
55
  @pytest.mark.options(CAPTCHETAT_BASE_URL=None)
56
- def test_ask_for_reset(self, api):
56
+ def test_ask_for_reset(self):
57
57
  # We cannot test for mail sending since they are sent with Flask
58
58
  # directly and not with our system but if the sending is working
59
59
  # we test the rendering of the mail.
60
60
 
61
61
  UserFactory(email="jane@example.org", confirmed_at=datetime.now())
62
62
 
63
- response = api.post(
63
+ response = self.post(
64
64
  url_for("security.forgot_password"), {"email": "jane@example.org", "submit": True}
65
65
  )
66
66
  self.assertStatus(response, 200)
67
67
 
68
68
  @pytest.mark.options(CAPTCHETAT_BASE_URL=None)
69
- def test_change_notice_mail(self, api):
69
+ def test_change_notice_mail(self):
70
70
  # We cannot test for mail sending since they are sent with Flask
71
71
  # directly and not with our system but if the sending is working
72
72
  # we test the rendering of the mail.
@@ -76,7 +76,7 @@ class SecurityAPITest(PytestOnlyAPITestCase):
76
76
  )
77
77
  self.login(user)
78
78
 
79
- response = api.post(
79
+ response = self.post(
80
80
  url_for("security.change_password"),
81
81
  {
82
82
  "password": "password",
@@ -88,12 +88,12 @@ class SecurityAPITest(PytestOnlyAPITestCase):
88
88
  self.assertStatus(response, 200)
89
89
 
90
90
  @pytest.mark.options(CAPTCHETAT_BASE_URL=None)
91
- def test_change_email_confirmation(self, api):
91
+ def test_change_email_confirmation(self):
92
92
  user = UserFactory(email="jane@example.org", confirmed_at=datetime.now())
93
93
  self.login(user)
94
94
 
95
95
  with capture_mails() as mails:
96
- response = api.post(
96
+ response = self.post(
97
97
  url_for("security.change_email"),
98
98
  {
99
99
  "new_email": "jane2@example.org",
@@ -109,11 +109,11 @@ class SecurityAPITest(PytestOnlyAPITestCase):
109
109
  assert mails[0].subject == _("Confirm your email address")
110
110
 
111
111
  @pytest.mark.options(CAPTCHETAT_BASE_URL=None, SECURITY_RETURN_GENERIC_RESPONSES=True)
112
- def test_reset_password(self, api):
112
+ def test_reset_password(self):
113
113
  user = UserFactory(email="jane@example.org", confirmed_at=datetime.now())
114
114
  token = generate_reset_password_token(user)
115
115
 
116
- response = api.post(
116
+ response = self.post(
117
117
  url_for("security.reset_password", token=token),
118
118
  {
119
119
  "password": "Password123",
@@ -8,16 +8,16 @@ from udata.tests.helpers import assert200
8
8
 
9
9
 
10
10
  class SwaggerBlueprintTest(PytestOnlyAPITestCase):
11
- def test_swagger_resource_type(self, api):
12
- response = api.get(url_for("api.specs"))
11
+ def test_swagger_resource_type(self):
12
+ response = self.get(url_for("api.specs"))
13
13
  assert200(response)
14
14
  swagger = json.loads(response.data)
15
15
  expected = swagger["paths"]["/datasets/{dataset}/resources/"]
16
16
  expected = expected["put"]["responses"]["200"]["schema"]["type"]
17
17
  assert expected == "array"
18
18
 
19
- def test_swagger_specs_validate(self, api):
20
- response = api.get(url_for("api.specs"))
19
+ def test_swagger_specs_validate(self):
20
+ response = self.get(url_for("api.specs"))
21
21
  try:
22
22
  schemas.validate(response.json)
23
23
  except schemas.SchemaValidationError as e:
@@ -9,7 +9,7 @@ from udata.utils import faker
9
9
 
10
10
 
11
11
  class TagsAPITest(PytestOnlyAPITestCase):
12
- def test_suggest_tags_api(self, api):
12
+ def test_suggest_tags_api(self):
13
13
  """It should suggest tags"""
14
14
  for i in range(3):
15
15
  tags = [faker.tag(), faker.tag(), "test", "test-{0}".format(i)]
@@ -18,7 +18,7 @@ class TagsAPITest(PytestOnlyAPITestCase):
18
18
 
19
19
  count_tags()
20
20
 
21
- response = api.get(url_for("api.suggest_tags", q="tes", size=5))
21
+ response = self.get(url_for("api.suggest_tags", q="tes", size=5))
22
22
  assert200(response)
23
23
 
24
24
  assert len(response.json) <= 5
@@ -29,7 +29,7 @@ class TagsAPITest(PytestOnlyAPITestCase):
29
29
  assert "text" in suggestion
30
30
  assert "tes" in suggestion["text"]
31
31
 
32
- def test_suggest_tags_api_with_unicode(self, api):
32
+ def test_suggest_tags_api_with_unicode(self):
33
33
  """It should suggest tags"""
34
34
  for i in range(3):
35
35
  tags = [faker.tag(), faker.tag(), "testé", "testé-{0}".format(i)]
@@ -38,7 +38,7 @@ class TagsAPITest(PytestOnlyAPITestCase):
38
38
 
39
39
  count_tags()
40
40
 
41
- response = api.get(url_for("api.suggest_tags", q="testé", size=5))
41
+ response = self.get(url_for("api.suggest_tags", q="testé", size=5))
42
42
  assert200(response)
43
43
 
44
44
  assert len(response.json) <= 5
@@ -49,7 +49,7 @@ class TagsAPITest(PytestOnlyAPITestCase):
49
49
  assert "text" in suggestion
50
50
  assert "teste" in suggestion["text"]
51
51
 
52
- def test_suggest_tags_api_no_match(self, api):
52
+ def test_suggest_tags_api_no_match(self):
53
53
  """It should not provide tag suggestion if no match"""
54
54
  for i in range(3):
55
55
  tags = ["aaaa", "aaaa-{0}".format(i)]
@@ -58,12 +58,12 @@ class TagsAPITest(PytestOnlyAPITestCase):
58
58
 
59
59
  count_tags()
60
60
 
61
- response = api.get(url_for("api.suggest_tags", q="bbbb", size=5))
61
+ response = self.get(url_for("api.suggest_tags", q="bbbb", size=5))
62
62
  assert200(response)
63
63
  assert len(response.json) == 0
64
64
 
65
- def test_suggest_tags_api_empty(self, api):
65
+ def test_suggest_tags_api_empty(self):
66
66
  """It should not provide tag suggestion if no data"""
67
- response = api.get(url_for("api.suggest_tags", q="bbbb", size=5))
67
+ response = self.get(url_for("api.suggest_tags", q="bbbb", size=5))
68
68
  assert200(response)
69
69
  assert len(response.json) == 0
@@ -382,7 +382,7 @@ class UserAPITest(APITestCase):
382
382
  response = self.delete(url_for("api.user", user=user_to_delete))
383
383
  self.assertEqual(list(storages.avatars.list_files()), [])
384
384
  self.assert204(response)
385
- self.assertEquals(len(mails), 1)
385
+ self.assertEqual(len(mails), 1)
386
386
 
387
387
  user_to_delete.reload()
388
388
  response = self.delete(url_for("api.user", user=user_to_delete))
@@ -8,16 +8,16 @@ from udata.tests.helpers import assert200
8
8
 
9
9
 
10
10
  class SwaggerBlueprintTest(PytestOnlyAPITestCase):
11
- def test_swagger_resource_type(self, api):
12
- response = api.get(url_for("apiv2.specs"))
11
+ def test_swagger_resource_type(self):
12
+ response = self.get(url_for("apiv2.specs"))
13
13
  assert200(response)
14
14
  swagger = json.loads(response.data)
15
15
  expected = swagger["paths"]["/datasets/{dataset}/resources/"]
16
16
  expected = expected["get"]["responses"]["200"]["schema"]["$ref"]
17
17
  assert expected == "#/definitions/ResourcePage"
18
18
 
19
- def test_swagger_specs_validate(self, api):
20
- response = api.get(url_for("apiv2.specs"))
19
+ def test_swagger_specs_validate(self):
20
+ response = self.get(url_for("apiv2.specs"))
21
21
  try:
22
22
  schemas.validate(response.json)
23
23
  except schemas.SchemaValidationError as e:
@@ -2,17 +2,16 @@ from udata.tests import PytestOnlyTestCase
2
2
 
3
3
 
4
4
  class CliBaseTest(PytestOnlyTestCase):
5
- def test_cli_help(self, cli):
5
+ def test_cli_help(self):
6
6
  """Should display help without errors"""
7
- cli()
8
- cli("-?")
9
- cli("-h")
10
- cli("--help")
7
+ self.cli("-?")
8
+ self.cli("-h")
9
+ self.cli("--help")
11
10
 
12
- def test_cli_log_and_printing(self, cli):
11
+ def test_cli_log_and_printing(self):
13
12
  """Should properly log and print"""
14
- cli("test log")
13
+ self.cli("test log")
15
14
 
16
- def test_cli_version(self, cli):
15
+ def test_cli_version(self):
17
16
  """Should display version without errors"""
18
- cli("--version")
17
+ self.cli("--version")
@@ -5,22 +5,22 @@ from udata.tests.api import PytestOnlyDBTestCase
5
5
 
6
6
 
7
7
  class DatasetCommandTest(PytestOnlyDBTestCase):
8
- def test_dataset_archive_one(self, cli):
8
+ def test_dataset_archive_one(self):
9
9
  dataset = DatasetFactory()
10
10
 
11
- cli("dataset", "archive-one", str(dataset.id))
11
+ self.cli("dataset", "archive-one", str(dataset.id))
12
12
 
13
13
  dataset.reload()
14
14
  assert dataset.archived is not None
15
15
 
16
- def test_dataset_archive(self, cli):
16
+ def test_dataset_archive(self):
17
17
  datasets = [DatasetFactory() for _ in range(2)]
18
18
 
19
19
  with NamedTemporaryFile(mode="w", encoding="utf8") as temp:
20
20
  temp.write("\n".join((str(d.id) for d in datasets)))
21
21
  temp.flush()
22
22
 
23
- cli("dataset", "archive", temp.name)
23
+ self.cli("dataset", "archive", temp.name)
24
24
 
25
25
  for dataset in datasets:
26
26
  dataset.reload()
@@ -1,4 +1,4 @@
1
- from datetime import datetime, timedelta
1
+ from datetime import date, datetime, timedelta, timezone
2
2
  from uuid import uuid4
3
3
 
4
4
  import pytest
@@ -206,6 +206,18 @@ class DatasetModelTest(PytestOnlyDBTestCase):
206
206
  assert dataset.quality["update_fulfilled_in_time"] is False
207
207
  assert dataset.quality["score"] == Dataset.normalize_score(1)
208
208
 
209
+ def test_quality_frequency_update_with_harvest_timezone_aware(self):
210
+ """Test that update_fulfilled_in_time works with timezone-aware harvest dates."""
211
+ dataset = DatasetFactory(
212
+ description="",
213
+ frequency=UpdateFrequency.DAILY,
214
+ harvest=HarvestDatasetMetadata(
215
+ modified_at=datetime.now(timezone.utc) - timedelta(hours=1),
216
+ ),
217
+ )
218
+ assert dataset.quality["update_frequency"] is True
219
+ assert dataset.quality["update_fulfilled_in_time"] is True
220
+
209
221
  def test_quality_description_length(self):
210
222
  dataset = DatasetFactory(
211
223
  description="a" * (current_app.config.get("QUALITY_DESCRIPTION_LENGTH") - 1)
@@ -326,9 +338,11 @@ class DatasetModelTest(PytestOnlyDBTestCase):
326
338
 
327
339
  assert dataset_without_resources.resources_len == 0
328
340
 
329
- def test_dataset_activities(self, api, mocker):
341
+ def test_dataset_activities(self, app, mocker):
330
342
  # A user must be authenticated for activities to be emitted
331
- user = api.login()
343
+ from flask_login import login_user
344
+
345
+ user = UserFactory()
332
346
 
333
347
  mock_created = mocker.patch.object(UserCreatedDataset, "emit")
334
348
  mock_updated = mocker.patch.object(UserUpdatedDataset, "emit")
@@ -337,35 +351,38 @@ class DatasetModelTest(PytestOnlyDBTestCase):
337
351
  mock_resouce_updated = mocker.patch.object(UserUpdatedResource, "emit")
338
352
  mock_resouce_removed = mocker.patch.object(UserRemovedResourceFromDataset, "emit")
339
353
 
340
- with assert_emit(Dataset.on_create):
341
- dataset = DatasetFactory(owner=user)
342
- mock_created.assert_called()
354
+ with app.test_request_context():
355
+ login_user(user)
343
356
 
344
- with assert_emit(Dataset.on_update):
345
- dataset.title = "new title"
346
- dataset.save()
347
- mock_updated.assert_called()
357
+ with assert_emit(Dataset.on_create):
358
+ dataset = DatasetFactory(owner=user)
359
+ mock_created.assert_called()
348
360
 
349
- with assert_emit(Dataset.on_resource_added):
350
- dataset.add_resource(ResourceFactory())
351
- mock_resource_added.assert_called()
361
+ with assert_emit(Dataset.on_update):
362
+ dataset.title = "new title"
363
+ dataset.save()
364
+ mock_updated.assert_called()
352
365
 
353
- dataset.reload()
366
+ with assert_emit(Dataset.on_resource_added):
367
+ dataset.add_resource(ResourceFactory())
368
+ mock_resource_added.assert_called()
354
369
 
355
- with assert_emit(Dataset.on_resource_updated):
356
- resource = dataset.resources[0]
357
- resource.description = "New description"
358
- dataset.update_resource(resource)
359
- mock_resouce_updated.assert_called()
370
+ dataset.reload()
360
371
 
361
- with assert_emit(Dataset.on_resource_removed):
362
- dataset.remove_resource(dataset.resources[-1])
363
- mock_resouce_removed.assert_called()
372
+ with assert_emit(Dataset.on_resource_updated):
373
+ resource = dataset.resources[0]
374
+ resource.description = "New description"
375
+ dataset.update_resource(resource)
376
+ mock_resouce_updated.assert_called()
364
377
 
365
- with assert_emit(Dataset.on_delete):
366
- dataset.deleted = datetime.utcnow()
367
- dataset.save()
368
- mock_deleted.assert_called()
378
+ with assert_emit(Dataset.on_resource_removed):
379
+ dataset.remove_resource(dataset.resources[-1])
380
+ mock_resouce_removed.assert_called()
381
+
382
+ with assert_emit(Dataset.on_delete):
383
+ dataset.deleted = datetime.utcnow()
384
+ dataset.save()
385
+ mock_deleted.assert_called()
369
386
 
370
387
  def test_dataset_metrics(self):
371
388
  # We need to init metrics module
@@ -867,3 +884,26 @@ class HarvestMetadataTest(PytestOnlyDBTestCase):
867
884
  resource.validate()
868
885
 
869
886
  assert resource.last_modified == resource.extras["analysis:last-modified-at"]
887
+
888
+ def test_quality_cached_next_update_with_date_last_update(self):
889
+ """Test that quality_cached with date (not datetime) last_update can be saved to MongoDB.
890
+
891
+ This reproduces a production bug where last_update could be a datetime.date instead
892
+ of datetime.datetime, causing next_update to also be a datetime.date, which BSON
893
+ cannot encode (it only supports datetime.datetime).
894
+
895
+ See error: bson.errors.InvalidDocument: cannot encode object: datetime.date(...), of type: <class 'datetime.date'>
896
+ """
897
+ dataset: Dataset = DatasetFactory(
898
+ frequency=UpdateFrequency.QUARTERLY,
899
+ )
900
+ # Set harvest metadata with modified_at as a date instead of datetime
901
+ # This simulates data coming from harvesting where dates might not be properly typed
902
+ dataset.harvest = HarvestDatasetMetadata(
903
+ created_at=datetime(2019, 1, 1),
904
+ modified_at=date(2019, 6, 7), # Using date instead of datetime
905
+ )
906
+ # Also set last_update as date to fully simulate the production scenario
907
+ dataset.last_update = date(2019, 6, 7)
908
+
909
+ dataset.save()
@@ -643,10 +643,10 @@ class RdfToDatasetTest(PytestOnlyDBTestCase):
643
643
 
644
644
  assert len(dataset.contact_points) == 1
645
645
  assert dataset.contact_points[0].role == "contact"
646
- assert dataset.contact_points[0].name == "foo"
646
+ assert dataset.contact_points[0].name == "foo (bar)"
647
647
  assert dataset.contact_points[0].email == "foo@example.com"
648
648
 
649
- def test_contact_point_organization_member_foaf(self):
649
+ def test_contact_point_organization_member_foaf_both_mails(self):
650
650
  g = Graph()
651
651
  node = URIRef("https://test.org/dataset")
652
652
  g.set((node, RDF.type, DCAT.Dataset))
@@ -673,10 +673,10 @@ class RdfToDatasetTest(PytestOnlyDBTestCase):
673
673
 
674
674
  assert len(dataset.contact_points) == 1
675
675
  assert dataset.contact_points[0].role == "creator"
676
- assert dataset.contact_points[0].name == "foo"
676
+ assert dataset.contact_points[0].name == "foo (bar)"
677
677
  assert dataset.contact_points[0].email == "foo@example.com"
678
678
 
679
- def test_contact_point_organization_member_foaf_no_mail(self):
679
+ def test_contact_point_organization_member_foaf_no_org_mail(self):
680
680
  g = Graph()
681
681
  node = URIRef("https://test.org/dataset")
682
682
  g.set((node, RDF.type, DCAT.Dataset))
@@ -703,9 +703,39 @@ class RdfToDatasetTest(PytestOnlyDBTestCase):
703
703
 
704
704
  assert len(dataset.contact_points) == 1
705
705
  assert dataset.contact_points[0].role == "creator"
706
- assert dataset.contact_points[0].name == "foo"
706
+ assert dataset.contact_points[0].name == "foo (bar)"
707
707
  assert dataset.contact_points[0].email == "foo@example.com"
708
708
 
709
+ def test_contact_point_organization_member_foaf_no_agent_mail(self):
710
+ g = Graph()
711
+ node = URIRef("https://test.org/dataset")
712
+ g.set((node, RDF.type, DCAT.Dataset))
713
+ g.set((node, DCT.identifier, Literal(faker.uuid4())))
714
+ g.set((node, DCT.title, Literal(faker.sentence())))
715
+
716
+ org = BNode()
717
+ g.add((org, RDF.type, FOAF.Organization))
718
+ g.add((org, FOAF.name, Literal("bar")))
719
+ g.add((org, FOAF.mbox, Literal("bar@example.com")))
720
+ contact = BNode()
721
+ g.add((contact, RDF.type, FOAF.Person))
722
+ g.add((contact, FOAF.name, Literal("foo")))
723
+ # no agent email
724
+ g.add((contact, ORG.memberOf, org))
725
+ g.add((node, DCT.creator, contact))
726
+
727
+ # Dataset needs an owner/organization for contact_points_from_rdf() to work
728
+ d = DatasetFactory.build()
729
+ d.organization = OrganizationFactory(name="organization")
730
+
731
+ dataset = dataset_from_rdf(g, d)
732
+ dataset.validate()
733
+
734
+ assert len(dataset.contact_points) == 1
735
+ assert dataset.contact_points[0].role == "creator"
736
+ assert dataset.contact_points[0].name == "foo (bar)"
737
+ assert dataset.contact_points[0].email == "bar@example.com"
738
+
709
739
  def test_theme_and_tags(self):
710
740
  node = BNode()
711
741
  g = Graph()
@@ -1364,6 +1394,70 @@ class DatasetRdfViewsTest(PytestOnlyAPITestCase):
1364
1394
  assert200(response)
1365
1395
  assert response.content_type == mime
1366
1396
 
1397
+ @pytest.mark.parametrize(
1398
+ "fmt,mime",
1399
+ [
1400
+ ("n3", "text/n3"),
1401
+ ("nt", "application/n-triples"),
1402
+ ("ttl", "application/x-turtle"),
1403
+ ("xml", "application/rdf+xml"),
1404
+ ("rdf", "application/rdf+xml"),
1405
+ ("owl", "application/rdf+xml"),
1406
+ ("trig", "application/trig"),
1407
+ ],
1408
+ )
1409
+ def test_dont_fail_with_invalid_uri(self, client, fmt, mime):
1410
+ """Invalid URIs (with spaces or curly brackets) shouldn't make rdf export fail in any format"""
1411
+ invalid_uri_with_quote = 'https://test.org/dataset_with"quote"'
1412
+ invalid_uri_with_curly_bracket = 'http://opendata-sig.saintdenis.re/datasets/identifiant.kml?outSR={"latestWkid":2975,"wkid":2975}'
1413
+ invalid_uri_with_space = "https://catalogue.opendata-ligair.fr/geonetwork/srv/60678572-36e5-4e78-9af3-48f726670dfd fr-modelisation-sirane-vacarm_no2"
1414
+ dataset = DatasetFactory(
1415
+ resources=[
1416
+ ResourceFactory(url=invalid_uri_with_quote),
1417
+ ResourceFactory(url=invalid_uri_with_curly_bracket),
1418
+ ],
1419
+ harvest=HarvestDatasetMetadata(uri=invalid_uri_with_space),
1420
+ )
1421
+
1422
+ url = url_for("api.dataset_rdf_format", dataset=dataset, _format=fmt)
1423
+ response = client.get(url, headers={"Accept": mime})
1424
+ assert200(response)
1425
+
1426
+ @pytest.mark.parametrize(
1427
+ "fmt,mime",
1428
+ [
1429
+ ("n3", "text/n3"),
1430
+ ("nt", "application/n-triples"),
1431
+ ("ttl", "application/x-turtle"),
1432
+ ("trig", "application/trig"),
1433
+ ],
1434
+ )
1435
+ def test_invalid_uri_escape_in_n3_turtle_format(self, client, fmt, mime):
1436
+ """Invalid URIs (with spaces or curly brackets) should be escaped in N3/turtle formats"""
1437
+ invalid_uri_with_quote = 'https://test.org/dataset_with"quote"'
1438
+ invalid_uri_with_curly_bracket = 'http://opendata-sig.saintdenis.re/datasets/identifiant.kml?outSR={"latestWkid":2975,"wkid":2975}'
1439
+ invalid_uri_with_space = "https://catalogue.opendata-ligair.fr/geonetwork/srv/60678572-36e5-4e78-9af3-48f726670dfd fr-modelisation-sirane-vacarm_no2"
1440
+ dataset = DatasetFactory(
1441
+ resources=[
1442
+ ResourceFactory(url=invalid_uri_with_quote),
1443
+ ResourceFactory(url=invalid_uri_with_curly_bracket),
1444
+ ],
1445
+ harvest=HarvestDatasetMetadata(uri=invalid_uri_with_space),
1446
+ )
1447
+
1448
+ url = url_for("api.dataset_rdf_format", dataset=dataset, _format=fmt)
1449
+ response = client.get(url, headers={"Accept": mime})
1450
+ assert200(response)
1451
+ assert "https://test.org/dataset_with%22quote%22" in response.text
1452
+ assert (
1453
+ "http://opendata-sig.saintdenis.re/datasets/identifiant.kml?outSR=%7B%22latestWkid%22:2975,%22wkid%22:2975%7D"
1454
+ in response.text
1455
+ )
1456
+ assert (
1457
+ "https://catalogue.opendata-ligair.fr/geonetwork/srv/60678572-36e5-4e78-9af3-48f726670dfd%20fr-modelisation-sirane-vacarm_no2"
1458
+ in response.text
1459
+ )
1460
+
1367
1461
 
1368
1462
  class DatasetFromRdfUtilsTest(PytestOnlyTestCase):
1369
1463
  def test_licenses_from_rdf(self):
@@ -1,7 +1,7 @@
1
1
  from flask import current_app, url_for
2
2
  from flask_security.utils import hash_data
3
3
 
4
- from udata.core.user.factories import AdminFactory
4
+ from udata.core.user.factories import AdminFactory, UserFactory
5
5
  from udata.tests.api import APITestCase
6
6
 
7
7
 
@@ -22,3 +22,26 @@ class AuthTest(APITestCase):
22
22
 
23
23
  user.reload()
24
24
  assert user.email == new_email
25
+
26
+ def test_change_mail_already_taken(self):
27
+ """Should not allow changing email to one already taken by another user"""
28
+ user = self.login(AdminFactory())
29
+ original_email = user.email
30
+
31
+ # Create another user with the target email
32
+ existing_user = UserFactory(email="taken@example.com")
33
+ new_email = existing_user.email
34
+
35
+ security = current_app.extensions["security"]
36
+
37
+ data = [str(user.fs_uniquifier), hash_data(user.email), new_email]
38
+ token = security.confirm_serializer.dumps(data)
39
+ confirmation_link = url_for("security.confirm_change_email", token=token)
40
+
41
+ resp = self.get(confirmation_link)
42
+ assert resp.status_code == 302
43
+ assert "change_email_already_taken" in resp.location
44
+
45
+ # Email should not have changed
46
+ user.reload()
47
+ assert user.email == original_email
@@ -269,7 +269,6 @@ class CsvTest(APITestCase):
269
269
 
270
270
  self.assert200(response)
271
271
  self.assertEqual(response.mimetype, "text/csv")
272
- self.assertEqual(response.charset, "utf-8")
273
272
 
274
273
  csvfile = StringIO(response.data.decode("utf8"))
275
274
  reader = csv.get_reader(csvfile)
@@ -327,7 +326,6 @@ class CsvTest(APITestCase):
327
326
 
328
327
  self.assert200(response)
329
328
  self.assertEqual(response.mimetype, "text/csv")
330
- self.assertEqual(response.charset, "utf-8")
331
329
 
332
330
  csvfile = StringIO(response.data.decode("utf8"))
333
331
  reader = csv.get_reader(csvfile)
@@ -349,7 +347,6 @@ class CsvTest(APITestCase):
349
347
 
350
348
  self.assert200(response)
351
349
  self.assertEqual(response.mimetype, "text/csv")
352
- self.assertEqual(response.charset, "utf-8")
353
350
 
354
351
  csvfile = StringIO(response.data.decode("utf8"))
355
352
  reader = csv.get_reader(csvfile)
udata/tests/helpers.py CHANGED
@@ -4,7 +4,6 @@ from datetime import timedelta
4
4
  from io import BytesIO
5
5
  from urllib.parse import parse_qs, urlparse
6
6
 
7
- import mock
8
7
  from flask import current_app, json
9
8
  from flask_security.babel import FsDomain
10
9
  from PIL import Image
@@ -35,51 +34,50 @@ def assert_json_equal(first, second):
35
34
 
36
35
 
37
36
  @contextmanager
38
- def mock_signals(callback, *signals):
37
+ def mock_signals(*signals):
39
38
  __tracebackhide__ = True
40
- specs = []
41
39
 
42
- def handler(sender, **kwargs):
43
- pass
40
+ callbacks_by_signal = {}
41
+ calls_kwargs_by_signal = {}
44
42
 
45
- for signal in signals:
46
- m = mock.Mock(spec=handler)
47
- signal.connect(m, weak=False)
48
- specs.append((signal, m))
43
+ for requestSignal in signals:
44
+ # We capture requestSignal with a default argument
45
+ def callback(*args, requestSignal=requestSignal, **kwargs):
46
+ calls_kwargs_by_signal.setdefault(requestSignal, [])
47
+ calls_kwargs_by_signal[requestSignal].append(kwargs)
48
+
49
+ callbacks_by_signal[requestSignal] = callback
50
+ requestSignal.connect(callback, weak=False)
49
51
 
50
- yield
52
+ yield calls_kwargs_by_signal
51
53
 
52
- for signal, mock_handler in specs:
53
- signal.disconnect(mock_handler)
54
- signal_name = getattr(signal, "name", str(signal))
55
- callback(signal_name, mock_handler)
54
+ for sig in signals:
55
+ sig.disconnect(callbacks_by_signal[sig])
56
56
 
57
57
 
58
58
  @contextmanager
59
59
  def assert_emit(*signals, assertions_callback=None):
60
60
  __tracebackhide__ = True
61
- msg = 'Signal "{0}" should have been emitted'
62
-
63
- def callback(name, handler):
64
- assert handler.called, msg.format(name)
65
- if assertions_callback is not None:
66
- assertions_callback(handler.call_args)
67
61
 
68
- with mock_signals(callback, *signals):
62
+ with mock_signals(*signals) as calls_kwargs_by_signal:
69
63
  yield
70
64
 
65
+ for signal in signals:
66
+ assert signal in calls_kwargs_by_signal, f'Signal "{signal}" should have been emitted'
67
+ if assertions_callback is not None:
68
+ for kwargs in calls_kwargs_by_signal[signal]:
69
+ assertions_callback(kwargs)
70
+
71
71
 
72
72
  @contextmanager
73
73
  def assert_not_emit(*signals):
74
74
  __tracebackhide__ = True
75
- msg = 'Signal "{0}" should NOT have been emitted'
76
-
77
- def callback(name, handler):
78
- assert not handler.called, msg.format(name)
79
-
80
- with mock_signals(callback, *signals):
75
+ with mock_signals(*signals) as calls_args_by_signal:
81
76
  yield
82
77
 
78
+ for signal in signals:
79
+ assert signal not in calls_args_by_signal, f'Signal "{signal}" should not have been emitted'
80
+
83
81
 
84
82
  @contextmanager
85
83
  def capture_mails():